├── .dockerignore ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── Makefile ├── PROJECT ├── README.md ├── chart └── drupal-operator │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── clusterrolebinding.yaml │ ├── controller-clusterrole.yaml │ ├── crds.yaml │ ├── deployment.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── cmd └── manager │ └── main.go ├── config ├── crds │ └── drupal_v1beta1_droplet.yaml ├── default │ ├── kustomization.yaml │ ├── manager_auth_proxy_patch.yaml │ ├── manager_image_patch.yaml │ └── manager_prometheus_metrics_patch.yaml ├── manager │ └── manager.yaml ├── rbac │ ├── auth_proxy_role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── rbac_role.yaml │ └── rbac_role_binding.yaml └── samples │ └── drupal_v1beta1_droplet.yaml ├── hack ├── boilerplate.go.txt └── chart-metadata.yaml └── pkg ├── apis ├── addtoscheme_drupal_v1beta1.go ├── apis.go └── drupal │ ├── group.go │ └── v1beta1 │ ├── doc.go │ ├── droplet_defaults.go │ ├── droplet_types.go │ ├── droplet_types_test.go │ ├── register.go │ ├── v1beta1_suite_test.go │ ├── zz_generated.deepcopy.go │ └── zz_generated.defaults.go ├── controller ├── add_droplet.go ├── controller.go └── droplet │ ├── droplet_controller.go │ ├── droplet_controller_suite_test.go │ ├── droplet_controller_test.go │ └── internal │ └── sync │ ├── common │ └── common.go │ ├── drupal │ ├── cm_drupal.go │ ├── cron.go │ ├── deploy.go │ ├── pvc_code.go │ ├── pvc_media.go │ ├── secret.go │ ├── service.go │ └── upgrade.go │ ├── nginx │ ├── cm_nginx.go │ ├── deploy.go │ ├── ingress.go │ └── service.go │ └── templates │ ├── nginx.conf.go │ └── settings.php.go ├── internal ├── drupal │ ├── defaults.go │ ├── drupal.go │ └── pod_template.go └── nginx │ ├── defaults.go │ ├── nginx.go │ └── pod_template.go ├── util ├── mergo │ └── transformers │ │ ├── transformers.go │ │ ├── transformers_suite_test.go │ │ └── transformers_test.go ├── rand │ └── rand.go └── syncer │ ├── example_test.go │ ├── external.go │ ├── interface.go │ ├── object.go │ ├── object_test.go │ ├── syncer.go │ └── syncer_suite_test.go └── webhook └── webhook.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | hack/* 3 | bin/* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Kubernetes Generated files - skip generated files, except for vendored files 17 | 18 | !vendor/**/zz_generated.* 19 | 20 | # editor and IDE paraphernalia 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | 26 | # Project 27 | .DS_Store 28 | bin/* 29 | webroot/* 30 | build 31 | vendor 32 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | concurrency: 4 3 | deadline: 1m 4 | issues-exit-code: 1 5 | 6 | output: 7 | format: colored-line-number 8 | print-issued-lines: true 9 | print-linter-name: true 10 | 11 | linters-settings: 12 | dupl: 13 | threshold: 400 14 | goconst: 15 | min-len: 3 16 | min-occurrences: 3 17 | gocritic: 18 | enabled-checks: 19 | - captlocal 20 | - rangeValCopy 21 | settings: 22 | captLocal: 23 | checkLocals: true 24 | rangeValCopy: 25 | sizeThreshold: 50 26 | gocyclo: 27 | min-complexity: 10 28 | gofmt: 29 | simplify: true 30 | goimports: 31 | local-prefixes: github.com/org/project 32 | golint: 33 | min-confidence: 0.8 34 | govet: 35 | check-shadowing: true 36 | misspell: 37 | locale: US 38 | lll: 39 | line-length: 170 40 | tab-width: 4 41 | maligned: 42 | suggest-new: true 43 | nakedret: 44 | max-func-lines: 30 45 | 46 | linters: 47 | presets: 48 | - bugs 49 | - unused 50 | - format 51 | - style 52 | - complexity 53 | - performance 54 | 55 | # we should re-enable them and make lint pass 56 | disable: 57 | - goimports 58 | - maligned 59 | - gochecknoglobals 60 | - gochecknoinits 61 | 62 | issues: 63 | max-same-issues: 0 64 | exclude-use-default: false 65 | exclude: 66 | # gosec G104, about unhandled errors. We do that with errcheck already 67 | - "G104: Errors unhandled" 68 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.10-alpine AS builder 2 | RUN apk --update --no-cache add git && go get github.com/golang/dep/cmd/dep 3 | WORKDIR /go/src/github.com/sylus/drupal-operator 4 | COPY Gopkg.toml Gopkg.lock ./ 5 | RUN dep ensure -vendor-only 6 | COPY . ./ 7 | RUN go install ./... 8 | 9 | FROM alpine:3.8 10 | RUN apk --update --no-cache add ca-certificates 11 | COPY --from=builder /go/bin/manager /usr/bin/manager 12 | ENTRYPOINT [ "/usr/bin/manager" ] 13 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | required = [ 2 | "github.com/emicklei/go-restful", 3 | "github.com/onsi/ginkgo", # for test framework 4 | "github.com/onsi/gomega", # for test matchers 5 | "k8s.io/client-go/plugin/pkg/client/auth/gcp", # for development against gcp 6 | "k8s.io/code-generator/cmd/client-gen", # for go generate 7 | "k8s.io/code-generator/cmd/deepcopy-gen", # for go generate 8 | "sigs.k8s.io/controller-tools/cmd/controller-gen", # for crd/rbac generation 9 | "sigs.k8s.io/controller-runtime/pkg/client/config", 10 | "sigs.k8s.io/controller-runtime/pkg/controller", 11 | "sigs.k8s.io/controller-runtime/pkg/handler", 12 | "sigs.k8s.io/controller-runtime/pkg/manager", 13 | "sigs.k8s.io/controller-runtime/pkg/runtime/signals", 14 | "sigs.k8s.io/controller-runtime/pkg/source", 15 | "sigs.k8s.io/testing_frameworks/integration", # for integration testing 16 | "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1", 17 | ] 18 | 19 | [prune] 20 | go-tests = true 21 | 22 | [[constraint]] 23 | name="github.com/imdario/mergo" 24 | version = "v0.3.6" 25 | 26 | [[override]] 27 | name="sigs.k8s.io/controller-runtime" 28 | version = "v0.1.4" 29 | 30 | # STANZAS BELOW ARE GENERATED AND MAY BE WRITTEN - DO NOT MODIFY BELOW THIS LINE. 31 | 32 | [[constraint]] 33 | name="sigs.k8s.io/controller-runtime" 34 | version="v0.1.1" 35 | 36 | [[constraint]] 37 | name="sigs.k8s.io/controller-tools" 38 | version="v0.1.1" 39 | 40 | # For dependency below: Refer to issue https://github.com/golang/dep/issues/1799 41 | [[override]] 42 | name = "gopkg.in/fsnotify.v1" 43 | source = "https://github.com/fsnotify/fsnotify.git" 44 | version="v1.4.7" 45 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_VERSION ?= $(shell git describe --abbrev=5 --dirty --tags --always) 2 | REGISTRY := sylus 3 | IMAGE_NAME := drupal-operator 4 | BUILD_TAG := v0.0.3 5 | IMAGE_TAGS := $(APP_VERSION) 6 | KUBEBUILDER_VERSION ?= 1.0.6 7 | BINDIR ?= $(PWD)/bin 8 | BUILDDIR ?= $(PWD)/build 9 | CHARTDIR ?= $(PWD)/chart/drupal-operator 10 | 11 | GOOS ?= $(shell uname -s | tr '[:upper:]' '[:lower:]') 12 | GOARCH ?= amd64 13 | 14 | PATH := $(BINDIR):$(PATH) 15 | SHELL := env PATH=$(PATH) /bin/sh 16 | 17 | all: test manager 18 | 19 | # Run tests 20 | test: generate manifests 21 | KUBEBUILDER_ASSETS=$(BINDIR) ginkgo \ 22 | --randomizeAllSpecs --randomizeSuites --failOnPending \ 23 | --cover --coverprofile cover.out --trace --race \ 24 | ./pkg/... ./cmd/... 25 | 26 | # Build manager binary 27 | manager: generate fmt vet 28 | go build -o bin/manager github.com/sylus/drupal-operator/cmd/manager 29 | 30 | # Run against the configured Kubernetes cluster in ~/.kube/config 31 | run: generate fmt vet 32 | go run ./cmd/manager/main.go 33 | 34 | # Install CRDs into a cluster 35 | install: manifests 36 | kubectl apply -f config/crds 37 | 38 | # Deploy controller in the configured Kubernetes cluster in ~/.kube/config 39 | deploy: manifests 40 | kubectl apply -f config/crds 41 | kustomize build config/default | kubectl apply -f - 42 | 43 | # Generate manifests e.g. CRD, RBAC etc. 44 | manifests: 45 | go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all 46 | # CRDs 47 | awk 'FNR==1 && NR!=1 {print "---"}{print}' config/crds/*.yaml > $(CHARTDIR)/templates/_crds.yaml 48 | yq m -d'*' -i $(CHARTDIR)/templates/_crds.yaml hack/chart-metadata.yaml 49 | yq w -d'*' -i $(CHARTDIR)/templates/_crds.yaml 'metadata.annotations[helm.sh/hook]' crd-install 50 | yq d -d'*' -i $(CHARTDIR)/templates/_crds.yaml metadata.creationTimestamp 51 | yq d -d'*' -i $(CHARTDIR)/templates/_crds.yaml status metadata.creationTimestamp 52 | # add shortName to CRD until https://github.com/kubernetes-sigs/kubebuilder/issues/404 is solved 53 | yq w -i $(CHARTDIR)/templates/_crds.yaml 'spec.names.shortNames[0]' droplet 54 | echo '{{- if .Values.crd.install }}' > $(CHARTDIR)/templates/crds.yaml 55 | cat $(CHARTDIR)/templates/_crds.yaml >> $(CHARTDIR)/templates/crds.yaml 56 | echo '{{- end }}' >> $(CHARTDIR)/templates/crds.yaml 57 | rm $(CHARTDIR)/templates/_crds.yaml 58 | # RBAC 59 | cp config/rbac/rbac_role.yaml $(CHARTDIR)/templates/_rbac.yaml 60 | yq m -d'*' -i $(CHARTDIR)/templates/_rbac.yaml hack/chart-metadata.yaml 61 | yq d -d'*' -i $(CHARTDIR)/templates/_rbac.yaml metadata.creationTimestamp 62 | yq w -d'*' -i $(CHARTDIR)/templates/_rbac.yaml metadata.name '{{ template "drupal-operator.fullname" . }}' 63 | echo '{{- if .Values.rbac.create }}' > $(CHARTDIR)/templates/controller-clusterrole.yaml 64 | cat $(CHARTDIR)/templates/_rbac.yaml >> $(CHARTDIR)/templates/controller-clusterrole.yaml 65 | echo '{{- end }}' >> $(CHARTDIR)/templates/controller-clusterrole.yaml 66 | rm $(CHARTDIR)/templates/_rbac.yaml 67 | 68 | .PHONY: chart 69 | chart: 70 | yq w -i $(CHARTDIR)/Chart.yaml version "$(APP_VERSION)" 71 | yq w -i $(CHARTDIR)/Chart.yaml appVersion "$(APP_VERSION)" 72 | mv $(CHARTDIR)/values.yaml $(CHARTDIR)/_values.yaml 73 | sed 's#$(REGISTRY)/$(IMAGE_NAME):latest#$(REGISTRY)/$(IMAGE_NAME):$(APP_VERSION)#g' $(CHARTDIR)/_values.yaml > $(CHARTDIR)/values.yaml 74 | rm $(CHARTDIR)/_values.yaml 75 | 76 | # Run go fmt against code 77 | fmt: 78 | go fmt ./pkg/... ./cmd/... 79 | 80 | # Run go vet against code 81 | vet: 82 | go vet ./pkg/... ./cmd/... 83 | 84 | # Generate code 85 | generate: 86 | go generate ./pkg/... ./cmd/... 87 | 88 | .PHONY: docker-build 89 | docker-build: 90 | docker build . -t $(REGISTRY)/$(IMAGE_NAME):$(BUILD_TAG) 91 | @echo "updating kustomize image patch file for manager resource" 92 | gsed -i'' -e 's@image: .*@image: '"${REGISTRY}/${IMAGE_NAME}:${BUILD_TAG}"'@' ./config/default/manager_image_patch.yaml 93 | 94 | set -e; \ 95 | for tag in $(IMAGE_TAGS); do \ 96 | docker tag $(REGISTRY)/$(IMAGE_NAME):$(BUILD_TAG) $(REGISTRY)/$(IMAGE_NAME):$${tag}; \ 97 | done 98 | 99 | .PHONY: docker-push 100 | docker-push: docker-build 101 | set -e; \ 102 | for tag in $(IMAGE_TAGS); do \ 103 | docker push $(REGISTRY)/$(IMAGE_NAME):$${tag}; \ 104 | done 105 | 106 | lint: 107 | $(BINDIR)/golangci-lint run ./pkg/... ./cmd/... 108 | 109 | # http://stackoverflow.com/questions/4219255/how-do-you-get-the-list-of-targets-in-a-makefile 110 | list: 111 | @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs 112 | 113 | dependencies: 114 | test -d $(BINDIR) || mkdir $(BINDIR) 115 | GOBIN=$(BINDIR) go install ./vendor/github.com/onsi/ginkgo/ginkgo 116 | curl -sL https://github.com/mikefarah/yq/releases/download/2.1.1/yq_$(GOOS)_$(GOARCH) -o $(BINDIR)/yq 117 | chmod +x $(BINDIR)/yq 118 | curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | bash -s -- -b $(BINDIR) v1.12.2 119 | curl -sL https://github.com/kubernetes-sigs/kubebuilder/releases/download/v$(KUBEBUILDER_VERSION)/kubebuilder_$(KUBEBUILDER_VERSION)_$(GOOS)_$(GOARCH).tar.gz | \ 120 | tar -zx -C $(BINDIR) --strip-components=2 121 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | version: "1" 2 | domain: sylus.ca 3 | repo: github.com/sylus/drupal-operator 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Drupal Operator 2 | 3 | Drupal Operator generated via KubeBuilder to enable managing multiple Drupal installs. 4 | 5 | > Note: This is vastly outdated and needs a complete refactoring. 6 | 7 | ## Goals 8 | 9 | The main goals of the operator are: 10 | 11 | 1. Ability to deploy Drupal sites on top of Kubernetes 12 | 2. Provide best practices for application lifecycle 13 | 3. Facilitate proper devops (backups, monitoring and high-availability) 14 | 15 | > Project is currently under active development. 16 | 17 | ## Components 18 | 19 | 1. Drupal Operator (this project) 20 | 2. Drupal Container Image (https://github.com/drupalwxt/site-wxt) 21 | 22 | ## Installation of Controller (CRD) 23 | 24 | ```sh 25 | helm repo add sylus https://sylus.github.io/charts 26 | helm --name drupal-operator install sylus/drupal-operator 27 | ``` 28 | 29 | ## Usage 30 | 31 | First we need to install the `mysql-operator` as well as default role bindings. 32 | 33 | ```sh 34 | # Create our namespace 35 | kubectl create ns mysql-operator 36 | 37 | # Install via Helm 38 | helm install --name mysql-operator -f values.yaml --namespace mysql-operator . 39 | 40 | # Install RoleBindings for appropriate namespace 41 | cat < 0 { 78 | tls := extv1beta1.IngressTLS{ 79 | SecretName: string(droplet.Spec.TLSSecretRef), 80 | } 81 | for _, d := range droplet.Spec.Domains { 82 | tls.Hosts = append(tls.Hosts, string(d)) 83 | } 84 | out.Spec.TLS = []extv1beta1.IngressTLS{tls} 85 | } else { 86 | out.Spec.TLS = nil 87 | } 88 | 89 | return nil 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/controller/droplet/internal/sync/nginx/service.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package sync 17 | 18 | import ( 19 | "fmt" 20 | 21 | corev1 "k8s.io/api/core/v1" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | "k8s.io/apimachinery/pkg/labels" 24 | "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/apimachinery/pkg/util/intstr" 26 | "sigs.k8s.io/controller-runtime/pkg/client" 27 | 28 | "github.com/sylus/drupal-operator/pkg/controller/droplet/internal/sync/common" 29 | "github.com/sylus/drupal-operator/pkg/internal/nginx" 30 | "github.com/sylus/drupal-operator/pkg/util/syncer" 31 | ) 32 | 33 | const ( 34 | nginxHTTPPort = 80 35 | ) 36 | 37 | // NewServiceSyncer returns a new sync.Interface for reconciling Nginx Service 38 | func NewServiceSyncer(droplet *nginx.Nginx, c client.Client, scheme *runtime.Scheme) syncer.Interface { 39 | objLabels := droplet.ComponentLabels(nginx.NginxDeployment) 40 | 41 | obj := &corev1.Service{ 42 | ObjectMeta: metav1.ObjectMeta{ 43 | Name: fmt.Sprintf("%s-%s", droplet.Name, "nginx"), 44 | Namespace: droplet.Namespace, 45 | }, 46 | } 47 | 48 | return syncer.NewObjectSyncer("Service", droplet.Unwrap(), obj, c, scheme, func(existing runtime.Object) error { 49 | out := existing.(*corev1.Service) 50 | out.Labels = labels.Merge(labels.Merge(out.Labels, objLabels), common.ControllerLabels) 51 | 52 | selector := droplet.PodLabels() 53 | if !labels.Equals(selector, out.Spec.Selector) { 54 | if out.ObjectMeta.CreationTimestamp.IsZero() { 55 | out.Spec.Selector = selector 56 | } else { 57 | return fmt.Errorf("service selector is immutable") 58 | } 59 | } 60 | 61 | if len(out.Spec.Ports) != 1 { 62 | out.Spec.Ports = make([]corev1.ServicePort, 1) 63 | } 64 | 65 | out.Spec.Ports[0].Name = "http" 66 | out.Spec.Ports[0].Port = int32(80) 67 | out.Spec.Ports[0].TargetPort = intstr.FromInt(nginxHTTPPort) 68 | 69 | return nil 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/controller/droplet/internal/sync/templates/nginx.conf.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | // ConfigMapNginx Nginx.conf file 4 | var ConfigMapNginx = ` 5 | error_log /proc/self/fd/2; 6 | pid /var/run/nginx.pid; 7 | user root; 8 | worker_processes auto; 9 | worker_rlimit_nofile 500000; 10 | 11 | events { 12 | multi_accept on; 13 | use epoll; 14 | worker_connections 8192; 15 | } 16 | 17 | http { 18 | access_log /proc/self/fd/1; 19 | client_max_body_size 20m; 20 | default_type application/octet-stream; 21 | gzip on; 22 | gzip_buffers 16 8k; 23 | gzip_comp_level 4; 24 | gzip_disable msie6; 25 | gzip_proxied off; 26 | gzip_types application/json; 27 | gzip_vary on; 28 | include /etc/nginx/mime.types; 29 | index index.html index.htm; 30 | keepalive_timeout 240; 31 | proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=one:8m max_size=3000m inactive=600m; 32 | proxy_temp_path /var/tmp; 33 | sendfile on; 34 | server_tokens off; 35 | tcp_nopush on; 36 | types_hash_max_size 2048; 37 | 38 | server { 39 | #IPv4 40 | listen 80; 41 | 42 | #IPv6 43 | listen [::]:80; 44 | 45 | # Filesystem root of the site and index with fallback. 46 | root /var/www/html; 47 | index index.php index.html index.htm; 48 | 49 | # Make site accessible from http://domain; 50 | server_name drupal.innovation.cloud.statcan.ca; 51 | 52 | location / { 53 | # First attempt to serve request as file, then 54 | # as directory, then fall back to displaying a 404. 55 | try_files $uri $uri/ /index.html /index.php?$query_string; 56 | } 57 | 58 | location ~ \.php$ { 59 | proxy_intercept_errors on; 60 | fastcgi_split_path_info ^(.+\.php)(/.+)$; 61 | #NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini 62 | include fastcgi_params; 63 | fastcgi_read_timeout 120; 64 | fastcgi_param SCRIPT_FILENAME $request_filename; 65 | fastcgi_intercept_errors on; 66 | fastcgi_pass [[ .Host ]]:9000; 67 | } 68 | 69 | location ~ (^/s3/files/styles/|^/sites/.*/files/imagecache/|^/sites/.*/files/styles/) { 70 | expires max; 71 | try_files $uri @rewrite; 72 | } 73 | 74 | location ~* ^/(s3fs-css|s3fs-js|sites/default/files)/(.*) { 75 | set $s3_base_path "stcdrupal.blob.core.windows.net/drupal-public"; 76 | set $file_path $2; 77 | 78 | resolver 10.0.0.10 valid=5s ipv6=off; 79 | resolver_timeout 5s; 80 | 81 | proxy_pass https://$s3_base_path/$file_path; 82 | } 83 | 84 | location ~ /\.ht { 85 | deny all; 86 | } 87 | } 88 | } 89 | ` 90 | -------------------------------------------------------------------------------- /pkg/internal/drupal/defaults.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package drupal 17 | 18 | const ( 19 | defaultTag = "0.0.1" 20 | defaultImage = "drupalwxt/site-canada" 21 | codeSrcMountPath = "/var/run/sylus.ca/code/src" 22 | defaultCodeMountPath = "/var/www/html/modules/custom" 23 | ) 24 | 25 | // SetDefaults sets Drupal field defaults 26 | func (o *Drupal) SetDefaults() { 27 | if len(o.Spec.Drupal.Image) == 0 { 28 | o.Spec.Drupal.Image = defaultImage 29 | } 30 | 31 | if len(o.Spec.Drupal.Tag) == 0 { 32 | o.Spec.Drupal.Tag = defaultTag 33 | } 34 | 35 | if o.Spec.Drupal.CodeVolumeSpec != nil && len(o.Spec.Drupal.CodeVolumeSpec.MountPath) == 0 { 36 | o.Spec.Drupal.CodeVolumeSpec.MountPath = defaultCodeMountPath 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/internal/drupal/drupal.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package drupal 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/cooleo/slugify" 22 | "k8s.io/apimachinery/pkg/labels" 23 | 24 | drupalv1beta1 "github.com/sylus/drupal-operator/pkg/apis/drupal/v1beta1" 25 | ) 26 | 27 | // Drupal embeds drupalv1beta1.Droplet and adds utility functions 28 | type Drupal struct { 29 | *drupalv1beta1.Droplet 30 | } 31 | 32 | type component struct { 33 | name string // eg. web, database, cache 34 | objNameFmt string 35 | objName string 36 | } 37 | 38 | var ( 39 | // DrupalSecret component 40 | DrupalSecret = component{name: "web", objNameFmt: "%s-drupal"} 41 | // DrupalConfigMap component 42 | DrupalConfigMap = component{name: "web", objNameFmt: "%s"} 43 | // DrupalDeployment component 44 | DrupalDeployment = component{name: "web", objNameFmt: "%s"} 45 | // DrupalCron component 46 | DrupalCron = component{name: "cron", objNameFmt: "%s-drupal-cron"} 47 | // DrupalDBUpgrade component 48 | DrupalDBUpgrade = component{name: "upgrade", objNameFmt: "%s-upgrade"} 49 | // DrupalService component 50 | DrupalService = component{name: "web", objNameFmt: "%s"} 51 | // DrupalIngress component 52 | DrupalIngress = component{name: "web", objNameFmt: "%s"} 53 | // DrupalCodePVC component 54 | DrupalCodePVC = component{name: "code", objNameFmt: "%s-code"} 55 | // DrupalMediaPVC component 56 | DrupalMediaPVC = component{name: "media", objNameFmt: "%s-media"} 57 | ) 58 | 59 | // New wraps a drupalv1beta1.Droplet into a Drupal object 60 | func New(obj *drupalv1beta1.Droplet) *Drupal { 61 | return &Drupal{obj} 62 | } 63 | 64 | // Unwrap returns the wrapped drupalv1beta1.Droplet object 65 | func (o *Drupal) Unwrap() *drupalv1beta1.Droplet { 66 | return o.Droplet 67 | } 68 | 69 | // Labels returns default label set for drupalv1beta1.Drupal 70 | func (o *Drupal) Labels() labels.Set { 71 | partOf := "drupal" 72 | if o.ObjectMeta.Labels != nil && len(o.ObjectMeta.Labels["app.kubernetes.io/part-of"]) > 0 { 73 | partOf = o.ObjectMeta.Labels["app.kubernetes.io/part-of"] 74 | } 75 | 76 | version := defaultTag 77 | if len(o.Spec.Drupal.Tag) != 0 { 78 | version = o.Spec.Drupal.Tag 79 | } 80 | 81 | labels := labels.Set{ 82 | "app.kubernetes.io/name": "drupal", 83 | "app.kubernetes.io/instance": o.ObjectMeta.Name, 84 | "app.kubernetes.io/version": version, 85 | "app.kubernetes.io/part-of": partOf, 86 | } 87 | 88 | return labels 89 | } 90 | 91 | // ComponentLabels returns labels for a label set for a drupalv1beta1.Drupal component 92 | func (o *Drupal) ComponentLabels(component component) labels.Set { 93 | l := o.Labels() 94 | l["app.kubernetes.io/component"] = component.name 95 | 96 | if component == DrupalDBUpgrade { 97 | l["drupal.sylus.ca/upgrade-for"] = o.ImageTagVersion() 98 | } 99 | 100 | return l 101 | } 102 | 103 | // ComponentName returns the object name for a component 104 | func (o *Drupal) ComponentName(component component) string { 105 | name := component.objName 106 | if len(component.objNameFmt) > 0 { 107 | name = fmt.Sprintf(component.objNameFmt, o.ObjectMeta.Name) 108 | } 109 | 110 | if component == DrupalDBUpgrade { 111 | name = fmt.Sprintf("%s-for-%s", name, o.ImageTagVersion()) 112 | } 113 | 114 | return name 115 | } 116 | 117 | // ImageTagVersion returns the version from the image tag in a format suitable 118 | // for kubernetes object names and labels 119 | func (o *Drupal) ImageTagVersion() string { 120 | return slugify.Slugify(o.Spec.Drupal.Tag) 121 | } 122 | 123 | // PodLabels return labels to apply to web pods 124 | func (o *Drupal) PodLabels() labels.Set { 125 | l := o.Labels() 126 | l["app.kubernetes.io/component"] = "drupal" 127 | return l 128 | } 129 | 130 | // JobPodLabels return labels to apply to cli job pods 131 | func (o *Drupal) JobPodLabels() labels.Set { 132 | l := o.Labels() 133 | l["app.kubernetes.io/component"] = "drupal-cli" 134 | return l 135 | } 136 | 137 | // DataBaseBackend returns the type of database to leverage 138 | func (o *Drupal) DataBaseBackend() (string, string) { 139 | if o.Spec.Drupal.DatabaseBackEnd == "postgres" { 140 | return "pgsql", "5432" 141 | } 142 | 143 | return "msql", "3306" 144 | } 145 | -------------------------------------------------------------------------------- /pkg/internal/drupal/pod_template.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package drupal 17 | 18 | import ( 19 | "fmt" 20 | 21 | corev1 "k8s.io/api/core/v1" 22 | ) 23 | 24 | //nolint 25 | const ( 26 | gitCloneImage = "docker.io/library/buildpack-deps:stretch-scm" 27 | drupalPort = 9000 28 | codeVolumeName = "code" 29 | mediaVolumeName = "media" 30 | ) 31 | 32 | const gitCloneScript = `#!/bin/bash 33 | set -e 34 | set -o pipefail 35 | 36 | export HOME="$(mktemp -d)" 37 | export GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=no" 38 | 39 | test -d "$HOME/.ssh" || mkdir "$HOME/.ssh" 40 | 41 | if [ ! -z "$SSH_RSA_PRIVATE_KEY" ] ; then 42 | echo "$SSH_RSA_PRIVATE_KEY" > "$HOME/.ssh/id_rsa" 43 | chmod 0400 "$HOME/.ssh/id_rsa" 44 | export GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o IdentityFile=$HOME/.ssh/id_rsa" 45 | fi 46 | 47 | if [ -z "$GIT_CLONE_URL" ] ; then 48 | echo "No \$GIT_CLONE_URL specified" >&2 49 | exit 1 50 | fi 51 | 52 | find "$SRC_DIR" -maxdepth 1 -mindepth 1 -print0 | xargs -0 /bin/rm -rf 53 | 54 | set -x 55 | git clone "$GIT_CLONE_URL" "$SRC_DIR" 56 | cd "$SRC_DIR" 57 | git checkout -B "$GIT_CLONE_REF" "$GIT_CLONE_REF" 58 | ` 59 | 60 | var ( 61 | wwwDataUserID int64 = 33 62 | ) 63 | 64 | var ( 65 | s3EnvVars = map[string]string{ 66 | "AWS_ACCESS_KEY_ID": "AWS_ACCESS_KEY_ID", 67 | "AWS_SECRET_ACCESS_KEY": "AWS_SECRET_ACCESS_KEY", 68 | "AWS_CONFIG_FILE": "AWS_CONFIG_FILE", 69 | "ENDPOINT": "S3_ENDPOINT", 70 | } 71 | gcsEnvVars = map[string]string{ 72 | "GOOGLE_CREDENTIALS": "GOOGLE_CREDENTIALS", 73 | "GOOGLE_APPLICATION_CREDENTIALS": "GOOGLE_APPLICATION_CREDENTIALS", 74 | } 75 | ) 76 | 77 | func (droplet *Drupal) image() string { 78 | return fmt.Sprintf("%s:%s", droplet.Spec.Drupal.Image, droplet.Spec.Drupal.Tag) 79 | } 80 | 81 | func (droplet *Drupal) env() []corev1.EnvVar { 82 | out := append([]corev1.EnvVar{ 83 | { 84 | Name: "DRUPAL_HOME", 85 | Value: fmt.Sprintf("http://%s", droplet.Spec.Domains[0]), 86 | }, 87 | { 88 | Name: "DRUPAL_SITEURL", 89 | Value: fmt.Sprintf("http://%s/droplet", droplet.Spec.Domains[0]), 90 | }, 91 | }, droplet.Spec.Drupal.Env...) 92 | 93 | if droplet.Spec.Drupal.MediaVolumeSpec != nil { 94 | if droplet.Spec.Drupal.MediaVolumeSpec.S3VolumeSource != nil { 95 | for _, env := range droplet.Spec.Drupal.MediaVolumeSpec.S3VolumeSource.Env { 96 | if name, ok := s3EnvVars[env.Name]; ok { 97 | _env := env.DeepCopy() 98 | _env.Name = name 99 | out = append(out, *_env) 100 | } 101 | } 102 | } 103 | 104 | if droplet.Spec.Drupal.MediaVolumeSpec.GCSVolumeSource != nil { 105 | out = append(out, corev1.EnvVar{ 106 | Name: "MEDIA_BUCKET", 107 | Value: fmt.Sprintf("gs://%s", droplet.Spec.Drupal.MediaVolumeSpec.GCSVolumeSource.Bucket), 108 | }) 109 | out = append(out, corev1.EnvVar{ 110 | Name: "MEDIA_BUCKET_PREFIX", 111 | Value: droplet.Spec.Drupal.MediaVolumeSpec.GCSVolumeSource.PathPrefix, 112 | }) 113 | for _, env := range droplet.Spec.Drupal.MediaVolumeSpec.GCSVolumeSource.Env { 114 | if name, ok := gcsEnvVars[env.Name]; ok { 115 | _env := env.DeepCopy() 116 | _env.Name = name 117 | out = append(out, *_env) 118 | } 119 | } 120 | } 121 | } 122 | return out 123 | } 124 | 125 | func (droplet *Drupal) envFrom() []corev1.EnvFromSource { 126 | out := []corev1.EnvFromSource{ 127 | { 128 | SecretRef: &corev1.SecretEnvSource{ 129 | LocalObjectReference: corev1.LocalObjectReference{ 130 | Name: fmt.Sprintf("%s-%s", droplet.ComponentName(DrupalSecret), "mysql-root-password"), 131 | }, 132 | }, 133 | }, 134 | } 135 | 136 | out = append(out, droplet.Spec.Drupal.EnvFrom...) 137 | 138 | return out 139 | } 140 | 141 | func (droplet *Drupal) gitCloneEnv() []corev1.EnvVar { 142 | if droplet.Spec.Drupal.CodeVolumeSpec.GitDir == nil { 143 | return []corev1.EnvVar{} 144 | } 145 | 146 | out := []corev1.EnvVar{ 147 | { 148 | Name: "GIT_CLONE_URL", 149 | Value: droplet.Spec.Drupal.CodeVolumeSpec.GitDir.Repository, 150 | }, 151 | { 152 | Name: "SRC_DIR", 153 | Value: codeSrcMountPath, 154 | }, 155 | } 156 | 157 | if len(droplet.Spec.Drupal.CodeVolumeSpec.GitDir.GitRef) > 0 { 158 | out = append(out, corev1.EnvVar{ 159 | Name: "GIT_CLONE_REF", 160 | Value: droplet.Spec.Drupal.CodeVolumeSpec.GitDir.GitRef, 161 | }) 162 | } 163 | 164 | out = append(out, droplet.Spec.Drupal.CodeVolumeSpec.GitDir.Env...) 165 | 166 | return out 167 | } 168 | 169 | func (droplet *Drupal) volumeMounts() (out []corev1.VolumeMount) { 170 | out = droplet.Spec.Drupal.VolumeMounts 171 | 172 | out = append(out, corev1.VolumeMount{ 173 | Name: "cm-drupal", 174 | MountPath: "/var/www/html/sites/default/settings.php", 175 | ReadOnly: false, 176 | SubPath: "d8.settings.php", 177 | }) 178 | 179 | if droplet.Spec.Drupal.CodeVolumeSpec != nil { 180 | out = append(out, corev1.VolumeMount{ 181 | Name: codeVolumeName, 182 | MountPath: codeSrcMountPath, 183 | ReadOnly: droplet.Spec.Drupal.CodeVolumeSpec.ReadOnly, 184 | }) 185 | out = append(out, corev1.VolumeMount{ 186 | Name: codeVolumeName, 187 | MountPath: droplet.Spec.Drupal.CodeVolumeSpec.MountPath, 188 | ReadOnly: droplet.Spec.Drupal.CodeVolumeSpec.ReadOnly, 189 | SubPath: droplet.Spec.Drupal.CodeVolumeSpec.ContentSubPath, 190 | }) 191 | } 192 | return out 193 | } 194 | 195 | func (droplet *Drupal) codeVolume() corev1.Volume { 196 | drupalCodeVolume := corev1.Volume{ 197 | Name: codeVolumeName, 198 | VolumeSource: corev1.VolumeSource{ 199 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 200 | }, 201 | } 202 | 203 | if droplet.Spec.Drupal.CodeVolumeSpec != nil { 204 | switch { 205 | case droplet.Spec.Drupal.CodeVolumeSpec.GitDir != nil: 206 | if droplet.Spec.Drupal.CodeVolumeSpec.GitDir.EmptyDir != nil { 207 | drupalCodeVolume.EmptyDir = droplet.Spec.Drupal.CodeVolumeSpec.GitDir.EmptyDir 208 | } 209 | case droplet.Spec.Drupal.CodeVolumeSpec.PersistentVolumeClaim != nil: 210 | drupalCodeVolume = corev1.Volume{ 211 | Name: codeVolumeName, 212 | VolumeSource: corev1.VolumeSource{ 213 | PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ 214 | ClaimName: droplet.ComponentName(DrupalCodePVC), 215 | }, 216 | }, 217 | } 218 | case droplet.Spec.Drupal.CodeVolumeSpec.HostPath != nil: 219 | drupalCodeVolume = corev1.Volume{ 220 | Name: codeVolumeName, 221 | VolumeSource: corev1.VolumeSource{ 222 | HostPath: droplet.Spec.Drupal.CodeVolumeSpec.HostPath, 223 | }, 224 | } 225 | case droplet.Spec.Drupal.CodeVolumeSpec.EmptyDir != nil: 226 | drupalCodeVolume.EmptyDir = droplet.Spec.Drupal.CodeVolumeSpec.EmptyDir 227 | } 228 | } 229 | 230 | return drupalCodeVolume 231 | } 232 | 233 | func (droplet *Drupal) mediaVolume() corev1.Volume { 234 | drupalMediaVolume := corev1.Volume{ 235 | Name: mediaVolumeName, 236 | VolumeSource: corev1.VolumeSource{ 237 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 238 | }, 239 | } 240 | 241 | if droplet.Spec.Drupal.MediaVolumeSpec != nil { 242 | switch { 243 | case droplet.Spec.Drupal.MediaVolumeSpec.PersistentVolumeClaim != nil: 244 | drupalMediaVolume = corev1.Volume{ 245 | Name: mediaVolumeName, 246 | VolumeSource: corev1.VolumeSource{ 247 | PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ 248 | ClaimName: droplet.ComponentName(DrupalMediaPVC), 249 | }, 250 | }, 251 | } 252 | case droplet.Spec.Drupal.MediaVolumeSpec.HostPath != nil: 253 | drupalMediaVolume = corev1.Volume{ 254 | Name: mediaVolumeName, 255 | VolumeSource: corev1.VolumeSource{ 256 | HostPath: droplet.Spec.Drupal.MediaVolumeSpec.HostPath, 257 | }, 258 | } 259 | case droplet.Spec.Drupal.MediaVolumeSpec.EmptyDir != nil: 260 | drupalMediaVolume.EmptyDir = droplet.Spec.Drupal.MediaVolumeSpec.EmptyDir 261 | } 262 | } 263 | 264 | return drupalMediaVolume 265 | } 266 | 267 | func (droplet *Drupal) configMap() corev1.Volume { 268 | configMap := corev1.Volume{ 269 | Name: "cm-drupal", 270 | VolumeSource: corev1.VolumeSource{ 271 | ConfigMap: &corev1.ConfigMapVolumeSource{ 272 | LocalObjectReference: corev1.LocalObjectReference{ 273 | Name: droplet.ComponentName(DrupalConfigMap), 274 | }, 275 | }, 276 | }, 277 | } 278 | 279 | return configMap 280 | } 281 | 282 | func (droplet *Drupal) volumes() []corev1.Volume { 283 | return append(droplet.Spec.Drupal.Volumes, droplet.configMap(), droplet.codeVolume(), droplet.mediaVolume()) 284 | } 285 | 286 | func (droplet *Drupal) gitCloneContainer() corev1.Container { 287 | return corev1.Container{ 288 | Name: "git", 289 | Args: []string{"/bin/bash", "-c", gitCloneScript}, 290 | Image: gitCloneImage, 291 | Env: droplet.gitCloneEnv(), 292 | EnvFrom: droplet.Spec.Drupal.CodeVolumeSpec.GitDir.EnvFrom, 293 | VolumeMounts: []corev1.VolumeMount{ 294 | { 295 | Name: codeVolumeName, 296 | MountPath: codeSrcMountPath, 297 | }, 298 | }, 299 | SecurityContext: &corev1.SecurityContext{ 300 | RunAsUser: &wwwDataUserID, 301 | }, 302 | } 303 | } 304 | 305 | // PodTemplateSpec generates a pod template spec suitable for use with Drupal 306 | func (droplet *Drupal) PodTemplateSpec() (out corev1.PodTemplateSpec) { 307 | out = corev1.PodTemplateSpec{} 308 | out.ObjectMeta.Labels = droplet.PodLabels() 309 | 310 | out.Spec.ImagePullSecrets = droplet.Spec.Drupal.ImagePullSecrets 311 | if len(droplet.Spec.ServiceAccountName) > 0 { 312 | out.Spec.ServiceAccountName = droplet.Spec.ServiceAccountName 313 | } 314 | 315 | if droplet.Spec.Drupal.CodeVolumeSpec != nil && droplet.Spec.Drupal.CodeVolumeSpec.GitDir != nil { 316 | out.Spec.InitContainers = []corev1.Container{ 317 | droplet.gitCloneContainer(), 318 | } 319 | } 320 | 321 | out.Spec.Containers = []corev1.Container{ 322 | { 323 | Name: "drupal", 324 | Image: droplet.image(), 325 | VolumeMounts: droplet.volumeMounts(), 326 | Env: droplet.env(), 327 | EnvFrom: droplet.envFrom(), 328 | Ports: []corev1.ContainerPort{ 329 | { 330 | Name: "http", 331 | ContainerPort: int32(drupalPort), 332 | }, 333 | }, 334 | }, 335 | } 336 | 337 | out.Spec.Volumes = droplet.volumes() 338 | 339 | out.Spec.SecurityContext = &corev1.PodSecurityContext{ 340 | FSGroup: &wwwDataUserID, 341 | } 342 | 343 | return out 344 | } 345 | 346 | // JobPodTemplateSpec generates a pod template spec suitable for use with Drupal jobs 347 | func (droplet *Drupal) JobPodTemplateSpec(cmd ...string) (out corev1.PodTemplateSpec) { 348 | out = corev1.PodTemplateSpec{} 349 | out.ObjectMeta.Labels = droplet.JobPodLabels() 350 | 351 | out.Spec.ImagePullSecrets = droplet.Spec.Drupal.ImagePullSecrets 352 | if len(droplet.Spec.ServiceAccountName) > 0 { 353 | out.Spec.ServiceAccountName = droplet.Spec.ServiceAccountName 354 | } 355 | 356 | out.Spec.RestartPolicy = corev1.RestartPolicyNever 357 | 358 | if droplet.Spec.Drupal.CodeVolumeSpec != nil && droplet.Spec.Drupal.CodeVolumeSpec.GitDir != nil { 359 | out.Spec.InitContainers = []corev1.Container{ 360 | droplet.gitCloneContainer(), 361 | } 362 | } 363 | 364 | out.Spec.Containers = []corev1.Container{ 365 | { 366 | Name: "droplet-cli", 367 | Image: droplet.image(), 368 | Args: cmd, 369 | VolumeMounts: droplet.volumeMounts(), 370 | Env: droplet.env(), 371 | EnvFrom: droplet.envFrom(), 372 | }, 373 | } 374 | 375 | out.Spec.Volumes = droplet.volumes() 376 | 377 | out.Spec.SecurityContext = &corev1.PodSecurityContext{ 378 | FSGroup: &wwwDataUserID, 379 | } 380 | 381 | return out 382 | } 383 | -------------------------------------------------------------------------------- /pkg/internal/nginx/defaults.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package nginx 17 | 18 | const ( 19 | defaultTag = "nginx-0.0.1" 20 | defaultImage = "drupalwxt/site-canada" 21 | ) 22 | 23 | // SetDefaults sets Nginx field defaults 24 | func (o *Nginx) SetDefaults() { 25 | if len(o.Spec.Nginx.Image) == 0 { 26 | o.Spec.Nginx.Image = defaultImage 27 | } 28 | 29 | if len(o.Spec.Nginx.Tag) == 0 { 30 | o.Spec.Nginx.Tag = defaultTag 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /pkg/internal/nginx/nginx.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package nginx 17 | 18 | import ( 19 | "fmt" 20 | 21 | "github.com/cooleo/slugify" 22 | "k8s.io/apimachinery/pkg/labels" 23 | 24 | drupalv1beta1 "github.com/sylus/drupal-operator/pkg/apis/drupal/v1beta1" 25 | ) 26 | 27 | // Nginx embeds drupalv1beta1.Droplet and adds utility functions 28 | type Nginx struct { 29 | *drupalv1beta1.Droplet 30 | } 31 | 32 | type component struct { 33 | name string // eg. web, database, cache 34 | objNameFmt string 35 | objName string 36 | } 37 | 38 | var ( 39 | // NginxSecret component 40 | NginxSecret = component{name: "web", objNameFmt: "%s-nginx"} 41 | // NginxConfigMap component 42 | NginxConfigMap = component{name: "web", objNameFmt: "%s"} 43 | // NginxDeployment component 44 | NginxDeployment = component{name: "web", objNameFmt: "%s"} 45 | // NginxCron component 46 | NginxCron = component{name: "cron", objNameFmt: "%s-nginx-cron"} 47 | // NginxDBUpgrade component 48 | NginxDBUpgrade = component{name: "upgrade", objNameFmt: "%s-upgrade"} 49 | // NginxService component 50 | NginxService = component{name: "web", objNameFmt: "%s"} 51 | // NginxIngress component 52 | NginxIngress = component{name: "web", objNameFmt: "%s"} 53 | // NginxCodePVC component 54 | NginxCodePVC = component{name: "code", objNameFmt: "%s-code"} 55 | // NginxMediaPVC component 56 | NginxMediaPVC = component{name: "media", objNameFmt: "%s-media"} 57 | ) 58 | 59 | // New wraps a drupalv1beta1.Droplet into a Nginx object 60 | func New(obj *drupalv1beta1.Droplet) *Nginx { 61 | return &Nginx{obj} 62 | } 63 | 64 | // Unwrap returns the wrapped drupalv1beta1.Droplet object 65 | func (o *Nginx) Unwrap() *drupalv1beta1.Droplet { 66 | return o.Droplet 67 | } 68 | 69 | // Labels returns default label set for drupalv1beta1.Nginx 70 | func (o *Nginx) Labels() labels.Set { 71 | partOf := "drupal" 72 | if o.ObjectMeta.Labels != nil && len(o.ObjectMeta.Labels["app.kubernetes.io/part-of"]) > 0 { 73 | partOf = o.ObjectMeta.Labels["app.kubernetes.io/part-of"] 74 | } 75 | 76 | version := defaultTag 77 | if len(o.Spec.Nginx.Tag) != 0 { 78 | version = o.Spec.Nginx.Tag 79 | } 80 | 81 | labels := labels.Set{ 82 | "app.kubernetes.io/name": "nginx", 83 | "app.kubernetes.io/instance": o.ObjectMeta.Name, 84 | "app.kubernetes.io/version": version, 85 | "app.kubernetes.io/part-of": partOf, 86 | } 87 | 88 | return labels 89 | } 90 | 91 | // ComponentLabels returns labels for a label set for a drupalv1beta1.Nginx component 92 | func (o *Nginx) ComponentLabels(component component) labels.Set { 93 | l := o.Labels() 94 | l["app.kubernetes.io/component"] = component.name 95 | 96 | if component == NginxDBUpgrade { 97 | l["drupal.sylus.ca/upgrade-for"] = o.ImageTagVersion() 98 | } 99 | 100 | return l 101 | } 102 | 103 | // ComponentName returns the object name for a component 104 | func (o *Nginx) ComponentName(component component) string { 105 | name := component.objName 106 | if len(component.objNameFmt) > 0 { 107 | name = fmt.Sprintf(component.objNameFmt, o.ObjectMeta.Name) 108 | } 109 | 110 | if component == NginxDBUpgrade { 111 | name = fmt.Sprintf("%s-for-%s", name, o.ImageTagVersion()) 112 | } 113 | 114 | return name 115 | } 116 | 117 | // ImageTagVersion returns the version from the image tag in a format suitable 118 | // fro kubernetes object names and labels 119 | func (o *Nginx) ImageTagVersion() string { 120 | return slugify.Slugify(o.Spec.Nginx.Tag) 121 | } 122 | 123 | // PodLabels return labels to apply to web pods 124 | func (o *Nginx) PodLabels() labels.Set { 125 | l := o.Labels() 126 | l["app.kubernetes.io/component"] = "nginx" 127 | return l 128 | } 129 | 130 | // JobPodLabels return labels to apply to cli job pods 131 | func (o *Nginx) JobPodLabels() labels.Set { 132 | l := o.Labels() 133 | l["app.kubernetes.io/component"] = "nginx-cli" 134 | return l 135 | } 136 | -------------------------------------------------------------------------------- /pkg/internal/nginx/pod_template.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package nginx 17 | 18 | import ( 19 | "fmt" 20 | 21 | corev1 "k8s.io/api/core/v1" 22 | "k8s.io/apimachinery/pkg/api/resource" 23 | ) 24 | 25 | const ( 26 | nginxHTTPPort = 80 27 | nginxHTTPSPort = 443 28 | ) 29 | 30 | var ( 31 | wwwDataUserID int64 = 33 32 | ) 33 | 34 | func (droplet *Nginx) image() string { 35 | return fmt.Sprintf("%s:%s", defaultImage, defaultTag) 36 | } 37 | 38 | func (droplet *Nginx) env() []corev1.EnvVar { 39 | out := append([]corev1.EnvVar{ 40 | { 41 | Name: "NGINX_HOST", 42 | Value: fmt.Sprintf("http://%s", droplet.Spec.Domains[0]), 43 | }, 44 | }, droplet.Spec.Nginx.Env...) 45 | 46 | return out 47 | } 48 | 49 | func (droplet *Nginx) envFrom() []corev1.EnvFromSource { 50 | out := []corev1.EnvFromSource{ 51 | { 52 | SecretRef: &corev1.SecretEnvSource{ 53 | LocalObjectReference: corev1.LocalObjectReference{ 54 | Name: droplet.ComponentName(NginxSecret), 55 | }, 56 | }, 57 | }, 58 | } 59 | 60 | out = append(out, droplet.Spec.Nginx.EnvFrom...) 61 | 62 | return out 63 | } 64 | 65 | func (droplet *Nginx) volumeMounts() (out []corev1.VolumeMount) { 66 | out = droplet.Spec.Nginx.VolumeMounts 67 | 68 | out = append(out, corev1.VolumeMount{ 69 | Name: "cm-nginx", 70 | MountPath: "/etc/nginx/nginx.conf", 71 | ReadOnly: false, 72 | SubPath: "nginx.conf", 73 | }) 74 | 75 | return out 76 | } 77 | 78 | func (droplet *Nginx) configMap() corev1.Volume { 79 | configMap := corev1.Volume{ 80 | Name: "cm-nginx", 81 | VolumeSource: corev1.VolumeSource{ 82 | ConfigMap: &corev1.ConfigMapVolumeSource{ 83 | LocalObjectReference: corev1.LocalObjectReference{ 84 | Name: fmt.Sprintf("%s-%s", droplet.ComponentName(NginxConfigMap), "nginx"), 85 | }, 86 | }, 87 | }, 88 | } 89 | 90 | return configMap 91 | } 92 | 93 | func (droplet *Nginx) volumes() []corev1.Volume { 94 | return append(droplet.Spec.Nginx.Volumes, droplet.configMap()) 95 | } 96 | 97 | // PodTemplateSpec generates a pod template spec suitable for use with Nginx 98 | func (droplet *Nginx) PodTemplateSpec() (out corev1.PodTemplateSpec) { 99 | out = corev1.PodTemplateSpec{} 100 | out.ObjectMeta.Labels = droplet.PodLabels() 101 | 102 | out.Spec.ImagePullSecrets = droplet.Spec.Nginx.ImagePullSecrets 103 | if len(droplet.Spec.ServiceAccountName) > 0 { 104 | out.Spec.ServiceAccountName = droplet.Spec.ServiceAccountName 105 | } 106 | 107 | out.Spec.Containers = []corev1.Container{ 108 | { 109 | Name: "nginx", 110 | Image: droplet.image(), 111 | VolumeMounts: droplet.volumeMounts(), 112 | Env: droplet.env(), 113 | EnvFrom: droplet.envFrom(), 114 | Ports: []corev1.ContainerPort{ 115 | { 116 | Name: "http", 117 | ContainerPort: int32(nginxHTTPPort), 118 | }, 119 | { 120 | Name: "https", 121 | ContainerPort: int32(nginxHTTPSPort), 122 | }, 123 | }, 124 | Resources: corev1.ResourceRequirements{ 125 | Requests: corev1.ResourceList{ 126 | corev1.ResourceCPU: resource.MustParse("250m"), 127 | corev1.ResourceMemory: resource.MustParse("200Mi"), 128 | }, 129 | Limits: corev1.ResourceList{ 130 | corev1.ResourceCPU: resource.MustParse("400m"), 131 | corev1.ResourceMemory: resource.MustParse("500Mi"), 132 | }, 133 | }, 134 | }, 135 | } 136 | 137 | out.Spec.Volumes = droplet.volumes() 138 | 139 | out.Spec.SecurityContext = &corev1.PodSecurityContext{ 140 | FSGroup: &wwwDataUserID, 141 | } 142 | 143 | return out 144 | } 145 | -------------------------------------------------------------------------------- /pkg/util/mergo/transformers/transformers.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package transformers 17 | 18 | import ( 19 | "fmt" 20 | "reflect" 21 | 22 | "github.com/imdario/mergo" 23 | 24 | corev1 "k8s.io/api/core/v1" 25 | ) 26 | 27 | // TransformerMap is a mergo.Transformers implementation 28 | type TransformerMap map[reflect.Type]func(dst, src reflect.Value) error 29 | 30 | // PodSpec mergo transformers for corev1.PodSpec 31 | var PodSpec TransformerMap 32 | 33 | func init() { 34 | PodSpec = TransformerMap{ 35 | reflect.TypeOf([]corev1.Container{}): PodSpec.MergeListByKey("Name", mergo.WithOverride), 36 | reflect.TypeOf([]corev1.ContainerPort{}): PodSpec.MergeListByKey("ContainerPort", mergo.WithOverride), 37 | reflect.TypeOf([]corev1.EnvVar{}): PodSpec.MergeListByKey("Name", mergo.WithOverride), 38 | reflect.TypeOf(corev1.EnvVar{}): PodSpec.OverrideFields("Value", "ValueFrom"), 39 | reflect.TypeOf(corev1.VolumeSource{}): PodSpec.NilOtherFields(), 40 | reflect.TypeOf([]corev1.Toleration{}): PodSpec.MergeListByKey("Key", mergo.WithOverride), 41 | reflect.TypeOf([]corev1.Volume{}): PodSpec.MergeListByKey("Name", mergo.WithOverride), 42 | reflect.TypeOf([]corev1.LocalObjectReference{}): PodSpec.MergeListByKey("Name", mergo.WithOverride), 43 | reflect.TypeOf([]corev1.HostAlias{}): PodSpec.MergeListByKey("IP", mergo.WithOverride), 44 | reflect.TypeOf([]corev1.VolumeMount{}): PodSpec.MergeListByKey("MountPath", mergo.WithOverride), 45 | } 46 | } 47 | 48 | // Transformer implements mergo.Tansformers interface for TransformenrMap 49 | func (s TransformerMap) Transformer(t reflect.Type) func(dst, src reflect.Value) error { 50 | if fn, ok := s[t]; ok { 51 | return fn 52 | } 53 | return nil 54 | } 55 | 56 | func (s *TransformerMap) mergeByKey(key string, dst, elem reflect.Value, opts ...func(*mergo.Config)) error { 57 | elemKey := elem.FieldByName(key) 58 | for i := 0; i < dst.Len(); i++ { 59 | dstKey := dst.Index(i).FieldByName(key) 60 | if elemKey.Kind() != dstKey.Kind() { 61 | return fmt.Errorf("cannot merge when key type differs") 62 | } 63 | eq := eq(key, elem, dst.Index(i)) 64 | if eq { 65 | opts = append(opts, mergo.WithTransformers(s)) 66 | return mergo.Merge(dst.Index(i).Addr().Interface(), elem.Interface(), opts...) 67 | } 68 | } 69 | dst.Set(reflect.Append(dst, elem)) 70 | return nil 71 | } 72 | 73 | func eq(key string, a, b reflect.Value) bool { 74 | aKey := a.FieldByName(key) 75 | bKey := b.FieldByName(key) 76 | if aKey.Kind() != bKey.Kind() { 77 | return false 78 | } 79 | eq := false 80 | switch aKey.Kind() { 81 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 82 | eq = aKey.Int() == bKey.Int() 83 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 84 | eq = aKey.Uint() == bKey.Uint() 85 | case reflect.String: 86 | eq = aKey.String() == bKey.String() 87 | case reflect.Float32, reflect.Float64: 88 | eq = aKey.Float() == bKey.Float() 89 | } 90 | return eq 91 | } 92 | 93 | func indexByKey(key string, v reflect.Value, list reflect.Value) (int, bool) { 94 | for i := 0; i < list.Len(); i++ { 95 | if eq(key, v, list.Index(i)) { 96 | return i, true 97 | } 98 | } 99 | return -1, false 100 | } 101 | 102 | // MergeListByKey merges two list by element key (eg. merge []corev1.Container 103 | // by name). If mergo.WithAppendSlice options is passed, the list is extended, 104 | // while elemnts with same name are merged. If not, the list is filtered to 105 | // elements in src 106 | func (s *TransformerMap) MergeListByKey(key string, opts ...func(*mergo.Config)) func(_, _ reflect.Value) error { 107 | conf := &mergo.Config{} 108 | for _, opt := range opts { 109 | opt(conf) 110 | } 111 | return func(dst, src reflect.Value) error { 112 | entries := reflect.MakeSlice(src.Type(), src.Len(), src.Len()) 113 | for i := 0; i < src.Len(); i++ { 114 | elem := src.Index(i) 115 | err := s.mergeByKey(key, dst, elem, opts...) 116 | if err != nil { 117 | return err 118 | } 119 | j, found := indexByKey(key, elem, dst) 120 | if found { 121 | entries.Index(i).Set(dst.Index(j)) 122 | } 123 | } 124 | if !conf.AppendSlice { 125 | dst.SetLen(entries.Len()) 126 | dst.SetCap(entries.Cap()) 127 | dst.Set(entries) 128 | } 129 | 130 | return nil 131 | } 132 | } 133 | 134 | // NilOtherFields nils all fields not defined in src 135 | func (s *TransformerMap) NilOtherFields(opts ...func(*mergo.Config)) func(_, _ reflect.Value) error { 136 | return func(dst, src reflect.Value) error { 137 | for i := 0; i < dst.NumField(); i++ { 138 | dstField := dst.Type().Field(i) 139 | srcValue := src.FieldByName(dstField.Name) 140 | dstValue := dst.FieldByName(dstField.Name) 141 | 142 | if srcValue.Kind() == reflect.Ptr && srcValue.IsNil() { 143 | dstValue.Set(srcValue) 144 | } else { 145 | if dstValue.Kind() == reflect.Ptr && dstValue.IsNil() { 146 | dstValue.Set(srcValue) 147 | } else { 148 | opts = append(opts, mergo.WithTransformers(s)) 149 | return mergo.Merge(dstValue.Interface(), srcValue.Interface(), opts...) 150 | } 151 | } 152 | } 153 | return nil 154 | } 155 | } 156 | 157 | // OverrideFields when merging override fields even if they are zero values (eg. nil or empty list) 158 | func (s *TransformerMap) OverrideFields(fields ...string) func(_, _ reflect.Value) error { 159 | return func(dst, src reflect.Value) error { 160 | for _, field := range fields { 161 | srcValue := src.FieldByName(field) 162 | dst.FieldByName(field).Set(srcValue) 163 | } 164 | return nil 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /pkg/util/mergo/transformers/transformers_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package transformers_test 17 | 18 | import ( 19 | "testing" 20 | 21 | "github.com/onsi/ginkgo" 22 | "github.com/onsi/gomega" 23 | "sigs.k8s.io/controller-runtime/pkg/envtest" 24 | ) 25 | 26 | func TestV1beta1(t *testing.T) { 27 | gomega.RegisterFailHandler(ginkgo.Fail) 28 | ginkgo.RunSpecsWithDefaultAndCustomReporters(t, "mergo transformers suite", []ginkgo.Reporter{envtest.NewlineReporter{}}) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/util/mergo/transformers/transformers_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package transformers_test 17 | 18 | import ( 19 | "fmt" 20 | "math/rand" 21 | 22 | "github.com/imdario/mergo" 23 | "github.com/onsi/ginkgo" 24 | "github.com/onsi/gomega" 25 | 26 | appsv1 "k8s.io/api/apps/v1" 27 | corev1 "k8s.io/api/core/v1" 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | 30 | "github.com/sylus/drupal-operator/pkg/util/mergo/transformers" 31 | ) 32 | 33 | var _ = ginkgo.Describe("PodSpec Transformer", func() { 34 | var deployment *appsv1.Deployment 35 | 36 | ginkgo.BeforeEach(func() { 37 | r := rand.Int31() 38 | name := fmt.Sprintf("depl-%d", r) 39 | deployment = &appsv1.Deployment{ 40 | ObjectMeta: metav1.ObjectMeta{ 41 | Name: name, 42 | Namespace: "default", 43 | }, 44 | Spec: appsv1.DeploymentSpec{ 45 | Template: corev1.PodTemplateSpec{ 46 | Spec: corev1.PodSpec{ 47 | Containers: []corev1.Container{ 48 | { 49 | Name: "main", 50 | Image: "main-image", 51 | Env: []corev1.EnvVar{ 52 | { 53 | Name: "TEST", 54 | Value: "me", 55 | }, 56 | }, 57 | }, 58 | { 59 | Name: "helper", 60 | Image: "helper-image", 61 | Ports: []corev1.ContainerPort{ 62 | { 63 | Name: "http", 64 | ContainerPort: 80, 65 | Protocol: corev1.ProtocolTCP, 66 | }, 67 | { 68 | Name: "prometheus", 69 | ContainerPort: 9125, 70 | Protocol: corev1.ProtocolTCP, 71 | }, 72 | }, 73 | }, 74 | }, 75 | Volumes: []corev1.Volume{ 76 | { 77 | Name: "code", 78 | VolumeSource: corev1.VolumeSource{ 79 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 80 | }, 81 | }, 82 | { 83 | Name: "media", 84 | VolumeSource: corev1.VolumeSource{ 85 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | } 93 | }) 94 | 95 | ginkgo.It("removes unused containers", func() { 96 | newSpec := corev1.PodSpec{ 97 | Containers: []corev1.Container{ 98 | { 99 | Name: "helper", 100 | Image: "helper-image", 101 | Ports: []corev1.ContainerPort{ 102 | { 103 | Name: "http", 104 | ContainerPort: 80, 105 | Protocol: corev1.ProtocolTCP, 106 | }, 107 | { 108 | Name: "prometheus", 109 | ContainerPort: 9125, 110 | Protocol: corev1.ProtocolTCP, 111 | }, 112 | }, 113 | }, 114 | }, 115 | } 116 | 117 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 118 | gomega.Expect(deployment.Spec.Template.Spec.Containers).To(gomega.HaveLen(1)) 119 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Name).To(gomega.Equal("helper")) 120 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Ports).To(gomega.HaveLen(2)) 121 | }) 122 | ginkgo.It("allows container rename", func() { 123 | newSpec := corev1.PodSpec{ 124 | Containers: []corev1.Container{ 125 | { 126 | Name: "new-helper", 127 | Image: "helper-image", 128 | Ports: []corev1.ContainerPort{ 129 | { 130 | Name: "http", 131 | ContainerPort: 80, 132 | Protocol: corev1.ProtocolTCP, 133 | }, 134 | { 135 | Name: "prometheus", 136 | ContainerPort: 9125, 137 | Protocol: corev1.ProtocolTCP, 138 | }, 139 | }, 140 | }, 141 | }, 142 | } 143 | 144 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 145 | gomega.Expect(deployment.Spec.Template.Spec.Containers).To(gomega.HaveLen(1)) 146 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Name).To(gomega.Equal("new-helper")) 147 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Ports).To(gomega.HaveLen(2)) 148 | }) 149 | ginkgo.It("allows container image update", func() { 150 | newSpec := deployment.Spec.Template.Spec.DeepCopy() 151 | newSpec.Containers[0].Image = "main-image-v2" 152 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 153 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Name).To(gomega.Equal("main")) 154 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Image).To(gomega.Equal("main-image-v2")) 155 | }) 156 | ginkgo.It("merges env vars", func() { 157 | newSpec := corev1.PodSpec{ 158 | Containers: []corev1.Container{ 159 | { 160 | Name: "main", 161 | Image: "main-image", 162 | Env: []corev1.EnvVar{ 163 | { 164 | Name: "TEST-2", 165 | Value: "me-2", 166 | }, 167 | }, 168 | }, 169 | }, 170 | } 171 | 172 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 173 | gomega.Expect(deployment.Spec.Template.Spec.Containers).To(gomega.HaveLen(1)) 174 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Env).To(gomega.HaveLen(1)) 175 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Env[0].Name).To(gomega.Equal("TEST-2")) 176 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Env[0].Value).To(gomega.Equal("me-2")) 177 | }) 178 | ginkgo.It("merges container ports", func() { 179 | newSpec := deployment.Spec.Template.Spec.DeepCopy() 180 | newSpec.Containers[1].Ports = []corev1.ContainerPort{ 181 | { 182 | Name: "prometheus", 183 | ContainerPort: 9125, 184 | Protocol: corev1.ProtocolTCP, 185 | }, 186 | } 187 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 188 | gomega.Expect(deployment.Spec.Template.Spec.Containers).To(gomega.HaveLen(2)) 189 | gomega.Expect(deployment.Spec.Template.Spec.Containers[1].Ports).To(gomega.HaveLen(1)) 190 | gomega.Expect(deployment.Spec.Template.Spec.Containers[1].Ports[0].ContainerPort).To(gomega.Equal(int32(9125))) 191 | }) 192 | ginkgo.It("allows prepending volume", func() { 193 | newSpec := deployment.Spec.Template.Spec.DeepCopy() 194 | newSpec.Volumes = []corev1.Volume{ 195 | { 196 | Name: "config", 197 | VolumeSource: corev1.VolumeSource{ 198 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 199 | }, 200 | }, 201 | { 202 | Name: "code", 203 | VolumeSource: corev1.VolumeSource{ 204 | HostPath: &corev1.HostPathVolumeSource{}, 205 | }, 206 | }, 207 | { 208 | Name: "media", 209 | VolumeSource: corev1.VolumeSource{ 210 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 211 | }, 212 | }, 213 | } 214 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 215 | gomega.Expect(deployment.Spec.Template.Spec.Volumes).To(gomega.HaveLen(3)) 216 | gomega.Expect(deployment.Spec.Template.Spec.Volumes[0].Name).To(gomega.Equal(newSpec.Volumes[0].Name)) 217 | gomega.Expect(deployment.Spec.Template.Spec.Volumes[1].Name).To(gomega.Equal(newSpec.Volumes[1].Name)) 218 | gomega.Expect(deployment.Spec.Template.Spec.Volumes[2].Name).To(gomega.Equal(newSpec.Volumes[2].Name)) 219 | 220 | gomega.Expect(deployment.Spec.Template.Spec.Volumes[1].EmptyDir).To(gomega.BeNil()) 221 | gomega.Expect(deployment.Spec.Template.Spec.Volumes[1].HostPath).ToNot(gomega.BeNil()) 222 | }) 223 | ginkgo.It("allows replacing volume list", func() { 224 | newSpec := deployment.Spec.Template.Spec.DeepCopy() 225 | newSpec.Volumes = []corev1.Volume{ 226 | { 227 | Name: "config", 228 | VolumeSource: corev1.VolumeSource{ 229 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 230 | }, 231 | }, 232 | } 233 | gomega.Expect(mergo.Merge(&deployment.Spec.Template.Spec, newSpec, mergo.WithTransformers(transformers.PodSpec))).To(gomega.Succeed()) 234 | gomega.Expect(deployment.Spec.Template.Spec.Volumes).To(gomega.HaveLen(1)) 235 | gomega.Expect(deployment.Spec.Template.Spec.Volumes[0].Name).To(gomega.Equal(newSpec.Volumes[0].Name)) 236 | }) 237 | }) 238 | -------------------------------------------------------------------------------- /pkg/util/rand/rand.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | // Package rand provide functions for securely generating random strings. It 17 | // uses crypto/rand to securely generate random sequences of characters. 18 | // It is adapted from https://gist.github.com/denisbrodbeck/635a644089868a51eccd6ae22b2eb800 19 | // to support multiple character sets. 20 | package rand 21 | 22 | import ( 23 | "crypto/rand" 24 | "fmt" 25 | "io" 26 | "math/big" 27 | ) 28 | 29 | const ( 30 | letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 31 | ascii = letters + "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" 32 | ) 33 | 34 | var ( 35 | alphaNumericStringGenerator = NewStringGenerator(letters) 36 | asciiStringGenerator = NewStringGenerator(ascii) 37 | ) 38 | 39 | // NewStringGenerator returns a cryptographically secure random sequence 40 | // generator from given characters. 41 | func NewStringGenerator(characters string) func(int) (string, error) { 42 | return func(length int) (string, error) { 43 | result := "" 44 | for { 45 | if len(result) >= length { 46 | return result, nil 47 | } 48 | num, err := rand.Int(rand.Reader, big.NewInt(int64(len(characters)))) 49 | if err != nil { 50 | return "", err 51 | } 52 | n := num.Int64() 53 | result += string(characters[n]) 54 | } 55 | } 56 | } 57 | 58 | // AlphaNumericString returns a cryptographically secure random sequence of 59 | // alphanumeric characters. 60 | func AlphaNumericString(length int) (string, error) { 61 | return alphaNumericStringGenerator(length) 62 | } 63 | 64 | // ASCIIString returns a cryptographically secure random sequence of 65 | // ASCII characters, excluding space. 66 | func ASCIIString(length int) (string, error) { 67 | return asciiStringGenerator(length) 68 | } 69 | 70 | func init() { 71 | assertAvailablePRNG() 72 | } 73 | 74 | func assertAvailablePRNG() { 75 | // Assert that a cryptographically secure PRNG is available. 76 | buf := make([]byte, 1) 77 | _, err := io.ReadFull(rand.Reader, buf) 78 | if err != nil { 79 | panic(fmt.Sprintf("crypto/rand is unavailable: Read() failed with %#v", err)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/util/syncer/example_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer_test 17 | 18 | import ( 19 | "context" 20 | 21 | appsv1 "k8s.io/api/apps/v1" 22 | corev1 "k8s.io/api/core/v1" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/client-go/kubernetes/scheme" 26 | "k8s.io/client-go/tools/record" 27 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 28 | 29 | "github.com/sylus/drupal-operator/pkg/util/syncer" 30 | ) 31 | 32 | var ( 33 | recorder record.EventRecorder 34 | owner runtime.Object 35 | log = logf.Log.WithName("controllerutil-examples") 36 | ) 37 | 38 | func NewDeploymentSyncer(owner runtime.Object) syncer.Interface { 39 | obj := &appsv1.Deployment{ 40 | ObjectMeta: metav1.ObjectMeta{ 41 | Name: "example", 42 | Namespace: "default", 43 | }, 44 | } 45 | 46 | // c is client.Client 47 | return syncer.NewObjectSyncer("ExampleDeployment", owner, obj, c, scheme.Scheme, func(existing runtime.Object) error { 48 | deploy := existing.(*appsv1.Deployment) 49 | 50 | // Deployment selector is immutable so we set this value only if 51 | // a new object is going to be created 52 | if deploy.ObjectMeta.CreationTimestamp.IsZero() { 53 | deploy.Spec.Selector = &metav1.LabelSelector{ 54 | MatchLabels: map[string]string{"foo": "bar"}, 55 | } 56 | } 57 | 58 | // update the Deployment pod template 59 | deploy.Spec.Template = corev1.PodTemplateSpec{ 60 | ObjectMeta: metav1.ObjectMeta{ 61 | Labels: map[string]string{ 62 | "foo": "bar", 63 | }, 64 | }, 65 | Spec: corev1.PodSpec{ 66 | Containers: []corev1.Container{ 67 | { 68 | Name: "busybox", 69 | Image: "busybox", 70 | }, 71 | }, 72 | }, 73 | } 74 | 75 | return nil 76 | }) 77 | } 78 | 79 | func ExampleNewObjectSyncer() { 80 | // recorder is record.EventRecorder 81 | // owner is the owner for the syncer subject 82 | 83 | deploymentSyncer := NewDeploymentSyncer(owner) 84 | err := syncer.Sync(context.TODO(), deploymentSyncer, recorder) 85 | if err != nil { 86 | log.Error(err, "unable to sync") 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/util/syncer/external.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | 22 | "k8s.io/apimachinery/pkg/runtime" 23 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 24 | ) 25 | 26 | type externalSyncer struct { 27 | name string 28 | obj interface{} 29 | owner runtime.Object 30 | syncFn func(context.Context, interface{}) (controllerutil.OperationResult, error) 31 | } 32 | 33 | func (s *externalSyncer) GetObject() interface{} { return s.obj } 34 | func (s *externalSyncer) GetOwner() runtime.Object { return s.owner } 35 | func (s *externalSyncer) Sync(ctx context.Context) (SyncResult, error) { 36 | var err error 37 | result := SyncResult{} 38 | result.Operation, err = s.syncFn(ctx, s.obj) 39 | 40 | if err != nil { 41 | result.SetEventData(eventWarning, basicEventReason(s.name, err), 42 | fmt.Sprintf("%T failed syncing: %s", s.obj, err)) 43 | log.Error(err, string(result.Operation), "kind", fmt.Sprintf("%T", s.obj)) 44 | } else { 45 | result.SetEventData(eventNormal, basicEventReason(s.name, err), 46 | fmt.Sprintf("%T successfully %s", s.obj, result.Operation)) 47 | log.V(1).Info(string(result.Operation), "kind", fmt.Sprintf("%T", s.obj)) 48 | } 49 | 50 | return result, err 51 | } 52 | 53 | // NewExternalSyncer creates a new syncer which syncs a generic object 54 | // persisting it's state into and external store The name is used for logging 55 | // and event emitting purposes and should be an valid go identifier in upper 56 | // camel case. (eg. GiteaRepo) 57 | func NewExternalSyncer(name string, owner runtime.Object, obj interface{}, syncFn func(context.Context, interface{}) (controllerutil.OperationResult, error)) Interface { 58 | return &externalSyncer{ 59 | name: name, 60 | obj: obj, 61 | owner: owner, 62 | syncFn: syncFn, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/util/syncer/interface.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer 17 | 18 | import ( 19 | "context" 20 | 21 | "k8s.io/apimachinery/pkg/runtime" 22 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 23 | ) 24 | 25 | // SyncResult is a result of an Sync call 26 | type SyncResult struct { 27 | Operation controllerutil.OperationResult 28 | EventType string 29 | EventReason string 30 | EventMessage string 31 | } 32 | 33 | // SetEventData sets event data on an SyncResult 34 | func (r *SyncResult) SetEventData(eventType, reason, message string) { 35 | r.EventType = eventType 36 | r.EventReason = reason 37 | r.EventMessage = message 38 | } 39 | 40 | // Interface represents a syncer. A syncer persists an object 41 | // (known as subject), into a store (kubernetes apiserver or generic stores) 42 | // and records kubernetes events 43 | type Interface interface { 44 | // GetObject returns the object for which sync applies 45 | GetObject() interface{} 46 | // GetOwner returns the object owner or nil if object does not have one 47 | GetOwner() runtime.Object 48 | // Sync persists data into the external store 49 | Sync(context.Context) (SyncResult, error) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/util/syncer/object.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | "sigs.k8s.io/controller-runtime/pkg/client" 25 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 26 | "sigs.k8s.io/controller-runtime/pkg/patch" 27 | ) 28 | 29 | // ObjectSyncer is a syncer.Interface for syncing kubernetes.Objects only by 30 | // passing a SyncFn 31 | type ObjectSyncer struct { 32 | Owner runtime.Object 33 | Obj runtime.Object 34 | SyncFn controllerutil.MutateFn 35 | Name string 36 | Client client.Client 37 | Scheme *runtime.Scheme 38 | previousObject runtime.Object 39 | } 40 | 41 | // GetObject returns the ObjectSyncer subject 42 | func (s *ObjectSyncer) GetObject() interface{} { return s.Obj } 43 | 44 | // GetOwner returns the ObjectSyncer owner 45 | func (s *ObjectSyncer) GetOwner() runtime.Object { return s.Owner } 46 | 47 | // Sync does the actual syncing and implements the syncer.Inteface Sync method 48 | func (s *ObjectSyncer) Sync(ctx context.Context) (SyncResult, error) { 49 | result := SyncResult{} 50 | 51 | key, err := getKey(s.Obj) 52 | if err != nil { 53 | return result, err 54 | } 55 | 56 | result.Operation, err = controllerutil.CreateOrUpdate(ctx, s.Client, s.Obj, s.mutateFn()) 57 | 58 | diff, _ := patch.NewJSONPatch(s.previousObject, s.Obj) 59 | 60 | if err != nil { 61 | result.SetEventData(eventWarning, basicEventReason(s.Name, err), 62 | fmt.Sprintf("%T %s failed syncing: %s", s.Obj, key, err)) 63 | log.Error(err, string(result.Operation), "key", key, "kind", fmt.Sprintf("%T", s.Obj), "diff", diff) 64 | } else { 65 | result.SetEventData(eventNormal, basicEventReason(s.Name, err), 66 | fmt.Sprintf("%T %s %s successfully", s.Obj, key, result.Operation)) 67 | log.V(1).Info(string(result.Operation), "key", key, "kind", fmt.Sprintf("%T", s.Obj), "diff", diff) 68 | } 69 | 70 | return result, err 71 | } 72 | 73 | // Given an ObjectSyncer, returns a controllerutil.MutateFn which also sets the 74 | // owner reference if the subject has one 75 | func (s *ObjectSyncer) mutateFn() controllerutil.MutateFn { 76 | return func(existing runtime.Object) error { 77 | s.previousObject = existing.DeepCopyObject() 78 | err := s.SyncFn(existing) 79 | if err != nil { 80 | return err 81 | } 82 | if s.Owner != nil { 83 | existingMeta, ok := existing.(metav1.Object) 84 | if !ok { 85 | return fmt.Errorf("%T is not a metav1.Object", existing) 86 | } 87 | ownerMeta, ok := s.Owner.(metav1.Object) 88 | if !ok { 89 | return fmt.Errorf("%T is not a metav1.Object", s.Owner) 90 | } 91 | err := controllerutil.SetControllerReference(ownerMeta, existingMeta, s.Scheme) 92 | if err != nil { 93 | return err 94 | } 95 | } 96 | return nil 97 | } 98 | } 99 | 100 | // NewObjectSyncer creates a new kubernetes.Object syncer for a given object 101 | // with an owner and persists data using controller-runtime's CreateOrUpdate. 102 | // The name is used for logging and event emitting purposes and should be an 103 | // valid go identifier in upper camel case. (eg. MysqlStatefulSet) 104 | func NewObjectSyncer(name string, owner, obj runtime.Object, c client.Client, scheme *runtime.Scheme, syncFn controllerutil.MutateFn) Interface { 105 | return &ObjectSyncer{ 106 | Owner: owner, 107 | Obj: obj, 108 | SyncFn: syncFn, 109 | Name: name, 110 | Client: c, 111 | Scheme: scheme, 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/util/syncer/object_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer_test 17 | 18 | import ( 19 | "github.com/onsi/ginkgo" 20 | "github.com/onsi/gomega" 21 | 22 | "golang.org/x/net/context" 23 | // metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | appsv1 "k8s.io/api/apps/v1" 25 | corev1 "k8s.io/api/core/v1" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | "k8s.io/apimachinery/pkg/types" 28 | "k8s.io/client-go/tools/record" 29 | 30 | . "github.com/sylus/drupal-operator/pkg/util/syncer" 31 | ) 32 | 33 | var _ = ginkgo.Describe("ObjectSyncer", func() { 34 | var syncer *ObjectSyncer 35 | var deployment *appsv1.Deployment 36 | var recorder *record.FakeRecorder 37 | key := types.NamespacedName{Name: "example", Namespace: "default"} 38 | 39 | ginkgo.BeforeEach(func() { 40 | deployment = &appsv1.Deployment{} 41 | recorder = record.NewFakeRecorder(100) 42 | }) 43 | 44 | ginkgo.AfterEach(func() { 45 | // nolint: errcheck 46 | c.Delete(context.TODO(), deployment) 47 | }) 48 | 49 | ginkgo.When("syncing", func() { 50 | ginkgo.It("successfully creates an ownerless object when owner is nil", func() { 51 | syncer = NewDeploymentSyncer(nil).(*ObjectSyncer) 52 | gomega.Expect(Sync(context.TODO(), syncer, recorder)).To(gomega.Succeed()) 53 | 54 | gomega.Expect(c.Get(context.TODO(), key, deployment)).To(gomega.Succeed()) 55 | 56 | gomega.Expect(deployment.ObjectMeta.OwnerReferences).To(gomega.HaveLen(0)) 57 | 58 | gomega.Expect(deployment.Spec.Template.Spec.Containers).To(gomega.HaveLen(1)) 59 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Name).To(gomega.Equal("busybox")) 60 | gomega.Expect(deployment.Spec.Template.Spec.Containers[0].Image).To(gomega.Equal("busybox")) 61 | 62 | // since this is an ownerless object, no event is emitted 63 | gomega.Consistently(recorder.Events).ShouldNot(gomega.Receive()) 64 | }) 65 | 66 | ginkgo.It("successfully creates an object and set owner references", func() { 67 | owner := &corev1.ConfigMap{ 68 | ObjectMeta: metav1.ObjectMeta{ 69 | Name: key.Name, 70 | Namespace: key.Namespace, 71 | }, 72 | } 73 | gomega.Expect(c.Create(context.TODO(), owner)).To(gomega.Succeed()) 74 | syncer = NewDeploymentSyncer(owner).(*ObjectSyncer) 75 | gomega.Expect(Sync(context.TODO(), syncer, recorder)).To(gomega.Succeed()) 76 | 77 | gomega.Expect(c.Get(context.TODO(), key, deployment)).To(gomega.Succeed()) 78 | 79 | gomega.Expect(deployment.ObjectMeta.OwnerReferences).To(gomega.HaveLen(1)) 80 | gomega.Expect(deployment.ObjectMeta.OwnerReferences[0].Name).To(gomega.Equal(owner.ObjectMeta.Name)) 81 | gomega.Expect(*deployment.ObjectMeta.OwnerReferences[0].Controller).To(gomega.BeTrue()) 82 | 83 | var event string 84 | gomega.Expect(recorder.Events).To(gomega.Receive(&event)) 85 | gomega.Expect(event).To(gomega.ContainSubstring("ExampleDeploymentSyncSuccessfull")) 86 | gomega.Expect(event).To(gomega.ContainSubstring("*v1.Deployment default/example created successfully")) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /pkg/util/syncer/syncer.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer 17 | 18 | import ( 19 | "context" 20 | "fmt" 21 | 22 | "github.com/iancoleman/strcase" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/apimachinery/pkg/types" 26 | "k8s.io/client-go/tools/record" 27 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 28 | logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" 29 | ) 30 | 31 | var log = logf.Log.WithName("syncer") 32 | 33 | const ( 34 | eventNormal = "Normal" 35 | eventWarning = "Warning" 36 | ) 37 | 38 | func getKey(obj runtime.Object) (types.NamespacedName, error) { 39 | key := types.NamespacedName{} 40 | objMeta, ok := obj.(metav1.Object) 41 | if !ok { 42 | return key, fmt.Errorf("%T is not a metav1.Object", obj) 43 | } 44 | 45 | key.Name = objMeta.GetName() 46 | key.Namespace = objMeta.GetNamespace() 47 | return key, nil 48 | } 49 | 50 | func basicEventReason(objKindName string, err error) string { 51 | if err != nil { 52 | return fmt.Sprintf("%sSyncFailed", strcase.ToCamel(objKindName)) 53 | } 54 | return fmt.Sprintf("%sSyncSuccessfull", strcase.ToCamel(objKindName)) 55 | } 56 | 57 | // Sync mutates the subject of the syncer interface using controller-runtime 58 | // CreateOrUpdate method, when obj is not nil. It takes care of setting owner 59 | // references and recording kubernetes events where appropriate 60 | func Sync(ctx context.Context, syncer Interface, recorder record.EventRecorder) error { 61 | result, err := syncer.Sync(ctx) 62 | owner := syncer.GetOwner() 63 | 64 | if recorder != nil && owner != nil && result.EventType != "" && result.EventReason != "" && result.EventMessage != "" { 65 | if err != nil || result.Operation != controllerutil.OperationResultNone { 66 | recorder.Eventf(owner, result.EventType, result.EventReason, result.EventMessage) 67 | } 68 | } 69 | 70 | return err 71 | } 72 | 73 | // WithoutOwner partially implements the syncer interface for the case the subject has no owner 74 | type WithoutOwner struct{} 75 | 76 | // GetOwner implementation of syncer interface for the case the subject has no owner 77 | func (*WithoutOwner) GetOwner() runtime.Object { 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /pkg/util/syncer/syncer_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package syncer_test 17 | 18 | import ( 19 | "testing" 20 | 21 | "github.com/onsi/ginkgo" 22 | "github.com/onsi/gomega" 23 | "k8s.io/client-go/kubernetes/scheme" 24 | "k8s.io/client-go/rest" 25 | "sigs.k8s.io/controller-runtime/pkg/client" 26 | "sigs.k8s.io/controller-runtime/pkg/envtest" 27 | ) 28 | 29 | var t *envtest.Environment 30 | var cfg *rest.Config 31 | var c client.Client 32 | 33 | func TestV1beta1(t *testing.T) { 34 | gomega.RegisterFailHandler(ginkgo.Fail) 35 | ginkgo.RunSpecsWithDefaultAndCustomReporters(t, "API v1 Suite", []ginkgo.Reporter{envtest.NewlineReporter{}}) 36 | } 37 | 38 | var _ = ginkgo.BeforeSuite(func() { 39 | var err error 40 | 41 | t = &envtest.Environment{} 42 | 43 | cfg, err = t.Start() 44 | gomega.Expect(err).NotTo(gomega.HaveOccurred()) 45 | 46 | c, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 47 | gomega.Expect(err).NotTo(gomega.HaveOccurred()) 48 | }) 49 | 50 | var _ = ginkgo.AfterSuite(func() { 51 | gomega.Expect(t.Stop()).To(gomega.Succeed()) 52 | }) 53 | -------------------------------------------------------------------------------- /pkg/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | 16 | package webhook 17 | 18 | import ( 19 | "sigs.k8s.io/controller-runtime/pkg/manager" 20 | ) 21 | 22 | // AddToManagerFuncs is a list of functions to add all Controllers to the Manager 23 | var AddToManagerFuncs []func(manager.Manager) error 24 | 25 | // AddToManager adds all Controllers to the Manager 26 | //nolint: lll 27 | // +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=mutatingwebhookconfigurations;validatingwebhookconfigurations,verbs=get;list;watch;create;update;patch;delete 28 | // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete 29 | // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete 30 | func AddToManager(m manager.Manager) error { 31 | for _, f := range AddToManagerFuncs { 32 | if err := f(m); err != nil { 33 | return err 34 | } 35 | } 36 | return nil 37 | } 38 | --------------------------------------------------------------------------------