├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── 1-bug.yaml │ ├── 2-feature.yaml │ └── config.yaml ├── release.yaml ├── runs-on.yml └── workflows │ ├── build_and_push.yaml │ ├── lint.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── PROJECT ├── README.md ├── api └── v1 │ ├── exec.go │ ├── execstatus.go │ ├── groupversion_info.go │ ├── store.go │ ├── store_deployment_resources_test.go │ ├── store_env.go │ ├── store_status.go │ ├── store_test.go │ ├── storedebuginstance_status.go │ ├── storedebuginstance_types.go │ ├── storesnapshotcreate_types.go │ ├── storesnapshotrestore_types.go │ └── zz_generated.deepcopy.go ├── build ├── Dockerfile ├── licenses.tpl └── snapshot │ └── Dockerfile ├── cmd ├── main.go └── snapshot │ └── snapshot.go ├── config ├── crd │ ├── bases │ │ └── .gitkeep │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ └── patches │ │ ├── cainjection_in_storeexecs.yaml │ │ ├── cainjection_in_storesnapshots.yaml │ │ ├── webhook_in_storeexecs.yaml │ │ └── webhook_in_storesnapshots.yaml ├── default │ ├── kustomization.yaml │ └── pull_secret_patch.yaml ├── helm │ └── kustomization.yaml ├── manager │ ├── kustomization.yaml │ ├── manager.yaml │ ├── manager_auth_proxy_patch.yaml │ └── manager_config_patch.yaml ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── rbac │ ├── auth_proxy_client_clusterrole.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── kustomization.yaml │ ├── leader_election_role.yaml │ ├── leader_election_role_binding.yaml │ ├── name_patch.yaml │ ├── role_binding.yaml │ ├── service_account.yaml │ ├── storesnapshotrestore_admin_role.yaml │ ├── storesnapshotrestore_editor_role.yaml │ └── storesnapshotrestore_viewer_role.yaml └── samples │ ├── kustomization.yaml │ ├── shop_v1_storedebuginstance.yaml │ ├── shop_v1_storeexec.yaml │ └── shop_v1_storesnapshot.yaml ├── examples ├── command.yaml ├── cron.yaml ├── snapshot_create.yaml └── snapshot_restore.yaml ├── go.mod ├── go.sum ├── hack └── boilerplate.go.txt ├── helm ├── .helmignore ├── Chart.yaml ├── LICENSE ├── README.md ├── templates │ ├── .gitkeep │ ├── crds │ │ └── .gitkeep │ └── deployment.yaml └── values.yaml ├── internal ├── config │ └── config.go ├── controller │ ├── event.go │ ├── predicate.go │ ├── store_controller.go │ ├── store_status.go │ ├── storedebuginstance_controller.go │ ├── storedebuginstance_status.go │ ├── storeexec_controller.go │ ├── storeexec_status.go │ ├── storesnapshot_base_reconciler.go │ ├── storesnapshot_create_controller.go │ ├── storesnapshot_restore_controller.go │ └── suite_test.go ├── cronjob │ └── scheduled_task.go ├── deployment │ ├── admin.go │ ├── admin_test.go │ ├── storefront.go │ ├── storefront_test.go │ ├── util.go │ ├── worker.go │ └── worker_test.go ├── event │ ├── event.go │ └── nats │ │ └── nats.go ├── hpa │ └── horizontal_pod_autoscaler.go ├── ingress │ └── ingress.go ├── job │ ├── command.go │ ├── command_test.go │ ├── migration.go │ ├── migration_test.go │ ├── setup.go │ ├── setup_test.go │ ├── snapshot.go │ └── util.go ├── k8s │ └── utils.go ├── logging │ └── logger.go ├── pdb │ └── pod_distrubtion_budget.go ├── pod │ └── debug.go ├── secret │ └── secret.go ├── service │ ├── admin.go │ ├── debug.go │ └── storefront.go ├── snapshot │ ├── archive.go │ ├── s3.go │ └── service.go └── util │ ├── annotations.go │ ├── db.go │ ├── db_test.go │ ├── env.go │ ├── env_merge_test.go │ ├── env_test.go │ ├── labels.go │ ├── labels_test.go │ ├── map.go │ ├── mysql_dump.go │ ├── mysql_shell.go │ ├── opensearch.go │ ├── ptr.go │ ├── s3.go │ └── s3_test.go └── shopware.svg /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Make a bug report. 3 | labels: ["bug", "triage"] 4 | projects: ["shopware/17"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: textarea 11 | id: reproduce 12 | attributes: 13 | label: Steps to reproduce 14 | description: Tell us, how we can reproduce the issue? 15 | validations: 16 | required: false 17 | - type: textarea 18 | id: what-happened 19 | attributes: 20 | label: What should happened? 21 | description: Tell us, what did you expect to happen? 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: what-actually 26 | attributes: 27 | label: What actually happened? 28 | description: Tell us, what actually happen? 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: logs 33 | attributes: 34 | label: Relevant log output 35 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 36 | render: shell 37 | - type: textarea 38 | id: crd 39 | attributes: 40 | label: Your custom resource 41 | description: Please copy and paste the custom resource for the operator. This will be automatically formatted into code, so no need for backticks. Please make sure to remove Credentials!!! 42 | render: shell 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Make a feature request. 3 | labels: ["feature", "triage"] 4 | projects: ["shopware/17"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this feature request! 10 | - type: textarea 11 | id: improvement 12 | attributes: 13 | label: Improvement 14 | description: Tell us, how we can improve our Project? 15 | validations: 16 | required: false 17 | - type: textarea 18 | id: reason 19 | attributes: 20 | label: Reason 21 | description: Tell us, what is your reason behind this feature request? 22 | validations: 23 | required: false 24 | - type: textarea 25 | id: information 26 | attributes: 27 | label: Information 28 | description: Tell us, what additional information could be useful 29 | validations: 30 | required: false 31 | - type: textarea 32 | id: dod 33 | attributes: 34 | label: Definition of Done 35 | description: Make a checklist when the improvement is done 36 | validations: 37 | required: false 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: Breaking Changes 7 | labels: 8 | - Semver-Major 9 | - breaking-change 10 | - title: Exciting New Features 11 | labels: 12 | - Semver-Minor 13 | - enhancement 14 | - title: Other Changes 15 | labels: 16 | - "*" 17 | -------------------------------------------------------------------------------- /.github/runs-on.yml: -------------------------------------------------------------------------------- 1 | _extends: .github-private 2 | -------------------------------------------------------------------------------- /.github/workflows/build_and_push.yaml: -------------------------------------------------------------------------------- 1 | # The name of this file is used in the helm charts repo for octo-sts. 2 | # Please keep that in mind if you want to rename this file. 3 | name: Build and push 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | branches: 9 | - "main" 10 | 11 | permissions: 12 | contents: write 13 | packages: write 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | 18 | jobs: 19 | goreleaser: 20 | runs-on: runs-on=${{ github.run_id }}/runner=sw-amd64/cpu=8 21 | outputs: 22 | tag: ${{ steps.get_tag.outputs.tag }} 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Setup Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: stable 33 | cache: true 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | with: 38 | driver-opts: | 39 | image=moby/buildkit:v0.12.5 40 | network=host 41 | 42 | - name: Login to GitHub Container Registry 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.actor }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Run goreleaser on developer branch 50 | id: goreleaser 51 | uses: goreleaser/goreleaser-action@v6 52 | with: 53 | distribution: goreleaser 54 | version: "~> v2" 55 | args: release --clean --snapshot 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Push images 60 | run: | 61 | docker push ghcr.io/shopware/shopware-operator:${{ fromJSON(steps.goreleaser.outputs.metadata).version }}-arm64 62 | docker push ghcr.io/shopware/shopware-operator:${{ fromJSON(steps.goreleaser.outputs.metadata).version }}-amd64 63 | 64 | - name: Create and push manifest for :snapshot tag 65 | run: | 66 | repo='ghcr.io/shopware/shopware-operator:${{ fromJSON(steps.goreleaser.outputs.metadata).version }}' 67 | docker manifest create $repo ${repo}-arm64 ${repo}-amd64 68 | docker manifest push $repo 69 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # The name of this file is used in the helm charts repo for octo-sts. 2 | # Please keep that in mind if you want to rename this file. 3 | name: Release Tag version 4 | 5 | on: 6 | push: 7 | tags: 8 | - "*.*.*" 9 | 10 | permissions: 11 | contents: write 12 | issues: write 13 | pull-requests: write 14 | id-token: write 15 | packages: write 16 | 17 | env: 18 | REGISTRY: ghcr.io 19 | 20 | jobs: 21 | goreleaser: 22 | runs-on: runs-on=${{ github.run_id }}/runner=sw-amd64/cpu=8 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Setup Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: stable 33 | cache: true 34 | 35 | - name: Cache Go build cache 36 | uses: actions/cache@v4 37 | with: 38 | path: | 39 | ~/.cache/go-build 40 | ~/go/pkg/mod 41 | key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} 42 | restore-keys: | 43 | ${{ runner.os }}-go-build- 44 | 45 | - name: Set up Docker Buildx 46 | uses: docker/setup-buildx-action@v3 47 | with: 48 | driver-opts: | 49 | image=moby/buildkit:v0.12.5 50 | network=host 51 | 52 | - name: Login to GitHub Container Registry 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ghcr.io 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Create resources for release 60 | run: | 61 | make resources path=release 62 | make licenses path=release 63 | 64 | - name: Run goreleaser on release branch 65 | id: goreleaser 66 | uses: goreleaser/goreleaser-action@v6 67 | with: 68 | distribution: goreleaser 69 | version: "~> v2" 70 | args: release --clean 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | 74 | - name: Update release with kube generated files 75 | uses: svenstaro/upload-release-action@v2 76 | with: 77 | repo_token: ${{ secrets.GITHUB_TOKEN }} 78 | tag: ${{ github.ref_name }} 79 | overwrite: true 80 | file_glob: true 81 | file: release/* 82 | 83 | release-helm-chart: 84 | runs-on: runs-on=${{ github.run_id }}/runner=sw-amd64/cpu=1 85 | needs: [goreleaser] 86 | steps: 87 | - name: Checkout 88 | uses: actions/checkout@v4 89 | with: 90 | fetch-depth: 0 91 | 92 | - name: Setup Go 93 | uses: actions/setup-go@v5 94 | with: 95 | go-version: stable 96 | cache: true 97 | 98 | - uses: octo-sts/action@v1.0.0 99 | id: octo-sts 100 | with: 101 | scope: shopware/helm-charts 102 | identity: shopware-operator 103 | 104 | - name: make pr on helm-charts for this release 105 | run: | 106 | make manifests 107 | tag=${{ github.ref_name }} 108 | git clone https://oauth2:${GITHUB_TOKEN}@github.com/shopware/helm-charts.git 109 | rm -rf helm-charts/charts/shopware-operator 110 | make helm version=${tag} path=helm-charts/charts/shopware-operator 111 | cd helm-charts 112 | git config user.name "$GITHUB_ACTOR" 113 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 114 | git checkout -b auto-release-${tag} 115 | git add . 116 | git commit -m "New shopware operator image" 117 | git push origin auto-release-${tag} 118 | gh pr create --title "Autorelease from shopware operator" --body "Auto pr from shopware operator" --head "auto-release-${tag}" --base main --reviewer shopware/product-paas --label autorelease 119 | env: 120 | GITHUB_TOKEN: ${{ steps.octo-sts.outputs.token }} 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /release 2 | 3 | /config/crd/bases/* 4 | 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.tar.gz 10 | *.zip 11 | *.gz 12 | *.so 13 | *.dylib 14 | *.zip 15 | bin/* 16 | Dockerfile.cross 17 | 18 | # Test binary, build with `go test -c` 19 | *.test 20 | 21 | # Output of the go coverage tool, specifically when used with LiteIDE 22 | *.out 23 | 24 | # Kubernetes Generated files - skip generated files, except for vendored files 25 | 26 | !vendor/**/zz_generated.* 27 | 28 | # editor and IDE paraphernalia 29 | .idea 30 | .vscode 31 | *.swp 32 | *.swo 33 | *~ 34 | dist/ 35 | config/rbac/role.yaml 36 | .claude/ 37 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | allow-parallel-runners: true 4 | linters: 5 | enable: 6 | - dupl 7 | - gocyclo 8 | - lll 9 | - misspell 10 | - nakedret 11 | - prealloc 12 | - unconvert 13 | - unparam 14 | exclusions: 15 | generated: lax 16 | rules: 17 | - linters: 18 | - lll 19 | path: api/* 20 | - linters: 21 | - dupl 22 | path: api/v1/store_test.go 23 | - linters: 24 | - dupl 25 | - lll 26 | path: internal/* 27 | paths: 28 | - third_party$ 29 | - builtin$ 30 | - examples$ 31 | formatters: 32 | enable: 33 | - gofmt 34 | - goimports 35 | exclusions: 36 | generated: lax 37 | paths: 38 | - third_party$ 39 | - builtin$ 40 | - examples$ 41 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | project_name: shopware-operator 9 | version: 2 10 | 11 | before: 12 | hooks: 13 | - go mod tidy 14 | - make licenses path=. 15 | 16 | builds: 17 | - id: manager 18 | binary: manager 19 | main: ./cmd 20 | env: 21 | - CGO_ENABLED=0 22 | goos: 23 | - linux 24 | - darwin 25 | 26 | - id: snapshot 27 | binary: snapshot 28 | main: ./cmd/snapshot 29 | env: 30 | - CGO_ENABLED=0 31 | goos: 32 | - linux 33 | - darwin 34 | 35 | snapshot: 36 | version_template: "{{.Branch}}" 37 | 38 | dockers_v2: 39 | - id: operator 40 | images: 41 | - "ghcr.io/shopware/{{.ProjectName}}" 42 | tags: 43 | - "{{.Version}}" 44 | platforms: 45 | - linux/amd64 46 | - linux/arm64 47 | extra_files: 48 | - "LICENSE" 49 | - "third-party-licenses.md" 50 | dockerfile: build/Dockerfile 51 | 52 | - id: snapshot 53 | images: 54 | - "ghcr.io/shopware/{{.ProjectName}}-snapshot" 55 | tags: 56 | - "{{.Version}}" 57 | platforms: 58 | - linux/amd64 59 | - linux/arm64 60 | extra_files: 61 | - "LICENSE" 62 | - "third-party-licenses.md" 63 | dockerfile: build/snapshot/Dockerfile 64 | 65 | archives: 66 | - formats: ["tar.gz"] 67 | # this name template makes the OS and Arch compatible with the results of `uname`. 68 | name_template: >- 69 | {{ .ProjectName }}_ 70 | {{- title .Os }}_ 71 | {{- if eq .Arch "amd64" }}x86_64 72 | {{- else if eq .Arch "386" }}i386 73 | {{- else }}{{ .Arch }}{{ end }} 74 | {{- if .Arm }}v{{ .Arm }}{{ end }} 75 | # use zip for windows archives 76 | format_overrides: 77 | - goos: windows 78 | formats: ["zip"] 79 | files: 80 | - release/* 81 | 82 | changelog: 83 | sort: asc 84 | filters: 85 | exclude: 86 | - "^docs:" 87 | - "^test:" 88 | 89 | release: 90 | make_latest: true 91 | disable: false 92 | skip_upload: false 93 | mode: append 94 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | # Code generated by tool. DO NOT EDIT. 2 | # This file is used to track the info used to scaffold your project 3 | # and allow the plugins properly work. 4 | # More info: https://book.kubebuilder.io/reference/project-config.html 5 | domain: shopware.com 6 | layout: 7 | - go.kubebuilder.io/v4 8 | projectName: shopware-operator 9 | repo: github.com/shopware/shopware-operator 10 | resources: 11 | - api: 12 | crdVersion: v1 13 | namespaced: true 14 | controller: true 15 | domain: shopware.com 16 | group: shop 17 | kind: Store 18 | path: github.com/shopware/shopware-operator/api/v1 19 | version: v1 20 | - api: 21 | crdVersion: v1 22 | namespaced: true 23 | controller: true 24 | domain: shopware.com 25 | group: shop 26 | kind: StoreExec 27 | path: github.com/shopware/shopware-operator/api/v1 28 | version: v1 29 | - api: 30 | crdVersion: v1 31 | namespaced: true 32 | controller: true 33 | domain: shopware.com 34 | group: shop 35 | kind: StoreSnapshotCreate 36 | path: github.com/shopware/shopware-operator/api/v1 37 | version: v1 38 | - api: 39 | crdVersion: v1 40 | namespaced: true 41 | controller: true 42 | domain: shopware.com 43 | group: shop 44 | kind: StoreDebugInstance 45 | path: github.com/shopware/shopware-operator/api/v1 46 | version: v1 47 | - api: 48 | crdVersion: v1 49 | namespaced: true 50 | domain: shopware.com 51 | group: shop 52 | kind: StoreSnapshotRestore 53 | path: github.com/shopware/shopware-operator/api/v1 54 | version: v1 55 | version: "3" 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopware Operator 2 | 3 | ![Shopware Kubernetes Operator](shopware.svg) 4 | 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/shopware/shopware-operator) 7 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/shopware/shopware-operator) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/shopware/shopware-operator)](https://goreportcard.com/report/github.com/shopware/shopware-operator) 9 | 10 | ## Overview 11 | 12 | This repository contains the Shopware Operator for Kubernetes. The Operator is a Kubernetes controller that manages Shopware installations in a Kubernetes cluster. 13 | 14 | ## Disclaimer 15 | 16 | This Shopware operator is currently in an experimental phase and is not yet ready for production use. 17 | The features, functionalities, and individual steps described in this repository are still under 18 | development and are not in a final state. As such, they may contain bugs, incomplete 19 | implementations, or other issues that could affect the stability and performance of your 20 | Shopware installation. 21 | 22 | Please be aware that using this operator in a live environment could lead to unexpected 23 | behavior, data loss, or other critical problems. We strongly recommend using this operator 24 | for testing and development purposes only. 25 | 26 | By using this software, you acknowledge that you understand these risks and agree not 27 | to hold the developers or maintainers of this repository liable for any 28 | damage or loss that may occur. 29 | 30 | If you encounter any issues or have suggestions for improvements, please feel free to 31 | open an issue or contribute to the project. 32 | 33 | ## Installation 34 | 35 | Below you find a descriptions how to deploy the Operator using `helm` or `kubectl`. 36 | 37 | ### Helm 38 | 39 | For a helm installation check out our [charts repository](https://github.com/shopware/helm-charts/tree/main/charts/shopware-operator) 40 | 41 | ### kubectl 42 | 43 | 1. Install the custom resource definitions (cdr) for your cluster: 44 | 45 | ```sh 46 | kubectl apply -f https://github.com/shopware/shopware-operator/releases/latest/download/crd.yaml --server-side 47 | ``` 48 | 49 | 2. Deploy the operator itself from `manager.yaml`: 50 | 51 | ```sh 52 | kubectl apply -f https://github.com/shopware/shopware-operator/releases/latest/download/manager.yaml 53 | ``` 54 | 55 | > [!IMPORTANT] 56 | > This will install the Operator in the default namespace, if you want to change this use `kubectl -n apply -f ...` 57 | 58 | ## Local Development 59 | 60 | To set up a local development environment, you must have the following components in place: 61 | 62 | - A valid Store custom resource 63 | - A MySQL-compatible database 64 | - An S3-compatible object storage 65 | 66 | These are required for a basic Shopware deployment within a Kubernetes cluster. 67 | 68 | We recommend using the [Shopware Helm Chart](https://github.com/shopware/helm-charts/tree/main/charts/shopware), which includes a Percona-based MySQL database and an S3-compatible interface 69 | provided by MinIO.To run the operator within your cluster, execute the following command: 70 | 71 | ```sh 72 | NAMESPACE=default make run 73 | ``` 74 | 75 | > [!IMPORTANT] 76 | > Ensure that you are using the correct Kubernetes context before running the command. 77 | 78 | ## Limitations and Issues 79 | 80 | #### Sidecars 81 | 82 | When using sidecars, please ensure they are properly terminated. Unfortunately, Kubernetes does not provide a reliable mechanism for 83 | managing the shutdown of jobs (such as setup and migration jobs). As a result, we cannot guarantee that containers within the pod will 84 | be stopped correctly. To address this, the job will be deleted once the operator container has completed its task. 85 | 86 | ## Contributing 87 | 88 | Shopware welcomes community contributions to help improving the Shopware Operator. 89 | If you found a bug or want to change something create an issue before fixing/changing it. 90 | 91 | Another good place to discuss the Shopware Operator with developers and other community members is the Slack channel: 92 | -------------------------------------------------------------------------------- /api/v1/exec.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 6 | ) 7 | 8 | type StatefulState string 9 | 10 | const ( 11 | ExecStateEmpty StatefulState = "" 12 | ExecStateWait StatefulState = "wait" 13 | ExecStateRunning StatefulState = "running" 14 | ExecStateDone StatefulState = "done" 15 | ExecStateError StatefulState = "error" 16 | ) 17 | 18 | type StoreExecSpec struct { 19 | StoreRef string `json:"storeRef"` 20 | CronSchedule string `json:"cronSchedule,omitempty"` 21 | 22 | // +kubebuilder:default=false 23 | CronSuspend bool `json:"cronSuspend,omitempty"` 24 | Script string `json:"script"` 25 | 26 | // +kubebuilder:default=3 27 | MaxRetries int32 `json:"maxRetries,omitempty"` 28 | 29 | ExtraEnvs []corev1.EnvVar `json:"extraEnvs,omitempty"` 30 | 31 | Container ContainerSpec `json:"container,omitempty"` 32 | } 33 | 34 | // +kubebuilder:object:root=true 35 | // +kubebuilder:subresource:status 36 | // +kubebuilder:printcolumn:name="State",type=string,JSONPath=".status.state" 37 | // +kubebuilder:printcolumn:name="MaxRetries",type=string,JSONPath=".spec.maxRetries" 38 | // +kubebuilder:printcolumn:name="CronSchedule",type=string,JSONPath=".spec.cronSchedule" 39 | // +kubebuilder:printcolumn:name="CronSuspend",type=string,JSONPath=".spec.cronSuspend" 40 | // +kubebuilder:resource:scope=Namespaced 41 | // +kubebuilder:resource:shortName=stexec 42 | type StoreExec struct { 43 | metav1.TypeMeta `json:",inline"` 44 | metav1.ObjectMeta `json:"metadata,omitempty"` 45 | 46 | Spec StoreExecSpec `json:"spec,omitempty"` 47 | Status StoreExecStatus `json:"status,omitempty"` 48 | } 49 | 50 | // +kubebuilder:object:root=true 51 | type StoreExecList struct { 52 | metav1.TypeMeta `json:",inline"` 53 | metav1.ListMeta `json:"metadata,omitempty"` 54 | Items []StoreExec `json:"items"` 55 | } 56 | 57 | func init() { 58 | SchemeBuilder.Register(&StoreExec{}, &StoreExecList{}) 59 | } 60 | -------------------------------------------------------------------------------- /api/v1/execstatus.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "slices" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | ) 8 | 9 | type StoreExecStatus struct { 10 | State StatefulState `json:"state,omitempty"` 11 | 12 | Done string `json:"ready,omitempty"` 13 | Conditions []ExecCondition `json:"conditions,omitempty"` 14 | } 15 | 16 | type ExecCondition struct { 17 | Type StatefulState `json:"type,omitempty"` 18 | LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` 19 | LastUpdateTime metav1.Time `json:"lastUpdatedTime,omitempty"` 20 | Message string `json:"message,omitempty"` 21 | Reason string `json:"reason,omitempty"` 22 | Status string `json:"status,omitempty"` 23 | } 24 | 25 | func (s *StoreExecStatus) AddCondition(c ExecCondition) { 26 | if len(s.Conditions) == 0 { 27 | s.Conditions = append(s.Conditions, c) 28 | return 29 | } 30 | 31 | // Update latest condition if the type is the same 32 | if s.Conditions[len(s.Conditions)-1].Type == c.Type { 33 | s.Conditions[len(s.Conditions)-1] = c 34 | return 35 | } 36 | 37 | // Add condition if the type is different then the last one 38 | if s.Conditions[len(s.Conditions)-1].Type != c.Type { 39 | s.Conditions = append(s.Conditions, c) 40 | } 41 | 42 | if len(s.Conditions) > maxStatusesQuantity { 43 | s.Conditions = s.Conditions[len(s.Conditions)-maxStatusesQuantity:] 44 | } 45 | } 46 | 47 | func (s *StoreExec) IsState(states ...StatefulState) bool { 48 | return slices.Contains(states, s.Status.State) 49 | } 50 | -------------------------------------------------------------------------------- /api/v1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | // Package v1 contains API Schema definitions for the shop v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=shop.shopware.com 20 | package v1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | var ( 28 | // GroupVersion is group version used to register these objects 29 | GroupVersion = schema.GroupVersion{Group: "shop.shopware.com", Version: "v1"} 30 | 31 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 32 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 33 | 34 | // AddToScheme adds the types in this group-version to the given scheme. 35 | AddToScheme = SchemeBuilder.AddToScheme 36 | ) 37 | -------------------------------------------------------------------------------- /api/v1/store_status.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "slices" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | ) 8 | 9 | const maxStatusesQuantity = 6 10 | 11 | type ( 12 | StatefulAppState string 13 | DeploymentState string 14 | ) 15 | 16 | const ( 17 | StateEmpty StatefulAppState = "" 18 | StateWait StatefulAppState = "waiting" 19 | StateSetup StatefulAppState = "setup" 20 | StateSetupError StatefulAppState = "setup_error" 21 | StateInitializing StatefulAppState = "initializing" 22 | StateMigration StatefulAppState = "migrating" 23 | StateMigrationError StatefulAppState = "migrating_error" 24 | StateReady StatefulAppState = "ready" 25 | ) 26 | 27 | const ( 28 | DeploymentStateUnknown DeploymentState = "unknown" 29 | DeploymentStateError DeploymentState = "error" 30 | DeploymentStateNotFound DeploymentState = "not-found" 31 | DeploymentStateRunning DeploymentState = "running" 32 | DeploymentStateScaling DeploymentState = "scaling" 33 | ) 34 | 35 | type StoreCondition struct { 36 | Type string `json:"type,omitempty"` 37 | LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` 38 | LastUpdateTime metav1.Time `json:"lastUpdatedTime,omitempty"` 39 | Message string `json:"message,omitempty"` 40 | Reason string `json:"reason,omitempty"` 41 | Status string `json:"status,omitempty"` 42 | } 43 | 44 | type StoreConditionsList struct { 45 | Conditions []StoreCondition `json:"conditions,omitempty"` 46 | } 47 | 48 | type DeploymentCondition struct { 49 | State DeploymentState `json:"state,omitempty"` 50 | LastUpdateTime metav1.Time `json:"lastUpdatedTime,omitempty"` 51 | Message string `json:"message,omitempty"` 52 | Ready string `json:"ready,omitempty"` 53 | StoreReplicas int32 `json:"storeReplicas,omitempty"` 54 | } 55 | 56 | type StoreStatus struct { 57 | State StatefulAppState `json:"state,omitempty"` 58 | Message string `json:"message,omitempty"` 59 | CurrentImageTag string `json:"currentImageTag,omitempty"` 60 | 61 | WorkerState DeploymentCondition `json:"workerState,omitempty"` 62 | AdminState DeploymentCondition `json:"adminState,omitempty"` 63 | StorefrontState DeploymentCondition `json:"storefrontState,omitempty"` 64 | 65 | StoreConditionsList `json:",inline"` 66 | } 67 | 68 | func (s *StoreConditionsList) AddCondition(c StoreCondition) { 69 | if len(s.Conditions) == 0 { 70 | s.Conditions = append(s.Conditions, c) 71 | return 72 | } 73 | 74 | // Update latest condition if the type is the same 75 | if s.Conditions[len(s.Conditions)-1].Type == c.Type { 76 | s.Conditions[len(s.Conditions)-1] = c 77 | return 78 | } 79 | 80 | // Add condition if the type is different then the last one 81 | if s.Conditions[len(s.Conditions)-1].Type != c.Type { 82 | s.Conditions = append(s.Conditions, c) 83 | } 84 | 85 | // Remove oldest conditions if the length exceeds maxStatusesQuantity 86 | if len(s.Conditions) > maxStatusesQuantity { 87 | s.Conditions = s.Conditions[len(s.Conditions)-maxStatusesQuantity:] 88 | } 89 | } 90 | 91 | func (s *StoreConditionsList) GetLastCondition() StoreCondition { 92 | if len(s.Conditions) == 0 { 93 | return StoreCondition{} 94 | } 95 | return s.Conditions[len(s.Conditions)-1] 96 | } 97 | 98 | func (s *Store) IsState(states ...StatefulAppState) bool { 99 | return slices.Contains(states, s.Status.State) 100 | } 101 | -------------------------------------------------------------------------------- /api/v1/storedebuginstance_status.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "slices" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | ) 8 | 9 | type StoreDebugInstanceState string 10 | 11 | const ( 12 | StoreDebugInstanceStateUnspecified StoreDebugInstanceState = "" 13 | StoreDebugInstanceStateWait StoreDebugInstanceState = "wait" 14 | StoreDebugInstanceStatePending StoreDebugInstanceState = "pending" 15 | StoreDebugInstanceStateRunning StoreDebugInstanceState = "running" 16 | StoreDebugInstanceStateDone StoreDebugInstanceState = "done" 17 | StoreDebugInstanceStateError StoreDebugInstanceState = "error" 18 | ) 19 | 20 | // StoreDebugInstanceStatus defines the observed state of StoreDebugInstance. 21 | type StoreDebugInstanceStatus struct { 22 | State StoreDebugInstanceState `json:"state,omitempty"` 23 | 24 | Conditions []StoreDebugInstanceCondition `json:"conditions,omitempty"` 25 | } 26 | 27 | type StoreDebugInstanceCondition struct { 28 | Type StoreDebugInstanceState `json:"type,omitempty"` 29 | LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` 30 | LastUpdateTime metav1.Time `json:"lastUpdatedTime,omitempty"` 31 | Message string `json:"message,omitempty"` 32 | Reason string `json:"reason,omitempty"` 33 | Status string `json:"status,omitempty"` 34 | } 35 | 36 | func (s *StoreDebugInstanceStatus) AddCondition(c StoreDebugInstanceCondition) { 37 | if len(s.Conditions) == 0 { 38 | s.Conditions = append(s.Conditions, c) 39 | return 40 | } 41 | 42 | // Update latest condition if the type is the same 43 | if s.Conditions[len(s.Conditions)-1].Type == c.Type { 44 | s.Conditions[len(s.Conditions)-1] = c 45 | return 46 | } 47 | 48 | // Add condition if the type is different then the last one 49 | if s.Conditions[len(s.Conditions)-1].Type != c.Type { 50 | s.Conditions = append(s.Conditions, c) 51 | } 52 | 53 | if len(s.Conditions) > maxStatusesQuantity { 54 | s.Conditions = s.Conditions[len(s.Conditions)-maxStatusesQuantity:] 55 | } 56 | } 57 | 58 | func (s *StoreDebugInstance) IsState(states ...StoreDebugInstanceState) bool { 59 | return slices.Contains(states, s.Status.State) 60 | } 61 | -------------------------------------------------------------------------------- /api/v1/storedebuginstance_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | package v1 18 | 19 | import ( 20 | corev1 "k8s.io/api/core/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | // StoreDebugInstanceSpec defines the desired state of StoreDebugInstance. 25 | type StoreDebugInstanceSpec struct { 26 | // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster 27 | // Important: Run "make" to regenerate code after modifying this file 28 | 29 | // StoreRef is the reference to the store to debug 30 | StoreRef string `json:"storeRef,omitempty"` 31 | // Duration is the duration of the debug instance after which it will be deleted 32 | // e.g. 1h or 30m 33 | // +default="1h" 34 | Duration string `json:"duration,omitempty"` 35 | // ExtraLabels is the extra labels to add to the debug instance 36 | ExtraLabels map[string]string `json:"extraLabels,omitempty"` 37 | // ExtraContainerPorts is the extra ports to add to the debug instance 38 | // if it should be exposed to the outside world 39 | ExtraContainerPorts []corev1.ContainerPort `json:"extraContainerPorts,omitempty"` 40 | } 41 | 42 | // +kubebuilder:object:root=true 43 | // +kubebuilder:subresource:status 44 | // +kubebuilder:printcolumn:name="State",type=string,JSONPath=".status.state" 45 | // +kubebuilder:resource:scope=Namespaced 46 | // +kubebuilder:resource:shortName=stdi 47 | // StoreDebugInstance is the Schema for the storedebuginstances API. 48 | type StoreDebugInstance struct { 49 | metav1.TypeMeta `json:",inline"` 50 | metav1.ObjectMeta `json:"metadata,omitempty"` 51 | 52 | Spec StoreDebugInstanceSpec `json:"spec,omitempty"` 53 | Status StoreDebugInstanceStatus `json:"status,omitempty"` 54 | } 55 | 56 | // +kubebuilder:object:root=true 57 | 58 | // StoreDebugInstanceList contains a list of StoreDebugInstance. 59 | type StoreDebugInstanceList struct { 60 | metav1.TypeMeta `json:",inline"` 61 | metav1.ListMeta `json:"metadata,omitempty"` 62 | Items []StoreDebugInstance `json:"items"` 63 | } 64 | 65 | func init() { 66 | SchemeBuilder.Register(&StoreDebugInstance{}, &StoreDebugInstanceList{}) 67 | } 68 | -------------------------------------------------------------------------------- /api/v1/storesnapshotrestore_types.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | ) 6 | 7 | // +kubebuilder:object:root=true 8 | // +kubebuilder:subresource:status 9 | // +kubebuilder:resource:scope=Namespaced 10 | // +kubebuilder:resource:shortName=store-snap-restore 11 | // +kubebuilder:printcolumn:name="State",type=string,JSONPath=".status.state" 12 | // +kubebuilder:printcolumn:name="Message",type=string,JSONPath=".status.message" 13 | // +kubebuilder:printcolumn:name="Completed",type="date",JSONPath=".status.completed" 14 | // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" 15 | 16 | // StoreSnapshotRestore is the Schema for the storesnapshotrestores API. 17 | type StoreSnapshotRestore struct { 18 | metav1.TypeMeta `json:",inline"` 19 | metav1.ObjectMeta `json:"metadata,omitempty"` 20 | 21 | Spec StoreSnapshotSpec `json:"spec,omitempty"` 22 | Status StoreSnapshotStatus `json:"status,omitempty"` 23 | } 24 | 25 | // +kubebuilder:object:root=true 26 | 27 | // StoreSnapshotRestoreList contains a list of StoreSnapshotRestore. 28 | type StoreSnapshotRestoreList struct { 29 | metav1.TypeMeta `json:",inline"` 30 | metav1.ListMeta `json:"metadata,omitempty"` 31 | Items []StoreSnapshotRestore `json:"items"` 32 | } 33 | 34 | func init() { 35 | SchemeBuilder.Register(&StoreSnapshotRestore{}, &StoreSnapshotRestoreList{}) 36 | } 37 | 38 | // GetObjectMeta implements SnapshotResource interface 39 | func (s *StoreSnapshotRestore) GetObjectMeta() metav1.Object { 40 | return s 41 | } 42 | 43 | // GetSpec implements SnapshotResource interface 44 | func (s *StoreSnapshotRestore) GetSpec() StoreSnapshotSpec { 45 | return s.Spec 46 | } 47 | 48 | // GetStatus implements SnapshotResource interface 49 | func (s *StoreSnapshotRestore) GetStatus() *StoreSnapshotStatus { 50 | return &s.Status 51 | } 52 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use distroless as minimal base image to package the manager binary 2 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 3 | FROM --platform=$TARGETPLATFORM gcr.io/distroless/static:nonroot 4 | 5 | ARG TARGETPLATFORM 6 | ARG BUILDPLATFORM 7 | 8 | ENV NAMESPACE=default 9 | 10 | COPY $TARGETPLATFORM/manager / 11 | COPY LICENSE /LICENSE 12 | COPY third-party-licenses.md /third-party-licenses.md 13 | USER 65532:65532 14 | 15 | ENTRYPOINT ["/manager"] 16 | -------------------------------------------------------------------------------- /build/licenses.tpl: -------------------------------------------------------------------------------- 1 | {{ range . }} 2 | ## {{.Name}} ([{{.LicenseName}}]({{.LicenseURL}})) 3 | 4 | ``` 5 | {{- .LicenseText -}} 6 | ``` 7 | {{- end }} 8 | -------------------------------------------------------------------------------- /build/snapshot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$TARGETPLATFORM debian:stable-slim 2 | 3 | ARG TARGETPLATFORM 4 | ARG BUILDPLATFORM 5 | 6 | # https://dev.mysql.com/get/Downloads/MySQL-Shell/mysql-shell-8.4.6-linux-glibc2.28-arm-64bit.tar.gz 7 | # https://dev.mysql.com/get/Downloads/MySQL-Shell/mysql-shell-8.4.6-linux-glibc2.28-x86-64bit.tar.gz 8 | 9 | # Dependencies for MySQL Shell 10 | RUN apt-get update && apt-get install -y \ 11 | wget libncurses6 libtinfo6 libstdc++6 libssl3 \ 12 | libuuid1 libkeyutils1 krb5-multidev \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | # Download MySQL Shell - detect architecture and download appropriate version 16 | RUN ARCH=$(dpkg --print-architecture) && \ 17 | if [ "$ARCH" = "amd64" ]; then \ 18 | MYSQL_ARCH="x86-64"; \ 19 | elif [ "$ARCH" = "arm64" ]; then \ 20 | MYSQL_ARCH="arm-64"; \ 21 | else \ 22 | echo "Unsupported architecture: $ARCH" && exit 1; \ 23 | fi && \ 24 | wget -q https://dev.mysql.com/get/Downloads/MySQL-Shell/mysql-shell-8.4.6-linux-glibc2.28-${MYSQL_ARCH}bit.tar.gz && \ 25 | mkdir -p /opt/mysqlsh && \ 26 | tar -xzf mysql-shell-*.tar.gz -C /opt/mysqlsh --strip-components=1 && \ 27 | rm mysql-shell-*.tar.gz 28 | 29 | COPY $TARGETPLATFORM/snapshot / 30 | COPY LICENSE /LICENSE 31 | COPY third-party-licenses.md /third-party-licenses.md 32 | 33 | #ENTRYPOINT ["/opt/mysqlsh/bin/mysqlsh"] 34 | ENTRYPOINT ["/snapshot"] 35 | CMD ["--help"] 36 | 37 | -------------------------------------------------------------------------------- /config/crd/bases/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopware/shopware-operator/e3ad4aca54e6a89762ee0618f119c354b4f2657c/config/crd/bases/.gitkeep -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/shop.shopware.com_stores.yaml 6 | - bases/shop.shopware.com_storeexecs.yaml 7 | - bases/shop.shopware.com_storedebuginstances.yaml 8 | - bases/shop.shopware.com_storesnapshotcreates.yaml 9 | - bases/shop.shopware.com_storesnapshotrestores.yaml 10 | #+kubebuilder:scaffold:crdkustomizeresource 11 | 12 | #patches: 13 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 14 | # patches here are for enabling the conversion webhook for each CRD 15 | #- path: patches/webhook_in_stores.yaml 16 | #- path: patches/webhook_in_storeexecs.yaml 17 | #- path: patches/webhook_in_storesnapshots.yaml 18 | #+kubebuilder:scaffold:crdkustomizewebhookpatch 19 | 20 | # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. 21 | # patches here are for enabling the CA injection for each CRD 22 | #- path: patches/cainjection_in_stores.yaml 23 | #- path: patches/cainjection_in_storeexecs.yaml 24 | #- path: patches/cainjection_in_storesnapshots.yaml 25 | #+kubebuilder:scaffold:crdkustomizecainjectionpatch 26 | 27 | # [WEBHOOK] To enable webhook, uncomment the following section 28 | # the following config is for teaching kustomize how to do kustomization for CRDs. 29 | 30 | #configurations: 31 | #- kustomizeconfig.yaml 32 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_storeexecs.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME 7 | name: storeexecs.shop.shopware.com 8 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_storesnapshots.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME 7 | name: storesnapshots.shop.shopware.com 8 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_storeexecs.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables a conversion webhook for the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: storeexecs.shop.shopware.com 6 | spec: 7 | conversion: 8 | strategy: Webhook 9 | webhook: 10 | clientConfig: 11 | service: 12 | namespace: system 13 | name: webhook-service 14 | path: /convert 15 | conversionReviewVersions: 16 | - v1 17 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_storesnapshots.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables a conversion webhook for the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: storesnapshots.shop.shopware.com 6 | spec: 7 | conversion: 8 | strategy: Webhook 9 | webhook: 10 | clientConfig: 11 | service: 12 | namespace: system 13 | name: webhook-service 14 | path: /convert 15 | conversionReviewVersions: 16 | - v1 17 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | #namespace: default 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | #namePrefix: shopware-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #labels: 13 | #- includeSelectors: true 14 | # pairs: 15 | # someName: someValue 16 | 17 | resources: 18 | - ../rbac 19 | - ../manager 20 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 21 | # crd/kustomization.yaml 22 | #- ../webhook 23 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 24 | #- ../certmanager 25 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 26 | #- ../prometheus 27 | 28 | patches: 29 | # Protect the /metrics endpoint by putting it behind auth. 30 | # If you want your controller-manager to expose the /metrics 31 | # endpoint w/o any authn/z, please comment the following line. 32 | #- path: manager_auth_proxy_patch.yaml 33 | 34 | # Patching all deployments and add a pull secret with name `regcred` 35 | - path: pull_secret_patch.yaml 36 | target: 37 | kind: Deployment 38 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 39 | # crd/kustomization.yaml 40 | #- path: manager_webhook_patch.yaml 41 | 42 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 43 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 44 | # 'CERTMANAGER' needs to be enabled to use ca injection 45 | #- path: webhookcainjection_patch.yaml 46 | 47 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 48 | # Uncomment the following replacements to add the cert-manager CA injection annotations 49 | #replacements: 50 | # - source: # Add cert-manager annotation to ValidatingWebhookConfiguration, MutatingWebhookConfiguration and CRDs 51 | # kind: Certificate 52 | # group: cert-manager.io 53 | # version: v1 54 | # name: serving-cert # this name should match the one in certificate.yaml 55 | # fieldPath: .metadata.namespace # namespace of the certificate CR 56 | # targets: 57 | # - select: 58 | # kind: ValidatingWebhookConfiguration 59 | # fieldPaths: 60 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 61 | # options: 62 | # delimiter: '/' 63 | # index: 0 64 | # create: true 65 | # - select: 66 | # kind: MutatingWebhookConfiguration 67 | # fieldPaths: 68 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 69 | # options: 70 | # delimiter: '/' 71 | # index: 0 72 | # create: true 73 | # - select: 74 | # kind: CustomResourceDefinition 75 | # fieldPaths: 76 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 77 | # options: 78 | # delimiter: '/' 79 | # index: 0 80 | # create: true 81 | # - source: 82 | # kind: Certificate 83 | # group: cert-manager.io 84 | # version: v1 85 | # name: serving-cert # this name should match the one in certificate.yaml 86 | # fieldPath: .metadata.name 87 | # targets: 88 | # - select: 89 | # kind: ValidatingWebhookConfiguration 90 | # fieldPaths: 91 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 92 | # options: 93 | # delimiter: '/' 94 | # index: 1 95 | # create: true 96 | # - select: 97 | # kind: MutatingWebhookConfiguration 98 | # fieldPaths: 99 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 100 | # options: 101 | # delimiter: '/' 102 | # index: 1 103 | # create: true 104 | # - select: 105 | # kind: CustomResourceDefinition 106 | # fieldPaths: 107 | # - .metadata.annotations.[cert-manager.io/inject-ca-from] 108 | # options: 109 | # delimiter: '/' 110 | # index: 1 111 | # create: true 112 | # - source: # Add cert-manager annotation to the webhook Service 113 | # kind: Service 114 | # version: v1 115 | # name: webhook-service 116 | # fieldPath: .metadata.name # namespace of the service 117 | # targets: 118 | # - select: 119 | # kind: Certificate 120 | # group: cert-manager.io 121 | # version: v1 122 | # fieldPaths: 123 | # - .spec.dnsNames.0 124 | # - .spec.dnsNames.1 125 | # options: 126 | # delimiter: '.' 127 | # index: 0 128 | # create: true 129 | # - source: 130 | # kind: Service 131 | # version: v1 132 | # name: webhook-service 133 | # fieldPath: .metadata.namespace # namespace of the service 134 | # targets: 135 | # - select: 136 | # kind: Certificate 137 | # group: cert-manager.io 138 | # version: v1 139 | # fieldPaths: 140 | # - .spec.dnsNames.0 141 | # - .spec.dnsNames.1 142 | # options: 143 | # delimiter: '.' 144 | # index: 1 145 | # create: true 146 | -------------------------------------------------------------------------------- /config/default/pull_secret_patch.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /spec/template/spec/imagePullSecrets 3 | value: [{ name: regcred }] 4 | -------------------------------------------------------------------------------- /config/helm/kustomization.yaml: -------------------------------------------------------------------------------- 1 | namespace: "{{ .Release.Namespace }}" 2 | namePrefix: "{{ .Release.Name }}-" 3 | 4 | resources: 5 | - ../rbac 6 | patches: 7 | - target: 8 | version: v1 9 | patch: |- 10 | - op: add 11 | path: /metadata/labels 12 | value: { 13 | "app.kubernetes.io/managed-by": "Helm", 14 | "app.kubernetes.io/component": "rbac", 15 | "app.kubernetes.io/created-by": "shopware-operator", 16 | "app.kubernetes.io/part-of": "shopware-operator", 17 | "app.kubernetes.io/release-name": "{{ .Release.Name }}", 18 | "app.kubernetes.io/release-namespace": "{{ .Release.Namespace }}" 19 | } 20 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | apiVersion: kustomize.config.k8s.io/v1beta1 4 | kind: Kustomization 5 | images: 6 | - name: controller 7 | newName: ghcr.io/shopware/shopware-operator 8 | newTag: latest 9 | 10 | #patches: 11 | # Protect the /metrics endpoint by putting it behind auth. 12 | # If you want your controller-manager to expose the /metrics 13 | # endpoint w/o any authn/z, please comment the following line. 14 | #- path: manager_auth_proxy_patch.yaml 15 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: shopware-operator 5 | labels: 6 | control-plane: shopware-operator 7 | app.kubernetes.io/name: deployment 8 | app.kubernetes.io/instance: shopware-operator 9 | app.kubernetes.io/component: manager 10 | app.kubernetes.io/created-by: shopware-operator 11 | app.kubernetes.io/part-of: shopware-operator 12 | app.kubernetes.io/managed-by: kustomize 13 | spec: 14 | selector: 15 | matchLabels: 16 | control-plane: shopware-operator 17 | replicas: 1 18 | template: 19 | metadata: 20 | annotations: 21 | kubectl.kubernetes.io/default-container: manager 22 | labels: 23 | control-plane: shopware-operator 24 | spec: 25 | affinity: 26 | nodeAffinity: 27 | requiredDuringSchedulingIgnoredDuringExecution: 28 | nodeSelectorTerms: 29 | - matchExpressions: 30 | - key: kubernetes.io/arch 31 | operator: In 32 | values: 33 | - amd64 34 | - arm64 35 | - key: kubernetes.io/os 36 | operator: In 37 | values: 38 | - linux 39 | securityContext: 40 | runAsNonRoot: true 41 | seccompProfile: 42 | type: RuntimeDefault 43 | containers: 44 | - command: 45 | - /manager 46 | args: 47 | - --leader-elect 48 | image: controller:latest 49 | env: 50 | - name: NAMESPACE 51 | valueFrom: 52 | fieldRef: 53 | fieldPath: metadata.namespace 54 | name: manager 55 | securityContext: 56 | allowPrivilegeEscalation: false 57 | capabilities: 58 | drop: 59 | - "ALL" 60 | livenessProbe: 61 | httpGet: 62 | path: /healthz 63 | port: 8081 64 | initialDelaySeconds: 15 65 | periodSeconds: 20 66 | readinessProbe: 67 | httpGet: 68 | path: /readyz 69 | port: 8081 70 | initialDelaySeconds: 5 71 | periodSeconds: 10 72 | # TODO(user): Configure the resources accordingly based on the project requirements. 73 | # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ 74 | resources: 75 | limits: 76 | cpu: 500m 77 | memory: 128Mi 78 | requests: 79 | cpu: 10m 80 | memory: 64Mi 81 | serviceAccountName: shopware-operator 82 | terminationGracePeriodSeconds: 10 83 | -------------------------------------------------------------------------------- /config/manager/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the 2 | # controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: shopware-operator 7 | spec: 8 | template: 9 | spec: 10 | containers: 11 | - name: kube-rbac-proxy 12 | securityContext: 13 | allowPrivilegeEscalation: false 14 | capabilities: 15 | drop: 16 | - "ALL" 17 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.15.0 18 | args: 19 | - "--secure-listen-address=0.0.0.0:8443" 20 | - "--upstream=http://127.0.0.1:8080/" 21 | - "--logtostderr=true" 22 | - "--v=0" 23 | ports: 24 | - containerPort: 8443 25 | protocol: TCP 26 | name: https 27 | resources: 28 | limits: 29 | cpu: 500m 30 | memory: 128Mi 31 | requests: 32 | cpu: 5m 33 | memory: 64Mi 34 | - name: manager 35 | args: 36 | - "--health-probe-bind-address=:8081" 37 | - "--metrics-bind-address=127.0.0.1:8080" 38 | - "--leader-elect" 39 | -------------------------------------------------------------------------------- /config/manager/manager_config_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: shopware-operator 5 | spec: 6 | template: 7 | spec: 8 | containers: 9 | - name: manager 10 | -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | # Prometheus Monitor Service (Metrics) 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | labels: 6 | control-plane: controller-manager 7 | app.kubernetes.io/name: servicemonitor 8 | app.kubernetes.io/instance: controller-manager-metrics-monitor 9 | app.kubernetes.io/component: metrics 10 | app.kubernetes.io/created-by: shopware-operator 11 | app.kubernetes.io/part-of: shopware-operator 12 | app.kubernetes.io/managed-by: kustomize 13 | name: controller-manager-metrics-monitor 14 | spec: 15 | endpoints: 16 | - path: /metrics 17 | port: https 18 | scheme: https 19 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 20 | tlsConfig: 21 | insecureSkipVerify: true 22 | selector: 23 | matchLabels: 24 | control-plane: controller-manager 25 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrole 6 | app.kubernetes.io/instance: metrics-reader 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: shopware-operator 9 | app.kubernetes.io/part-of: shopware-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: metrics-reader 12 | rules: 13 | - nonResourceURLs: 14 | - "/metrics" 15 | verbs: 16 | - get 17 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrole 6 | app.kubernetes.io/instance: proxy-role 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: shopware-operator 9 | app.kubernetes.io/part-of: shopware-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: proxy-role 12 | rules: 13 | - apiGroups: 14 | - authentication.k8s.io 15 | resources: 16 | - tokenreviews 17 | verbs: 18 | - create 19 | - apiGroups: 20 | - authorization.k8s.io 21 | resources: 22 | - subjectaccessreviews 23 | verbs: 24 | - create 25 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrolebinding 6 | app.kubernetes.io/instance: proxy-rolebinding 7 | app.kubernetes.io/component: kube-rbac-proxy 8 | app.kubernetes.io/created-by: shopware-operator 9 | app.kubernetes.io/part-of: shopware-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: proxy-rolebinding 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: ClusterRole 15 | name: proxy-role 16 | subjects: 17 | - kind: ServiceAccount 18 | name: controller-manager 19 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | app.kubernetes.io/name: service 7 | app.kubernetes.io/instance: controller-manager-metrics-service 8 | app.kubernetes.io/component: kube-rbac-proxy 9 | app.kubernetes.io/created-by: shopware-operator 10 | app.kubernetes.io/part-of: shopware-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: controller-manager-metrics-service 13 | spec: 14 | ports: 15 | - name: https 16 | port: 8443 17 | protocol: TCP 18 | targetPort: https 19 | selector: 20 | control-plane: controller-manager 21 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # Comment the following 4 lines if you want to disable 13 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 14 | # which protects your /metrics endpoint. 15 | # - auth_proxy_service.yaml 16 | # - auth_proxy_role.yaml 17 | # - auth_proxy_role_binding.yaml 18 | # - auth_proxy_client_clusterrole.yaml 19 | patches: 20 | - target: 21 | group: rbac.authorization.k8s.io 22 | version: v1 23 | kind: Role 24 | name: manager-role 25 | path: name_patch.yaml 26 | # For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by 27 | # default, aiding admins in cluster management. Those roles are 28 | # not used by the {{ .ProjectName }} itself. You can comment the following lines 29 | # if you do not want those helpers be installed with your Project. 30 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | labels: 6 | app.kubernetes.io/name: role 7 | app.kubernetes.io/instance: leader-election-role 8 | app.kubernetes.io/component: rbac 9 | app.kubernetes.io/created-by: shopware-operator 10 | app.kubernetes.io/part-of: shopware-operator 11 | app.kubernetes.io/managed-by: kustomize 12 | name: le-shopware-operator 13 | rules: 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - configmaps 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | - create 23 | - update 24 | - patch 25 | - delete 26 | - apiGroups: 27 | - coordination.k8s.io 28 | resources: 29 | - leases 30 | verbs: 31 | - get 32 | - list 33 | - watch 34 | - create 35 | - update 36 | - patch 37 | - delete 38 | - apiGroups: 39 | - "" 40 | resources: 41 | - events 42 | verbs: 43 | - create 44 | - patch 45 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: rolebinding 6 | app.kubernetes.io/instance: leader-election-rolebinding 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: shopware-operator 9 | app.kubernetes.io/part-of: shopware-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: le-shopware-operator 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: Role 15 | name: le-shopware-operator 16 | subjects: 17 | - kind: ServiceAccount 18 | name: shopware-operator 19 | -------------------------------------------------------------------------------- /config/rbac/name_patch.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /metadata/name 3 | value: 'shopware-operator' 4 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: clusterrolebinding 6 | app.kubernetes.io/instance: manager-rolebinding 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: shopware-operator 9 | app.kubernetes.io/part-of: shopware-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: shopware-operator 12 | roleRef: 13 | apiGroup: rbac.authorization.k8s.io 14 | kind: Role 15 | name: shopware-operator 16 | subjects: 17 | - kind: ServiceAccount 18 | name: shopware-operator 19 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: serviceaccount 6 | app.kubernetes.io/instance: controller-manager-sa 7 | app.kubernetes.io/component: rbac 8 | app.kubernetes.io/created-by: shopware-operator 9 | app.kubernetes.io/part-of: shopware-operator 10 | app.kubernetes.io/managed-by: kustomize 11 | name: shopware-operator 12 | -------------------------------------------------------------------------------- /config/rbac/storesnapshotrestore_admin_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project shopware-operator itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants full permissions ('*') over shop.shopware.com. 5 | # This role is intended for users authorized to modify roles and bindings within the cluster, 6 | # enabling them to delegate specific permissions to other users or groups as needed. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: shopware-operator 13 | app.kubernetes.io/managed-by: kustomize 14 | name: storesnapshotrestore-admin-role 15 | rules: 16 | - apiGroups: 17 | - shop.shopware.com 18 | resources: 19 | - storesnapshotrestores 20 | verbs: 21 | - '*' 22 | - apiGroups: 23 | - shop.shopware.com 24 | resources: 25 | - storesnapshotrestores/status 26 | verbs: 27 | - get 28 | -------------------------------------------------------------------------------- /config/rbac/storesnapshotrestore_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project shopware-operator itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants permissions to create, update, and delete resources within the shop.shopware.com. 5 | # This role is intended for users who need to manage these resources 6 | # but should not control RBAC or manage permissions for others. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: shopware-operator 13 | app.kubernetes.io/managed-by: kustomize 14 | name: storesnapshotrestore-editor-role 15 | rules: 16 | - apiGroups: 17 | - shop.shopware.com 18 | resources: 19 | - storesnapshotrestores 20 | verbs: 21 | - create 22 | - delete 23 | - get 24 | - list 25 | - patch 26 | - update 27 | - watch 28 | - apiGroups: 29 | - shop.shopware.com 30 | resources: 31 | - storesnapshotrestores/status 32 | verbs: 33 | - get 34 | -------------------------------------------------------------------------------- /config/rbac/storesnapshotrestore_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # This rule is not used by the project shopware-operator itself. 2 | # It is provided to allow the cluster admin to help manage permissions for users. 3 | # 4 | # Grants read-only access to shop.shopware.com resources. 5 | # This role is intended for users who need visibility into these resources 6 | # without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. 7 | 8 | apiVersion: rbac.authorization.k8s.io/v1 9 | kind: ClusterRole 10 | metadata: 11 | labels: 12 | app.kubernetes.io/name: shopware-operator 13 | app.kubernetes.io/managed-by: kustomize 14 | name: storesnapshotrestore-viewer-role 15 | rules: 16 | - apiGroups: 17 | - shop.shopware.com 18 | resources: 19 | - storesnapshotrestores 20 | verbs: 21 | - get 22 | - list 23 | - watch 24 | - apiGroups: 25 | - shop.shopware.com 26 | resources: 27 | - storesnapshotrestores/status 28 | verbs: 29 | - get 30 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## Append samples of your project ## 2 | resources: 3 | - shop_v1_storeexec.yaml 4 | - shop_v1_storesnapshot.yaml 5 | - shop_v1_storedebuginstance.yaml 6 | #+kubebuilder:scaffold:manifestskustomizesamples 7 | -------------------------------------------------------------------------------- /config/samples/shop_v1_storedebuginstance.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreDebugInstance 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: shopware-operator 6 | app.kubernetes.io/managed-by: kustomize 7 | name: storedebuginstance-sample 8 | spec: 9 | storeRef: shopware-dev 10 | duration: 1h 11 | extraLabels: 12 | debug: true 13 | -------------------------------------------------------------------------------- /config/samples/shop_v1_storeexec.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreExec 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: storeexec 6 | app.kubernetes.io/instance: storeexec-sample 7 | app.kubernetes.io/part-of: shopware-operator 8 | app.kubernetes.io/managed-by: kustomize 9 | app.kubernetes.io/created-by: shopware-operator 10 | name: storeexec-sample 11 | spec: 12 | # TODO(user): Add fields here 13 | -------------------------------------------------------------------------------- /config/samples/shop_v1_storesnapshot.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreSnapshot 3 | metadata: 4 | labels: 5 | app.kubernetes.io/name: storesnapshot 6 | app.kubernetes.io/instance: storesnapshot-sample 7 | app.kubernetes.io/part-of: shopware-operator 8 | app.kubernetes.io/managed-by: kustomize 9 | app.kubernetes.io/created-by: shopware-operator 10 | name: storesnapshot-sample 11 | spec: 12 | # TODO(user): Add fields here 13 | -------------------------------------------------------------------------------- /examples/command.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreExec 3 | metadata: 4 | name: test 5 | spec: 6 | storeRef: test 7 | maxRetries: 1 8 | script: | 9 | echo "Hello World" 10 | /setup 11 | -------------------------------------------------------------------------------- /examples/cron.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreExec 3 | metadata: 4 | name: test-cron 5 | spec: 6 | storeRef: test 7 | cronSchedule: "1 * * * *" 8 | script: | 9 | echo "test" 10 | -------------------------------------------------------------------------------- /examples/snapshot_create.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreSnapshotCreate 3 | metadata: 4 | name: snap-create 5 | namespace: test 6 | spec: 7 | storeNameRef: store-name 8 | path: s3://bucket/zip-file.zip 9 | maxRetries: 3 10 | -------------------------------------------------------------------------------- /examples/snapshot_restore.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: shop.shopware.com/v1 2 | kind: StoreSnapshotRestore 3 | metadata: 4 | name: snap-create 5 | namespace: test 6 | spec: 7 | storeNameRef: store-name 8 | path: s3://bucket/zip-file.zip 9 | maxRetries: 3 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shopware/shopware-operator 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.6 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go-v2 v1.37.2 9 | github.com/aws/aws-sdk-go-v2/config v1.27.36 10 | github.com/cert-manager/cert-manager v1.17.1 11 | github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf 12 | github.com/minio/minio-go/v7 v7.0.88 13 | github.com/nats-io/nats.go v1.43.0 14 | github.com/pkg/errors v0.9.1 15 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 16 | github.com/sethvargo/go-envconfig v1.3.0 17 | github.com/stretchr/testify v1.10.0 18 | github.com/urfave/cli/v3 v3.4.1 19 | go.uber.org/zap v1.27.0 20 | k8s.io/api v0.33.3 21 | k8s.io/apimachinery v0.33.3 22 | k8s.io/client-go v0.33.3 23 | sigs.k8s.io/controller-runtime v0.20.3 24 | ) 25 | 26 | require ( 27 | filippo.io/edwards25519 v1.1.0 // indirect 28 | github.com/aws/aws-sdk-go-v2/credentials v1.17.34 // indirect 29 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect 32 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/sso v1.23.0 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.0 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.31.0 // indirect 38 | github.com/aws/smithy-go v1.22.5 // indirect 39 | github.com/dustin/go-humanize v1.0.1 // indirect 40 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 41 | github.com/go-ini/ini v1.67.0 // indirect 42 | github.com/go-logr/logr v1.4.3 // indirect 43 | github.com/goccy/go-json v0.10.5 // indirect 44 | github.com/google/btree v1.1.3 // indirect 45 | github.com/klauspost/compress v1.18.0 // indirect 46 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 47 | github.com/minio/crc64nvme v1.0.1 // indirect 48 | github.com/minio/md5-simd v1.1.2 // indirect 49 | github.com/nats-io/nkeys v0.4.11 // indirect 50 | github.com/nats-io/nuid v1.0.1 // indirect 51 | github.com/rs/xid v1.6.0 // indirect 52 | github.com/x448/float16 v0.8.4 // indirect 53 | go.opentelemetry.io/otel v1.37.0 // indirect 54 | golang.org/x/crypto v0.37.0 // indirect 55 | golang.org/x/sync v0.16.0 // indirect 56 | golang.org/x/tools v0.29.0 // indirect 57 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 58 | sigs.k8s.io/randfill v1.0.0 // indirect 59 | ) 60 | 61 | require ( 62 | github.com/beorn7/perks v1.0.1 // indirect 63 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 64 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 65 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 66 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 67 | github.com/fsnotify/fsnotify v1.9.0 // indirect 68 | github.com/go-logr/zapr v1.3.0 69 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 70 | github.com/go-openapi/jsonreference v0.21.0 // indirect 71 | github.com/go-openapi/swag v0.23.0 // indirect 72 | github.com/go-sql-driver/mysql v1.9.0 73 | github.com/gogo/protobuf v1.3.2 // indirect 74 | github.com/google/gnostic-models v0.6.9 // indirect 75 | github.com/google/go-cmp v0.7.0 // indirect 76 | github.com/google/uuid v1.6.0 // indirect 77 | github.com/josharian/intern v1.0.0 // indirect 78 | github.com/json-iterator/go v1.1.12 // indirect 79 | github.com/mailru/easyjson v0.9.0 // indirect 80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 81 | github.com/modern-go/reflect2 v1.0.2 // indirect 82 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 83 | github.com/prometheus/client_golang v1.22.0 // indirect 84 | github.com/prometheus/client_model v0.6.1 // indirect 85 | github.com/prometheus/common v0.62.0 // indirect 86 | github.com/prometheus/procfs v0.15.1 // indirect 87 | github.com/spf13/pflag v1.0.6 // indirect 88 | go.opentelemetry.io/otel/trace v1.37.0 89 | go.uber.org/multierr v1.11.0 // indirect 90 | golang.org/x/net v0.38.0 // indirect 91 | golang.org/x/oauth2 v0.28.0 // indirect 92 | golang.org/x/sys v0.34.0 // indirect 93 | golang.org/x/term v0.31.0 // indirect 94 | golang.org/x/text v0.24.0 // indirect 95 | golang.org/x/time v0.11.0 // indirect 96 | gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect 97 | google.golang.org/protobuf v1.36.6 // indirect 98 | gopkg.in/inf.v0 v0.9.1 // indirect 99 | gopkg.in/yaml.v3 v3.0.1 // indirect 100 | k8s.io/apiextensions-apiserver v0.32.2 // indirect 101 | k8s.io/klog/v2 v2.130.1 // indirect 102 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 103 | k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect 104 | sigs.k8s.io/gateway-api v1.2.1 // indirect 105 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 106 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 107 | sigs.k8s.io/yaml v1.4.0 // indirect 108 | ) 109 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | */ -------------------------------------------------------------------------------- /helm/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: operator 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | # This is the chart version. This version number should be incremented each time you make changes 6 | # to the chart and its templates, including the app version. 7 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 8 | version: 1.0.0 9 | # This value is equal to the used shopware operator image. 10 | appVersion: "1.0.8" 11 | -------------------------------------------------------------------------------- /helm/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 shopware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /helm/README.md: -------------------------------------------------------------------------------- 1 | # Shopware Operator 2 | 3 | Useful links 4 | * [Operator GitHub repository](https://github.com/shopware/shopware-operator) 5 | 6 | ## Pre-requisites 7 | * Kubernetes 1.28+ 8 | * Helm v3 9 | 10 | # Disclaimer 11 | 12 | This Shopware Helm chart is currently in an experimental phase and is not ready for 13 | production use. The services, configurations, and individual steps described in this 14 | repository are still under active development and are not in a final state. 15 | As such, they are subject to change at any time and may contain bugs, 16 | incomplete implementations, or other issues that could affect the stability and performance 17 | of your Shopware installation. 18 | 19 | Please be aware that using this Helm chart in a live environment could lead to 20 | unexpected behavior, data loss, or other critical problems. We strongly recommend using 21 | this Helm chart for testing and development purposes only. 22 | 23 | By using this software, you acknowledge that you understand these risks and agree not 24 | to hold the developers or maintainers of this repository liable for any damage or 25 | loss that may occur. 26 | 27 | If you encounter any issues or have suggestions for improvements, please feel free to 28 | open an issue or contribute to the project. 29 | 30 | # Installation 31 | 32 | This chart will deploy the Shopware Operator in you Kubernetes cluster. 33 | 34 | ## Installing the Chart 35 | To install the chart using a dedicated namespace is recommended: 36 | 37 | ```sh 38 | helm repo add shopware https://shopware.github.io/helm-charts/ 39 | helm install my-operator shopware/operator --namespace my-namespace --create-namespace 40 | ``` 41 | 42 | Checkout the [values.yaml](values.yaml) file to modify the operator deployment. 43 | Change it to your needs and install it: 44 | ```sh 45 | helm install operator shopware/operator -f values.yaml 46 | ``` 47 | -------------------------------------------------------------------------------- /helm/templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopware/shopware-operator/e3ad4aca54e6a89762ee0618f119c354b4f2657c/helm/templates/.gitkeep -------------------------------------------------------------------------------- /helm/templates/crds/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopware/shopware-operator/e3ad4aca54e6a89762ee0618f119c354b4f2657c/helm/templates/crds/.gitkeep -------------------------------------------------------------------------------- /helm/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.crds.installOnly }} 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: '{{ .Release.Name }}-shopware-operator' 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | app.kubernetes.io/component: manager 9 | app.kubernetes.io/created-by: shopware-operator 10 | app.kubernetes.io/instance: shopware-operator 11 | app.kubernetes.io/managed-by: shopware-operator 12 | app.kubernetes.io/name: deployment 13 | app.kubernetes.io/part-of: shopware-operator 14 | control-plane: shopware-operator 15 | {{- with .Values.labels }} 16 | {{- toYaml . | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | replicas: {{ .Values.replicaCount }} 20 | selector: 21 | matchLabels: 22 | control-plane: shopware-operator 23 | strategy: 24 | rollingUpdate: 25 | maxUnavailable: 1 26 | type: RollingUpdate 27 | template: 28 | metadata: 29 | annotations: 30 | kubectl.kubernetes.io/default-container: operator 31 | {{- with .Values.podAnnotations }} 32 | {{- toYaml . | nindent 8 }} 33 | {{- end }} 34 | labels: 35 | control-plane: shopware-operator 36 | {{- with .Values.podLabels }} 37 | {{- toYaml . | nindent 8 }} 38 | {{- end }} 39 | spec: 40 | {{- if hasKey .Values "affinity" }} 41 | affinity: 42 | {{- with .Values.affinity }} 43 | {{- toYaml . | nindent 8 }} 44 | {{- end }} 45 | {{- else }} 46 | affinity: 47 | nodeAffinity: 48 | requiredDuringSchedulingIgnoredDuringExecution: 49 | nodeSelectorTerms: 50 | - matchExpressions: 51 | - key: kubernetes.io/arch 52 | operator: In 53 | values: 54 | - amd64 55 | - arm64 56 | - key: kubernetes.io/os 57 | operator: In 58 | values: 59 | - linux 60 | {{- end }} 61 | tolerations: 62 | {{- with .Values.tolerations }} 63 | {{- toYaml . | nindent 10 }} 64 | {{- end }} 65 | containers: 66 | - args: 67 | command: 68 | - /manager 69 | env: 70 | - name: LEADER_ELECT 71 | value: "true" 72 | - name: NAMESPACE 73 | valueFrom: 74 | fieldRef: 75 | fieldPath: metadata.namespace 76 | - name: LOG_LEVEL 77 | value: "{{ .Values.logLevel | default "info" }}" 78 | - name: LOG_FORMAT 79 | value: "{{ .Values.logFormat | default "json" }}" 80 | - name: DISABLE_CHECKS 81 | value: "{{ .Values.disableChecks | default "false" }}" 82 | {{- if and (hasKey .Values "events") (hasKey .Values.events "nats") (.Values.events.nats.enable) }} 83 | - name: NATS_ENABLE 84 | value: "true" 85 | - name: NATS_ADDRESS 86 | value: "{{ required "if nats is enabled set the address" .Values.events.nats.address }}" 87 | {{- if hasKey .Values.events.nats "topic" }} 88 | - name: NATS_TOPIC 89 | value: "{{ .Values.events.nats.topic | default "shopware-events" }}" 90 | {{- end }} 91 | {{- if hasKey .Values.events.nats "credentialsRef" }} 92 | - name: NATS_CREDENTIALS_FILE 93 | value: "/secrets/{{ .Values.events.nats.credentialsRef.key }}" 94 | {{- end }} 95 | {{- if hasKey .Values.events.nats "nkeyRef" }} 96 | - name: NATS_NKEY_FILE 97 | value: "/secrets/{{ .Values.events.nats.nkeyRef.key }}" 98 | {{- end }} 99 | {{- end }} 100 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 101 | imagePullPolicy: "{{ .Values.image.pullPolicy }}" 102 | livenessProbe: 103 | httpGet: 104 | path: /healthz 105 | port: 8081 106 | initialDelaySeconds: 15 107 | periodSeconds: 20 108 | name: operator 109 | readinessProbe: 110 | httpGet: 111 | path: /readyz 112 | port: 8081 113 | initialDelaySeconds: 5 114 | periodSeconds: 10 115 | resources: 116 | {{- with .Values.resources }} 117 | {{- toYaml . | nindent 10 }} 118 | {{- end }} 119 | securityContext: 120 | allowPrivilegeEscalation: false 121 | capabilities: 122 | drop: 123 | - ALL 124 | volumeMounts: 125 | {{- if and (hasKey .Values "events") (hasKey .Values.events "nats") (.Values.events.nats.enable) (hasKey .Values.events.nats "nkeyRef") }} 126 | - mountPath: /secrets 127 | name: nkey 128 | {{- end }} 129 | {{- if and (hasKey .Values "events") (hasKey .Values.events "nats") (.Values.events.nats.enable) (hasKey .Values.events.nats "credentialsRef") }} 130 | - mountPath: /secrets 131 | name: credentials 132 | {{- end }} 133 | securityContext: 134 | runAsNonRoot: true 135 | seccompProfile: 136 | type: RuntimeDefault 137 | serviceAccountName: '{{ .Release.Name }}-shopware-operator' 138 | terminationGracePeriodSeconds: 10 139 | volumes: 140 | {{- if and (hasKey .Values "events") (hasKey .Values.events "nats") (.Values.events.nats.enable) (hasKey .Values.events.nats "nkeyRef") }} 141 | - name: nkey 142 | secret: 143 | secretName: {{ .Values.events.nats.nkeyRef.name }} 144 | {{- end }} 145 | {{- if and (hasKey .Values "events") (hasKey .Values.events "nats") (.Values.events.nats.enable) (hasKey .Values.events.nats "credentialsRef") }} 146 | - name: credentials 147 | secret: 148 | secretName: {{ .Values.events.nats.credentialsRef.name }} 149 | {{- end }} 150 | {{- end }} 151 | -------------------------------------------------------------------------------- /helm/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for the operator. 2 | # This is a YAML-formatted file. 3 | # Declare variables that will be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: ghcr.io/shopware/shopware-operator 9 | # if not set appVersion field from Chart.yaml is used 10 | tag: "" 11 | pullPolicy: IfNotPresent 12 | 13 | ## Custom resource configuration 14 | crds: 15 | # Install and upgrade CRDs 16 | install: true 17 | # This will install only the crd's 18 | installOnly: false 19 | 20 | # rbac: settings for deployer RBAC creation 21 | rbac: 22 | # rbac.create: if false, RBAC resources should be in place 23 | create: true 24 | 25 | # serviceAccount: settings for Service Accounts used by the deployer 26 | serviceAccount: 27 | # serviceAccount.create: Whether to create the Service Accounts or not 28 | create: true 29 | 30 | # Enable event publishing when the store status resource is updated. This 31 | # is useful if you want to add external applications which will wait until the 32 | # operator is ready. Example event: 33 | # { 34 | # "message": "Update store status", 35 | # "condition": { 36 | # "type": "initializing", 37 | # "lastTransitionTime": null, 38 | # "lastUpdatedTime": "2025-07-07T14:00:52Z", 39 | # "message": "Waiting for deployment to get ready. Target replicas: 2, Ready replicas: 0", 40 | # "status": "True" 41 | # }, 42 | # "deployedImage": "ghcr.io/shopware/shopware-kubernetes:latest", 43 | # "storeLabels": { 44 | # "app.kubernetes.io/managed-by": "Helm" 45 | # } 46 | # } 47 | # Currently we only support Nats for internal usage, If you want a different publish message system let us know with an issue or pull request. 48 | # events: 49 | # nats: 50 | # nkeyRef: 51 | # name: "nats-nkey" # secret name 52 | # key: "nkey" # file 53 | # enable: true 54 | # credentialsRef: 55 | # name: "nats-credentials" # secret name 56 | # key: "creds" # file 57 | # address: nats://nats.nats-namespace.svc.cluster.local 58 | # topic: "shopware-operator" 59 | 60 | # These are the defaults of the operator SDK. Feel free to adjust to your needs. 61 | resources: 62 | limits: 63 | cpu: 500m 64 | memory: 128Mi 65 | requests: 66 | cpu: 10m 67 | memory: 64Mi 68 | 69 | tolerations: [] 70 | # affinity: 71 | # nodeAffinity: 72 | # requiredDuringSchedulingIgnoredDuringExecution: 73 | # nodeSelectorTerms: 74 | # - matchExpressions: 75 | # - key: kubernetes.io/arch 76 | # operator: In 77 | # values: 78 | # - amd64 79 | # - arm64 80 | # - key: kubernetes.io/os 81 | # operator: In 82 | # values: 83 | # - linux 84 | 85 | labels: {} 86 | podAnnotations: {} 87 | podLabels: {} 88 | 89 | # Supported levels: debug, info, warn, error, dpanic, panic, fatal 90 | logLevel: info 91 | # Supported formats: json, text, zap-pretty 92 | # Use zap-pretty for the makefile run command, as it will be properly formatted 93 | logFormat: json 94 | # Disable check for s3 and database checks. Useful if network access is not given for the s3 or database. 95 | # This is a global level. You can also control this per store. 96 | disableChecks: false 97 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/sethvargo/go-envconfig" 10 | ) 11 | 12 | type NatsHandler struct { 13 | Enable bool `env:"ENABLE, default=false"` 14 | NkeyFile string `env:"NKEY_FILE"` 15 | CredentialsFile string `env:"CREDENTIALS_FILE"` 16 | Address string `env:"ADDRESS, default=nats://localhost:4222"` 17 | NatsTopic string `env:"TOPIC, default=shopware-events"` 18 | } 19 | 20 | type DatabaseConfig struct { 21 | MysqlShellBinaryPath string `env:"MYSQL_SHELL_BINARY_PATH, default=/opt/mysqlsh/bin/mysqlsh"` 22 | MysqlDumpBinaryPath string `env:"MYSQL_DUMP_BINARY_PATH, default=mysqldump"` 23 | Host string `env:"HOST"` 24 | Port int32 `env:"PORT, default=3306"` 25 | User string `env:"USER"` 26 | Password string `env:"PASSWORD"` 27 | Database string `env:"DATABASE"` 28 | Version string `env:"VERSION"` 29 | Options string `env:"OPTIONS"` 30 | SSLMode string `env:"SSL_MODE, default=disable"` 31 | } 32 | 33 | type S3Config struct { 34 | // Specified when running in an EKS cluster with IAM roles for service accounts 35 | RoleARN string `env:"ROLE_ARN"` 36 | WebIdentityTokenFile string `env:"WEB_IDENTITY_TOKEN_FILE"` 37 | STSReginalEndpoints string `env:"STS_REGIONAL_ENDPOINTS, default=regional"` 38 | DefaultRegion string `env:"DEFAULT_REGION, default=eu-central-1"` 39 | Region string `env:"REGION, default=eu-central-1"` 40 | 41 | Endpoint string `env:"ENDPOINT, default=s3.eu-central-1.amazonaws.com"` 42 | AccessKeyID string `env:"ACCESS_KEY_ID"` 43 | SecretAccessKey string `env:"SECRET_ACCESS_KEY"` 44 | 45 | PrivateBucket string `env:"PRIVATE_BUCKET"` 46 | PublicBucket string `env:"PUBLIC_BUCKET"` 47 | } 48 | 49 | type SnapshotConfig struct { 50 | Config 51 | Database DatabaseConfig `env:",prefix=DB_"` 52 | S3 S3Config `env:",prefix=AWS_"` 53 | MetaStoreJson string `env:"META_STORE_STATE"` 54 | } 55 | 56 | type StoreConfig struct { 57 | Config 58 | NatsHandler NatsHandler `env:",prefix=NATS_"` 59 | 60 | // Metrics and health probe configuration 61 | MetricsAddr string `env:"METRICS_BIND_ADDRESS, default=0"` 62 | ProbeAddr string `env:"HEALTH_PROBE_BIND_ADDRESS, default=:8081"` 63 | 64 | EnableLeaderElection bool `env:"LEADER_ELECT, default=true"` 65 | DisableChecks bool `env:"DISABLE_CHECKS, default=false"` 66 | Namespace string `env:"NAMESPACE, default=default"` 67 | } 68 | 69 | type Config struct { 70 | LogLevel string `env:"LOG_LEVEL, default=info"` 71 | LogFormat string `env:"LOG_FORMAT, default=json"` 72 | } 73 | 74 | func LoadStoreConfig(ctx context.Context) (*StoreConfig, error) { 75 | cfg := &StoreConfig{} 76 | 77 | // Load configuration from environment variables 78 | if err := envconfig.Process(ctx, cfg); err != nil { 79 | return nil, err 80 | } 81 | 82 | return cfg, nil 83 | } 84 | 85 | func LoadSnapshotConfig(ctx context.Context) (*SnapshotConfig, error) { 86 | cfg := &SnapshotConfig{} 87 | 88 | // Load configuration from environment variables 89 | if err := envconfig.Process(ctx, cfg); err != nil { 90 | return nil, err 91 | } 92 | 93 | return cfg, nil 94 | } 95 | 96 | func (c StoreConfig) String() string { 97 | var out []byte 98 | var err error 99 | if c.IsDebug() { 100 | out, err = json.MarshalIndent(c, "", " ") 101 | } else { 102 | out, err = json.Marshal(c) 103 | } 104 | 105 | if err != nil { 106 | return fmt.Sprintf("Error marshalling config to string: %s", err.Error()) 107 | } 108 | return string(out) 109 | } 110 | 111 | func (c Config) IsDebug() bool { 112 | return strings.ToLower(c.LogLevel) == "debug" 113 | } 114 | -------------------------------------------------------------------------------- /internal/controller/event.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/event" 9 | "github.com/shopware/shopware-operator/internal/logging" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func (c *StoreReconciler) SendEvent(ctx context.Context, store v1.Store, message string) { 14 | e := event.Event{ 15 | Message: message, 16 | Condition: store.Status.GetLastCondition(), 17 | DeployedImage: store.Status.CurrentImageTag, 18 | Labels: store.Labels, 19 | KindType: reflect.TypeOf(store).String(), 20 | } 21 | log := logging.FromContext(ctx).With( 22 | zap.Any("event", e), 23 | ) 24 | 25 | for _, handler := range c.EventHandlers { 26 | log.Info("Sending event", "handler", reflect.TypeOf(handler).String()) 27 | err := handler.Send(ctx, e) 28 | if err != nil { 29 | log.Error(err, "Sending event", "handler", reflect.TypeOf(handler).String()) 30 | } 31 | } 32 | } 33 | 34 | func (c *StoreSnapshotCreateReconciler) SendEvent(ctx context.Context, snap v1.StoreSnapshotCreate) { 35 | e := event.Event{ 36 | Message: snap.Status.Message, 37 | Condition: snap.Status.GetLastCondition(), 38 | DeployedImage: snap.Spec.Container.Image, 39 | Labels: snap.Labels, 40 | KindType: reflect.TypeOf(snap).String(), 41 | } 42 | 43 | log := logging.FromContext(ctx).With( 44 | zap.Any("event", e), 45 | ) 46 | 47 | for _, handler := range c.EventHandlers { 48 | log.Info("Sending event", "handler", reflect.TypeOf(handler).String()) 49 | err := handler.Send(ctx, e) 50 | if err != nil { 51 | log.Error(err, "Sending event", "handler", reflect.TypeOf(handler).String()) 52 | } 53 | } 54 | } 55 | 56 | func (c *StoreSnapshotRestoreReconciler) SendEvent(ctx context.Context, snap v1.StoreSnapshotRestore) { 57 | e := event.Event{ 58 | Message: snap.Status.Message, 59 | Condition: snap.Status.GetLastCondition(), 60 | DeployedImage: snap.Spec.Container.Image, 61 | Labels: snap.Labels, 62 | KindType: reflect.TypeOf(snap).String(), 63 | } 64 | 65 | log := logging.FromContext(ctx).With( 66 | zap.Any("event", e), 67 | ) 68 | 69 | for _, handler := range c.EventHandlers { 70 | log.Info("Sending event", "handler", reflect.TypeOf(handler).String()) 71 | err := handler.Send(ctx, e) 72 | if err != nil { 73 | log.Error(err, "Sending event", "handler", reflect.TypeOf(handler).String()) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/controller/storedebuginstance_status.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | "github.com/shopware/shopware-operator/internal/logging" 10 | "github.com/shopware/shopware-operator/internal/pod" 11 | "go.uber.org/zap" 12 | corev1 "k8s.io/api/core/v1" 13 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/types" 16 | k8sretry "k8s.io/client-go/util/retry" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | ) 19 | 20 | func (r *StoreDebugInstanceReconciler) reconcileCRStatus( 21 | ctx context.Context, 22 | store *v1.Store, 23 | storeDebugInstance *v1.StoreDebugInstance, 24 | reconcileError error, 25 | ) error { 26 | if storeDebugInstance == nil || storeDebugInstance.DeletionTimestamp != nil { 27 | return nil 28 | } 29 | 30 | if reconcileError != nil { 31 | storeDebugInstance.Status.AddCondition( 32 | v1.StoreDebugInstanceCondition{ 33 | Type: storeDebugInstance.Status.State, 34 | LastTransitionTime: metav1.Time{}, 35 | LastUpdateTime: metav1.NewTime(time.Now()), 36 | Message: reconcileError.Error(), 37 | Reason: "ReconcileError", 38 | Status: Error, 39 | }, 40 | ) 41 | } 42 | 43 | if store == nil { 44 | storeDebugInstance.Status.State = v1.StoreDebugInstanceStateWait 45 | storeDebugInstance.Status.AddCondition( 46 | v1.StoreDebugInstanceCondition{ 47 | Type: storeDebugInstance.Status.State, 48 | LastTransitionTime: metav1.Time{}, 49 | LastUpdateTime: metav1.NewTime(time.Now()), 50 | Message: fmt.Sprintf("StoreRef not found (stores/%s), waiting for store to be created", storeDebugInstance.Spec.StoreRef), 51 | Reason: "StoreRef error", 52 | Status: Error, 53 | }, 54 | ) 55 | } else { 56 | if storeDebugInstance.IsState(v1.StoreDebugInstanceStateUnspecified) { 57 | storeDebugInstance.Status.State = v1.StoreDebugInstanceStatePending 58 | } 59 | } 60 | 61 | if storeDebugInstance.IsState(v1.StoreDebugInstanceStateRunning, v1.StoreDebugInstanceStatePending) { 62 | storeDebugInstance.Status.State = r.stateRunning(ctx, store, storeDebugInstance) 63 | } 64 | 65 | logging.FromContext(ctx).Infow("Update store debug instance status", zap.Any("status", storeDebugInstance.Status)) 66 | return writeStoreDebugInstanceStatus(ctx, r.Client, types.NamespacedName{ 67 | Namespace: storeDebugInstance.Namespace, 68 | Name: storeDebugInstance.Name, 69 | }, storeDebugInstance.Status) 70 | } 71 | 72 | func writeStoreDebugInstanceStatus( 73 | ctx context.Context, 74 | cl client.Client, 75 | nn types.NamespacedName, 76 | status v1.StoreDebugInstanceStatus, 77 | ) error { 78 | return k8sretry.RetryOnConflict(k8sretry.DefaultRetry, func() error { 79 | cr := &v1.StoreDebugInstance{} 80 | if err := cl.Get(ctx, nn, cr); err != nil { 81 | return fmt.Errorf("write status: %w", err) 82 | } 83 | 84 | cr.Status = status 85 | return cl.Status().Update(ctx, cr) 86 | }) 87 | } 88 | 89 | func (r *StoreDebugInstanceReconciler) stateRunning(ctx context.Context, store *v1.Store, storeDebugInstance *v1.StoreDebugInstance) v1.StoreDebugInstanceState { 90 | con := v1.StoreDebugInstanceCondition{ 91 | Type: v1.StoreDebugInstanceStateRunning, 92 | LastTransitionTime: metav1.Time{}, 93 | LastUpdateTime: metav1.Now(), 94 | Message: "Waiting for pod to get started", 95 | Reason: "", 96 | Status: "True", 97 | } 98 | defer func() { 99 | storeDebugInstance.Status.AddCondition(con) 100 | }() 101 | 102 | duration, _ := time.ParseDuration(storeDebugInstance.Spec.Duration) 103 | if time.Now().After(storeDebugInstance.CreationTimestamp.Add(duration)) { 104 | con.Message = "Store debug instance expired" 105 | con.Status = string(v1.StoreDebugInstanceStateDone) 106 | return v1.StoreDebugInstanceStateDone 107 | } 108 | 109 | pod, err := pod.GetDebugPod(ctx, r.Client, *store, *storeDebugInstance) 110 | if err != nil { 111 | if k8serrors.IsNotFound(err) { 112 | con.Message = "Pod not found, waiting for creation" 113 | return v1.StoreDebugInstanceStatePending 114 | } 115 | con.Reason = err.Error() 116 | con.Status = Error 117 | return v1.StoreDebugInstanceStateError 118 | } 119 | 120 | if pod == nil { 121 | con.Message = "Pod not found, waiting for creation" 122 | return v1.StoreDebugInstanceStatePending 123 | } 124 | 125 | if pod.Status.Phase == corev1.PodPending { 126 | con.Message = "Pod is pending, waiting for resources" 127 | return v1.StoreDebugInstanceStatePending 128 | } 129 | 130 | if pod.Status.Phase == corev1.PodRunning { 131 | con.Message = "Pod is running successfully" 132 | con.LastTransitionTime = metav1.Now() 133 | return v1.StoreDebugInstanceStateRunning 134 | } 135 | 136 | if pod.Status.Phase == corev1.PodFailed { 137 | con.Message = "Pod failed to start" 138 | con.Reason = "PodFailed" 139 | con.Status = Error 140 | return v1.StoreDebugInstanceStateError 141 | } 142 | 143 | con.Message = fmt.Sprintf("Pod in unknown state: %s", pod.Status.Phase) 144 | return v1.StoreDebugInstanceStatePending 145 | } 146 | -------------------------------------------------------------------------------- /internal/controller/storeexec_controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | "github.com/shopware/shopware-operator/internal/job" 10 | "github.com/shopware/shopware-operator/internal/k8s" 11 | "github.com/shopware/shopware-operator/internal/logging" 12 | "go.uber.org/zap" 13 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | "k8s.io/apimachinery/pkg/types" 16 | "k8s.io/client-go/tools/record" 17 | ctrl "sigs.k8s.io/controller-runtime" 18 | "sigs.k8s.io/controller-runtime/pkg/client" 19 | ) 20 | 21 | type StoreExecReconciler struct { 22 | client.Client 23 | Scheme *runtime.Scheme 24 | Recorder record.EventRecorder 25 | Logger *zap.SugaredLogger 26 | } 27 | 28 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storeexecs,verbs=get;list;watch;create;update;patch;delete 29 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storeexecs/status,verbs=get;update;patch 30 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storeexecs/finalizers,verbs=update 31 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=stores,verbs=get 32 | // +kubebuilder:rbac:groups="",namespace=default,resources=secrets,verbs=get;list;watch;create;patch 33 | // +kubebuilder:rbac:groups="",namespace=default,resources=pods,verbs=get;list;watch; 34 | // +kubebuilder:rbac:groups="batch",namespace=default,resources=jobs,verbs=get;list;watch;create;delete 35 | 36 | func (r *StoreExecReconciler) Reconcile(ctx context.Context, req ctrl.Request) (rr ctrl.Result, err error) { 37 | log := logging.FromContext(ctx). 38 | With(zap.String("namespace", req.Namespace)). 39 | With(zap.String("name", req.Name)) 40 | 41 | rr = ctrl.Result{RequeueAfter: 10 * time.Second} 42 | 43 | var ex *v1.StoreExec 44 | var store *v1.Store 45 | defer func() { 46 | if err := r.reconcileCRStatus(ctx, store, ex, err); err != nil { 47 | log.Errorw("failed to update status", zap.Error(err)) 48 | } 49 | }() 50 | 51 | ex, err = k8s.GetStoreExec(ctx, r.Client, req.NamespacedName) 52 | if err != nil { 53 | if k8serrors.IsNotFound(err) { 54 | return rr, nil 55 | } 56 | log.Errorw("get CR exec", zap.Error(err)) 57 | return rr, nil 58 | } 59 | 60 | store, err = k8s.GetStore(ctx, r.Client, types.NamespacedName{ 61 | Namespace: req.Namespace, 62 | Name: ex.Spec.StoreRef, 63 | }) 64 | if err != nil { 65 | if k8serrors.IsNotFound(err) { 66 | log.Info("Skip exec reconcile, because store is not found", zap.String("storeRef", ex.Spec.StoreRef)) 67 | return rr, nil 68 | } 69 | log.Errorw("get CR store", zap.Error(err)) 70 | return rr, nil 71 | } 72 | 73 | if !store.IsState(v1.StateReady) { 74 | log.Info("Skip exec reconcile, because store is not ready yet.", zap.Any("store", store.Status)) 75 | return rr, nil 76 | } 77 | 78 | log = log.With(zap.String("store", ex.Spec.StoreRef)) 79 | log.Info("Do reconcile on store-exec") 80 | 81 | if ex.IsState(v1.ExecStateEmpty) { 82 | log.Info("skip reconcile because state is empty") 83 | return rr, nil 84 | } 85 | 86 | if ex.Spec.CronSchedule != "" { 87 | if err := r.reconcileCronJob(ctx, store, ex); err != nil { 88 | log.Errorw("exec error", zap.Error(err)) 89 | return rr, nil 90 | } 91 | } else { 92 | if ex.IsState(v1.ExecStateDone, v1.ExecStateError) { 93 | return ctrl.Result{Requeue: false}, nil 94 | } 95 | if err := r.reconcileJob(ctx, store, ex); err != nil { 96 | log.Errorw("exec error", zap.Error(err)) 97 | return rr, nil 98 | } 99 | } 100 | 101 | log.Info("Reconcile finished") 102 | rr.RequeueAfter = 20 * time.Second 103 | return rr, nil 104 | } 105 | 106 | // SetupWithManager sets up the controller with the Manager. 107 | func (r *StoreExecReconciler) SetupWithManager(mgr ctrl.Manager) error { 108 | return ctrl.NewControllerManagedBy(mgr). 109 | For(&v1.StoreExec{}). 110 | Complete(r) 111 | } 112 | 113 | func (r *StoreExecReconciler) reconcileJob(ctx context.Context, store *v1.Store, exec *v1.StoreExec) (err error) { 114 | var changed bool 115 | obj := job.CommandJob(*store, *exec) 116 | 117 | if changed, err = k8s.HasObjectChanged(ctx, r.Client, obj); err != nil { 118 | return fmt.Errorf("reconcile unready setup job: %w", err) 119 | } 120 | 121 | if changed { 122 | r.Recorder.Event(store, "Normal", "Diff command job hash", 123 | fmt.Sprintf("Update command Job %s in namespace %s. Diff hash", 124 | exec.Name, 125 | exec.Namespace)) 126 | if err := k8s.EnsureJob(ctx, r.Client, exec, obj, r.Scheme, true); err != nil { 127 | return fmt.Errorf("reconcile unready setup job: %w", err) 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (r *StoreExecReconciler) reconcileCronJob(ctx context.Context, store *v1.Store, exec *v1.StoreExec) (err error) { 135 | var changed bool 136 | obj := job.CommandCronJob(*store, *exec) 137 | 138 | if changed, err = k8s.HasObjectChanged(ctx, r.Client, obj); err != nil { 139 | return fmt.Errorf("reconcile unready setup cron job: %w", err) 140 | } 141 | 142 | if changed { 143 | r.Recorder.Event(store, "Normal", "Diff command cron job hash", 144 | fmt.Sprintf("Update command cron Job %s in namespace %s. Diff hash", 145 | exec.Name, 146 | exec.Namespace)) 147 | if err := k8s.EnsureCronJob(ctx, r.Client, exec, obj, r.Scheme, true); err != nil { 148 | return fmt.Errorf("reconcile unready setup cron job: %w", err) 149 | } 150 | } 151 | 152 | return nil 153 | } 154 | -------------------------------------------------------------------------------- /internal/controller/storeexec_status.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | "github.com/shopware/shopware-operator/internal/job" 10 | "github.com/shopware/shopware-operator/internal/logging" 11 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | k8sretry "k8s.io/client-go/util/retry" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | const ( 19 | Error = "Error" 20 | Ready = "Ready" 21 | ) 22 | 23 | func (r *StoreExecReconciler) reconcileCRStatus( 24 | ctx context.Context, 25 | store *v1.Store, 26 | ex *v1.StoreExec, 27 | reconcileError error, 28 | ) error { 29 | if ex == nil { 30 | return nil 31 | } 32 | 33 | if reconcileError != nil { 34 | ex.Status.AddCondition( 35 | v1.ExecCondition{ 36 | Type: ex.Status.State, 37 | LastTransitionTime: metav1.Time{}, 38 | LastUpdateTime: metav1.NewTime(time.Now()), 39 | Message: reconcileError.Error(), 40 | Reason: "ReconcileError", 41 | Status: Error, 42 | }, 43 | ) 44 | } 45 | 46 | if store == nil { 47 | ex.Status.State = v1.ExecStateWait 48 | ex.Status.AddCondition( 49 | v1.ExecCondition{ 50 | Type: ex.Status.State, 51 | LastTransitionTime: metav1.Time{}, 52 | LastUpdateTime: metav1.NewTime(time.Now()), 53 | Message: fmt.Sprintf("StoreRef not found (stores/%s), waiting for store to be created", ex.Spec.StoreRef), 54 | Reason: "StoreRef error", 55 | Status: Error, 56 | }, 57 | ) 58 | } else { 59 | if ex.IsState(v1.ExecStateEmpty, v1.ExecStateWait) { 60 | ex.Status.State = v1.ExecStateRunning 61 | } 62 | } 63 | 64 | if ex.IsState(v1.ExecStateRunning) { 65 | ex.Status.State = r.stateRunning(ctx, store, ex) 66 | } 67 | 68 | logging.FromContext(ctx).Info("Update exec status", "status", ex.Status) 69 | return writeExecStatus(ctx, r.Client, types.NamespacedName{ 70 | Namespace: ex.Namespace, 71 | Name: ex.Name, 72 | }, ex.Status) 73 | } 74 | 75 | func writeExecStatus( 76 | ctx context.Context, 77 | cl client.Client, 78 | nn types.NamespacedName, 79 | status v1.StoreExecStatus, 80 | ) error { 81 | return k8sretry.RetryOnConflict(k8sretry.DefaultRetry, func() error { 82 | cr := &v1.StoreExec{} 83 | if err := cl.Get(ctx, nn, cr); err != nil { 84 | return fmt.Errorf("write status: %w", err) 85 | } 86 | 87 | cr.Status = status 88 | return cl.Status().Update(ctx, cr) 89 | }) 90 | } 91 | 92 | func (r *StoreExecReconciler) stateRunning(ctx context.Context, store *v1.Store, ex *v1.StoreExec) v1.StatefulState { 93 | con := v1.ExecCondition{ 94 | Type: v1.ExecStateRunning, 95 | LastTransitionTime: metav1.Time{}, 96 | LastUpdateTime: metav1.Now(), 97 | Message: "Waiting for command job to get started", 98 | Reason: "", 99 | Status: "True", 100 | } 101 | defer func() { 102 | ex.Status.AddCondition(con) 103 | }() 104 | 105 | command, err := job.GetCommandJob(ctx, r.Client, *store, *ex) 106 | if err != nil { 107 | if k8serrors.IsNotFound(err) { 108 | return v1.ExecStateRunning 109 | } 110 | con.Reason = err.Error() 111 | con.Status = Error 112 | return v1.ExecStateRunning 113 | } 114 | 115 | // Controller is to fast so we need to check the command job 116 | if command == nil { 117 | return v1.ExecStateRunning 118 | } 119 | 120 | jobState, err := job.IsJobContainerDone(ctx, r.Client, command, job.CONTAINER_NAME_COMMAND) 121 | if err != nil { 122 | con.Reason = err.Error() 123 | con.Status = Error 124 | return v1.ExecStateRunning 125 | } 126 | 127 | if jobState.IsDone() && jobState.HasErrors() { 128 | con.Message = "Command is Done but has Errors. Check logs for more details" 129 | con.Reason = fmt.Sprintf("Exit code: %d", jobState.ExitCode) 130 | con.Status = Error 131 | con.LastTransitionTime = metav1.Now() 132 | return v1.ExecStateError 133 | } 134 | 135 | if jobState.IsDone() && !jobState.HasErrors() { 136 | con.Message = "Command finished" 137 | con.LastTransitionTime = metav1.Now() 138 | return v1.ExecStateDone 139 | } 140 | 141 | con.Message = fmt.Sprintf( 142 | "Waiting for command job to finish (Notice sidecars are counted). Active jobs: %d, Failed jobs: %d", 143 | command.Status.Active, 144 | command.Status.Failed, 145 | ) 146 | 147 | return v1.ExecStateRunning 148 | } 149 | -------------------------------------------------------------------------------- /internal/controller/storesnapshot_create_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | package controller 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/shopware/shopware-operator/internal/job" 23 | "github.com/shopware/shopware-operator/internal/k8s" 24 | batchv1 "k8s.io/api/batch/v1" 25 | "k8s.io/apimachinery/pkg/types" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | 29 | v1 "github.com/shopware/shopware-operator/api/v1" 30 | ) 31 | 32 | // Send EVENT 33 | // StoreSnapshotCreateReconciler reconciles a StoreSnapshot object 34 | type StoreSnapshotCreateReconciler struct { 35 | StoreSnapshotBaseReconciler 36 | } 37 | 38 | // TODO: Filter if the state is failed or succeeded, because then we don't reconcile finished snapshots 39 | // SetupWithManager sets up the controller with the Manager. 40 | func (r *StoreSnapshotCreateReconciler) SetupWithManager(mgr ctrl.Manager) error { 41 | skipStatusUpdates, err := NewSkipStatusUpdates(r.Logger) 42 | if err != nil { 43 | return err 44 | } 45 | return ctrl.NewControllerManagedBy(mgr). 46 | For(&v1.StoreSnapshotCreate{}). 47 | Owns(&batchv1.Job{}). 48 | WithEventFilter(skipStatusUpdates). 49 | Complete(r) 50 | } 51 | 52 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storesnapshotcreates,verbs=get;list;watch;create;update;patch;delete 53 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storesnapshotcreates/status,verbs=get;update;patch 54 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storesnapshotcreates/finalizers,verbs=update 55 | // +kubebuilder:rbac:groups="batch",namespace=default,resources=jobs,verbs=get;list;watch;create;delete 56 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=stores,verbs=get;list;update;patch 57 | // +kubebuilder:rbac:groups="",namespace=default,resources=persistentvolumes,verbs=get;list;watch;create;delete 58 | // +kubebuilder:rbac:groups="",namespace=default,resources=persistentvolumeclaims,verbs=get;list;watch;create;delete 59 | 60 | func (r *StoreSnapshotCreateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 61 | getSnapshot := func(ctx context.Context, client client.Client, key types.NamespacedName) (SnapshotResource, error) { 62 | snapshot, err := k8s.GetStoreSnapshotCreate(ctx, client, key) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return snapshot, nil 67 | } 68 | 69 | getJob := func(ctx context.Context, client client.Client, store v1.Store, snapshot SnapshotResource) (*batchv1.Job, error) { 70 | createSnapshot := snapshot.(*v1.StoreSnapshotCreate) 71 | return job.GetSnapshotCreateJob(ctx, client, store, *createSnapshot) 72 | } 73 | 74 | createJob := func(store v1.Store, snapshot SnapshotResource) *batchv1.Job { 75 | createSnapshot := snapshot.(*v1.StoreSnapshotCreate) 76 | return job.SnapshotCreateJob(store, *createSnapshot) 77 | } 78 | 79 | writeStatus := func(ctx context.Context, client client.Client, key types.NamespacedName, status v1.StoreSnapshotStatus) error { 80 | return WriteSnapshotStatus(ctx, client, key, status, func() *v1.StoreSnapshotCreate { 81 | return &v1.StoreSnapshotCreate{} 82 | }) 83 | } 84 | 85 | return r.ReconcileSnapshot(ctx, req, "create", getSnapshot, getJob, createJob, writeStatus) 86 | } 87 | -------------------------------------------------------------------------------- /internal/controller/storesnapshot_restore_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | package controller 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/shopware/shopware-operator/internal/job" 23 | "github.com/shopware/shopware-operator/internal/k8s" 24 | batchv1 "k8s.io/api/batch/v1" 25 | "k8s.io/apimachinery/pkg/types" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | 29 | v1 "github.com/shopware/shopware-operator/api/v1" 30 | ) 31 | 32 | // StoreSnapshotRestoreReconciler reconciles a StoreSnapshot object 33 | type StoreSnapshotRestoreReconciler struct { 34 | StoreSnapshotBaseReconciler 35 | } 36 | 37 | // SetupWithManager sets up the controller with the Manager. 38 | func (r *StoreSnapshotRestoreReconciler) SetupWithManager(mgr ctrl.Manager) error { 39 | skipStatusUpdates, err := NewSkipStatusUpdates(r.Logger) 40 | if err != nil { 41 | return err 42 | } 43 | return ctrl.NewControllerManagedBy(mgr). 44 | For(&v1.StoreSnapshotRestore{}). 45 | Owns(&batchv1.Job{}). 46 | WithEventFilter(skipStatusUpdates). 47 | Complete(r) 48 | } 49 | 50 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storesnapshotrestores,verbs=get;list;watch;create;update;patch;delete 51 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storesnapshotrestores/status,verbs=get;update;patch 52 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=storesnapshotrestores/finalizers,verbs=update 53 | // +kubebuilder:rbac:groups="batch",namespace=default,resources=jobs,verbs=get;list;watch;create;delete 54 | // +kubebuilder:rbac:groups=shop.shopware.com,namespace=default,resources=stores,verbs=get;list;update;patch 55 | // +kubebuilder:rbac:groups="",namespace=default,resources=persistentvolumes,verbs=get;list;watch;create;delete 56 | // +kubebuilder:rbac:groups="",namespace=default,resources=persistentvolumeclaims,verbs=get;list;watch;create;delete 57 | 58 | func (r *StoreSnapshotRestoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 59 | getSnapshot := func(ctx context.Context, client client.Client, key types.NamespacedName) (SnapshotResource, error) { 60 | snapshot, err := k8s.GetStoreSnapshotRestore(ctx, client, key) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return snapshot, nil 65 | } 66 | 67 | getJob := func(ctx context.Context, client client.Client, store v1.Store, snapshot SnapshotResource) (*batchv1.Job, error) { 68 | restoreSnapshot := snapshot.(*v1.StoreSnapshotRestore) 69 | return job.GetSnapshotRestoreJob(ctx, client, store, *restoreSnapshot) 70 | } 71 | 72 | createJob := func(store v1.Store, snapshot SnapshotResource) *batchv1.Job { 73 | restoreSnapshot := snapshot.(*v1.StoreSnapshotRestore) 74 | return job.SnapshotRestoreJob(store, *restoreSnapshot) 75 | } 76 | 77 | writeStatus := func(ctx context.Context, client client.Client, key types.NamespacedName, status v1.StoreSnapshotStatus) error { 78 | return WriteSnapshotStatus(ctx, client, key, status, func() *v1.StoreSnapshotRestore { 79 | return &v1.StoreSnapshotRestore{} 80 | }) 81 | } 82 | 83 | return r.ReconcileSnapshot(ctx, req, "restore", getSnapshot, getJob, createJob, writeStatus) 84 | } 85 | -------------------------------------------------------------------------------- /internal/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024. 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 | package controller 18 | 19 | // import ( 20 | // "context" 21 | // "os" 22 | // "path/filepath" 23 | // "testing" 24 | // 25 | // . "github.com/onsi/ginkgo/v2" 26 | // . "github.com/onsi/gomega" 27 | // 28 | // "k8s.io/client-go/kubernetes/scheme" 29 | // "k8s.io/client-go/rest" 30 | // "sigs.k8s.io/controller-runtime/pkg/client" 31 | // "sigs.k8s.io/controller-runtime/pkg/envtest" 32 | // logf 33 | // "sigs.k8s.io/controller-runtime/pkg/log/zap" 34 | // 35 | // shopv1 "github.com/shopware/shopware-operator/api/v1" 36 | // // +kubebuilder:scaffold:imports 37 | // ) 38 | // 39 | // // These tests use Ginkgo (BDD-style Go testing framework). Refer to 40 | // // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 41 | // 42 | // var ( 43 | // ctx context.Context 44 | // cancel context.CancelFunc 45 | // testEnv *envtest.Environment 46 | // cfg *rest.Config 47 | // k8sClient client.Client 48 | // ) 49 | // 50 | // func TestControllers(t *testing.T) { 51 | // RegisterFailHandler(Fail) 52 | // 53 | // RunSpecs(t, "Controller Suite") 54 | // } 55 | // 56 | // var _ = BeforeSuite(func() { 57 | // logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 58 | // 59 | // ctx, cancel = context.WithCancel(context.TODO()) 60 | // 61 | // var err error 62 | // err = shopv1.AddToScheme(scheme.Scheme) 63 | // Expect(err).NotTo(HaveOccurred()) 64 | // 65 | // // +kubebuilder:scaffold:scheme 66 | // 67 | // By("bootstrapping test environment") 68 | // testEnv = &envtest.Environment{ 69 | // CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 70 | // ErrorIfCRDPathMissing: true, 71 | // } 72 | // 73 | // // Retrieve the first found binary directory to allow running tests from IDEs 74 | // if getFirstFoundEnvTestBinaryDir() != "" { 75 | // testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() 76 | // } 77 | // 78 | // // cfg is defined in this file globally. 79 | // cfg, err = testEnv.Start() 80 | // Expect(err).NotTo(HaveOccurred()) 81 | // Expect(cfg).NotTo(BeNil()) 82 | // 83 | // k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 84 | // Expect(err).NotTo(HaveOccurred()) 85 | // Expect(k8sClient).NotTo(BeNil()) 86 | // }) 87 | // 88 | // var _ = AfterSuite(func() { 89 | // By("tearing down the test environment") 90 | // cancel() 91 | // err := testEnv.Stop() 92 | // Expect(err).NotTo(HaveOccurred()) 93 | // }) 94 | // 95 | // // getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. 96 | // // ENVTEST-based tests depend on specific binaries, usually located in paths set by 97 | // // controller-runtime. When running tests directly (e.g., via an IDE) without using 98 | // // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. 99 | // // 100 | // // This function streamlines the process by finding the required binaries, similar to 101 | // // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are 102 | // // properly set up, run 'make setup-envtest' beforehand. 103 | // func getFirstFoundEnvTestBinaryDir() string { 104 | // basePath := filepath.Join("..", "..", "bin", "k8s") 105 | // entries, err := os.ReadDir(basePath) 106 | // if err != nil { 107 | // logf.Log.Error(err, "Failed to read directory", "path", basePath) 108 | // return "" 109 | // } 110 | // for _, entry := range entries { 111 | // if entry.IsDir() { 112 | // return filepath.Join(basePath, entry.Name()) 113 | // } 114 | // } 115 | // return "" 116 | // } 117 | -------------------------------------------------------------------------------- /internal/cronjob/scheduled_task.go: -------------------------------------------------------------------------------- 1 | package cronjob 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/util" 9 | batchv1 "k8s.io/api/batch/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/types" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | const CONTAINER_NAME_SCHEDULED_JOB = "shopware-scheduled-task" 17 | 18 | func GetScheduledCronJob(ctx context.Context, client client.Client, store v1.Store) (*batchv1.CronJob, error) { 19 | setup := ScheduledTaskJob(store) 20 | search := &batchv1.CronJob{ 21 | ObjectMeta: setup.ObjectMeta, 22 | } 23 | err := client.Get(ctx, types.NamespacedName{ 24 | Namespace: setup.Namespace, 25 | Name: setup.Name, 26 | }, search) 27 | return search, err 28 | } 29 | 30 | func ScheduledTaskJob(store v1.Store) *batchv1.CronJob { 31 | // Merge Overwritten jobContainer fields into container fields 32 | store.Spec.Container.Merge(store.Spec.SetupJobContainer) 33 | 34 | parallelism := int32(1) 35 | completions := int32(1) 36 | sharedProcessNamespace := true 37 | var sa string 38 | 39 | // Global way 40 | if store.Spec.ServiceAccountName != "" { 41 | sa = store.Spec.ServiceAccountName 42 | } 43 | // Per container way 44 | if store.Spec.Container.ServiceAccountName != "" { 45 | sa = store.Spec.Container.ServiceAccountName 46 | } 47 | 48 | labels := util.GetDefaultContainerStoreLabels(store, store.Spec.SetupJobContainer.Labels) 49 | labels["shop.shopware.com/store.type"] = "scheduled-task" 50 | 51 | annotations := util.GetDefaultContainerAnnotations(CONTAINER_NAME_SCHEDULED_JOB, store, store.Spec.SetupJobContainer.Annotations) 52 | 53 | containers := append(store.Spec.Container.ExtraContainers, corev1.Container{ 54 | Name: CONTAINER_NAME_SCHEDULED_JOB, 55 | VolumeMounts: store.Spec.Container.VolumeMounts, 56 | ImagePullPolicy: store.Spec.Container.ImagePullPolicy, 57 | Image: store.Spec.Container.Image, 58 | Command: []string{"sh", "-c"}, 59 | Args: []string{store.Spec.ScheduledTask.Command}, 60 | Env: store.GetEnv(), 61 | Resources: store.Spec.Container.Resources, // Add Resources here 62 | }) 63 | 64 | job := &batchv1.CronJob{ 65 | TypeMeta: metav1.TypeMeta{ 66 | Kind: "CronJob", 67 | APIVersion: "batch/v1", 68 | }, 69 | ObjectMeta: metav1.ObjectMeta{ 70 | Name: GetScheduledCronJobName(store), 71 | Namespace: store.Namespace, 72 | Labels: labels, 73 | Annotations: annotations, 74 | }, 75 | Spec: batchv1.CronJobSpec{ 76 | Schedule: store.Spec.ScheduledTask.Schedule, 77 | TimeZone: &store.Spec.ScheduledTask.TimeZone, 78 | ConcurrencyPolicy: "Forbid", 79 | Suspend: &store.Spec.ScheduledTask.Suspend, 80 | JobTemplate: batchv1.JobTemplateSpec{ 81 | ObjectMeta: metav1.ObjectMeta{ 82 | Name: GetScheduledCronJobName(store), 83 | Namespace: store.Namespace, 84 | Labels: labels, 85 | Annotations: annotations, 86 | }, 87 | Spec: batchv1.JobSpec{ 88 | Parallelism: ¶llelism, 89 | Completions: &completions, 90 | Template: corev1.PodTemplateSpec{ 91 | ObjectMeta: metav1.ObjectMeta{ 92 | Labels: labels, 93 | Annotations: annotations, 94 | }, 95 | Spec: corev1.PodSpec{ 96 | ShareProcessNamespace: &sharedProcessNamespace, 97 | TerminationGracePeriodSeconds: &store.Spec.Container.TerminationGracePeriodSeconds, 98 | Volumes: store.Spec.Container.Volumes, 99 | TopologySpreadConstraints: store.Spec.Container.TopologySpreadConstraints, 100 | NodeSelector: store.Spec.Container.NodeSelector, 101 | ImagePullSecrets: store.Spec.Container.ImagePullSecrets, 102 | RestartPolicy: "Never", 103 | Containers: containers, 104 | SecurityContext: store.Spec.Container.SecurityContext, 105 | ServiceAccountName: sa, 106 | InitContainers: store.Spec.Container.InitContainers, 107 | }, 108 | }, 109 | }, 110 | }, 111 | }, 112 | } 113 | 114 | return job 115 | } 116 | 117 | func GetScheduledCronJobName(store v1.Store) string { 118 | return fmt.Sprintf("%s-scheduled-jobs", store.Name) 119 | } 120 | -------------------------------------------------------------------------------- /internal/deployment/util.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | appsv1 "k8s.io/api/apps/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/types" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | ) 13 | 14 | func getDeploymentCondition( 15 | deployment *appsv1.Deployment, 16 | storeReplicas int32, 17 | ) v1.DeploymentCondition { 18 | if deployment.Status.AvailableReplicas == deployment.Status.Replicas { 19 | 20 | // This happens if you use a hpa or scaling the deployment manually 21 | if deployment.Status.AvailableReplicas != storeReplicas { 22 | return v1.DeploymentCondition{ 23 | State: v1.DeploymentStateRunning, 24 | LastUpdateTime: metav1.Now(), 25 | Message: "Deployment is running, but has own scaling", 26 | Ready: fmt.Sprintf("%d/%d", deployment.Status.AvailableReplicas, storeReplicas), 27 | StoreReplicas: storeReplicas, 28 | } 29 | } 30 | 31 | return v1.DeploymentCondition{ 32 | State: v1.DeploymentStateRunning, 33 | LastUpdateTime: metav1.Now(), 34 | Message: "Deployment is running", 35 | Ready: fmt.Sprintf("%d/%d", deployment.Status.AvailableReplicas, storeReplicas), 36 | StoreReplicas: storeReplicas, 37 | } 38 | } 39 | 40 | if deployment.Status.AvailableReplicas != deployment.Status.Replicas { 41 | return v1.DeploymentCondition{ 42 | State: v1.DeploymentStateScaling, 43 | LastUpdateTime: metav1.Now(), 44 | Message: "Deployment is scaling", 45 | Ready: fmt.Sprintf("%d/%d", deployment.Status.AvailableReplicas, storeReplicas), 46 | StoreReplicas: storeReplicas, 47 | } 48 | } 49 | 50 | if deployment.Status.UnavailableReplicas > 0 { 51 | return v1.DeploymentCondition{ 52 | State: v1.DeploymentStateError, 53 | LastUpdateTime: metav1.Now(), 54 | Message: "Deployment has UnavailableReplicas", 55 | Ready: fmt.Sprintf("%d/%d", deployment.Status.AvailableReplicas, storeReplicas), 56 | StoreReplicas: storeReplicas, 57 | } 58 | } 59 | 60 | // TODO: log State Unknown with the status of the deployment to make a known status out of it 61 | 62 | return v1.DeploymentCondition{ 63 | State: v1.DeploymentStateUnknown, 64 | LastUpdateTime: metav1.Now(), 65 | Message: "Unknown state of the deployment", 66 | Ready: "0/0", 67 | StoreReplicas: storeReplicas, 68 | } 69 | } 70 | 71 | func GetStoreDeploymentImage( 72 | ctx context.Context, 73 | store v1.Store, 74 | client client.Client, 75 | ) (string, error) { 76 | setup := StorefrontDeployment(store) 77 | search := &appsv1.Deployment{ 78 | ObjectMeta: setup.ObjectMeta, 79 | } 80 | err := client.Get(ctx, types.NamespacedName{ 81 | Namespace: setup.Namespace, 82 | Name: setup.Name, 83 | }, search) 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | for _, container := range search.Spec.Template.Spec.Containers { 89 | if container.Name == DEPLOYMENT_STOREFRONT_CONTAINER_NAME { 90 | return container.Image, nil 91 | } 92 | } 93 | return "", fmt.Errorf("could not find storefront deployment container") 94 | } 95 | -------------------------------------------------------------------------------- /internal/deployment/worker.go: -------------------------------------------------------------------------------- 1 | package deployment 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | "github.com/shopware/shopware-operator/internal/util" 10 | appsv1 "k8s.io/api/apps/v1" 11 | corev1 "k8s.io/api/core/v1" 12 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/types" 15 | "k8s.io/apimachinery/pkg/util/intstr" 16 | "sigs.k8s.io/controller-runtime/pkg/client" 17 | ) 18 | 19 | func GetWorkerDeployment( 20 | ctx context.Context, 21 | store v1.Store, 22 | client client.Client, 23 | ) (*appsv1.Deployment, error) { 24 | setup := WorkerDeployment(store) 25 | search := &appsv1.Deployment{ 26 | ObjectMeta: setup.ObjectMeta, 27 | } 28 | err := client.Get(ctx, types.NamespacedName{ 29 | Namespace: setup.Namespace, 30 | Name: setup.Name, 31 | }, search) 32 | return search, err 33 | } 34 | 35 | func GetWorkerDeploymentCondition( 36 | ctx context.Context, 37 | store v1.Store, 38 | client client.Client, 39 | ) v1.DeploymentCondition { 40 | deployment := WorkerDeployment(store) 41 | search := &appsv1.Deployment{ 42 | ObjectMeta: deployment.ObjectMeta, 43 | } 44 | err := client.Get(ctx, types.NamespacedName{ 45 | Namespace: deployment.Namespace, 46 | Name: deployment.Name, 47 | }, search) 48 | if err != nil { 49 | if k8serrors.IsNotFound(err) { 50 | return v1.DeploymentCondition{ 51 | State: v1.DeploymentStateNotFound, 52 | LastUpdateTime: metav1.Now(), 53 | Message: "No deployment found", 54 | Ready: "0/0", 55 | } 56 | } else { 57 | return v1.DeploymentCondition{ 58 | State: v1.DeploymentStateError, 59 | LastUpdateTime: metav1.Now(), 60 | //nolint:staticcheck 61 | Message: fmt.Errorf("Error on client get: %w", err).Error(), 62 | Ready: "0/0", 63 | } 64 | } 65 | } 66 | return getDeploymentCondition(search, *deployment.Spec.Replicas) 67 | } 68 | 69 | func WorkerDeployment(store v1.Store) *appsv1.Deployment { 70 | containerSpec := store.Spec.Container.DeepCopy() 71 | containerSpec.Merge(store.Spec.WorkerDeploymentContainer) 72 | 73 | appName := "shopware-worker" 74 | labels := util.GetDefaultContainerStoreLabels(store, store.Spec.WorkerDeploymentContainer.Labels) 75 | maps.Copy(labels, util.GetWorkerDeploymentMatchLabel()) 76 | 77 | annotations := util.GetDefaultContainerAnnotations(appName, store, store.Spec.WorkerDeploymentContainer.Annotations) 78 | 79 | // Merge containerSpec.ExtraEnvs to override with merged values from WorkerDeploymentContainer 80 | envs := util.MergeEnv(store.GetEnv(), containerSpec.ExtraEnvs) 81 | 82 | containers := append(containerSpec.ExtraContainers, corev1.Container{ 83 | Name: appName, 84 | Image: containerSpec.Image, 85 | ImagePullPolicy: containerSpec.ImagePullPolicy, 86 | Env: envs, 87 | Command: []string{ 88 | "bin/console", 89 | }, 90 | Args: []string{ 91 | "messenger:consume", 92 | "async", 93 | "low_priority", 94 | "failed", 95 | "scheduler_shopware", 96 | }, 97 | VolumeMounts: containerSpec.VolumeMounts, 98 | Ports: []corev1.ContainerPort{ 99 | { 100 | ContainerPort: containerSpec.Port, 101 | Protocol: corev1.ProtocolTCP, 102 | }, 103 | }, 104 | Resources: containerSpec.Resources, 105 | }) 106 | 107 | deployment := &appsv1.Deployment{ 108 | TypeMeta: metav1.TypeMeta{ 109 | Kind: "Deployment", 110 | APIVersion: "apps/v1", 111 | }, 112 | ObjectMeta: metav1.ObjectMeta{ 113 | Name: GetWorkerDeploymentName(store), 114 | Namespace: store.Namespace, 115 | Labels: labels, 116 | Annotations: annotations, 117 | }, 118 | Spec: appsv1.DeploymentSpec{ 119 | ProgressDeadlineSeconds: &containerSpec.ProgressDeadlineSeconds, 120 | Replicas: &containerSpec.Replicas, 121 | Selector: &metav1.LabelSelector{ 122 | MatchLabels: util.GetWorkerDeploymentMatchLabel(), 123 | }, 124 | Strategy: appsv1.DeploymentStrategy{ 125 | RollingUpdate: &appsv1.RollingUpdateDeployment{ 126 | MaxSurge: &intstr.IntOrString{ 127 | Type: intstr.String, 128 | StrVal: "25%", 129 | }, 130 | MaxUnavailable: &intstr.IntOrString{ 131 | Type: intstr.String, 132 | StrVal: "25%", 133 | }, 134 | }, 135 | }, 136 | Template: corev1.PodTemplateSpec{ 137 | ObjectMeta: metav1.ObjectMeta{ 138 | Labels: labels, 139 | Annotations: annotations, 140 | }, 141 | Spec: corev1.PodSpec{ 142 | Volumes: containerSpec.Volumes, 143 | TopologySpreadConstraints: containerSpec.TopologySpreadConstraints, 144 | NodeSelector: containerSpec.NodeSelector, 145 | ImagePullSecrets: containerSpec.ImagePullSecrets, 146 | RestartPolicy: containerSpec.RestartPolicy, 147 | Containers: containers, 148 | SecurityContext: containerSpec.SecurityContext, 149 | InitContainers: containerSpec.InitContainers, 150 | }, 151 | }, 152 | }, 153 | } 154 | 155 | // Old way 156 | if store.Spec.ServiceAccountName != "" { 157 | deployment.Spec.Template.Spec.ServiceAccountName = store.Spec.ServiceAccountName 158 | } 159 | // New way 160 | if containerSpec.ServiceAccountName != "" { 161 | deployment.Spec.Template.Spec.ServiceAccountName = containerSpec.ServiceAccountName 162 | } 163 | 164 | return deployment 165 | } 166 | 167 | func GetWorkerDeploymentName(store v1.Store) string { 168 | return fmt.Sprintf("%s-store-worker", store.Name) 169 | } 170 | -------------------------------------------------------------------------------- /internal/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | ) 8 | 9 | type Event struct { 10 | // Kind Type {store, snapshotCreate, snapshotRestore, exec, debugInstance} 11 | KindType string `json:"kindType"` 12 | // Custom Message 13 | Message string `json:"message"` 14 | // Last condition of the store 15 | Condition v1.StoreCondition `json:"condition"` 16 | // Current Running image tag 17 | DeployedImage string `json:"deployedImage"` 18 | // Labels of the store custom resource 19 | Labels map[string]string `json:"storeLabels"` 20 | } 21 | 22 | type EventHandler interface { 23 | Send(ctx context.Context, event Event) error 24 | Close() 25 | } 26 | -------------------------------------------------------------------------------- /internal/event/nats/nats.go: -------------------------------------------------------------------------------- 1 | package nats 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/nats-io/nats.go" 9 | "github.com/shopware/shopware-operator/internal/event" 10 | ) 11 | 12 | var _ event.EventHandler = (*NatsEventServer)(nil) 13 | 14 | type NatsEventServer struct { 15 | conn *nats.Conn 16 | topic string 17 | } 18 | 19 | // Send implements event.EventHandler. 20 | func (w *NatsEventServer) Send(ctx context.Context, event event.Event) error { 21 | data, err := json.MarshalIndent(event, "", " ") 22 | if err != nil { 23 | return fmt.Errorf("failed to marshal event data in nats handler: %w", err) 24 | } 25 | 26 | err = w.conn.Publish(w.topic, data) 27 | if err != nil { 28 | return fmt.Errorf("failed to publish event to NATS: %w", err) 29 | } 30 | return nil 31 | } 32 | 33 | func (w *NatsEventServer) Close() { 34 | w.conn.Close() 35 | } 36 | 37 | func NewNatsEventServer(addr string, nkeyFile string, credentialsFile string, topic string) (*NatsEventServer, error) { 38 | options := []nats.Option{ 39 | nats.Name("shopware-operator"), 40 | } 41 | 42 | if nkeyFile != "" { 43 | opt, err := nats.NkeyOptionFromSeed(nkeyFile) 44 | if err != nil { 45 | return &NatsEventServer{}, fmt.Errorf("failed to read NKeyFile: %w", err) 46 | } 47 | options = append(options, opt) 48 | } 49 | 50 | if credentialsFile != "" { 51 | options = append(options, nats.UserCredentials(credentialsFile)) 52 | } 53 | 54 | // Connect to a server 55 | nc, err := nats.Connect(addr, options...) 56 | if err != nil { 57 | return &NatsEventServer{}, fmt.Errorf("failed to connect to NATS server: %w", err) 58 | } 59 | 60 | if !nc.IsConnected() { 61 | return &NatsEventServer{}, fmt.Errorf("NATS server connection test failed: server is not connected") 62 | } 63 | 64 | return &NatsEventServer{ 65 | conn: nc, 66 | topic: topic, 67 | }, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/hpa/horizontal_pod_autoscaler.go: -------------------------------------------------------------------------------- 1 | package hpa 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/deployment" 9 | "github.com/shopware/shopware-operator/internal/util" 10 | autoscaling "k8s.io/api/autoscaling/v2" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/types" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | func GetStoreHPA( 17 | ctx context.Context, 18 | store v1.Store, 19 | client client.Client, 20 | ) (*autoscaling.HorizontalPodAutoscaler, error) { 21 | hpa := StoreHPA(store) 22 | search := &autoscaling.HorizontalPodAutoscaler{ 23 | ObjectMeta: hpa.ObjectMeta, 24 | } 25 | err := client.Get(ctx, types.NamespacedName{ 26 | Namespace: hpa.Namespace, 27 | Name: hpa.Name, 28 | }, search) 29 | return search, err 30 | } 31 | 32 | func StoreHPA(store v1.Store) *autoscaling.HorizontalPodAutoscaler { 33 | dep := deployment.StorefrontDeployment(store) 34 | 35 | if len(store.Spec.HorizontalPodAutoscaler.Metrics) == 0 { 36 | util := int32(70) 37 | store.Spec.HorizontalPodAutoscaler.Metrics = append( 38 | store.Spec.HorizontalPodAutoscaler.Metrics, 39 | autoscaling.MetricSpec{ 40 | Type: autoscaling.ResourceMetricSourceType, 41 | Resource: &autoscaling.ResourceMetricSource{ 42 | Name: "cpu", 43 | Target: autoscaling.MetricTarget{ 44 | Type: "Utilization", 45 | AverageUtilization: &util, 46 | }, 47 | }, 48 | }, 49 | ) 50 | } 51 | 52 | return &autoscaling.HorizontalPodAutoscaler{ 53 | TypeMeta: metav1.TypeMeta{}, 54 | ObjectMeta: metav1.ObjectMeta{ 55 | Name: GetStoreHPAName(store), 56 | Namespace: store.GetNamespace(), 57 | Labels: util.GetDefaultContainerStoreLabels(store, map[string]string{}), 58 | Annotations: store.Spec.HorizontalPodAutoscaler.Annotations, 59 | }, 60 | Spec: autoscaling.HorizontalPodAutoscalerSpec{ 61 | ScaleTargetRef: autoscaling.CrossVersionObjectReference{ 62 | Kind: dep.Kind, 63 | Name: dep.Name, 64 | APIVersion: dep.APIVersion, 65 | }, 66 | MinReplicas: store.Spec.HorizontalPodAutoscaler.MinReplicas, 67 | MaxReplicas: store.Spec.HorizontalPodAutoscaler.MaxReplicas, 68 | Metrics: store.Spec.HorizontalPodAutoscaler.Metrics, 69 | Behavior: store.Spec.HorizontalPodAutoscaler.Behavior, 70 | }, 71 | } 72 | } 73 | 74 | func GetStoreHPAName(store v1.Store) string { 75 | return fmt.Sprintf("store-%s", store.Name) 76 | } 77 | -------------------------------------------------------------------------------- /internal/ingress/ingress.go: -------------------------------------------------------------------------------- 1 | package ingress 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | "github.com/shopware/shopware-operator/internal/service" 10 | "github.com/shopware/shopware-operator/internal/util" 11 | appsv1 "k8s.io/api/apps/v1" 12 | networkingv1 "k8s.io/api/networking/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/types" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | func GetStoreIngress( 19 | ctx context.Context, 20 | store v1.Store, 21 | client client.Client, 22 | ) (*appsv1.Deployment, error) { 23 | ingress := StoreIngress(store) 24 | search := &appsv1.Deployment{ 25 | ObjectMeta: ingress.ObjectMeta, 26 | } 27 | err := client.Get(ctx, types.NamespacedName{ 28 | Namespace: ingress.Namespace, 29 | Name: ingress.Name, 30 | }, search) 31 | return search, err 32 | } 33 | 34 | func StoreIngress(store v1.Store) *networkingv1.Ingress { 35 | pathType := networkingv1.PathTypePrefix 36 | 37 | labels := util.GetDefaultContainerStoreLabels(store, map[string]string{}) 38 | maps.Copy(labels, store.Spec.Network.Labels) 39 | 40 | hosts := make([]string, len(store.Spec.Network.Hosts)) 41 | _ = copy(hosts, store.Spec.Network.Hosts) 42 | 43 | if store.Spec.Network.Host != "" { 44 | hosts = append(hosts, store.Spec.Network.Host) 45 | } 46 | 47 | var tls []networkingv1.IngressTLS 48 | if store.Spec.Network.TLSSecretName != "" { 49 | tls = append(tls, networkingv1.IngressTLS{ 50 | Hosts: hosts, 51 | SecretName: store.Spec.Network.TLSSecretName, 52 | }) 53 | } 54 | 55 | rules := make([]networkingv1.IngressRule, 0, len(hosts)) 56 | for _, host := range hosts { 57 | rules = append(rules, networkingv1.IngressRule{ 58 | Host: host, 59 | IngressRuleValue: networkingv1.IngressRuleValue{ 60 | HTTP: &networkingv1.HTTPIngressRuleValue{ 61 | Paths: []networkingv1.HTTPIngressPath{ 62 | { 63 | Path: "/api", 64 | PathType: &pathType, 65 | Backend: networkingv1.IngressBackend{ 66 | Service: &networkingv1.IngressServiceBackend{ 67 | Name: service.GetAdminServiceName(store), 68 | Port: networkingv1.ServiceBackendPort{ 69 | Number: store.Spec.Network.Port, 70 | }, 71 | }, 72 | }, 73 | }, 74 | { 75 | Path: "/admin", 76 | PathType: &pathType, 77 | Backend: networkingv1.IngressBackend{ 78 | Service: &networkingv1.IngressServiceBackend{ 79 | Name: service.GetAdminServiceName(store), 80 | Port: networkingv1.ServiceBackendPort{ 81 | Number: store.Spec.Network.Port, 82 | }, 83 | }, 84 | }, 85 | }, 86 | { 87 | Path: "/store-api", 88 | PathType: &pathType, 89 | Backend: networkingv1.IngressBackend{ 90 | Service: &networkingv1.IngressServiceBackend{ 91 | Name: service.GetStorefrontServiceName(store), 92 | Port: networkingv1.ServiceBackendPort{ 93 | Number: store.Spec.Network.Port, 94 | }, 95 | }, 96 | }, 97 | }, 98 | { 99 | Path: "/", 100 | PathType: &pathType, 101 | Backend: networkingv1.IngressBackend{ 102 | Service: &networkingv1.IngressServiceBackend{ 103 | Name: service.GetStorefrontServiceName(store), 104 | Port: networkingv1.ServiceBackendPort{ 105 | Number: store.Spec.Network.Port, 106 | }, 107 | }, 108 | }, 109 | }, 110 | }, 111 | }, 112 | }, 113 | }) 114 | } 115 | 116 | return &networkingv1.Ingress{ 117 | TypeMeta: metav1.TypeMeta{}, 118 | ObjectMeta: metav1.ObjectMeta{ 119 | Name: GetStoreIngressName(store), 120 | Namespace: store.GetNamespace(), 121 | Annotations: store.Spec.Network.Annotations, 122 | Labels: labels, 123 | }, 124 | Spec: networkingv1.IngressSpec{ 125 | IngressClassName: &store.Spec.Network.IngressClassName, 126 | Rules: rules, 127 | TLS: tls, 128 | }, 129 | } 130 | } 131 | 132 | func GetStoreIngressName(store v1.Store) string { 133 | return fmt.Sprintf("store-%s", store.Name) 134 | } 135 | -------------------------------------------------------------------------------- /internal/job/command.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | "github.com/shopware/shopware-operator/internal/util" 8 | batchv1 "k8s.io/api/batch/v1" 9 | corev1 "k8s.io/api/core/v1" 10 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/types" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | const CONTAINER_NAME_COMMAND = "shopware-command" 17 | 18 | func GetCommandJob( 19 | ctx context.Context, 20 | client client.Client, 21 | store v1.Store, 22 | exec v1.StoreExec, 23 | ) (*batchv1.Job, error) { 24 | mig := CommandJob(store, exec) 25 | search := &batchv1.Job{ 26 | ObjectMeta: mig.ObjectMeta, 27 | } 28 | err := client.Get(ctx, types.NamespacedName{ 29 | Namespace: mig.Namespace, 30 | Name: mig.Name, 31 | }, search) 32 | return search, err 33 | } 34 | 35 | func GetCommandCronJob( 36 | ctx context.Context, 37 | client client.Client, 38 | store v1.Store, 39 | exec v1.StoreExec, 40 | ) (*batchv1.CronJob, error) { 41 | mig := CommandJob(store, exec) 42 | search := &batchv1.CronJob{ 43 | ObjectMeta: mig.ObjectMeta, 44 | } 45 | err := client.Get(ctx, types.NamespacedName{ 46 | Namespace: mig.Namespace, 47 | Name: mig.Name, 48 | }, search) 49 | return search, err 50 | } 51 | 52 | func CommandCronJob(store v1.Store, ex v1.StoreExec) *batchv1.CronJob { 53 | labels := util.GetDefaultStoreExecLabels(store, ex) 54 | labels["shop.shopware.com/storeexec.type"] = "command" 55 | annotations := util.GetDefaultContainerExecAnnotations(CONTAINER_NAME_COMMAND, ex) 56 | 57 | job := &batchv1.CronJob{ 58 | TypeMeta: metav1.TypeMeta{ 59 | Kind: "CronJob", 60 | APIVersion: "batch/v1", 61 | }, 62 | ObjectMeta: metav1.ObjectMeta{ 63 | Name: CommandJobName(ex), 64 | Namespace: ex.Namespace, 65 | Labels: labels, 66 | Annotations: annotations, 67 | }, 68 | Spec: batchv1.CronJobSpec{ 69 | Schedule: ex.Spec.CronSchedule, 70 | Suspend: &ex.Spec.CronSuspend, 71 | JobTemplate: batchv1.JobTemplateSpec{ 72 | ObjectMeta: metav1.ObjectMeta{ 73 | Name: CommandJobName(ex), 74 | Namespace: ex.Namespace, 75 | Labels: labels, 76 | Annotations: annotations, 77 | }, 78 | Spec: getJobSpec(store, ex, labels), 79 | }, 80 | }, 81 | } 82 | 83 | return job 84 | } 85 | 86 | func CommandJob(store v1.Store, ex v1.StoreExec) *batchv1.Job { 87 | // Copy container spec from store to exec 88 | store.Spec.Container.DeepCopyInto(&ex.Spec.Container) 89 | 90 | labels := util.GetDefaultStoreExecLabels(store, ex) 91 | labels["shop.shopware.com/storeexec.type"] = "cron_command" 92 | annotations := util.GetDefaultContainerExecAnnotations(CONTAINER_NAME_COMMAND, ex) 93 | 94 | job := &batchv1.Job{ 95 | TypeMeta: metav1.TypeMeta{ 96 | Kind: "Job", 97 | APIVersion: "batch/v1", 98 | }, 99 | ObjectMeta: metav1.ObjectMeta{ 100 | Name: CommandJobName(ex), 101 | Namespace: ex.Namespace, 102 | Labels: labels, 103 | Annotations: annotations, 104 | }, 105 | Spec: getJobSpec(store, ex, labels), 106 | } 107 | 108 | return job 109 | } 110 | 111 | func CommandJobName(exec v1.StoreExec) string { 112 | return exec.Name 113 | } 114 | 115 | func getJobSpec(store v1.Store, ex v1.StoreExec, labels map[string]string) batchv1.JobSpec { 116 | containerSpec := store.Spec.Container.DeepCopy() 117 | sharedProcessNamespace := true 118 | 119 | envs := util.MergeEnv(store.GetEnv(), ex.Spec.ExtraEnvs) 120 | 121 | containers := append(containerSpec.ExtraContainers, corev1.Container{ 122 | Name: CONTAINER_NAME_COMMAND, 123 | VolumeMounts: containerSpec.VolumeMounts, 124 | ImagePullPolicy: containerSpec.ImagePullPolicy, 125 | Image: containerSpec.Image, 126 | Command: []string{"sh", "-c"}, 127 | Args: []string{ex.Spec.Script}, 128 | Env: envs, 129 | }) 130 | 131 | var sa string 132 | // Global way 133 | if store.Spec.ServiceAccountName != "" { 134 | sa = store.Spec.ServiceAccountName 135 | } 136 | // Per container way 137 | if containerSpec.ServiceAccountName != "" { 138 | sa = containerSpec.ServiceAccountName 139 | } 140 | 141 | return batchv1.JobSpec{ 142 | BackoffLimit: &ex.Spec.MaxRetries, 143 | TTLSecondsAfterFinished: &ttlSecondsAfterFinished, 144 | Template: corev1.PodTemplateSpec{ 145 | ObjectMeta: metav1.ObjectMeta{ 146 | Labels: labels, 147 | }, 148 | Spec: corev1.PodSpec{ 149 | ServiceAccountName: sa, 150 | ShareProcessNamespace: &sharedProcessNamespace, 151 | Volumes: containerSpec.Volumes, 152 | TopologySpreadConstraints: containerSpec.TopologySpreadConstraints, 153 | TerminationGracePeriodSeconds: &containerSpec.TerminationGracePeriodSeconds, 154 | NodeSelector: containerSpec.NodeSelector, 155 | ImagePullSecrets: containerSpec.ImagePullSecrets, 156 | RestartPolicy: "Never", 157 | Containers: containers, 158 | SecurityContext: containerSpec.SecurityContext, 159 | InitContainers: containerSpec.InitContainers, 160 | }, 161 | }, 162 | } 163 | } 164 | 165 | // This is just a soft check, use container check for a clean check 166 | // Will return true if container is stopped (Completed, Error) 167 | func IsCommandJobCompleted( 168 | ctx context.Context, 169 | c client.Client, 170 | store v1.Store, 171 | exec v1.StoreExec, 172 | ) (bool, error) { 173 | job, err := GetCommandJob(ctx, c, store, exec) 174 | if err != nil { 175 | if k8serrors.IsNotFound(err) { 176 | return false, nil 177 | } 178 | return false, err 179 | } 180 | 181 | state, err := IsJobContainerDone(ctx, c, job, CONTAINER_NAME_COMMAND) 182 | if err != nil { 183 | return false, err 184 | } 185 | 186 | return state.IsDone(), nil 187 | } 188 | -------------------------------------------------------------------------------- /internal/job/command_test.go: -------------------------------------------------------------------------------- 1 | package job_test 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | "github.com/shopware/shopware-operator/internal/job" 8 | "github.com/stretchr/testify/assert" 9 | corev1 "k8s.io/api/core/v1" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | func TestCommandJob(t *testing.T) { 14 | t.Run("test env merging", func(t *testing.T) { 15 | store := v1.Store{ 16 | ObjectMeta: metav1.ObjectMeta{ 17 | Name: "test-store", 18 | Namespace: "test", 19 | }, 20 | Spec: v1.StoreSpec{ 21 | Container: v1.ContainerSpec{ 22 | Image: "shopware:latest", 23 | ImagePullPolicy: "IfNotPresent", 24 | ExtraEnvs: []corev1.EnvVar{ 25 | { 26 | Name: "CONTAINER_ENV", 27 | Value: "value", 28 | }, 29 | }, 30 | }, 31 | SecretName: "store-secret", 32 | }, 33 | } 34 | 35 | exec := v1.StoreExec{ 36 | ObjectMeta: metav1.ObjectMeta{ 37 | Name: "test-exec", 38 | Namespace: "test", 39 | }, 40 | Spec: v1.StoreExecSpec{ 41 | Script: "echo 'test'", 42 | ExtraEnvs: []corev1.EnvVar{ 43 | { 44 | Name: "EXEC_ENV", 45 | Value: "value", 46 | }, 47 | { 48 | Name: "CONTAINER_ENV", 49 | Value: "overwritten", 50 | }, 51 | }, 52 | }, 53 | } 54 | 55 | result := job.CommandJob(store, exec) 56 | container := result.Spec.Template.Spec.Containers[0] 57 | 58 | // Verify env vars are merged 59 | hasExecEnv := false 60 | hasContainerEnv := false 61 | for _, env := range container.Env { 62 | if env.Name == "EXEC_ENV" { 63 | hasExecEnv = true 64 | assert.Equal(t, "value", env.Value) 65 | } 66 | if env.Name == "CONTAINER_ENV" { 67 | hasContainerEnv = true 68 | assert.Equal(t, "overwritten", env.Value) 69 | } 70 | } 71 | assert.True(t, hasExecEnv, "Exec env var should be present") 72 | assert.True(t, hasContainerEnv, "Container env var should be present and overwritten") 73 | }) 74 | 75 | t.Run("test script execution", func(t *testing.T) { 76 | store := v1.Store{ 77 | ObjectMeta: metav1.ObjectMeta{ 78 | Name: "test-store", 79 | Namespace: "test", 80 | }, 81 | Spec: v1.StoreSpec{ 82 | Container: v1.ContainerSpec{ 83 | Image: "shopware:latest", 84 | ImagePullPolicy: "IfNotPresent", 85 | }, 86 | SecretName: "store-secret", 87 | }, 88 | } 89 | 90 | exec := v1.StoreExec{ 91 | ObjectMeta: metav1.ObjectMeta{ 92 | Name: "test-exec", 93 | Namespace: "test", 94 | }, 95 | Spec: v1.StoreExecSpec{ 96 | Script: "bin/console cache:clear", 97 | }, 98 | } 99 | 100 | result := job.CommandJob(store, exec) 101 | container := result.Spec.Template.Spec.Containers[0] 102 | 103 | // Verify command and args 104 | assert.Equal(t, []string{"sh", "-c"}, container.Command) 105 | assert.Equal(t, []string{"bin/console cache:clear"}, container.Args) 106 | assert.Equal(t, "shopware-command", container.Name) 107 | }) 108 | } 109 | 110 | func TestCommandCronJob(t *testing.T) { 111 | t.Run("test cron schedule", func(t *testing.T) { 112 | store := v1.Store{ 113 | ObjectMeta: metav1.ObjectMeta{ 114 | Name: "test-store", 115 | Namespace: "test", 116 | }, 117 | Spec: v1.StoreSpec{ 118 | Container: v1.ContainerSpec{ 119 | Image: "shopware:latest", 120 | ImagePullPolicy: "IfNotPresent", 121 | }, 122 | SecretName: "store-secret", 123 | }, 124 | } 125 | 126 | suspend := false 127 | exec := v1.StoreExec{ 128 | ObjectMeta: metav1.ObjectMeta{ 129 | Name: "test-exec", 130 | Namespace: "test", 131 | }, 132 | Spec: v1.StoreExecSpec{ 133 | Script: "bin/console scheduled-task:run", 134 | CronSchedule: "*/5 * * * *", 135 | CronSuspend: suspend, 136 | }, 137 | } 138 | 139 | result := job.CommandCronJob(store, exec) 140 | 141 | // Verify cron job spec 142 | assert.Equal(t, "*/5 * * * *", result.Spec.Schedule) 143 | assert.Equal(t, &suspend, result.Spec.Suspend) 144 | assert.Equal(t, "test-exec", result.Name) 145 | assert.Equal(t, "test", result.Namespace) 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /internal/job/migration.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "fmt" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | "github.com/shopware/shopware-operator/internal/util" 10 | batchv1 "k8s.io/api/batch/v1" 11 | corev1 "k8s.io/api/core/v1" 12 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/apimachinery/pkg/types" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | var MigrationJobIdentifyer = map[string]string{"type": "migration"} 19 | 20 | const CONTAINER_NAME_MIGRATION_JOB = "shopware-migration" 21 | 22 | func GetMigrationJob( 23 | ctx context.Context, 24 | client client.Client, 25 | store v1.Store, 26 | ) (*batchv1.Job, error) { 27 | mig := MigrationJob(store) 28 | search := &batchv1.Job{ 29 | ObjectMeta: mig.ObjectMeta, 30 | } 31 | err := client.Get(ctx, types.NamespacedName{ 32 | Namespace: mig.Namespace, 33 | Name: mig.Name, 34 | }, search) 35 | return search, err 36 | } 37 | 38 | func MigrationJob(store v1.Store) *batchv1.Job { 39 | containerSpec := store.Spec.Container.DeepCopy() 40 | containerSpec.Merge(store.Spec.MigrationJobContainer) 41 | 42 | backoffLimit := int32(3) 43 | sharedProcessNamespace := true 44 | 45 | labels := util.GetDefaultContainerStoreLabels(store, store.Spec.MigrationJobContainer.Labels) 46 | labels["shop.shopware.com/store.hash"] = GetMigrateHash(store) 47 | labels["shop.shopware.com/store.type"] = "migration" 48 | 49 | annotations := util.GetDefaultContainerAnnotations(CONTAINER_NAME_MIGRATION_JOB, store, store.Spec.MigrationJobContainer.Annotations) 50 | annotations["shop.shopware.com/store.oldImage"] = store.Status.CurrentImageTag 51 | annotations["shop.shopware.com/store.newImage"] = containerSpec.Image 52 | 53 | // Merge containerSpec.ExtraEnvs to override with merged values from MigrationJobContainer 54 | envs := util.MergeEnv(store.GetEnv(), containerSpec.ExtraEnvs) 55 | 56 | containers := append(containerSpec.ExtraContainers, corev1.Container{ 57 | Name: CONTAINER_NAME_MIGRATION_JOB, 58 | VolumeMounts: containerSpec.VolumeMounts, 59 | ImagePullPolicy: containerSpec.ImagePullPolicy, 60 | Image: containerSpec.Image, 61 | Command: []string{"sh", "-c"}, 62 | Args: []string{store.Spec.MigrationScript}, 63 | Env: envs, 64 | Resources: containerSpec.Resources, 65 | }) 66 | 67 | job := &batchv1.Job{ 68 | TypeMeta: metav1.TypeMeta{ 69 | Kind: "Job", 70 | APIVersion: "batch/v1", 71 | }, 72 | ObjectMeta: metav1.ObjectMeta{ 73 | Name: MigrateJobName(store), 74 | Namespace: store.Namespace, 75 | Labels: labels, 76 | Annotations: annotations, 77 | }, 78 | Spec: batchv1.JobSpec{ 79 | BackoffLimit: &backoffLimit, 80 | TTLSecondsAfterFinished: &ttlSecondsAfterFinished, 81 | Template: corev1.PodTemplateSpec{ 82 | ObjectMeta: metav1.ObjectMeta{ 83 | Labels: labels, 84 | Annotations: annotations, 85 | }, 86 | Spec: corev1.PodSpec{ 87 | ShareProcessNamespace: &sharedProcessNamespace, 88 | Volumes: containerSpec.Volumes, 89 | TopologySpreadConstraints: containerSpec.TopologySpreadConstraints, 90 | TerminationGracePeriodSeconds: &containerSpec.TerminationGracePeriodSeconds, 91 | NodeSelector: containerSpec.NodeSelector, 92 | ImagePullSecrets: containerSpec.ImagePullSecrets, 93 | RestartPolicy: "Never", 94 | Containers: containers, 95 | SecurityContext: containerSpec.SecurityContext, 96 | InitContainers: containerSpec.InitContainers, 97 | }, 98 | }, 99 | }, 100 | } 101 | 102 | // Global way 103 | if store.Spec.ServiceAccountName != "" { 104 | job.Spec.Template.Spec.ServiceAccountName = store.Spec.ServiceAccountName 105 | } 106 | // Per container way 107 | if containerSpec.ServiceAccountName != "" { 108 | job.Spec.Template.Spec.ServiceAccountName = containerSpec.ServiceAccountName 109 | } 110 | 111 | return job 112 | } 113 | 114 | func MigrateJobName(store v1.Store) string { 115 | return fmt.Sprintf("%s-migrate-%s", store.Name, GetMigrateHash(store)) 116 | } 117 | 118 | func GetMigrateHash(store v1.Store) string { 119 | data := []byte(store.Status.CurrentImageTag) 120 | return fmt.Sprintf("%x", md5.Sum(data)) 121 | } 122 | 123 | func DeleteAllMigrationJobs(ctx context.Context, c client.Client, store *v1.Store) error { 124 | return deleteJobsByLabel(ctx, c, store.Namespace, MigrationJobIdentifyer) 125 | } 126 | 127 | // This is just a soft check, use container check for a clean check 128 | // Will return true if container is stopped (Completed, Error) 129 | func IsMigrationJobCompleted( 130 | ctx context.Context, 131 | c client.Client, 132 | store v1.Store, 133 | ) (bool, error) { 134 | migration, err := GetMigrationJob(ctx, c, store) 135 | if err != nil { 136 | if k8serrors.IsNotFound(err) { 137 | return false, nil 138 | } 139 | return false, err 140 | } 141 | 142 | state, err := IsJobContainerDone(ctx, c, migration, CONTAINER_NAME_MIGRATION_JOB) 143 | if err != nil { 144 | return false, err 145 | } 146 | 147 | return state.IsDone(), nil 148 | } 149 | -------------------------------------------------------------------------------- /internal/job/setup.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/util" 9 | batchv1 "k8s.io/api/batch/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | k8serrors "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | ) 16 | 17 | const CONTAINER_NAME_SETUP_JOB = "shopware-setup" 18 | 19 | func GetSetupJob(ctx context.Context, client client.Client, store v1.Store) (*batchv1.Job, error) { 20 | setup := SetupJob(store) 21 | search := &batchv1.Job{ 22 | ObjectMeta: setup.ObjectMeta, 23 | } 24 | err := client.Get(ctx, types.NamespacedName{ 25 | Namespace: setup.Namespace, 26 | Name: setup.Name, 27 | }, search) 28 | return search, err 29 | } 30 | 31 | func SetupJob(store v1.Store) *batchv1.Job { 32 | containerSpec := store.Spec.Container.DeepCopy() 33 | containerSpec.Merge(store.Spec.SetupJobContainer) 34 | 35 | sharedProcessNamespace := true 36 | backoffLimit := int32(3) 37 | 38 | labels := util.GetDefaultContainerStoreLabels(store, store.Spec.MigrationJobContainer.Labels) 39 | labels["shop.shopware.com/store.type"] = "setup" 40 | 41 | // Use util function for annotations 42 | annotations := util.GetDefaultContainerAnnotations(CONTAINER_NAME_SETUP_JOB, store, store.Spec.SetupJobContainer.Annotations) 43 | 44 | envs := append(store.GetEnv(), 45 | corev1.EnvVar{ 46 | Name: "INSTALL_ADMIN_PASSWORD", 47 | ValueFrom: &corev1.EnvVarSource{ 48 | SecretKeyRef: &corev1.SecretKeySelector{ 49 | LocalObjectReference: corev1.LocalObjectReference{ 50 | Name: store.GetSecretName(), 51 | }, 52 | Key: "admin-password", 53 | }, 54 | }, 55 | }, 56 | corev1.EnvVar{ 57 | Name: "INSTALL_ADMIN_USERNAME", 58 | Value: store.Spec.AdminCredentials.Username, 59 | }, 60 | ) 61 | 62 | // Merge containerSpec.ExtraEnvs to override with merged values from SetupJobContainer 63 | envs = util.MergeEnv(envs, containerSpec.ExtraEnvs) 64 | 65 | containers := append(containerSpec.ExtraContainers, corev1.Container{ 66 | Name: CONTAINER_NAME_SETUP_JOB, 67 | VolumeMounts: containerSpec.VolumeMounts, 68 | ImagePullPolicy: containerSpec.ImagePullPolicy, 69 | Image: containerSpec.Image, 70 | Command: []string{"sh", "-c"}, 71 | Args: []string{store.Spec.SetupScript}, 72 | Env: envs, 73 | Resources: containerSpec.Resources, // Add Resources here 74 | }) 75 | 76 | job := &batchv1.Job{ 77 | TypeMeta: metav1.TypeMeta{ 78 | Kind: "Job", 79 | APIVersion: "batch/v1", 80 | }, 81 | ObjectMeta: metav1.ObjectMeta{ 82 | Name: GetSetupJobName(store), 83 | Namespace: store.Namespace, 84 | Labels: labels, 85 | Annotations: annotations, 86 | }, 87 | Spec: batchv1.JobSpec{ 88 | BackoffLimit: &backoffLimit, 89 | TTLSecondsAfterFinished: &ttlSecondsAfterFinished, 90 | Template: corev1.PodTemplateSpec{ 91 | ObjectMeta: metav1.ObjectMeta{ 92 | Labels: labels, 93 | Annotations: annotations, 94 | }, 95 | Spec: corev1.PodSpec{ 96 | ShareProcessNamespace: &sharedProcessNamespace, 97 | TerminationGracePeriodSeconds: &containerSpec.TerminationGracePeriodSeconds, 98 | Volumes: containerSpec.Volumes, 99 | TopologySpreadConstraints: containerSpec.TopologySpreadConstraints, 100 | NodeSelector: containerSpec.NodeSelector, 101 | ImagePullSecrets: containerSpec.ImagePullSecrets, 102 | RestartPolicy: "Never", 103 | Containers: containers, 104 | SecurityContext: containerSpec.SecurityContext, 105 | InitContainers: containerSpec.InitContainers, 106 | }, 107 | }, 108 | }, 109 | } 110 | 111 | // Global way 112 | if store.Spec.ServiceAccountName != "" { 113 | job.Spec.Template.Spec.ServiceAccountName = store.Spec.ServiceAccountName 114 | } 115 | // Per container way 116 | if containerSpec.ServiceAccountName != "" { 117 | job.Spec.Template.Spec.ServiceAccountName = containerSpec.ServiceAccountName 118 | } 119 | 120 | return job 121 | } 122 | 123 | func GetSetupJobName(store v1.Store) string { 124 | return fmt.Sprintf("%s-setup", store.Name) 125 | } 126 | 127 | func DeleteSetupJob(ctx context.Context, c client.Client, store v1.Store) error { 128 | job, err := GetSetupJob(ctx, c, store) 129 | if err != nil { 130 | if k8serrors.IsNotFound(err) { 131 | return nil 132 | } 133 | return err 134 | } 135 | 136 | return c.Delete(ctx, job, client.PropagationPolicy("Foreground")) 137 | } 138 | 139 | // This is just a soft check, use container check for a clean check 140 | // Will return true if container is stopped (Completed, Error) 141 | func IsSetupJobCompleted( 142 | ctx context.Context, 143 | c client.Client, 144 | store v1.Store, 145 | ) (bool, error) { 146 | setup, err := GetSetupJob(ctx, c, store) 147 | if err != nil { 148 | if k8serrors.IsNotFound(err) { 149 | return false, nil 150 | } 151 | return false, err 152 | } 153 | 154 | state, err := IsJobContainerDone(ctx, c, setup, CONTAINER_NAME_SETUP_JOB) 155 | if err != nil { 156 | return false, err 157 | } 158 | 159 | return state.IsDone(), nil 160 | } 161 | -------------------------------------------------------------------------------- /internal/job/snapshot.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/util" 9 | batchv1 "k8s.io/api/batch/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/api/resource" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | ) 16 | 17 | const CONTAINER_NAME_SNAPSHOT = "operator-snapshot" 18 | 19 | func GetSnapshotCreateJob( 20 | ctx context.Context, 21 | client client.Client, 22 | store v1.Store, 23 | snap v1.StoreSnapshotCreate, 24 | ) (*batchv1.Job, error) { 25 | mig := SnapshotCreateJob(store, snap) 26 | search := &batchv1.Job{ 27 | ObjectMeta: mig.ObjectMeta, 28 | } 29 | err := client.Get(ctx, types.NamespacedName{ 30 | Namespace: mig.Namespace, 31 | Name: mig.Name, 32 | }, search) 33 | return search, err 34 | } 35 | 36 | func SnapshotCreateJob(store v1.Store, snapshot v1.StoreSnapshotCreate) *batchv1.Job { 37 | return snapshotJob(store, snapshot.ObjectMeta, snapshot.Spec, "create") 38 | } 39 | 40 | func GetSnapshotRestoreJob( 41 | ctx context.Context, 42 | client client.Client, 43 | store v1.Store, 44 | snap v1.StoreSnapshotRestore, 45 | ) (*batchv1.Job, error) { 46 | mig := SnapshotRestoreJob(store, snap) 47 | search := &batchv1.Job{ 48 | ObjectMeta: mig.ObjectMeta, 49 | } 50 | err := client.Get(ctx, types.NamespacedName{ 51 | Namespace: mig.Namespace, 52 | Name: mig.Name, 53 | }, search) 54 | return search, err 55 | } 56 | 57 | func SnapshotRestoreJob(store v1.Store, snapshot v1.StoreSnapshotRestore) *batchv1.Job { 58 | return snapshotJob(store, snapshot.ObjectMeta, snapshot.Spec, "restore") 59 | } 60 | 61 | func snapshotJob(store v1.Store, meta metav1.ObjectMeta, snapshot v1.StoreSnapshotSpec, subCommand string) *batchv1.Job { 62 | sharedProcessNamespace := true 63 | res := resource.MustParse("20Gi") 64 | 65 | vm := append(snapshot.Container.VolumeMounts, corev1.VolumeMount{ 66 | Name: "tempdir", 67 | ReadOnly: false, 68 | MountPath: "/temp", 69 | }) 70 | containers := append(snapshot.Container.ExtraContainers, corev1.Container{ 71 | Name: CONTAINER_NAME_SNAPSHOT, 72 | VolumeMounts: vm, 73 | ImagePullPolicy: snapshot.Container.ImagePullPolicy, 74 | Image: snapshot.Container.Image, 75 | Args: []string{ 76 | subCommand, 77 | "--backup-file", snapshot.Path, 78 | "--tempdir", "/temp", 79 | }, 80 | Env: snapshot.GetEnv(store), 81 | }) 82 | 83 | volumes := append(snapshot.Container.Volumes, corev1.Volume{ 84 | Name: "tempdir", 85 | VolumeSource: corev1.VolumeSource{ 86 | EmptyDir: &corev1.EmptyDirVolumeSource{ 87 | SizeLimit: &res, 88 | }, 89 | }, 90 | }) 91 | 92 | labels := util.GetDefaultStoreSnapshotLabels(store, meta) 93 | labels["shop.shopware.com/storesnapshot.type"] = "create" 94 | annotations := util.GetDefaultContainerSnapshotAnnotations(CONTAINER_NAME_SNAPSHOT, snapshot) 95 | 96 | job := &batchv1.Job{ 97 | TypeMeta: metav1.TypeMeta{ 98 | Kind: "Job", 99 | APIVersion: "batch/v1", 100 | }, 101 | ObjectMeta: metav1.ObjectMeta{ 102 | Name: SnapshotJobName(store, meta, subCommand), 103 | Namespace: meta.Namespace, 104 | Labels: labels, 105 | Annotations: annotations, 106 | }, 107 | Spec: batchv1.JobSpec{ 108 | BackoffLimit: &snapshot.MaxRetries, 109 | Template: corev1.PodTemplateSpec{ 110 | ObjectMeta: metav1.ObjectMeta{ 111 | Labels: labels, 112 | }, 113 | Spec: corev1.PodSpec{ 114 | ServiceAccountName: snapshot.Container.ServiceAccountName, 115 | ShareProcessNamespace: &sharedProcessNamespace, 116 | Volumes: volumes, 117 | TopologySpreadConstraints: snapshot.Container.TopologySpreadConstraints, 118 | TerminationGracePeriodSeconds: &snapshot.Container.TerminationGracePeriodSeconds, 119 | NodeSelector: snapshot.Container.NodeSelector, 120 | ImagePullSecrets: snapshot.Container.ImagePullSecrets, 121 | RestartPolicy: "Never", 122 | Containers: containers, 123 | SecurityContext: snapshot.Container.SecurityContext, 124 | InitContainers: snapshot.Container.InitContainers, 125 | }, 126 | }, 127 | }, 128 | } 129 | 130 | return job 131 | } 132 | 133 | func SnapshotJobName(store v1.Store, snap metav1.ObjectMeta, subCommand string) string { 134 | return fmt.Sprintf("%s-snapshot-%s-%s", store.Name, subCommand, snap.Name) 135 | } 136 | 137 | func IsSnapshotJobCompleted( 138 | ctx context.Context, 139 | c client.Client, 140 | j *batchv1.Job, 141 | ) (bool, error) { 142 | state, err := IsJobContainerDone(ctx, c, j, CONTAINER_NAME_SNAPSHOT) 143 | if err != nil { 144 | return false, err 145 | } 146 | 147 | return state.IsDone(), nil 148 | } 149 | -------------------------------------------------------------------------------- /internal/job/util.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/shopware/shopware-operator/internal/logging" 8 | "go.uber.org/zap" 9 | batchv1 "k8s.io/api/batch/v1" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/labels" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | var ttlSecondsAfterFinished int32 = 86400 // Hardcoded to 1 day for now 16 | 17 | type JobState struct { 18 | ExitCode int 19 | Running bool 20 | } 21 | 22 | func (s JobState) HasErrors() bool { 23 | return s.ExitCode != 0 24 | } 25 | 26 | func (s JobState) IsDone() bool { 27 | return !s.Running 28 | } 29 | 30 | // This is used when sidecars are able to run. We should always use this method for checking 31 | func IsJobContainerDone( 32 | ctx context.Context, 33 | c client.Client, 34 | job *batchv1.Job, 35 | containerName string, 36 | ) (JobState, error) { 37 | if job == nil { 38 | return JobState{}, fmt.Errorf("job to check is nil") 39 | } 40 | 41 | logger := logging.FromContext(ctx).With(zap.String("job", job.Name)) 42 | 43 | // TODO: if job is created this returns an error JobNotFoundInContainer 44 | 45 | var errorStates []JobState 46 | for _, container := range job.Spec.Template.Spec.Containers { 47 | if container.Name == containerName { 48 | selector, err := labels.ValidatedSelectorFromSet(job.Labels) 49 | if err != nil { 50 | return JobState{}, fmt.Errorf("get selector: %w", err) 51 | } 52 | 53 | listOptions := client.ListOptions{ 54 | LabelSelector: selector, 55 | Namespace: job.Namespace, 56 | } 57 | 58 | var pods corev1.PodList 59 | err = c.List(ctx, &pods, &listOptions) 60 | if err != nil { 61 | return JobState{}, fmt.Errorf("get pods: %w", err) 62 | } 63 | 64 | for _, pod := range pods.Items { 65 | if pod.Status.Phase == corev1.PodPending { 66 | logger.Infow("The job pod is still pending. Could be stuck, check the conditions", zap.Any("conditions", pod.Status.Conditions)) 67 | return JobState{ 68 | ExitCode: -1, 69 | Running: true, 70 | }, nil 71 | } 72 | 73 | for _, c := range pod.Status.ContainerStatuses { 74 | if c.Name == containerName { 75 | logger.Infof("Found container for job `%s`", c.Name) 76 | if c.State.Terminated == nil { 77 | logger.Info("Job not terminated still running") 78 | return JobState{ 79 | ExitCode: -1, 80 | Running: true, 81 | }, nil 82 | } 83 | if c.State.Terminated.ExitCode != 0 { 84 | logger.With(zap.Int32("exitcode", c.State.Terminated.ExitCode)). 85 | Info("Job has not 0 as exit code, check job") 86 | errorStates = append(errorStates, JobState{ 87 | ExitCode: int(c.State.Terminated.ExitCode), 88 | Running: false, 89 | }) 90 | } 91 | if c.State.Terminated.Reason == "Completed" { 92 | logger.Info("Job completed") 93 | return JobState{ 94 | ExitCode: 0, 95 | Running: false, 96 | }, nil 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | if job.Status.Succeeded > 0 { 105 | logger.Info(fmt.Sprintf("job not found in container: %s. But job has succeeded continue with job done.", containerName)) 106 | return JobState{ 107 | ExitCode: 0, 108 | Running: false, 109 | }, nil 110 | } 111 | 112 | if len(errorStates) > 0 { 113 | // Return the latest error state 114 | return errorStates[len(errorStates)-1], nil 115 | } 116 | 117 | if job.Status.Failed > 0 { 118 | logger.Info(fmt.Sprintf("job not found in container: %s. But job has failed.", containerName)) 119 | return JobState{ 120 | ExitCode: -404, 121 | Running: false, 122 | }, nil 123 | } 124 | 125 | // We tried to detect if this is a timing issue, but unfortunately we could no verify this. So it's better to run into 126 | // an endless loop until the job is done. If the container name is not found it should be catched by an e2e test. 127 | logger.Debugw( 128 | "No result yet for job completed, this can be a timing problem but if the job never finishes this is a operator problem", 129 | zap.Any("job_status", job.Status), zap.Any("job_spec", job.Spec), 130 | ) 131 | return JobState{ 132 | Running: true, 133 | }, nil 134 | } 135 | 136 | func deleteJobsByLabel( 137 | ctx context.Context, 138 | c client.Client, 139 | namespace string, 140 | la map[string]string, 141 | ) error { 142 | selector, err := labels.ValidatedSelectorFromSet(la) 143 | if err != nil { 144 | return fmt.Errorf("get selector: %w", err) 145 | } 146 | 147 | listOptions := client.ListOptions{ 148 | LabelSelector: selector, 149 | Namespace: namespace, 150 | } 151 | 152 | var jobs batchv1.JobList 153 | err = c.List(ctx, &jobs, &listOptions) 154 | if err != nil { 155 | return fmt.Errorf("get jobs: %w", err) 156 | } 157 | 158 | logging.FromContext(ctx).With(zap.Any("jobs", jobs.Items)).Info("Delete jobs") 159 | 160 | for _, job := range jobs.Items { 161 | err = c.Delete(ctx, &job, client.PropagationPolicy("Foreground")) 162 | if err != nil { 163 | return err 164 | } 165 | } 166 | 167 | return nil 168 | } 169 | -------------------------------------------------------------------------------- /internal/logging/logger.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "go.opentelemetry.io/otel/trace" 13 | 14 | "go.uber.org/zap" 15 | "go.uber.org/zap/zapcore" 16 | "go.uber.org/zap/zaptest/observer" 17 | ) 18 | 19 | // contextKey is a private string type to prevent collisions in the context map. 20 | type contextKey string 21 | 22 | // loggerKey points to the value in the context where the logger is stored. 23 | const loggerKey = contextKey("logger") 24 | 25 | func NewLogger(level string, format string) *zap.SugaredLogger { 26 | if flag.Lookup("test.v") != nil { 27 | return zap.NewNop().Sugar() 28 | } 29 | 30 | loggerCfg := zap.NewProductionConfig() 31 | switch strings.ToLower(format) { 32 | case "zap-pretty": 33 | loggerCfg = zap.NewProductionConfig() 34 | loggerCfg.EncoderConfig.EncodeTime = timeEncoderTS() 35 | loggerCfg.EncoderConfig.TimeKey = "ts" 36 | loggerCfg.EncoderConfig.MessageKey = "msg" 37 | case "text": 38 | loggerCfg = zap.NewDevelopmentConfig() 39 | case "json": 40 | loggerCfg.EncoderConfig.MessageKey = "message" 41 | loggerCfg.EncoderConfig.EncodeTime = defaultTimeEncoder() 42 | loggerCfg.EncoderConfig.TimeKey = "timestamp" 43 | loggerCfg.EncoderConfig.EncodeDuration = zapcore.NanosDurationEncoder 44 | loggerCfg.EncoderConfig.StacktraceKey = "errorstack" 45 | loggerCfg.EncoderConfig.FunctionKey = "logger.method_name" 46 | default: 47 | panic(fmt.Sprintf("invalid log format. possible values: json, text, zap-pretty. %s given", format)) 48 | } 49 | 50 | zapLevel, err := zap.ParseAtomicLevel(strings.ToLower(level)) 51 | if err != nil { 52 | panic(fmt.Sprintf("invalid log level. possible values: debug, info, warn, error. %s given", level)) 53 | } 54 | loggerCfg.Level = zapLevel 55 | 56 | logger, err := loggerCfg.Build() 57 | if err != nil { 58 | panic(err) 59 | } 60 | 61 | environment := os.Getenv("ENVIRONMENT") 62 | if environment != "" { 63 | logger = logger.With(zap.String("environment", environment)) 64 | } 65 | 66 | return logger.Sugar() 67 | } 68 | 69 | // defaultTimeEncoder encodes the time as RFC3339 nano. 70 | func defaultTimeEncoder() zapcore.TimeEncoder { 71 | return func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 72 | enc.AppendString(t.Format(time.RFC3339Nano)) 73 | } 74 | } 75 | 76 | // timeEncoderTS encodes the time as a Unix timestamp (seconds since the Unix epoch). 77 | func timeEncoderTS() zapcore.TimeEncoder { 78 | return func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { 79 | enc.AppendInt64(t.Unix()) 80 | } 81 | } 82 | 83 | func WithLogger(ctx context.Context, logger *zap.SugaredLogger) context.Context { 84 | return context.WithValue(ctx, loggerKey, logger) 85 | } 86 | 87 | func FromContext(ctx context.Context) *zap.SugaredLogger { 88 | if logger, ok := ctx.Value(loggerKey).(*zap.SugaredLogger); ok { 89 | spanCtx := trace.SpanContextFromContext(ctx) 90 | if spanCtx.HasTraceID() { 91 | traceID := spanCtx.TraceID().String() 92 | spanID := spanCtx.SpanID().String() 93 | logger = logger.With(zap.String("dd.trace_id", convertTraceID(traceID)), zap.String("dd.span_id", convertTraceID(spanID)), zap.String("otel_trace_id", spanCtx.TraceID().String())) 94 | } 95 | return logger 96 | } 97 | 98 | l := NewLogger("info", "json") 99 | l.Warn("logger not found in context, using default json logger") 100 | return l 101 | } 102 | 103 | func convertTraceID(id string) string { 104 | if len(id) < 16 { 105 | return "" 106 | } 107 | if len(id) > 16 { 108 | id = id[16:] 109 | } 110 | intValue, err := strconv.ParseUint(id, 16, 64) 111 | if err != nil { 112 | return "" 113 | } 114 | return strconv.FormatUint(intValue, 10) 115 | } 116 | 117 | func WithTestLogger(ctx context.Context) (context.Context, *observer.ObservedLogs) { 118 | observedZapCore, observedLogs := observer.New(zap.InfoLevel) 119 | observedLoggerSugared := zap.New(observedZapCore).Sugar() 120 | 121 | return WithLogger(ctx, observedLoggerSugared), observedLogs 122 | } 123 | 124 | func LogCommandExecution(ctx context.Context, commandName string, cmd interface{}, err error) { 125 | logger := FromContext(ctx).With( 126 | zap.Error(err), 127 | zap.String("command.name", commandName), 128 | zap.Any("command.data", cmd), 129 | ).Desugar().WithOptions(zap.AddCallerSkip(1)).Sugar() 130 | 131 | if err == nil { 132 | logger.Infow(commandName + " command succeed") 133 | } else { 134 | logger.Errorw(commandName + " command failed") 135 | } 136 | } 137 | 138 | func PanicHandler(logger *zap.SugaredLogger) { 139 | if r := recover(); r != nil { 140 | logger.Desugar().WithOptions(zap.AddCallerSkip(1)).Sugar().DPanicf("panic: %v", r) 141 | } 142 | } 143 | 144 | func NewLeveledLogger(logger *zap.SugaredLogger) *LeveledLogger { 145 | return &LeveledLogger{logger: logger.Desugar().WithOptions(zap.AddCallerSkip(1)).Sugar()} 146 | } 147 | 148 | // LeveledLogger interface implements the basic methods that a logger library needs. 149 | type LeveledLogger struct { 150 | logger *zap.SugaredLogger 151 | } 152 | 153 | func (l *LeveledLogger) Error(msg string, keysAndVals ...interface{}) { 154 | l.logger.Errorw(msg, keysAndVals...) 155 | } 156 | 157 | func (l *LeveledLogger) Info(msg string, keysAndVals ...interface{}) { 158 | l.logger.Infow(msg, keysAndVals...) 159 | } 160 | 161 | func (l *LeveledLogger) Debug(msg string, keysAndVals ...interface{}) { 162 | l.logger.Debugw(msg, keysAndVals...) 163 | } 164 | 165 | func (l *LeveledLogger) Warn(msg string, keysAndVals ...interface{}) { 166 | l.logger.Warnw(msg, keysAndVals...) 167 | } 168 | 169 | func (l *LeveledLogger) Log(msg string) { 170 | l.logger.Infow(msg) 171 | } 172 | -------------------------------------------------------------------------------- /internal/pdb/pod_distrubtion_budget.go: -------------------------------------------------------------------------------- 1 | package pdb 2 | 3 | import ( 4 | "fmt" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | "github.com/shopware/shopware-operator/internal/util" 8 | policy "k8s.io/api/policy/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/util/intstr" 11 | ) 12 | 13 | func StorefrontPDB(store v1.Store) *policy.PodDisruptionBudget { 14 | store.Spec.Container.Merge(store.Spec.StorefrontDeploymentContainer) 15 | 16 | spec := policy.PodDisruptionBudgetSpec{ 17 | Selector: &metav1.LabelSelector{ 18 | MatchLabels: util.GetStorefrontDeploymentMatchLabel(), 19 | }, 20 | MaxUnavailable: &intstr.IntOrString{ 21 | IntVal: 1, 22 | Type: intstr.Int, 23 | }, 24 | } 25 | 26 | return &policy.PodDisruptionBudget{ 27 | TypeMeta: metav1.TypeMeta{}, 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: GetStorefrontPDBName(store), 30 | Namespace: store.GetNamespace(), 31 | Labels: util.GetDefaultStoreLabels(store), 32 | }, 33 | Spec: spec, 34 | } 35 | } 36 | 37 | func WorkerPDB(store v1.Store) *policy.PodDisruptionBudget { 38 | store.Spec.Container.Merge(store.Spec.WorkerDeploymentContainer) 39 | 40 | spec := policy.PodDisruptionBudgetSpec{ 41 | Selector: &metav1.LabelSelector{ 42 | MatchLabels: util.GetWorkerDeploymentMatchLabel(), 43 | }, 44 | MaxUnavailable: &intstr.IntOrString{ 45 | IntVal: 1, 46 | Type: intstr.Int, 47 | }, 48 | } 49 | 50 | return &policy.PodDisruptionBudget{ 51 | TypeMeta: metav1.TypeMeta{}, 52 | ObjectMeta: metav1.ObjectMeta{ 53 | Name: GetWorkerPDBName(store), 54 | Namespace: store.GetNamespace(), 55 | Labels: util.GetDefaultStoreLabels(store), 56 | }, 57 | Spec: spec, 58 | } 59 | } 60 | 61 | func AdminPDB(store v1.Store) *policy.PodDisruptionBudget { 62 | store.Spec.Container.Merge(store.Spec.AdminDeploymentContainer) 63 | 64 | spec := policy.PodDisruptionBudgetSpec{ 65 | Selector: &metav1.LabelSelector{ 66 | MatchLabels: util.GetAdminDeploymentMatchLabel(), 67 | }, 68 | MaxUnavailable: &intstr.IntOrString{ 69 | IntVal: 1, 70 | Type: intstr.Int, 71 | }, 72 | } 73 | 74 | return &policy.PodDisruptionBudget{ 75 | TypeMeta: metav1.TypeMeta{}, 76 | ObjectMeta: metav1.ObjectMeta{ 77 | Name: GetAdminPDBName(store), 78 | Namespace: store.GetNamespace(), 79 | Labels: util.GetDefaultStoreLabels(store), 80 | }, 81 | Spec: spec, 82 | } 83 | } 84 | 85 | func GetStorefrontPDBName(store v1.Store) string { 86 | return fmt.Sprintf("%s-storefront", store.Name) 87 | } 88 | 89 | func GetAdminPDBName(store v1.Store) string { 90 | return fmt.Sprintf("%s-admin", store.Name) 91 | } 92 | 93 | func GetWorkerPDBName(store v1.Store) string { 94 | return fmt.Sprintf("%s-worker", store.Name) 95 | } 96 | -------------------------------------------------------------------------------- /internal/pod/debug.go: -------------------------------------------------------------------------------- 1 | package pod 2 | 3 | import ( 4 | "context" 5 | 6 | corev1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/types" 9 | 10 | v1 "github.com/shopware/shopware-operator/api/v1" 11 | "github.com/shopware/shopware-operator/internal/deployment" 12 | "github.com/shopware/shopware-operator/internal/util" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | ) 15 | 16 | func GetDebugPod(ctx context.Context, client client.Client, store v1.Store, storeDebugInstance v1.StoreDebugInstance) (*corev1.Pod, error) { 17 | pod := DebugPod(store, storeDebugInstance) 18 | search := &corev1.Pod{ 19 | ObjectMeta: pod.ObjectMeta, 20 | } 21 | err := client.Get(ctx, types.NamespacedName{ 22 | Namespace: pod.Namespace, 23 | Name: pod.Name, 24 | }, search) 25 | return search, err 26 | } 27 | 28 | func DebugPod(store v1.Store, storeDebugInstance v1.StoreDebugInstance) *corev1.Pod { 29 | podSpec := new(corev1.Pod) 30 | 31 | podSpec.ObjectMeta = metav1.ObjectMeta{ 32 | Name: storeDebugInstance.Name, 33 | Namespace: storeDebugInstance.Namespace, 34 | } 35 | 36 | store.Spec.Container.Merge(store.Spec.StorefrontDeploymentContainer) 37 | 38 | labels := util.GetDefaultStoreInstanceDebugLabels(store, storeDebugInstance) 39 | 40 | podSpec.Labels = labels 41 | podSpec.Spec.RestartPolicy = corev1.RestartPolicyNever 42 | 43 | ports := []corev1.ContainerPort{ 44 | { 45 | ContainerPort: store.Spec.Container.Port, 46 | Protocol: corev1.ProtocolTCP, 47 | }, 48 | } 49 | ports = append(ports, storeDebugInstance.Spec.ExtraContainerPorts...) 50 | 51 | containers := append(store.Spec.Container.ExtraContainers, corev1.Container{ 52 | Name: deployment.DEPLOYMENT_STOREFRONT_CONTAINER_NAME, 53 | // we don't need the liveness and readiness probe to make sure that the container always starts 54 | Image: store.Spec.Container.Image, 55 | ImagePullPolicy: store.Spec.Container.ImagePullPolicy, 56 | Env: store.GetEnv(), 57 | VolumeMounts: store.Spec.Container.VolumeMounts, 58 | Ports: ports, 59 | Resources: store.Spec.Container.Resources, 60 | }) 61 | 62 | podSpec.Spec.Containers = containers 63 | podSpec.Spec.Volumes = store.Spec.Container.Volumes 64 | podSpec.Spec.TopologySpreadConstraints = store.Spec.Container.TopologySpreadConstraints 65 | podSpec.Spec.NodeSelector = store.Spec.Container.NodeSelector 66 | podSpec.Spec.ImagePullSecrets = store.Spec.Container.ImagePullSecrets 67 | podSpec.Spec.SecurityContext = store.Spec.Container.SecurityContext 68 | podSpec.Spec.InitContainers = store.Spec.Container.InitContainers 69 | 70 | if store.Spec.ServiceAccountName != "" { 71 | podSpec.Spec.ServiceAccountName = store.Spec.ServiceAccountName 72 | } 73 | if store.Spec.Container.ServiceAccountName != "" { 74 | podSpec.Spec.ServiceAccountName = store.Spec.Container.ServiceAccountName 75 | } 76 | 77 | return podSpec 78 | } 79 | -------------------------------------------------------------------------------- /internal/secret/secret.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "encoding/base64" 10 | "encoding/pem" 11 | "fmt" 12 | "math/big" 13 | "net/url" 14 | "time" 15 | 16 | "github.com/pkg/errors" 17 | v1 "github.com/shopware/shopware-operator/api/v1" 18 | "github.com/shopware/shopware-operator/internal/util" 19 | corev1 "k8s.io/api/core/v1" 20 | ) 21 | 22 | const ( 23 | passSymbols = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + 24 | "abcdefghijklmnopqrstuvwxyz" + 25 | "0123456789" 26 | rsaKeySize = 2024 27 | ) 28 | 29 | func GenerateStoreSecret(ctx context.Context, store *v1.Store, secret *corev1.Secret, dbSpec *util.DatabaseSpec, esp []byte) error { 30 | if secret.Data == nil { 31 | secret.Data = make(map[string][]byte) 32 | } 33 | 34 | if _, ok := secret.Data["app-secret"]; !ok { 35 | pass, err := generatePass(128) 36 | if err != nil { 37 | return fmt.Errorf("generate app secret: %w", err) 38 | } 39 | secret.Data["app-secret"] = pass 40 | } 41 | 42 | if _, ok := secret.Data["admin-password"]; !ok { 43 | if store.Spec.AdminCredentials.Password != "" { 44 | secret.Data["admin-password"] = []byte(store.Spec.AdminCredentials.Password) 45 | } else { 46 | admin, err := generatePass(20) 47 | if err != nil { 48 | return fmt.Errorf("generate admin secret: %w", err) 49 | } 50 | secret.Data["admin-password"] = admin 51 | } 52 | } 53 | 54 | if _, ok := secret.Data["jwt-private-key"]; !ok { 55 | privateKey, publicKey, err := generatePrivatePublicKey(rsaKeySize) 56 | if err != nil { 57 | return fmt.Errorf("create jwt keys: %w", err) 58 | } 59 | secret.Data["jwt-private-key"] = []byte(base64.StdEncoding.EncodeToString(privateKey)) 60 | secret.Data["jwt-public-key"] = []byte(base64.StdEncoding.EncodeToString(publicKey)) 61 | } 62 | 63 | // Used for snapshot controller 64 | secret.Data["database-password"] = []byte(url.QueryEscape(string(dbSpec.Password))) 65 | secret.Data["database-user"] = []byte(dbSpec.User) 66 | secret.Data["database-host"] = []byte(dbSpec.Host) 67 | 68 | secret.Data["database-url"] = util.GenerateDatabaseURLForShopware(dbSpec) 69 | secret.Data["opensearch-url"] = util.GenerateOpensearchURLForShopware(&store.Spec.OpensearchSpec, dbSpec.Password) 70 | 71 | return nil 72 | } 73 | 74 | func generatePass(long int) ([]byte, error) { 75 | b := make([]byte, long) 76 | for i := 0; i != long; i++ { 77 | randInt, err := rand.Int(rand.Reader, big.NewInt(int64(len(passSymbols)))) 78 | if err != nil { 79 | return nil, errors.Wrap(err, "get rand int") 80 | } 81 | b[i] = passSymbols[randInt.Int64()] 82 | } 83 | 84 | return b, nil 85 | } 86 | 87 | func generatePrivatePublicKey(keyLength int) ([]byte, []byte, error) { 88 | rsaKey, err := rsa.GenerateKey(rand.Reader, keyLength) 89 | if err != nil { 90 | return nil, nil, err 91 | } 92 | 93 | rsaPrivKey := encodePrivateKeyToPEM(rsaKey) 94 | rsaPubKey, err := generatePublicKey(rsaKey) 95 | if err != nil { 96 | return nil, nil, fmt.Errorf("unable to generate public key: %v", err) 97 | } 98 | 99 | return rsaPrivKey, rsaPubKey, nil 100 | } 101 | 102 | func encodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte { 103 | key := &pem.Block{ 104 | Type: "RSA PRIVATE KEY", 105 | Bytes: x509.MarshalPKCS1PrivateKey(privateKey), 106 | } 107 | 108 | return pem.EncodeToMemory(key) 109 | } 110 | 111 | func generatePublicKey(key *rsa.PrivateKey) ([]byte, error) { 112 | template := x509.Certificate{ 113 | SerialNumber: big.NewInt(1), 114 | Subject: pkix.Name{ 115 | Organization: []string{"Acme Co"}, 116 | }, 117 | NotBefore: time.Now(), 118 | NotAfter: time.Now().Add(time.Hour * 24 * 180), 119 | 120 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 121 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 122 | BasicConstraintsValid: true, 123 | } 124 | 125 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | pubPem := &pem.Block{Type: "CERTIFICATE", Bytes: derBytes} 131 | 132 | return pem.EncodeToMemory(pubPem), nil 133 | } 134 | -------------------------------------------------------------------------------- /internal/service/admin.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | "github.com/shopware/shopware-operator/internal/util" 8 | corev1 "k8s.io/api/core/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/util/intstr" 11 | ) 12 | 13 | func AdminService(store v1.Store) *corev1.Service { 14 | port := int32(8000) 15 | appName := "shopware-admin" 16 | 17 | return &corev1.Service{ 18 | TypeMeta: metav1.TypeMeta{ 19 | APIVersion: "v1", 20 | Kind: "Service", 21 | }, 22 | ObjectMeta: metav1.ObjectMeta{ 23 | Name: GetAdminServiceName(store), 24 | Namespace: store.Namespace, 25 | Labels: util.GetDefaultStoreLabels(store), 26 | }, 27 | Spec: corev1.ServiceSpec{ 28 | Selector: map[string]string{ 29 | "shop.shopware.com/store.app": appName, 30 | }, 31 | Type: corev1.ServiceTypeClusterIP, 32 | ClusterIP: "None", 33 | Ports: []corev1.ServicePort{ 34 | { 35 | Protocol: "TCP", 36 | Port: port, 37 | TargetPort: intstr.FromInt32(port), 38 | }, 39 | }, 40 | PublishNotReadyAddresses: false, 41 | }, 42 | } 43 | } 44 | 45 | func GetAdminServiceName(store v1.Store) string { 46 | return fmt.Sprintf("%s-admin", store.Name) 47 | } 48 | -------------------------------------------------------------------------------- /internal/service/debug.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | v1 "github.com/shopware/shopware-operator/api/v1" 5 | "github.com/shopware/shopware-operator/internal/util" 6 | corev1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/util/intstr" 9 | ) 10 | 11 | func DebugService(store v1.Store, debugInstance v1.StoreDebugInstance) *corev1.Service { 12 | ports := []corev1.ServicePort{ 13 | { 14 | Name: "http", 15 | Protocol: "TCP", 16 | Port: store.Spec.Container.Port, 17 | TargetPort: intstr.FromInt32(store.Spec.Container.Port), 18 | }, 19 | } 20 | 21 | // Add any extra ports from the debug instance 22 | for _, port := range debugInstance.Spec.ExtraContainerPorts { 23 | ports = append(ports, corev1.ServicePort{ 24 | Name: port.Name, 25 | Protocol: port.Protocol, 26 | Port: port.ContainerPort, 27 | TargetPort: intstr.FromInt32(port.ContainerPort), 28 | }) 29 | } 30 | 31 | selector := util.GetDefaultStoreInstanceDebugLabels(store, debugInstance) 32 | 33 | return &corev1.Service{ 34 | TypeMeta: metav1.TypeMeta{ 35 | APIVersion: "v1", 36 | Kind: "Service", 37 | }, 38 | ObjectMeta: metav1.ObjectMeta{ 39 | Name: debugInstance.Name, 40 | Namespace: store.Namespace, 41 | Labels: util.GetDefaultStoreLabels(store), 42 | }, 43 | Spec: corev1.ServiceSpec{ 44 | Selector: selector, 45 | Type: corev1.ServiceTypeClusterIP, 46 | Ports: ports, 47 | }, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/service/storefront.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | "github.com/shopware/shopware-operator/internal/util" 8 | corev1 "k8s.io/api/core/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/util/intstr" 11 | ) 12 | 13 | func StorefrontService(store v1.Store) *corev1.Service { 14 | port := int32(8000) 15 | appName := "shopware-storefront" 16 | 17 | return &corev1.Service{ 18 | TypeMeta: metav1.TypeMeta{ 19 | APIVersion: "v1", 20 | Kind: "Service", 21 | }, 22 | ObjectMeta: metav1.ObjectMeta{ 23 | Name: GetStorefrontServiceName(store), 24 | Namespace: store.Namespace, 25 | Labels: util.GetDefaultStoreLabels(store), 26 | }, 27 | Spec: corev1.ServiceSpec{ 28 | Selector: map[string]string{ 29 | "shop.shopware.com/store.app": appName, 30 | }, 31 | Type: corev1.ServiceTypeClusterIP, 32 | ClusterIP: "None", 33 | Ports: []corev1.ServicePort{ 34 | { 35 | Protocol: "TCP", 36 | Port: port, 37 | TargetPort: intstr.FromInt32(port), 38 | }, 39 | }, 40 | PublishNotReadyAddresses: false, 41 | }, 42 | } 43 | } 44 | 45 | func GetStorefrontServiceName(store v1.Store) string { 46 | return fmt.Sprintf("%s-storefront", store.Name) 47 | } 48 | -------------------------------------------------------------------------------- /internal/snapshot/service.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "strings" 9 | 10 | aconfig "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/minio/minio-go/v7/pkg/credentials" 12 | v1 "github.com/shopware/shopware-operator/api/v1" 13 | "github.com/shopware/shopware-operator/internal/config" 14 | "github.com/shopware/shopware-operator/internal/logging" 15 | "github.com/shopware/shopware-operator/internal/util" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | type SnapshotContext struct { 20 | TempArchiveDir string 21 | BackupFile string 22 | IncludeDB bool 23 | IncludeS3 bool 24 | } 25 | 26 | type TarEntry struct { 27 | Name string 28 | Size int64 29 | ReadCloser io.ReadCloser 30 | } 31 | 32 | // SnapshotService handles backup operations 33 | type SnapshotService struct { 34 | config *config.SnapshotConfig 35 | } 36 | 37 | // NewSnapshotService creates a new snapshot service 38 | func NewSnapshotService(cfg *config.SnapshotConfig) *SnapshotService { 39 | return &SnapshotService{ 40 | config: cfg, 41 | } 42 | } 43 | 44 | func (s *SnapshotService) restoreS3(ctx context.Context, cfg config.S3Config, directory string) error { 45 | logger := logging.FromContext(ctx) 46 | logger.Debugw("Starting with following s3 configuration", zap.Any("config", cfg)) 47 | 48 | cred, err := getAWSKeysWithAsumeRole(ctx, cfg) 49 | if err != nil { 50 | return fmt.Errorf("failed to get AWS credentials: %w", err) 51 | } 52 | logger.Debugw("S3 Credentials", zap.Any("credentials", cred)) 53 | 54 | err = util.RestoreFromFilesystem(ctx, cred, directory, v1.S3Storage{ 55 | EndpointURL: cfg.Endpoint, 56 | PrivateBucketName: cfg.PrivateBucket, 57 | PublicBucketName: cfg.PublicBucket, 58 | Region: cfg.Region, 59 | AccessKeyRef: v1.SecretRef{}, 60 | SecretAccessKeyRef: v1.SecretRef{}, 61 | }, true) 62 | if err != nil { 63 | return fmt.Errorf("failed to copy filesystem data to s3: %w", err) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // This is only used if the secret and access key are not provided by the config. 70 | // We assume that a service role is defined in the pod and use this to assume the role for the backup. 71 | // AWS_ROLE_ARN=arn:aws:iam::471112763676:role/new-update-020561d1-64c1-4964-bc51-c6420e76f5cc 72 | // AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token 73 | // AWS_STS_REGIONAL_ENDPOINTS=regional 74 | // AWS_DEFAULT_REGION=eu-central-1 75 | // AWS_REGION=eu-central-1 76 | func getAWSKeysWithAsumeRole(ctx context.Context, cfg config.S3Config) (*credentials.Credentials, error) { 77 | if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { 78 | logging.FromContext(ctx). 79 | Infow("AccessKey and SecretAccessKey set, using provided AWS credentials, instead of assuming role") 80 | 81 | return credentials.NewStaticV4( 82 | cfg.AccessKeyID, 83 | cfg.SecretAccessKey, 84 | "", 85 | ), nil 86 | } 87 | 88 | awsCfg, err := aconfig.LoadDefaultConfig(ctx) 89 | if err != nil { 90 | return nil, fmt.Errorf("failed to load AWS config: %w", err) 91 | } 92 | 93 | // stsClient := sts.NewFromConfig(awsCfg) 94 | // if cfg.RoleARN != "" { 95 | // logging.FromContext(ctx). 96 | // Infow("RoleARN is given try to assume role", zap.String("roleARN", cfg.RoleARN)) 97 | // assumeRoleOutput, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{ 98 | // RoleArn: aws.String(cfg.RoleARN), 99 | // RoleSessionName: aws.String("shopware-snapshot"), 100 | // DurationSeconds: aws.Int32(1800), 101 | // }) 102 | // if err != nil { 103 | // return nil, fmt.Errorf("failed to assume role: %w", err) 104 | // } 105 | // return credentials.NewStaticV4( 106 | // *assumeRoleOutput.Credentials.AccessKeyId, 107 | // *assumeRoleOutput.Credentials.SecretAccessKey, 108 | // *assumeRoleOutput.Credentials.SessionToken, 109 | // ), nil 110 | // } 111 | 112 | creds, err := awsCfg.Credentials.Retrieve(ctx) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to retrieve AWS credentials: %w", err) 115 | } 116 | 117 | return credentials.NewStaticV4( 118 | creds.AccessKeyID, 119 | creds.SecretAccessKey, 120 | creds.SessionToken, 121 | ), nil 122 | } 123 | 124 | func parseS3URL(raw string) (bucket, object string, err error) { 125 | u, err := url.Parse(raw) 126 | if err != nil { 127 | return "", "", err 128 | } 129 | if u.Scheme != "s3" { 130 | return "", "", fmt.Errorf("invalid scheme: %s", u.Scheme) 131 | } 132 | 133 | bucket = u.Host 134 | object = strings.TrimPrefix(u.Path, "/") 135 | 136 | return bucket, object, nil 137 | } 138 | -------------------------------------------------------------------------------- /internal/util/annotations.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "maps" 5 | 6 | v1 "github.com/shopware/shopware-operator/api/v1" 7 | ) 8 | 9 | func GetDefaultContainerAnnotations(defaultContainer string, store v1.Store, overwrite map[string]string) map[string]string { 10 | annotations := make(map[string]string) 11 | if store.Spec.Container.Annotations != nil { 12 | annotations = store.Spec.Container.Annotations 13 | } 14 | annotations["kubectl.kubernetes.io/default-container"] = defaultContainer 15 | annotations["kubectl.kubernetes.io/default-logs-container"] = defaultContainer 16 | if overwrite != nil { 17 | maps.Copy(annotations, overwrite) 18 | } 19 | return annotations 20 | } 21 | 22 | func GetDefaultContainerExecAnnotations(defaultContainer string, ex v1.StoreExec) map[string]string { 23 | annotations := make(map[string]string) 24 | if ex.Spec.Container.Annotations != nil { 25 | annotations = ex.Spec.Container.Annotations 26 | } 27 | annotations["kubectl.kubernetes.io/default-container"] = defaultContainer 28 | annotations["kubectl.kubernetes.io/default-logs-container"] = defaultContainer 29 | return annotations 30 | } 31 | 32 | func GetDefaultContainerSnapshotAnnotations(defaultContainer string, sn v1.StoreSnapshotSpec) map[string]string { 33 | annotations := make(map[string]string) 34 | if sn.Container.Annotations != nil { 35 | annotations = sn.Container.Annotations 36 | } 37 | annotations["kubectl.kubernetes.io/default-container"] = defaultContainer 38 | annotations["kubectl.kubernetes.io/default-logs-container"] = defaultContainer 39 | return annotations 40 | } 41 | -------------------------------------------------------------------------------- /internal/util/db.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "net/url" 8 | "os/exec" 9 | 10 | "github.com/go-sql-driver/mysql" 11 | v1 "github.com/shopware/shopware-operator/api/v1" 12 | corev1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | ) 16 | 17 | func GenerateDatabaseURLForShopware(spec *DatabaseSpec) []byte { 18 | urlP := url.QueryEscape(string(spec.Password)) 19 | 20 | var options string 21 | if spec.Options != "" { 22 | options = "&" + spec.Options 23 | } 24 | 25 | mode := spec.SSLMode 26 | if spec.SSLMode == "" { 27 | mode = "PREFERRED" 28 | } 29 | 30 | plain := fmt.Sprintf( 31 | "mysql://%s:%s@%s:%d/%s?serverVersion=%s&sslMode=%s%s", 32 | spec.User, 33 | urlP, 34 | spec.Host, 35 | spec.Port, 36 | spec.Name, 37 | spec.Version, 38 | mode, 39 | options, 40 | ) 41 | return []byte(plain) 42 | } 43 | 44 | func GenerateDatabaseURLForGo(spec *DatabaseSpec) []byte { 45 | mode := spec.SSLMode 46 | if spec.SSLMode == "" { 47 | mode = "PREFERRED" 48 | } 49 | 50 | plain := fmt.Sprintf( 51 | "%s:%s@tcp(%s:%d)/%s?tls=%s", 52 | spec.User, 53 | spec.Password, 54 | spec.Host, 55 | spec.Port, 56 | spec.Name, 57 | mode, 58 | ) 59 | 60 | return []byte(plain) 61 | } 62 | 63 | func GetDBSpec(ctx context.Context, store v1.Store, r client.Client) (*DatabaseSpec, error) { 64 | var dbHost string 65 | if store.Spec.Database.HostRef.Name != "" { 66 | hostSecret := new(corev1.Secret) 67 | if err := r.Get(ctx, types.NamespacedName{ 68 | Namespace: store.Namespace, 69 | Name: store.Spec.Database.HostRef.Name, 70 | }, hostSecret); err != nil { 71 | return nil, err 72 | } 73 | dbHost = string(hostSecret.Data[store.Spec.Database.HostRef.Key]) 74 | } else { 75 | dbHost = store.Spec.Database.Host 76 | } 77 | 78 | dbSecret := new(corev1.Secret) 79 | if err := r.Get(ctx, types.NamespacedName{ 80 | Namespace: store.Namespace, 81 | Name: store.Spec.Database.PasswordSecretRef.Name, 82 | }, dbSecret); err != nil { 83 | return nil, err 84 | } 85 | 86 | var password []byte 87 | var ok bool 88 | if password, ok = dbSecret.Data[store.Spec.Database.PasswordSecretRef.Key]; !ok { 89 | return nil, fmt.Errorf("password key %s not found in secret %s", store.Spec.Database.PasswordSecretRef.Key, store.Spec.Database.PasswordSecretRef.Name) 90 | } 91 | 92 | return &DatabaseSpec{ 93 | Host: dbHost, 94 | Password: password, 95 | User: store.Spec.Database.User, 96 | Port: store.Spec.Database.Port, 97 | Name: store.Spec.Database.Name, 98 | Version: store.Spec.Database.Version, 99 | SSLMode: store.Spec.Database.SSLMode, 100 | Options: store.Spec.Database.Options, 101 | }, nil 102 | } 103 | 104 | func GetMysqlShell(ctx context.Context, spec DatabaseSpec) *exec.Cmd { 105 | return exec.CommandContext(ctx, 106 | "mysqlsh", 107 | "--mysql", 108 | "--schema", "shopware", 109 | "-h"+spec.Host, 110 | "-u"+spec.User, 111 | "-p"+string(spec.Password), 112 | "--js", 113 | ) 114 | } 115 | 116 | func TestSQLConnection(ctx context.Context, spec *DatabaseSpec) error { 117 | url := GenerateDatabaseURLForGo(spec) 118 | db, err := sql.Open("mysql", string(url)) 119 | if err != nil { 120 | return err 121 | } 122 | //nolint:errcheck 123 | defer db.Close() 124 | err = db.PingContext(ctx) 125 | 126 | if mysqlErr, ok := err.(*mysql.MySQLError); ok { 127 | // Error 1049 (42000): Unknown database 128 | if mysqlErr.Number == 1049 { 129 | return nil 130 | } 131 | } else { 132 | return err 133 | } 134 | 135 | return nil 136 | } 137 | -------------------------------------------------------------------------------- /internal/util/db_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shopware/shopware-operator/internal/util" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDatabaseConnectionStringTest(t *testing.T) { 11 | dbSpec := &util.DatabaseSpec{ 12 | Host: "host", 13 | Port: 1234, 14 | Version: "v2", 15 | User: "user", 16 | Name: "testName", 17 | SSLMode: "REQUIRED", 18 | Options: "tls-version=TLSv1.3&auth-method=AUTO", 19 | Password: []byte("password"), 20 | } 21 | 22 | b := util.GenerateDatabaseURLForShopware(dbSpec) 23 | assert.Equal(t, "mysql://user:password@host:1234/testName?serverVersion=v2&sslMode=REQUIRED&tls-version=TLSv1.3&auth-method=AUTO", string(b)) 24 | } 25 | -------------------------------------------------------------------------------- /internal/util/env.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | ) 6 | 7 | // Copy returns a new slice where src is overwriting dst. 8 | // When a env in src is already present in dst, 9 | // the value in dst will be overwritten by the value associated 10 | // with the value in src. 11 | func MergeEnv(dst, src []corev1.EnvVar) []corev1.EnvVar { 12 | toAppend := []corev1.EnvVar{} 13 | for si, srcEnv := range src { 14 | found := false 15 | for di, dstEnv := range dst { 16 | // Overwrite if name exists in both slices 17 | if srcEnv.Name == dstEnv.Name { 18 | dst[di] = src[si] 19 | found = true 20 | break 21 | } 22 | } 23 | // If not found in dst, add it to toAppend 24 | if !found { 25 | toAppend = append(toAppend, src[si]) 26 | } 27 | } 28 | return append(dst, toAppend...) 29 | } 30 | -------------------------------------------------------------------------------- /internal/util/env_merge_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shopware/shopware-operator/internal/util" 7 | "github.com/stretchr/testify/assert" 8 | corev1 "k8s.io/api/core/v1" 9 | ) 10 | 11 | func TestMergeEnv(t *testing.T) { 12 | t.Run("test overwrite existing env var", func(t *testing.T) { 13 | dst := []corev1.EnvVar{ 14 | {Name: "ENV1", Value: "value1"}, 15 | {Name: "ENV2", Value: "value2"}, 16 | } 17 | src := []corev1.EnvVar{ 18 | {Name: "ENV2", Value: "overwritten"}, 19 | } 20 | 21 | result := util.MergeEnv(dst, src) 22 | 23 | assert.Len(t, result, 2) 24 | assert.Equal(t, "value1", result[0].Value) 25 | assert.Equal(t, "overwritten", result[1].Value) 26 | }) 27 | 28 | t.Run("test append new env vars", func(t *testing.T) { 29 | dst := []corev1.EnvVar{ 30 | {Name: "ENV1", Value: "value1"}, 31 | } 32 | src := []corev1.EnvVar{ 33 | {Name: "ENV2", Value: "value2"}, 34 | {Name: "ENV3", Value: "value3"}, 35 | } 36 | 37 | result := util.MergeEnv(dst, src) 38 | 39 | assert.Len(t, result, 3) 40 | assert.Equal(t, "value1", result[0].Value) 41 | assert.Equal(t, "value2", result[1].Value) 42 | assert.Equal(t, "value3", result[2].Value) 43 | }) 44 | 45 | t.Run("test overwrite and append", func(t *testing.T) { 46 | dst := []corev1.EnvVar{ 47 | {Name: "ENV1", Value: "value1"}, 48 | {Name: "ENV2", Value: "value2"}, 49 | } 50 | src := []corev1.EnvVar{ 51 | {Name: "ENV2", Value: "overwritten"}, 52 | {Name: "ENV3", Value: "value3"}, 53 | } 54 | 55 | result := util.MergeEnv(dst, src) 56 | 57 | assert.Len(t, result, 3) 58 | assert.Equal(t, "value1", result[0].Value) 59 | assert.Equal(t, "overwritten", result[1].Value) 60 | assert.Equal(t, "value3", result[2].Value) 61 | }) 62 | 63 | t.Run("test empty dst", func(t *testing.T) { 64 | dst := []corev1.EnvVar{} 65 | src := []corev1.EnvVar{ 66 | {Name: "ENV1", Value: "value1"}, 67 | {Name: "ENV2", Value: "value2"}, 68 | } 69 | 70 | result := util.MergeEnv(dst, src) 71 | 72 | assert.Len(t, result, 2) 73 | assert.Equal(t, "value1", result[0].Value) 74 | assert.Equal(t, "value2", result[1].Value) 75 | }) 76 | 77 | t.Run("test empty src", func(t *testing.T) { 78 | dst := []corev1.EnvVar{ 79 | {Name: "ENV1", Value: "value1"}, 80 | {Name: "ENV2", Value: "value2"}, 81 | } 82 | src := []corev1.EnvVar{} 83 | 84 | result := util.MergeEnv(dst, src) 85 | 86 | assert.Len(t, result, 2) 87 | assert.Equal(t, "value1", result[0].Value) 88 | assert.Equal(t, "value2", result[1].Value) 89 | }) 90 | 91 | t.Run("test multiple overwrites", func(t *testing.T) { 92 | dst := []corev1.EnvVar{ 93 | {Name: "ENV1", Value: "value1"}, 94 | {Name: "ENV2", Value: "value2"}, 95 | {Name: "ENV3", Value: "value3"}, 96 | } 97 | src := []corev1.EnvVar{ 98 | {Name: "ENV1", Value: "overwritten1"}, 99 | {Name: "ENV3", Value: "overwritten3"}, 100 | } 101 | 102 | result := util.MergeEnv(dst, src) 103 | 104 | assert.Len(t, result, 3) 105 | assert.Equal(t, "overwritten1", result[0].Value) 106 | assert.Equal(t, "value2", result[1].Value) 107 | assert.Equal(t, "overwritten3", result[2].Value) 108 | }) 109 | 110 | t.Run("test with ValueFrom", func(t *testing.T) { 111 | dst := []corev1.EnvVar{ 112 | {Name: "ENV1", Value: "value1"}, 113 | { 114 | Name: "ENV2", 115 | ValueFrom: &corev1.EnvVarSource{ 116 | SecretKeyRef: &corev1.SecretKeySelector{ 117 | LocalObjectReference: corev1.LocalObjectReference{Name: "secret1"}, 118 | Key: "key1", 119 | }, 120 | }, 121 | }, 122 | } 123 | src := []corev1.EnvVar{ 124 | { 125 | Name: "ENV2", 126 | ValueFrom: &corev1.EnvVarSource{ 127 | SecretKeyRef: &corev1.SecretKeySelector{ 128 | LocalObjectReference: corev1.LocalObjectReference{Name: "secret2"}, 129 | Key: "key2", 130 | }, 131 | }, 132 | }, 133 | {Name: "ENV3", Value: "value3"}, 134 | } 135 | 136 | result := util.MergeEnv(dst, src) 137 | 138 | assert.Len(t, result, 3) 139 | assert.Equal(t, "value1", result[0].Value) 140 | assert.NotNil(t, result[1].ValueFrom) 141 | assert.Equal(t, "secret2", result[1].ValueFrom.SecretKeyRef.Name) 142 | assert.Equal(t, "key2", result[1].ValueFrom.SecretKeyRef.Key) 143 | assert.Equal(t, "value3", result[2].Value) 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /internal/util/env_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shopware/shopware-operator/internal/util" 7 | "github.com/stretchr/testify/assert" 8 | corev1 "k8s.io/api/core/v1" 9 | ) 10 | 11 | func TestEnv(t *testing.T) { 12 | src := []corev1.EnvVar{ 13 | { 14 | Name: "TEST", 15 | Value: "valueSrc", 16 | }, 17 | } 18 | dst := []corev1.EnvVar{ 19 | { 20 | Name: "TEST", 21 | Value: "ValueDst", 22 | }, 23 | { 24 | Name: "SecondValue", 25 | Value: "ignore", 26 | }, 27 | } 28 | merged := util.MergeEnv(dst, src) 29 | assert.Len(t, merged, 2) 30 | assert.Equal(t, "valueSrc", merged[0].Value) 31 | } 32 | -------------------------------------------------------------------------------- /internal/util/labels.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "maps" 6 | "time" 7 | 8 | v1 "github.com/shopware/shopware-operator/api/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | func GetDefaultStoreLabels(store v1.Store) map[string]string { 13 | return map[string]string{ 14 | "shop.shopware.com/store.name": store.Name, 15 | } 16 | } 17 | 18 | func GetDefaultContainerStoreLabels(store v1.Store, overwrite map[string]string) map[string]string { 19 | labels := make(map[string]string) 20 | if store.Spec.Container.Labels != nil { 21 | maps.Copy(labels, store.Spec.Container.Labels) 22 | } 23 | labels["shop.shopware.com/store.name"] = store.Name 24 | if overwrite != nil { 25 | maps.Copy(labels, overwrite) 26 | } 27 | return labels 28 | } 29 | 30 | func GetDefaultStoreExecLabels(store v1.Store, ex v1.StoreExec) map[string]string { 31 | labels := make(map[string]string) 32 | if store.Spec.Container.Labels != nil { 33 | maps.Copy(labels, store.Spec.Container.Labels) 34 | } 35 | labels["shop.shopware.com/store.name"] = store.Name 36 | labels["shop.shopware.com/storeexec.name"] = ex.Name 37 | return labels 38 | } 39 | 40 | func GetDefaultStoreSnapshotLabels(store v1.Store, meta metav1.ObjectMeta) map[string]string { 41 | labels := make(map[string]string) 42 | if store.Spec.Container.Labels != nil { 43 | maps.Copy(labels, store.Spec.Container.Labels) 44 | } 45 | labels["shop.shopware.com/store.name"] = store.Name 46 | labels["shop.shopware.com/storesnapshot.name"] = meta.Name 47 | return labels 48 | } 49 | 50 | func GetDefaultStoreInstanceDebugLabels(store v1.Store, storeDebugInstance v1.StoreDebugInstance) map[string]string { 51 | labels := GetDefaultContainerStoreLabels(store, storeDebugInstance.Spec.ExtraLabels) 52 | 53 | // we don't need to check for errors here, because the duration is validated in the controller 54 | duration, _ := time.ParseDuration(storeDebugInstance.Spec.Duration) 55 | validUntil := storeDebugInstance.CreationTimestamp.Add(duration) 56 | 57 | labels["shop.shopware.com/store.debug"] = "true" 58 | labels["shop.shopware.com/store.debug.instance"] = storeDebugInstance.Name 59 | labels["shop.shopware.com/store.debug.validUntil"] = fmt.Sprintf("%d", validUntil.UnixNano()) 60 | 61 | return labels 62 | } 63 | 64 | func GetAdminDeploymentMatchLabel() map[string]string { 65 | labels := make(map[string]string) 66 | labels["shop.shopware.com/store.app"] = "shopware-admin" 67 | return labels 68 | } 69 | 70 | func GetStorefrontDeploymentMatchLabel() map[string]string { 71 | labels := make(map[string]string) 72 | labels["shop.shopware.com/store.app"] = "shopware-storefront" 73 | return labels 74 | } 75 | 76 | func GetWorkerDeploymentMatchLabel() map[string]string { 77 | labels := make(map[string]string) 78 | labels["shop.shopware.com/store.app"] = "shopware-worker" 79 | return labels 80 | } 81 | -------------------------------------------------------------------------------- /internal/util/labels_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/util" 9 | "github.com/stretchr/testify/require" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | func TestLabelMerge(t *testing.T) { 14 | store := v1.Store{ 15 | ObjectMeta: metav1.ObjectMeta{ 16 | Name: "name", 17 | }, 18 | Spec: v1.StoreSpec{ 19 | Container: v1.ContainerSpec{ 20 | Labels: map[string]string{ 21 | "test": "test", 22 | "app": "selector", 23 | }, 24 | }, 25 | }, 26 | } 27 | 28 | overwrite := make(map[string]string) 29 | overwrite["test"] = "overwrite" 30 | overwrite["test2"] = "test2" 31 | 32 | labels := util.GetDefaultContainerStoreLabels(store, overwrite) 33 | fmt.Println(labels) 34 | require.Equal(t, "selector", labels["app"]) 35 | require.Equal(t, "overwrite", labels["test"]) 36 | require.Equal(t, "test2", labels["test2"]) 37 | require.Equal(t, "name", labels["shop.shopware.com/store.name"]) 38 | } 39 | -------------------------------------------------------------------------------- /internal/util/map.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func MapEqual(a, b map[string]string) bool { 4 | if len(a) != len(b) { 5 | return false 6 | } 7 | 8 | for k, v := range a { 9 | if b[k] != v { 10 | return false 11 | } 12 | } 13 | 14 | return true 15 | } 16 | -------------------------------------------------------------------------------- /internal/util/mysql_dump.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "compress/gzip" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os/exec" 9 | "time" 10 | 11 | "github.com/shopware/shopware-operator/internal/logging" 12 | ) 13 | 14 | type MySQLDump struct { 15 | binaryPath string 16 | } 17 | 18 | func NewMySQLDump(binaryPath string) MySQLDump { 19 | return MySQLDump{binaryPath: binaryPath} 20 | } 21 | 22 | func (h MySQLDump) Dump( 23 | ctx context.Context, 24 | input DumpInput, 25 | writer io.WriteCloser, 26 | ) (*DumpOutput, error) { 27 | if input.Name == "" { 28 | return nil, fmt.Errorf("empty database name") 29 | } 30 | 31 | if input.Host == "" { 32 | return nil, fmt.Errorf("empty host") 33 | } 34 | 35 | if string(input.Password) == "" { 36 | return nil, fmt.Errorf("empty password") 37 | } 38 | 39 | if input.User == "" { 40 | return nil, fmt.Errorf("empty user") 41 | } 42 | 43 | duration := time.Now() 44 | cmd := exec.Command( 45 | "mysqldump", 46 | "--column-statistics=0", 47 | "--set-gtid-purged=OFF", 48 | "--hex-blob", 49 | "--skip-set-charset", 50 | "-h", 51 | input.Host, 52 | "-u", 53 | input.User, 54 | fmt.Sprintf("-p%s", input.Password), 55 | input.Name, 56 | ) 57 | 58 | logging.FromContext(ctx).Debugw("mysqlDump command", "cmd", cmd.String()) 59 | 60 | dump, err := cmd.StdoutPipe() 61 | if err != nil { 62 | return nil, fmt.Errorf("get stdout pipe: %w", err) 63 | } 64 | if err := cmd.Start(); err != nil { 65 | return nil, fmt.Errorf("start command: %w", err) 66 | } 67 | 68 | // Own writer to count the gzipped size 69 | counterWriter := &countingWriter{w: writer} 70 | gw, err := gzip.NewWriterLevel(counterWriter, gzip.BestSpeed) 71 | if err != nil { 72 | return nil, fmt.Errorf("create gzip writer: %w", err) 73 | } 74 | 75 | uncompressedCount, err := io.CopyBuffer(gw, dump, make([]byte, 1<<20)) 76 | if err != nil { 77 | return nil, fmt.Errorf("copy to gzip writer: %w", err) 78 | } 79 | 80 | err = cmd.Wait() 81 | if err != nil { 82 | return nil, fmt.Errorf("wait command: %w", err) 83 | } 84 | 85 | err = gw.Close() 86 | if err != nil { 87 | return nil, fmt.Errorf("close gzip writer: %w", err) 88 | } 89 | 90 | err = writer.Close() 91 | if err != nil { 92 | return nil, fmt.Errorf("close writer: %w", err) 93 | } 94 | 95 | return &DumpOutput{ 96 | Duration: time.Since(duration), 97 | UncompressedSize: uncompressedCount, 98 | CompressedSize: counterWriter.count, 99 | CompressionRation: float64(counterWriter.count) / float64(uncompressedCount), 100 | }, nil 101 | } 102 | 103 | func (h MySQLDump) Restore( 104 | ctx context.Context, 105 | input DumpInput, 106 | reader io.ReadCloser, 107 | ) error { 108 | if input.Name == "" { 109 | return fmt.Errorf("empty database name") 110 | } 111 | 112 | if input.Host == "" { 113 | return fmt.Errorf("empty host") 114 | } 115 | 116 | if string(input.Password) == "" { 117 | return fmt.Errorf("empty password") 118 | } 119 | 120 | if input.User == "" { 121 | return fmt.Errorf("empty user") 122 | } 123 | 124 | cmd := exec.Command( 125 | "mysql", 126 | "-A", // Quicker startup because skipping table sync 127 | "-h", 128 | input.Host, 129 | "-u", 130 | input.User, 131 | fmt.Sprintf("-p%s", input.Password), 132 | input.Name, 133 | ) 134 | 135 | logging.FromContext(ctx).Debugw("mysql restore command", "cmd", cmd.String()) 136 | 137 | var err error 138 | cmd.Stdin, err = gzip.NewReader(reader) 139 | if err != nil { 140 | return fmt.Errorf("create gzip reader: %w", err) 141 | } 142 | 143 | if err := cmd.Start(); err != nil { 144 | return fmt.Errorf("start command: %w", err) 145 | } 146 | 147 | err = cmd.Wait() 148 | if err != nil { 149 | return fmt.Errorf("wait command: %w", err) 150 | } 151 | 152 | err = reader.Close() 153 | if err != nil { 154 | return fmt.Errorf("close gzip writer: %w", err) 155 | } 156 | 157 | return nil 158 | } 159 | 160 | type countingWriter struct { 161 | w io.Writer 162 | count int64 163 | } 164 | 165 | func (c *countingWriter) Write(p []byte) (int, error) { 166 | n, err := c.w.Write(p) 167 | c.count += int64(n) 168 | return n, err 169 | } 170 | -------------------------------------------------------------------------------- /internal/util/opensearch.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | ) 9 | 10 | func GenerateOpensearchURLForShopware(os *v1.OpensearchSpec, p []byte) []byte { 11 | urlP := url.QueryEscape(string(p)) 12 | 13 | plain := fmt.Sprintf( 14 | "%s://%s:%s@%s:%d", 15 | os.Schema, 16 | os.Username, 17 | urlP, 18 | os.Host, 19 | os.Port, 20 | ) 21 | return []byte(plain) 22 | } 23 | -------------------------------------------------------------------------------- /internal/util/ptr.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func Int64(v int64) *int64 { 4 | return &v 5 | } 6 | -------------------------------------------------------------------------------- /internal/util/s3_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | v1 "github.com/shopware/shopware-operator/api/v1" 8 | "github.com/shopware/shopware-operator/internal/util" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestS3Errors(t *testing.T) { 15 | err := util.TestS3Connection(context.Background(), v1.S3Storage{}, aws.Credentials{ 16 | SecretAccessKey: "", 17 | SessionToken: "", 18 | }) 19 | assert.ErrorContains(t, err, "") 20 | } 21 | -------------------------------------------------------------------------------- /shopware.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | --------------------------------------------------------------------------------