├── .github └── workflows │ └── e2e.yml ├── .gitignore ├── .golangciversion ├── .snyk ├── .tekton ├── integration-test.yaml ├── maestro-pull-request.yaml ├── maestro-push.yaml └── unit-test.yaml ├── Containerfile.rhtap ├── Dockerfile ├── Dockerfile.openapi ├── Dockerfile.test ├── LICENSE ├── Makefile ├── README.md ├── arch.png ├── build_deploy.sh ├── cas ├── README.md ├── rds-combined-ca-bundle.pem └── redhat-it.pem ├── cmd └── maestro │ ├── agent │ └── cmd.go │ ├── common │ └── otlp_sdk.go │ ├── environments │ ├── e_development.go │ ├── e_integration_testing.go │ ├── e_production.go │ ├── framework.go │ ├── framework_test.go │ ├── service_types.go │ ├── types.go │ └── types │ │ └── environments.go │ ├── main.go │ ├── migrate │ └── cmd.go │ ├── servecmd │ └── cmd.go │ └── server │ ├── api_server.go │ ├── auth_interceptor.go │ ├── controllers.go │ ├── event_server.go │ ├── grpc_broker.go │ ├── grpc_server.go │ ├── healthcheck_server.go │ ├── logging │ ├── formatter.go │ ├── formatter_json.go │ ├── logging.go │ ├── request_logging_middleware.go │ ├── responseinfo.go │ └── writer.go │ ├── metrics_interceptor.go │ ├── metrics_middleware.go │ ├── metrics_server.go │ ├── routes.go │ └── server.go ├── data └── generated │ └── openapi │ └── openapi.go ├── docs ├── continuous-delivery-migration.md ├── dao.md ├── grpc.md ├── images │ ├── maestro-mqtt-pub-dataflow.drawio │ ├── maestro-mqtt-pub-dataflow.png │ ├── maestro-mqtt-sub-dataflow.drawio │ ├── maestro-mqtt-sub-dataflow.png │ ├── maestro-overview.drawio │ ├── maestro-overview.jpg │ ├── maestro-resource-create-flow-grpc.png │ ├── maestro-resource-delete-flow-grpc.png │ ├── maestro-resource-patch-flow-grpc.png │ └── maestro-resource-status-flow.png ├── maestro.md ├── metrics.md ├── observability │ ├── Makefile │ ├── README.md │ └── jaeger │ │ ├── jaeger.yaml │ │ ├── kustomization.yaml │ │ ├── namespace.yaml │ │ └── service.yaml └── troubleshooting.md ├── examples ├── grpc │ ├── README.md │ ├── cloudevent-delete.json │ ├── cloudevent-status-resync.json │ ├── cloudevent-update.json │ ├── cloudevent.json │ └── grpcclient.go ├── manifestworkclient │ ├── README.md │ ├── client-a │ │ └── main.go │ ├── client-b │ │ └── main.go │ └── client │ │ ├── README.md │ │ ├── main.go │ │ └── manifestwork.json └── resource-in-db.md ├── go.mod ├── go.sum ├── hack └── mosquitto.conf ├── openapi └── openapi.yaml ├── openapitools.json ├── pkg ├── api │ ├── consumer.go │ ├── error.go │ ├── error_types.go │ ├── event.go │ ├── event_instances.go │ ├── metadata.go │ ├── metadata_types.go │ ├── openapi │ │ ├── .gitignore │ │ ├── .openapi-generator-ignore │ │ ├── .openapi-generator │ │ │ ├── FILES │ │ │ └── VERSION │ │ ├── .travis.yml │ │ ├── README.md │ │ ├── api │ │ │ └── openapi.yaml │ │ ├── api_default.go │ │ ├── client.go │ │ ├── configuration.go │ │ ├── docs │ │ │ ├── Consumer.md │ │ │ ├── ConsumerAllOf.md │ │ │ ├── ConsumerList.md │ │ │ ├── ConsumerListAllOf.md │ │ │ ├── ConsumerPatchRequest.md │ │ │ ├── DefaultApi.md │ │ │ ├── Error.md │ │ │ ├── ErrorAllOf.md │ │ │ ├── ErrorList.md │ │ │ ├── ErrorListAllOf.md │ │ │ ├── List.md │ │ │ ├── ObjectReference.md │ │ │ ├── ResourceBundle.md │ │ │ ├── ResourceBundleAllOf.md │ │ │ ├── ResourceBundleList.md │ │ │ └── ResourceBundleListAllOf.md │ │ ├── git_push.sh │ │ ├── model_consumer.go │ │ ├── model_consumer_all_of.go │ │ ├── model_consumer_list.go │ │ ├── model_consumer_list_all_of.go │ │ ├── model_consumer_patch_request.go │ │ ├── model_error.go │ │ ├── model_error_all_of.go │ │ ├── model_error_list.go │ │ ├── model_error_list_all_of.go │ │ ├── model_list.go │ │ ├── model_object_reference.go │ │ ├── model_resource_bundle.go │ │ ├── model_resource_bundle_all_of.go │ │ ├── model_resource_bundle_list.go │ │ ├── model_resource_bundle_list_all_of.go │ │ ├── response.go │ │ └── utils.go │ ├── presenters │ │ ├── consumer.go │ │ ├── error.go │ │ ├── kind.go │ │ ├── object_reference.go │ │ ├── path.go │ │ ├── resource_bundle.go │ │ └── slice_filter.go │ ├── resource_bundle.go │ ├── resource_bundle_test.go │ ├── resource_id.go │ ├── resource_types.go │ ├── server_instance.go │ └── status_event.go ├── auth │ ├── auth_middleware.go │ ├── auth_middleware_mock.go │ ├── authz_middleware.go │ ├── authz_middleware_mock.go │ ├── context.go │ └── helpers.go ├── client │ ├── cloudevents │ │ ├── codec.go │ │ ├── grpcsource │ │ │ ├── client.go │ │ │ ├── mock │ │ │ │ └── maestro.go │ │ │ ├── pager.go │ │ │ ├── pager_test.go │ │ │ ├── util.go │ │ │ ├── util_test.go │ │ │ ├── watch.go │ │ │ └── watcherstore.go │ │ ├── source_client.go │ │ └── source_client_mock.go │ ├── grpcauthorizer │ │ ├── interface.go │ │ ├── kube_authorizer.go │ │ └── mock_authorizer.go │ └── ocm │ │ ├── authorization.go │ │ ├── authorization_mock.go │ │ └── client.go ├── config │ ├── config.go │ ├── config_test.go │ ├── db.go │ ├── event_server.go │ ├── event_server_test.go │ ├── grpc_server.go │ ├── health_check.go │ ├── http_server.go │ ├── message_broker.go │ ├── metrics.go │ ├── ocm.go │ └── sentry.go ├── constants │ └── constants.go ├── controllers │ ├── event_filter.go │ ├── event_filter_test.go │ ├── framework.go │ ├── framework_test.go │ ├── status_controller.go │ └── status_controller_test.go ├── dao │ ├── consumer.go │ ├── event.go │ ├── event_instance.go │ ├── generic.go │ ├── instance.go │ ├── mocks │ │ ├── consumer.go │ │ ├── event.go │ │ ├── event_instance.go │ │ ├── instance.go │ │ └── resource.go │ ├── resource.go │ └── status_event.go ├── db │ ├── README.md │ ├── advisory_locks.go │ ├── context.go │ ├── db_context │ │ └── db_context.go │ ├── db_session │ │ ├── db_session.go │ │ ├── default.go │ │ └── test.go │ ├── metrics_collector.go │ ├── migrations.go │ ├── migrations │ │ ├── 201911212019_add_dinosaurs.go │ │ ├── 202309020925_add_events.go │ │ ├── 202311151850_add_resources.go │ │ ├── 202311151856_add_consumers.go │ │ ├── 202311201127_drop_dinosaurs.go │ │ ├── 202401151014_add_server_instances.go │ │ ├── 202406241426_add_status_events.go │ │ ├── 202406241506_add_event_instances.go │ │ ├── 202412171429_add_last_heartbeat_and_ready_column_in_server_instances_tables.go │ │ ├── 202412181141_alter_event_instances.go │ │ └── migration_structs.go │ ├── mocks │ │ └── advisory_locks.go │ ├── session.go │ ├── sql_helpers.go │ ├── transaction │ │ └── transaction.go │ ├── transaction_middleware.go │ ├── transactions.go │ └── util.go ├── dispatcher │ ├── dispatcher.go │ ├── hash_dispatcher.go │ ├── hash_dispatcher_test.go │ └── noop_dispatcher.go ├── errors │ ├── errors.go │ └── errors_test.go ├── event │ └── event.go ├── handlers │ ├── consumer.go │ ├── errors.go │ ├── framework.go │ ├── framework_test.go │ ├── helpers.go │ ├── openapi.go │ ├── prometheus_metrics.go │ ├── resource_bundle.go │ ├── rest.go │ └── validation.go ├── logger │ ├── logger.go │ ├── operationid_middleware.go │ └── zap.go ├── services │ ├── consumer.go │ ├── event.go │ ├── generic.go │ ├── generic_test.go │ ├── resource.go │ ├── resource_test.go │ ├── status_event.go │ ├── types.go │ ├── util.go │ ├── validation.go │ └── validation_test.go └── util │ └── utils.go ├── pr_check.sh ├── pr_check_docker.sh ├── renovate.json ├── secrets ├── db.host ├── db.name ├── db.port ├── db.user ├── ocm-service.clientId ├── ocm-service.clientSecret ├── ocm-service.token └── sentry.key ├── templates ├── README.md ├── agent-template-aro-hcp.yml ├── agent-template-rosa.yml ├── agent-template.yml ├── agent-tls-template.yml ├── db-template.yml ├── mqtt-template.yml ├── mqtt-tls-template.yml ├── route-template.yml ├── secrets-template.yml ├── service-template-aro-hcp.yml ├── service-template-rosa.yml ├── service-template.yml └── service-tls-template.yml └── test ├── e2e ├── migration │ └── test.sh ├── pkg │ ├── consumer_test.go │ ├── grpc_test.go │ ├── reporter │ │ └── reporter.go │ ├── resources_test.go │ ├── serverside_test.go │ ├── sourceclient_test.go │ ├── spec_resync_test.go │ ├── status_resync_test.go │ └── suite_test.go └── setup │ ├── aro │ ├── Makefile │ └── README.md │ ├── e2e_setup.sh │ ├── e2e_teardown.sh │ ├── rosa │ ├── Makefile │ ├── README.md │ └── setup │ │ ├── agent.sh │ │ ├── aws-iot-policies │ │ ├── consumer.template.json │ │ └── source.template.json │ │ ├── e2e.sh │ │ ├── maestro.sh │ │ └── teardown.sh │ ├── service-ca-crds │ ├── clusteroperators-crd.yaml │ └── servicecas-crd.yaml │ └── service-ca │ ├── 00_roles.yaml │ ├── 01_namespace.yaml │ ├── 02_service.yaml │ ├── 03_cm.yaml │ ├── 03_operator.cr.yaml │ ├── 04_sa.yaml │ ├── 05_deploy.yaml │ └── 07_clusteroperator.yaml ├── factories.go ├── helper.go ├── integration ├── consumers_test.go ├── controller_test.go ├── db_listener_test.go ├── healthcheck_test.go ├── integration_test.go ├── resource_test.go └── status_dispatcher_test.go ├── mocks ├── jwk_cert_server.go ├── mocks.go └── ocm.go ├── performance ├── README.md ├── cmd │ └── main.go ├── hack │ └── aro-hcp │ │ ├── check-result.sh │ │ ├── cleanup.sh │ │ ├── create-clusters.sh │ │ ├── create-works.sh │ │ ├── prepare.consumer.sh │ │ ├── prepare.kind.sh │ │ ├── prepare.kv.certs.sh │ │ ├── prepare.kv.sh │ │ ├── prepare.maestro.sh │ │ ├── start-consumer-agents.sh │ │ └── start-spoke-watcher.sh ├── pkg │ ├── hub │ │ ├── perparer.aro-hcp.go │ │ ├── sourceclient │ │ │ └── client.go │ │ ├── store │ │ │ └── createonly.go │ │ └── workloads │ │ │ ├── manifests │ │ │ └── aro-hcp │ │ │ │ ├── managedcluster.yaml │ │ │ │ ├── manifestwork.hypershift.yaml │ │ │ │ └── manifestwork.namespace.yaml │ │ │ └── workload.aro-hcp.go │ ├── spoke │ │ └── spoke.aro-hcp.go │ ├── util │ │ ├── events.go │ │ └── util.go │ └── watcher │ │ ├── hypershift.go │ │ └── watcher.aro-hpc.go └── result │ └── aro-hpc │ └── resource-usage │ ├── cpu-mem │ ├── db-cpu-avg.png │ ├── db-cpu-max.png │ ├── db-mem-avg.png │ ├── db-mem-max.png │ ├── db-mem-ws-avg.png │ ├── db-mem-ws-max.png │ ├── svc-cpu-avg.png │ ├── svc-cpu-max.png │ ├── svc-mem-avg.png │ ├── svc-mem-max.png │ ├── svc-mem-ws-avg.png │ └── svc-mem-ws-max.png │ └── mqtt │ ├── conns.png │ ├── req-counts.png │ └── throughput.png ├── registration.go ├── store.go └── support ├── certs.json ├── jwt_ca.pem └── jwt_private_key.pem /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E Test 2 | 3 | on: 4 | workflow_dispatch: {} 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | env: 10 | GO_VERSION: '1.23' 11 | GO_REQUIRED_MIN_VERSION: '' 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | e2e: 18 | runs-on: ubuntu-22.04 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - name: Setup Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: ${{ env.GO_VERSION }} 26 | - name: install ginkgo 27 | run: go install github.com/onsi/ginkgo/v2/ginkgo@v2.15.0 28 | - name: Test E2E 29 | run: | 30 | make e2e-test 31 | env: 32 | container_tool: docker 33 | SERVER_REPLICAS: 2 34 | e2e-broadcast-subscription: 35 | runs-on: ubuntu-22.04 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - name: Setup Go 40 | uses: actions/setup-go@v5 41 | with: 42 | go-version: ${{ env.GO_VERSION }} 43 | - name: install ginkgo 44 | run: go install github.com/onsi/ginkgo/v2/ginkgo@v2.15.0 45 | - name: Test E2E 46 | run: | 47 | make e2e-test 48 | env: 49 | container_tool: docker 50 | SERVER_REPLICAS: 3 51 | ENABLE_BROADCAST_SUBSCRIPTION: true 52 | e2e-grpc-broker: 53 | runs-on: ubuntu-22.04 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | - name: Setup Go 58 | uses: actions/setup-go@v5 59 | with: 60 | go-version: ${{ env.GO_VERSION }} 61 | - name: install ginkgo 62 | run: go install github.com/onsi/ginkgo/v2/ginkgo@v2.15.0 63 | - name: Test E2E 64 | run: | 65 | make e2e-test 66 | env: 67 | container_tool: docker 68 | SERVER_REPLICAS: 2 69 | MESSAGE_DRIVER_TYPE: grpc 70 | migration: 71 | runs-on: ubuntu-22.04 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v4 75 | - name: Setup Go 76 | uses: actions/setup-go@v5 77 | with: 78 | go-version: ${{ env.GO_VERSION }} 79 | - name: install ginkgo 80 | run: go install github.com/onsi/ginkgo/v2/ginkgo@v2.15.0 81 | - name: Test E2E 82 | run: | 83 | make migration-test 84 | env: 85 | container_tool: docker 86 | SERVER_REPLICAS: 2 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore uploaded files in development 17 | /storage/* 18 | 19 | # Ignore temporary *.swp files 20 | *.swp 21 | 22 | # Ignore test databases 23 | /db.sqlite.test.* 24 | 25 | .byebug_history 26 | 27 | # Ignore master key for decrypting credentials and more. 28 | /config/master.key 29 | 30 | /coverage 31 | 32 | # Ignore built binaries 33 | /maestro 34 | /maestroperf 35 | 36 | # Ignore generated templates 37 | /templates/*-template.json 38 | /templates/generate-*.txt 39 | 40 | # Ignore editor config 41 | .vscode 42 | 43 | # Ignore IntelliJ config 44 | .idea/ 45 | 46 | # Don't push the testing password file 47 | secrets/db.password 48 | secrets/mqtt.password 49 | secrets/mqtt.config 50 | hack/mosquitto-passwd.txt 51 | 52 | # Ignore vendor 53 | vendor/ 54 | 55 | # Ignore test data 56 | _output 57 | test/e2e/.kubeconfig 58 | test/e2e/.consumer_id 59 | test/e2e/.consumer_name 60 | test/e2e/.external_host_ip 61 | test/e2e/report/* 62 | test/e2e/certs 63 | unit-test-results.json 64 | *integration-test-results.json 65 | test/e2e/setup/aro/aro-hcp 66 | -------------------------------------------------------------------------------- /.golangciversion: -------------------------------------------------------------------------------- 1 | 1.43.0 2 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.25.0 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | exclude: 5 | global: 6 | - db.go 7 | patch: {} 8 | -------------------------------------------------------------------------------- /Containerfile.rhtap: -------------------------------------------------------------------------------- 1 | FROM brew.registry.redhat.io/rh-osbs/openshift-golang-builder:rhel_9_1.23 AS builder 2 | 3 | ENV SOURCE_DIR=/maestro 4 | WORKDIR $SOURCE_DIR 5 | COPY . $SOURCE_DIR 6 | 7 | ENV GOEXPERIMENT=strictfipsruntime 8 | ENV CGO_ENABLED=1 9 | RUN make binary BUILD_OPTS="-tags strictfipsruntime" 10 | 11 | FROM registry.access.redhat.com/ubi9/ubi-minimal:latest 12 | 13 | RUN microdnf update -y && microdnf install -y util-linux && microdnf clean all 14 | COPY --from=builder /maestro/maestro /usr/local/bin/ 15 | EXPOSE 8000 16 | ENTRYPOINT ["/usr/local/bin/maestro", "server"] 17 | 18 | LABEL name="maestro" \ 19 | vendor="Red Hat, Inc." \ 20 | version="0.0.1" \ 21 | summary="maestro API" \ 22 | description="maestro API" \ 23 | io.k8s.description="maestro API" \ 24 | io.k8s.display-name="maestro" \ 25 | io.openshift.tags="maestro" 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | 3 | ENV SOURCE_DIR=/maestro 4 | WORKDIR $SOURCE_DIR 5 | COPY . $SOURCE_DIR 6 | 7 | ENV GOFLAGS="" 8 | RUN make binary 9 | 10 | FROM registry.access.redhat.com/ubi9/ubi-minimal:latest 11 | 12 | RUN microdnf update -y && \ 13 | microdnf install -y util-linux && \ 14 | microdnf clean all 15 | 16 | COPY --from=builder maestro/maestro /usr/local/bin/ 17 | 18 | EXPOSE 8000 19 | 20 | ENTRYPOINT ["/usr/local/bin/maestro", "server"] 21 | 22 | LABEL name="maestro" \ 23 | vendor="Red Hat, Inc." \ 24 | version="0.0.1" \ 25 | summary="maestro API" \ 26 | description="maestro API" \ 27 | io.k8s.description="maestro API" \ 28 | io.k8s.display-name="maestro" \ 29 | io.openshift.tags="maestro" -------------------------------------------------------------------------------- /Dockerfile.openapi: -------------------------------------------------------------------------------- 1 | FROM openapitools/openapi-generator-cli:v7.12.0 2 | 3 | RUN apt-get update 4 | RUN apt-get install -y make sudo git 5 | 6 | # Add sources to get golang 1.21 and skip time related release not valid until failures 7 | RUN echo "deb http://deb.debian.org/debian bookworm-backports main contrib non-free\ndeb-src http://deb.debian.org/debian bookworm-backports main contrib non-free" >> /etc/apt/sources.list 8 | RUN echo "Acquire::Check-Valid-Until \"false\";\nAcquire::Check-Date \"false\";" | cat > /etc/apt/apt.conf.d/10no--check-valid-until 9 | RUN apt-get update 10 | RUN apt-get install -y golang-1.21 11 | 12 | RUN mkdir -p /local 13 | COPY . /local 14 | 15 | ENV PATH="/uhc/bin:/usr/lib/go-1.21/bin/:${PATH}" 16 | ENV GOPATH="/uhc" 17 | ENV GOBIN /usr/lib/go-1.21/bin/ 18 | ENV CGO_ENABLED=0 19 | 20 | # these git and go flags to avoid self signed certificate errors 21 | 22 | WORKDIR /local 23 | 24 | RUN go install -a github.com/go-bindata/go-bindata/...@v3.1.2 25 | RUN bash /usr/local/bin/docker-entrypoint.sh generate -i /local/openapi/openapi.yaml -g go -o /local/pkg/api/openapi 26 | RUN rm /local/pkg/api/openapi/go.mod /local/pkg/api/openapi/go.sum 27 | RUN rm -r /local/pkg/api/openapi/test 28 | RUN go generate /local/cmd/maestro/main.go 29 | RUN gofmt -w /local/pkg/api/openapi 30 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM quay.io/app-sre/postgres:buster 2 | 3 | ENV POSTGRES_PASSWORD foobar-bizz-buzz 4 | ENV POSTGRES_USER maestro 5 | ENV POSTGRES_DB maestro 6 | 7 | ENV GORM_DIALECT postgres 8 | ENV GORM_HOST 127.0.0.1 9 | ENV GORM_PORT 5432 10 | ENV GORM_NAME maestro 11 | ENV GORM_USERNAME maestro 12 | ENV GORM_PASSWORD foobar-bizz-buzz 13 | ENV GORM_SSLMODE disable 14 | ENV GORM_DEBUG false 15 | 16 | RUN apt-get update && apt-get install -y make sudo git wget curl ca-certificates 17 | 18 | COPY cas/redhat-it.pem /usr/local/share/ca-certificates/redhat-it.crt 19 | RUN update-ca-certificates 20 | 21 | COPY db_setup_docker.sql /docker-entrypoint-initdb.d/ 22 | COPY pr_check_docker.sh /docker-entrypoint-initdb.d/ 23 | 24 | COPY go1.18.1.linux-amd64.tar.gz . 25 | RUN tar -C /usr/local -xzf go1.18.1.linux-amd64.tar.gz 26 | 27 | ENV PATH="/ocm/bin:/usr/local/go/bin:${PATH}" 28 | ENV GOPATH="/ocm" 29 | ENV CGO_ENABLED=0 30 | 31 | RUN mkdir -p /ocm/src/gitlab.cee.redhat.com/service/maestro 32 | COPY . /ocm/src/gitlab.cee.redhat.com/service/maestro 33 | 34 | # Docker built / owned as 'root' but the 'postgres' user runs the image 35 | RUN chown -R postgres:postgres /ocm /usr/local/go 36 | WORKDIR /ocm/src/gitlab.cee.redhat.com/service/maestro 37 | 38 | ENTRYPOINT ["docker-entrypoint.sh"] 39 | 40 | CMD ["postgres"] 41 | -------------------------------------------------------------------------------- /arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/arch.png -------------------------------------------------------------------------------- /build_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | # 3 | # Copyright (c) 2018 Red Hat, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | 18 | # This script builds the UHC clusters service image, and pushes it to the 19 | # registry. In order to work it needs the following variables defined in the 20 | # CI/CD configuration of the project: 21 | # 22 | # QUAY_USER - The name of the robot account used to push images to 'quay.io', 23 | # for example 'openshift-unified-hybrid-cloud+jenkins'. 24 | # 25 | # QUAY_TOKEN - The token of the robot account used to push images to 'quay.io'. 26 | # 27 | # The machines that run this script need to have access to internet, so that the 28 | # built images can be pushed to quay.io. 29 | 30 | # Prepare the environment: 31 | if [ ! -z "${WORKSPACE}" ]; then 32 | source jenkins/environment.sh 33 | fi 34 | 35 | # The version should be the short git hash: 36 | VERSION="$(git log --pretty=format:'%H' -n 1 | head -c 7)" 37 | 38 | # Log in to the image registry: 39 | if [ -z "${QUAY_USER}" ]; then 40 | echo "The 'quay.io' push user name hasn't been provided." 41 | echo "Make sure to set the 'QUAY_USER' environment variable." 42 | exit 1 43 | fi 44 | if [ -z "${QUAY_TOKEN}" ]; then 45 | echo "The 'quay.io' push token hasn't been provided." 46 | echo "Make sure to set the 'QUAY_TOKEN' environment variable." 47 | exit 1 48 | fi 49 | podman login -u "${QUAY_USER}" -p "${QUAY_TOKEN}" quay.io 50 | 51 | # Push the image: 52 | make \ 53 | version="${VERSION}" \ 54 | external_image_registry="quay.io" \ 55 | image_repository="stolostron/maestro" \ 56 | push 57 | -------------------------------------------------------------------------------- /cas/README.md: -------------------------------------------------------------------------------- 1 | # Trusted Certificate Authorites 2 | 3 | This directory contains the certificates of additional certificate authorities 4 | that we need to trust in our environments: 5 | 6 | - `rds-combined-ca-bundle.pem` - Contains the certificates used by the 7 | PostgreSQL databases hosted in Amazon. 8 | 9 | - `redhat-it.pem` - Contains the certificates used by internal Red Hat systems, 10 | in particular by _developers.stage.redhat.com_. 11 | -------------------------------------------------------------------------------- /cas/redhat-it.pem: -------------------------------------------------------------------------------- 1 | This file contains the Red Hat IT CA certificate, needed to enable the HTTPS 2 | connection to 'developer.stage.redhat.com'. 3 | 4 | -----BEGIN CERTIFICATE----- 5 | MIIENDCCAxygAwIBAgIJANunI0D662cnMA0GCSqGSIb3DQEBCwUAMIGlMQswCQYD 6 | VQQGEwJVUzEXMBUGA1UECAwOTm9ydGggQ2Fyb2xpbmExEDAOBgNVBAcMB1JhbGVp 7 | Z2gxFjAUBgNVBAoMDVJlZCBIYXQsIEluYy4xEzARBgNVBAsMClJlZCBIYXQgSVQx 8 | GzAZBgNVBAMMElJlZCBIYXQgSVQgUm9vdCBDQTEhMB8GCSqGSIb3DQEJARYSaW5m 9 | b3NlY0ByZWRoYXQuY29tMCAXDTE1MDcwNjE3MzgxMVoYDzIwNTUwNjI2MTczODEx 10 | WjCBpTELMAkGA1UEBhMCVVMxFzAVBgNVBAgMDk5vcnRoIENhcm9saW5hMRAwDgYD 11 | VQQHDAdSYWxlaWdoMRYwFAYDVQQKDA1SZWQgSGF0LCBJbmMuMRMwEQYDVQQLDApS 12 | ZWQgSGF0IElUMRswGQYDVQQDDBJSZWQgSGF0IElUIFJvb3QgQ0ExITAfBgkqhkiG 13 | 9w0BCQEWEmluZm9zZWNAcmVkaGF0LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEP 14 | ADCCAQoCggEBALQt9OJQh6GC5LT1g80qNh0u50BQ4sZ/yZ8aETxt+5lnPVX6MHKz 15 | bfwI6nO1aMG6j9bSw+6UUyPBHP796+FT/pTS+K0wsDV7c9XvHoxJBJJU38cdLkI2 16 | c/i7lDqTfTcfLL2nyUBd2fQDk1B0fxrskhGIIZ3ifP1Ps4ltTkv8hRSob3VtNqSo 17 | GxkKfvD2PKjTPxDPWYyruy9irLZioMffi3i/gCut0ZWtAyO3MVH5qWF/enKwgPES 18 | X9po+TdCvRB/RUObBaM761EcrLSM1GqHNueSfqnho3AjLQ6dBnPWlo638Zm1VebK 19 | BELyhkLWMSFkKwDmne0jQ02Y4g075vCKvCsCAwEAAaNjMGEwHQYDVR0OBBYEFH7R 20 | 4yC+UehIIPeuL8Zqw3PzbgcZMB8GA1UdIwQYMBaAFH7R4yC+UehIIPeuL8Zqw3Pz 21 | bgcZMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB 22 | CwUAA4IBAQBDNvD2Vm9sA5A9AlOJR8+en5Xz9hXcxJB5phxcZQ8jFoG04Vshvd0e 23 | LEnUrMcfFgIZ4njMKTQCM4ZFUPAieyLx4f52HuDopp3e5JyIMfW+KFcNIpKwCsak 24 | oSoKtIUOsUJK7qBVZxcrIyeQV2qcYOeZhtS5wBqIwOAhFwlCET7Ze58QHmS48slj 25 | S9K0JAcps2xdnGu0fkzhSQxY8GPQNFTlr6rYld5+ID/hHeS76gq0YG3q6RLWRkHf 26 | 4eTkRjivAlExrFzKcljC4axKQlnOvVAzz+Gm32U0xPBF4ByePVxCJUHw1TsyTmel 27 | RxNEp7yHoXcwn+fXna+t5JWh1gxUZty3 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /cmd/maestro/environments/e_development.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/db/db_session" 5 | ) 6 | 7 | // devEnvImpl environment is intended for local use while developing features 8 | type devEnvImpl struct { 9 | env *Env 10 | } 11 | 12 | var _ EnvironmentImpl = &devEnvImpl{} 13 | 14 | func (e *devEnvImpl) VisitDatabase(c *Database) error { 15 | c.SessionFactory = db_session.NewProdFactory(e.env.Config.Database) 16 | return nil 17 | } 18 | 19 | func (e *devEnvImpl) VisitMessageBroker(c *MessageBroker) error { 20 | return nil 21 | } 22 | 23 | func (e *devEnvImpl) VisitConfig(c *ApplicationConfig) error { 24 | c.ApplicationConfig.HTTPServer.EnableJWT = false 25 | c.ApplicationConfig.HTTPServer.EnableHTTPS = false 26 | return nil 27 | } 28 | 29 | func (e *devEnvImpl) VisitServices(s *Services) error { 30 | return nil 31 | } 32 | 33 | func (e *devEnvImpl) VisitHandlers(h *Handlers) error { 34 | return nil 35 | } 36 | 37 | func (e *devEnvImpl) VisitClients(c *Clients) error { 38 | return nil 39 | } 40 | 41 | func (e *devEnvImpl) Flags() map[string]string { 42 | return map[string]string{ 43 | "v": "10", 44 | "enable-authz": "false", 45 | "ocm-debug": "false", 46 | "enable-ocm-mock": "true", 47 | "enable-https": "false", 48 | "enable-metrics-https": "false", 49 | "server-hostname": "localhost", 50 | "http-server-bindport": "8000", 51 | "enable-sentry": "false", 52 | "source-id": "maestro", 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/maestro/environments/e_integration_testing.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/openshift-online/maestro/pkg/db/db_session" 7 | ) 8 | 9 | var _ EnvironmentImpl = &testingEnvImpl{} 10 | 11 | // testingEnvImpl is configuration for local integration tests 12 | type testingEnvImpl struct { 13 | env *Env 14 | } 15 | 16 | func (e *testingEnvImpl) VisitDatabase(c *Database) error { 17 | c.SessionFactory = db_session.NewTestFactory(e.env.Config.Database) 18 | return nil 19 | } 20 | 21 | func (e *testingEnvImpl) VisitMessageBroker(c *MessageBroker) error { 22 | return nil 23 | } 24 | 25 | func (e *testingEnvImpl) VisitConfig(c *ApplicationConfig) error { 26 | // Support a one-off env to allow enabling db debug in testing 27 | if os.Getenv("DB_DEBUG") == "true" { 28 | c.ApplicationConfig.Database.Debug = true 29 | } 30 | return nil 31 | } 32 | 33 | func (e *testingEnvImpl) VisitServices(s *Services) error { 34 | return nil 35 | } 36 | 37 | func (e *testingEnvImpl) VisitHandlers(h *Handlers) error { 38 | return nil 39 | } 40 | 41 | func (e *testingEnvImpl) VisitClients(c *Clients) error { 42 | return nil 43 | } 44 | 45 | func (e *testingEnvImpl) Flags() map[string]string { 46 | return map[string]string{ 47 | "v": "0", 48 | "logtostderr": "true", 49 | "ocm-base-url": "https://api.integration.openshift.com", 50 | "enable-https": "false", 51 | "enable-metrics-https": "false", 52 | "enable-authz": "true", 53 | "ocm-debug": "false", 54 | "enable-ocm-mock": "true", 55 | "enable-sentry": "false", 56 | "source-id": "maestro", 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /cmd/maestro/environments/e_production.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/db/db_session" 5 | ) 6 | 7 | var _ EnvironmentImpl = &productionEnvImpl{} 8 | 9 | // productionEnvImpl is any deployed instance of the service through app-interface 10 | type productionEnvImpl struct { 11 | env *Env 12 | } 13 | 14 | var _ EnvironmentImpl = &productionEnvImpl{} 15 | 16 | func (e *productionEnvImpl) VisitDatabase(c *Database) error { 17 | c.SessionFactory = db_session.NewProdFactory(e.env.Config.Database) 18 | return nil 19 | } 20 | 21 | func (e *productionEnvImpl) VisitMessageBroker(c *MessageBroker) error { 22 | return nil 23 | } 24 | 25 | func (e *productionEnvImpl) VisitConfig(c *ApplicationConfig) error { 26 | return nil 27 | } 28 | 29 | func (e *productionEnvImpl) VisitServices(s *Services) error { 30 | return nil 31 | } 32 | 33 | func (e *productionEnvImpl) VisitHandlers(h *Handlers) error { 34 | return nil 35 | } 36 | 37 | func (e *productionEnvImpl) VisitClients(c *Clients) error { 38 | return nil 39 | } 40 | 41 | func (e *productionEnvImpl) Flags() map[string]string { 42 | return map[string]string{ 43 | "v": "1", 44 | "ocm-debug": "false", 45 | "enable-ocm-mock": "false", 46 | "enable-sentry": "false", 47 | "source-id": "maestro", 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/maestro/environments/framework_test.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "os/exec" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | func BenchmarkGetResources(b *testing.B) { 12 | b.ReportAllocs() 13 | fn := func(b *testing.B) { 14 | cmd := exec.Command("ocm", "get", "/api/maestro/v1/resources", "params='size=2'") 15 | _, err := cmd.CombinedOutput() 16 | if err != nil { 17 | b.Errorf("ERROR %+v", err) 18 | } 19 | } 20 | for n := 0; n < b.N; n++ { 21 | fn(b) 22 | } 23 | } 24 | 25 | func TestLoadServices(t *testing.T) { 26 | env := Environment() 27 | // Override environment name 28 | env.Name = "testing" 29 | err := env.AddFlags(pflag.CommandLine) 30 | if err != nil { 31 | t.Errorf("Unable to add flags for testing environment: %s", err.Error()) 32 | return 33 | } 34 | pflag.Parse() 35 | 36 | err = env.Initialize() 37 | if err != nil { 38 | t.Errorf("Unable to load testing environment: %s", err.Error()) 39 | return 40 | } 41 | 42 | s := reflect.ValueOf(env.Services) 43 | 44 | for i := 0; i < s.NumField(); i++ { 45 | if s.Field(i).IsNil() { 46 | t.Errorf("Service %v is nil", s) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /cmd/maestro/environments/service_types.go: -------------------------------------------------------------------------------- 1 | package environments 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/dao" 5 | "github.com/openshift-online/maestro/pkg/db" 6 | "github.com/openshift-online/maestro/pkg/services" 7 | ) 8 | 9 | type ResourceServiceLocator func() services.ResourceService 10 | 11 | func NewResourceServiceLocator(env *Env) ResourceServiceLocator { 12 | return func() services.ResourceService { 13 | return services.NewResourceService( 14 | db.NewAdvisoryLockFactory(env.Database.SessionFactory), 15 | dao.NewResourceDao(&env.Database.SessionFactory), 16 | env.Services.Events(), 17 | env.Services.Generic(), 18 | ) 19 | } 20 | } 21 | 22 | type GenericServiceLocator func() services.GenericService 23 | 24 | func NewGenericServiceLocator(env *Env) GenericServiceLocator { 25 | return func() services.GenericService { 26 | return services.NewGenericService(dao.NewGenericDao(&env.Database.SessionFactory)) 27 | } 28 | } 29 | 30 | type EventServiceLocator func() services.EventService 31 | 32 | func NewEventServiceLocator(env *Env) EventServiceLocator { 33 | return func() services.EventService { 34 | return services.NewEventService(dao.NewEventDao(&env.Database.SessionFactory)) 35 | } 36 | } 37 | 38 | type StatusEventServiceLocator func() services.StatusEventService 39 | 40 | func NewStatusEventServiceLocator(env *Env) StatusEventServiceLocator { 41 | return func() services.StatusEventService { 42 | return services.NewStatusEventService(dao.NewStatusEventDao(&env.Database.SessionFactory)) 43 | } 44 | } 45 | 46 | type ConsumerServiceLocator func() services.ConsumerService 47 | 48 | func NewConsumerServiceLocator(env *Env) ConsumerServiceLocator { 49 | return func() services.ConsumerService { 50 | return services.NewConsumerService( 51 | db.NewAdvisoryLockFactory(env.Database.SessionFactory), 52 | dao.NewConsumerDao(&env.Database.SessionFactory), 53 | dao.NewResourceDao(&env.Database.SessionFactory), 54 | env.Services.Events(), 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /cmd/maestro/environments/types/environments.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "os" 4 | 5 | const ( 6 | TestingEnv string = "testing" 7 | DevelopmentEnv string = "development" 8 | ProductionEnv string = "production" 9 | 10 | EnvironmentStringKey string = "MAESTRO_ENV" 11 | EnvironmentDefault string = DevelopmentEnv 12 | ) 13 | 14 | func GetEnvironmentStrFromEnv() string { 15 | envStr, specified := os.LookupEnv(EnvironmentStringKey) 16 | if !specified || envStr == "" { 17 | envStr = EnvironmentDefault 18 | } 19 | return envStr 20 | } 21 | -------------------------------------------------------------------------------- /cmd/maestro/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | "github.com/openshift-online/maestro/cmd/maestro/agent" 9 | "github.com/openshift-online/maestro/cmd/maestro/migrate" 10 | "github.com/openshift-online/maestro/cmd/maestro/servecmd" 11 | "github.com/openshift-online/maestro/pkg/logger" 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/viper" 14 | "k8s.io/klog/v2" 15 | ) 16 | 17 | // nolint 18 | // 19 | //go:generate go-bindata -o ../../data/generated/openapi/openapi.go -pkg openapi -prefix ../../openapi/ ../../openapi 20 | const ( 21 | logConfigFile = "/configs/logging/config.yaml" 22 | varLogLevel = "log_level" 23 | ) 24 | 25 | var log = logger.GetLogger() 26 | 27 | func main() { 28 | defer logger.SyncLogger() // flush the logger 29 | 30 | // check if the glog flag is already registered to avoid duplicate flag define error 31 | if flag.CommandLine.Lookup("alsologtostderr") != nil { 32 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 33 | } 34 | 35 | // add klog flags 36 | klog.InitFlags(nil) 37 | 38 | // Initialize root command 39 | rootCmd := &cobra.Command{ 40 | Use: "maestro", 41 | Long: "maestro is a multi-cluster resources orchestrator for Kubernetes", 42 | } 43 | 44 | // set the logging config file 45 | viper.SetConfigFile(logConfigFile) 46 | // default log level is info 47 | viper.SetDefault(varLogLevel, "info") 48 | if err := viper.ReadInConfig(); err != nil { 49 | if _, ok := err.(*os.PathError); ok { 50 | log.Infof("no config file '%s'", logConfigFile) 51 | } else { 52 | log.Errorf("failed to read the config file '%s': %v", logConfigFile, err) 53 | } 54 | } else { 55 | logger.SetLogLevel(viper.GetString(varLogLevel)) 56 | } 57 | // monitor the changes in the config file 58 | viper.WatchConfig() 59 | viper.OnConfigChange(func(e fsnotify.Event) { 60 | log.Infof("config file changed: %s, new log level: %s", e.Name, viper.GetString(varLogLevel)) 61 | logger.SetLogLevel(viper.GetString(varLogLevel)) 62 | }) 63 | 64 | // Add klog flags to root command 65 | rootCmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) 66 | 67 | // All subcommands under root 68 | migrateCmd := migrate.NewMigrationCommand() 69 | serveCmd := servecmd.NewServerCommand() 70 | agentCmd := agent.NewAgentCommand() 71 | 72 | // Add subcommand(s) 73 | rootCmd.AddCommand(migrateCmd, serveCmd, agentCmd) 74 | 75 | if err := rootCmd.Execute(); err != nil { 76 | log.Fatalf("error running command: %v", err) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /cmd/maestro/migrate/cmd.go: -------------------------------------------------------------------------------- 1 | package migrate 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/openshift-online/maestro/pkg/db/db_session" 7 | "github.com/openshift-online/maestro/pkg/logger" 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/openshift-online/maestro/pkg/config" 11 | "github.com/openshift-online/maestro/pkg/db" 12 | ) 13 | 14 | var log = logger.GetLogger() 15 | var dbConfig = config.NewDatabaseConfig() 16 | 17 | // migration sub-command handles running migrations 18 | func NewMigrationCommand() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "migration", 21 | Short: "Run maestro service data migrations", 22 | Long: "Run maestro service data migrations", 23 | Run: runMigration, 24 | } 25 | 26 | dbConfig.AddFlags(cmd.PersistentFlags()) 27 | return cmd 28 | } 29 | 30 | func runMigration(_ *cobra.Command, _ []string) { 31 | err := dbConfig.ReadFiles() 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | 36 | connection := db_session.NewProdFactory(dbConfig) 37 | if err := db.Migrate(connection.New(context.Background())); err != nil { 38 | log.Fatal(err) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cmd/maestro/server/controllers.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/openshift-online/maestro/pkg/api" 7 | "github.com/openshift-online/maestro/pkg/controllers" 8 | "github.com/openshift-online/maestro/pkg/dao" 9 | "github.com/openshift-online/maestro/pkg/db" 10 | ) 11 | 12 | func NewControllersServer(eventServer EventServer, eventFilter controllers.EventFilter) *ControllersServer { 13 | s := &ControllersServer{ 14 | KindControllerManager: controllers.NewKindControllerManager( 15 | eventFilter, 16 | env().Services.Events(), 17 | ), 18 | StatusController: controllers.NewStatusController( 19 | env().Services.StatusEvents(), 20 | dao.NewInstanceDao(&env().Database.SessionFactory), 21 | dao.NewEventInstanceDao(&env().Database.SessionFactory), 22 | ), 23 | } 24 | 25 | s.KindControllerManager.Add(&controllers.ControllerConfig{ 26 | Source: "Resources", 27 | Handlers: map[api.EventType][]controllers.ControllerHandlerFunc{ 28 | api.CreateEventType: {eventServer.OnCreate}, 29 | api.UpdateEventType: {eventServer.OnUpdate}, 30 | api.DeleteEventType: {eventServer.OnDelete}, 31 | }, 32 | }) 33 | 34 | s.StatusController.Add(map[api.StatusEventType][]controllers.StatusHandlerFunc{ 35 | api.StatusUpdateEventType: {eventServer.OnStatusUpdate}, 36 | api.StatusDeleteEventType: {eventServer.OnStatusUpdate}, 37 | }) 38 | 39 | return s 40 | } 41 | 42 | type ControllersServer struct { 43 | KindControllerManager *controllers.KindControllerManager 44 | StatusController *controllers.StatusController 45 | 46 | DB db.SessionFactory 47 | } 48 | 49 | // Start is a blocking call that starts this controller server 50 | func (s ControllersServer) Start(ctx context.Context) { 51 | log.Infof("Kind controller handling events") 52 | go s.KindControllerManager.Run(ctx.Done()) 53 | log.Infof("Status controller handling events") 54 | go s.StatusController.Run(ctx.Done()) 55 | 56 | log.Infof("Kind controller listening for events") 57 | go env().Database.SessionFactory.NewListener(ctx, "events", s.KindControllerManager.AddEvent) 58 | log.Infof("Status controller listening for status events") 59 | go env().Database.SessionFactory.NewListener(ctx, "status_events", s.StatusController.AddStatusEvent) 60 | 61 | // block until the context is done 62 | <-ctx.Done() 63 | } 64 | -------------------------------------------------------------------------------- /cmd/maestro/server/logging/formatter.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import "net/http" 4 | 5 | type LogFormatter interface { 6 | FormatRequestLog(request *http.Request) (string, error) 7 | FormatResponseLog(responseInfo *ResponseInfo) (string, error) 8 | } 9 | -------------------------------------------------------------------------------- /cmd/maestro/server/logging/formatter_json.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/openshift-online/maestro/pkg/logger" 9 | ) 10 | 11 | func NewJSONLogFormatter() *jsonLogFormatter { 12 | return &jsonLogFormatter{} 13 | } 14 | 15 | type jsonLogFormatter struct{} 16 | 17 | var _ LogFormatter = &jsonLogFormatter{} 18 | 19 | func (f *jsonLogFormatter) FormatRequestLog(r *http.Request) (string, error) { 20 | jsonlog := jsonRequestLog{ 21 | Method: r.Method, 22 | RequestURI: r.RequestURI, 23 | RemoteAddr: r.RemoteAddr, 24 | } 25 | if logger.GetLoggerLevel() == "debug" { 26 | jsonlog.Header = r.Header 27 | jsonlog.Body = r.Body 28 | } 29 | 30 | log, err := json.Marshal(jsonlog) 31 | if err != nil { 32 | return "", err 33 | } 34 | return string(log[:]), nil 35 | } 36 | 37 | func (f *jsonLogFormatter) FormatResponseLog(info *ResponseInfo) (string, error) { 38 | jsonlog := jsonResponseLog{Header: nil, Status: info.Status, Elapsed: info.Elapsed} 39 | if logger.GetLoggerLevel() == "debug" { 40 | jsonlog.Body = string(info.Body[:]) 41 | } 42 | log, err := json.Marshal(jsonlog) 43 | if err != nil { 44 | return "", err 45 | } 46 | return string(log[:]), nil 47 | } 48 | 49 | type jsonRequestLog struct { 50 | Method string `json:"request_method"` 51 | RequestURI string `json:"request_url"` 52 | Header http.Header `json:"request_header,omitempty"` 53 | Body io.ReadCloser `json:"request_body,omitempty"` 54 | RemoteAddr string `json:"request_remote_ip,omitempty"` 55 | } 56 | 57 | type jsonResponseLog struct { 58 | Header http.Header `json:"response_header,omitempty"` 59 | Status int `json:"response_status,omitempty"` 60 | Body string `json:"response_body,omitempty"` 61 | Elapsed string `json:"elapsed,omitempty"` 62 | } 63 | -------------------------------------------------------------------------------- /cmd/maestro/server/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import "github.com/openshift-online/maestro/pkg/logger" 4 | 5 | var log = logger.GetLogger() 6 | -------------------------------------------------------------------------------- /cmd/maestro/server/logging/request_logging_middleware.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func RequestLoggingMiddleware(handler http.Handler) http.Handler { 10 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 11 | path := strings.TrimSuffix(request.URL.Path, "/") 12 | doLog := true 13 | 14 | // these contribute greatly to log spam but are not useful or meaningful. 15 | // consider a list/map of URLs should this grow in the future. 16 | if path == "/api/maestro" { 17 | doLog = false 18 | } 19 | 20 | loggingWriter := NewLoggingWriter(writer, request, NewJSONLogFormatter()) 21 | 22 | if doLog { 23 | loggingWriter.log(loggingWriter.prepareRequestLog()) 24 | } 25 | 26 | before := time.Now() 27 | handler.ServeHTTP(loggingWriter, request) 28 | elapsed := time.Since(before).String() 29 | 30 | if doLog { 31 | loggingWriter.log(loggingWriter.prepareResponseLog(elapsed)) 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/maestro/server/logging/responseinfo.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import "net/http" 4 | 5 | type ResponseInfo struct { 6 | Header http.Header `json:"response_header,omitempty"` 7 | Body []byte `json:"response_body,omitempty"` 8 | Status int `json:"response_status,omitempty"` 9 | Elapsed string `json:"elapsed,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /cmd/maestro/server/logging/writer.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func NewLoggingWriter(w http.ResponseWriter, r *http.Request, f LogFormatter) *loggingWriter { 8 | return &loggingWriter{ResponseWriter: w, request: r, formatter: f} 9 | } 10 | 11 | type loggingWriter struct { 12 | http.ResponseWriter 13 | request *http.Request 14 | formatter LogFormatter 15 | responseStatus int 16 | responseBody []byte 17 | } 18 | 19 | func (writer *loggingWriter) Write(body []byte) (int, error) { 20 | writer.responseBody = body 21 | return writer.ResponseWriter.Write(body) 22 | } 23 | 24 | func (writer *loggingWriter) WriteHeader(status int) { 25 | writer.responseStatus = status 26 | writer.ResponseWriter.WriteHeader(status) 27 | } 28 | 29 | func (writer *loggingWriter) log(logMsg string, err error) { 30 | switch err { 31 | case nil: 32 | log.Debug(logMsg) 33 | default: 34 | log.With("error", err.Error()).Error("Unable to log request/response for log.") 35 | } 36 | } 37 | 38 | func (writer *loggingWriter) prepareRequestLog() (string, error) { 39 | return writer.formatter.FormatRequestLog(writer.request) 40 | } 41 | 42 | func (writer *loggingWriter) prepareResponseLog(elapsed string) (string, error) { 43 | info := &ResponseInfo{ 44 | Header: writer.ResponseWriter.Header(), 45 | Body: writer.responseBody, 46 | Status: writer.responseStatus, 47 | Elapsed: elapsed, 48 | } 49 | 50 | return writer.formatter.FormatResponseLog(info) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/maestro/server/metrics_server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | 11 | "github.com/openshift-online/maestro/pkg/api" 12 | "github.com/openshift-online/maestro/pkg/handlers" 13 | ) 14 | 15 | func NewMetricsServer() Server { 16 | mainRouter := mux.NewRouter() 17 | mainRouter.NotFoundHandler = http.HandlerFunc(api.SendNotFound) 18 | 19 | // metrics endpoint 20 | prometheusMetricsHandler := handlers.NewPrometheusMetricsHandler() 21 | mainRouter.Handle("/metrics", prometheusMetricsHandler.Handler()) 22 | 23 | var mainHandler http.Handler = mainRouter 24 | 25 | s := &metricsServer{} 26 | s.httpServer = &http.Server{ 27 | Addr: env().Config.HTTPServer.Hostname + ":" + env().Config.Metrics.BindPort, 28 | Handler: mainHandler, 29 | } 30 | return s 31 | } 32 | 33 | type metricsServer struct { 34 | httpServer *http.Server 35 | } 36 | 37 | var _ Server = &metricsServer{} 38 | 39 | func (s metricsServer) Listen() (listener net.Listener, err error) { 40 | return nil, nil 41 | } 42 | 43 | func (s metricsServer) Serve(listener net.Listener) { 44 | } 45 | 46 | func (s metricsServer) Start() { 47 | var err error 48 | if env().Config.Metrics.EnableHTTPS { 49 | if env().Config.HTTPServer.HTTPSCertFile == "" || env().Config.HTTPServer.HTTPSKeyFile == "" { 50 | check( 51 | fmt.Errorf("unspecified required --https-cert-file, --https-key-file"), 52 | "Can't start https server", 53 | ) 54 | } 55 | 56 | // Serve with TLS 57 | log.Infof("Serving Metrics with TLS at %s", env().Config.HTTPServer.BindPort) 58 | err = s.httpServer.ListenAndServeTLS(env().Config.HTTPServer.HTTPSCertFile, env().Config.HTTPServer.HTTPSKeyFile) 59 | } else { 60 | log.Infof("Serving Metrics without TLS at %s", env().Config.Metrics.BindPort) 61 | err = s.httpServer.ListenAndServe() 62 | } 63 | check(err, "Metrics server terminated with errors") 64 | log.Infof("Metrics server terminated") 65 | } 66 | 67 | func (s metricsServer) Stop() error { 68 | return s.httpServer.Shutdown(context.Background()) 69 | } 70 | -------------------------------------------------------------------------------- /cmd/maestro/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/getsentry/sentry-go" 10 | 11 | "github.com/openshift-online/maestro/cmd/maestro/environments" 12 | "github.com/openshift-online/maestro/pkg/logger" 13 | ) 14 | 15 | var log = logger.GetLogger() 16 | 17 | type Server interface { 18 | Start() 19 | Stop() error 20 | Listen() (net.Listener, error) 21 | Serve(net.Listener) 22 | } 23 | 24 | func removeTrailingSlash(next http.Handler) http.Handler { 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") 27 | next.ServeHTTP(w, r) 28 | }) 29 | } 30 | 31 | // Exit on error 32 | func check(err error, msg string) { 33 | if err != nil && err != http.ErrServerClosed { 34 | log.Errorf("%s: %s", msg, err) 35 | sentry.CaptureException(err) 36 | sentry.Flush(environments.Environment().Config.Sentry.Timeout) 37 | os.Exit(1) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docs/continuous-delivery-migration.md: -------------------------------------------------------------------------------- 1 | ## CD Migration 2 | 3 | Migrations can be problematic when trying to move towards continuous deployment. At first glance it looks impossible to CD a breaking migration. Essentially the solution is always to break the migration into multiple smaller and safer steps. Note that migrations can and should be tested -- like any other code change. In AMS we have a lot of experience testing complicated migrations: 4 | 5 | https://gitlab.cee.redhat.com/service/uhc-account-manager/blob/master/pkg/db/README.md#migration-tests 6 | 7 | https://gitlab.cee.redhat.com/service/uhc-account-manager/blob/master/test/integration/migrations_test.go 8 | 9 | ### Example 10 | 11 | In this example from AMS we drop the `subscription_managed` field from `resource_quota` table and AMS API. This change is not backwards compatible as any existing running image of AMS will error if we simply drop the column from the database table. The solution is to break this migration down into multiple steps. 12 | 13 | In AMS we deploy our services on pods running RollingUpdate. When deploying a code change each pod will roll and try to run migrations as part of the init container. We ensure migrations run only once by relying on [postgres' advisory lock](https://gitlab.cee.redhat.com/service/uhc-account-manager/blob/master/pkg/db/migrations.go#L193). 14 | 15 | In order to deploy this change in a CD way we must first merge the change removing support for the field in the API. That code change needs to propagate through to all production service pods and cronjob pods: 16 | 17 | https://issues.redhat.com/browse/SDB-849 18 | 19 | https://gitlab.cee.redhat.com/service/uhc-account-manager/-/merge_requests/1213 20 | 21 | Note that the merge request was merged Jan 22 2020. After the code change is fully deployed we drop the field from the database it is no longer used by any AMS code. Note that the second merge request was merged nearly 2 weeks later on Feb 3 2020: 22 | 23 | https://issues.redhat.com/browse/SDB-858 24 | 25 | https://gitlab.cee.redhat.com/service/uhc-account-manager/-/merge_requests/1214 26 | -------------------------------------------------------------------------------- /docs/dao.md: -------------------------------------------------------------------------------- 1 | **DAO** stands for Data Access Object. It is used to separate the data persistence logic in a separate layer, which is known as ***Separation of Logic***. 2 | 3 | DAO pattern emphasises the low coupling between different components of an application. None of layers depend on it, but only `services` layer. (The most proper way would be using interfaces and not a concrete implementation. We're not using interfaces as there are no plans to replace implementation in foreseeable future.) 4 | 5 | As the persistence logic is completely separate, it is much easier to write Unit tests for individual components. It is quite easy to mock data for an individual component of the application. 6 | 7 | The DAO layer implementation resides in package `dao`, and correspondent mocks in package `mocks`. An example of a Unit test may be found in `pkg/services/resource_test.go`. 8 | -------------------------------------------------------------------------------- /docs/images/maestro-mqtt-pub-dataflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-mqtt-pub-dataflow.png -------------------------------------------------------------------------------- /docs/images/maestro-mqtt-sub-dataflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-mqtt-sub-dataflow.png -------------------------------------------------------------------------------- /docs/images/maestro-overview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-overview.jpg -------------------------------------------------------------------------------- /docs/images/maestro-resource-create-flow-grpc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-resource-create-flow-grpc.png -------------------------------------------------------------------------------- /docs/images/maestro-resource-delete-flow-grpc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-resource-delete-flow-grpc.png -------------------------------------------------------------------------------- /docs/images/maestro-resource-patch-flow-grpc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-resource-patch-flow-grpc.png -------------------------------------------------------------------------------- /docs/images/maestro-resource-status-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/docs/images/maestro-resource-status-flow.png -------------------------------------------------------------------------------- /docs/observability/Makefile: -------------------------------------------------------------------------------- 1 | all: deploy patch-maestro-server 2 | @echo "Observability services are now configured and enabled." 3 | @echo "Run the following command to port-forward traffic to the Jaeger service:" 4 | @echo "" 5 | @echo "kubectl port-forward -n observability svc/jaeger 16686:16686" 6 | @echo "" 7 | @echo "Then open 'http://localhost:16686' in your browser." 8 | 9 | deploy: 10 | @kubectl apply -k jaeger/ 11 | @kubectl wait --for=condition=Available deployment -n observability jaeger --timeout=60s 12 | .PHONY: deploy 13 | 14 | patch-maestro-server: 15 | @kubectl set env -n maestro deployment maestro --containers service OTEL_EXPORTER_OTLP_ENDPOINT=http://ingest.observability:4318 OTEL_TRACES_EXPORTER=otlp 16 | @kubectl wait --for=condition=Available -n maestro deployment maestro --timeout=30s 17 | .PHONY: patch-maestro-server 18 | -------------------------------------------------------------------------------- /docs/observability/README.md: -------------------------------------------------------------------------------- 1 | # Observability for development environment 2 | 3 | ## Tracing 4 | 5 | The maestro server is instrumented with the OpenTelemetry SDK but by default, tracing capability is not enabled. There's no official backend configured to collect and visualize the traces. 6 | 7 | For development environment, tracing can be abled and tracing information can be visualized using Jaeger instance. 8 | 9 | ### Deploy Jaeger all-in-one instance 10 | 11 | A [Jaeger](https://www.jaegertracing.io/) instance with in-memory storage to store and visualize the traces received from the maestro server. 12 | 13 | #### Install 14 | 15 | ``` 16 | make deploy 17 | ``` 18 | 19 | After installation, the `jaeger` service becomes available in the `observability` namespace. We can access the UI using `kubectl port-forward`: 20 | 21 | ``` 22 | kubectl port-forward -n observability svc/jaeger 16686:16686 23 | ``` 24 | 25 | Open http://localhost:16686 in your browser to access the Jaeger UI. 26 | The `observability` namespace contains a second service named `ingest` which accepts otlp via gRPC and HTTP. 27 | 28 | #### Configure maestro server observability 29 | 30 | Run the following commands: 31 | 32 | ``` 33 | make patch-maestro-server 34 | ``` 35 | 36 | The export of the trace information is configured via environment variables. Existing deployments are patched as follows: 37 | 38 | ```diff 39 | + env: 40 | + - name: OTEL_EXPORTER_OTLP_ENDPOINT 41 | + value: https://ingest.observability:4318 42 | + - name: OTEL_TRACES_EXPORTER 43 | + value: otlp 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/observability/jaeger/jaeger.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: jaeger 5 | spec: 6 | replicas: 1 7 | selector: 8 | matchLabels: 9 | app: jaeger 10 | template: 11 | metadata: 12 | labels: 13 | app: jaeger 14 | spec: 15 | containers: 16 | - name: jaeger 17 | image: jaegertracing/all-in-one:1.63.0 18 | env: 19 | - name: SPAN_STORAGE_TYPE 20 | value: memory 21 | - name: JAEGER_DISABLED 22 | value: "false" 23 | - name: COLLECTOR_OTLP_ENABLED 24 | value: "true" 25 | - name: COLLECTOR_OTLP_GRPC_HOST_PORT 26 | value: 0.0.0.0:4317 27 | - name: COLLECTOR_OTLP_HTTP_HOST_PORT 28 | value: 0.0.0.0:4318 29 | ports: 30 | - containerPort: 4317 31 | name: grpc-otlp 32 | - containerPort: 4318 33 | name: http-otlp 34 | - containerPort: 16686 35 | name: jaeger-ui 36 | livenessProbe: 37 | failureThreshold: 5 38 | httpGet: 39 | path: / 40 | port: 14269 41 | initialDelaySeconds: 5 42 | periodSeconds: 15 43 | readinessProbe: 44 | httpGet: 45 | path: / 46 | port: 14269 47 | initialDelaySeconds: 1 48 | -------------------------------------------------------------------------------- /docs/observability/jaeger/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: observability 4 | resources: 5 | - namespace.yaml 6 | - service.yaml 7 | - jaeger.yaml 8 | -------------------------------------------------------------------------------- /docs/observability/jaeger/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: placeholder 5 | -------------------------------------------------------------------------------- /docs/observability/jaeger/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: jaeger 5 | spec: 6 | selector: 7 | app: jaeger 8 | ports: 9 | - name: jaeger-ui 10 | port: 16686 11 | targetPort: 16686 12 | protocol: TCP 13 | type: ClusterIP 14 | --- 15 | apiVersion: v1 16 | kind: Service 17 | metadata: 18 | name: ingest 19 | spec: 20 | selector: 21 | app: jaeger 22 | ports: 23 | - name: grpc-otlp 24 | port: 4317 25 | targetPort: 4317 26 | protocol: TCP 27 | - name: http-otlp 28 | port: 4318 29 | targetPort: 4318 30 | protocol: TCP 31 | type: ClusterIP 32 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Update Maestro Log Level at Runtime 4 | 5 | To aid in troubleshooting, you may need detailed logs from Maestro. Currently, the supported log levels are debug, info, warn, and error, with info as the default. For the complete list of available log levels, refer to [zap log levels](https://github.com/uber-go/zap/blob/master/level.go#L30-L49). 6 | 7 | To adjust the log level, create or update the configmap named in `maestro-logging-config ` in maestro namespace. This change will dynamically modify the log level for Maestro without requiring a restart. 8 | 9 | ```yaml 10 | cat << EOF | kubectl -n maestro apply -f - 11 | apiVersion: v1 12 | kind: ConfigMap 13 | metadata: 14 | name: maestro-logging-config 15 | data: 16 | config.yaml: | 17 | log_level: debug 18 | EOF 19 | ``` 20 | 21 | ## Access to Maestro Metrics 22 | 23 | ### Access maestro server metrics 24 | 25 | Access maestro server metrics via maestro-metrics service: 26 | 27 | ```shell 28 | kubectl -n maestro port-forward svc/maestro-metrics 8080 & 29 | curl http://localhost:8080/metrics 30 | ``` 31 | 32 | ### Access maestro agent metrics 33 | 34 | 1. Apply RBAC resources to access maestro agent metrics 35 | 36 | ```shell 37 | export maestro_agent_ns= 38 | cat << EOF | kubectl apply -n ${maestro_agent_ns} -f - 39 | apiVersion: rbac.authorization.k8s.io/v1 40 | kind: ClusterRole 41 | metadata: 42 | name: metrics-reader 43 | rules: 44 | - nonResourceURLs: 45 | - "/metrics" 46 | verbs: 47 | - get 48 | --- 49 | apiVersion: rbac.authorization.k8s.io/v1 50 | kind: ClusterRoleBinding 51 | metadata: 52 | name: metrics-reader-binding 53 | subjects: 54 | - kind: ServiceAccount 55 | name: maestro-agent-sa 56 | namespace: ${maestro_agent_ns} 57 | roleRef: 58 | kind: ClusterRole 59 | name: metrics-reader 60 | apiGroup: rbac.authorization.k8s.io 61 | EOF 62 | ``` 63 | 64 | 2. Get the token to access maestro agent metrics 65 | 66 | ```shell 67 | export TOKEN=$(kubectl -n ${maestro_agent_ns} create token maestro-agent-sa) 68 | ``` 69 | 70 | 3. Access maestro agent metrics via maestro-agent pod: 71 | 72 | ```shell 73 | kubectl -n ${maestro_agent_ns} port-forward deploy/maestro-agent 8443 74 | curl -k -H "Authorization: Bearer $TOKEN" https://localhost:8443/metrics 75 | ``` 76 | -------------------------------------------------------------------------------- /examples/grpc/cloudevent-delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "specversion": "1.0", 3 | "id": "aa27051a-5578-4e03-b737-5d6416d09694", 4 | "type": "io.open-cluster-management.works.v1alpha1.manifestbundles.spec.delete_request", 5 | "source": "grpc", 6 | "clustername": "cluster1", 7 | "resourceid": "68ebf474-6709-48bb-b760-386181268064", 8 | "resourceversion": 1, 9 | "deletiontimestamp": "2024-10-15T09:54:06.582625606Z", 10 | "datacontenttype": "application/json", 11 | "data": {} 12 | } -------------------------------------------------------------------------------- /examples/grpc/cloudevent-status-resync.json: -------------------------------------------------------------------------------- 1 | { 2 | "specversion": "1.0", 3 | "id": "ff8b8e9b-d433-4707-bedd-66d52bbfb9f1", 4 | "type": "io.open-cluster-management.works.v1alpha1.manifestbundles.status.resync_request", 5 | "source": "grpc", 6 | "time": "2024-10-15T17:31:05Z", 7 | "clustername": "", 8 | "originalsource": "", 9 | "datacontenttype": "application/json", 10 | "data": { 11 | "statusHashes": [] 12 | } 13 | } -------------------------------------------------------------------------------- /examples/manifestworkclient/client/manifestwork.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "work.open-cluster-management.io/v1", 3 | "kind": "ManifestWork", 4 | "metadata": { 5 | "name": "nginx-work" 6 | }, 7 | "spec": { 8 | "workload": { 9 | "manifests": [ 10 | { 11 | "apiVersion": "apps/v1", 12 | "kind": "Deployment", 13 | "metadata": { 14 | "name": "nginx", 15 | "namespace": "default" 16 | }, 17 | "spec": { 18 | "replicas": 1, 19 | "selector": { 20 | "matchLabels": { 21 | "app": "nginx" 22 | } 23 | }, 24 | "template": { 25 | "metadata": { 26 | "labels": { 27 | "app": "nginx" 28 | } 29 | }, 30 | "spec": { 31 | "containers": [ 32 | { 33 | "name": "nginx", 34 | "image": "nginxinc/nginx-unprivileged", 35 | "imagePullPolicy": "IfNotPresent" 36 | } 37 | ] 38 | } 39 | } 40 | } 41 | } 42 | ] 43 | }, 44 | "deleteOption": { 45 | "propagationPolicy": "Foreground" 46 | }, 47 | "manifestConfigs": [ 48 | { 49 | "resourceIdentifier": { 50 | "group": "apps", 51 | "resource": "deployments", 52 | "namespace": "default", 53 | "name": "nginx" 54 | }, 55 | "feedbackRules": [ 56 | { 57 | "type": "JSONPaths", 58 | "jsonPaths": [ 59 | { 60 | "name": "status", 61 | "path": ".status" 62 | } 63 | ] 64 | } 65 | ], 66 | "updateStrategy": { 67 | "type": "ServerSideApply" 68 | } 69 | } 70 | ] 71 | } 72 | } -------------------------------------------------------------------------------- /hack/mosquitto.conf: -------------------------------------------------------------------------------- 1 | listener 1883 0.0.0.0 2 | password_file /mosquitto/config/password.txt 3 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "5.4.0" 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /pkg/api/consumer.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/db" 5 | "gorm.io/gorm" 6 | ) 7 | 8 | type Consumer struct { 9 | Meta 10 | 11 | // Name must be unique and not null, it can be treated as the consumer external ID. 12 | // The format of the name should be follow the RFC 1123 (same as the k8s namespace). 13 | // When creating a consumer, if its name is not specified, the consumer id will be used as its name. 14 | // 15 | // Cannot be updated. 16 | Name string 17 | Labels *db.StringMap 18 | } 19 | 20 | type ConsumerList []*Consumer 21 | type ConsumerIndex map[string]*Consumer 22 | 23 | func (l ConsumerList) Index() ConsumerIndex { 24 | index := ConsumerIndex{} 25 | for _, o := range l { 26 | index[o.ID] = o 27 | } 28 | return index 29 | } 30 | 31 | func (d *Consumer) BeforeCreate(tx *gorm.DB) error { 32 | d.ID = NewID() 33 | 34 | if d.Name == "" { 35 | d.Name = d.ID 36 | } 37 | 38 | return nil 39 | } 40 | 41 | type ConsumerPatchRequest struct { 42 | } 43 | -------------------------------------------------------------------------------- /pkg/api/error_types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // ErrorType is the name of the type used to report errors. 4 | const ErrorType = "Error" 5 | 6 | // Error represents an error reported by the API. 7 | type Error struct { 8 | Type string `json:"type,omitempty"` 9 | ID string `json:"id,omitempty"` 10 | HREF string `json:"href,omitempty"` 11 | Code string `json:"code,omitempty"` 12 | Reason string `json:"reason,omitempty"` 13 | } 14 | -------------------------------------------------------------------------------- /pkg/api/event.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type EventType string 10 | 11 | const ( 12 | CreateEventType EventType = "Create" 13 | UpdateEventType EventType = "Update" 14 | DeleteEventType EventType = "Delete" 15 | ) 16 | 17 | type Event struct { 18 | Meta 19 | Source string // MyTable 20 | SourceID string // primary key of MyTable 21 | EventType EventType // Add|Update|Delete 22 | ReconciledDate *time.Time `json:"gorm:null"` 23 | } 24 | 25 | type EventList []*Event 26 | type EventIndex map[string]*Event 27 | 28 | func (l EventList) Index() EventIndex { 29 | index := EventIndex{} 30 | for _, o := range l { 31 | index[o.ID] = o 32 | } 33 | return index 34 | } 35 | 36 | func (d *Event) BeforeCreate(tx *gorm.DB) error { 37 | d.ID = NewID() 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/api/event_instances.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type EventInstance struct { 4 | EventID string 5 | InstanceID string 6 | } 7 | 8 | type EventInstanceList []*EventInstance 9 | -------------------------------------------------------------------------------- /pkg/api/metadata_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2018 Red Hat, Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // This file contains the API metadata types used by the maestro. 18 | 19 | package api 20 | 21 | import ( 22 | "time" 23 | 24 | "gorm.io/gorm" 25 | ) 26 | 27 | // CollectionMetadata represents a collection. 28 | type CollectionMetadata struct { 29 | ID string `json:"id"` 30 | HREF string `json:"href"` 31 | Kind string `json:"kind"` 32 | } 33 | 34 | // VersionMetadata represents a version. 35 | type VersionMetadata struct { 36 | ID string `json:"id"` 37 | HREF string `json:"href"` 38 | Kind string `json:"kind"` 39 | Collections []CollectionMetadata `json:"collections"` 40 | } 41 | 42 | // Metadata api metadata. 43 | type Metadata struct { 44 | ID string `json:"id"` 45 | HREF string `json:"href"` 46 | Kind string `json:"kind"` 47 | Versions []VersionMetadata `json:"versions"` 48 | } 49 | 50 | // Meta is base model definition, embedded in all kinds 51 | type Meta struct { 52 | ID string 53 | CreatedAt time.Time 54 | UpdatedAt time.Time 55 | DeletedAt gorm.DeletedAt `gorm:"index"` 56 | } 57 | 58 | // List Paging metadata 59 | type PagingMeta struct { 60 | Page int 61 | Size int64 62 | Total int64 63 | } 64 | -------------------------------------------------------------------------------- /pkg/api/openapi/.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /pkg/api/openapi/.openapi-generator-ignore: -------------------------------------------------------------------------------- 1 | # OpenAPI Generator Ignore 2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator 3 | 4 | # Use this file to prevent files from being overwritten by the generator. 5 | # The patterns follow closely to .gitignore or .dockerignore. 6 | 7 | # As an example, the C# client generator defines ApiClient.cs. 8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: 9 | #ApiClient.cs 10 | 11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*): 12 | #foo/*/qux 13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux 14 | 15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**): 16 | #foo/**/qux 17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux 18 | 19 | # You can also negate patterns with an exclamation (!). 20 | # For example, you can ignore all files in a docs folder with the file extension .md: 21 | #docs/*.md 22 | # Then explicitly reverse the ignore rule for a single file: 23 | #!docs/README.md 24 | -------------------------------------------------------------------------------- /pkg/api/openapi/.openapi-generator/FILES: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .openapi-generator-ignore 3 | .travis.yml 4 | README.md 5 | api/openapi.yaml 6 | api_default.go 7 | client.go 8 | configuration.go 9 | docs/Consumer.md 10 | docs/ConsumerAllOf.md 11 | docs/ConsumerList.md 12 | docs/ConsumerListAllOf.md 13 | docs/ConsumerPatchRequest.md 14 | docs/DefaultApi.md 15 | docs/Error.md 16 | docs/ErrorAllOf.md 17 | docs/ErrorList.md 18 | docs/ErrorListAllOf.md 19 | docs/List.md 20 | docs/ObjectReference.md 21 | docs/ResourceBundle.md 22 | docs/ResourceBundleAllOf.md 23 | docs/ResourceBundleList.md 24 | docs/ResourceBundleListAllOf.md 25 | git_push.sh 26 | go.mod 27 | go.sum 28 | model_consumer.go 29 | model_consumer_all_of.go 30 | model_consumer_list.go 31 | model_consumer_list_all_of.go 32 | model_consumer_patch_request.go 33 | model_error.go 34 | model_error_all_of.go 35 | model_error_list.go 36 | model_error_list_all_of.go 37 | model_list.go 38 | model_object_reference.go 39 | model_resource_bundle.go 40 | model_resource_bundle_all_of.go 41 | model_resource_bundle_list.go 42 | model_resource_bundle_list_all_of.go 43 | response.go 44 | test/api_default_test.go 45 | utils.go 46 | -------------------------------------------------------------------------------- /pkg/api/openapi/.openapi-generator/VERSION: -------------------------------------------------------------------------------- 1 | 6.6.0 -------------------------------------------------------------------------------- /pkg/api/openapi/.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | install: 4 | - go get -d -v . 5 | 6 | script: 7 | - go build -v ./ 8 | 9 | -------------------------------------------------------------------------------- /pkg/api/openapi/docs/ConsumerListAllOf.md: -------------------------------------------------------------------------------- 1 | # ConsumerListAllOf 2 | 3 | ## Properties 4 | 5 | Name | Type | Description | Notes 6 | ------------ | ------------- | ------------- | ------------- 7 | **Items** | Pointer to [**[]Consumer**](Consumer.md) | | [optional] 8 | 9 | ## Methods 10 | 11 | ### NewConsumerListAllOf 12 | 13 | `func NewConsumerListAllOf() *ConsumerListAllOf` 14 | 15 | NewConsumerListAllOf instantiates a new ConsumerListAllOf object 16 | This constructor will assign default values to properties that have it defined, 17 | and makes sure properties required by API are set, but the set of arguments 18 | will change when the set of required properties is changed 19 | 20 | ### NewConsumerListAllOfWithDefaults 21 | 22 | `func NewConsumerListAllOfWithDefaults() *ConsumerListAllOf` 23 | 24 | NewConsumerListAllOfWithDefaults instantiates a new ConsumerListAllOf object 25 | This constructor will only assign default values to properties that have it defined, 26 | but it doesn't guarantee that properties required by API are set 27 | 28 | ### GetItems 29 | 30 | `func (o *ConsumerListAllOf) GetItems() []Consumer` 31 | 32 | GetItems returns the Items field if non-nil, zero value otherwise. 33 | 34 | ### GetItemsOk 35 | 36 | `func (o *ConsumerListAllOf) GetItemsOk() (*[]Consumer, bool)` 37 | 38 | GetItemsOk returns a tuple with the Items field if it's non-nil, zero value otherwise 39 | and a boolean to check if the value has been set. 40 | 41 | ### SetItems 42 | 43 | `func (o *ConsumerListAllOf) SetItems(v []Consumer)` 44 | 45 | SetItems sets Items field to given value. 46 | 47 | ### HasItems 48 | 49 | `func (o *ConsumerListAllOf) HasItems() bool` 50 | 51 | HasItems returns a boolean if a field has been set. 52 | 53 | 54 | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) 55 | 56 | 57 | -------------------------------------------------------------------------------- /pkg/api/openapi/docs/ConsumerPatchRequest.md: -------------------------------------------------------------------------------- 1 | # ConsumerPatchRequest 2 | 3 | ## Properties 4 | 5 | Name | Type | Description | Notes 6 | ------------ | ------------- | ------------- | ------------- 7 | **Labels** | Pointer to **map[string]string** | | [optional] 8 | 9 | ## Methods 10 | 11 | ### NewConsumerPatchRequest 12 | 13 | `func NewConsumerPatchRequest() *ConsumerPatchRequest` 14 | 15 | NewConsumerPatchRequest instantiates a new ConsumerPatchRequest object 16 | This constructor will assign default values to properties that have it defined, 17 | and makes sure properties required by API are set, but the set of arguments 18 | will change when the set of required properties is changed 19 | 20 | ### NewConsumerPatchRequestWithDefaults 21 | 22 | `func NewConsumerPatchRequestWithDefaults() *ConsumerPatchRequest` 23 | 24 | NewConsumerPatchRequestWithDefaults instantiates a new ConsumerPatchRequest object 25 | This constructor will only assign default values to properties that have it defined, 26 | but it doesn't guarantee that properties required by API are set 27 | 28 | ### GetLabels 29 | 30 | `func (o *ConsumerPatchRequest) GetLabels() map[string]string` 31 | 32 | GetLabels returns the Labels field if non-nil, zero value otherwise. 33 | 34 | ### GetLabelsOk 35 | 36 | `func (o *ConsumerPatchRequest) GetLabelsOk() (*map[string]string, bool)` 37 | 38 | GetLabelsOk returns a tuple with the Labels field if it's non-nil, zero value otherwise 39 | and a boolean to check if the value has been set. 40 | 41 | ### SetLabels 42 | 43 | `func (o *ConsumerPatchRequest) SetLabels(v map[string]string)` 44 | 45 | SetLabels sets Labels field to given value. 46 | 47 | ### HasLabels 48 | 49 | `func (o *ConsumerPatchRequest) HasLabels() bool` 50 | 51 | HasLabels returns a boolean if a field has been set. 52 | 53 | 54 | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) 55 | 56 | 57 | -------------------------------------------------------------------------------- /pkg/api/openapi/docs/ErrorListAllOf.md: -------------------------------------------------------------------------------- 1 | # ErrorListAllOf 2 | 3 | ## Properties 4 | 5 | Name | Type | Description | Notes 6 | ------------ | ------------- | ------------- | ------------- 7 | **Items** | Pointer to [**[]Error**](Error.md) | | [optional] 8 | 9 | ## Methods 10 | 11 | ### NewErrorListAllOf 12 | 13 | `func NewErrorListAllOf() *ErrorListAllOf` 14 | 15 | NewErrorListAllOf instantiates a new ErrorListAllOf object 16 | This constructor will assign default values to properties that have it defined, 17 | and makes sure properties required by API are set, but the set of arguments 18 | will change when the set of required properties is changed 19 | 20 | ### NewErrorListAllOfWithDefaults 21 | 22 | `func NewErrorListAllOfWithDefaults() *ErrorListAllOf` 23 | 24 | NewErrorListAllOfWithDefaults instantiates a new ErrorListAllOf object 25 | This constructor will only assign default values to properties that have it defined, 26 | but it doesn't guarantee that properties required by API are set 27 | 28 | ### GetItems 29 | 30 | `func (o *ErrorListAllOf) GetItems() []Error` 31 | 32 | GetItems returns the Items field if non-nil, zero value otherwise. 33 | 34 | ### GetItemsOk 35 | 36 | `func (o *ErrorListAllOf) GetItemsOk() (*[]Error, bool)` 37 | 38 | GetItemsOk returns a tuple with the Items field if it's non-nil, zero value otherwise 39 | and a boolean to check if the value has been set. 40 | 41 | ### SetItems 42 | 43 | `func (o *ErrorListAllOf) SetItems(v []Error)` 44 | 45 | SetItems sets Items field to given value. 46 | 47 | ### HasItems 48 | 49 | `func (o *ErrorListAllOf) HasItems() bool` 50 | 51 | HasItems returns a boolean if a field has been set. 52 | 53 | 54 | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) 55 | 56 | 57 | -------------------------------------------------------------------------------- /pkg/api/openapi/docs/ResourceBundleListAllOf.md: -------------------------------------------------------------------------------- 1 | # ResourceBundleListAllOf 2 | 3 | ## Properties 4 | 5 | Name | Type | Description | Notes 6 | ------------ | ------------- | ------------- | ------------- 7 | **Items** | Pointer to [**[]ResourceBundle**](ResourceBundle.md) | | [optional] 8 | 9 | ## Methods 10 | 11 | ### NewResourceBundleListAllOf 12 | 13 | `func NewResourceBundleListAllOf() *ResourceBundleListAllOf` 14 | 15 | NewResourceBundleListAllOf instantiates a new ResourceBundleListAllOf object 16 | This constructor will assign default values to properties that have it defined, 17 | and makes sure properties required by API are set, but the set of arguments 18 | will change when the set of required properties is changed 19 | 20 | ### NewResourceBundleListAllOfWithDefaults 21 | 22 | `func NewResourceBundleListAllOfWithDefaults() *ResourceBundleListAllOf` 23 | 24 | NewResourceBundleListAllOfWithDefaults instantiates a new ResourceBundleListAllOf object 25 | This constructor will only assign default values to properties that have it defined, 26 | but it doesn't guarantee that properties required by API are set 27 | 28 | ### GetItems 29 | 30 | `func (o *ResourceBundleListAllOf) GetItems() []ResourceBundle` 31 | 32 | GetItems returns the Items field if non-nil, zero value otherwise. 33 | 34 | ### GetItemsOk 35 | 36 | `func (o *ResourceBundleListAllOf) GetItemsOk() (*[]ResourceBundle, bool)` 37 | 38 | GetItemsOk returns a tuple with the Items field if it's non-nil, zero value otherwise 39 | and a boolean to check if the value has been set. 40 | 41 | ### SetItems 42 | 43 | `func (o *ResourceBundleListAllOf) SetItems(v []ResourceBundle)` 44 | 45 | SetItems sets Items field to given value. 46 | 47 | ### HasItems 48 | 49 | `func (o *ResourceBundleListAllOf) HasItems() bool` 50 | 51 | HasItems returns a boolean if a field has been set. 52 | 53 | 54 | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) 55 | 56 | 57 | -------------------------------------------------------------------------------- /pkg/api/openapi/git_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ 3 | # 4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" 5 | 6 | git_user_id=$1 7 | git_repo_id=$2 8 | release_note=$3 9 | git_host=$4 10 | 11 | if [ "$git_host" = "" ]; then 12 | git_host="github.com" 13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host" 14 | fi 15 | 16 | if [ "$git_user_id" = "" ]; then 17 | git_user_id="GIT_USER_ID" 18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" 19 | fi 20 | 21 | if [ "$git_repo_id" = "" ]; then 22 | git_repo_id="GIT_REPO_ID" 23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" 24 | fi 25 | 26 | if [ "$release_note" = "" ]; then 27 | release_note="Minor update" 28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note" 29 | fi 30 | 31 | # Initialize the local directory as a Git repository 32 | git init 33 | 34 | # Adds the files in the local repository and stages them for commit. 35 | git add . 36 | 37 | # Commits the tracked changes and prepares them to be pushed to a remote repository. 38 | git commit -m "$release_note" 39 | 40 | # Sets the new remote 41 | git_remote=$(git remote) 42 | if [ "$git_remote" = "" ]; then # git remote not defined 43 | 44 | if [ "$GIT_TOKEN" = "" ]; then 45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." 46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git 47 | else 48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git 49 | fi 50 | 51 | fi 52 | 53 | git pull origin master 54 | 55 | # Pushes (Forces) the changes in the local repository up to the remote repository 56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" 57 | git push origin master 2>&1 | grep -v 'To https' 58 | -------------------------------------------------------------------------------- /pkg/api/openapi/response.go: -------------------------------------------------------------------------------- 1 | /* 2 | maestro Service API 3 | 4 | maestro Service API 5 | 6 | API version: 0.0.1 7 | */ 8 | 9 | // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. 10 | 11 | package openapi 12 | 13 | import ( 14 | "net/http" 15 | ) 16 | 17 | // APIResponse stores the API response returned by the server. 18 | type APIResponse struct { 19 | *http.Response `json:"-"` 20 | Message string `json:"message,omitempty"` 21 | // Operation is the name of the OpenAPI operation. 22 | Operation string `json:"operation,omitempty"` 23 | // RequestURL is the request URL. This value is always available, even if the 24 | // embedded *http.Response is nil. 25 | RequestURL string `json:"url,omitempty"` 26 | // Method is the HTTP method used for the request. This value is always 27 | // available, even if the embedded *http.Response is nil. 28 | Method string `json:"method,omitempty"` 29 | // Payload holds the contents of the response body (which may be nil or empty). 30 | // This is provided here as the raw response.Body() reader will have already 31 | // been drained. 32 | Payload []byte `json:"-"` 33 | } 34 | 35 | // NewAPIResponse returns a new APIResponse object. 36 | func NewAPIResponse(r *http.Response) *APIResponse { 37 | 38 | response := &APIResponse{Response: r} 39 | return response 40 | } 41 | 42 | // NewAPIResponseWithError returns a new APIResponse object with the provided error message. 43 | func NewAPIResponseWithError(errorMessage string) *APIResponse { 44 | 45 | response := &APIResponse{Message: errorMessage} 46 | return response 47 | } 48 | -------------------------------------------------------------------------------- /pkg/api/presenters/consumer.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/api" 5 | "github.com/openshift-online/maestro/pkg/api/openapi" 6 | "github.com/openshift-online/maestro/pkg/db" 7 | "github.com/openshift-online/maestro/pkg/util" 8 | ) 9 | 10 | func ConvertConsumer(consumer openapi.Consumer) *api.Consumer { 11 | return &api.Consumer{ 12 | Meta: api.Meta{ 13 | ID: util.NilToEmptyString(consumer.Id), 14 | }, 15 | Name: util.NilToEmptyString(consumer.Name), 16 | Labels: db.EmptyMapToNilStringMap(consumer.Labels), 17 | } 18 | } 19 | 20 | func PresentConsumer(consumer *api.Consumer) openapi.Consumer { 21 | reference := PresentReference(consumer.ID, consumer) 22 | return openapi.Consumer{ 23 | Id: reference.Id, 24 | Kind: reference.Kind, 25 | Href: reference.Href, 26 | Name: openapi.PtrString(consumer.Name), 27 | Labels: consumer.Labels.ToMap(), 28 | CreatedAt: openapi.PtrTime(consumer.CreatedAt), 29 | UpdatedAt: openapi.PtrTime(consumer.UpdatedAt), 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/presenters/error.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/api/openapi" 5 | "github.com/openshift-online/maestro/pkg/errors" 6 | ) 7 | 8 | func PresentError(err *errors.ServiceError) openapi.Error { 9 | return err.AsOpenapiError("") 10 | } 11 | -------------------------------------------------------------------------------- /pkg/api/presenters/kind.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/api" 5 | "github.com/openshift-online/maestro/pkg/api/openapi" 6 | "github.com/openshift-online/maestro/pkg/errors" 7 | ) 8 | 9 | func ObjectKind(i interface{}) *string { 10 | result := "" 11 | switch i.(type) { 12 | case api.Consumer, *api.Consumer: 13 | result = "Consumer" 14 | case api.ConsumerList, *api.ConsumerList, []api.Consumer, []*api.Consumer: 15 | result = "ConsumerList" 16 | case api.Resource, *api.Resource: 17 | result = "ResourceBundle" 18 | case api.ResourceList, *api.ResourceList, []api.Resource, []*api.Resource: 19 | result = "ResourceBundleList" 20 | case errors.ServiceError, *errors.ServiceError: 21 | result = "Error" 22 | } 23 | 24 | return openapi.PtrString(result) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/api/presenters/object_reference.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/api/openapi" 5 | ) 6 | 7 | func PresentReference(id, obj interface{}) openapi.ObjectReference { 8 | refId, ok := makeReferenceId(id) 9 | 10 | if !ok { 11 | return openapi.ObjectReference{} 12 | } 13 | 14 | return openapi.ObjectReference{ 15 | Id: openapi.PtrString(refId), 16 | Kind: ObjectKind(obj), 17 | Href: ObjectPath(refId, obj), 18 | } 19 | } 20 | 21 | func makeReferenceId(id interface{}) (string, bool) { 22 | var refId string 23 | 24 | if i, ok := id.(string); ok { 25 | refId = i 26 | } 27 | 28 | if i, ok := id.(*string); ok { 29 | if i != nil { 30 | refId = *i 31 | } 32 | } 33 | 34 | return refId, refId != "" 35 | } 36 | -------------------------------------------------------------------------------- /pkg/api/presenters/path.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/openshift-online/maestro/pkg/api/openapi" 7 | 8 | "github.com/openshift-online/maestro/pkg/api" 9 | "github.com/openshift-online/maestro/pkg/errors" 10 | ) 11 | 12 | const ( 13 | BasePath = "/api/maestro/v1" 14 | ) 15 | 16 | func ObjectPath(id string, obj interface{}) *string { 17 | return openapi.PtrString(fmt.Sprintf("%s/%s/%s", BasePath, path(obj), id)) 18 | } 19 | 20 | func path(i interface{}) string { 21 | switch i.(type) { 22 | case api.Resource, *api.Resource: 23 | return "resource-bundles" 24 | case api.Consumer, *api.Consumer: 25 | return "consumers" 26 | case errors.ServiceError, *errors.ServiceError: 27 | return "errors" 28 | default: 29 | return "" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/presenters/resource_bundle.go: -------------------------------------------------------------------------------- 1 | package presenters 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/api" 5 | "github.com/openshift-online/maestro/pkg/api/openapi" 6 | ) 7 | 8 | // PresentResourceBundle converts a resource from the API to the openapi representation. 9 | func PresentResourceBundle(resource *api.Resource) (*openapi.ResourceBundle, error) { 10 | manifestWrapper, err := api.DecodeManifestBundle(resource.Payload) 11 | if err != nil { 12 | return nil, err 13 | } 14 | status, err := api.DecodeBundleStatus(resource.Status) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | reference := PresentReference(resource.ID, resource) 20 | rb := &openapi.ResourceBundle{ 21 | Id: reference.Id, 22 | Kind: reference.Kind, 23 | Href: reference.Href, 24 | Name: openapi.PtrString(resource.Name), 25 | ConsumerName: openapi.PtrString(resource.ConsumerName), 26 | Version: openapi.PtrInt32(resource.Version), 27 | CreatedAt: openapi.PtrTime(resource.CreatedAt), 28 | UpdatedAt: openapi.PtrTime(resource.UpdatedAt), 29 | Status: status, 30 | } 31 | 32 | if manifestWrapper != nil { 33 | rb.Metadata = manifestWrapper.Meta 34 | rb.Manifests = manifestWrapper.Manifests 35 | rb.ManifestConfigs = manifestWrapper.ManifestConfigs 36 | rb.DeleteOption = manifestWrapper.DeleteOption 37 | } 38 | 39 | // set the deletedAt field if the resource has been marked as deleted 40 | if !resource.DeletedAt.Time.IsZero() { 41 | rb.DeletedAt = openapi.PtrTime(resource.DeletedAt.Time) 42 | } 43 | 44 | return rb, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/api/resource_id.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "github.com/google/uuid" 4 | 5 | func NewID() string { 6 | // resource id will be the k8s resource ".metadata.name", 7 | // it must be validated with following regex expression: 8 | // '[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*' 9 | // here use uuid as resource id because ksuid is not a valid k8s resource name 10 | return uuid.NewString() 11 | } 12 | -------------------------------------------------------------------------------- /pkg/api/resource_types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "strconv" 5 | 6 | "gorm.io/datatypes" 7 | "gorm.io/gorm" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ktypes "k8s.io/apimachinery/pkg/types" 10 | ) 11 | 12 | type ResourceType string 13 | 14 | type Resource struct { 15 | Meta 16 | Version int32 17 | Source string 18 | ConsumerName string 19 | Type ResourceType 20 | Payload datatypes.JSONMap 21 | Status datatypes.JSONMap 22 | // Name must be unique and not null, it can be treated as the resource external ID. 23 | // The format of the name should be follow the RFC 1123 (same as the k8s namespace). 24 | // When creating a resource, if its name is not specified, the resource id will be used as its name. 25 | // Cannot be updated. 26 | Name string 27 | } 28 | 29 | type ResourceList []*Resource 30 | type ResourceIndex map[string]*Resource 31 | 32 | func (l ResourceList) Index() ResourceIndex { 33 | index := ResourceIndex{} 34 | for _, o := range l { 35 | index[o.ID] = o 36 | } 37 | return index 38 | } 39 | 40 | func (d *Resource) BeforeCreate(tx *gorm.DB) error { 41 | // generate a new ID if it doesn't exist 42 | if d.ID == "" { 43 | d.ID = NewID() 44 | } 45 | if d.Name == "" { 46 | d.Name = d.ID 47 | } 48 | // start the resource version from 1 49 | if d.Version == 0 { 50 | d.Version = 1 51 | } 52 | return nil 53 | } 54 | 55 | func (d *Resource) GetUID() ktypes.UID { 56 | return ktypes.UID(d.Meta.ID) 57 | } 58 | 59 | func (d *Resource) GetResourceVersion() string { 60 | return strconv.FormatInt(int64(d.Version), 10) 61 | } 62 | 63 | func (d *Resource) GetDeletionTimestamp() *metav1.Time { 64 | return &metav1.Time{Time: d.Meta.DeletedAt.Time} 65 | } 66 | -------------------------------------------------------------------------------- /pkg/api/server_instance.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "time" 4 | 5 | // ServerInstance is employed by Maestro to discover active server instances. The updatedAt field 6 | // determines the liveness of the instance; if the instance remains unchanged for three consecutive 7 | // check intervals (30 seconds by default), it is marked as dead. 8 | // However, it is not meant for direct exposure to end users through the API. 9 | type ServerInstance struct { 10 | Meta 11 | LastHeartbeat time.Time // LastHeartbeat indicates the last time the instance sent a heartbeat. 12 | Ready bool // Ready indicates whether the instance is ready to serve requests. 13 | } 14 | 15 | type ServerInstanceList []*ServerInstance 16 | 17 | // String returns the identifier of the maestro instance. 18 | func (i *ServerInstance) String() string { 19 | return i.ID 20 | } 21 | -------------------------------------------------------------------------------- /pkg/api/status_event.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/datatypes" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | type StatusEventType string 11 | 12 | const ( 13 | StatusUpdateEventType StatusEventType = "StatusUpdate" 14 | StatusDeleteEventType StatusEventType = "StatusDelete" 15 | ) 16 | 17 | type StatusEvent struct { 18 | Meta 19 | ResourceID string 20 | ResourceSource string 21 | ResourceType ResourceType 22 | Payload datatypes.JSONMap 23 | Status datatypes.JSONMap 24 | StatusEventType StatusEventType // Update|Delete 25 | ReconciledDate *time.Time `json:"gorm:null"` 26 | } 27 | 28 | type StatusEventList []*StatusEvent 29 | type StatusEventIndex map[string]*StatusEvent 30 | 31 | func (l StatusEventList) Index() StatusEventIndex { 32 | index := StatusEventIndex{} 33 | for _, o := range l { 34 | index[o.ID] = o 35 | } 36 | return index 37 | } 38 | 39 | func (e *StatusEvent) BeforeCreate(tx *gorm.DB) error { 40 | e.ID = NewID() 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /pkg/auth/auth_middleware.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/getsentry/sentry-go" 8 | 9 | "github.com/openshift-online/maestro/pkg/errors" 10 | ) 11 | 12 | type JWTMiddleware interface { 13 | AuthenticateAccountJWT(next http.Handler) http.Handler 14 | } 15 | 16 | type AuthMiddleware struct{} 17 | 18 | var _ JWTMiddleware = &AuthMiddleware{} 19 | 20 | func NewAuthMiddleware() (*AuthMiddleware, error) { 21 | middleware := AuthMiddleware{} 22 | return &middleware, nil 23 | } 24 | 25 | // Middleware handler to validate JWT tokens and authenticate users 26 | func (a *AuthMiddleware) AuthenticateAccountJWT(next http.Handler) http.Handler { 27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | ctx := r.Context() 29 | payload, err := GetAuthPayload(r) 30 | if err != nil { 31 | handleError(ctx, w, errors.ErrorUnauthorized, fmt.Sprintf("Unable to get payload details from JWT token: %s", err)) 32 | return 33 | } 34 | 35 | // Append the username to the request context 36 | ctx = SetUsernameContext(ctx, payload.Username) 37 | *r = *r.WithContext(ctx) 38 | 39 | // Add username to sentry context 40 | if hub := sentry.GetHubFromContext(ctx); hub != nil { 41 | hub.ConfigureScope(func(scope *sentry.Scope) { 42 | scope.SetUser(sentry.User{ID: payload.Username}) 43 | }) 44 | } 45 | next.ServeHTTP(w, r) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/auth/auth_middleware_mock.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type AuthMiddlewareMock struct{} 8 | 9 | var _ JWTMiddleware = &AuthMiddlewareMock{} 10 | 11 | func (a *AuthMiddlewareMock) AuthenticateAccountJWT(next http.Handler) http.Handler { 12 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 13 | // TODO need to append a username to the request context 14 | next.ServeHTTP(w, r) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/auth/authz_middleware.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | /* 4 | The goal of this simple authz middlewre is to provide a way for access review 5 | parameters to be declared for each route in a microservice. This is not meant 6 | to handle more complex access review calls in particular scopes, but rather 7 | just authz calls at the application scope 8 | 9 | This is a big TODO, not ready for consumption 10 | */ 11 | 12 | import ( 13 | "fmt" 14 | "net/http" 15 | 16 | "github.com/openshift-online/maestro/pkg/client/ocm" 17 | ) 18 | 19 | type AuthorizationMiddleware interface { 20 | AuthorizeApi(next http.Handler) http.Handler 21 | } 22 | 23 | type authzMiddleware struct { 24 | action string 25 | resourceType string 26 | 27 | ocmClient *ocm.Client 28 | } 29 | 30 | var _ AuthorizationMiddleware = &authzMiddleware{} 31 | 32 | func NewAuthzMiddleware(ocmClient *ocm.Client, action, resourceType string) AuthorizationMiddleware { 33 | return &authzMiddleware{ 34 | ocmClient: ocmClient, 35 | action: action, 36 | resourceType: resourceType, 37 | } 38 | } 39 | 40 | func (a authzMiddleware) AuthorizeApi(next http.Handler) http.Handler { 41 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 | ctx := r.Context() 43 | 44 | // Get username from context 45 | username := GetUsernameFromContext(ctx) 46 | if username == "" { 47 | _ = fmt.Errorf("Authenticated username not present in request context") 48 | // TODO 49 | //body := api.E500.Format(r, "Authentication details not present in context") 50 | //api.SendError(w, r, &body) 51 | return 52 | } 53 | 54 | allowed, err := a.ocmClient.Authorization.AccessReview( 55 | ctx, username, a.action, a.resourceType, "", "", "") 56 | if err != nil { 57 | _ = fmt.Errorf("Unable to make authorization request: %s", err) 58 | // TODO 59 | //body := api.E500.Format(r, "Unable to make authorization request") 60 | //api.SendError(w, r, &body) 61 | return 62 | } 63 | 64 | if allowed { 65 | next.ServeHTTP(w, r) 66 | } 67 | 68 | // TODO 69 | //body := api.E403.Format(r, "") 70 | //api.SendError(w, r, &body) 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/auth/authz_middleware_mock.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type authzMiddlewareMock struct{} 8 | 9 | var _ AuthorizationMiddleware = &authzMiddlewareMock{} 10 | 11 | func NewAuthzMiddlewareMock() AuthorizationMiddleware { 12 | return &authzMiddlewareMock{} 13 | } 14 | 15 | func (a authzMiddlewareMock) AuthorizeApi(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | next.ServeHTTP(w, r) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/auth/helpers.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/openshift-online/maestro/pkg/errors" 9 | "github.com/openshift-online/maestro/pkg/logger" 10 | ) 11 | 12 | var log = logger.GetLogger() 13 | 14 | func handleError(ctx context.Context, w http.ResponseWriter, code errors.ServiceErrorCode, reason string) { 15 | operationID := logger.GetOperationID(ctx) 16 | err := errors.New(code, reason) 17 | if err.HttpCode >= 400 && err.HttpCode <= 499 { 18 | log.Infof(err.Error()) 19 | } else { 20 | log.Error(err.Error()) 21 | } 22 | 23 | writeJSONResponse(w, err.HttpCode, err.AsOpenapiError(operationID)) 24 | } 25 | 26 | func writeJSONResponse(w http.ResponseWriter, code int, payload interface{}) { 27 | w.Header().Set("Content-Type", "application/json") 28 | w.WriteHeader(code) 29 | 30 | if payload != nil { 31 | response, _ := json.Marshal(payload) 32 | _, _ = w.Write(response) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/client/cloudevents/grpcsource/mock/maestro.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/openshift-online/maestro/pkg/api/openapi" 11 | ) 12 | 13 | type ResourceBundlesStore struct { 14 | items []openapi.ResourceBundle 15 | } 16 | 17 | func (g *ResourceBundlesStore) Get() []openapi.ResourceBundle { 18 | return g.items 19 | } 20 | 21 | func (g *ResourceBundlesStore) Set(items []openapi.ResourceBundle) { 22 | g.items = items 23 | } 24 | 25 | type MaestroMockServer struct { 26 | server *httptest.Server 27 | } 28 | 29 | func NewMaestroMockServer(store *ResourceBundlesStore) *MaestroMockServer { 30 | mockServer := &MaestroMockServer{} 31 | 32 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | switch r.Method { 34 | case http.MethodGet: 35 | list := &openapi.ResourceBundleList{} 36 | page, _ := strconv.Atoi(r.URL.Query().Get("page")) 37 | size, _ := strconv.Atoi(r.URL.Query().Get("size")) 38 | 39 | items := store.Get() 40 | index := ((page - 1) * size) 41 | for i := 0; i < size; i++ { 42 | if index >= len(items) { 43 | break 44 | } 45 | list.Items = append(list.Items, items[index]) 46 | index = index + 1 47 | } 48 | 49 | list.Page = int32(page) 50 | list.Total = int32(len(items)) 51 | list.Size = int32(len(list.Items)) 52 | data, _ := json.Marshal(list) 53 | w.Header().Set("Content-Type", "application/json") 54 | w.WriteHeader(http.StatusOK) 55 | _, _ = w.Write(data) 56 | default: 57 | w.WriteHeader(http.StatusNotImplemented) 58 | } 59 | }) 60 | 61 | mockServer.server = httptest.NewUnstartedServer(handler) 62 | return mockServer 63 | } 64 | 65 | func (m *MaestroMockServer) URL() string { 66 | return m.server.URL 67 | } 68 | 69 | func (m *MaestroMockServer) Start() { 70 | m.server.Start() 71 | } 72 | 73 | func (m *MaestroMockServer) Stop() { 74 | m.server.Close() 75 | } 76 | 77 | func NewMaestroAPIClient(maestroServerAddress string) *openapi.APIClient { 78 | cfg := &openapi.Configuration{ 79 | DefaultHeader: make(map[string]string), 80 | UserAgent: "OpenAPI-Generator/1.0.0/go", 81 | Debug: false, 82 | Servers: openapi.ServerConfigurations{ 83 | { 84 | URL: maestroServerAddress, 85 | Description: "current domain", 86 | }, 87 | }, 88 | OperationServers: map[string]openapi.ServerConfigurations{}, 89 | HTTPClient: &http.Client{ 90 | Timeout: 10 * time.Second, 91 | }, 92 | } 93 | return openapi.NewAPIClient(cfg) 94 | } 95 | -------------------------------------------------------------------------------- /pkg/client/cloudevents/grpcsource/watch.go: -------------------------------------------------------------------------------- 1 | package grpcsource 2 | 3 | import ( 4 | "sync" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/labels" 8 | "k8s.io/apimachinery/pkg/watch" 9 | "k8s.io/klog/v2" 10 | workv1 "open-cluster-management.io/api/work/v1" 11 | ) 12 | 13 | // workWatcher implements the watch.Interface. 14 | type workWatcher struct { 15 | sync.RWMutex 16 | 17 | result chan watch.Event 18 | done chan struct{} 19 | stopped bool 20 | 21 | namespace string 22 | labelSelector labels.Selector 23 | } 24 | 25 | var _ watch.Interface = &workWatcher{} 26 | 27 | func newWorkWatcher(namespace string, labelSelector labels.Selector) *workWatcher { 28 | return &workWatcher{ 29 | result: make(chan watch.Event), 30 | done: make(chan struct{}), 31 | namespace: namespace, 32 | labelSelector: labelSelector, 33 | } 34 | } 35 | 36 | // ResultChan implements Interface. 37 | func (w *workWatcher) ResultChan() <-chan watch.Event { 38 | return w.result 39 | } 40 | 41 | // Stop implements Interface. 42 | func (w *workWatcher) Stop() { 43 | // Call Close() exactly once by locking and setting a flag. 44 | w.Lock() 45 | defer w.Unlock() 46 | // closing a closed channel always panics, therefore check before closing 47 | select { 48 | case <-w.done: 49 | close(w.result) 50 | default: 51 | w.stopped = true 52 | close(w.done) 53 | } 54 | } 55 | 56 | // Receive an event and sends down the result channel. 57 | func (w *workWatcher) Receive(evt watch.Event) { 58 | if w.isStopped() { 59 | // this watcher is stopped, do nothing. 60 | return 61 | } 62 | 63 | work, ok := evt.Object.(*workv1.ManifestWork) 64 | if !ok { 65 | klog.Errorf("unknown event object type %T", evt.Object) 66 | return 67 | } 68 | 69 | if w.namespace != metav1.NamespaceAll && w.namespace != work.Namespace { 70 | klog.V(4).Infof("ignore the work %s/%s for the watcher %s", work.Namespace, work.Name, w.namespace) 71 | return 72 | } 73 | 74 | if !w.labelSelector.Matches(labels.Set(work.GetLabels())) { 75 | klog.V(4).Infof("ignore the label unmatched work %s/%s for the watcher %s", work.Namespace, work.Name, w.namespace) 76 | return 77 | } 78 | 79 | w.result <- evt 80 | } 81 | 82 | func (w *workWatcher) isStopped() bool { 83 | w.RLock() 84 | defer w.RUnlock() 85 | 86 | return w.stopped 87 | } 88 | -------------------------------------------------------------------------------- /pkg/client/grpcauthorizer/interface.go: -------------------------------------------------------------------------------- 1 | package grpcauthorizer 2 | 3 | import "context" 4 | 5 | // GRPCAuthorizer defines an interface for performing access reviews in a gRPC-based authorization. 6 | type GRPCAuthorizer interface { 7 | // TokenReview validates the given token and returns the user and groups associated with it. 8 | // 9 | // Parameters: 10 | // - ctx: The context for managing request lifecycle. 11 | // - token: The token to validate. 12 | // 13 | // Returns: 14 | // - user: The user associated with the token. 15 | // - groups: The groups associated with the token. 16 | // - err: Any error encountered during the review process. 17 | TokenReview(ctx context.Context, token string) (user string, groups []string, err error) 18 | // AccessReview checks if the specified user or groups has permission to perform a given action on a specified resource. 19 | // 20 | // Parameters: 21 | // - ctx: The context for managing request lifecycle. 22 | // - action: The action being requested, e.g., "pub" (publish) or "sub" (subscribe). 23 | // - resourceType: The type of resource, e.g., "source" or "cluster". 24 | // - resource: The specific resource name within the given resource type. 25 | // - user: The user requesting the action (may be empty if groups are used). 26 | // - groups: The groups requesting the action (may be empty if user is used). 27 | // 28 | // Returns: 29 | // - allowed: True if access is granted, false otherwise. 30 | // - err: Any error encountered during the review process. 31 | AccessReview(ctx context.Context, action, resourceType, resource, user string, groups []string) (allowed bool, err error) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/client/grpcauthorizer/mock_authorizer.go: -------------------------------------------------------------------------------- 1 | package grpcauthorizer 2 | 3 | import "context" 4 | 5 | // MockGRPCAuthorizer returns allowed=true for every request 6 | type MockGRPCAuthorizer struct { 7 | } 8 | 9 | func NewMockGRPCAuthorizer() GRPCAuthorizer { 10 | return &MockGRPCAuthorizer{} 11 | } 12 | 13 | var _ GRPCAuthorizer = &MockGRPCAuthorizer{} 14 | 15 | // TokenReview returns an empty user and groups 16 | func (m *MockGRPCAuthorizer) TokenReview(ctx context.Context, token string) (user string, groups []string, err error) { 17 | return "", []string{}, nil 18 | } 19 | 20 | // SelfAccessReview returns allowed=true for every request 21 | func (m *MockGRPCAuthorizer) AccessReview(ctx context.Context, action, resourceType, resource, user string, groups []string) (allowed bool, err error) { 22 | return true, nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/client/ocm/authorization.go: -------------------------------------------------------------------------------- 1 | package ocm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | azv1 "github.com/openshift-online/ocm-sdk-go/authorizations/v1" 8 | ) 9 | 10 | type OCMAuthorization interface { 11 | SelfAccessReview(ctx context.Context, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) 12 | AccessReview(ctx context.Context, username, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) 13 | } 14 | 15 | type authorization service 16 | 17 | var _ OCMAuthorization = &authorization{} 18 | 19 | func (a authorization) SelfAccessReview(ctx context.Context, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) { 20 | con := a.client.connection 21 | selfAccessReview := con.Authorizations().V1().SelfAccessReview() 22 | 23 | request, err := azv1.NewSelfAccessReviewRequest(). 24 | Action(action). 25 | ResourceType(resourceType). 26 | OrganizationID(organizationID). 27 | ClusterID(clusterID). 28 | SubscriptionID(subscriptionID). 29 | Build() 30 | if err != nil { 31 | return false, err 32 | } 33 | 34 | postResp, err := selfAccessReview.Post(). 35 | Request(request). 36 | SendContext(ctx) 37 | if err != nil { 38 | return false, err 39 | } 40 | response, ok := postResp.GetResponse() 41 | if !ok { 42 | return false, fmt.Errorf("Empty response from authorization post request") 43 | } 44 | 45 | return response.Allowed(), nil 46 | } 47 | 48 | func (a authorization) AccessReview(ctx context.Context, username, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) { 49 | con := a.client.connection 50 | accessReview := con.Authorizations().V1().AccessReview() 51 | 52 | request, err := azv1.NewAccessReviewRequest(). 53 | AccountUsername(username). 54 | Action(action). 55 | ResourceType(resourceType). 56 | OrganizationID(organizationID). 57 | ClusterID(clusterID). 58 | SubscriptionID(subscriptionID). 59 | Build() 60 | if err != nil { 61 | return false, err 62 | } 63 | 64 | postResp, err := accessReview.Post(). 65 | Request(request). 66 | SendContext(ctx) 67 | if err != nil { 68 | return false, err 69 | } 70 | 71 | response, ok := postResp.GetResponse() 72 | if !ok { 73 | return false, fmt.Errorf("Empty response from authorization post request") 74 | } 75 | 76 | return response.Allowed(), nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/client/ocm/authorization_mock.go: -------------------------------------------------------------------------------- 1 | package ocm 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // authorizationMock returns allowed=true for every request 8 | type authorizationMock service 9 | 10 | var _ OCMAuthorization = &authorizationMock{} 11 | 12 | func (a authorizationMock) SelfAccessReview(ctx context.Context, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) { 13 | return true, nil 14 | } 15 | 16 | func (a authorizationMock) AccessReview(ctx context.Context, username, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) { 17 | return true, nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/client/ocm/client.go: -------------------------------------------------------------------------------- 1 | package ocm 2 | 3 | import ( 4 | "fmt" 5 | 6 | sdkClient "github.com/openshift-online/ocm-sdk-go" 7 | ) 8 | 9 | type Client struct { 10 | config *Config 11 | logger sdkClient.Logger 12 | connection *sdkClient.Connection 13 | 14 | Authorization OCMAuthorization 15 | } 16 | 17 | type Config struct { 18 | BaseURL string 19 | ClientID string 20 | ClientSecret string 21 | SelfToken string 22 | TokenURL string 23 | Debug bool 24 | } 25 | 26 | func NewClient(config Config) (*Client, error) { 27 | // Create a logger that has the debug level enabled: 28 | logger, err := sdkClient.NewGoLoggerBuilder(). 29 | Debug(config.Debug). 30 | Build() 31 | if err != nil { 32 | return nil, fmt.Errorf("Unable to build OCM logger: %s", err.Error()) 33 | } 34 | 35 | client := &Client{ 36 | config: &config, 37 | logger: logger, 38 | } 39 | err = client.newConnection() 40 | if err != nil { 41 | return nil, fmt.Errorf("Unable to build OCM connection: %s", err.Error()) 42 | } 43 | client.Authorization = &authorization{client: client} 44 | return client, nil 45 | } 46 | 47 | func NewClientMock(config Config) (*Client, error) { 48 | client := &Client{ 49 | config: &config, 50 | } 51 | client.Authorization = &authorizationMock{client: client} 52 | return client, nil 53 | } 54 | 55 | func (c *Client) newConnection() error { 56 | builder := sdkClient.NewConnectionBuilder(). 57 | Logger(c.logger). 58 | URL(c.config.BaseURL). 59 | MetricsSubsystem("api_outbound") 60 | 61 | if c.config.ClientID != "" && c.config.ClientSecret != "" { 62 | builder = builder.Client(c.config.ClientID, c.config.ClientSecret) 63 | } else if c.config.SelfToken != "" { 64 | builder = builder.Tokens(c.config.SelfToken) 65 | } else { 66 | return fmt.Errorf("Can't build OCM client connection. No Client/Secret or Token has been provided.") 67 | } 68 | 69 | connection, err := builder.Build() 70 | 71 | if err != nil { 72 | return fmt.Errorf("Can't build OCM client connection: %s", err.Error()) 73 | } 74 | c.connection = connection 75 | return nil 76 | } 77 | 78 | func (c *Client) Close() { 79 | if c.connection != nil { 80 | c.connection.Close() 81 | } 82 | } 83 | 84 | type service struct { 85 | client *Client 86 | } 87 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestConfigReadStringFile(t *testing.T) { 11 | RegisterTestingT(t) 12 | 13 | stringFile, err := createConfigFile("string", "example\n") 14 | defer os.Remove(stringFile.Name()) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | var stringConfig string 20 | err = readFileValueString(stringFile.Name(), &stringConfig) 21 | Expect(err).NotTo(HaveOccurred()) 22 | Expect(stringConfig).To(Equal("example")) 23 | } 24 | 25 | func TestConfigReadIntFile(t *testing.T) { 26 | RegisterTestingT(t) 27 | 28 | intFile, err := createConfigFile("int", "123") 29 | defer os.Remove(intFile.Name()) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | var intConfig int 35 | err = readFileValueInt(intFile.Name(), &intConfig) 36 | Expect(err).NotTo(HaveOccurred()) 37 | Expect(intConfig).To(Equal(123)) 38 | } 39 | 40 | func TestConfigReadBoolFile(t *testing.T) { 41 | RegisterTestingT(t) 42 | 43 | boolFile, err := createConfigFile("bool", "true") 44 | defer os.Remove(boolFile.Name()) 45 | if err != nil { 46 | t.Fatal(err) 47 | } 48 | 49 | var boolConfig bool = false 50 | err = readFileValueBool(boolFile.Name(), &boolConfig) 51 | Expect(err).NotTo(HaveOccurred()) 52 | Expect(boolConfig).To(Equal(true)) 53 | } 54 | 55 | func TestConfigReadQuotedFile(t *testing.T) { 56 | RegisterTestingT(t) 57 | 58 | stringFile, err := createConfigFile("string", "example") 59 | defer os.Remove(stringFile.Name()) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | quotedFileName := "\"" + stringFile.Name() + "\"" 65 | val, err := ReadFile(quotedFileName) 66 | Expect(err).NotTo(HaveOccurred()) 67 | Expect(val).To(Equal("example")) 68 | } 69 | func createConfigFile(namePrefix, contents string) (*os.File, error) { 70 | configFile, err := os.CreateTemp("", namePrefix) 71 | if err != nil { 72 | return nil, err 73 | } 74 | if _, err = configFile.Write([]byte(contents)); err != nil { 75 | return configFile, err 76 | } 77 | err = configFile.Close() 78 | return configFile, err 79 | } 80 | -------------------------------------------------------------------------------- /pkg/config/event_server_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/spf13/pflag" 8 | ) 9 | 10 | func TestEventServerConfig(t *testing.T) { 11 | cases := []struct { 12 | name string 13 | input map[string]string 14 | want *EventServerConfig 15 | }{ 16 | { 17 | name: "default subscription type", 18 | input: map[string]string{}, 19 | want: &EventServerConfig{ 20 | SubscriptionType: "shared", 21 | ConsistentHashConfig: &ConsistentHashConfig{ 22 | PartitionCount: 7, 23 | ReplicationFactor: 20, 24 | Load: 1.25, 25 | }, 26 | }, 27 | }, 28 | { 29 | name: "broadcast subscription type", 30 | input: map[string]string{ 31 | "subscription-type": "broadcast", 32 | }, 33 | want: &EventServerConfig{ 34 | SubscriptionType: "broadcast", 35 | ConsistentHashConfig: &ConsistentHashConfig{ 36 | PartitionCount: 7, 37 | ReplicationFactor: 20, 38 | Load: 1.25, 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "custom consistent hash config", 44 | input: map[string]string{ 45 | "subscription-type": "broadcast", 46 | "consistent-hash-partition-count": "10", 47 | "consistent-hash-replication-factor": "30", 48 | "consistent-hash-load": "1.5", 49 | }, 50 | want: &EventServerConfig{ 51 | SubscriptionType: "broadcast", 52 | ConsistentHashConfig: &ConsistentHashConfig{ 53 | PartitionCount: 10, 54 | ReplicationFactor: 30, 55 | Load: 1.5, 56 | }, 57 | }, 58 | }, 59 | } 60 | 61 | config := NewEventServerConfig() 62 | pflag.NewFlagSet("test", pflag.ContinueOnError) 63 | fs := pflag.CommandLine 64 | config.AddFlags(fs) 65 | for _, tc := range cases { 66 | t.Run(tc.name, func(t *testing.T) { 67 | // set flags 68 | for key, value := range tc.input { 69 | fs.Set(key, value) 70 | } 71 | if !reflect.DeepEqual(config, tc.want) { 72 | t.Errorf("NewEventServerConfig() = %v; want %v", config, tc.want) 73 | } 74 | // clear flags 75 | fs.VisitAll(func(f *pflag.Flag) { 76 | fs.Lookup(f.Name).Changed = false 77 | }) 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/config/health_check.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/pflag" 5 | ) 6 | 7 | type HealthCheckConfig struct { 8 | BindPort string `json:"bind_port"` 9 | EnableHTTPS bool `json:"enable_https"` 10 | HeartbeartInterval int `json:"heartbeat_interval"` 11 | } 12 | 13 | func NewHealthCheckConfig() *HealthCheckConfig { 14 | return &HealthCheckConfig{ 15 | BindPort: "8083", 16 | EnableHTTPS: false, 17 | HeartbeartInterval: 15, 18 | } 19 | } 20 | 21 | func (c *HealthCheckConfig) AddFlags(fs *pflag.FlagSet) { 22 | fs.StringVar(&c.BindPort, "health-check-server-bindport", c.BindPort, "Health check server bind port") 23 | fs.BoolVar(&c.EnableHTTPS, "enable-health-check-https", c.EnableHTTPS, "Enable HTTPS for health check server") 24 | fs.IntVar(&c.HeartbeartInterval, "heartbeat-interval", c.HeartbeartInterval, "Heartbeat interval for health check server") 25 | } 26 | 27 | func (c *HealthCheckConfig) ReadFiles() error { 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /pkg/config/message_broker.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type MessageBrokerConfig struct { 10 | EnableMock bool `json:"enable_message_broker_mock"` 11 | SourceID string `json:"source_id"` 12 | ClientID string `json:"client_id"` 13 | MessageBrokerType string `json:"message_broker_type"` 14 | MessageBrokerConfig string `json:"message_broker_file"` 15 | } 16 | 17 | func NewMessageBrokerConfig() *MessageBrokerConfig { 18 | return &MessageBrokerConfig{ 19 | EnableMock: false, 20 | SourceID: "maestro", 21 | ClientID: "maestro", 22 | MessageBrokerType: "mqtt", 23 | MessageBrokerConfig: filepath.Join(GetProjectRootDir(), "secrets/mqtt.config"), 24 | } 25 | } 26 | 27 | func (c *MessageBrokerConfig) AddFlags(fs *pflag.FlagSet) { 28 | fs.BoolVar(&c.EnableMock, "enable-message-broker-mock", c.EnableMock, "Enable message broker mock") 29 | fs.StringVar(&c.SourceID, "source-id", c.SourceID, "Source ID") 30 | fs.StringVar(&c.ClientID, "client-id", c.ClientID, "Client ID") 31 | fs.StringVar(&c.MessageBrokerType, "message-broker-type", c.MessageBrokerType, "Message broker type ('grpc' or 'mqtt'). Default is 'mqtt'.") 32 | fs.StringVar(&c.MessageBrokerConfig, "message-broker-config-file", c.MessageBrokerConfig, "The config file path of message broker") 33 | } 34 | -------------------------------------------------------------------------------- /pkg/config/metrics.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type MetricsConfig struct { 10 | BindPort string `json:"bind_port"` 11 | EnableHTTPS bool `json:"enable_https"` 12 | LabelMetricsInclusionDuration time.Duration `json:"label_metrics_inclusion_duration"` 13 | } 14 | 15 | func NewMetricsConfig() *MetricsConfig { 16 | return &MetricsConfig{ 17 | BindPort: "8080", 18 | EnableHTTPS: false, 19 | LabelMetricsInclusionDuration: 7 * 24 * time.Hour, 20 | } 21 | } 22 | 23 | func (s *MetricsConfig) AddFlags(fs *pflag.FlagSet) { 24 | fs.StringVar(&s.BindPort, "metrics-server-bindport", s.BindPort, "Metrics server bind port") 25 | fs.BoolVar(&s.EnableHTTPS, "enable-metrics-https", s.EnableHTTPS, "Enable HTTPS for metrics server") 26 | fs.DurationVar(&s.LabelMetricsInclusionDuration, "label-metrics-inclusion-duration", 7*24*time.Hour, "A cluster's last telemetry date needs be within in this duration in order to have labels collected") 27 | } 28 | 29 | func (s *MetricsConfig) ReadFiles() error { 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/config/ocm.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/pflag" 5 | ) 6 | 7 | type OCMConfig struct { 8 | BaseURL string `json:"base_url"` 9 | ClientID string `json:"client-id"` 10 | ClientIDFile string `json:"client-id_file"` 11 | ClientSecret string `json:"client-secret"` 12 | ClientSecretFile string `json:"client-secret_file"` 13 | SelfToken string `json:"self_token"` 14 | SelfTokenFile string `json:"self_token_file"` 15 | TokenURL string `json:"token_url"` 16 | Debug bool `json:"debug"` 17 | EnableMock bool `json:"enable_mock"` 18 | } 19 | 20 | func NewOCMConfig() *OCMConfig { 21 | return &OCMConfig{ 22 | BaseURL: "https://api.integration.openshift.com", 23 | TokenURL: "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token", 24 | ClientIDFile: "secrets/ocm-service.clientId", 25 | ClientSecretFile: "secrets/ocm-service.clientSecret", 26 | SelfTokenFile: "", 27 | Debug: false, 28 | EnableMock: true, 29 | } 30 | } 31 | 32 | func (c *OCMConfig) AddFlags(fs *pflag.FlagSet) { 33 | fs.StringVar(&c.ClientIDFile, "ocm-client-id-file", c.ClientIDFile, "File containing OCM API privileged account client-id") 34 | fs.StringVar(&c.ClientSecretFile, "ocm-client-secret-file", c.ClientSecretFile, "File containing OCM API privileged account client-secret") 35 | fs.StringVar(&c.SelfTokenFile, "self-token-file", c.SelfTokenFile, "File containing OCM API privileged offline SSO token") 36 | fs.StringVar(&c.BaseURL, "ocm-base-url", c.BaseURL, "The base URL of the OCM API, integration by default") 37 | fs.StringVar(&c.TokenURL, "ocm-token-url", c.TokenURL, "The base URL that OCM uses to request tokens, stage by default") 38 | fs.BoolVar(&c.Debug, "ocm-debug", c.Debug, "Debug flag for OCM API") 39 | fs.BoolVar(&c.EnableMock, "enable-ocm-mock", c.EnableMock, "Enable mock ocm clients") 40 | } 41 | 42 | func (c *OCMConfig) ReadFiles() error { 43 | if c.EnableMock { 44 | return nil 45 | } 46 | err := readFileValueString(c.ClientIDFile, &c.ClientID) 47 | if err != nil { 48 | return err 49 | } 50 | err = readFileValueString(c.ClientSecretFile, &c.ClientSecret) 51 | if err != nil { 52 | return err 53 | } 54 | err = readFileValueString(c.SelfTokenFile, &c.SelfToken) 55 | return err 56 | } 57 | -------------------------------------------------------------------------------- /pkg/config/sentry.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type SentryConfig struct { 10 | Enabled bool `json:"enabled"` 11 | Key string `json:"key"` 12 | URL string `json:"url"` 13 | Project string `json:"project"` 14 | Debug bool `json:"debug"` 15 | Timeout time.Duration `json:"timeout"` 16 | 17 | KeyFile string `json:"key_file"` 18 | } 19 | 20 | func NewSentryConfig() *SentryConfig { 21 | return &SentryConfig{ 22 | Enabled: false, 23 | Key: "", 24 | URL: "glitchtip.devshift.net", 25 | Project: "53", // 16 is the ocm-service-dev project for local dev/testing 26 | Debug: false, 27 | KeyFile: "secrets/sentry.key", 28 | } 29 | } 30 | 31 | func (c *SentryConfig) AddFlags(fs *pflag.FlagSet) { 32 | fs.BoolVar(&c.Enabled, "enable-sentry", c.Enabled, "Enable sentry error monitoring") 33 | fs.StringVar(&c.KeyFile, "sentry-key-file", c.KeyFile, "File containing Sentry key") 34 | fs.StringVar(&c.URL, "sentry-url", c.URL, "Base URL of Sentry isntance") 35 | fs.StringVar(&c.Project, "sentry-project", c.Project, "Sentry project to report to") 36 | fs.BoolVar(&c.Debug, "enable-sentry-debug", c.Debug, "Enable sentry error monitoring") 37 | fs.DurationVar(&c.Timeout, "sentry-timeout", 5*time.Second, "Timeout for all requests made to Sentry") 38 | } 39 | 40 | func (c *SentryConfig) ReadFiles() error { 41 | if !c.Enabled { 42 | return nil 43 | } 44 | return readFileValueString(c.KeyFile, &c.Key) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | DefaultSourceID = "maestro" 5 | 6 | AuthMethodPassword = "password" // Standard postgres username/password authentication. 7 | AuthMethodMicrosoftEntra = "az-entra" // Microsoft Entra ID-based token authentication. 8 | 9 | // MinTokenLifeThreshold defines the minimum remaining lifetime (in seconds) of the access token before 10 | // it should be refreshed. 11 | MinTokenLifeThreshold = 60.0 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/controllers/status_controller_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBatchStatusEventIDs(t *testing.T) { 8 | const batchSize = 500 9 | 10 | cases := []struct { 11 | name string 12 | statusEventIDs []string 13 | expected [][]string 14 | }{ 15 | { 16 | name: "empty input", 17 | statusEventIDs: []string{}, 18 | expected: [][]string{}, 19 | }, 20 | { 21 | name: "single batch less than batch size", 22 | statusEventIDs: make([]string, 499), 23 | expected: [][]string{make([]string, 499)}, 24 | }, 25 | { 26 | name: "single batch equal to batch size", 27 | statusEventIDs: make([]string, batchSize), 28 | expected: [][]string{make([]string, batchSize)}, 29 | }, 30 | { 31 | name: "multiple batches full", 32 | statusEventIDs: make([]string, batchSize*2), 33 | expected: [][]string{make([]string, batchSize), make([]string, batchSize)}, 34 | }, 35 | { 36 | name: "multiple batches partial last", 37 | statusEventIDs: make([]string, batchSize+100), 38 | expected: [][]string{make([]string, batchSize), make([]string, 100)}, 39 | }, 40 | { 41 | name: "multiple batches full partial last", 42 | statusEventIDs: make([]string, batchSize*2+300), 43 | expected: [][]string{make([]string, batchSize), make([]string, batchSize), make([]string, 300)}, 44 | }, 45 | } 46 | 47 | for _, tt := range cases { 48 | t.Run(tt.name, func(t *testing.T) { 49 | result := batchStatusEventIDs(tt.statusEventIDs, batchSize) 50 | 51 | // Ensure the number of batches is correct 52 | if len(result) != len(tt.expected) { 53 | t.Errorf("number of batches mismatch, got %d, want %d", len(result), len(tt.expected)) 54 | } 55 | 56 | // Check the length of each batch 57 | for i := range result { 58 | if len(result[i]) != len(tt.expected[i]) { 59 | t.Errorf("length of batch %d mismatch, got %d, want %d", i+1, len(result[i]), len(tt.expected[i])) 60 | } 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/dao/mocks/consumer.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | 6 | "gorm.io/gorm" 7 | 8 | "github.com/openshift-online/maestro/pkg/api" 9 | "github.com/openshift-online/maestro/pkg/dao" 10 | "github.com/openshift-online/maestro/pkg/errors" 11 | ) 12 | 13 | var _ dao.ConsumerDao = &consumerDaoMock{} 14 | 15 | type consumerDaoMock struct { 16 | consumers api.ConsumerList 17 | } 18 | 19 | func NewConsumerDao() *consumerDaoMock { 20 | return &consumerDaoMock{} 21 | } 22 | 23 | func (d *consumerDaoMock) Get(ctx context.Context, id string) (*api.Consumer, error) { 24 | for _, consumer := range d.consumers { 25 | if consumer.ID == id { 26 | return consumer, nil 27 | } 28 | } 29 | return nil, gorm.ErrRecordNotFound 30 | } 31 | 32 | func (d *consumerDaoMock) Create(ctx context.Context, consumer *api.Consumer) (*api.Consumer, error) { 33 | d.consumers = append(d.consumers, consumer) 34 | return consumer, nil 35 | } 36 | 37 | func (d *consumerDaoMock) Replace(ctx context.Context, consumer *api.Consumer) (*api.Consumer, error) { 38 | return nil, errors.NotImplemented("Consumer").AsError() 39 | } 40 | 41 | func (d *consumerDaoMock) Delete(ctx context.Context, id string, unscoped bool) error { 42 | return errors.NotImplemented("Consumer").AsError() 43 | } 44 | 45 | func (d *consumerDaoMock) FindByIDs(ctx context.Context, ids []string) (api.ConsumerList, error) { 46 | return nil, errors.NotImplemented("Consumer").AsError() 47 | } 48 | 49 | func (d *consumerDaoMock) All(ctx context.Context) (api.ConsumerList, error) { 50 | return d.consumers, nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/dao/mocks/event_instance.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/openshift-online/maestro/pkg/api" 9 | "github.com/openshift-online/maestro/pkg/dao" 10 | ) 11 | 12 | var _ dao.EventInstanceDao = &eventInstanceDaoMock{} 13 | 14 | type eventInstanceDaoMock struct { 15 | mux sync.RWMutex 16 | eventInstances api.EventInstanceList 17 | } 18 | 19 | func NewEventInstanceDaoMock() *eventInstanceDaoMock { 20 | return &eventInstanceDaoMock{} 21 | } 22 | 23 | func (d *eventInstanceDaoMock) Get(ctx context.Context, eventID, instanceID string) (*api.EventInstance, error) { 24 | d.mux.RLock() 25 | defer d.mux.RUnlock() 26 | 27 | for _, ei := range d.eventInstances { 28 | if ei.EventID == eventID && ei.InstanceID == instanceID { 29 | return ei, nil 30 | } 31 | } 32 | 33 | return nil, fmt.Errorf("event instance not found") 34 | } 35 | 36 | func (d *eventInstanceDaoMock) Create(ctx context.Context, eventInstance *api.EventInstance) (*api.EventInstance, error) { 37 | d.mux.Lock() 38 | defer d.mux.Unlock() 39 | 40 | d.eventInstances = append(d.eventInstances, eventInstance) 41 | 42 | return eventInstance, nil 43 | } 44 | 45 | func (d *eventInstanceDaoMock) FindStatusEvents(ctx context.Context, ids []string) (api.EventInstanceList, error) { 46 | d.mux.RLock() 47 | defer d.mux.RUnlock() 48 | 49 | var eventInstances api.EventInstanceList 50 | for _, id := range ids { 51 | for _, ei := range d.eventInstances { 52 | if ei.EventID == id { 53 | eventInstances = append(eventInstances, ei) 54 | } 55 | } 56 | } 57 | 58 | return eventInstances, nil 59 | } 60 | 61 | func (d *eventInstanceDaoMock) GetEventsAssociatedWithInstances(ctx context.Context, instanceIDs []string) ([]string, error) { 62 | d.mux.RLock() 63 | defer d.mux.RUnlock() 64 | 65 | var eventIDs []string 66 | for _, ei := range d.eventInstances { 67 | if contains(instanceIDs, ei.InstanceID) { 68 | if ei.EventID == "" { 69 | continue 70 | } 71 | eventIDs = append(eventIDs, ei.EventID) 72 | } 73 | } 74 | 75 | return eventIDs, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/dao/mocks/resource.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/openshift-online/maestro/pkg/dao" 7 | 8 | "gorm.io/gorm" 9 | 10 | "github.com/openshift-online/maestro/pkg/api" 11 | "github.com/openshift-online/maestro/pkg/errors" 12 | ) 13 | 14 | var _ dao.ResourceDao = &resourceDaoMock{} 15 | 16 | type resourceDaoMock struct { 17 | resources api.ResourceList 18 | } 19 | 20 | func NewResourceDao() *resourceDaoMock { 21 | return &resourceDaoMock{} 22 | } 23 | 24 | func (d *resourceDaoMock) Get(ctx context.Context, id string) (*api.Resource, error) { 25 | for _, resource := range d.resources { 26 | if resource.ID == id { 27 | return resource, nil 28 | } 29 | } 30 | return nil, gorm.ErrRecordNotFound 31 | } 32 | 33 | func (d *resourceDaoMock) Create(ctx context.Context, resource *api.Resource) (*api.Resource, error) { 34 | d.resources = append(d.resources, resource) 35 | return resource, nil 36 | } 37 | 38 | func (d *resourceDaoMock) Update(ctx context.Context, resource *api.Resource) (*api.Resource, error) { 39 | return nil, errors.NotImplemented("Resource").AsError() 40 | } 41 | 42 | func (d *resourceDaoMock) Delete(ctx context.Context, id string, unscoped bool) error { 43 | return errors.NotImplemented("Resource").AsError() 44 | } 45 | 46 | func (d *resourceDaoMock) FindByIDs(ctx context.Context, ids []string) (api.ResourceList, error) { 47 | return nil, errors.NotImplemented("Resource").AsError() 48 | } 49 | 50 | func (d *resourceDaoMock) FindByConsumerName(ctx context.Context, consumerID string) (api.ResourceList, error) { 51 | var resources api.ResourceList 52 | for _, resource := range d.resources { 53 | if resource.ConsumerName == consumerID { 54 | resources = append(resources, resource) 55 | } 56 | } 57 | return resources, nil 58 | } 59 | 60 | func (d *resourceDaoMock) FindBySource(ctx context.Context, source string) (api.ResourceList, error) { 61 | var resources api.ResourceList 62 | for _, resource := range d.resources { 63 | if resource.Source == source { 64 | resources = append(resources, resource) 65 | } 66 | } 67 | return resources, nil 68 | } 69 | 70 | func (d *resourceDaoMock) All(ctx context.Context) (api.ResourceList, error) { 71 | return d.resources, nil 72 | } 73 | 74 | func (d *resourceDaoMock) FirstByConsumerName(ctx context.Context, consumerName string, unscoped bool) (api.Resource, error) { 75 | return *d.resources[0], errors.NotImplemented("Resource").AsError() 76 | } 77 | -------------------------------------------------------------------------------- /pkg/db/context.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | 6 | dbContext "github.com/openshift-online/maestro/pkg/db/db_context" 7 | ) 8 | 9 | // NewContext returns a new context with transaction stored in it. 10 | // Upon error, the original context is still returned along with an error 11 | func NewContext(ctx context.Context, connection SessionFactory) (context.Context, error) { 12 | tx, err := newTransaction(connection) 13 | if err != nil { 14 | return ctx, err 15 | } 16 | 17 | ctx = dbContext.WithTransaction(ctx, tx) 18 | 19 | return ctx, nil 20 | } 21 | 22 | // Resolve resolves the current transaction according to the rollback flag. 23 | func Resolve(ctx context.Context) { 24 | tx, ok := dbContext.Transaction(ctx) 25 | if !ok { 26 | log.Error("Could not retrieve transaction from context") 27 | return 28 | } 29 | 30 | if tx.MarkedForRollback() { 31 | if err := tx.Rollback(); err != nil { 32 | log.With("error", err.Error()).Error("Could not rollback transaction") 33 | return 34 | } 35 | log.Infof("Rolled back transaction") 36 | } else { 37 | if err := tx.Commit(); err != nil { 38 | // TODO: what does the user see when this occurs? seems like they will get a false positive 39 | log.With("error", err.Error()).Error("Could not commit transaction") 40 | return 41 | } 42 | } 43 | } 44 | 45 | // MarkForRollback flags the transaction stored in the context for rollback and logs whatever error caused the rollback 46 | func MarkForRollback(ctx context.Context, err error) { 47 | transaction, ok := dbContext.Transaction(ctx) 48 | if !ok { 49 | log.Error("failed to mark transaction for rollback: could not retrieve transaction from context") 50 | return 51 | } 52 | transaction.SetRollbackFlag(true) 53 | log.Infof("Marked transaction for rollback, err: %v", err) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/db/db_context/db_context.go: -------------------------------------------------------------------------------- 1 | // Package db_context dbContext provides a wrapper around db context handling to allow access to the db context without 2 | // requiring importing the db package, thus avoiding cyclic imports 3 | package db_context 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/openshift-online/maestro/pkg/db/transaction" 9 | ) 10 | 11 | type contextKey int 12 | 13 | const ( 14 | transactionKey contextKey = iota 15 | ) 16 | 17 | // WithTransaction adds the transaction to the context and returns a new context 18 | func WithTransaction(ctx context.Context, tx *transaction.Transaction) context.Context { 19 | return context.WithValue(ctx, transactionKey, tx) 20 | } 21 | 22 | // Transaction extracts the transaction value from the context 23 | func Transaction(ctx context.Context) (tx *transaction.Transaction, ok bool) { 24 | tx, ok = ctx.Value(transactionKey).(*transaction.Transaction) 25 | return tx, ok 26 | } 27 | 28 | // Return the transaction ID from the context, if it exists. If there is no transaction, ok is false. 29 | func TxID(ctx context.Context) (id int64, ok bool) { 30 | tx, ok := Transaction(ctx) 31 | if !ok { 32 | return 0, false 33 | } 34 | return tx.TxID(), true 35 | } 36 | -------------------------------------------------------------------------------- /pkg/db/db_session/db_session.go: -------------------------------------------------------------------------------- 1 | package db_session 2 | 3 | import "sync" 4 | 5 | const ( 6 | disable = "disable" 7 | ) 8 | 9 | var once sync.Once 10 | -------------------------------------------------------------------------------- /pkg/db/migrations.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | "github.com/openshift-online/maestro/pkg/db/migrations" 8 | "github.com/openshift-online/maestro/pkg/logger" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | var log = logger.GetLogger() 14 | 15 | // gormigrate is a wrapper for gorm's migration functions that adds schema versioning and rollback capabilities. 16 | // For help writing migration steps, see the gorm documentation on migrations: http://doc.gorm.io/database.html#migration 17 | 18 | func Migrate(g2 *gorm.DB) error { 19 | if err := migrations.CleanUpDirtyData(g2); err != nil { 20 | return err 21 | } 22 | 23 | m := newGormigrate(g2) 24 | 25 | if err := m.Migrate(); err != nil { 26 | return err 27 | } 28 | return nil 29 | } 30 | 31 | // MigrateTo a specific migration will not seed the database, seeds are up to date with the latest 32 | // schema based on the most recent migration 33 | // This should be for testing purposes mainly 34 | func MigrateTo(sessionFactory SessionFactory, migrationID string) { 35 | g2 := sessionFactory.New(context.Background()) 36 | m := newGormigrate(g2) 37 | 38 | if err := m.MigrateTo(migrationID); err != nil { 39 | log.Fatalf("Could not migrate: %v", err) 40 | } 41 | } 42 | 43 | func newGormigrate(g2 *gorm.DB) *gormigrate.Gormigrate { 44 | return gormigrate.New(g2, gormigrate.DefaultOptions, migrations.MigrationList) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/db/migrations/201911212019_add_dinosaurs.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | // Migrations should NEVER use types from other packages. Types can change 4 | // and then migrations run on a _new_ database will fail or behave unexpectedly. 5 | // Instead of importing types, always re-create the type in the migration, as 6 | // is done here, even though the same type is defined in pkg/api 7 | 8 | import ( 9 | "gorm.io/gorm" 10 | 11 | "github.com/go-gormigrate/gormigrate/v2" 12 | ) 13 | 14 | func addDinosaurs() *gormigrate.Migration { 15 | type Dinosaur struct { 16 | Model 17 | Species string `gorm:"index"` 18 | } 19 | 20 | return &gormigrate.Migration{ 21 | ID: "201911212019", 22 | Migrate: func(tx *gorm.DB) error { 23 | return tx.AutoMigrate(&Dinosaur{}) 24 | }, 25 | Rollback: func(tx *gorm.DB) error { 26 | return tx.Migrator().DropTable(&Dinosaur{}) 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/db/migrations/202309020925_add_events.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | 8 | "github.com/go-gormigrate/gormigrate/v2" 9 | ) 10 | 11 | func addEvents() *gormigrate.Migration { 12 | type Event struct { 13 | Model 14 | Source string `gorm:"index"` // MyTable, any string 15 | // SourceID must be an indexable key for querying, *not* a json data payload. 16 | // an indexed column of data json data would explode 17 | SourceID string `gorm:"index"` // primary key of MyTable 18 | EventType string // Add|Update|Delete, any string 19 | ReconciledDate *time.Time `gorm:"null;index"` 20 | } 21 | 22 | return &gormigrate.Migration{ 23 | ID: "202309020925", 24 | Migrate: func(tx *gorm.DB) error { 25 | return tx.AutoMigrate(&Event{}) 26 | }, 27 | Rollback: func(tx *gorm.DB) error { 28 | return tx.Migrator().DropTable(&Event{}) 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/db/migrations/202311151850_add_resources.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "gorm.io/datatypes" 5 | "gorm.io/gorm" 6 | 7 | "github.com/go-gormigrate/gormigrate/v2" 8 | ) 9 | 10 | func addResources() *gormigrate.Migration { 11 | type Resource struct { 12 | Name string `gorm:"uniqueIndex"` 13 | Model 14 | Source string `gorm:"index"` 15 | ConsumerName string `gorm:"index"` 16 | Version int `gorm:"not null"` 17 | // Type indicates the resource type. Supported types: "Single" and "Bundle". 18 | // "Single" resource type for RESTful API calls, 19 | // "Bundle" resource type mainly for gRPC calls. 20 | Type string `gorm:"index"` 21 | // Payload is CloudEvent payload with CloudEvent format (JSON representation). 22 | Payload datatypes.JSON `gorm:"type:json"` 23 | // Status represents the resource status in CloudEvent format (JSON representation). 24 | Status datatypes.JSON `gorm:"type:json"` 25 | } 26 | 27 | return &gormigrate.Migration{ 28 | ID: "202311151850", 29 | Migrate: func(tx *gorm.DB) error { 30 | return tx.AutoMigrate(&Resource{}) 31 | }, 32 | Rollback: func(tx *gorm.DB) error { 33 | return tx.Migrator().DropTable(&Resource{}) 34 | }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/db/migrations/202311151856_add_consumers.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "gorm.io/datatypes" 5 | "gorm.io/gorm" 6 | 7 | "github.com/go-gormigrate/gormigrate/v2" 8 | ) 9 | 10 | func addConsumers() *gormigrate.Migration { 11 | type Consumer struct { 12 | Model 13 | Name string `gorm:"uniqueIndex;not null"` 14 | Labels datatypes.JSON `gorm:"type:json"` 15 | } 16 | 17 | return &gormigrate.Migration{ 18 | ID: "202311151856", 19 | Migrate: func(tx *gorm.DB) error { 20 | if err := tx.AutoMigrate(&Consumer{}); err != nil { 21 | return err 22 | } 23 | 24 | if err := CreateFK(tx, fkMigration{ 25 | "resources", "consumers", "consumer_name", "consumers(name)", "ON DELETE RESTRICT ON UPDATE RESTRICT", 26 | }); err != nil { 27 | return err 28 | } 29 | 30 | return nil 31 | }, 32 | Rollback: func(tx *gorm.DB) error { 33 | return tx.Migrator().DropTable(&Consumer{}) 34 | }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkg/db/migrations/202311201127_drop_dinosaurs.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | ) 8 | 9 | func dropDinosaurs() *gormigrate.Migration { 10 | type Dinosaur struct { 11 | Model 12 | Species string `gorm:"index"` 13 | } 14 | 15 | return &gormigrate.Migration{ 16 | ID: "202311151859", 17 | Migrate: func(tx *gorm.DB) error { 18 | return tx.Migrator().DropTable(&Dinosaur{}) 19 | }, 20 | Rollback: func(tx *gorm.DB) error { 21 | return tx.AutoMigrate(&Dinosaur{}) 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/db/migrations/202401151014_add_server_instances.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | ) 8 | 9 | func addServerInstances() *gormigrate.Migration { 10 | type ServerInstance struct { 11 | Model 12 | } 13 | 14 | return &gormigrate.Migration{ 15 | ID: "202401151014", 16 | Migrate: func(tx *gorm.DB) error { 17 | return tx.AutoMigrate(&ServerInstance{}) 18 | }, 19 | Rollback: func(tx *gorm.DB) error { 20 | return tx.Migrator().DropTable(&ServerInstance{}) 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkg/db/migrations/202406241426_add_status_events.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/datatypes" 7 | "gorm.io/gorm" 8 | 9 | "github.com/go-gormigrate/gormigrate/v2" 10 | ) 11 | 12 | func addStatusEvents() *gormigrate.Migration { 13 | type StatusEvent struct { 14 | Model 15 | ResourceID string `gorm:"index"` // resource id 16 | ResourceSource string 17 | ResourceType string 18 | Payload datatypes.JSON `gorm:"type:json"` 19 | Status datatypes.JSON `gorm:"type:json"` 20 | StatusEventType string // Update|Delete, any string 21 | ReconciledDate *time.Time `gorm:"null;index"` 22 | } 23 | 24 | return &gormigrate.Migration{ 25 | ID: "202406241426", 26 | Migrate: func(tx *gorm.DB) error { 27 | return tx.AutoMigrate(&StatusEvent{}) 28 | }, 29 | Rollback: func(tx *gorm.DB) error { 30 | return tx.Migrator().DropTable(&StatusEvent{}) 31 | }, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/db/migrations/202406241506_add_event_instances.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | ) 8 | 9 | func addEventInstances() *gormigrate.Migration { 10 | type EventInstance struct { 11 | EventID string `gorm:"index"` // primary key of events table 12 | InstanceID string `gorm:"index"` // primary key of server_instances table 13 | } 14 | 15 | return &gormigrate.Migration{ 16 | ID: "202406241506", 17 | Migrate: func(tx *gorm.DB) error { 18 | return tx.AutoMigrate(&EventInstance{}) 19 | }, 20 | Rollback: func(tx *gorm.DB) error { 21 | return tx.Migrator().DropTable(&EventInstance{}) 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/db/migrations/202412171429_add_last_heartbeat_and_ready_column_in_server_instances_tables.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | 8 | "github.com/go-gormigrate/gormigrate/v2" 9 | ) 10 | 11 | func addLastHeartBeatAndReadyColumnInServerInstancesTable() *gormigrate.Migration { 12 | type ServerInstance struct { 13 | LastHeartbeat time.Time 14 | Ready bool `gorm:"default:false"` 15 | } 16 | 17 | return &gormigrate.Migration{ 18 | ID: "202412171429", 19 | Migrate: func(tx *gorm.DB) error { 20 | return tx.AutoMigrate(&ServerInstance{}) 21 | }, 22 | Rollback: func(tx *gorm.DB) error { 23 | err := tx.Migrator().DropColumn(&ServerInstance{}, "ready") 24 | if err != nil { 25 | return err 26 | } 27 | return tx.Migrator().DropColumn(&ServerInstance{}, "last_heartbeat") 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/db/migrations/202412181141_alter_event_instances.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | 6 | "github.com/go-gormigrate/gormigrate/v2" 7 | ) 8 | 9 | func alterEventInstances() *gormigrate.Migration { 10 | type EventInstance struct { 11 | EventID string `gorm:"index:idx_status_event_instance"` // primary key of status_events table 12 | InstanceID string `gorm:"index:idx_status_event_instance"` // primary key of server_instances table 13 | SpecEventID string `gorm:"index"` // primary key of events table 14 | } 15 | 16 | return &gormigrate.Migration{ 17 | ID: "202412181141", 18 | Migrate: func(tx *gorm.DB) error { 19 | if err := tx.AutoMigrate(&EventInstance{}); err != nil { 20 | return err 21 | } 22 | 23 | return CreateFK(tx, fkMigration{ 24 | "event_instances", "server_instances", "instance_id", "server_instances(id)", "ON DELETE CASCADE", 25 | }, fkMigration{ 26 | "event_instances", "status_events", "event_id", "status_events(id)", "ON DELETE CASCADE", 27 | }, fkMigration{ 28 | "event_instances", "events", "spec_event_id", "events(id)", "ON DELETE CASCADE", 29 | }) 30 | }, 31 | Rollback: func(tx *gorm.DB) error { 32 | if err := tx.Migrator().DropColumn(&EventInstance{}, "spec_event_id"); err != nil { 33 | return err 34 | } 35 | 36 | if err := tx.Migrator().DropIndex(&EventInstance{}, "idx_status_event_instance"); err != nil { 37 | return err 38 | } 39 | 40 | if err := tx.Migrator().DropConstraint(&EventInstance{}, fkName("event_instances", "server_instances")); err != nil { 41 | return err 42 | } 43 | 44 | if err := tx.Migrator().DropConstraint(&EventInstance{}, fkName("event_instances", "status_events")); err != nil { 45 | return err 46 | } 47 | 48 | return tx.Migrator().DropConstraint(&EventInstance{}, fkName("event_instances", "events")) 49 | }, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/db/mocks/advisory_locks.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/uuid" 8 | "github.com/openshift-online/maestro/pkg/db" 9 | ) 10 | 11 | type MockAdvisoryLockFactory struct { 12 | locks map[string]string 13 | } 14 | 15 | func NewMockAdvisoryLockFactory() *MockAdvisoryLockFactory { 16 | return &MockAdvisoryLockFactory{ 17 | locks: make(map[string]string), 18 | } 19 | } 20 | 21 | func (f *MockAdvisoryLockFactory) NewAdvisoryLock(ctx context.Context, id string, lockType db.LockType) (string, error) { 22 | lockOwnerID := uuid.New().String() 23 | key := fmt.Sprintf("%s-%s", id, lockType) 24 | if _, ok := f.locks[key]; ok { 25 | return lockOwnerID, nil 26 | } 27 | 28 | f.locks[key] = lockOwnerID 29 | return lockOwnerID, nil 30 | } 31 | 32 | func (f *MockAdvisoryLockFactory) NewNonBlockingLock(ctx context.Context, id string, lockType db.LockType) (string, bool, error) { 33 | lockOwnerID := uuid.New().String() 34 | key := fmt.Sprintf("%s-%s", id, lockType) 35 | if _, ok := f.locks[key]; ok { 36 | return lockOwnerID, true, nil 37 | } 38 | 39 | f.locks[key] = lockOwnerID 40 | return lockOwnerID, true, nil 41 | } 42 | 43 | func (f *MockAdvisoryLockFactory) Unlock(ctx context.Context, uuid string) { 44 | for k, v := range f.locks { 45 | if v == uuid { 46 | delete(f.locks, k) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/db/session.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/lib/pq" 8 | "gorm.io/gorm" 9 | 10 | "github.com/openshift-online/maestro/pkg/config" 11 | ) 12 | 13 | type SessionFactory interface { 14 | Init(*config.DatabaseConfig) 15 | DirectDB() *sql.DB 16 | New(ctx context.Context) *gorm.DB 17 | CheckConnection() error 18 | Close() error 19 | ResetDB() 20 | NewListener(ctx context.Context, channel string, callback func(id string)) *pq.Listener 21 | } 22 | -------------------------------------------------------------------------------- /pkg/db/transaction/transaction.go: -------------------------------------------------------------------------------- 1 | package transaction 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | ) 7 | 8 | // By default do no roll back transaction. 9 | // only perform rollback if explicitly set by g2.g2.MarkForRollback(ctx, err) 10 | const defaultRollbackPolicy = false 11 | 12 | // Transaction represents an sql transaction 13 | type Transaction struct { 14 | rollbackFlag bool 15 | tx *sql.Tx 16 | txid int64 17 | } 18 | 19 | // Build Creates a new transaction object 20 | func Build(tx *sql.Tx, id int64, rollbackFlag bool) *Transaction { 21 | return &Transaction{ 22 | tx: tx, 23 | txid: id, 24 | rollbackFlag: defaultRollbackPolicy, 25 | } 26 | } 27 | 28 | // MarkedForRollback returns true if a transaction is flagged for rollback and false otherwise. 29 | func (tx *Transaction) MarkedForRollback() bool { 30 | return tx.rollbackFlag 31 | } 32 | 33 | func (tx *Transaction) Tx() *sql.Tx { 34 | return tx.tx 35 | } 36 | 37 | func (tx *Transaction) TxID() int64 { 38 | return tx.txid 39 | } 40 | 41 | func (tx *Transaction) Commit() error { 42 | // tx must exits 43 | if tx.tx == nil { 44 | return errors.New("db: transaction hasn't been started yet") 45 | } 46 | 47 | // must call commit on 'g2' which is Gorm 48 | // do *not* call commit on the underlying transaction itself. Gorm does that. 49 | err := tx.tx.Commit() 50 | tx.tx = nil 51 | return err 52 | } 53 | 54 | // rollback ends the transaction by rolling back 55 | func (tx *Transaction) Rollback() error { 56 | // tx must exist 57 | if tx.tx == nil { 58 | return errors.New("db: transaction hasn't been started yet") 59 | } 60 | err := tx.tx.Rollback() 61 | tx.tx = nil 62 | return err 63 | } 64 | 65 | func (tx *Transaction) SetRollbackFlag(flag bool) { 66 | tx.rollbackFlag = flag 67 | } 68 | -------------------------------------------------------------------------------- /pkg/db/transaction_middleware.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/getsentry/sentry-go" 9 | "github.com/openshift-online/maestro/pkg/db/db_context" 10 | 11 | "github.com/openshift-online/maestro/pkg/errors" 12 | "github.com/openshift-online/maestro/pkg/logger" 13 | ) 14 | 15 | // TransactionMiddleware creates a new HTTP middleware that begins a database transaction 16 | // and stores it in the request context. 17 | func TransactionMiddleware(next http.Handler, connection SessionFactory) http.Handler { 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | // Create a new Context with the transaction stored in it. 20 | ctx, err := NewContext(r.Context(), connection) 21 | if err != nil { 22 | log.With("error", err.Error()).Error("Could not create transaction") 23 | // use default error to avoid exposing internals to users 24 | err := errors.GeneralError("") 25 | operationID := logger.GetOperationID(ctx) 26 | writeJSONResponse(w, err.HttpCode, err.AsOpenapiError(operationID)) 27 | return 28 | } 29 | 30 | // Set the value of the request pointer to the value of a new copy of the request with the new context key,vale 31 | // stored in it 32 | *r = *r.WithContext(ctx) 33 | 34 | if hub := sentry.GetHubFromContext(ctx); hub != nil { 35 | hub.ConfigureScope(func(scope *sentry.Scope) { 36 | if txid, ok := db_context.TxID(ctx); ok { 37 | scope.SetTag("db_transaction_id", fmt.Sprintf("%d", txid)) 38 | } 39 | }) 40 | } 41 | 42 | // Returned from handlers and resolve transactions. 43 | defer func() { Resolve(r.Context()) }() 44 | 45 | // Continue handling requests. 46 | next.ServeHTTP(w, r) 47 | }) 48 | } 49 | 50 | func writeJSONResponse(w http.ResponseWriter, code int, payload interface{}) { 51 | w.Header().Set("Content-Type", "application/json") 52 | w.WriteHeader(code) 53 | 54 | if payload != nil { 55 | response, _ := json.Marshal(payload) 56 | _, _ = w.Write(response) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/db/transactions.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "github.com/openshift-online/maestro/pkg/db/transaction" 5 | ) 6 | 7 | // By default do no roll back transaction. 8 | // only perform rollback if explicitly set by g2.g2.MarkForRollback(ctx, err) 9 | const defaultRollbackPolicy = false 10 | 11 | // newTransaction constructs a new Transaction object. 12 | func newTransaction(connection SessionFactory) (*transaction.Transaction, error) { 13 | if connection == nil { 14 | // This happens in non-integration tests 15 | return nil, nil 16 | } 17 | 18 | dbx := connection.DirectDB() 19 | tx, err := dbx.Begin() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | // current transaction ID set by postgres. these are *not* distinct across time 25 | // and do get reset after postgres performs "vacuuming" to reclaim used IDs. 26 | var txid int64 27 | row := tx.QueryRow("select txid_current()") 28 | if row != nil { 29 | err := row.Scan(&txid) 30 | if err != nil { 31 | return nil, err 32 | } 33 | } 34 | 35 | return transaction.Build(tx, txid, defaultRollbackPolicy), nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/db/util.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | // similar to gorms datatypes.JSONMap but it restricts the values to strings 9 | type StringMap map[string]string 10 | 11 | func (m *StringMap) Scan(value interface{}) error { 12 | return json.Unmarshal(value.([]byte), m) 13 | } 14 | 15 | func (m StringMap) Value() (driver.Value, error) { 16 | return json.Marshal(m) 17 | } 18 | 19 | func (m *StringMap) ToMap() *map[string]string { 20 | if m == nil { 21 | return nil 22 | } 23 | return (*map[string]string)(m) 24 | } 25 | 26 | func EmptyMapToNilStringMap(a *map[string]string) *StringMap { 27 | if a == nil { 28 | return nil 29 | } 30 | if len(*a) == 0 { 31 | return nil 32 | } 33 | sm := StringMap(*a) 34 | return &sm 35 | } 36 | -------------------------------------------------------------------------------- /pkg/dispatcher/dispatcher.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Dispatcher defines methods for coordinating resource status updates in the context of multiple active maestro instances. 8 | // 9 | // The dispatcher ensures only one instance processes specific resource status updates from a consumer. 10 | // It needs to handle status resync based on the instances' status and different implementations. 11 | type Dispatcher interface { 12 | // Start initializes and runs the dispatcher based on different implementations. 13 | Start(ctx context.Context) 14 | // Dispatch determines if the current Maestro instance should process the resource status update based on the consumer ID. 15 | Dispatch(consumerName string) bool 16 | } 17 | -------------------------------------------------------------------------------- /pkg/dispatcher/hash_dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/buraksezer/consistent" 7 | "github.com/google/uuid" 8 | 9 | "github.com/openshift-online/maestro/pkg/api" 10 | ) 11 | 12 | func TestHashDispatcher(t *testing.T) { 13 | consistent := consistent.New(nil, consistent.Config{ 14 | PartitionCount: 7, 15 | ReplicationFactor: 20, 16 | Load: 1.5, 17 | Hasher: hasher{}, 18 | }) 19 | for _, member := range []string{"maestro-maestro-598fb77bf4-rht4s", "maestro-maestro-598fb77bf4-2fslb"} { 20 | consistent.Add(&api.ServerInstance{ 21 | Meta: api.Meta{ 22 | ID: member, 23 | }, 24 | }) 25 | } 26 | 27 | var consumers []string 28 | for i := 0; i < 100; i++ { 29 | id := uuid.New().String() 30 | consumers = append(consumers, id) 31 | } 32 | 33 | for _, consumer := range consumers { 34 | instance := consistent.LocateKey([]byte(consumer)).String() 35 | if instance == "" { 36 | t.Fatalf("should locate to one instance for the consumer %s", consumer) 37 | } 38 | } 39 | consistent.Add(&api.ServerInstance{ 40 | Meta: api.Meta{ 41 | ID: "maestro-maestro-598fb77bf4-b4znx", 42 | }, 43 | }) 44 | 45 | for _, consumer := range consumers { 46 | instance := consistent.LocateKey([]byte(consumer)).String() 47 | if instance == "" { 48 | t.Fatalf("should locate to one instance for the consumer %s", consumer) 49 | } 50 | } 51 | 52 | consistent.Remove("maestro-maestro-598fb77bf4-rht4s") 53 | 54 | for _, consumer := range consumers { 55 | instance := consistent.LocateKey([]byte(consumer)).String() 56 | if instance == "" { 57 | t.Fatalf("should locate to one instance for the consumer %s", consumer) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/errors/errors_test.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | func TestErrorFormatting(t *testing.T) { 10 | RegisterTestingT(t) 11 | err := New(ErrorGeneral, "test %s, %d", "errors", 1) 12 | Expect(err.Reason).To(Equal("test errors, 1")) 13 | } 14 | 15 | func TestErrorFind(t *testing.T) { 16 | RegisterTestingT(t) 17 | exists, err := Find(ErrorNotFound) 18 | Expect(exists).To(Equal(true)) 19 | Expect(err.Code).To(Equal(ErrorNotFound)) 20 | 21 | // Hopefully we never reach 91,823,719 error codes or this test will fail 22 | exists, err = Find(ServiceErrorCode(91823719)) 23 | Expect(exists).To(Equal(false)) 24 | Expect(err).To(BeNil()) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/handlers/errors.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/openshift-online/maestro/pkg/api/openapi" 9 | "github.com/openshift-online/maestro/pkg/api/presenters" 10 | "github.com/openshift-online/maestro/pkg/errors" 11 | "github.com/openshift-online/maestro/pkg/services" 12 | ) 13 | 14 | func NewErrorsHandler() *errorHandler { 15 | return &errorHandler{} 16 | } 17 | 18 | type errorHandler struct{} 19 | 20 | var _ RestHandler = errorHandler{} 21 | 22 | func (h errorHandler) List(w http.ResponseWriter, r *http.Request) { 23 | cfg := &handlerConfig{ 24 | Action: func() (interface{}, *errors.ServiceError) { 25 | listArgs := services.NewListArguments(r.URL.Query()) 26 | allErrors := errors.Errors() 27 | list, total := determineListRange(allErrors, listArgs.Page, listArgs.Size) 28 | errorList := openapi.ErrorList{ 29 | Kind: "ErrorList", 30 | Page: int32(listArgs.Page), 31 | Size: int32(len(list)), 32 | Total: int32(total), 33 | Items: []openapi.Error{}, 34 | } 35 | for _, e := range list { 36 | err := e.(errors.ServiceError) 37 | errorList.Items = append(errorList.Items, presenters.PresentError(&err)) 38 | } 39 | 40 | return errorList, nil 41 | }, 42 | } 43 | 44 | handleList(w, r, cfg) 45 | } 46 | 47 | func (h errorHandler) Get(w http.ResponseWriter, r *http.Request) { 48 | cfg := &handlerConfig{ 49 | Action: func() (interface{}, *errors.ServiceError) { 50 | id := mux.Vars(r)["id"] 51 | value, err := strconv.Atoi(id) 52 | if err != nil { 53 | return nil, errors.NotFound("No error with id %s exists", id) 54 | } 55 | code := errors.ServiceErrorCode(value) 56 | exists, sErr := errors.Find(code) 57 | if !exists { 58 | return nil, errors.NotFound("No error with id %s exists", id) 59 | } 60 | return presenters.PresentError(sErr), nil 61 | }, 62 | } 63 | 64 | handleGet(w, r, cfg) 65 | } 66 | 67 | func (h errorHandler) Create(w http.ResponseWriter, r *http.Request) { 68 | handleError(r.Context(), w, errors.NotImplemented("create")) 69 | } 70 | 71 | func (h errorHandler) Patch(w http.ResponseWriter, r *http.Request) { 72 | handleError(r.Context(), w, errors.NotImplemented("path")) 73 | } 74 | 75 | func (h errorHandler) Delete(w http.ResponseWriter, r *http.Request) { 76 | handleError(r.Context(), w, errors.NotImplemented("delete")) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/handlers/framework_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "net/http" 4 | 5 | type mockResponseWriter struct { 6 | written string 7 | status int 8 | } 9 | 10 | func (m *mockResponseWriter) Header() http.Header { 11 | return map[string][]string{} 12 | } 13 | func (m *mockResponseWriter) Write(b []byte) (int, error) { 14 | m.written = string(b) 15 | return 0, nil 16 | } 17 | func (m *mockResponseWriter) WriteHeader(code int) { 18 | m.status = code 19 | } 20 | -------------------------------------------------------------------------------- /pkg/handlers/helpers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "reflect" 7 | ) 8 | 9 | func writeJSONResponse(w http.ResponseWriter, code int, payload interface{}) { 10 | w.Header().Set("Content-Type", "application/json") 11 | // By default, decide whether or not a cache is usable based on the matching of the JWT 12 | // For example, this will keep caches from being used in the same browser if two users were to log in back to back 13 | w.Header().Set("Vary", "Authorization") 14 | 15 | w.WriteHeader(code) 16 | 17 | if payload != nil { 18 | response, _ := json.Marshal(payload) 19 | _, _ = w.Write(response) 20 | } 21 | } 22 | 23 | // Prepare a 'list' of non-db-backed resources 24 | func determineListRange(obj interface{}, page int, size int64) (list []interface{}, total int64) { 25 | items := reflect.ValueOf(obj) 26 | total = int64(items.Len()) 27 | low := int64(page-1) * size 28 | high := low + size 29 | if low < 0 || low >= total || high >= total { 30 | low = 0 31 | high = total 32 | } 33 | for i := low; i < high; i++ { 34 | list = append(list, items.Index(int(i)).Interface()) 35 | } 36 | 37 | return list, total 38 | } 39 | -------------------------------------------------------------------------------- /pkg/handlers/openapi.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type openAPIHandler struct { 8 | OpenAPIDefinitions []byte 9 | } 10 | 11 | func NewOpenAPIHandler(openAPIDefinitions []byte) *openAPIHandler { 12 | return &openAPIHandler{OpenAPIDefinitions: openAPIDefinitions} 13 | } 14 | 15 | func (h openAPIHandler) Get(w http.ResponseWriter, r *http.Request) { 16 | w.Header().Set("Content-Type", "application/json") 17 | w.WriteHeader(http.StatusOK) 18 | _, _ = w.Write(h.OpenAPIDefinitions) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/handlers/prometheus_metrics.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/prometheus/client_golang/prometheus/promhttp" 7 | ) 8 | 9 | type prometheusMetricsHandler struct { 10 | } 11 | 12 | // NewPrometheusMetricsHandler adds custom metrics and proxy to prometheus handler 13 | func NewPrometheusMetricsHandler() *prometheusMetricsHandler { 14 | return &prometheusMetricsHandler{} 15 | } 16 | 17 | func (h *prometheusMetricsHandler) Handler() http.Handler { 18 | handler := promhttp.Handler() 19 | 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | handler.ServeHTTP(w, r) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/handlers/rest.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "net/http" 4 | 5 | type RestHandler interface { 6 | List(w http.ResponseWriter, r *http.Request) 7 | Get(w http.ResponseWriter, r *http.Request) 8 | Create(w http.ResponseWriter, r *http.Request) 9 | Patch(w http.ResponseWriter, r *http.Request) 10 | Delete(w http.ResponseWriter, r *http.Request) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/handlers/validation.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/openshift-online/maestro/pkg/errors" 7 | ) 8 | 9 | func validateNotEmpty(i interface{}, fieldName string, field string) validate { 10 | return func() *errors.ServiceError { 11 | value := reflect.ValueOf(i).Elem().FieldByName(fieldName) 12 | if value.Kind() == reflect.Ptr { 13 | if value.IsNil() { 14 | return errors.Validation("%s is required", field) 15 | } 16 | value = value.Elem() 17 | } 18 | if len(value.String()) == 0 { 19 | return errors.Validation("%s is required", field) 20 | } 21 | return nil 22 | } 23 | } 24 | 25 | func validateEmpty(i interface{}, fieldName string, field string) validate { 26 | return func() *errors.ServiceError { 27 | value := reflect.ValueOf(i).Elem().FieldByName(fieldName) 28 | if value.Kind() == reflect.Ptr { 29 | if value.IsNil() { 30 | return nil 31 | } 32 | value = value.Elem() 33 | } 34 | if len(value.String()) != 0 { 35 | return errors.Validation("%s must be empty", field) 36 | } 37 | return nil 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/logger/operationid_middleware.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | 8 | "go.opentelemetry.io/otel/attribute" 9 | "go.opentelemetry.io/otel/trace" 10 | 11 | "github.com/getsentry/sentry-go" 12 | "github.com/segmentio/ksuid" 13 | ) 14 | 15 | type OperationIDKey string 16 | 17 | const OpIDKey OperationIDKey = "opID" 18 | const OpIDHeader OperationIDKey = "X-Operation-ID" 19 | 20 | // Middleware wraps the given HTTP handler so that the details of the request are sent to the log. 21 | func OperationIDMiddleware(handler http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | ctx := WithOpID(r.Context()) 24 | span := trace.SpanFromContext(ctx) 25 | 26 | opID, ok := ctx.Value(OpIDKey).(string) 27 | if ok && len(opID) > 0 { 28 | span.SetAttributes(operationIDAttribute(opID)) 29 | w.Header().Set(string(OpIDHeader), opID) 30 | } 31 | 32 | // Add operation ID to sentry context 33 | if hub := sentry.GetHubFromContext(ctx); hub != nil { 34 | hub.ConfigureScope(func(scope *sentry.Scope) { 35 | scope.SetTag("operation_id", opID) 36 | }) 37 | } 38 | 39 | handler.ServeHTTP(w, r.WithContext(ctx)) 40 | }) 41 | } 42 | 43 | func WithOpID(ctx context.Context) context.Context { 44 | if ctx.Value(OpIDKey) != nil { 45 | return ctx 46 | } 47 | opID := ksuid.New().String() 48 | return context.WithValue(ctx, OpIDKey, opID) 49 | } 50 | 51 | // GetOperationID get operationID of the context 52 | func GetOperationID(ctx context.Context) string { 53 | if opID, ok := ctx.Value(OpIDKey).(string); ok { 54 | return opID 55 | } 56 | return "" 57 | } 58 | 59 | func operationIDAttribute(id string) attribute.KeyValue { 60 | return attribute.String(strings.ToLower(string(OpIDKey)), id) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/logger/zap.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | envtypes "github.com/openshift-online/maestro/cmd/maestro/environments/types" 5 | "go.uber.org/zap" 6 | "go.uber.org/zap/zapcore" 7 | ) 8 | 9 | // keep the logger in a singleton mode, allowing for 10 | // dynamic changes to the log level at runtime. 11 | var ( 12 | zapLogLevel zap.AtomicLevel 13 | zapLogger *zap.SugaredLogger 14 | ) 15 | 16 | // GetLogger returns the singleton logger instance, initializing it 17 | // with the given environment if necessary. 18 | func GetLogger() *zap.SugaredLogger { 19 | if zapLogger == nil { 20 | zapLogLevel = zap.NewAtomicLevel() 21 | zapConfig := zap.NewDevelopmentConfig() 22 | zapConfig.DisableStacktrace = true 23 | zapConfig.Encoding = "console" 24 | if envtypes.GetEnvironmentStrFromEnv() == "development" { 25 | zapLogLevel.SetLevel(zapcore.DebugLevel) 26 | } else { 27 | zapLogLevel.SetLevel(zapcore.InfoLevel) 28 | } 29 | zapConfig.Level = zapLogLevel 30 | zlog, err := zapConfig.Build() 31 | if err != nil { 32 | panic(err) 33 | } 34 | zapLogger = zlog.Sugar() 35 | } 36 | return zapLogger 37 | } 38 | 39 | // SetLogLevel sets the log level for the logger. 40 | func SetLogLevel(level string) { 41 | zapLevel, err := zapcore.ParseLevel(level) 42 | if err != nil { 43 | zapLogger.Errorf("failed to parse log level: %v", err) 44 | return 45 | } 46 | zapLogLevel.SetLevel(zapLevel) 47 | } 48 | 49 | // GetLoggerLevel returns the current log level of the logger. 50 | func GetLoggerLevel() string { 51 | return zapLogLevel.String() 52 | } 53 | 54 | // SyncLogger flushes any buffered log entries. 55 | // This should be called before the application exits. 56 | func SyncLogger() { 57 | if zapLogger != nil { 58 | _ = zapLogger.Sync() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/services/types.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "net/url" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // ListArguments are arguments relevant for listing objects. 10 | // This struct is common to all service List funcs in this package 11 | type ListArguments struct { 12 | Page int 13 | Size int64 14 | Preloads []string 15 | Search string 16 | OrderBy []string 17 | Fields []string 18 | } 19 | 20 | // ~65500 is the maximum number of parameters that can be provided to a postgres WHERE IN clause 21 | // Use it as a sane max 22 | const MAX_LIST_SIZE = 65500 23 | 24 | // Create ListArguments from url query parameters with sane defaults 25 | func NewListArguments(params url.Values) *ListArguments { 26 | listArgs := &ListArguments{ 27 | Page: 1, 28 | Size: 100, 29 | Search: "", 30 | } 31 | if v := strings.Trim(params.Get("page"), " "); v != "" { 32 | listArgs.Page, _ = strconv.Atoi(v) 33 | } 34 | if v := strings.Trim(params.Get("size"), " "); v != "" { 35 | listArgs.Size, _ = strconv.ParseInt(v, 10, 0) 36 | } 37 | if listArgs.Size > MAX_LIST_SIZE || listArgs.Size < 0 { 38 | // MAX_LIST_SIZE is the maximum number of *parameters* that can be provided to a postgres WHERE IN clause 39 | // Use it as a sane max 40 | listArgs.Size = MAX_LIST_SIZE 41 | } 42 | if v := strings.Trim(params.Get("search"), " "); v != "" { 43 | listArgs.Search = v 44 | } 45 | if v := strings.Trim(params.Get("orderBy"), " "); v != "" { 46 | listArgs.OrderBy = strings.Split(v, ",") 47 | } 48 | if v := strings.Trim(params.Get("fields"), " "); v != "" { 49 | fields := strings.Split(v, ",") 50 | idNotPresent := true 51 | for i := 0; i < len(fields); i++ { 52 | field := strings.Trim(fields[i], " ") 53 | if field == "" { // skip leading/trailing commas and spaces 54 | continue 55 | } 56 | if field == "id" { 57 | idNotPresent = false 58 | } 59 | listArgs.Fields = append(listArgs.Fields, field) 60 | } 61 | if idNotPresent { 62 | listArgs.Fields = append(listArgs.Fields, "id") 63 | } 64 | } 65 | 66 | return listArgs 67 | } 68 | -------------------------------------------------------------------------------- /pkg/util/utils.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func EmptyStringToNil(a string) *string { 9 | if a == "" { 10 | return nil 11 | } 12 | return &a 13 | } 14 | 15 | func NilToEmptyString(a *string) string { 16 | if a == nil { 17 | return "" 18 | } 19 | return *a 20 | } 21 | 22 | func NilToEmptyInt32(a *int32) int32 { 23 | if a == nil { 24 | return 0 25 | } 26 | return *a 27 | } 28 | 29 | func GetAccountIDFromContext(ctx context.Context) string { 30 | accountID := ctx.Value("accountID") 31 | if accountID == nil { 32 | return "" 33 | } 34 | return fmt.Sprintf("%v", accountID) 35 | } 36 | -------------------------------------------------------------------------------- /pr_check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | # Ensure git 2.9 is used 4 | export PATH=/opt/rh/rh-git29/root/usr/bin/:$PATH 5 | # Ensure the httpd 2.4 libraries are imported for libcurl to function properly in the git 2.9 SCL 6 | export LD_LIBRARY_PATH=/opt/rh/httpd24/root/usr/lib64:$LD_LIBRARY_PATH 7 | 8 | # Use same name for all instances. This makes it easy to clean up a previously 9 | # failed instance. This assumes only one instance will be running at a time. 10 | export IMAGE_NAME="test/maestro" 11 | 12 | function cleanUp { 13 | if [ -n "${IMAGE_NAME:-}" ] && \ 14 | [ -n "$(podman container ls -aqf name="${IMAGE_NAME}")" ] && \ 15 | [ "$(podman container inspect -f '{{.State.Running}}' "${IMAGE_NAME}")" = "true" ]; then 16 | podman container kill "${IMAGE_NAME}" 17 | fi 18 | } 19 | trap cleanUp EXIT 20 | 21 | test -f go1.23.4.linux-amd64.tar.gz || curl -O -J https://dl.google.com/go/go1.23.4.linux-amd64.tar.gz 22 | 23 | podman build -t "$IMAGE_NAME" -f Dockerfile.test . 24 | 25 | podman run -i "$IMAGE_NAME" 26 | -------------------------------------------------------------------------------- /pr_check_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | # Reflects defaults found in pkg/config/db.go 4 | export GORM_DIALECT="postgres" 5 | export GORM_HOST="localhost" 6 | export GORM_PORT="5432" 7 | export GORM_NAME="maestro" 8 | export GORM_USERNAME="maestro" 9 | export GORM_PASSWORD="foobar-bizz-buzz" 10 | export GORM_SSLMODE="disable" 11 | export GORM_DEBUG="false" 12 | 13 | export LOGLEVEL="1" 14 | export TEST_SUMMARY_FORMAT="standard-verbose" 15 | 16 | go version 17 | mkdir "$(go env GOPATH)/bin" 18 | which gotestsum || curl -sSL "https://github.com/gotestyourself/gotestsum/releases/download/v0.3.5/gotestsum_0.3.5_linux_amd64.tar.gz" | tar -xz -C "$(go env GOPATH)/bin" gotestsum 19 | 20 | which pg_ctl 21 | PGDATA=/var/lib/postgresql/data /usr/lib/postgresql/*/bin/pg_ctl -w stop 22 | PGDATA=/var/lib/postgresql/data /usr/lib/postgresql/*/bin/pg_ctl start -o "-c listen_addresses='*' -p 5432" 23 | 24 | make test 25 | make test-integration 26 | 27 | exit 0 28 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "baseBranches": ["main"], 4 | "addLabels": ["ok-to-test"], 5 | "schedule": ["every weekend"], 6 | "timezone": "America/New_York", 7 | "gomod":{ 8 | "postUpdateOptions": [ 9 | "gomodUpdateImportPaths", 10 | "gomodTidy" 11 | ], 12 | "packageRules": [ 13 | { 14 | "matchManagers": [ 15 | "gomod" 16 | ], 17 | "matchDepTypes": [ 18 | "indirect", 19 | "replace", 20 | "final", 21 | "stage" 22 | ], 23 | "matchUpdateTypes": [ 24 | "pin", 25 | "pinDigest", 26 | "digest", 27 | "lockFileMaintenance", 28 | "rollback", 29 | "bump", 30 | "replacement", 31 | "patch", 32 | "minor", 33 | "major" 34 | ], 35 | "enabled": false 36 | }, 37 | { 38 | "matchManagers": [ 39 | "gomod" 40 | ], 41 | "matchDepTypes": [ 42 | "require" 43 | ], 44 | "matchUpdateTypes": [ 45 | "pin", 46 | "pinDigest", 47 | "digest", 48 | "lockFileMaintenance", 49 | "rollback", 50 | "bump", 51 | "replacement", 52 | "minor", 53 | "major" 54 | ], 55 | "enabled": false 56 | } 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /secrets/db.host: -------------------------------------------------------------------------------- 1 | localhost -------------------------------------------------------------------------------- /secrets/db.name: -------------------------------------------------------------------------------- 1 | maestro 2 | -------------------------------------------------------------------------------- /secrets/db.port: -------------------------------------------------------------------------------- 1 | 5432 -------------------------------------------------------------------------------- /secrets/db.user: -------------------------------------------------------------------------------- 1 | maestro 2 | -------------------------------------------------------------------------------- /secrets/ocm-service.clientId: -------------------------------------------------------------------------------- 1 | ocm-ams-testing -------------------------------------------------------------------------------- /secrets/ocm-service.clientSecret: -------------------------------------------------------------------------------- 1 | your-client-secret-here -------------------------------------------------------------------------------- /secrets/ocm-service.token: -------------------------------------------------------------------------------- 1 | your-ocm-api-token-here -------------------------------------------------------------------------------- /secrets/sentry.key: -------------------------------------------------------------------------------- 1 | your-key-here-for-dev-or-mounted-by-volume-in-a-deployment 2 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | # Openshift Deployment Templates 2 | 3 | The openshift deployment consists of 4 templates that, together, make an all-in-one deployment. 4 | 5 | When deploying to production, the only template necessary is the service template. 6 | 7 | ## Service template 8 | 9 | `templates/service-template.yml` 10 | 11 | This is the main service template that deploys two objects, the `maestro` deployment and the related service. 12 | 13 | ## Route template 14 | 15 | `templates/route-template.yml` 16 | 17 | This template just deploys a route with the select `app:maestro` to map to the service deployed by the service template. 18 | 19 | TLS is used by default for the route. No port is specified, all ports are allowed. 20 | 21 | ## Database template 22 | 23 | `templates/db-template.yml` 24 | 25 | This template deploys a simple postgresl-9.4 database deployment with a TLS-enabled service. 26 | 27 | ## MQTT template 28 | 29 | `templates/mqtt-template.yml` 30 | 31 | This template deploys a simple mosquitto-2.0.18 mqtt broker deployment. 32 | 33 | ## Secrets template 34 | 35 | `templates/secrets-template.yml` 36 | 37 | This template deploys the `maestro` secret with all of the necessary secret key/value pairs. 38 | 39 | ## Agent template 40 | 41 | `templates/agent-template.yml` 42 | 43 | This template deploys the `maestro-agent` deployment and the related resources. 44 | -------------------------------------------------------------------------------- /templates/route-template.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: template.openshift.io/v1 3 | kind: Template 4 | name: uhc-acct-mangr-routes 5 | metadata: 6 | name: route 7 | 8 | parameters: 9 | 10 | - name: EXTERNAL_APPS_DOMAIN 11 | description: external apps domain for exposing route 12 | 13 | objects: 14 | 15 | - apiVersion: route.openshift.io/v1 16 | kind: Route 17 | metadata: 18 | name: maestro 19 | labels: 20 | app: maestro 21 | spec: 22 | host: maestro.${EXTERNAL_APPS_DOMAIN} 23 | to: 24 | kind: Service 25 | name: maestro 26 | tls: 27 | termination: reencrypt 28 | insecureEdgeTerminationPolicy: Redirect 29 | 30 | - apiVersion: route.openshift.io/v1 31 | kind: Route 32 | metadata: 33 | name: maestro-grpc 34 | labels: 35 | app: maestro-grpc 36 | spec: 37 | host: maestro-grpc.${EXTERNAL_APPS_DOMAIN} 38 | to: 39 | kind: Service 40 | name: maestro-grpc 41 | tls: 42 | termination: passthrough 43 | insecureEdgeTerminationPolicy: None 44 | 45 | - apiVersion: route.openshift.io/v1 46 | kind: Route 47 | metadata: 48 | name: maestro-grpc-broker 49 | labels: 50 | app: maestro-grpc-broker 51 | spec: 52 | host: maestro-grpc-broker.${EXTERNAL_APPS_DOMAIN} 53 | to: 54 | kind: Service 55 | name: maestro-grpc-broker 56 | tls: 57 | termination: passthrough 58 | insecureEdgeTerminationPolicy: None 59 | -------------------------------------------------------------------------------- /test/e2e/migration/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | # 3 | # Copyright (c) 2023 Red Hat, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # get the last image tag from quay.io 18 | img_repo_api="https://quay.io/api/v1/repository/redhat-user-workloads/maestro-rhtap-tenant/maestro/maestro" 19 | img_registry="quay.io/redhat-user-workloads/maestro-rhtap-tenant" 20 | last_tag=$(curl -s -X GET "${img_repo_api}" | jq -s -c -r 'sort_by(.tags[].last_modified) | .[].tags[].name' | grep -E '^[a-z0-9]{40}$' | head -n 1) 21 | 22 | # use the last tag as the default commit sha 23 | commit_sha=${commit_sha:-"$last_tag"} 24 | 25 | output_dir="./_output/migration" 26 | 27 | rm -rf $output_dir 28 | mkdir -p $output_dir 29 | 30 | # run the e2e-test in the main repo with the commit sha 31 | git clone https://github.com/openshift-online/maestro.git "$output_dir/maestro" 32 | pushd $output_dir/maestro 33 | git checkout $commit_sha 34 | image_tag=$commit_sha external_image_registry=$img_registry internal_image_registry=$img_registry make e2e-test 35 | popd 36 | 37 | # copy the configurations 38 | cp $output_dir/maestro/test/e2e/.kubeconfig ./test/e2e/.kubeconfig 39 | cp $output_dir/maestro/test/e2e/.consumer_name ./test/e2e/.consumer_name 40 | cp $output_dir/maestro/test/e2e/.external_host_ip ./test/e2e/.external_host_ip 41 | cp -r $output_dir/maestro/test/e2e/certs ./test/e2e/certs 42 | 43 | # run the e2e test in the current repo (upgrade) 44 | make e2e-test/setup 45 | 46 | sleep 180 # wait for the upgrade env is ready 47 | 48 | make e2e-test/run 49 | -------------------------------------------------------------------------------- /test/e2e/setup/aro/Makefile: -------------------------------------------------------------------------------- 1 | # define the variables 2 | REPO_URL = https://github.com/Azure/ARO-HCP.git 3 | BRANCH = maestro-light-setup 4 | CLONE_DIR = aro-hcp 5 | 6 | # clone the repo 7 | clone: 8 | @if [ -d $(CLONE_DIR) ]; then \ 9 | echo "Removing existing directory $(CLONE_DIR)..."; \ 10 | rm -rf $(CLONE_DIR); \ 11 | fi; \ 12 | echo "Cloning repository..."; \ 13 | git clone $(REPO_URL) -b $(BRANCH) $(CLONE_DIR) 14 | .PHONY: clone 15 | 16 | # create the cluster (svc-cluster or mgmt-cluster) 17 | cluster: clone 18 | ifndef AKSCONFIG 19 | $(error "Must set AKSCONFIG") 20 | endif 21 | @$(MAKE) -C $(CLONE_DIR)/dev-infrastructure cluster 22 | .PHONY: cluster 23 | 24 | # grant admin access to the cluster 25 | aks.admin-access: 26 | ifndef AKSCONFIG 27 | $(error "Must set AKSCONFIG") 28 | endif 29 | @$(MAKE) -C $(CLONE_DIR)/dev-infrastructure aks.admin-access 30 | .PHONY: aks.admin-access 31 | 32 | # retrieve the kubeconfig 33 | aks.kubeconfig: 34 | ifndef AKSCONFIG 35 | $(error "Must set AKSCONFIG") 36 | endif 37 | @$(MAKE) -C $(CLONE_DIR)/dev-infrastructure aks.kubeconfig 38 | .PHONY: aks.kubeconfig 39 | 40 | # deploy the maestro server 41 | deploy-server: 42 | @AKSCONFIG=svc-cluster $(MAKE) -C $(CLONE_DIR)/maestro deploy-server 43 | .PHONY: deploy-server 44 | 45 | # deploy the maestro agent 46 | deploy-agent: 47 | @AKSCONFIG=mgmt-cluster $(MAKE) -C $(CLONE_DIR)/maestro deploy-agent 48 | .PHONY: deploy-agent 49 | 50 | # register the maestro agent 51 | register-agent: 52 | @AKSCONFIG=svc-cluster $(MAKE) -C $(CLONE_DIR)/maestro register-agent 53 | .PHONY: register-agent 54 | 55 | # enable the aks metrics 56 | enable-aks-metrics: clone 57 | ifndef AKSCONFIG 58 | $(error "Must set AKSCONFIG") 59 | endif 60 | @$(MAKE) -C $(CLONE_DIR)/dev-infrastructure enable-aks-metrics 61 | .PHONY: enable-aks-metrics 62 | 63 | # clean up the resources 64 | clean: 65 | ifndef AKSCONFIG 66 | $(error "Must set AKSCONFIG") 67 | endif 68 | @$(MAKE) -C $(CLONE_DIR)/dev-infrastructure clean 69 | .PHONY: clean 70 | -------------------------------------------------------------------------------- /test/e2e/setup/e2e_teardown.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | # 3 | # Copyright (c) 2023 Red Hat, Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | if ! command -v kind >/dev/null 2>&1; then 18 | echo "This script will install kind (https://kind.sigs.k8s.io/) on your machine." 19 | curl -Lo ./kind-amd64 "https://kind.sigs.k8s.io/dl/v0.12.0/kind-$(uname)-amd64" 20 | chmod +x ./kind-amd64 21 | sudo mv ./kind-amd64 /usr/local/bin/kind 22 | fi 23 | 24 | kind delete cluster --name maestro 25 | 26 | # cleanup the generated files 27 | rm -rf ./test/e2e/.kubeconfig 28 | rm -rf ./test/e2e/.consumer_name 29 | rm -rf ./test/e2e/.external_host_ip 30 | rm -rf ./test/e2e/certs 31 | -------------------------------------------------------------------------------- /test/e2e/setup/rosa/Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=/bin/bash 2 | 3 | e2e_dir=$(shell cd ${PWD}/../.. && pwd -P) 4 | 5 | rosa/setup-maestro: 6 | ./setup/maestro.sh 7 | .PHONY: rosa/setup-maestro 8 | 9 | rosa/setup-agent: 10 | ./setup/agent.sh 11 | .PHONY: rosa/setup-agent 12 | 13 | rosa/setup-e2e: 14 | ./setup/e2e.sh 15 | .PHONY: rosa/setup-e2e 16 | 17 | rosa/e2e-test: rosa/setup-e2e 18 | ginkgo -v --fail-fast --label-filter="!(e2e-tests-spec-resync-reconnect||e2e-tests-status-resync-reconnect)" \ 19 | --output-dir="$(e2e_dir)/report" --json-report=report.json --junit-report=report.xml \ 20 | ${e2e_dir}/pkg -- \ 21 | -api-server="http://127.0.0.1:8000" \ 22 | -grpc-server="127.0.0.1:8090" \ 23 | -server-kubeconfig=$(KUBECONFIG) \ 24 | -agent-kubeconfig=$(KUBECONFIG) \ 25 | -consumer-name=${PWD}/_output/consumer_id 26 | .PHONY: rosa/e2e-test 27 | 28 | rosa/teardown: 29 | ./setup/teardown.sh 30 | .PHONY: rosa/teardown 31 | -------------------------------------------------------------------------------- /test/e2e/setup/rosa/setup/aws-iot-policies/consumer.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "iot:Connect" 8 | ], 9 | "Resource": [ 10 | "arn:aws:iot:{region}:{aws_account}:client/{consumer_id}-client" 11 | ] 12 | }, 13 | { 14 | "Effect": "Allow", 15 | "Action": [ 16 | "iot:Publish" 17 | ], 18 | "Resource": [ 19 | "arn:aws:iot:{region}:{aws_account}:topic/sources/maestro/consumers/{consumer_id}/agentevents" 20 | ] 21 | }, 22 | { 23 | "Effect": "Allow", 24 | "Action": [ 25 | "iot:Subscribe" 26 | ], 27 | "Resource": [ 28 | "arn:aws:iot:{region}:{aws_account}:topicfilter/sources/maestro/consumers/{consumer_id}/sourceevents" 29 | ] 30 | }, 31 | { 32 | "Effect": "Allow", 33 | "Action": [ 34 | "iot:Receive" 35 | ], 36 | "Resource": [ 37 | "arn:aws:iot:{region}:{aws_account}:topic/sources/maestro/consumers/{consumer_id}/sourceevents" 38 | ] 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /test/e2e/setup/rosa/setup/aws-iot-policies/source.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "iot:Connect" 8 | ], 9 | "Resource": [ 10 | "*" 11 | ] 12 | }, 13 | { 14 | "Effect": "Allow", 15 | "Action": [ 16 | "iot:Publish" 17 | ], 18 | "Resource": [ 19 | "arn:aws:iot:{region}:{aws_account}:topic/sources/maestro/consumers/*/sourceevents" 20 | ] 21 | }, 22 | { 23 | "Effect": "Allow", 24 | "Action": [ 25 | "iot:Subscribe" 26 | ], 27 | "Resource": [ 28 | "arn:aws:iot:{region}:{aws_account}:topicfilter/sources/maestro/consumers/+/agentevents", 29 | "arn:aws:iot:{region}:{aws_account}:topicfilter/$share/statussubscribers/sources/maestro/consumers/+/agentevents" 30 | ] 31 | }, 32 | { 33 | "Effect": "Allow", 34 | "Action": [ 35 | "iot:Receive" 36 | ], 37 | "Resource": [ 38 | "arn:aws:iot:{region}:{aws_account}:topic/sources/maestro/consumers/*/agentevents", 39 | "arn:aws:iot:{region}:{aws_account}:topic/$share/statussubscribers/sources/maestro/consumers/*/agentevents" 40 | ] 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /test/e2e/setup/rosa/setup/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ##################### 4 | # Setup Maestro e2e 5 | ##################### 6 | 7 | PWD="$(cd "$(dirname ${BASH_SOURCE[0]})" ; pwd -P)" 8 | ROSA_DIR="$(cd ${PWD}/.. && pwd -P)" 9 | 10 | output_dir=${ROSA_DIR}/_output 11 | 12 | mkdir -p $output_dir 13 | 14 | echo "$output_dir" 15 | 16 | # Setup Maestro server 17 | CLUSTER_VPC=$vpc ${PWD}/maestro.sh 18 | sleep 90 # wait the maestro service ready 19 | 20 | # Start Maestro servers 21 | exec oc relay service/maestro 8000:8000 -n maestro > ${output_dir}/maestro.svc.log 2>&1 & 22 | maestro_server_pid=$! 23 | echo "Maestro server started: $maestro_server_pid" 24 | echo "$maestro_server_pid" > ${output_dir}/maestro_server.pid 25 | exec oc relay service/maestro-grpc 8090:8090 -n maestro > ${output_dir}/maestro-grpc.svc.log 2>&1 & 26 | maestro_grpc_server_pid=$! 27 | echo "Maestro GRPC server started: $maestro_grpc_server_pid" 28 | echo "$maestro_grpc_server_pid" > ${output_dir}/maestro_grpc_server.pid 29 | 30 | # need to wait the relay build the connection before we get the consumer id 31 | sleep 15 32 | 33 | # Prepare a consumer 34 | consumer_id=$(curl -s -X POST -H "Content-Type: application/json" http://127.0.0.1:8000/api/maestro/v1/consumers -d '{}' | jq -r '.id') 35 | echo $consumer_id > ${output_dir}/consumer_id 36 | echo "Consumer $consumer_id is created" 37 | 38 | # Setup Maestro agent 39 | oc apply -f https://raw.githubusercontent.com/open-cluster-management-io/api/release-0.14/work/v1/0000_00_work.open-cluster-management.io_manifestworks.crd.yaml 40 | CONSUMER_ID=$consumer_id ${PWD}/agent.sh 41 | -------------------------------------------------------------------------------- /test/e2e/setup/rosa/setup/teardown.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | region=${REGION:-""} 4 | 5 | if [ -z "$region" ]; then 6 | echo "cluster region is required" 7 | exit 1 8 | fi 9 | 10 | # Delete AWS PostgreSQL 11 | db_status=$(aws rds delete-db-instance --region ${region} --db-instance-identifier maestro --skip-final-snapshot --delete-automated-backups | jq -r '.DBInstance.DBInstanceStatus') 12 | echo "Deleting maestro db ($db_status)" 13 | 14 | i=1 15 | while [ $i -le 20 ] 16 | do 17 | db_status=$(aws rds describe-db-instances --region ${region} --db-instance-identifier maestro | jq -r '.DBInstances[0].DBInstanceStatus') 18 | if [[ -z "$db_status" ]]; then 19 | echo "DB is deleted" 20 | break 21 | fi 22 | echo "[$i] DB status: ${db_status}" 23 | i=$((i + 1)) 24 | sleep 30 25 | done 26 | 27 | aws rds delete-db-subnet-group --region ${region} --db-subnet-group-name maestrosubnetgroup 28 | echo "DB db subnet group is removed" 29 | 30 | # Remove AWS IoT polices and certificates 31 | for cert_id in $(aws iot list-certificates --region ${region} | jq -r '.certificates[].certificateId'); do 32 | cert_arn=$(aws iot describe-certificate --region ${region} --certificate-id $cert_id | jq -r '.certificateDescription.certificateArn') 33 | # List all 34 | for policy_name in $(aws iot list-attached-policies --region ${region} --target $cert_arn | jq -r '.policies[].policyName'); do 35 | if [[ $policy_name == maestro* ]]; then 36 | echo "delelet policy $policy_name" 37 | aws iot detach-policy --region ${region} --target $cert_arn --policy-name $policy_name 38 | aws iot delete-policy --region ${region} --policy-name $policy_name 39 | 40 | echo "delelet certificate $cert_id" 41 | aws iot update-certificate --region ${region} --certificate-id $cert_id --new-status REVOKED 42 | sleep 5 43 | aws iot delete-certificate --region ${region} --certificate-id $cert_id 44 | fi 45 | done 46 | done 47 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/00_roles.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: system:openshift:operator:service-ca-operator 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: cluster-admin 9 | subjects: 10 | - kind: ServiceAccount 11 | name: service-ca-operator 12 | namespace: openshift-service-ca-operator -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/01_namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | openshift.io/run-level: "1" 6 | openshift.io/cluster-monitoring: "true" 7 | name: openshift-service-ca-operator 8 | annotations: 9 | openshift.io/node-selector: "" 10 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/02_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: 5 | service.beta.openshift.io/serving-cert-secret-name: serving-cert 6 | labels: 7 | app: service-ca-operator 8 | name: metrics 9 | namespace: openshift-service-ca-operator 10 | spec: 11 | ports: 12 | - name: https 13 | port: 443 14 | protocol: TCP 15 | targetPort: 8443 16 | selector: 17 | app: service-ca-operator 18 | sessionAffinity: None 19 | type: ClusterIP 20 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/03_cm.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | namespace: openshift-service-ca-operator 5 | name: service-ca-operator-config 6 | data: 7 | operator-config.yaml: | 8 | apiVersion: operator.openshift.io/v1alpha1 9 | kind: GenericOperatorConfig 10 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/03_operator.cr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: operator.openshift.io/v1 2 | kind: ServiceCA 3 | metadata: 4 | name: cluster 5 | annotations: 6 | release.openshift.io/create-only: "true" 7 | spec: 8 | managementState: Managed 9 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/04_sa.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | namespace: openshift-service-ca-operator 5 | name: service-ca-operator 6 | labels: 7 | app: service-ca-operator 8 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/05_deploy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | namespace: openshift-service-ca-operator 5 | name: service-ca-operator 6 | labels: 7 | app: service-ca-operator 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: service-ca-operator 13 | template: 14 | metadata: 15 | name: service-ca-operator 16 | labels: 17 | app: service-ca-operator 18 | spec: 19 | serviceAccountName: service-ca-operator 20 | containers: 21 | - name: service-ca-operator 22 | image: quay.io/openshift/origin-service-ca-operator:4.6 23 | imagePullPolicy: IfNotPresent 24 | command: ["service-ca-operator", "operator"] 25 | args: 26 | - "--config=/var/run/configmaps/config/operator-config.yaml" 27 | - "-v=4" 28 | resources: 29 | requests: 30 | memory: 80Mi 31 | cpu: 10m 32 | env: 33 | - name: CONTROLLER_IMAGE 34 | value: quay.io/openshift/origin-service-ca-operator:4.6 35 | - name: OPERATOR_IMAGE_VERSION 36 | value: "0.0.1-snapshot" 37 | volumeMounts: 38 | - mountPath: /var/run/configmaps/config 39 | name: config 40 | - mountPath: /var/run/secrets/serving-cert 41 | name: serving-cert 42 | volumes: 43 | - name: serving-cert 44 | secret: 45 | defaultMode: 400 46 | secretName: serving-cert 47 | optional: true 48 | - name: config 49 | configMap: 50 | defaultMode: 440 51 | name: service-ca-operator-config 52 | nodeSelector: 53 | node-role.kubernetes.io/master: "" 54 | priorityClassName: "system-cluster-critical" 55 | tolerations: 56 | - key: node-role.kubernetes.io/master 57 | operator: Exists 58 | effect: "NoSchedule" 59 | - key: "node.kubernetes.io/unreachable" 60 | operator: "Exists" 61 | effect: "NoExecute" 62 | tolerationSeconds: 120 63 | - key: "node.kubernetes.io/not-ready" 64 | operator: "Exists" 65 | effect: "NoExecute" 66 | tolerationSeconds: 120 67 | -------------------------------------------------------------------------------- /test/e2e/setup/service-ca/07_clusteroperator.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: config.openshift.io/v1 2 | kind: ClusterOperator 3 | metadata: 4 | name: service-ca 5 | spec: {} 6 | status: 7 | versions: 8 | - name: operator 9 | version: "0.0.1-snapshot" 10 | -------------------------------------------------------------------------------- /test/integration/db_listener_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | . "github.com/onsi/gomega" 10 | 11 | "github.com/openshift-online/maestro/test" 12 | ) 13 | 14 | func TestWaitForNotification(t *testing.T) { 15 | // it is used to check the result of the notification 16 | result := make(chan string) 17 | 18 | h, _ := test.RegisterIntegration(t) 19 | 20 | account := h.NewRandAccount() 21 | ctx, cancel := context.WithCancel(h.NewAuthenticatedContext(account)) 22 | defer func() { 23 | cancel() 24 | }() 25 | 26 | g2 := h.Env().Database.SessionFactory.New(ctx) 27 | listener := h.Env().Database.SessionFactory.NewListener(ctx, "channel", func(id string) { 28 | result <- id 29 | }) 30 | var originalListenerId string 31 | // find the original listener id in the pg_stat_activity table 32 | g2.Raw("SELECT pid FROM pg_stat_activity WHERE query LIKE 'LISTEN%channel%'").Scan(&originalListenerId) 33 | if originalListenerId == "" { 34 | t.Errorf("the original Listener was not recreated") 35 | } 36 | 37 | // Simulate an errListenerClosed and wait for the listener to be recreated 38 | listener.Close() 39 | 40 | Eventually(func() error { 41 | var newListenerId string 42 | g2.Raw("SELECT pid FROM pg_stat_activity WHERE query LIKE 'LISTEN%channel%'").Scan(&newListenerId) 43 | if newListenerId == "" { 44 | return fmt.Errorf("the new Listener was not created") 45 | } 46 | // Validate the listener was re-created 47 | if originalListenerId == newListenerId { 48 | return fmt.Errorf("Listener was not re-created") 49 | } 50 | return nil 51 | }, 10*time.Second, 1*time.Second).Should(Succeed()) 52 | 53 | // send a notification to the new listener 54 | g2.Exec("NOTIFY channel, 'test'") 55 | 56 | // wait for the notification to be received 57 | time.Sleep(1 * time.Second) 58 | 59 | if <-result != "test" { 60 | t.Errorf("the notification was not received") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/integration/healthcheck_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | . "github.com/onsi/gomega" 10 | "github.com/openshift-online/maestro/pkg/api" 11 | "github.com/openshift-online/maestro/pkg/dao" 12 | "github.com/openshift-online/maestro/test" 13 | ) 14 | 15 | func TestHealthCheckServer(t *testing.T) { 16 | h, _ := test.RegisterIntegration(t) 17 | ctx, cancel := context.WithCancel(context.Background()) 18 | defer func() { 19 | cancel() 20 | }() 21 | 22 | instanceDao := dao.NewInstanceDao(&h.Env().Database.SessionFactory) 23 | // insert two existing instances, one is ready and the other is not 24 | _, err := instanceDao.Create(ctx, &api.ServerInstance{ 25 | Meta: api.Meta{ 26 | ID: "instance1", 27 | }, 28 | // last heartbeat is 3 seconds ago 29 | LastHeartbeat: time.Now().Add(-3 * time.Second), 30 | Ready: true, 31 | }) 32 | Expect(err).NotTo(HaveOccurred()) 33 | 34 | _, err = instanceDao.Create(ctx, &api.ServerInstance{ 35 | Meta: api.Meta{ 36 | ID: "instance2", 37 | }, 38 | // last heartbeat is 3 seconds ago 39 | LastHeartbeat: time.Now().Add(-3 * time.Second), 40 | Ready: false, 41 | }) 42 | Expect(err).NotTo(HaveOccurred()) 43 | 44 | instanceID := &h.Env().Config.MessageBroker.ClientID 45 | Eventually(func() error { 46 | instances, err := instanceDao.All(ctx) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if len(instances) != 3 { 52 | return fmt.Errorf("expected 3 instances, got %d", len(instances)) 53 | } 54 | 55 | readyInstanceIDs, err := instanceDao.FindReadyIDs(ctx) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | if len(readyInstanceIDs) != 1 { 61 | return fmt.Errorf("expected 1 ready instance, got %d", len(readyInstanceIDs)) 62 | } 63 | 64 | if readyInstanceIDs[0] != *instanceID { 65 | return fmt.Errorf("expected instance %s to be ready, got %s", *instanceID, readyInstanceIDs[0]) 66 | } 67 | 68 | return nil 69 | }, 10*time.Second, 1*time.Second).Should(Succeed()) 70 | } 71 | -------------------------------------------------------------------------------- /test/integration/integration_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/openshift-online/maestro/pkg/logger" 10 | "github.com/openshift-online/maestro/test" 11 | ) 12 | 13 | var log = logger.GetLogger() 14 | 15 | func TestMain(m *testing.M) { 16 | flag.Parse() 17 | log.Infof("Starting integration test using go version %s", runtime.Version()) 18 | helper := test.NewHelper(&testing.T{}) 19 | exitCode := m.Run() 20 | helper.Teardown() 21 | helper.CleanDB() 22 | os.Exit(exitCode) 23 | } 24 | -------------------------------------------------------------------------------- /test/mocks/jwk_cert_server.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "crypto" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/mendsley/gojwk" 11 | ) 12 | 13 | const ( 14 | certEndpoint = "/auth/realms/rhd/protocol/openid-connect/certs" 15 | ) 16 | 17 | func NewJWKCertServerMock(t *testing.T, pubKey crypto.PublicKey, jwkKID string, jwkAlg string) (url string, teardown func() error) { 18 | certHandler := http.NewServeMux() 19 | certHandler.HandleFunc(certEndpoint, 20 | func(w http.ResponseWriter, r *http.Request) { 21 | pubjwk, err := gojwk.PublicKey(pubKey) 22 | if err != nil { 23 | t.Errorf("Unable to generate public jwk: %s", err) 24 | return 25 | } 26 | pubjwk.Kid = jwkKID 27 | pubjwk.Alg = jwkAlg 28 | jwkBytes, err := gojwk.Marshal(pubjwk) 29 | if err != nil { 30 | t.Errorf("Unable to marshal public jwk: %s", err) 31 | return 32 | } 33 | fmt.Fprintf(w, fmt.Sprintf(`{"keys":[%s]}`, string(jwkBytes))) 34 | }, 35 | ) 36 | 37 | server := httptest.NewServer(certHandler) 38 | return fmt.Sprintf("%s%s", server.URL, certEndpoint), serverClose(server) 39 | } 40 | 41 | func serverClose(server *httptest.Server) func() error { 42 | return func() error { 43 | server.Close() 44 | return nil 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/mocks/mocks.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "time" 7 | ) 8 | 9 | // Returns a server that will wait waitTime when hit at endpoint 10 | func NewMockServerTimeout(endpoint string, waitTime time.Duration) (*httptest.Server, func()) { 11 | apiHandler := http.NewServeMux() 12 | apiHandler.HandleFunc(endpoint, 13 | func(w http.ResponseWriter, r *http.Request) { 14 | time.Sleep(waitTime) 15 | }, 16 | ) 17 | server := httptest.NewServer(apiHandler) 18 | return server, server.Close 19 | } 20 | -------------------------------------------------------------------------------- /test/mocks/ocm.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/openshift-online/maestro/pkg/client/ocm" 7 | ) 8 | 9 | /* 10 | The OCM Validator Mock will simply return true to all access_review requests instead 11 | of reaching out to the AMS system or using the built-in OCM mock. It will record 12 | the action and resourceType sent to it in the struct itself. This can be used 13 | to validate that the expected action/resourceType for a particular endpoint was 14 | determined in the authorization middleware 15 | 16 | Use: 17 | h, client := test.RegisterIntegration(t) 18 | authzMock, ocmMock := mocks.NewOCMAuthzValidatorMockClient() 19 | // Use the OCM client mock, re-load services so they pick up the mock 20 | h.Env().Clients.OCM = ocmMock 21 | // The built-in mock has to be disabled or the server will use it instead 22 | h.Env().Config.OCM.EnableMock = false 23 | // Services and the server should be re-loaded to pick up the client with this mock 24 | h.Env().LoadServices() 25 | h.RestartServer() 26 | 27 | // Make a request, then validate the action and resourceType 28 | Expect(authzMock.Action).To(Equal("get")) 29 | Expect(authzMock.ResourceType).To(Equal("JQSJobQueue")) 30 | authzMock.Reset() 31 | */ 32 | 33 | var _ ocm.OCMAuthorization = &OCMAuthzValidatorMock{} 34 | 35 | type OCMAuthzValidatorMock struct { 36 | Action string 37 | ResourceType string 38 | } 39 | 40 | func NewOCMAuthzValidatorMockClient() (*OCMAuthzValidatorMock, *ocm.Client) { 41 | authz := &OCMAuthzValidatorMock{ 42 | Action: "", 43 | ResourceType: "", 44 | } 45 | client := &ocm.Client{} 46 | client.Authorization = authz 47 | return authz, client 48 | } 49 | 50 | func (m *OCMAuthzValidatorMock) SelfAccessReview(ctx context.Context, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) { 51 | m.Action = action 52 | m.ResourceType = resourceType 53 | return true, nil 54 | } 55 | 56 | func (m *OCMAuthzValidatorMock) AccessReview(ctx context.Context, username, action, resourceType, organizationID, subscriptionID, clusterID string) (allowed bool, err error) { 57 | m.Action = action 58 | m.ResourceType = resourceType 59 | return true, nil 60 | } 61 | 62 | func (m OCMAuthzValidatorMock) Reset() { 63 | m.Action = "" 64 | m.ResourceType = "" 65 | } 66 | -------------------------------------------------------------------------------- /test/performance/hack/aro-hcp/check-result.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | db_user=${db_user:-"maestro-server"} 4 | db_pod_name=$(kubectl -n maestro get pods -l name=maestro-db -ojsonpath='{.items[0].metadata.name}') 5 | 6 | kubectl -n maestro exec ${db_pod_name} -- psql -d maestro -U ${db_user} -c 'select count(*) from resources' 7 | kubectl -n maestro exec ${db_pod_name} -- psql -d maestro -U ${db_user} -c "select created_at,updated_at,extract(epoch from age(updated_at,created_at)) from resources where consumer_name='maestro-cluster-9' order by created_at" 8 | kubectl -n maestro exec ${db_pod_name} -- psql -d maestro -U ${db_user} -c "select created_at,updated_at,extract(epoch from age(updated_at,created_at)) from resources where consumer_name='maestro-cluster-10' order by created_at" 9 | kubectl -n maestro exec ${db_pod_name} -- psql -d maestro -U ${db_user} -c "select pg_size_pretty(pg_total_relation_size('resources')) as total, pg_size_pretty(pg_relation_size('resources')) as data" 10 | -------------------------------------------------------------------------------- /test/performance/hack/aro-hcp/cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ARO_HCP_REPO_PATH="$HOME/go/src/github.com/Azure/ARO-HCP" 4 | 5 | ls _output/performance/aro/pids | xargs kill 6 | kind delete clusters --all 7 | 8 | pushd $ARO_HCP_REPO_PATH/dev-infrastructure 9 | AKSCONFIG=svc-cluster make clean 10 | popd 11 | -------------------------------------------------------------------------------- /test/performance/hack/aro-hcp/create-clusters.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | total=${counts:-1} 3 | begin_index=${begin_index:-1} 4 | 5 | lastIndex=$(($begin_index + $total - 1)) 6 | echo "create clusters from maestro-cluster-$begin_index to maestro-cluster-$lastIndex" 7 | 8 | kubectl apply -f - < ${agent_log_dir}/agents.log & 29 | PERF_PID=$! 30 | echo "$counts agents started: $PERF_PID" 31 | touch $pid_dir/$PERF_PID 32 | -------------------------------------------------------------------------------- /test/performance/hack/aro-hcp/start-spoke-watcher.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | REPO_DIR="$(cd "$(dirname ${BASH_SOURCE[0]})/../../../.." ; pwd -P)" 4 | 5 | index=${index:-1} 6 | counts=${counts:-1} 7 | 8 | # work dir 9 | work_dir=${REPO_DIR}/_output/performance/aro 10 | spoke_kube_dir=${work_dir}/clusters 11 | log_dir=${work_dir}/logs 12 | pid_dir=${work_dir}/pids 13 | 14 | 15 | args="--spoke-kubeconfig=${spoke_kube_dir}/test.kubeconfig" 16 | args="${args} --clusters-index=$index" 17 | args="${args} --clusters-counts=$counts" 18 | 19 | (exec "${REPO_DIR}"/maestroperf aro-hcp-watch $args) &> ${log_dir}/watcher.log & 20 | PERF_PID=$! 21 | echo "watcher started: $PERF_PID" 22 | touch $pid_dir/$PERF_PID 23 | -------------------------------------------------------------------------------- /test/performance/pkg/hub/store/createonly.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "fmt" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/runtime" 8 | "k8s.io/apimachinery/pkg/watch" 9 | 10 | workv1 "open-cluster-management.io/api/work/v1" 11 | "open-cluster-management.io/sdk-go/pkg/cloudevents/clients/store" 12 | "open-cluster-management.io/sdk-go/pkg/cloudevents/generic/types" 13 | ) 14 | 15 | type CreateOnlyWatcherStore struct { 16 | } 17 | 18 | func NewCreateOnlyWatcherStore() *CreateOnlyWatcherStore { 19 | return &CreateOnlyWatcherStore{} 20 | } 21 | 22 | func (s *CreateOnlyWatcherStore) GetWatcher(namespace string, opts metav1.ListOptions) (watch.Interface, error) { 23 | return nil, fmt.Errorf("unsupported") 24 | } 25 | 26 | func (s *CreateOnlyWatcherStore) HandleReceivedResource(action types.ResourceAction, work *workv1.ManifestWork) error { 27 | // do nothing 28 | return nil 29 | } 30 | 31 | func (s *CreateOnlyWatcherStore) Add(work runtime.Object) error { 32 | // do nothing 33 | return nil 34 | } 35 | 36 | func (s *CreateOnlyWatcherStore) Update(work runtime.Object) error { 37 | return fmt.Errorf("unsupported") 38 | } 39 | 40 | func (s *CreateOnlyWatcherStore) Delete(work runtime.Object) error { 41 | return fmt.Errorf("unsupported") 42 | } 43 | 44 | func (s *CreateOnlyWatcherStore) List(namespace string, opts metav1.ListOptions) (*store.ResourceList[*workv1.ManifestWork], error) { 45 | return nil, fmt.Errorf("unsupported") 46 | } 47 | 48 | func (s *CreateOnlyWatcherStore) ListAll() ([]*workv1.ManifestWork, error) { 49 | return nil, fmt.Errorf("unsupported") 50 | } 51 | 52 | func (s *CreateOnlyWatcherStore) Get(namespace, name string) (*workv1.ManifestWork, bool, error) { 53 | return nil, false, nil 54 | } 55 | 56 | func (s *CreateOnlyWatcherStore) HasInitiated() bool { 57 | return false 58 | } 59 | -------------------------------------------------------------------------------- /test/performance/pkg/hub/workloads/manifests/aro-hcp/managedcluster.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cluster.open-cluster-management.io/v1 2 | kind: ManagedCluster 3 | metadata: 4 | name: {{ .Name }} 5 | annotations: 6 | import.open-cluster-management.io/hosting-cluster-name: {{ .ClusterName }} 7 | import.open-cluster-management.io/klusterlet-deploy-mode: Hosted 8 | open-cluster-management/created-via: other 9 | addon.open-cluster-management.io/enable-hosted-mode-addons: "true" 10 | spec: 11 | hubAcceptsClient: true 12 | -------------------------------------------------------------------------------- /test/performance/pkg/hub/workloads/manifests/aro-hcp/manifestwork.namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: work.open-cluster-management.io/v1 2 | kind: ManifestWork 3 | metadata: 4 | annotations: 5 | hypershift-deployment.open-cluster-management.io/created-by: ignore/ignore 6 | name: {{ .Name }}-namespace 7 | namespace: {{ .ClusterName }} 8 | labels: 9 | api.openshift.com/environment: maestro-perf-test 10 | api.openshift.com/id: {{ .Name }} 11 | api.openshift.com/legal-entity-id: {{ .Name }} 12 | api.openshift.com/name: {{ .Name }} 13 | api.openshift.com/management-cluster: {{ .ClusterName }} 14 | containsNamespaces: "true" 15 | spec: 16 | deleteOption: 17 | propagationPolicy: Foreground 18 | workload: 19 | manifests: 20 | - apiVersion: v1 21 | kind: Namespace 22 | metadata: 23 | name: {{ .Name }} 24 | spec: {} 25 | -------------------------------------------------------------------------------- /test/performance/pkg/util/events.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/openshift/library-go/pkg/operator/events" 8 | "k8s.io/klog/v2" 9 | ) 10 | 11 | type eventRecorder struct { 12 | source string 13 | } 14 | 15 | func NewRecorder(sourceComponent string) events.Recorder { 16 | return &eventRecorder{source: sourceComponent} 17 | } 18 | 19 | func (r *eventRecorder) ComponentName() string { 20 | return r.source 21 | } 22 | 23 | func (r *eventRecorder) Shutdown() {} 24 | 25 | func (r *eventRecorder) ForComponent(component string) events.Recorder { 26 | // do nothing 27 | return r 28 | } 29 | 30 | func (r *eventRecorder) WithContext(ctx context.Context) events.Recorder { 31 | // do nothing 32 | return r 33 | } 34 | 35 | func (r *eventRecorder) WithComponentSuffix(suffix string) events.Recorder { 36 | // do nothing 37 | return r 38 | } 39 | 40 | func (r *eventRecorder) Event(reason, message string) { 41 | klog.V(4).Infof("[%s] reason=%s, message=%s", r.source, reason, message) 42 | } 43 | 44 | func (r *eventRecorder) Eventf(reason, messageFmt string, args ...interface{}) { 45 | r.Event(reason, fmt.Sprintf(messageFmt, args...)) 46 | } 47 | 48 | func (r *eventRecorder) Warning(reason, message string) { 49 | klog.V(2).Infof("[%s] reason=%s, message=%s", r.source, reason, message) 50 | } 51 | 52 | func (r *eventRecorder) Warningf(reason, messageFmt string, args ...interface{}) { 53 | r.Warning(reason, fmt.Sprintf(messageFmt, args...)) 54 | } 55 | -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/db-cpu-avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/db-cpu-avg.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/db-cpu-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/db-cpu-max.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-avg.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-max.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-ws-avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-ws-avg.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-ws-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/db-mem-ws-max.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-cpu-avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-cpu-avg.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-cpu-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-cpu-max.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-avg.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-max.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-ws-avg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-ws-avg.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-ws-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/cpu-mem/svc-mem-ws-max.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/mqtt/conns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/mqtt/conns.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/mqtt/req-counts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/mqtt/req-counts.png -------------------------------------------------------------------------------- /test/performance/result/aro-hpc/resource-usage/mqtt/throughput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift-online/maestro/6154c6fb6afa6aaebaec0d82bf71f09456af0eec/test/performance/result/aro-hpc/resource-usage/mqtt/throughput.png -------------------------------------------------------------------------------- /test/registration.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | gm "github.com/onsi/gomega" 7 | 8 | "github.com/openshift-online/maestro/pkg/api/openapi" 9 | ) 10 | 11 | // Register a test 12 | // This should be run before every integration test 13 | func RegisterIntegration(t *testing.T) (*Helper, *openapi.APIClient) { 14 | // Register the test with gomega 15 | gm.RegisterTestingT(t) 16 | // Create a new helper 17 | helper := NewHelper(t) 18 | 19 | // Reset the database to a seeded blank state 20 | if err := helper.ResetDB(); err != nil { 21 | panic(err) 22 | } 23 | 24 | // Create an api client 25 | client := helper.NewApiClient() 26 | 27 | return helper, client 28 | } 29 | -------------------------------------------------------------------------------- /test/support/certs.json: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "kid": "HjYaVHwyM77lw0mv7ko-qC7tKri03jqSukNea0SWY7M", 5 | "kty": "RSA", 6 | "alg": "RS256", 7 | "use": "sig", 8 | "n": "q6DF0dZFJnnVVIUtyaVV9Hial9hsSRXtH8Z01kOoAdGwQLqFjKDzNeliOL9KL0i-D71Bo9vKp13Qo8r9UjjNPGV6HzxgXR95MIZP4nqWo9Qp_9SHOjxMSqg-ZFf45p0pSKRdgKTfzu0eJ1CpZt4BdYM9wM3iuOgon09hIMKcO0AU7xqX0KmCg-ToIgVDCaGtXqcC0qv3fr7acTUBoVd8sWNaIOKXiL90cR7oZX_wLoApF2cQyrgTozaMrdEe3RuvwU8hE_r3kYTUYsxTv0liJ8FRfuO5FJuEGVpYc7QDyIztt9YOqowQgHq_2IhqcWhULtzGIXh26voAgWfA2BGAFw", 9 | "e": "AQAB" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/support/jwt_ca.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC/zCCAeegAwIBAgIBATANBgkqhkiG9w0BAQUFADAaMQswCQYDVQQGEwJVUzEL 3 | MAkGA1UECgwCWjQwHhcNMTMwODI4MTgyODM0WhcNMjMwODI4MTgyODM0WjAaMQsw 4 | CQYDVQQGEwJVUzELMAkGA1UECgwCWjQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw 5 | ggEKAoIBAQDfdOqotHd55SYO0dLz2oXengw/tZ+q3ZmOPeVmMuOMIYO/Cv1wk2U0 6 | OK4pug4OBSJPhl09Zs6IwB8NwPOU7EDTgMOcQUYB/6QNCI1J7Zm2oLtuchzz4pIb 7 | +o4ZAhVprLhRyvqi8OTKQ7kfGfs5Tuwmn1M/0fQkfzMxADpjOKNgf0uy6lN6utjd 8 | TrPKKFUQNdc6/Ty8EeTnQEwUlsT2LAXCfEKxTn5RlRljDztS7Sfgs8VL0FPy1Qi8 9 | B+dFcgRYKFrcpsVaZ1lBmXKsXDRu5QR/Rg3f9DRq4GR1sNH8RLY9uApMl2SNz+sR 10 | 4zRPG85R/se5Q06Gu0BUQ3UPm67ETVZLAgMBAAGjUDBOMB0GA1UdDgQWBBQHZPTE 11 | yQVu/0I/3QWhlTyW7WoTzTAfBgNVHSMEGDAWgBQHZPTEyQVu/0I/3QWhlTyW7WoT 12 | zTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQDHxqJ9y8alTH7agVMW 13 | Zfic/RbrdvHwyq+IOrgDToqyo0w+IZ6BCn9vjv5iuhqu4ForOWDAFpQKZW0DLBJE 14 | Qy/7/0+9pk2DPhK1XzdOovlSrkRt+GcEpGnUXnzACXDBbO0+Wrk+hcjEkQRRK1bW 15 | 2rknARIEJG9GS+pShP9Bq/0BmNsMepdNcBa0z3a5B0fzFyCQoUlX6RTqxRw1h1Qt 16 | 5F00pfsp7SjXVIvYcewHaNASbto1n5hrSz1VY9hLba11ivL1N4WoWbmzAL6BWabs 17 | C2D/MenST2/X6hTKyGXpg3Eg2h3iLvUtwcNny0hRKstc73Jl9xR3qXfXKJH0ThTl 18 | q0gq 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /test/support/jwt_private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | Proc-Type: 4,ENCRYPTED 3 | DEK-Info: DES-EDE3-CBC,2E65118E6C7B5207 4 | 5 | 7cYUTW4ZBdmVZ4ILB08hcTdm5ib0E0zcy+I7pHpNQfJHtI7BJ4omys5S19ufJPBJ 6 | IzYjeO7oTVqI37F6EUmjZqG4WVE2UQbQDkosZbZN82O4Ipu1lFAPEbwjqePMKufz 7 | snSQHKfnbyyDPEVNlJbs19NXC8v6g+pQay5rH/I6N2iBxgsTmuemZ54EhNQMZyEN 8 | R/CiheArWEH9H8/4hd2gc9Tb2s0MwGHILL4kbbNm5tp3xw4ik7OYWNrj3m+nG6Xb 9 | vKXh2xEanAZAyMXTqDJTHdn7/CEqusQPJjZGV+Mf1kjKu7p4qcXFnIXP5ILnTW7b 10 | lHoWC4eweDzKOMRzXmbABEVSUvx2SmPl4TcoC5L1SCAHEmZaKbaY7S5l53u6gl0f 11 | ULuQbt7Hr3THznlNFKkGT1/yVNt2QOm1emZd55LaNe8E7XsNSlhl0grYQ+Ue8Jba 12 | x85OapltVjxM9wVCwbgFyi04ihdKHo9e+uYKeTGKv0hU5O7HEH1ev6t/s2u/UG6h 13 | TqEsYrVp0CMHpt5uAF6nZyK6GZ/CHTxh/rz1hADMofem59+e6tVtjnPGA3EjnJT8 14 | BMOw/D2QIDxjxj2GUzz+YJp50ENhWrL9oSDkG2nzv4NVL77QIy+T/2/f4PgokUDO 15 | QJjIfxPWE40cHGHpnQtZvEPoxP0H3T0YhmEVwuJxX3uaWOY/8Fa1c7Ln0SwWdfV5 16 | gYvJV8o6c3sumcq1O3agPDlHC5O4IxG7AZQ8CHRDyASogzfkY6P579ZOGYaO4al7 17 | WA1YIpsHs3/1f4SByMuWe0NVkFfvXckjpqGrBQpTmqQzk6baa0VQ0cwU3XlkwHac 18 | WB/fQ4jylwFzZDcp5JAo53n6aU72zgNvDlGTNKwdXXZI5U3JPocH0AiZgFFWYJLd 19 | 63PJLDnjyE3i6XMVlxifXKkXVv0RYSz+ByS7Oz9aCgnQhNU8ycv+UxtfkPQih5zE 20 | /0Y2EEFknajmFJpNXczzF8OEzaswmR0AOjcCiklZKRf61rf5faJxJhhqKEEBJuL6 21 | oodDVRk3OGU1yQSBazT8nK3V+e6FMo3tWkra2BXFCD+pKxTy014Cp59S1w6F1Fjt 22 | WX7eMWSLWfQ56j2kLMBHq5gb2arqlqH3fsYOTD3TNjCYF3Sgx309kVPuOK5vw61P 23 | pnL/LN3iGY42WR+9lfAyNN2qj9zvwKwscyYs5+DPQoPmcPcVGc3v/u66bLcOGbEU 24 | OlGa/6gdD4GCp5E4fP/7GbnEY/PW2abquFhGB+pVdl3/4+1U/8kItlfWNZoG4FhE 25 | gjMd7glmrdFiNJFFpf5ks1lVXGqJ4mZxqtEZrxUEwciZjm4V27a+E2KyV9NnksZ6 26 | xF4tGPKIPsvNTV5o8ZqjiacxgbYmr2ywqDXKCgpU/RWSh1sLapqSQqbH/w0MquUj 27 | VhVX0RMYH/foKtjagZf/KO1/mnCITl86treIdachGgR4wr/qqMjrpPUaPLCRY3JQ 28 | 00XUP1Mu6YPE0SnMYAVxZheqKHly3a1pg4Xp7YWlM671oUORs3+VENfnbIxgr+2D 29 | TiJT9PxwpfK53Oh7RBSWHJZRuAdLUXE8DG+bl0N/QkJM6pFUxTI1AQ== 30 | -----END RSA PRIVATE KEY----- 31 | --------------------------------------------------------------------------------