├── config ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── certmanager │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ └── certificate.yaml ├── samples │ ├── kustomization.yaml │ ├── slack-secret.yaml │ └── slack_v1alpha1_channel.yaml ├── webhook │ ├── kustomization.yaml │ ├── service.yaml │ ├── kustomizeconfig.yaml │ └── manifests.yaml ├── rbac │ ├── service_account.yaml │ ├── auth_proxy_client_clusterrole.yaml │ ├── auth_proxy_service.yaml │ ├── role_binding.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_role.yaml │ ├── leader_election_role_binding.yaml │ ├── channel_viewer_role.yaml │ ├── kustomization.yaml │ ├── channel_editor_role.yaml │ ├── role.yaml │ └── leader_election_role.yaml ├── scorecard │ ├── bases │ │ └── config.yaml │ ├── patches │ │ ├── basic.config.yaml │ │ └── olm.config.yaml │ └── kustomization.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── crd │ ├── patches │ │ ├── cainjection_in_channels.yaml │ │ └── webhook_in_channels.yaml │ ├── kustomizeconfig.yaml │ ├── kustomization.yaml │ └── bases │ │ └── slack.stakater.com_channels.yaml ├── default │ ├── manager_webhook_patch.yaml │ ├── webhookcainjection_patch.yaml │ ├── manager_auth_proxy_patch.yaml │ └── kustomization.yaml └── manifests │ ├── kustomization.yaml │ └── bases │ └── slack-operator.clusterserviceversion.yaml ├── renovate.json ├── .dockerignore ├── bundle ├── manifests │ ├── slack-operator-controller-manager_v1_serviceaccount.yaml │ ├── slack-operator-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml │ ├── slack-operator-metrics-reader_rbac.authorization.k8s.io_v1beta1_clusterrole.yaml │ ├── slack-operator-webhook-service_v1_service.yaml │ ├── slack-operator-manager-metrics_v1_service.yaml │ ├── slack-operator-controller-manager-metrics-service_v1_service.yaml │ ├── slack.stakater.com_channels.yaml │ └── slack-operator.clusterserviceversion.yaml ├── metadata │ └── annotations.yaml └── tests │ └── scorecard │ └── config.yaml ├── charts └── slack-operator │ ├── .helmignore │ ├── templates │ ├── serviceaccount.yaml │ ├── tests │ │ └── test-connection.yaml │ ├── rolebinding.yaml │ ├── servicemonitor.yaml │ ├── service.yaml │ ├── role.yaml │ ├── certificate.yaml │ ├── mutating-webhook.yaml │ ├── validating-webhook.yaml │ ├── clusterrole.yaml │ ├── clusterrolebinding.yaml │ ├── _helpers.tpl │ └── deployment.yaml │ ├── Chart.yaml │ ├── README.md │ ├── values.yaml │ └── crds │ └── slack.stakater.com_channels.yaml ├── .gitignore ├── hack └── boilerplate.go.txt ├── PROJECT ├── pkg ├── slack │ ├── service_mock.go │ ├── service_test.go │ ├── mock │ │ ├── slackapi.go │ │ └── data.go │ └── service.go ├── util │ └── util.go └── config │ └── config.go ├── Dockerfile ├── bundle.Dockerfile ├── .goreleaser.yml ├── go.mod ├── .github └── workflows │ ├── release.yml │ ├── pull_request.yml │ └── push.yml ├── api └── v1alpha1 │ ├── groupversion_info.go │ ├── webhook_suite_test.go │ ├── channel_types.go │ ├── channel_webhook.go │ └── zz_generated.deepcopy.go ├── README.md ├── controllers ├── suite_test.go ├── util │ └── testUtil.go ├── channel_controller_test.go └── channel_controller.go ├── main.go └── Makefile /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/certmanager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - certificate.yaml 3 | 4 | configurations: 5 | - kustomizeconfig.yaml 6 | -------------------------------------------------------------------------------- /config/samples/kustomization.yaml: -------------------------------------------------------------------------------- 1 | ## This file is auto-generated, do not modify ## 2 | resources: 3 | - slack_v1alpha1_channel.yaml 4 | -------------------------------------------------------------------------------- /config/webhook/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manifests.yaml 3 | - service.yaml 4 | 5 | configurations: 6 | - kustomizeconfig.yaml 7 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ### Git ### 2 | .git* 3 | 4 | ### GitHub Actions ### 5 | /.github/ 6 | 7 | # Dependency directories 8 | .local 9 | /bin/ 10 | /config/ -------------------------------------------------------------------------------- /config/scorecard/bases/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: scorecard.operatorframework.io/v1 2 | kind: Configuration 3 | metadata: 4 | name: config 5 | stages: 6 | - parallel: true 7 | tests: [] 8 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator-controller-manager_v1_serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | creationTimestamp: null 5 | name: slack-operator-controller-manager 6 | -------------------------------------------------------------------------------- /config/samples/slack-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: slack-secret 5 | type: Opaque 6 | data: 7 | APIToken: eG94Yi0xMjM0NTY3ODktMTIzNDU0MzIxLWFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6 -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: ["/metrics"] 7 | verbs: ["get"] 8 | -------------------------------------------------------------------------------- /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: stakater/slack-operator 8 | newTag: v0.0.39 9 | -------------------------------------------------------------------------------- /config/webhook/service.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: webhook-service 6 | namespace: system 7 | spec: 8 | ports: 9 | - port: 443 10 | targetPort: 9443 11 | selector: 12 | control-plane: controller-manager 13 | -------------------------------------------------------------------------------- /config/scorecard/patches/basic.config.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /stages/0/tests/- 3 | value: 4 | entrypoint: 5 | - scorecard-test 6 | - basic-check-spec 7 | image: quay.io/operator-framework/scorecard-test:1.7.2 8 | labels: 9 | suite: basic 10 | test: basic-check-spec-test 11 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | creationTimestamp: null 5 | name: slack-operator-metrics-reader 6 | rules: 7 | - nonResourceURLs: 8 | - /metrics 9 | verbs: 10 | - get 11 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator-metrics-reader_rbac.authorization.k8s.io_v1beta1_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | creationTimestamp: null 5 | name: slack-operator-metrics-reader 6 | rules: 7 | - nonResourceURLs: 8 | - /metrics 9 | verbs: 10 | - get 11 | -------------------------------------------------------------------------------- /config/samples/slack_v1alpha1_channel.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: slack.stakater.com/v1alpha1 2 | kind: Channel 3 | metadata: 4 | name: building-channel 5 | spec: 6 | name: building-channel 7 | private: true 8 | topic: "Buildings" 9 | description: "Why is it called a 'building' if it's already built?" 10 | users: 11 | - hazim@stakater.com -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: manager-metrics 7 | namespace: system 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | targetPort: https 13 | selector: 14 | control-plane: controller-manager 15 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator-webhook-service_v1_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | creationTimestamp: null 5 | name: slack-operator-webhook-service 6 | spec: 7 | ports: 8 | - port: 443 9 | targetPort: 9443 10 | selector: 11 | control-plane: controller-manager 12 | status: 13 | loadBalancer: {} 14 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: proxy-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: proxy-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: proxy-role 5 | rules: 6 | - apiGroups: ["authentication.k8s.io"] 7 | resources: 8 | - tokenreviews 9 | verbs: ["create"] 10 | - apiGroups: ["authorization.k8s.io"] 11 | resources: 12 | - subjectaccessreviews 13 | verbs: ["create"] 14 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: leader-election-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: leader-election-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_channels.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | # CRD conversion requires k8s 1.13 or later. 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 8 | name: channels.slack.stakater.com 9 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator-manager-metrics_v1_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | creationTimestamp: null 5 | labels: 6 | control-plane: controller-manager 7 | name: slack-operator-manager-metrics 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | targetPort: https 13 | selector: 14 | control-plane: controller-manager 15 | status: 16 | loadBalancer: {} 17 | -------------------------------------------------------------------------------- /config/rbac/channel_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view channels. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: channel-viewer-role 6 | rules: 7 | - apiGroups: 8 | - slack.stakater.com 9 | resources: 10 | - channels 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - slack.stakater.com 17 | resources: 18 | - channels/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator-controller-manager-metrics-service_v1_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | creationTimestamp: null 5 | labels: 6 | control-plane: controller-manager 7 | name: slack-operator-controller-manager-metrics-service 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | targetPort: https 13 | selector: 14 | control-plane: controller-manager 15 | status: 16 | loadBalancer: {} 17 | -------------------------------------------------------------------------------- /config/scorecard/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - bases/config.yaml 3 | patchesJson6902: 4 | - path: patches/basic.config.yaml 5 | target: 6 | group: scorecard.operatorframework.io 7 | version: v1 8 | kind: Configuration 9 | name: config 10 | - path: patches/olm.config.yaml 11 | target: 12 | group: scorecard.operatorframework.io 13 | version: v1 14 | kind: Configuration 15 | name: config 16 | # +kubebuilder:scaffold:patchesJson6902 17 | -------------------------------------------------------------------------------- /charts/slack-operator/.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 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | --- 3 | apiVersion: v1 4 | kind: ServiceAccount 5 | metadata: 6 | name: {{ include "slack-operator.serviceAccountName" . }} 7 | namespace: {{ .Release.Namespace }} 8 | labels: 9 | {{- include "slack-operator.labels" . | nindent 4 }} 10 | {{- with .Values.serviceAccount.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - service_account.yaml 3 | - role.yaml 4 | - role_binding.yaml 5 | - leader_election_role.yaml 6 | - leader_election_role_binding.yaml 7 | # Comment the following 4 lines if you want to disable 8 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 9 | # which protects your /metrics endpoint. 10 | - auth_proxy_service.yaml 11 | - auth_proxy_role.yaml 12 | - auth_proxy_role_binding.yaml 13 | - auth_proxy_client_clusterrole.yaml 14 | -------------------------------------------------------------------------------- /config/certmanager/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This configuration is for teaching kustomize how to update name ref and var substitution 2 | nameReference: 3 | - kind: Issuer 4 | group: cert-manager.io 5 | fieldSpecs: 6 | - kind: Certificate 7 | group: cert-manager.io 8 | path: spec/issuerRef/name 9 | 10 | varReference: 11 | - kind: Certificate 12 | group: cert-manager.io 13 | path: spec/commonName 14 | - kind: Certificate 15 | group: cert-manager.io 16 | path: spec/dnsNames 17 | -------------------------------------------------------------------------------- /.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 | .local 26 | testbin 27 | .vscode 28 | dist/ 29 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "slack-operator.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "slack-operator.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "slack-operator.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /config/rbac/channel_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit channels. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: channel-editor-role 6 | rules: 7 | - apiGroups: 8 | - slack.stakater.com 9 | resources: 10 | - channels 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - slack.stakater.com 21 | resources: 22 | - channels/status 23 | verbs: 24 | - get 25 | -------------------------------------------------------------------------------- /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 | group: apiextensions.k8s.io 8 | path: spec/conversion/webhook/clientConfig/service/name 9 | 10 | namespace: 11 | - kind: CustomResourceDefinition 12 | group: apiextensions.k8s.io 13 | path: spec/conversion/webhook/clientConfig/service/namespace 14 | create: false 15 | 16 | varReference: 17 | - path: metadata/annotations 18 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Prometheus Monitor Service (Metrics) 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | labels: 7 | control-plane: controller-manager 8 | name: controller-manager-metrics-monitor 9 | namespace: system 10 | spec: 11 | endpoints: 12 | - path: /metrics 13 | port: https 14 | scheme: https 15 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 16 | tlsConfig: 17 | insecureSkipVerify: true 18 | selector: 19 | matchLabels: 20 | control-plane: controller-manager 21 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | 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 | */ -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: stakater.com 2 | layout: 3 | - go.kubebuilder.io/v3 4 | plugins: 5 | manifests.sdk.operatorframework.io/v2: {} 6 | scorecard.sdk.operatorframework.io/v2: {} 7 | projectName: slack-operator 8 | repo: github.com/stakater/slack-operator 9 | resources: 10 | - api: 11 | crdVersion: v1 12 | namespaced: true 13 | controller: true 14 | domain: stakater.com 15 | group: slack 16 | kind: Channel 17 | path: github.com/stakater/slack-operator/api/v1alpha1 18 | version: v1alpha1 19 | webhooks: 20 | defaulting: true 21 | validation: true 22 | webhookVersion: v1 23 | version: "3" 24 | -------------------------------------------------------------------------------- /charts/slack-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: slack-operator 3 | description: Helm Chart for slack-operator 4 | 5 | type: application 6 | 7 | sources: 8 | - https://github.com/stakater/slack-operator 9 | 10 | # Helm chart Version 11 | version: 0.0.39 12 | 13 | # Application version to be deployed 14 | appVersion: 0.0.39 15 | 16 | keywords: 17 | - stakater 18 | - sro 19 | - slack-operator 20 | - slack 21 | 22 | # Maintainers 23 | maintainers: 24 | - name: stakater 25 | email: hello@stakater.com 26 | 27 | icon: https://github.com/stakater/ForecastleIcons/blob/master/stakater.png 28 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | creationTimestamp: null 7 | name: manager-role 8 | rules: 9 | - apiGroups: 10 | - "" 11 | resources: 12 | - secrets 13 | verbs: 14 | - get 15 | - list 16 | - apiGroups: 17 | - slack.stakater.com 18 | resources: 19 | - channels 20 | verbs: 21 | - create 22 | - delete 23 | - get 24 | - list 25 | - patch 26 | - update 27 | - watch 28 | - apiGroups: 29 | - slack.stakater.com 30 | resources: 31 | - channels/status 32 | verbs: 33 | - get 34 | - patch 35 | - update 36 | -------------------------------------------------------------------------------- /config/default/manager_webhook_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | ports: 12 | - containerPort: 9443 13 | name: webhook-server 14 | protocol: TCP 15 | volumeMounts: 16 | - mountPath: /tmp/k8s-webhook-server/serving-certs 17 | name: cert 18 | readOnly: true 19 | volumes: 20 | - name: cert 21 | secret: 22 | defaultMode: 420 23 | secretName: webhook-server-cert 24 | -------------------------------------------------------------------------------- /charts/slack-operator/README.md: -------------------------------------------------------------------------------- 1 | # slack-operator 2 | 3 | A Helm chart to deploy slack-operator 4 | 5 | ## Pre-requisites 6 | 7 | - Make sure that [certman](https://cert-manager.io/) is deployed in your cluster since webhooks require certman to generate valid certs since webhooks serve using HTTPS 8 | 9 | ```terminal 10 | $ kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.0.1/cert-manager.yaml 11 | ``` 12 | 13 | ## Installing the chart 14 | 15 | ```sh 16 | helm repo add stakater https://stakater.github.io/stakater-charts/ 17 | helm repo update 18 | helm install stakater/slack-operator --namespace slack-operator 19 | ``` -------------------------------------------------------------------------------- /charts/slack-operator/templates/rolebinding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled -}} 2 | {{- if .Values.rbac.allowLeaderElectionRole }} 3 | --- 4 | apiVersion: rbac.authorization.k8s.io/v1 5 | kind: RoleBinding 6 | metadata: 7 | name: {{ include "slack-operator.fullname" . }}-leader-election-rolebinding 8 | namespace: {{ .Release.Namespace }} 9 | roleRef: 10 | apiGroup: rbac.authorization.k8s.io 11 | kind: Role 12 | name: {{ include "slack-operator.fullname" . }}-leader-election-role 13 | subjects: 14 | - kind: ServiceAccount 15 | name: {{ include "slack-operator.serviceAccountName" . }} 16 | namespace: {{ .Release.Namespace }} 17 | {{- end }} 18 | 19 | {{- end }} 20 | -------------------------------------------------------------------------------- /config/default/webhookcainjection_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch add annotation to admission webhook config and 2 | # the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. 3 | apiVersion: admissionregistration.k8s.io/v1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | name: mutating-webhook-configuration 7 | annotations: 8 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 9 | --- 10 | apiVersion: admissionregistration.k8s.io/v1 11 | kind: ValidatingWebhookConfiguration 12 | metadata: 13 | name: validating-webhook-configuration 14 | annotations: 15 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 16 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceMonitor.enabled -}} 2 | --- 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | name: '{{ .Release.Name }}' 7 | namespace: '{{ .Release.Namespace }}' 8 | labels: 9 | app: '{{ .Chart.Name }}' 10 | app.kubernetes.io/name: '{{ .Chart.Name }}' 11 | app.kubernetes.io/instance: '{{ .Release.Name }}' 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: '{{ .Chart.Name }}' 16 | app.kubernetes.io/name: '{{ .Chart.Name }}' 17 | app.kubernetes.io/instance: '{{ .Release.Name }}' 18 | endpoints: 19 | - port: metrics 20 | path: /metrics 21 | namespaceSelector: 22 | matchNames: 23 | - '{{ .Release.Namespace }}' 24 | {{- end }} -------------------------------------------------------------------------------- /pkg/slack/service_mock.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "github.com/go-logr/logr" 5 | "github.com/slack-go/slack" 6 | "github.com/stakater/slack-operator/pkg/slack/mock" 7 | ) 8 | 9 | var mockSlackService *SlackService 10 | 11 | //NewMockService creates a mock service with SlackTestServer 12 | func NewMockService(log logr.Logger) *SlackService { 13 | 14 | if mockSlackService == nil { 15 | testServer := mock.InitSlackTestServer() 16 | go testServer.Start() 17 | 18 | log.Info("Starting Test Server", "url", testServer.GetAPIURL()) 19 | 20 | opts := slack.OptionAPIURL(testServer.GetAPIURL()) 21 | 22 | mockSlackService = &SlackService{ 23 | api: slack.New("apitoken", opts), 24 | log: log.WithName("SlackService"), 25 | } 26 | } 27 | 28 | return mockSlackService 29 | } 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 | name: leader-election-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - create 16 | - update 17 | - patch 18 | - delete 19 | - apiGroups: 20 | - "" 21 | resources: 22 | - configmaps/status 23 | verbs: 24 | - get 25 | - update 26 | - patch 27 | - apiGroups: 28 | - coordination.k8s.io 29 | resources: 30 | - leases 31 | verbs: 32 | - get 33 | - list 34 | - watch 35 | - create 36 | - update 37 | - patch 38 | - delete 39 | - apiGroups: 40 | - "" 41 | resources: 42 | - events 43 | verbs: 44 | - create 45 | - patch 46 | -------------------------------------------------------------------------------- /bundle/metadata/annotations.yaml: -------------------------------------------------------------------------------- 1 | annotations: 2 | # Core bundle annotations. 3 | operators.operatorframework.io.bundle.mediatype.v1: registry+v1 4 | operators.operatorframework.io.bundle.manifests.v1: manifests/ 5 | operators.operatorframework.io.bundle.metadata.v1: metadata/ 6 | operators.operatorframework.io.bundle.package.v1: slack-operator 7 | operators.operatorframework.io.bundle.channels.v1: alpha 8 | operators.operatorframework.io.metrics.builder: operator-sdk-v1.7.1+git 9 | operators.operatorframework.io.metrics.mediatype.v1: metrics+v1 10 | operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v3 11 | 12 | # Annotations for testing. 13 | operators.operatorframework.io.test.mediatype.v1: scorecard+v1 14 | operators.operatorframework.io.test.config.v1: tests/scorecard/ 15 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_channels.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables conversion webhook for CRD 2 | # CRD conversion requires k8s 1.13 or later. 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | name: channels.slack.stakater.com 7 | spec: 8 | preserveUnknownFields: false 9 | conversion: 10 | strategy: Webhook 11 | webhook: 12 | clientConfig: 13 | # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, 14 | # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) 15 | caBundle: Cg== 16 | service: 17 | namespace: system 18 | name: webhook-service 19 | path: /convert 20 | port: 443 21 | conversionReviewVersions: ["v1","v1beta1"] 22 | -------------------------------------------------------------------------------- /config/webhook/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # the following config is for teaching kustomize where to look at when substituting vars. 2 | # It requires kustomize v2.1.0 or newer to work properly. 3 | nameReference: 4 | - kind: Service 5 | version: v1 6 | fieldSpecs: 7 | - kind: MutatingWebhookConfiguration 8 | group: admissionregistration.k8s.io 9 | path: webhooks/clientConfig/service/name 10 | - kind: ValidatingWebhookConfiguration 11 | group: admissionregistration.k8s.io 12 | path: webhooks/clientConfig/service/name 13 | 14 | namespace: 15 | - kind: MutatingWebhookConfiguration 16 | group: admissionregistration.k8s.io 17 | path: webhooks/clientConfig/service/namespace 18 | create: true 19 | - kind: ValidatingWebhookConfiguration 20 | group: admissionregistration.k8s.io 21 | path: webhooks/clientConfig/service/namespace 22 | create: true 23 | 24 | varReference: 25 | - path: metadata/annotations 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.16 as builder 3 | 4 | WORKDIR /workspace 5 | # Copy the Go Modules manifests 6 | COPY go.mod go.mod 7 | COPY go.sum go.sum 8 | # cache deps before building and copying source so that we don't need to re-download as much 9 | # and so that source changes don't invalidate our downloaded layer 10 | RUN go mod download 11 | 12 | # Copy the go source 13 | COPY main.go main.go 14 | COPY api/ api/ 15 | COPY controllers/ controllers/ 16 | COPY pkg/ pkg/ 17 | 18 | # Build 19 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -mod=mod -a -o manager main.go 20 | 21 | # Use distroless as minimal base image to package the manager binary 22 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 23 | FROM gcr.io/distroless/static:nonroot 24 | WORKDIR / 25 | COPY --from=builder /workspace/manager . 26 | USER 65532:65532 27 | 28 | ENTRYPOINT ["/manager"] 29 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/service.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ include "slack-operator.fullname" . }}-webhook-service 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "slack-operator.labels" . | nindent 4 }} 9 | spec: 10 | type: {{ .Values.service.type }} 11 | ports: 12 | - port: {{ .Values.service.port }} 13 | targetPort: 9443 14 | protocol: TCP 15 | name: http 16 | selector: 17 | {{- include "slack-operator.selectorLabels" . | nindent 4 }} 18 | --- 19 | apiVersion: v1 20 | kind: Service 21 | metadata: 22 | name: {{ include "slack-operator.fullname" . }}-metrics-service 23 | namespace: {{ .Release.Namespace }} 24 | labels: 25 | {{- include "slack-operator.labels" . | nindent 4 }} 26 | spec: 27 | ports: 28 | - name: https 29 | port: 8443 30 | targetPort: https 31 | selector: 32 | {{- include "slack-operator.selectorLabels" . | nindent 4 }} -------------------------------------------------------------------------------- /charts/slack-operator/templates/role.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled -}} 2 | {{- if .Values.rbac.allowLeaderElectionRole }} 3 | --- 4 | apiVersion: rbac.authorization.k8s.io/v1 5 | kind: Role 6 | metadata: 7 | name: {{ include "slack-operator.fullname" . }}-leader-election-role 8 | namespace: {{ .Release.Namespace }} 9 | rules: 10 | - apiGroups: 11 | - "" 12 | resources: 13 | - configmaps 14 | verbs: 15 | - get 16 | - list 17 | - watch 18 | - create 19 | - update 20 | - patch 21 | - delete 22 | - apiGroups: 23 | - "" 24 | resources: 25 | - configmaps/status 26 | verbs: 27 | - get 28 | - update 29 | - patch 30 | - apiGroups: 31 | - coordination.k8s.io 32 | resources: 33 | - leases 34 | verbs: 35 | - get 36 | - list 37 | - watch 38 | - create 39 | - update 40 | - patch 41 | - delete 42 | - apiGroups: 43 | - "" 44 | resources: 45 | - events 46 | verbs: 47 | - create 48 | - patch 49 | {{- end }} 50 | 51 | {{- end }} 52 | -------------------------------------------------------------------------------- /bundle.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | # Core bundle labels. 4 | LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1 5 | LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ 6 | LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ 7 | LABEL operators.operatorframework.io.bundle.package.v1=slack-operator 8 | LABEL operators.operatorframework.io.bundle.channels.v1=alpha 9 | LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.7.1+git 10 | LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 11 | LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v3 12 | 13 | # Labels for testing. 14 | LABEL operators.operatorframework.io.test.mediatype.v1=scorecard+v1 15 | LABEL operators.operatorframework.io.test.config.v1=tests/scorecard/ 16 | 17 | # Copy files to locations specified by labels. 18 | COPY bundle/manifests /manifests/ 19 | COPY bundle/metadata /metadata/ 20 | COPY bundle/tests/scorecard /tests/scorecard/ 21 | -------------------------------------------------------------------------------- /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/slack.stakater.com_channels.yaml 6 | # +kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patchesStrategicMerge: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | - patches/webhook_in_channels.yaml 12 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | - patches/cainjection_in_channels.yaml 17 | # +kubebuilder:scaffold:crdkustomizecainjectionpatch 18 | 19 | # the following config is for teaching kustomize how to do kustomization for CRDs. 20 | configurations: 21 | - kustomizeconfig.yaml 22 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod download 6 | - go generate ./... 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | goarch: 16 | - 386 17 | - amd64 18 | - arm64 19 | ignore: 20 | - goos: windows 21 | goarch: arm64 22 | 23 | archives: 24 | - format: tar.gz 25 | # this name template makes the OS and Arch compatible with the results of `uname` 26 | name_template: >- 27 | {{ .ProjectName }}_ 28 | {{- .Version }}_ 29 | {{- title .Os }}_ 30 | {{- if eq .Arch "amd64" }}x86_64 31 | {{- else if eq .Arch "386" }}i386 32 | {{- else }}{{ .Arch }}{{ end }} 33 | {{- if .Arm }}v{{ .Arm }}{{ end }} 34 | 35 | checksum: 36 | name_template: 'checksums.txt' 37 | snapshot: 38 | version_template: "{{ .Tag }}-next" 39 | 40 | changelog: 41 | sort: asc 42 | filters: 43 | exclude: 44 | - "^docs:" 45 | - "^test:" 46 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/certificate.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.webhook.enabled }} 2 | 3 | {{- if not (.Capabilities.APIVersions.Has "cert-manager.io/v1") -}} 4 | {{- fail "cert-manager is not installed" }} 5 | {{ end }} 6 | --- 7 | apiVersion: cert-manager.io/v1 8 | kind: Issuer 9 | metadata: 10 | name: {{ include "slack-operator.fullname" . }}-selfsigned-issuer 11 | namespace: {{ .Release.Namespace }} 12 | spec: 13 | selfSigned: {} 14 | --- 15 | apiVersion: cert-manager.io/v1 16 | kind: Certificate 17 | metadata: 18 | name: {{ include "slack-operator.fullname" . }}-serving-cert 19 | namespace: {{ .Release.Namespace }} 20 | spec: 21 | dnsNames: 22 | - "{{ include "slack-operator.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc" 23 | - "{{ include "slack-operator.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc.cluster.local" 24 | issuerRef: 25 | kind: Issuer 26 | name: {{ include "slack-operator.fullname" . }}-selfsigned-issuer 27 | secretName: webhook-server-cert 28 | {{- end -}} -------------------------------------------------------------------------------- /config/default/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: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: kube-rbac-proxy 13 | # Issue: https://github.com/operator-framework/operator-sdk/issues/4813 14 | image: registry.redhat.io/openshift4/ose-kube-rbac-proxy:v4.7.0 15 | args: 16 | - "--secure-listen-address=0.0.0.0:8443" 17 | - "--upstream=http://127.0.0.1:8080/" 18 | - "--logtostderr=true" 19 | - "--v=10" 20 | ports: 21 | - containerPort: 8443 22 | name: https 23 | - name: manager 24 | args: 25 | - "--health-probe-bind-address=:8081" 26 | - "--metrics-bind-address=127.0.0.1:8080" 27 | - "--leader-elect" 28 | -------------------------------------------------------------------------------- /config/certmanager/certificate.yaml: -------------------------------------------------------------------------------- 1 | # The following manifests contain a self-signed issuer CR and a certificate CR. 2 | # More document can be found at https://docs.cert-manager.io 3 | # WARNING: Targets CertManager 0.11 check https://docs.cert-manager.io/en/latest/tasks/upgrading/index.html for 4 | # breaking changes 5 | apiVersion: cert-manager.io/v1 6 | kind: Issuer 7 | metadata: 8 | name: selfsigned-issuer 9 | namespace: system 10 | spec: 11 | selfSigned: {} 12 | --- 13 | apiVersion: cert-manager.io/v1 14 | kind: Certificate 15 | metadata: 16 | name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml 17 | namespace: system 18 | spec: 19 | # $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize 20 | dnsNames: 21 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc 22 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local 23 | issuerRef: 24 | kind: Issuer 25 | name: selfsigned-issuer 26 | secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize 27 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/mutating-webhook.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.webhook.enabled -}} 2 | --- 3 | apiVersion: admissionregistration.k8s.io/v1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | annotations: 7 | cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "slack-operator.fullname" . }}-serving-cert 8 | creationTimestamp: null 9 | name: {{ include "slack-operator.fullname" . }}-mutating-webhook-configuration 10 | webhooks: 11 | - admissionReviewVersions: 12 | - v1 13 | - v1beta1 14 | clientConfig: 15 | service: 16 | name: {{ include "slack-operator.fullname" . }}-webhook-service 17 | namespace: {{ .Release.Namespace }} 18 | path: /mutate-slack-stakater-com-v1alpha1-channel 19 | failurePolicy: Fail 20 | sideEffects: None 21 | name: mchannel.kb.io 22 | rules: 23 | - apiGroups: 24 | - slack.stakater.com 25 | apiVersions: 26 | - v1alpha1 27 | operations: 28 | - CREATE 29 | - UPDATE 30 | resources: 31 | - channels 32 | {{- end -}} 33 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/validating-webhook.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.webhook.enabled -}} 2 | --- 3 | apiVersion: admissionregistration.k8s.io/v1 4 | kind: ValidatingWebhookConfiguration 5 | metadata: 6 | annotations: 7 | cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ include "slack-operator.fullname" . }}-serving-cert 8 | creationTimestamp: null 9 | name: {{ include "slack-operator.fullname" . }}-validating-webhook-configuration 10 | webhooks: 11 | - admissionReviewVersions: 12 | - v1 13 | - v1beta1 14 | clientConfig: 15 | service: 16 | name: {{ include "slack-operator.fullname" . }}-webhook-service 17 | namespace: {{ .Release.Namespace }} 18 | path: /validate-slack-stakater-com-v1alpha1-channel 19 | failurePolicy: Fail 20 | sideEffects: None 21 | name: vchannel.kb.io 22 | rules: 23 | - apiGroups: 24 | - slack.stakater.com 25 | apiVersions: 26 | - v1alpha1 27 | operations: 28 | - CREATE 29 | - UPDATE 30 | resources: 31 | - channels 32 | {{- end -}} 33 | 34 | -------------------------------------------------------------------------------- /config/manifests/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - ../default 3 | - ../samples 4 | - ../scorecard 5 | 6 | # [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix. 7 | # Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager. 8 | # These patches remove the unnecessary "cert" volume and its manager container volumeMount. 9 | #patchesJson6902: 10 | #- target: 11 | # group: apps 12 | # version: v1 13 | # kind: Deployment 14 | # name: controller-manager 15 | # namespace: system 16 | # patch: |- 17 | # # Remove the manager container's "cert" volumeMount, since OLM will create and mount a set of certs. 18 | # # Update the indices in this path if adding or removing containers/volumeMounts in the manager's Deployment. 19 | # - op: remove 20 | # path: /spec/template/spec/containers/1/volumeMounts/0 21 | # # Remove the "cert" volume, since OLM will create and mount a set of certs. 22 | # # Update the indices in this path if adding or removing volumes in the manager's Deployment. 23 | # - op: remove 24 | # path: /spec/template/spec/volumes/0 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stakater/slack-operator 2 | 3 | go 1.16 4 | 5 | require ( 6 | cloud.google.com/go v0.72.0 // indirect 7 | github.com/go-logr/logr v0.3.0 8 | github.com/go-logr/zapr v0.3.0 // indirect 9 | github.com/google/gofuzz v1.2.0 // indirect 10 | github.com/googleapis/gnostic v0.5.3 // indirect 11 | github.com/imdario/mergo v0.3.11 // indirect 12 | github.com/onsi/ginkgo v1.14.1 13 | github.com/onsi/gomega v1.10.2 14 | github.com/prometheus/client_golang v1.8.0 // indirect 15 | github.com/prometheus/common v0.15.0 // indirect 16 | github.com/slack-go/slack v0.7.2 17 | github.com/stakater/operator-utils v0.1.13 18 | github.com/stretchr/testify v1.6.1 19 | go.uber.org/multierr v1.6.0 // indirect 20 | go.uber.org/zap v1.16.0 // indirect 21 | golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392 // indirect 22 | golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58 // indirect 23 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect 24 | google.golang.org/appengine v1.6.7 // indirect 25 | gopkg.in/yaml.v2 v2.3.0 26 | k8s.io/apimachinery v0.20.2 27 | k8s.io/client-go v0.20.2 28 | sigs.k8s.io/controller-runtime v0.8.3 29 | ) 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Go project 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | GOLANG_VERSION: 1.16 10 | 11 | jobs: 12 | build: 13 | name: GoReleaser build 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 # See: https://goreleaser.com/ci/actions/ 21 | 22 | - name: Set up Go 1.x 23 | uses: actions/setup-go@v2 24 | with: 25 | go-version: ${{ env.GOLANG_VERSION }} 26 | id: go 27 | 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@master 30 | with: 31 | version: '~> v2' 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 35 | 36 | - name: Notify Slack 37 | uses: 8398a7/action-slack@v3 38 | if: always() 39 | with: 40 | status: ${{ job.status }} 41 | fields: repo,author,action,eventName,ref,workflow 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 44 | SLACK_WEBHOOK_URL: ${{ secrets.STAKATER_DELIVERY_SLACK_WEBHOOK }} 45 | -------------------------------------------------------------------------------- /charts/slack-operator/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | 3 | image: 4 | repository: stakater/slack-operator 5 | tag: v0.0.39 6 | pullPolicy: IfNotPresent 7 | imagePullSecrets: [] 8 | 9 | nameOverride: "" 10 | fullnameOverride: "" 11 | 12 | watchNamespaces: [] 13 | configSecretName: "slack-secret" 14 | 15 | # Webhook Configuration 16 | webhook: 17 | enabled: true 18 | 19 | service: 20 | type: ClusterIP 21 | port: 443 22 | 23 | # Monitoring Configuration 24 | serviceMonitor: 25 | enabled: false 26 | 27 | rbac: 28 | enabled: true 29 | allowProxyRole: true 30 | allowMetricsReaderRole: true 31 | allowLeaderElectionRole: true 32 | 33 | serviceAccount: 34 | create: true 35 | annotations: {} 36 | # If not set and create is true, a name is generated using the fullname template 37 | name: "controller-manager" 38 | 39 | resources: 40 | {} 41 | # limits: 42 | # cpu: 100m 43 | # memory: 128Mi 44 | # requests: 45 | # cpu: 100m 46 | # memory: 128Mi 47 | 48 | podAnnotations: {} 49 | 50 | podSecurityContext: 51 | runAsNonRoot: true 52 | 53 | securityContext: 54 | {} 55 | # capabilities: 56 | # drop: 57 | # - ALL 58 | # readOnlyRootFilesystem: true 59 | # runAsNonRoot: true 60 | # runAsUser: 1000 61 | 62 | nodeSelector: {} 63 | 64 | tolerations: [] 65 | 66 | affinity: {} 67 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 v1alpha1 contains API Schema definitions for the slack v1alpha1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=slack.stakater.com 20 | package v1alpha1 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: "slack.stakater.com", Version: "v1alpha1"} 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 | -------------------------------------------------------------------------------- /config/webhook/manifests.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: admissionregistration.k8s.io/v1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | creationTimestamp: null 7 | name: mutating-webhook-configuration 8 | webhooks: 9 | - admissionReviewVersions: 10 | - v1 11 | - v1beta1 12 | clientConfig: 13 | service: 14 | name: webhook-service 15 | namespace: system 16 | path: /mutate-slack-stakater-com-v1alpha1-channel 17 | failurePolicy: Fail 18 | name: mchannel.kb.io 19 | rules: 20 | - apiGroups: 21 | - slack.stakater.com 22 | apiVersions: 23 | - v1alpha1 24 | operations: 25 | - CREATE 26 | - UPDATE 27 | resources: 28 | - channels 29 | sideEffects: None 30 | 31 | --- 32 | apiVersion: admissionregistration.k8s.io/v1 33 | kind: ValidatingWebhookConfiguration 34 | metadata: 35 | creationTimestamp: null 36 | name: validating-webhook-configuration 37 | webhooks: 38 | - admissionReviewVersions: 39 | - v1 40 | - v1beta1 41 | clientConfig: 42 | service: 43 | name: webhook-service 44 | namespace: system 45 | path: /validate-slack-stakater-com-v1alpha1-channel 46 | failurePolicy: Fail 47 | name: vchannel.kb.io 48 | rules: 49 | - apiGroups: 50 | - slack.stakater.com 51 | apiVersions: 52 | - v1alpha1 53 | operations: 54 | - CREATE 55 | - UPDATE 56 | resources: 57 | - channels 58 | sideEffects: None 59 | -------------------------------------------------------------------------------- /config/scorecard/patches/olm.config.yaml: -------------------------------------------------------------------------------- 1 | - op: add 2 | path: /stages/0/tests/- 3 | value: 4 | entrypoint: 5 | - scorecard-test 6 | - olm-bundle-validation 7 | image: quay.io/operator-framework/scorecard-test:1.7.2 8 | labels: 9 | suite: olm 10 | test: olm-bundle-validation-test 11 | - op: add 12 | path: /stages/0/tests/- 13 | value: 14 | entrypoint: 15 | - scorecard-test 16 | - olm-crds-have-validation 17 | image: quay.io/operator-framework/scorecard-test:1.7.2 18 | labels: 19 | suite: olm 20 | test: olm-crds-have-validation-test 21 | - op: add 22 | path: /stages/0/tests/- 23 | value: 24 | entrypoint: 25 | - scorecard-test 26 | - olm-crds-have-resources 27 | image: quay.io/operator-framework/scorecard-test:1.7.2 28 | labels: 29 | suite: olm 30 | test: olm-crds-have-resources-test 31 | - op: add 32 | path: /stages/0/tests/- 33 | value: 34 | entrypoint: 35 | - scorecard-test 36 | - olm-spec-descriptors 37 | image: quay.io/operator-framework/scorecard-test:1.7.2 38 | labels: 39 | suite: olm 40 | test: olm-spec-descriptors-test 41 | - op: add 42 | path: /stages/0/tests/- 43 | value: 44 | entrypoint: 45 | - scorecard-test 46 | - olm-status-descriptors 47 | image: quay.io/operator-framework/scorecard-test:1.7.2 48 | labels: 49 | suite: olm 50 | test: olm-status-descriptors-test 51 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/clusterrole.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled -}} 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | name: {{ include "slack-operator.fullname" . }}-manager-role 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - secrets 12 | verbs: 13 | - get 14 | - list 15 | - apiGroups: 16 | - slack.stakater.com 17 | resources: 18 | - channels 19 | verbs: 20 | - create 21 | - delete 22 | - get 23 | - list 24 | - patch 25 | - update 26 | - watch 27 | - apiGroups: 28 | - slack.stakater.com 29 | resources: 30 | - channels/status 31 | verbs: 32 | - get 33 | - patch 34 | - update 35 | --- 36 | {{- if .Values.rbac.allowProxyRole }} 37 | apiVersion: rbac.authorization.k8s.io/v1 38 | kind: ClusterRole 39 | metadata: 40 | name: {{ include "slack-operator.fullname" . }}-proxy-role 41 | rules: 42 | - apiGroups: 43 | - authentication.k8s.io 44 | resources: 45 | - tokenreviews 46 | verbs: 47 | - create 48 | - apiGroups: 49 | - authorization.k8s.io 50 | resources: 51 | - subjectaccessreviews 52 | verbs: 53 | - create 54 | {{- end }} 55 | 56 | --- 57 | {{- if .Values.rbac.allowMetricsReaderRole }} 58 | apiVersion: rbac.authorization.k8s.io/v1 59 | kind: ClusterRole 60 | metadata: 61 | name: {{ include "slack-operator.fullname" . }}-metrics-reader 62 | rules: 63 | - nonResourceURLs: 64 | - /metrics 65 | verbs: 66 | - get 67 | {{- end }} 68 | 69 | {{- end }} 70 | -------------------------------------------------------------------------------- /api/v1alpha1/webhook_suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 v1alpha1 18 | 19 | import ( 20 | "testing" 21 | 22 | . "github.com/onsi/ginkgo" 23 | . "github.com/onsi/gomega" 24 | 25 | //+kubebuilder:scaffold:imports 26 | 27 | "sigs.k8s.io/controller-runtime/pkg/envtest/printer" 28 | logf "sigs.k8s.io/controller-runtime/pkg/log" 29 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 30 | ) 31 | 32 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 33 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 34 | 35 | func TestAPIs(t *testing.T) { 36 | RegisterFailHandler(Fail) 37 | 38 | RunSpecsWithDefaultAndCustomReporters(t, 39 | "Webhook Suite", 40 | []Reporter{printer.NewlineReporter{}}) 41 | } 42 | 43 | var _ = BeforeSuite(func() { 44 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 45 | 46 | }, 60) 47 | 48 | var _ = AfterSuite(func() { 49 | 50 | }) 51 | -------------------------------------------------------------------------------- /config/manifests/bases/slack-operator.clusterserviceversion.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: operators.coreos.com/v1alpha1 2 | kind: ClusterServiceVersion 3 | metadata: 4 | annotations: 5 | alm-examples: '[]' 6 | capabilities: Basic Install 7 | operators.operatorframework.io/builder: operator-sdk-v1.7.2 8 | operators.operatorframework.io/project_layout: go.kubebuilder.io/v3 9 | name: slack-operator.v0.0.0 10 | namespace: placeholder 11 | spec: 12 | apiservicedefinitions: {} 13 | customresourcedefinitions: 14 | owned: 15 | - description: Channel is the Schema for the channels API 16 | displayName: Channel 17 | kind: Channel 18 | name: channels.slack.stakater.com 19 | version: v1alpha1 20 | description: Kubernetes operator for Slack 21 | displayName: slack-operator 22 | icon: 23 | - base64data: "" 24 | mediatype: "" 25 | install: 26 | spec: 27 | deployments: null 28 | strategy: "" 29 | installModes: 30 | - supported: true 31 | type: OwnNamespace 32 | - supported: true 33 | type: SingleNamespace 34 | - supported: false 35 | type: MultiNamespace 36 | - supported: true 37 | type: AllNamespaces 38 | keywords: 39 | - operator 40 | - slack 41 | - kubernetes 42 | - channel 43 | - stakater 44 | - openshift 45 | links: 46 | - name: Slack Operator 47 | url: https://slack-operator.domain 48 | maturity: alpha 49 | provider: 50 | name: stakater 51 | url: https://stakater.com 52 | version: 0.0.0 53 | -------------------------------------------------------------------------------- /bundle/tests/scorecard/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: scorecard.operatorframework.io/v1 2 | kind: Configuration 3 | metadata: 4 | name: config 5 | stages: 6 | - parallel: true 7 | tests: 8 | - entrypoint: 9 | - scorecard-test 10 | - basic-check-spec 11 | image: quay.io/operator-framework/scorecard-test:1.7.2 12 | labels: 13 | suite: basic 14 | test: basic-check-spec-test 15 | - entrypoint: 16 | - scorecard-test 17 | - olm-bundle-validation 18 | image: quay.io/operator-framework/scorecard-test:1.7.2 19 | labels: 20 | suite: olm 21 | test: olm-bundle-validation-test 22 | - entrypoint: 23 | - scorecard-test 24 | - olm-crds-have-validation 25 | image: quay.io/operator-framework/scorecard-test:1.7.2 26 | labels: 27 | suite: olm 28 | test: olm-crds-have-validation-test 29 | - entrypoint: 30 | - scorecard-test 31 | - olm-crds-have-resources 32 | image: quay.io/operator-framework/scorecard-test:1.7.2 33 | labels: 34 | suite: olm 35 | test: olm-crds-have-resources-test 36 | - entrypoint: 37 | - scorecard-test 38 | - olm-spec-descriptors 39 | image: quay.io/operator-framework/scorecard-test:1.7.2 40 | labels: 41 | suite: olm 42 | test: olm-spec-descriptors-test 43 | - entrypoint: 44 | - scorecard-test 45 | - olm-status-descriptors 46 | image: quay.io/operator-framework/scorecard-test:1.7.2 47 | labels: 48 | suite: olm 49 | test: olm-status-descriptors-test 50 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/clusterrolebinding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled -}} 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRoleBinding 5 | metadata: 6 | name: {{ include "slack-operator.fullname" . }}-manager-rolebinding 7 | roleRef: 8 | apiGroup: rbac.authorization.k8s.io 9 | kind: ClusterRole 10 | name: {{ include "slack-operator.fullname" . }}-manager-role 11 | subjects: 12 | - kind: ServiceAccount 13 | name: {{ include "slack-operator.serviceAccountName" . }} 14 | namespace: {{ .Release.Namespace }} 15 | 16 | --- 17 | {{- if .Values.rbac.allowProxyRole }} 18 | apiVersion: rbac.authorization.k8s.io/v1 19 | kind: ClusterRoleBinding 20 | metadata: 21 | name: {{ include "slack-operator.fullname" . }}-proxy-rolebinding 22 | roleRef: 23 | apiGroup: rbac.authorization.k8s.io 24 | kind: ClusterRole 25 | name: {{ include "slack-operator.fullname" . }}-proxy-role 26 | subjects: 27 | - kind: ServiceAccount 28 | name: {{ include "slack-operator.serviceAccountName" . }} 29 | namespace: {{ .Release.Namespace }} 30 | {{- end }} 31 | 32 | --- 33 | {{- if .Values.rbac.allowMetricsReaderRole }} 34 | apiVersion: rbac.authorization.k8s.io/v1 35 | kind: ClusterRoleBinding 36 | metadata: 37 | name: {{ include "slack-operator.fullname" . }}-metrics-reader-rolebbinding 38 | roleRef: 39 | apiGroup: rbac.authorization.k8s.io 40 | kind: ClusterRole 41 | name: {{ include "slack-operator.fullname" . }}-metrics-reader 42 | subjects: 43 | - kind: ServiceAccount 44 | name: {{ include "slack-operator.serviceAccountName" . }} 45 | namespace: {{ .Release.Namespace }} 46 | {{- end }} 47 | 48 | {{- end }} 49 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: system 7 | --- 8 | apiVersion: apps/v1 9 | kind: Deployment 10 | metadata: 11 | name: controller-manager 12 | namespace: system 13 | labels: 14 | control-plane: controller-manager 15 | spec: 16 | selector: 17 | matchLabels: 18 | control-plane: controller-manager 19 | replicas: 1 20 | template: 21 | metadata: 22 | labels: 23 | control-plane: controller-manager 24 | spec: 25 | securityContext: 26 | runAsNonRoot: true 27 | containers: 28 | - command: 29 | - /manager 30 | args: 31 | - --leader-elect 32 | image: controller:latest 33 | name: manager 34 | securityContext: 35 | allowPrivilegeEscalation: false 36 | livenessProbe: 37 | httpGet: 38 | path: /healthz 39 | port: 8081 40 | initialDelaySeconds: 15 41 | periodSeconds: 20 42 | readinessProbe: 43 | httpGet: 44 | path: /readyz 45 | port: 8081 46 | initialDelaySeconds: 5 47 | periodSeconds: 10 48 | resources: 49 | limits: 50 | cpu: 100m 51 | memory: 30Mi 52 | requests: 53 | cpu: 100m 54 | memory: 20Mi 55 | env: 56 | - name: WATCH_NAMESPACE 57 | valueFrom: 58 | fieldRef: 59 | fieldPath: metadata.namespace 60 | - name: CONFIG_SECRET_NAME 61 | value: slack-secret 62 | serviceAccountName: controller-manager 63 | terminationGracePeriodSeconds: 10 64 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package pkgutil 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/stakater/slack-operator/pkg/config" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | ctrl "sigs.k8s.io/controller-runtime" 13 | 14 | k8sClient "sigs.k8s.io/controller-runtime/pkg/client" 15 | 16 | reconcilerUtil "github.com/stakater/operator-utils/util/reconciler" 17 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 18 | ) 19 | 20 | // MapErrorListToError maps multiple errors into a single error 21 | func MapErrorListToError(errs []error) error { 22 | 23 | if len(errs) == 0 { 24 | return nil 25 | } 26 | 27 | errMsg := []string{} 28 | for _, err := range errs { 29 | errMsg = append(errMsg, err.Error()) 30 | } 31 | 32 | return fmt.Errorf(strings.Join(errMsg, "\n")) 33 | } 34 | 35 | func ManageError(ctx context.Context, client k8sClient.Client, channelInstance *slackv1alpha1.Channel, issue error) (ctrl.Result, error) { 36 | 37 | // Base object for patch, which patches using the merge-patch strategy with the given object as base. 38 | channelInstancePatchBase := k8sClient.MergeFrom(channelInstance.DeepCopy()) 39 | 40 | // Update status 41 | channelInstance.Status.Conditions = []metav1.Condition{ 42 | { 43 | Type: "ReconcileError", 44 | LastTransitionTime: metav1.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), time.Now().Hour(), time.Now().Minute(), 0, 0, time.Now().Location()), 45 | Message: issue.Error(), 46 | Reason: reconcilerUtil.FailedReason, 47 | Status: metav1.ConditionTrue, 48 | }, 49 | } 50 | 51 | // Patch status 52 | err := client.Status().Patch(ctx, channelInstance, channelInstancePatchBase) 53 | if err != nil { 54 | return ctrl.Result{}, err 55 | } 56 | 57 | return reconcilerUtil.RequeueAfter(config.ErrorRequeueTime) 58 | } 59 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "slack-operator.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "slack-operator.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "slack-operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "slack-operator.labels" -}} 37 | helm.sh/chart: {{ include "slack-operator.chart" . }} 38 | {{ include "slack-operator.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "slack-operator.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "slack-operator.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "slack-operator.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "slack-operator.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slack-operator 2 | 3 | Kubernetes operator for Slack 4 | 5 | ## About 6 | 7 | Slack operator is used to automate the process of setting up a Slack channel for alertmanager in a k8s native way. By using CRDs it lets you: 8 | 9 | 1. Manage Channels 10 | 2. Configure Issues 11 | 12 | It uses [Slack Api](https://api.slack.com/methods) in it's underlying layer and can be extended to perform other tasks that are supported via the REST API. 13 | 14 | ## Usage 15 | 16 | ### Prerequisites 17 | 18 | - Slack account 19 | - API Token to access Slack API (https://api.slack.com/) 20 | 21 | ### Create secret 22 | 23 | Create the following secret which is required for slack-operator: 24 | 25 | ```yaml 26 | kind: Secret 27 | apiVersion: v1 28 | metadata: 29 | name: slack-secret 30 | type: Opaque 31 | data: 32 | APIToken: 33 | ``` 34 | 35 | ### Deploy operator 36 | 37 | - Make sure that [certman](https://cert-manager.io/) is deployed in your cluster since webhooks require certman to generate valid certs since webhooks serve using HTTPS 38 | - To install certman 39 | 40 | ```terminal 41 | $ kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.3.1/cert-manager.yaml 42 | ``` 43 | 44 | - Deploy operator 45 | 46 | ```terminal 47 | $ oc apply -f bundle/manifests 48 | ``` 49 | 50 | ## Local Development 51 | 52 | - [Operator-sdk v1.7.2](https://github.com/operator-framework/operator-sdk/releases/tag/v1.7.2) is required for local development. 53 | 54 | 1. Create `slack-secret` secret 55 | 2. Run `make run ENABLE_WEBHOOKS=false WATCH_NAMESPACE=default OPERATOR_NAMESPACE=default` where `WATCH_NAMESPACE` denotes the namespaces that the operator is supposed to watch and `OPERATOR_NAMESPACE` is the namespace in which it's supposed to be deployed. 56 | 57 | 3. Before committing your changes run the following to ensure that everything is verified and up-to-date: 58 | - `make verify` 59 | 60 | ## Running Tests 61 | 62 | ### Pre-requisites: 63 | 64 | 1. Create a namespace with the name `test` 65 | 2. Create `slack-secret` secret in test namespace 66 | 67 | ### To run tests: 68 | 69 | Use the following command to run tests: 70 | `make test OPERATOR_NAMESPACE=test USE_EXISTING_CLUSTER=true` 71 | -------------------------------------------------------------------------------- /api/v1alpha1/channel_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 v1alpha1 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | ) 22 | 23 | // ChannelSpec defines the desired state of Channel 24 | type ChannelSpec struct { 25 | // Name of the slack channel 26 | // +required 27 | Name string `json:"name"` 28 | 29 | // Make the channel private or public 30 | // +optional 31 | Private bool `json:"private,omitempty"` 32 | 33 | // List of user IDs of the users to invite 34 | // +kubebuilder:validation:MinItems=1 35 | // +required 36 | Users []string `json:"users"` 37 | 38 | // Description of the channel 39 | // +optional 40 | Description string `json:"description,omitempty"` 41 | 42 | // Topic of the channel 43 | // +optional 44 | Topic string `json:"topic,omitempty"` 45 | } 46 | 47 | // ChannelStatus defines the observed state of Channel 48 | type ChannelStatus struct { 49 | // ID of the slack channel 50 | ID string `json:"id"` 51 | 52 | // Status conditions 53 | Conditions []metav1.Condition `json:"conditions,omitempty"` 54 | } 55 | 56 | // +kubebuilder:object:root=true 57 | // +kubebuilder:subresource:status 58 | 59 | // Channel is the Schema for the channels API 60 | type Channel struct { 61 | metav1.TypeMeta `json:",inline"` 62 | metav1.ObjectMeta `json:"metadata,omitempty"` 63 | 64 | Spec ChannelSpec `json:"spec,omitempty"` 65 | Status ChannelStatus `json:"status,omitempty"` 66 | } 67 | 68 | // +kubebuilder:object:root=true 69 | 70 | // ChannelList contains a list of Channel 71 | type ChannelList struct { 72 | metav1.TypeMeta `json:",inline"` 73 | metav1.ListMeta `json:"metadata,omitempty"` 74 | Items []Channel `json:"items"` 75 | } 76 | 77 | func init() { 78 | SchemeBuilder.Register(&Channel{}, &ChannelList{}) 79 | } 80 | 81 | // GetReconcileStatus - returns conditions, required for making Channel ConditionsStatusAware 82 | func (channel *Channel) GetReconcileStatus() []metav1.Condition { 83 | return channel.Status.Conditions 84 | } 85 | 86 | // SetReconcileStatus - sets status, required for making Channel ConditionsStatusAware 87 | func (channel *Channel) SetReconcileStatus(reconcileStatus []metav1.Condition) { 88 | channel.Status.Conditions = reconcileStatus 89 | } 90 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: slack-operator-system 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: slack-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #commonLabels: 13 | # someName: someValue 14 | 15 | bases: 16 | - ../crd 17 | - ../rbac 18 | - ../manager 19 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 20 | # crd/kustomization.yaml 21 | - ../webhook 22 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 23 | - ../certmanager 24 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 25 | #- ../prometheus 26 | 27 | patchesStrategicMerge: 28 | # Protect the /metrics endpoint by putting it behind auth. 29 | # If you want your controller-manager to expose the /metrics 30 | # endpoint w/o any authn/z, please comment the following line. 31 | - manager_auth_proxy_patch.yaml 32 | 33 | # Mount the controller config file for loading manager configurations 34 | # through a ComponentConfig type 35 | #- manager_config_patch.yaml 36 | 37 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 38 | # crd/kustomization.yaml 39 | - manager_webhook_patch.yaml 40 | 41 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 42 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 43 | # 'CERTMANAGER' needs to be enabled to use ca injection 44 | - webhookcainjection_patch.yaml 45 | 46 | # the following config is for teaching kustomize how to do var substitution 47 | vars: 48 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 49 | - name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 50 | objref: 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 | fieldref: 56 | fieldpath: metadata.namespace 57 | - name: CERTIFICATE_NAME 58 | objref: 59 | kind: Certificate 60 | group: cert-manager.io 61 | version: v1 62 | name: serving-cert # this name should match the one in certificate.yaml 63 | - name: SERVICE_NAMESPACE # namespace of the service 64 | objref: 65 | kind: Service 66 | version: v1 67 | name: webhook-service 68 | fieldref: 69 | fieldpath: metadata.namespace 70 | - name: SERVICE_NAME 71 | objref: 72 | kind: Service 73 | version: v1 74 | name: webhook-service -------------------------------------------------------------------------------- /pkg/slack/service_test.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stakater/slack-operator/pkg/slack/mock" 8 | "github.com/stretchr/testify/assert" 9 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 10 | ) 11 | 12 | var log = zap.New() 13 | 14 | func TestSlackService_CreateChannel_shouldCreatePublicChannel_whenPrivateIsFalse(t *testing.T) { 15 | s := NewMockService(log) 16 | 17 | id, err := s.CreateChannel("my-channel", false) 18 | 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | assert.Equal(t, mock.PublicConversationID, *id) 24 | } 25 | 26 | func TestSlackService_CreateChannel_shouldCreatePrivateChannel_whenPrivateIsTrue(t *testing.T) { 27 | s := NewMockService(log) 28 | 29 | id, err := s.CreateChannel("my-channel", true) 30 | 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | assert.Equal(t, mock.PrivateConversationID, *id) 36 | } 37 | 38 | func TestSlackService_CreateChannel_shouldThrowError_whenChannelWithSameNameExists(t *testing.T) { 39 | s := NewMockService(log) 40 | 41 | _, err := s.CreateChannel(mock.NameTakenConversationName, true) 42 | 43 | assert.EqualError(t, err, "name_taken") 44 | } 45 | 46 | func TestSlackService_SetDescription_shouldSetPurpose(t *testing.T) { 47 | s := NewMockService(log) 48 | 49 | channel, err := s.SetDescription(mock.PublicConversationID, "myDescription") 50 | assert.NoError(t, err) 51 | assert.Equal(t, "myDescription", channel.Purpose.Value) 52 | } 53 | 54 | func TestSlackService_SetTopic_shouldSetTopic(t *testing.T) { 55 | s := NewMockService(log) 56 | channel, err := s.SetTopic(mock.PublicConversationID, "myTopic") 57 | assert.NoError(t, err) 58 | assert.Equal(t, "myTopic", channel.Topic.Value) 59 | } 60 | 61 | func TestSlackService_RenameChannel_shouldSetNewName(t *testing.T) { 62 | s := NewMockService(log) 63 | channel, err := s.RenameChannel(mock.PublicConversationID, "new-channel") 64 | assert.NoError(t, err) 65 | assert.Equal(t, "new-channel", channel.Name) 66 | } 67 | 68 | func TestSlackService_ArchiveChannel_shouldArchiveChannel(t *testing.T) { 69 | s := NewMockService(log) 70 | err := s.ArchiveChannel(mock.PublicConversationID) 71 | assert.NoError(t, err) 72 | } 73 | 74 | func TestSlackService_ArchiveChannel_shouldThrowError_whenChannelNotFound(t *testing.T) { 75 | s := NewMockService(log) 76 | err := s.ArchiveChannel(mock.NotFoundConversationID) 77 | assert.EqualError(t, err, "channel_not_found") 78 | } 79 | 80 | func TestSlackService_InviteUsers_shouldSendUserInvites_whenUserExists(t *testing.T) { 81 | s := NewMockService(log) 82 | errs := s.InviteUsers(mock.PublicConversationID, []string{mock.ExistingUserEmail}) 83 | assert.Equal(t, 0, len(errs)) 84 | } 85 | 86 | func TestSlackService_InviteUsers_shouldThowError_whenUserDoesNotExists(t *testing.T) { 87 | s := NewMockService(log) 88 | emailList := []string{"spengler@ghostbusters.example.com"} 89 | errs := s.InviteUsers(mock.PublicConversationID, emailList) 90 | assert.Equal(t, 1, len(errs)) 91 | assert.EqualError(t, errs[0], fmt.Sprintf("Error fetching user by Email %s", emailList[0])) 92 | } 93 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "time" 7 | 8 | util "github.com/stakater/operator-utils/util" 9 | secretsUtil "github.com/stakater/operator-utils/util/secrets" 10 | "gopkg.in/yaml.v2" 11 | ctrl "sigs.k8s.io/controller-runtime" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 14 | ) 15 | 16 | const ( 17 | ErrorRequeueTime = 15 * time.Minute 18 | 19 | SlackDefaultSecretName string = "slack-secret" 20 | SlackAPITokenSecretKey string = "APIToken" 21 | ) 22 | 23 | var ( 24 | setupLog = ctrl.Log.WithName("setup") 25 | SlackSecretName string = getConfigSecretName() 26 | ) 27 | 28 | // Config struct for operator config yaml 29 | type Config struct { 30 | Slack Slack `yaml:"slack"` 31 | } 32 | 33 | // Slack for config yaml structure 34 | type Slack struct { 35 | APIToken APIToken `yaml:"APIToken"` 36 | } 37 | 38 | // APIToken for config yaml structure 39 | type APIToken struct { 40 | SecretName string `yaml:"secretName"` 41 | Key string `yaml:"key"` 42 | } 43 | 44 | var log = zap.New() 45 | 46 | func readConfig(filePath string) (*Config, error) { 47 | var config Config 48 | 49 | // Read YML 50 | source, err := ioutil.ReadFile(filePath) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | // Unmarshall 56 | err = yaml.Unmarshal(source, &config) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return &config, nil 62 | } 63 | 64 | // GetOperatorConfig returns the config object for the operator 65 | func GetOperatorConfig() (*Config, error) { 66 | configFilePath := os.Getenv("CONFIG_FILE_PATH") 67 | if len(configFilePath) == 0 { 68 | configFilePath = "config/operator/default-config.yaml" 69 | } 70 | 71 | log.Info("Reading config file", "configFilePath", configFilePath) 72 | config, err := readConfig(configFilePath) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return config, nil 77 | } 78 | 79 | func getConfigSecretName() string { 80 | configSecretName, _ := os.LookupEnv("CONFIG_SECRET_NAME") 81 | if len(configSecretName) == 0 { 82 | configSecretName = SlackDefaultSecretName 83 | setupLog.Info("CONFIG_SECRET_NAME is unset, using default value: " + SlackDefaultSecretName) 84 | } 85 | return configSecretName 86 | } 87 | 88 | func ReadSlackTokenSecret(k8sReader client.Reader) string { 89 | operatorNamespace, _ := os.LookupEnv("OPERATOR_NAMESPACE") 90 | if len(operatorNamespace) == 0 { 91 | operatorNamespaceTemp, err := util.GetOperatorNamespace() 92 | if err != nil { 93 | setupLog.Error(err, "Unable to get operator namespace") 94 | os.Exit(1) 95 | } 96 | operatorNamespace = operatorNamespaceTemp 97 | } 98 | 99 | token, err := secretsUtil.LoadSecretData(k8sReader, SlackSecretName, operatorNamespace, SlackAPITokenSecretKey) 100 | if err != nil { 101 | setupLog.Error(err, "Could not read API token from key", "secretName", SlackSecretName, "secretKey", SlackAPITokenSecretKey) 102 | os.Exit(1) 103 | } 104 | 105 | return token 106 | } 107 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | env: 9 | DOCKER_FILE_PATH: Dockerfile 10 | GOLANG_VERSION: 1.16 11 | KUBERNETES_VERSION: "1.20.2" 12 | KIND_VERSION: "0.10.0" 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | name: Build 18 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 19 | steps: 20 | - name: Check out code 21 | uses: actions/checkout@v2 22 | with: 23 | ref: ${{github.event.pull_request.head.sha}} 24 | 25 | - name: Set up Go 26 | id: go 27 | uses: actions/setup-go@v2 28 | with: 29 | go-version: ${{ env.GOLANG_VERSION }} 30 | 31 | - name: Lint 32 | uses: golangci/golangci-lint-action@v3 33 | with: 34 | version: v1.50.0 35 | only-new-issues: false 36 | args: --timeout 10m 37 | 38 | - name: Install kubectl 39 | run: | 40 | curl -LO "https://storage.googleapis.com/kubernetes-release/release/v${KUBERNETES_VERSION}/bin/linux/amd64/kubectl" 41 | sudo install ./kubectl /usr/local/bin/ && rm kubectl 42 | kubectl version --short --client 43 | kubectl version --short --client | grep -q ${KUBERNETES_VERSION} 44 | 45 | - name: Install Kind 46 | run: | 47 | curl -L -o kind https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/kind-linux-amd64 48 | sudo install ./kind /usr/local/bin && rm kind 49 | kind version 50 | kind version | grep -q ${KIND_VERSION} 51 | 52 | - name: Create Kind Cluster 53 | run: | 54 | kind create cluster 55 | 56 | - name: Set up Cluster 57 | run: | 58 | kubectl cluster-info 59 | kubectl create namespace test 60 | 61 | - name: Test 62 | run: make test OPERATOR_NAMESPACE=test USE_EXISTING_CLUSTER=true 63 | 64 | - name: Generate Tag 65 | id: generate_tag 66 | run: | 67 | sha=${{ github.event.pull_request.head.sha }} 68 | tag="SNAPSHOT-PR-${{ github.event.pull_request.number }}-${sha:0:8}" 69 | echo "##[set-output name=GIT_TAG;]$(echo ${tag})" 70 | 71 | - name: Set up QEMU 72 | uses: docker/setup-qemu-action@v1 73 | 74 | - name: Set up Docker Buildx 75 | uses: docker/setup-buildx-action@v1 76 | 77 | - name: Generate image repository path 78 | run: | 79 | echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 80 | 81 | - name: Build and Push Docker Image 82 | uses: docker/build-push-action@v2 83 | with: 84 | context: . 85 | file: ${{ env.DOCKER_FILE_PATH }} 86 | pull: true 87 | push: false 88 | build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} 89 | cache-to: type=inline 90 | tags: | 91 | ${{ env.IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.GIT_TAG }} 92 | labels: | 93 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 94 | org.opencontainers.image.revision=${{ github.sha }} 95 | -------------------------------------------------------------------------------- /controllers/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 controllers 18 | 19 | import ( 20 | "context" 21 | "flag" 22 | "path/filepath" 23 | "testing" 24 | 25 | "github.com/go-logr/logr" 26 | . "github.com/onsi/ginkgo" 27 | . "github.com/onsi/gomega" 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 | "sigs.k8s.io/controller-runtime/pkg/envtest/printer" 33 | logf "sigs.k8s.io/controller-runtime/pkg/log" 34 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 35 | 36 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 37 | controllerUtil "github.com/stakater/slack-operator/controllers/util" 38 | "github.com/stakater/slack-operator/pkg/slack" 39 | // +kubebuilder:scaffold:imports 40 | ) 41 | 42 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 43 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 44 | 45 | var cfg *rest.Config 46 | var k8sClient client.Client 47 | var testEnv *envtest.Environment 48 | 49 | var ctx context.Context 50 | var r *ChannelReconciler 51 | var util *controllerUtil.TestUtil 52 | var ns = "test" 53 | 54 | var log logr.Logger 55 | 56 | func TestAPIs(t *testing.T) { 57 | RegisterFailHandler(Fail) 58 | 59 | RunSpecsWithDefaultAndCustomReporters(t, 60 | "Controller Suite", 61 | []Reporter{printer.NewlineReporter{}}) 62 | } 63 | 64 | var _ = BeforeSuite(func(done Done) { 65 | opts := zap.Options{ 66 | Development: true, 67 | } 68 | opts.BindFlags(flag.CommandLine) 69 | log = zap.New(zap.UseFlagOptions(&opts), zap.WriteTo(GinkgoWriter)) 70 | logf.SetLogger(log) 71 | 72 | By("bootstrapping test environment") 73 | testEnv = &envtest.Environment{ 74 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 75 | } 76 | 77 | var err error 78 | cfg, err = testEnv.Start() 79 | Expect(err).ToNot(HaveOccurred()) 80 | Expect(cfg).ToNot(BeNil()) 81 | 82 | err = slackv1alpha1.AddToScheme(scheme.Scheme) 83 | Expect(err).NotTo(HaveOccurred()) 84 | 85 | // +kubebuilder:scaffold:scheme 86 | 87 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 88 | 89 | Expect(err).ToNot(HaveOccurred()) 90 | Expect(k8sClient).ToNot(BeNil()) 91 | 92 | ctx = context.Background() 93 | 94 | r = &ChannelReconciler{ 95 | Client: k8sClient, 96 | Scheme: scheme.Scheme, 97 | Log: log.WithName("Reconciler"), 98 | SlackService: slack.NewMockService(log.WithName("SlackTestServer")), 99 | } 100 | Expect(r).ToNot((BeNil())) 101 | 102 | util = controllerUtil.New(ctx, k8sClient, r) 103 | Expect(util).ToNot(BeNil()) 104 | 105 | close(done) 106 | }, 60) 107 | 108 | var _ = AfterSuite(func() { 109 | // Remove remnent resources 110 | util.DeleteAllSlackChannels(ns) 111 | 112 | By("tearing down the test environment") 113 | err := testEnv.Stop() 114 | Expect(err).ToNot(HaveOccurred()) 115 | }) 116 | -------------------------------------------------------------------------------- /api/v1alpha1/channel_webhook.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 v1alpha1 18 | 19 | import ( 20 | "fmt" 21 | 22 | "k8s.io/apimachinery/pkg/runtime" 23 | ctrl "sigs.k8s.io/controller-runtime" 24 | logf "sigs.k8s.io/controller-runtime/pkg/log" 25 | "sigs.k8s.io/controller-runtime/pkg/webhook" 26 | ) 27 | 28 | // log is for logging in this package. 29 | var channellog = logf.Log.WithName("channel-resource") 30 | 31 | func (r *Channel) SetupWebhookWithManager(mgr ctrl.Manager) error { 32 | return ctrl.NewWebhookManagedBy(mgr). 33 | For(r). 34 | Complete() 35 | } 36 | 37 | // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! 38 | 39 | // +kubebuilder:webhook:path=/mutate-slack-stakater-com-v1alpha1-channel,mutating=true,failurePolicy=fail,sideEffects=None,groups=slack.stakater.com,resources=channels,verbs=create;update,versions=v1alpha1,name=mchannel.kb.io,admissionReviewVersions={v1,v1beta1} 40 | 41 | var _ webhook.Defaulter = &Channel{} 42 | 43 | // Default implements webhook.Defaulter so a webhook will be registered for the type 44 | func (r *Channel) Default() { 45 | channellog.Info("default", "name", r.Name) 46 | 47 | // TODO(user): fill in your defaulting logic. 48 | } 49 | 50 | // TODO(user): change verbs to "verbs=create;update;delete" if you want to enable deletion validation. 51 | // +kubebuilder:webhook:verbs=create;update,path=/validate-slack-stakater-com-v1alpha1-channel,mutating=false,failurePolicy=fail,sideEffects=None,groups=slack.stakater.com,resources=channels,versions=v1alpha1,name=vchannel.kb.io,admissionReviewVersions={v1,v1beta1} 52 | 53 | var _ webhook.Validator = &Channel{} 54 | 55 | // ValidateCreate implements webhook.Validator so a webhook will be registered for the type 56 | func (r *Channel) ValidateCreate() error { 57 | channellog.Info("validate create", "name", r.Name) 58 | 59 | if len(r.Spec.Users) < 1 { 60 | return fmt.Errorf("Users can not be empty") 61 | } 62 | 63 | return nil 64 | } 65 | 66 | // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type 67 | func (r *Channel) ValidateUpdate(old runtime.Object) error { 68 | channellog.Info("validate update", "name", r.Name) 69 | 70 | oldChannel, ok := old.(*Channel) 71 | if !ok { 72 | return fmt.Errorf("Error casting old runtime object to %T from %T", oldChannel, old) 73 | } 74 | 75 | if len(r.Spec.Users) < 1 { 76 | return fmt.Errorf("Users can not be empty") 77 | } 78 | 79 | return ValidateImmutableFields(r, oldChannel) 80 | } 81 | 82 | // ValidateDelete implements webhook.Validator so a webhook will be registered for the type 83 | func (r *Channel) ValidateDelete() error { 84 | channellog.Info("validate delete", "name", r.Name) 85 | 86 | // TODO(user): fill in your validation logic upon object deletion. 87 | return nil 88 | } 89 | 90 | func ValidateImmutableFields(newChannel *Channel, oldChannel *Channel) error { 91 | if oldChannel.Spec.Private != newChannel.Spec.Private { 92 | return fmt.Errorf("Field 'isPrivate' is immutable and cannot be changed after Slack Channel has been created") 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /charts/slack-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "slack-operator.fullname" . }} 5 | namespace: {{ .Release.Namespace }} 6 | labels: 7 | {{- include "slack-operator.labels" . | nindent 4 }} 8 | control-plane: controller-manager 9 | spec: 10 | replicas: {{ .Values.replicaCount }} 11 | selector: 12 | matchLabels: 13 | {{- include "slack-operator.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "slack-operator.selectorLabels" . | nindent 8 }} 22 | spec: 23 | {{- with .Values.imagePullSecrets }} 24 | imagePullSecrets: 25 | {{- toYaml . | nindent 8 }} 26 | {{- end }} 27 | serviceAccountName: {{ include "slack-operator.serviceAccountName" . }} 28 | securityContext: 29 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 30 | containers: 31 | - args: 32 | - --secure-listen-address=0.0.0.0:8443 33 | - --upstream=http://127.0.0.1:8080/ 34 | - --logtostderr=true 35 | - --v=10 36 | image: registry.redhat.io/openshift4/ose-kube-rbac-proxy:v4.7.0 37 | imagePullPolicy: {{ .Values.image.pullPolicy }} 38 | name: kube-rbac-proxy 39 | ports: 40 | - containerPort: 8443 41 | name: https 42 | - args: 43 | - --health-probe-bind-address=:8081 44 | - --metrics-bind-address=127.0.0.1:8080 45 | - --leader-elect 46 | command: 47 | - /manager 48 | env: 49 | - name: WATCH_NAMESPACE 50 | value: {{ .Values.watchNamespaces | join "," | quote }} 51 | - name: CONFIG_SECRET_NAME 52 | value: "{{ default "slack-secret" .Values.configSecretName }}" 53 | - name: ENABLE_WEBHOOKS 54 | value: "{{ default true .Values.webhook.enabled }}" 55 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 56 | imagePullPolicy: {{ .Values.image.pullPolicy }} 57 | securityContext: 58 | allowPrivilegeEscalation: false 59 | name: manager 60 | livenessProbe: 61 | httpGet: 62 | path: /healthz 63 | port: 8081 64 | initialDelaySeconds: 15 65 | periodSeconds: 20 66 | name: manager 67 | readinessProbe: 68 | httpGet: 69 | path: /readyz 70 | port: 8081 71 | initialDelaySeconds: 5 72 | periodSeconds: 10 73 | ports: 74 | - containerPort: 9443 75 | name: webhook-server 76 | protocol: TCP 77 | resources: 78 | {{- toYaml .Values.resources | nindent 12 }} 79 | volumeMounts: 80 | - mountPath: /tmp/k8s-webhook-server/serving-certs 81 | name: cert 82 | readOnly: true 83 | terminationGracePeriodSeconds: 10 84 | volumes: 85 | - name: cert 86 | secret: 87 | defaultMode: 420 88 | secretName: webhook-server-cert 89 | {{- with .Values.nodeSelector }} 90 | nodeSelector: 91 | {{- toYaml . | nindent 8 }} 92 | {{- end }} 93 | {{- with .Values.affinity }} 94 | affinity: 95 | {{- toYaml . | nindent 8 }} 96 | {{- end }} 97 | {{- with .Values.tolerations }} 98 | tolerations: 99 | {{- toYaml . | nindent 8 }} 100 | {{- end }} 101 | --- -------------------------------------------------------------------------------- /api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | // +build !ignore_autogenerated 2 | 3 | /* 4 | 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package v1alpha1 22 | 23 | import ( 24 | "k8s.io/apimachinery/pkg/apis/meta/v1" 25 | "k8s.io/apimachinery/pkg/runtime" 26 | ) 27 | 28 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 29 | func (in *Channel) DeepCopyInto(out *Channel) { 30 | *out = *in 31 | out.TypeMeta = in.TypeMeta 32 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 33 | in.Spec.DeepCopyInto(&out.Spec) 34 | in.Status.DeepCopyInto(&out.Status) 35 | } 36 | 37 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Channel. 38 | func (in *Channel) DeepCopy() *Channel { 39 | if in == nil { 40 | return nil 41 | } 42 | out := new(Channel) 43 | in.DeepCopyInto(out) 44 | return out 45 | } 46 | 47 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 48 | func (in *Channel) DeepCopyObject() runtime.Object { 49 | if c := in.DeepCopy(); c != nil { 50 | return c 51 | } 52 | return nil 53 | } 54 | 55 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 56 | func (in *ChannelList) DeepCopyInto(out *ChannelList) { 57 | *out = *in 58 | out.TypeMeta = in.TypeMeta 59 | in.ListMeta.DeepCopyInto(&out.ListMeta) 60 | if in.Items != nil { 61 | in, out := &in.Items, &out.Items 62 | *out = make([]Channel, len(*in)) 63 | for i := range *in { 64 | (*in)[i].DeepCopyInto(&(*out)[i]) 65 | } 66 | } 67 | } 68 | 69 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChannelList. 70 | func (in *ChannelList) DeepCopy() *ChannelList { 71 | if in == nil { 72 | return nil 73 | } 74 | out := new(ChannelList) 75 | in.DeepCopyInto(out) 76 | return out 77 | } 78 | 79 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 80 | func (in *ChannelList) DeepCopyObject() runtime.Object { 81 | if c := in.DeepCopy(); c != nil { 82 | return c 83 | } 84 | return nil 85 | } 86 | 87 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 88 | func (in *ChannelSpec) DeepCopyInto(out *ChannelSpec) { 89 | *out = *in 90 | if in.Users != nil { 91 | in, out := &in.Users, &out.Users 92 | *out = make([]string, len(*in)) 93 | copy(*out, *in) 94 | } 95 | } 96 | 97 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChannelSpec. 98 | func (in *ChannelSpec) DeepCopy() *ChannelSpec { 99 | if in == nil { 100 | return nil 101 | } 102 | out := new(ChannelSpec) 103 | in.DeepCopyInto(out) 104 | return out 105 | } 106 | 107 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 108 | func (in *ChannelStatus) DeepCopyInto(out *ChannelStatus) { 109 | *out = *in 110 | if in.Conditions != nil { 111 | in, out := &in.Conditions, &out.Conditions 112 | *out = make([]v1.Condition, len(*in)) 113 | for i := range *in { 114 | (*in)[i].DeepCopyInto(&(*out)[i]) 115 | } 116 | } 117 | } 118 | 119 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChannelStatus. 120 | func (in *ChannelStatus) DeepCopy() *ChannelStatus { 121 | if in == nil { 122 | return nil 123 | } 124 | out := new(ChannelStatus) 125 | in.DeepCopyInto(out) 126 | return out 127 | } 128 | -------------------------------------------------------------------------------- /controllers/util/testUtil.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | 9 | ginkgo "github.com/onsi/ginkgo" 10 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 11 | mockdata "github.com/stakater/slack-operator/pkg/slack/mock" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 16 | ) 17 | 18 | // TestUtil contains necessary objects required to perform operations during tests 19 | type TestUtil struct { 20 | ctx context.Context 21 | k8sClient client.Client 22 | r reconcile.Reconciler 23 | } 24 | 25 | // New creates new TestUtil 26 | func New(ctx context.Context, k8sClient client.Client, r reconcile.Reconciler) *TestUtil { 27 | return &TestUtil{ 28 | ctx: ctx, 29 | k8sClient: k8sClient, 30 | r: r, 31 | } 32 | } 33 | 34 | // CreateChannel creates and submits a Slack Channel object to the kubernetes server 35 | func (t *TestUtil) CreateChannel(name string, isPrivate bool, topic string, description string, users []string, namespace string) *slackv1alpha1.Channel { 36 | channelObject := t.CreateSlackChannelObject(name, isPrivate, topic, description, users, namespace) 37 | err := t.k8sClient.Create(t.ctx, channelObject) 38 | 39 | if err != nil { 40 | ginkgo.Fail(err.Error()) 41 | } 42 | 43 | req := reconcile.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}} 44 | ctx := context.Background() 45 | 46 | _, err = t.r.Reconcile(ctx, req) 47 | if err != nil { 48 | ginkgo.Fail(err.Error()) 49 | } 50 | 51 | return channelObject 52 | } 53 | 54 | // GetChannel fetches a channel object from kubernetes 55 | func (t *TestUtil) GetChannel(name string, namespace string) *slackv1alpha1.Channel { 56 | channelObject := &slackv1alpha1.Channel{} 57 | err := t.k8sClient.Get(t.ctx, types.NamespacedName{Name: name, Namespace: namespace}, channelObject) 58 | 59 | if err != nil { 60 | ginkgo.Fail(err.Error()) 61 | } 62 | 63 | return channelObject 64 | } 65 | 66 | // DeleteChannel deletes the channel resource 67 | func (t *TestUtil) DeleteChannel(name string, namespace string) { 68 | channelObject := &slackv1alpha1.Channel{} 69 | err := t.k8sClient.Get(t.ctx, types.NamespacedName{Name: name, Namespace: namespace}, channelObject) 70 | 71 | if err != nil { 72 | ginkgo.Fail(err.Error()) 73 | } 74 | 75 | err = t.k8sClient.Delete(t.ctx, channelObject) 76 | 77 | if err != nil { 78 | ginkgo.Fail(err.Error()) 79 | } 80 | 81 | req := reconcile.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}} 82 | ctx := context.Background() 83 | 84 | _, err = t.r.Reconcile(ctx, req) 85 | if err != nil { 86 | ginkgo.Fail(err.Error()) 87 | } 88 | } 89 | 90 | // TryDeleteChannel - Tries to delete channel if it exists, does not fail on any error 91 | func (t *TestUtil) TryDeleteChannel(name string, namespace string) { 92 | channelObject := &slackv1alpha1.Channel{} 93 | _ = t.k8sClient.Get(t.ctx, types.NamespacedName{Name: name, Namespace: namespace}, channelObject) 94 | _ = t.k8sClient.Delete(t.ctx, channelObject) 95 | req := reconcile.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}} 96 | ctx := context.Background() 97 | 98 | _, _ = t.r.Reconcile(ctx, req) 99 | } 100 | 101 | // DeleteAllSlackChannels delete all the slack channels in the namespace 102 | func (t *TestUtil) DeleteAllSlackChannels(namespace string) { 103 | // Specify namespace in list Options 104 | listOptions := &client.ListOptions{Namespace: namespace} 105 | 106 | // List channels in a specified namespace 107 | channelList := &slackv1alpha1.ChannelList{} 108 | err := t.k8sClient.List(context.TODO(), channelList, listOptions) 109 | if err != nil { 110 | ginkgo.Fail(err.Error()) 111 | } 112 | 113 | for _, channel := range channelList.Items { 114 | channel.Finalizers = []string{} 115 | 116 | err := t.k8sClient.Update(t.ctx, &channel) 117 | if err != nil { 118 | if err.Error() == fmt.Sprintf(mockdata.ChannelObjectModifiedError, channel.Name) { 119 | currentChannel := t.GetChannel(channel.Name, namespace) 120 | currentChannel.Finalizers = []string{} 121 | if err != nil { 122 | ginkgo.Fail(err.Error()) 123 | } 124 | } else { 125 | ginkgo.Fail(err.Error()) 126 | } 127 | } 128 | 129 | t.TryDeleteChannel(channel.Name, namespace) 130 | } 131 | } 132 | 133 | // CreateSlackChannelObject creates a slack channel custom resource object 134 | func (t *TestUtil) CreateSlackChannelObject(name string, isPrivate bool, topic string, description string, users []string, namespace string) *slackv1alpha1.Channel { 135 | return &slackv1alpha1.Channel{ 136 | ObjectMeta: metav1.ObjectMeta{ 137 | Name: name, 138 | Namespace: namespace, 139 | }, 140 | Spec: slackv1alpha1.ChannelSpec{ 141 | Name: name, 142 | Private: isPrivate, 143 | Topic: topic, 144 | Description: description, 145 | Users: users, 146 | }, 147 | } 148 | } 149 | 150 | // RandSeq Generates a letter sequence with `n` characters 151 | func (t *TestUtil) RandSeq(n int) string { 152 | letters := []rune("abcdefghijklmnopqrstuvwxyz") 153 | 154 | rand.Seed(time.Now().UnixNano()) 155 | b := make([]rune, n) 156 | for i := range b { 157 | b[i] = letters[rand.Intn(len(letters))] 158 | } 159 | return string(b) 160 | } 161 | -------------------------------------------------------------------------------- /controllers/channel_controller_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 10 | "github.com/stakater/slack-operator/pkg/slack/mock" 11 | slackMock "github.com/stakater/slack-operator/pkg/slack/mock" 12 | "k8s.io/apimachinery/pkg/types" 13 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 14 | ) 15 | 16 | var _ = Describe("ChannelController", func() { 17 | 18 | var channelName string 19 | 20 | BeforeEach(func() { 21 | channelName = util.RandSeq(10) 22 | }) 23 | 24 | AfterEach(func() { 25 | util.TryDeleteChannel(channelName, ns) 26 | }) 27 | 28 | Describe("Creating SlackChannel resource", func() { 29 | Context("With required fields", func() { 30 | It("should set status.ID to public channel ID", func() { 31 | _ = util.CreateChannel(channelName, false, "", "", []string{mock.ExistingUserEmail}, ns) 32 | channel := util.GetChannel(channelName, ns) 33 | 34 | Expect(channel.Status.ID).To(Equal(slackMock.PublicConversationID)) 35 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 36 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 37 | }) 38 | }) 39 | 40 | Context("With private field true", func() { 41 | It("should set status.ID status to private channel ID and not set status.Error", func() { 42 | _ = util.CreateChannel(channelName, true, "", "", []string{mock.ExistingUserEmail}, ns) 43 | channel := util.GetChannel(channelName, ns) 44 | 45 | Expect(channel.Status.ID).To(Equal(slackMock.PrivateConversationID)) 46 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 47 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 48 | }) 49 | }) 50 | 51 | Context("With description", func() { 52 | It("should set channel description", func() { 53 | description := "my description" 54 | 55 | _ = util.CreateChannel(channelName, true, "", description, []string{mock.ExistingUserEmail}, ns) 56 | channel := util.GetChannel(channelName, ns) 57 | 58 | Expect(channel.Spec.Description).To(Equal(description)) 59 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 60 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 61 | }) 62 | }) 63 | 64 | Context("With topic", func() { 65 | It("should set channel topic", func() { 66 | topic := "topic of the channel" 67 | 68 | _ = util.CreateChannel(channelName, true, topic, "", []string{mock.ExistingUserEmail}, ns) 69 | channel := util.GetChannel(channelName, ns) 70 | 71 | Expect(channel.Spec.Topic).To(Equal(topic)) 72 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 73 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 74 | }) 75 | }) 76 | 77 | Context("With user emails", func() { 78 | It("should set success condition when user exists", func() { 79 | 80 | _ = util.CreateChannel(channelName, true, "", "", []string{mock.ExistingUserEmail}, ns) 81 | channel := util.GetChannel(channelName, ns) 82 | 83 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 84 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 85 | }) 86 | 87 | It("should set error condition when user does not exists", func() { 88 | emailList := []string{"nonexistent@slack.com"} 89 | _ = util.CreateChannel(channelName, true, "", "", emailList, ns) 90 | channel := util.GetChannel(channelName, ns) 91 | 92 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 93 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Failed")) 94 | Expect(channel.Status.Conditions[0].Message).To(Equal(fmt.Sprintf("Error fetching user by Email %s", emailList[0]))) 95 | }) 96 | }) 97 | }) 98 | 99 | Describe("Updating SlackChannel resource", func() { 100 | Context("With new name", func() { 101 | It("should assign new name to channel", func() { 102 | _ = util.CreateChannel(channelName, false, "", "", []string{mock.ExistingUserEmail}, ns) 103 | channel := util.GetChannel(channelName, ns) 104 | 105 | newName := "old-channel-new-name" 106 | channel.Spec.Name = newName 107 | err := k8sClient.Update(ctx, channel) 108 | 109 | if err != nil { 110 | Fail(err.Error()) 111 | } 112 | 113 | req := reconcile.Request{NamespacedName: types.NamespacedName{Name: channelName, Namespace: ns}} 114 | ctx := context.Background() 115 | _, err = r.Reconcile(ctx, req) 116 | if err != nil { 117 | Fail(err.Error()) 118 | } 119 | 120 | updatedChannel := util.GetChannel(channelName, ns) 121 | 122 | Expect(updatedChannel.Spec.Name).To(Equal(newName)) 123 | 124 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 125 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 126 | }) 127 | }) 128 | }) 129 | 130 | Describe("Deleting SlackChannel resource", func() { 131 | Context("When Channel on slack was created", func() { 132 | It("should remove resource and delete channel ", func() { 133 | _ = util.CreateChannel(channelName, false, "", "", []string{mock.ExistingUserEmail}, ns) 134 | channel := util.GetChannel(channelName, ns) 135 | 136 | Expect(channel.Status.ID).ToNot(BeEmpty()) 137 | Expect(len(channel.Status.Conditions)).To(Equal(1)) 138 | Expect(channel.Status.Conditions[0].Reason).To(Equal("Successful")) 139 | 140 | util.DeleteChannel(channelName, ns) 141 | 142 | channelObject := &slackv1alpha1.Channel{} 143 | err := k8sClient.Get(ctx, types.NamespacedName{Name: channelName, Namespace: ns}, channelObject) 144 | 145 | Expect(err).To(HaveOccurred()) 146 | }) 147 | }) 148 | }) 149 | }) 150 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 main 18 | 19 | import ( 20 | "flag" 21 | "fmt" 22 | "os" 23 | "strings" 24 | 25 | "k8s.io/apimachinery/pkg/runtime" 26 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 27 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 28 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 29 | // to ensure that exec-entrypoint and run can make use of them. 30 | _ "k8s.io/client-go/plugin/pkg/client/auth" 31 | ctrl "sigs.k8s.io/controller-runtime" 32 | "sigs.k8s.io/controller-runtime/pkg/cache" 33 | "sigs.k8s.io/controller-runtime/pkg/healthz" 34 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 35 | 36 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 37 | "github.com/stakater/slack-operator/controllers" 38 | config "github.com/stakater/slack-operator/pkg/config" 39 | slack "github.com/stakater/slack-operator/pkg/slack" 40 | // +kubebuilder:scaffold:imports 41 | ) 42 | 43 | var ( 44 | scheme = runtime.NewScheme() 45 | setupLog = ctrl.Log.WithName("setup") 46 | ) 47 | 48 | func init() { 49 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 50 | 51 | utilruntime.Must(slackv1alpha1.AddToScheme(scheme)) 52 | // +kubebuilder:scaffold:scheme 53 | } 54 | 55 | func main() { 56 | var metricsAddr string 57 | var enableLeaderElection bool 58 | var probeAddr string 59 | 60 | flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") 61 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 62 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 63 | "Enable leader election for controller manager. "+ 64 | "Enabling this will ensure there is only one active controller manager.") 65 | 66 | opts := zap.Options{ 67 | Development: true, 68 | } 69 | opts.BindFlags(flag.CommandLine) 70 | flag.Parse() 71 | 72 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 73 | 74 | watchNamespace, err := getWatchNamespace() 75 | if err != nil { 76 | setupLog.Info("Unable to fetch WatchNamespace, the manager will watch and manage resources in all Namespaces") 77 | } 78 | 79 | options := ctrl.Options{ 80 | Scheme: scheme, 81 | MetricsBindAddress: metricsAddr, 82 | Port: 9443, 83 | HealthProbeBindAddress: probeAddr, 84 | LeaderElection: enableLeaderElection, 85 | LeaderElectionID: "957ea167.stakater.com", 86 | Namespace: watchNamespace, // namespaced-scope when the value is not an empty string 87 | } 88 | 89 | // Add support for MultiNamespace set in WATCH_NAMESPACE (e.g ns1,ns2) 90 | // More Info: https://godoc.org/github.com/kubernetes-sigs/controller-runtime/pkg/cache#MultiNamespacedCacheBuilder 91 | if strings.Contains(watchNamespace, ",") { 92 | setupLog.Info("Manager will be watching namespace(s) %q", watchNamespace) 93 | // configure cluster-scoped with MultiNamespacedCacheBuilder 94 | options.Namespace = "" 95 | options.NewCache = cache.MultiNamespacedCacheBuilder(strings.Split(watchNamespace, ",")) 96 | } 97 | 98 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), options) 99 | if err != nil { 100 | setupLog.Error(err, "unable to start manager") 101 | os.Exit(1) 102 | } 103 | 104 | slackAPIToken := config.ReadSlackTokenSecret(mgr.GetAPIReader()) 105 | 106 | if err = (&controllers.ChannelReconciler{ 107 | Client: mgr.GetClient(), 108 | Log: ctrl.Log.WithName("controllers").WithName("Channel"), 109 | Scheme: mgr.GetScheme(), 110 | SlackService: slack.New(slackAPIToken, ctrl.Log.WithName("service").WithName("Slack")), 111 | }).SetupWithManager(mgr); err != nil { 112 | setupLog.Error(err, "unable to create controller", "controller", "Channel") 113 | os.Exit(1) 114 | } 115 | 116 | if os.Getenv("ENABLE_WEBHOOKS") != "false" { 117 | if err = (&slackv1alpha1.Channel{}).SetupWebhookWithManager(mgr); err != nil { 118 | setupLog.Error(err, "unable to create webhook", "webhook", "Channel") 119 | os.Exit(1) 120 | } 121 | } 122 | 123 | // Add health endpoints 124 | if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 125 | setupLog.Error(err, "unable to set up health check") 126 | os.Exit(1) 127 | } 128 | if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 129 | setupLog.Error(err, "unable to set up ready check") 130 | os.Exit(1) 131 | } 132 | 133 | // +kubebuilder:scaffold:builder 134 | 135 | setupLog.Info("starting manager") 136 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 137 | setupLog.Error(err, "problem running manager") 138 | os.Exit(1) 139 | } 140 | } 141 | 142 | func getWatchNamespace() (string, error) { 143 | // WatchNamespaceEnvVar is the constant for env variable WATCH_NAMESPACE 144 | // which specifies the Namespace to watch. 145 | // An empty value means the operator is running with cluster scope. 146 | var watchNamespaceEnvVar = "WATCH_NAMESPACE" 147 | 148 | ns, found := os.LookupEnv(watchNamespaceEnvVar) 149 | if !found { 150 | return "", fmt.Errorf("%s must be set", watchNamespaceEnvVar) 151 | } 152 | return ns, nil 153 | } 154 | -------------------------------------------------------------------------------- /pkg/slack/mock/slackapi.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "regexp" 10 | 11 | "github.com/slack-go/slack/slacktest" 12 | ) 13 | 14 | // InitSlackTestServer initializes mock server for slack api 15 | func InitSlackTestServer() *slacktest.Server { 16 | 17 | testServer := slacktest.NewTestServer( 18 | func(c slacktest.Customize) { 19 | c.Handle("/conversations.info", conversationInfoHandler) 20 | }, 21 | func(c slacktest.Customize) { 22 | c.Handle("/conversations.create", createConversationHandler) 23 | }, 24 | func(c slacktest.Customize) { 25 | c.Handle("/conversations.setTopic", setConversationTopicHandler) 26 | }, 27 | func(c slacktest.Customize) { 28 | c.Handle("/conversations.setPurpose", setConversationPurposeHandler) 29 | }, 30 | func(c slacktest.Customize) { 31 | c.Handle("/conversations.rename", renameConversationHandler) 32 | }, 33 | func(c slacktest.Customize) { 34 | c.Handle("/conversations.archive", archiveConversationHandler) 35 | }, 36 | func(c slacktest.Customize) { 37 | c.Handle("/conversations.invite", inviteConversationHandler) 38 | }, 39 | func(c slacktest.Customize) { 40 | c.Handle("/users.lookupByEmail", usersLookupByEmailHandler) 41 | }, 42 | func(c slacktest.Customize) { 43 | c.Handle("/conversations.members", getMembersInConversationHandler) 44 | }, 45 | func(c slacktest.Customize) { 46 | c.Handle("/conversations.kick", kickMemberFromConversationHandler) 47 | }, 48 | ) 49 | 50 | return testServer 51 | } 52 | 53 | // handle conversations.info 54 | func conversationInfoHandler(w http.ResponseWriter, r *http.Request) { 55 | 56 | channelID := extractParamValue(r, "channel") 57 | 58 | var responseJSON string 59 | if channelID == PublicConversationID { 60 | responseJSON = publicConversationJSON 61 | } else if channelID == PrivateConversationID { 62 | responseJSON = privateConversationJSON 63 | } 64 | 65 | _, _ = w.Write([]byte(responseJSON)) 66 | } 67 | 68 | // handle conversations.create 69 | func createConversationHandler(w http.ResponseWriter, r *http.Request) { 70 | 71 | isPrivate := extractParamValue(r, "is_private") 72 | channelName := extractParamValue(r, "name") 73 | 74 | var responseJSON string 75 | 76 | if channelName == NameTakenConversationName { 77 | responseJSON = conversationNameTakenJSON 78 | } else if isPrivate == "true" { 79 | responseJSON = privateConversationJSON 80 | } else { 81 | responseJSON = publicConversationJSON 82 | } 83 | 84 | _, _ = w.Write([]byte(responseJSON)) 85 | } 86 | 87 | // handle conversations.setTopic 88 | func setConversationTopicHandler(w http.ResponseWriter, r *http.Request) { 89 | topic := extractParamValue(r, "topic") 90 | _, _ = w.Write([]byte(getConversationTopicResponse(topic))) 91 | } 92 | 93 | // handle conversations.setPurpose 94 | func setConversationPurposeHandler(w http.ResponseWriter, r *http.Request) { 95 | purpose := extractParamValue(r, "purpose") 96 | _, _ = w.Write([]byte(getConversationPurposeResponse(purpose))) 97 | } 98 | 99 | // handle conversations.rename 100 | func renameConversationHandler(w http.ResponseWriter, r *http.Request) { 101 | newName := extractParamValue(r, "name") 102 | _, _ = w.Write([]byte(getConversationNameResponse(newName))) 103 | } 104 | 105 | // handle conversations.archive 106 | func archiveConversationHandler(w http.ResponseWriter, r *http.Request) { 107 | channelID := extractParamValue(r, "channel") 108 | 109 | response := "" 110 | if channelID == NotFoundConversationID { 111 | response = getConversationArchiveChannelNotFoundRespose() 112 | } else { 113 | response = getConversationArchiveResponse() 114 | } 115 | _, _ = w.Write([]byte(response)) 116 | } 117 | 118 | // handle conversations.invite 119 | func inviteConversationHandler(w http.ResponseWriter, r *http.Request) { 120 | _, _ = w.Write([]byte(inviteConversationJSON)) 121 | } 122 | 123 | // handle conversations.members 124 | func getMembersInConversationHandler(w http.ResponseWriter, r *http.Request) { 125 | channelID := extractParamValue(r, "channel") 126 | 127 | response := "" 128 | if channelID == NotFoundConversationID { 129 | response = getConversationNotFoundResponse() 130 | } else { 131 | response = getMembersInConversationResponse() 132 | } 133 | 134 | _, _ = w.Write([]byte(response)) 135 | } 136 | 137 | // handle conversations.kick 138 | func kickMemberFromConversationHandler(w http.ResponseWriter, r *http.Request) { 139 | channelID := extractParamValue(r, "channel") 140 | userID := extractParamValue(r, "user") 141 | 142 | response := "" 143 | if channelID == NotFoundConversationID { 144 | response = getConversationNotFoundResponse() 145 | } else if userID == NotFoundUserID { 146 | response = getUserNotFoundResponse() 147 | } else { 148 | response = kickUserFromConversationSuccessResponse() 149 | } 150 | 151 | _, _ = w.Write([]byte(response)) 152 | } 153 | 154 | // handle users.lookupByEmail 155 | func usersLookupByEmailHandler(w http.ResponseWriter, r *http.Request) { 156 | email := extractParamValue(r, "email") 157 | 158 | userJSON := "" 159 | if email == url.QueryEscape(ExistingUserEmail) { 160 | userJSON = fmt.Sprintf(templateUserJSON, ExistingUserEmail) 161 | } else { 162 | userJSON = userNotFoundJSON 163 | } 164 | 165 | _, _ = w.Write([]byte(userJSON)) 166 | } 167 | 168 | func extractParamValue(r *http.Request, key string) string { 169 | buf, bodyErr := ioutil.ReadAll(r.Body) 170 | if bodyErr != nil { 171 | return "" 172 | } 173 | 174 | rdr1, _ := ioutil.ReadAll(bytes.NewBuffer(buf)) 175 | rdr2 := ioutil.NopCloser(bytes.NewBuffer(buf)) 176 | r.Body = rdr2 177 | 178 | bodyParams := string(rdr1) 179 | re := regexp.MustCompile(key + "=(.*?)(&|$)") 180 | match := re.FindStringSubmatch(bodyParams) 181 | 182 | if len(match) > 1 { 183 | return match[1] 184 | } 185 | 186 | return "" 187 | } 188 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | 3 | # Current Operator version 4 | VERSION ?= 0.0.1 5 | # Default bundle image tag 6 | BUNDLE_IMG ?= controller-bundle:$(VERSION) 7 | # Options for 'bundle-build' 8 | ifneq ($(origin CHANNELS), undefined) 9 | BUNDLE_CHANNELS := --channels=$(CHANNELS) 10 | endif 11 | ifneq ($(origin DEFAULT_CHANNEL), undefined) 12 | BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) 13 | endif 14 | BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) 15 | 16 | # Image URL to use all building/pushing image targets 17 | IMG ?= stakater/slack-operator:v$(VERSION) 18 | # Produce CRDs that work back to Kubernetes 1.11 (no version conversion) 19 | CRD_OPTIONS ?= "crd:trivialVersions=true,crdVersions=v1,preserveUnknownFields=false" 20 | 21 | # GOLANGCI_LINT env 22 | GOLANGCI_LINT = _output/tools/golangci-lint 23 | GOLANGCI_LINT_CACHE = $(PWD)/_output/golangci-lint-cache 24 | GOLANGCI_LINT_VERSION = v1.24 25 | 26 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 27 | ifeq (,$(shell go env GOBIN)) 28 | GOBIN=$(shell go env GOPATH)/bin 29 | else 30 | GOBIN=$(shell go env GOBIN) 31 | endif 32 | 33 | all: manager 34 | 35 | 36 | ENVTEST_ASSETS_DIR=$(shell pwd)/testbin 37 | test: manifests generate fmt vet ## Run tests. 38 | mkdir -p ${ENVTEST_ASSETS_DIR} 39 | test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/v0.7.2/hack/setup-envtest.sh 40 | source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test ./... -coverprofile cover.out 41 | 42 | 43 | # Build manager binary 44 | manager: generate fmt vet 45 | go build -o bin/manager main.go 46 | 47 | # Run against the configured Kubernetes cluster in ~/.kube/config 48 | run: generate fmt vet manifests install 49 | go run ./main.go 50 | 51 | # Install CRDs into a cluster 52 | install: manifests kustomize 53 | $(KUSTOMIZE) build config/crd | kubectl apply -f - 54 | 55 | # Uninstall CRDs from a cluster 56 | uninstall: manifests kustomize 57 | $(KUSTOMIZE) build config/crd | kubectl delete -f - 58 | 59 | # Deploy controller in the configured Kubernetes cluster in ~/.kube/config 60 | deploy: manifests kustomize 61 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 62 | $(KUSTOMIZE) build config/default | kubectl apply -f - 63 | 64 | # Generate manifests e.g. CRD, RBAC etc. 65 | manifests: controller-gen 66 | $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases 67 | 68 | # Run go fmt against code 69 | fmt: 70 | go fmt ./... 71 | 72 | # Run go vet against code 73 | vet: 74 | go vet ./... 75 | 76 | # Generate code 77 | generate: controller-gen 78 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 79 | 80 | # Build the docker image 81 | docker-build: test 82 | docker build -t ${IMG} . 83 | 84 | # Push the docker image 85 | docker-push: 86 | docker push ${IMG} 87 | 88 | # find or download controller-gen 89 | # download controller-gen if necessary 90 | 91 | CONTROLLER_GEN = $(shell pwd)/bin/controller-gen 92 | controller-gen: ## Download controller-gen locally if necessary. 93 | $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen@v0.4.1) 94 | 95 | KUSTOMIZE = $(shell pwd)/bin/kustomize 96 | kustomize: ## Download kustomize locally if necessary. 97 | $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v4@v4.5.4) 98 | 99 | # go-install-tool will 'go get' any package $2 and install it to $1. 100 | PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) 101 | define go-install-tool 102 | @[ -f $(1) ] || { \ 103 | set -e ;\ 104 | TMP_DIR=$$(mktemp -d) ;\ 105 | cd $$TMP_DIR ;\ 106 | go mod init tmp ;\ 107 | echo "Downloading $(2)" ;\ 108 | GOBIN=$(PROJECT_DIR)/bin go install $(2) ;\ 109 | rm -rf $$TMP_DIR ;\ 110 | } 111 | endef 112 | 113 | # Generate bundle manifests and metadata, then validate generated files. 114 | .PHONY: bundle 115 | bundle: manifests kustomize ## Generate bundle manifests and metadata, then validate generated files. 116 | operator-sdk generate kustomize manifests -q 117 | cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG) 118 | $(KUSTOMIZE) build config/manifests | operator-sdk generate bundle -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) 119 | operator-sdk bundle validate ./bundle 120 | 121 | # Options for "packagemanifests". 122 | ifneq ($(origin FROM_VERSION), undefined) 123 | PKG_FROM_VERSION := --from-version=$(FROM_VERSION) 124 | endif 125 | ifneq ($(origin CHANNEL), undefined) 126 | PKG_CHANNELS := --channel=$(CHANNEL) 127 | endif 128 | ifeq ($(IS_CHANNEL_DEFAULT), 1) 129 | PKG_IS_DEFAULT_CHANNEL := --default-channel 130 | endif 131 | PKG_MAN_OPTS ?= $(FROM_VERSION) $(PKG_CHANNELS) $(PKG_IS_DEFAULT_CHANNEL) 132 | 133 | # Generate package manifests. 134 | packagemanifests: manifests 135 | operator-sdk generate kustomize manifests -q 136 | $(KUSTOMIZE) build config/manifests | operator-sdk generate packagemanifests -q --version $(VERSION) $(PKG_MAN_OPTS) 137 | 138 | # Build the bundle image. 139 | .PHONY: bundle-build 140 | bundle-build: 141 | docker build -f bundle.Dockerfile -t $(BUNDLE_IMG) . 142 | 143 | verify-fmt: 144 | ./hack/verify-gofmt.sh 145 | 146 | $(GOLANGCI_LINT): 147 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(dir $@) v1.36.0 148 | 149 | verify-golangci-lint: $(GOLANGCI_LINT) 150 | GOLANGCI_LINT_CACHE=$(GOLANGCI_LINT_CACHE) $(GOLANGCI_LINT) run --timeout=300s ./... 151 | 152 | verify: verify-fmt verify-golangci-lint 153 | 154 | bump-chart-operator: 155 | sed -i "s/^version:.*/version: $(VERSION)/" charts/slack-operator/Chart.yaml 156 | sed -i "s/^appVersion:.*/appVersion: $(VERSION)/" charts/slack-operator/Chart.yaml 157 | sed -i "s/tag:.*/tag: v$(VERSION)/" charts/slack-operator/values.yaml 158 | 159 | # Bump Chart 160 | bump-chart: bump-chart-operator 161 | 162 | generate-crds: controller-gen 163 | $(CONTROLLER_GEN) crd paths="./..." output:crd:artifacts:config=charts/slack-operator/crds -------------------------------------------------------------------------------- /pkg/slack/mock/data.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/slack-go/slack" 8 | ) 9 | 10 | var ChannelObjectModifiedError = "Operation cannot be fulfilled on channels.slack.stakater.com \"%s\": the object has been modified; please apply your changes to the latest version and try again" 11 | 12 | var ConversationName = "bat-channel" 13 | var NameTakenConversationName = "name-taken" 14 | var InvalidChannelName = "#$%^^^&)$#(!($&!#KHLREJOIWQRHQOIWRHQWRIOWQIHEIUWQ BETy2180943u20932" 15 | var PublicConversationID = "C0EAQDV4Z" 16 | var PrivateConversationID = "Y7HGFWC6Q" 17 | var NotFoundConversationID = "-" 18 | var NotFoundUserID = "-" 19 | var BotID = "U023BECGF" 20 | var Description = "My channel Description" 21 | 22 | var templateChannelJSON = ` 23 | { 24 | "id": "%s", 25 | "name": "%s", 26 | "is_channel": true, 27 | "is_group": false, 28 | "is_im": false, 29 | "created": %d, 30 | "creator": "%s", 31 | "is_archived": false, 32 | "is_general": false, 33 | "unlinked": 0, 34 | "name_normalized": "%s", 35 | "is_shared": false, 36 | "is_ext_shared": false, 37 | "is_org_shared": false, 38 | "pending_shared": [], 39 | "is_pending_ext_shared": false, 40 | "is_member": true, 41 | "is_private": %s, 42 | "is_mpim": false, 43 | "last_read": "0000000000.000000", 44 | "latest": null, 45 | "unread_count": 0, 46 | "unread_count_display": 0, 47 | "topic": { 48 | "value": "%s", 49 | "creator": "%s", 50 | "last_set": %d 51 | }, 52 | "purpose": { 53 | "value": "%s", 54 | "creator": "%s", 55 | "last_set": %d 56 | }, 57 | "num_members": %d, 58 | "previous_names": [], 59 | "priority": 0 60 | }` 61 | 62 | var conversationNameTakenJSON = ` 63 | { 64 | "ok": false, 65 | "error": "name_taken" 66 | }` 67 | 68 | var templateConversationJSON = fmt.Sprintf(` 69 | { 70 | "ok": true, 71 | "channel": %s 72 | }`, templateChannelJSON) 73 | 74 | var publicConversationJSON = fmt.Sprintf(templateConversationJSON, PublicConversationID, ConversationName, 75 | nowAsJSONTime(), BotID, ConversationName, "false", "", "", 0, "", "", 0, 0) 76 | 77 | var privateConversationJSON = fmt.Sprintf(templateConversationJSON, PrivateConversationID, ConversationName, 78 | nowAsJSONTime(), BotID, ConversationName, "true", "", "", 0, "", "", 0, 0) 79 | 80 | var inviteConversationJSON = fmt.Sprintf(templateConversationJSON, PublicConversationID, ConversationName, 81 | nowAsJSONTime(), BotID, ConversationName, "false", "", "", 0, "", "", 0, 1) 82 | 83 | func getConversationNameResponse(name string) string { 84 | return fmt.Sprintf(templateConversationJSON, PublicConversationID, name, 85 | nowAsJSONTime(), BotID, name, "false", "", "", 0, "", "", 0, 0) 86 | } 87 | 88 | func getConversationArchiveResponse() string { 89 | return ` 90 | { 91 | "ok": true 92 | } 93 | ` 94 | } 95 | 96 | func getConversationArchiveChannelNotFoundRespose() string { 97 | return ` 98 | { 99 | "ok": false, 100 | "error": "channel_not_found" 101 | } 102 | ` 103 | } 104 | 105 | func getConversationTopicResponse(topic string) string { 106 | return fmt.Sprintf(templateConversationJSON, PublicConversationID, ConversationName, 107 | nowAsJSONTime(), BotID, ConversationName, "false", topic, BotID, 108 | nowAsJSONTime(), "I didn't set this purpose on purpose!", BotID, nowAsJSONTime(), 0) 109 | } 110 | 111 | func getConversationPurposeResponse(purpose string) string { 112 | return fmt.Sprintf(templateConversationJSON, PublicConversationID, ConversationName, 113 | nowAsJSONTime(), BotID, ConversationName, "false", "random topic", BotID, 114 | nowAsJSONTime(), purpose, BotID, nowAsJSONTime(), 0) 115 | } 116 | 117 | func getMembersInConversationResponse() string { 118 | return membersInConversationJSON 119 | } 120 | 121 | func getConversationNotFoundResponse() string { 122 | return channelNotFoundJSON 123 | } 124 | 125 | func getUserNotFoundResponse() string { 126 | return userNotFoundJSON 127 | } 128 | 129 | func kickUserFromConversationSuccessResponse() string { 130 | return `{ 131 | "ok": true 132 | }` 133 | } 134 | 135 | const ExistingUserEmail = "iamuser@slack.com" 136 | 137 | var templateUserJSON = ` 138 | { 139 | "ok": true, 140 | "user": { 141 | "id": "W012A3CDE", 142 | "team_id": "T012AB3C4", 143 | "name": "spengler", 144 | "deleted": false, 145 | "color": "9f69e7", 146 | "real_name": "Egon Spengler", 147 | "tz": "America/Los_Angeles", 148 | "tz_label": "Pacific Daylight Time", 149 | "tz_offset": -25200, 150 | "profile": { 151 | "avatar_hash": "ge3b51ca72de", 152 | "status_text": "Print is dead", 153 | "status_emoji": ":books:", 154 | "real_name": "Egon Spengler", 155 | "display_name": "spengler", 156 | "real_name_normalized": "Egon Spengler", 157 | "display_name_normalized": "spengler", 158 | "email": "%s", 159 | "image_24": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", 160 | "image_32": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", 161 | "image_48": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", 162 | "image_72": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", 163 | "image_192": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", 164 | "image_512": "https://.../avatar/e3b51ca72dee4ef87916ae2b9240df50.jpg", 165 | "team": "T012AB3C4" 166 | }, 167 | "is_admin": true, 168 | "is_owner": false, 169 | "is_primary_owner": false, 170 | "is_restricted": false, 171 | "is_ultra_restricted": false, 172 | "is_bot": false, 173 | "updated": 1502138686, 174 | "is_app_user": false, 175 | "has_2fa": false 176 | } 177 | }` 178 | 179 | var membersInConversationJSON = ` 180 | { 181 | "ok": true, 182 | "members": [ 183 | "U023BECGF", 184 | "U061F7AUR", 185 | "W012A3CDE" 186 | ], 187 | "response_metadata": { 188 | "next_cursor": "e3VzZXJfaWQ6IFcxMjM0NTY3fQ==" 189 | } 190 | }` 191 | 192 | var userNotFoundJSON = ` 193 | { 194 | "ok": false, 195 | "error": "users_not_found" 196 | } 197 | ` 198 | 199 | var channelNotFoundJSON = ` 200 | { 201 | "ok": false, 202 | "error": "channel_not_found" 203 | } 204 | ` 205 | 206 | func nowAsJSONTime() slack.JSONTime { 207 | return slack.JSONTime(time.Now().Unix()) 208 | } 209 | -------------------------------------------------------------------------------- /bundle/manifests/slack.stakater.com_channels.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | cert-manager.io/inject-ca-from: slack-operator-system/slack-operator-serving-cert 6 | controller-gen.kubebuilder.io/version: v0.4.1 7 | creationTimestamp: null 8 | name: channels.slack.stakater.com 9 | spec: 10 | conversion: 11 | strategy: Webhook 12 | webhook: 13 | clientConfig: 14 | caBundle: Cg== 15 | service: 16 | name: slack-operator-webhook-service 17 | namespace: slack-operator-system 18 | path: /convert 19 | port: 443 20 | conversionReviewVersions: 21 | - v1 22 | - v1beta1 23 | group: slack.stakater.com 24 | names: 25 | kind: Channel 26 | listKind: ChannelList 27 | plural: channels 28 | singular: channel 29 | scope: Namespaced 30 | versions: 31 | - name: v1alpha1 32 | schema: 33 | openAPIV3Schema: 34 | description: Channel is the Schema for the channels API 35 | properties: 36 | apiVersion: 37 | description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 38 | type: string 39 | kind: 40 | description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 41 | type: string 42 | metadata: 43 | type: object 44 | spec: 45 | description: ChannelSpec defines the desired state of Channel 46 | properties: 47 | description: 48 | description: Description of the channel 49 | type: string 50 | name: 51 | description: Name of the slack channel 52 | type: string 53 | private: 54 | description: Make the channel private or public 55 | type: boolean 56 | topic: 57 | description: Topic of the channel 58 | type: string 59 | users: 60 | description: List of user IDs of the users to invite 61 | items: 62 | type: string 63 | minItems: 1 64 | type: array 65 | required: 66 | - name 67 | - users 68 | type: object 69 | status: 70 | description: ChannelStatus defines the observed state of Channel 71 | properties: 72 | conditions: 73 | description: Status conditions 74 | items: 75 | description: "Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" 76 | properties: 77 | lastTransitionTime: 78 | description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. 79 | format: date-time 80 | type: string 81 | message: 82 | description: message is a human readable message indicating details about the transition. This may be an empty string. 83 | maxLength: 32768 84 | type: string 85 | observedGeneration: 86 | description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. 87 | format: int64 88 | minimum: 0 89 | type: integer 90 | reason: 91 | description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. 92 | maxLength: 1024 93 | minLength: 1 94 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 95 | type: string 96 | status: 97 | description: status of the condition, one of True, False, Unknown. 98 | enum: 99 | - "True" 100 | - "False" 101 | - Unknown 102 | type: string 103 | type: 104 | description: type of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) 105 | maxLength: 316 106 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 107 | type: string 108 | required: 109 | - lastTransitionTime 110 | - message 111 | - reason 112 | - status 113 | - type 114 | type: object 115 | type: array 116 | id: 117 | description: ID of the slack channel 118 | type: string 119 | required: 120 | - id 121 | type: object 122 | type: object 123 | served: true 124 | storage: true 125 | subresources: 126 | status: {} 127 | status: 128 | acceptedNames: 129 | kind: "" 130 | plural: "" 131 | conditions: [] 132 | storedVersions: [] 133 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | DOCKER_FILE_PATH: Dockerfile 10 | GOLANG_VERSION: 1.16 11 | OPERATOR_SDK_VERSION: "1.7.2" 12 | KUSTOMIZE_VERSION: "3.8.7" 13 | KUBERNETES_VERSION: "1.20.2" 14 | KIND_VERSION: "0.10.0" 15 | 16 | jobs: 17 | build: 18 | name: Build 19 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Check out code 24 | uses: actions/checkout@v2 25 | with: 26 | persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token 27 | fetch-depth: 0 # otherwise, you will fail to push refs to dest repo 28 | 29 | - name: Set up Go 30 | id: go 31 | uses: actions/setup-go@v2 32 | with: 33 | go-version: ${{ env.GOLANG_VERSION }} 34 | 35 | - name: Lint 36 | uses: golangci/golangci-lint-action@v3 37 | with: 38 | version: v1.50.0 39 | only-new-issues: false 40 | args: --timeout 10m 41 | 42 | - name: Install kubectl 43 | run: | 44 | curl -LO "https://storage.googleapis.com/kubernetes-release/release/v${KUBERNETES_VERSION}/bin/linux/amd64/kubectl" 45 | sudo install ./kubectl /usr/local/bin/ && rm kubectl 46 | kubectl version --short --client 47 | kubectl version --short --client | grep -q ${KUBERNETES_VERSION} 48 | 49 | - name: Install Kind 50 | run: | 51 | curl -L -o kind https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/kind-linux-amd64 52 | sudo install ./kind /usr/local/bin && rm kind 53 | kind version 54 | kind version | grep -q ${KIND_VERSION} 55 | 56 | - name: Create Kind Cluster 57 | run: | 58 | kind create cluster 59 | 60 | - name: Set up Cluster 61 | run: | 62 | kubectl cluster-info 63 | kubectl create namespace test 64 | 65 | - name: Test 66 | run: make test OPERATOR_NAMESPACE=test USE_EXISTING_CLUSTER=true 67 | 68 | - name: Generate Tag 69 | id: generate_tag 70 | uses: anothrNick/github-tag-action@1.36.0 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 73 | WITH_V: true 74 | DEFAULT_BUMP: patch 75 | DRY_RUN: true 76 | 77 | - name: Set up QEMU 78 | uses: docker/setup-qemu-action@v1 79 | 80 | - name: Set up Docker Buildx 81 | uses: docker/setup-buildx-action@v1 82 | 83 | - name: Login to Registry 84 | uses: docker/login-action@v1 85 | with: 86 | username: ${{ secrets.STAKATER_DOCKERHUB_USERNAME }} 87 | password: ${{ secrets.STAKATER_DOCKERHUB_PASSWORD }} 88 | 89 | - name: Generate image repository path 90 | run: | 91 | echo IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV 92 | 93 | - name: Build and push 94 | uses: docker/build-push-action@v2 95 | with: 96 | context: . 97 | file: ${{ env.DOCKER_FILE_PATH }} 98 | pull: true 99 | push: true 100 | build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} 101 | cache-to: type=inline 102 | tags: | 103 | ${{ env.IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.new_tag }} 104 | labels: | 105 | org.opencontainers.image.source=${{ github.event.repository.clone_url }} 106 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 107 | org.opencontainers.image.revision=${{ github.sha }} 108 | 109 | ############################## 110 | ## Add steps to generate required artifacts for a release here(helm chart, operator manifest etc.) 111 | ############################## 112 | 113 | # Generate tag for operator without "v" 114 | - name: Generate Operator Tag 115 | id: generate_operator_tag 116 | uses: anothrNick/github-tag-action@1.36.0 117 | env: 118 | GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 119 | WITH_V: false 120 | DEFAULT_BUMP: patch 121 | DRY_RUN: true 122 | 123 | # Install operator-sdk 124 | - name: Install operator-sdk 125 | env: 126 | OPERATOR_SDK_VERSION: ${{ env.OPERATOR_SDK_VERSION }} 127 | run: | 128 | curl -fL -o /tmp/operator-sdk "https://github.com/operator-framework/operator-sdk/releases/download/v${OPERATOR_SDK_VERSION}/operator-sdk_linux_amd64" 129 | sudo install /tmp/operator-sdk /usr/local/bin && rm -f /tmp/operator-sdk 130 | operator-sdk version 131 | operator-sdk version | grep -q "${OPERATOR_SDK_VERSION}" 132 | 133 | # Install kustomize 134 | - uses: imranismail/setup-kustomize@v1 135 | with: 136 | kustomize-version: ${{ env.KUSTOMIZE_VERSION }} 137 | 138 | - name: Generate Bundle 139 | env: 140 | VERSION: ${{ steps.generate_operator_tag.outputs.new_tag }} 141 | run: make bundle 142 | 143 | # Update chart tag to the latest semver tag 144 | - name: Update Chart Version 145 | env: 146 | VERSION: ${{ steps.generate_operator_tag.outputs.new_tag }} 147 | run: make bump-chart 148 | 149 | - name: Update Chart CRDs 150 | run: make generate-crds 151 | 152 | # Publish helm chart 153 | - name: Publish Helm chart 154 | uses: stefanprodan/helm-gh-pages@master 155 | with: 156 | branch: master 157 | repository: stakater-charts 158 | target_dir: docs 159 | token: ${{ secrets.GHCR_TOKEN }} 160 | charts_dir: charts 161 | charts_url: https://stakater.github.io/stakater-charts 162 | owner: stakater 163 | linting: off 164 | commit_username: stakater-user 165 | commit_email: stakater@gmail.com 166 | 167 | # Commit back changes 168 | - name: Commit files 169 | run: | 170 | git config --local user.email "stakater@gmail.com" 171 | git config --local user.name "stakater-user" 172 | git status 173 | git add . 174 | git commit -m "[skip-ci] Update artifacts" -a 175 | 176 | - name: Push changes 177 | uses: ad-m/github-push-action@master 178 | with: 179 | github_token: ${{ secrets.PUBLISH_TOKEN }} 180 | 181 | - name: Push Latest Tag 182 | uses: anothrNick/github-tag-action@1.36.0 183 | env: 184 | GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 185 | WITH_V: true 186 | DEFAULT_BUMP: patch 187 | 188 | - name: Notify Slack 189 | uses: 8398a7/action-slack@v3 190 | if: always() # Pick up events even if the job fails or is canceled. 191 | with: 192 | status: ${{ job.status }} 193 | fields: repo,author,action,eventName,ref,workflow 194 | env: 195 | GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 196 | SLACK_WEBHOOK_URL: ${{ secrets.STAKATER_DELIVERY_SLACK_WEBHOOK }} 197 | -------------------------------------------------------------------------------- /config/crd/bases/slack.stakater.com_channels.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.1 8 | creationTimestamp: null 9 | name: channels.slack.stakater.com 10 | spec: 11 | group: slack.stakater.com 12 | names: 13 | kind: Channel 14 | listKind: ChannelList 15 | plural: channels 16 | singular: channel 17 | scope: Namespaced 18 | versions: 19 | - name: v1alpha1 20 | schema: 21 | openAPIV3Schema: 22 | description: Channel is the Schema for the channels API 23 | properties: 24 | apiVersion: 25 | description: 'APIVersion defines the versioned schema of this representation 26 | of an object. Servers should convert recognized schemas to the latest 27 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 28 | type: string 29 | kind: 30 | description: 'Kind is a string value representing the REST resource this 31 | object represents. Servers may infer this from the endpoint the client 32 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 33 | type: string 34 | metadata: 35 | type: object 36 | spec: 37 | description: ChannelSpec defines the desired state of Channel 38 | properties: 39 | description: 40 | description: Description of the channel 41 | type: string 42 | name: 43 | description: Name of the slack channel 44 | type: string 45 | private: 46 | description: Make the channel private or public 47 | type: boolean 48 | topic: 49 | description: Topic of the channel 50 | type: string 51 | users: 52 | description: List of user IDs of the users to invite 53 | items: 54 | type: string 55 | minItems: 1 56 | type: array 57 | required: 58 | - name 59 | - users 60 | type: object 61 | status: 62 | description: ChannelStatus defines the observed state of Channel 63 | properties: 64 | conditions: 65 | description: Status conditions 66 | items: 67 | description: "Condition contains details for one aspect of the current 68 | state of this API Resource. --- This struct is intended for direct 69 | use as an array at the field path .status.conditions. For example, 70 | type FooStatus struct{ // Represents the observations of a 71 | foo's current state. // Known .status.conditions.type are: 72 | \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type 73 | \ // +patchStrategy=merge // +listType=map // +listMapKey=type 74 | \ Conditions []metav1.Condition `json:\"conditions,omitempty\" 75 | patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` 76 | \n // other fields }" 77 | properties: 78 | lastTransitionTime: 79 | description: lastTransitionTime is the last time the condition 80 | transitioned from one status to another. This should be when 81 | the underlying condition changed. If that is not known, then 82 | using the time when the API field changed is acceptable. 83 | format: date-time 84 | type: string 85 | message: 86 | description: message is a human readable message indicating 87 | details about the transition. This may be an empty string. 88 | maxLength: 32768 89 | type: string 90 | observedGeneration: 91 | description: observedGeneration represents the .metadata.generation 92 | that the condition was set based upon. For instance, if .metadata.generation 93 | is currently 12, but the .status.conditions[x].observedGeneration 94 | is 9, the condition is out of date with respect to the current 95 | state of the instance. 96 | format: int64 97 | minimum: 0 98 | type: integer 99 | reason: 100 | description: reason contains a programmatic identifier indicating 101 | the reason for the condition's last transition. Producers 102 | of specific condition types may define expected values and 103 | meanings for this field, and whether the values are considered 104 | a guaranteed API. The value should be a CamelCase string. 105 | This field may not be empty. 106 | maxLength: 1024 107 | minLength: 1 108 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 109 | type: string 110 | status: 111 | description: status of the condition, one of True, False, Unknown. 112 | enum: 113 | - "True" 114 | - "False" 115 | - Unknown 116 | type: string 117 | type: 118 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 119 | --- Many .condition.type values are consistent across resources 120 | like Available, but because arbitrary conditions can be useful 121 | (see .node.status.conditions), the ability to deconflict is 122 | important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) 123 | maxLength: 316 124 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 125 | type: string 126 | required: 127 | - lastTransitionTime 128 | - message 129 | - reason 130 | - status 131 | - type 132 | type: object 133 | type: array 134 | id: 135 | description: ID of the slack channel 136 | type: string 137 | required: 138 | - id 139 | type: object 140 | type: object 141 | served: true 142 | storage: true 143 | subresources: 144 | status: {} 145 | status: 146 | acceptedNames: 147 | kind: "" 148 | plural: "" 149 | conditions: [] 150 | storedVersions: [] 151 | -------------------------------------------------------------------------------- /charts/slack-operator/crds/slack.stakater.com_channels.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.1 8 | creationTimestamp: null 9 | name: channels.slack.stakater.com 10 | spec: 11 | group: slack.stakater.com 12 | names: 13 | kind: Channel 14 | listKind: ChannelList 15 | plural: channels 16 | singular: channel 17 | scope: Namespaced 18 | versions: 19 | - name: v1alpha1 20 | schema: 21 | openAPIV3Schema: 22 | description: Channel is the Schema for the channels API 23 | properties: 24 | apiVersion: 25 | description: 'APIVersion defines the versioned schema of this representation 26 | of an object. Servers should convert recognized schemas to the latest 27 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 28 | type: string 29 | kind: 30 | description: 'Kind is a string value representing the REST resource this 31 | object represents. Servers may infer this from the endpoint the client 32 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 33 | type: string 34 | metadata: 35 | type: object 36 | spec: 37 | description: ChannelSpec defines the desired state of Channel 38 | properties: 39 | description: 40 | description: Description of the channel 41 | type: string 42 | name: 43 | description: Name of the slack channel 44 | type: string 45 | private: 46 | description: Make the channel private or public 47 | type: boolean 48 | topic: 49 | description: Topic of the channel 50 | type: string 51 | users: 52 | description: List of user IDs of the users to invite 53 | items: 54 | type: string 55 | minItems: 1 56 | type: array 57 | required: 58 | - name 59 | - users 60 | type: object 61 | status: 62 | description: ChannelStatus defines the observed state of Channel 63 | properties: 64 | conditions: 65 | description: Status conditions 66 | items: 67 | description: "Condition contains details for one aspect of the current 68 | state of this API Resource. --- This struct is intended for direct 69 | use as an array at the field path .status.conditions. For example, 70 | type FooStatus struct{ // Represents the observations of a 71 | foo's current state. // Known .status.conditions.type are: 72 | \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type 73 | \ // +patchStrategy=merge // +listType=map // +listMapKey=type 74 | \ Conditions []metav1.Condition `json:\"conditions,omitempty\" 75 | patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` 76 | \n // other fields }" 77 | properties: 78 | lastTransitionTime: 79 | description: lastTransitionTime is the last time the condition 80 | transitioned from one status to another. This should be when 81 | the underlying condition changed. If that is not known, then 82 | using the time when the API field changed is acceptable. 83 | format: date-time 84 | type: string 85 | message: 86 | description: message is a human readable message indicating 87 | details about the transition. This may be an empty string. 88 | maxLength: 32768 89 | type: string 90 | observedGeneration: 91 | description: observedGeneration represents the .metadata.generation 92 | that the condition was set based upon. For instance, if .metadata.generation 93 | is currently 12, but the .status.conditions[x].observedGeneration 94 | is 9, the condition is out of date with respect to the current 95 | state of the instance. 96 | format: int64 97 | minimum: 0 98 | type: integer 99 | reason: 100 | description: reason contains a programmatic identifier indicating 101 | the reason for the condition's last transition. Producers 102 | of specific condition types may define expected values and 103 | meanings for this field, and whether the values are considered 104 | a guaranteed API. The value should be a CamelCase string. 105 | This field may not be empty. 106 | maxLength: 1024 107 | minLength: 1 108 | pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ 109 | type: string 110 | status: 111 | description: status of the condition, one of True, False, Unknown. 112 | enum: 113 | - "True" 114 | - "False" 115 | - Unknown 116 | type: string 117 | type: 118 | description: type of condition in CamelCase or in foo.example.com/CamelCase. 119 | --- Many .condition.type values are consistent across resources 120 | like Available, but because arbitrary conditions can be useful 121 | (see .node.status.conditions), the ability to deconflict is 122 | important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) 123 | maxLength: 316 124 | pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ 125 | type: string 126 | required: 127 | - lastTransitionTime 128 | - message 129 | - reason 130 | - status 131 | - type 132 | type: object 133 | type: array 134 | id: 135 | description: ID of the slack channel 136 | type: string 137 | required: 138 | - id 139 | type: object 140 | type: object 141 | served: true 142 | storage: true 143 | subresources: 144 | status: {} 145 | status: 146 | acceptedNames: 147 | kind: "" 148 | plural: "" 149 | conditions: [] 150 | storedVersions: [] 151 | -------------------------------------------------------------------------------- /bundle/manifests/slack-operator.clusterserviceversion.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: operators.coreos.com/v1alpha1 2 | kind: ClusterServiceVersion 3 | metadata: 4 | annotations: 5 | alm-examples: |- 6 | [ 7 | { 8 | "apiVersion": "slack.stakater.com/v1alpha1", 9 | "kind": "Channel", 10 | "metadata": { 11 | "name": "building-channel" 12 | }, 13 | "spec": { 14 | "description": "Why is it called a 'building' if it's already built?", 15 | "name": "building-channel", 16 | "private": true, 17 | "topic": "Buildings", 18 | "users": [ 19 | "hazim@stakater.com" 20 | ] 21 | } 22 | } 23 | ] 24 | capabilities: Basic Install 25 | operators.operatorframework.io/builder: operator-sdk-v1.7.1+git 26 | operators.operatorframework.io/project_layout: go.kubebuilder.io/v3 27 | name: slack-operator.v0.0.39 28 | namespace: placeholder 29 | spec: 30 | apiservicedefinitions: {} 31 | customresourcedefinitions: 32 | owned: 33 | - description: Channel is the Schema for the channels API 34 | displayName: Channel 35 | kind: Channel 36 | name: channels.slack.stakater.com 37 | version: v1alpha1 38 | description: Kubernetes operator for Slack 39 | displayName: slack-operator 40 | icon: 41 | - base64data: "" 42 | mediatype: "" 43 | install: 44 | spec: 45 | clusterPermissions: 46 | - rules: 47 | - apiGroups: 48 | - "" 49 | resources: 50 | - secrets 51 | verbs: 52 | - get 53 | - list 54 | - apiGroups: 55 | - slack.stakater.com 56 | resources: 57 | - channels 58 | verbs: 59 | - create 60 | - delete 61 | - get 62 | - list 63 | - patch 64 | - update 65 | - watch 66 | - apiGroups: 67 | - slack.stakater.com 68 | resources: 69 | - channels/status 70 | verbs: 71 | - get 72 | - patch 73 | - update 74 | - apiGroups: 75 | - authentication.k8s.io 76 | resources: 77 | - tokenreviews 78 | verbs: 79 | - create 80 | - apiGroups: 81 | - authorization.k8s.io 82 | resources: 83 | - subjectaccessreviews 84 | verbs: 85 | - create 86 | serviceAccountName: slack-operator-controller-manager 87 | deployments: 88 | - name: slack-operator-controller-manager 89 | spec: 90 | replicas: 1 91 | selector: 92 | matchLabels: 93 | control-plane: controller-manager 94 | strategy: {} 95 | template: 96 | metadata: 97 | labels: 98 | control-plane: controller-manager 99 | spec: 100 | containers: 101 | - args: 102 | - --health-probe-bind-address=:8081 103 | - --metrics-bind-address=127.0.0.1:8080 104 | - --leader-elect 105 | command: 106 | - /manager 107 | env: 108 | - name: WATCH_NAMESPACE 109 | valueFrom: 110 | fieldRef: 111 | fieldPath: metadata.annotations['olm.targetNamespaces'] 112 | - name: CONFIG_SECRET_NAME 113 | value: slack-secret 114 | image: stakater/slack-operator:v0.0.39 115 | livenessProbe: 116 | httpGet: 117 | path: /healthz 118 | port: 8081 119 | initialDelaySeconds: 15 120 | periodSeconds: 20 121 | name: manager 122 | ports: 123 | - containerPort: 9443 124 | name: webhook-server 125 | protocol: TCP 126 | readinessProbe: 127 | httpGet: 128 | path: /readyz 129 | port: 8081 130 | initialDelaySeconds: 5 131 | periodSeconds: 10 132 | resources: 133 | limits: 134 | cpu: 100m 135 | memory: 30Mi 136 | requests: 137 | cpu: 100m 138 | memory: 20Mi 139 | securityContext: 140 | allowPrivilegeEscalation: false 141 | volumeMounts: 142 | - mountPath: /tmp/k8s-webhook-server/serving-certs 143 | name: cert 144 | readOnly: true 145 | - args: 146 | - --secure-listen-address=0.0.0.0:8443 147 | - --upstream=http://127.0.0.1:8080/ 148 | - --logtostderr=true 149 | - --v=10 150 | image: registry.redhat.io/openshift4/ose-kube-rbac-proxy:v4.7.0 151 | name: kube-rbac-proxy 152 | ports: 153 | - containerPort: 8443 154 | name: https 155 | resources: {} 156 | securityContext: 157 | runAsNonRoot: true 158 | serviceAccountName: slack-operator-controller-manager 159 | terminationGracePeriodSeconds: 10 160 | volumes: 161 | - name: cert 162 | secret: 163 | defaultMode: 420 164 | secretName: webhook-server-cert 165 | permissions: 166 | - rules: 167 | - apiGroups: 168 | - "" 169 | resources: 170 | - configmaps 171 | verbs: 172 | - get 173 | - list 174 | - watch 175 | - create 176 | - update 177 | - patch 178 | - delete 179 | - apiGroups: 180 | - "" 181 | resources: 182 | - configmaps/status 183 | verbs: 184 | - get 185 | - update 186 | - patch 187 | - apiGroups: 188 | - coordination.k8s.io 189 | resources: 190 | - leases 191 | verbs: 192 | - get 193 | - list 194 | - watch 195 | - create 196 | - update 197 | - patch 198 | - delete 199 | - apiGroups: 200 | - "" 201 | resources: 202 | - events 203 | verbs: 204 | - create 205 | - patch 206 | serviceAccountName: slack-operator-controller-manager 207 | strategy: deployment 208 | installModes: 209 | - supported: true 210 | type: OwnNamespace 211 | - supported: true 212 | type: SingleNamespace 213 | - supported: false 214 | type: MultiNamespace 215 | - supported: true 216 | type: AllNamespaces 217 | keywords: 218 | - operator 219 | - slack 220 | - kubernetes 221 | - channel 222 | - stakater 223 | - openshift 224 | links: 225 | - name: Slack Operator 226 | url: https://slack-operator.domain 227 | maturity: alpha 228 | provider: 229 | name: stakater 230 | url: https://stakater.com 231 | version: 0.0.39 232 | webhookdefinitions: 233 | - admissionReviewVersions: 234 | - v1 235 | - v1beta1 236 | containerPort: 443 237 | deploymentName: slack-operator-controller-manager 238 | failurePolicy: Fail 239 | generateName: vchannel.kb.io 240 | rules: 241 | - apiGroups: 242 | - slack.stakater.com 243 | apiVersions: 244 | - v1alpha1 245 | operations: 246 | - CREATE 247 | - UPDATE 248 | resources: 249 | - channels 250 | sideEffects: None 251 | targetPort: 9443 252 | type: ValidatingAdmissionWebhook 253 | webhookPath: /validate-slack-stakater-com-v1alpha1-channel 254 | - admissionReviewVersions: 255 | - v1 256 | - v1beta1 257 | containerPort: 443 258 | deploymentName: slack-operator-controller-manager 259 | failurePolicy: Fail 260 | generateName: mchannel.kb.io 261 | rules: 262 | - apiGroups: 263 | - slack.stakater.com 264 | apiVersions: 265 | - v1alpha1 266 | operations: 267 | - CREATE 268 | - UPDATE 269 | resources: 270 | - channels 271 | sideEffects: None 272 | targetPort: 9443 273 | type: MutatingAdmissionWebhook 274 | webhookPath: /mutate-slack-stakater-com-v1alpha1-channel 275 | -------------------------------------------------------------------------------- /controllers/channel_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | 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 controllers 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/go-logr/logr" 23 | "k8s.io/apimachinery/pkg/api/errors" 24 | "k8s.io/apimachinery/pkg/runtime" 25 | ctrl "sigs.k8s.io/controller-runtime" 26 | "sigs.k8s.io/controller-runtime/pkg/client" 27 | 28 | finalizerUtil "github.com/stakater/operator-utils/util/finalizer" 29 | reconcilerUtil "github.com/stakater/operator-utils/util/reconciler" 30 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 31 | slack "github.com/stakater/slack-operator/pkg/slack" 32 | pkgutil "github.com/stakater/slack-operator/pkg/util" 33 | ) 34 | 35 | var ( 36 | channelFinalizer string = "slack.stakater.com/channel" 37 | ) 38 | 39 | // ChannelReconciler reconciles a Channel object 40 | type ChannelReconciler struct { 41 | client.Client 42 | Log logr.Logger 43 | Scheme *runtime.Scheme 44 | SlackService slack.Service 45 | } 46 | 47 | // +kubebuilder:rbac:groups=slack.stakater.com,resources=channels,verbs=get;list;watch;create;update;patch;delete 48 | // +kubebuilder:rbac:groups=slack.stakater.com,resources=channels/status,verbs=get;update;patch 49 | // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list 50 | 51 | // Reconcile loop for the Channel resource 52 | func (r *ChannelReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 53 | log := r.Log.WithValues("channel", req.NamespacedName) 54 | 55 | channel := &slackv1alpha1.Channel{} 56 | err := r.Get(ctx, req.NamespacedName, channel) 57 | 58 | if err != nil { 59 | if errors.IsNotFound(err) { 60 | // Request object not found, could have been deleted after reconcile request. 61 | // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. 62 | // Return and don't requeue 63 | return reconcilerUtil.DoNotRequeue() 64 | } 65 | // Error reading channel, requeue 66 | return reconcilerUtil.RequeueWithError(err) 67 | } 68 | 69 | // Channel is marked for deletion 70 | if channel.GetDeletionTimestamp() != nil { 71 | log.Info("Deletion timestamp found for channel " + req.Name) 72 | if finalizerUtil.HasFinalizer(channel, channelFinalizer) { 73 | return r.finalizeChannel(req, channel) 74 | } 75 | // Finalizer doesn't exist so clean up is already done 76 | return reconcilerUtil.DoNotRequeue() 77 | } 78 | 79 | // Add finalizer if it doesn't exist 80 | if !finalizerUtil.HasFinalizer(channel, channelFinalizer) { 81 | log.Info("Adding finalizer for channel " + req.Name) 82 | 83 | // Base object for patch, which patches using the merge-patch strategy with the given object as base. 84 | channelPatchBase := client.MergeFrom(channel.DeepCopy()) 85 | 86 | finalizerUtil.AddFinalizer(channel, channelFinalizer) 87 | 88 | err := r.Client.Patch(ctx, channel, channelPatchBase) 89 | if err != nil { 90 | return reconcilerUtil.ManageError(r.Client, channel, err, true) 91 | } 92 | } 93 | 94 | // Check for validity of slack channel custom resource 95 | err = r.SlackService.IsValidChannel(channel) 96 | if err != nil { 97 | return reconcilerUtil.ManageError(r.Client, channel, err, true) 98 | } 99 | 100 | if channel.Status.ID == "" { 101 | name := channel.Spec.Name 102 | isPrivate := channel.Spec.Private 103 | 104 | log.Info("Creating new channel", "name", name) 105 | 106 | channelID, err := r.SlackService.CreateChannel(name, isPrivate) 107 | if err != nil { 108 | if err.Error() == "name_taken" { 109 | // Check if the channel already exists and then just reconstruct the status accordingly 110 | existingChannel, err := r.SlackService.GetChannelByName(name) 111 | if err != nil { 112 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 113 | } 114 | 115 | if existingChannel != nil && existingChannel.GroupConversation.IsArchived { 116 | err = r.SlackService.UnArchiveChannel(existingChannel) 117 | if err != nil { 118 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 119 | } 120 | } 121 | channelID = &existingChannel.ID 122 | } else { 123 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 124 | } 125 | } 126 | 127 | // Base object for patch, which patches using the merge-patch strategy with the given object as base. 128 | channelPatchBase := client.MergeFrom(channel.DeepCopy()) 129 | 130 | channel.Status.ID = *channelID 131 | 132 | err = r.Status().Patch(ctx, channel, channelPatchBase) 133 | if err != nil { 134 | log.Error(err, "Failed to update Channel status") 135 | return reconcilerUtil.ManageError(r.Client, channel, err, true) 136 | } 137 | return r.updateSlackChannel(ctx, channel) 138 | } 139 | 140 | existingChannel, err := r.SlackService.GetChannel(channel.Status.ID) 141 | if err != nil { 142 | return reconcilerUtil.ManageError(r.Client, channel, err, true) 143 | } 144 | 145 | existingChannelCR := r.SlackService.GetChannelCRFromChannel(existingChannel) 146 | 147 | err = slackv1alpha1.ValidateImmutableFields(existingChannelCR, channel) 148 | if err != nil { 149 | return reconcilerUtil.ManageError(r.Client, channel, err, true) 150 | } 151 | 152 | updated, err := r.SlackService.IsChannelUpdated(channel) 153 | if err != nil { 154 | return pkgutil.ManageError(ctx, r.Client, channel, err) 155 | } 156 | 157 | if !updated { 158 | log.Info("Skipping update. No changes found") 159 | return reconcilerUtil.DoNotRequeue() 160 | } 161 | 162 | return r.updateSlackChannel(ctx, channel) 163 | } 164 | 165 | func (r *ChannelReconciler) updateSlackChannel(ctx context.Context, channel *slackv1alpha1.Channel) (ctrl.Result, error) { 166 | channelID := channel.Status.ID 167 | log := r.Log.WithValues("channelID", channelID) 168 | 169 | log.Info("Updating channel details") 170 | 171 | name := channel.Spec.Name 172 | users := channel.Spec.Users 173 | topic := channel.Spec.Topic 174 | description := channel.Spec.Description 175 | 176 | _, err := r.SlackService.RenameChannel(channelID, name) 177 | if err != nil { 178 | log.Error(err, "Error renaming channel") 179 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 180 | } 181 | 182 | _, err = r.SlackService.SetTopic(channelID, topic) 183 | if err != nil { 184 | log.Error(err, "Error setting channel topic") 185 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 186 | } 187 | 188 | _, err = r.SlackService.SetDescription(channelID, description) 189 | if err != nil { 190 | log.Error(err, "Error setting channel description") 191 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 192 | } 193 | 194 | errorlist := r.SlackService.InviteUsers(channelID, users) 195 | if len(errorlist) > 0 { 196 | log.Error(err, "Error inviting users to channel") 197 | return pkgutil.ManageError(ctx, r.Client, channel, pkgutil.MapErrorListToError(errorlist)) 198 | } 199 | 200 | err = r.SlackService.RemoveUsers(channelID, users) 201 | if err != nil { 202 | log.Error(err, "Error removing users from the channel") 203 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 204 | } 205 | 206 | return reconcilerUtil.ManageSuccess(r.Client, channel) 207 | } 208 | 209 | func (r *ChannelReconciler) finalizeChannel(req ctrl.Request, channel *slackv1alpha1.Channel) (ctrl.Result, error) { 210 | if channel == nil { 211 | return reconcilerUtil.DoNotRequeue() 212 | } 213 | 214 | channelID := channel.Status.ID 215 | log := r.Log.WithValues("channelID", channelID) 216 | 217 | err := r.SlackService.ArchiveChannel(channelID) 218 | 219 | if err != nil && err.Error() != "channel_not_found" && err.Error() != "already_archived" { 220 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 221 | } 222 | 223 | // Base object for patch, which patches using the merge-patch strategy with the given object as base. 224 | channelPatchBase := client.MergeFrom(channel.DeepCopy()) 225 | 226 | finalizerUtil.DeleteFinalizer(channel, channelFinalizer) 227 | log.V(1).Info("Finalizer removed for channel") 228 | 229 | err = r.Client.Patch(context.Background(), channel, channelPatchBase) 230 | if err != nil { 231 | return reconcilerUtil.ManageError(r.Client, channel, err, false) 232 | } 233 | 234 | return reconcilerUtil.DoNotRequeue() 235 | } 236 | 237 | // SetupWithManager - Controller-Manager binding configuration 238 | func (r *ChannelReconciler) SetupWithManager(mgr ctrl.Manager) error { 239 | return ctrl.NewControllerManagedBy(mgr). 240 | For(&slackv1alpha1.Channel{}). 241 | Complete(r) 242 | } 243 | -------------------------------------------------------------------------------- /pkg/slack/service.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | 7 | "github.com/go-logr/logr" 8 | "github.com/slack-go/slack" 9 | 10 | slackv1alpha1 "github.com/stakater/slack-operator/api/v1alpha1" 11 | ) 12 | 13 | const ( 14 | ChannelAlreadyExistsError string = "A channel with the same name already exists" 15 | ) 16 | 17 | // Service interface 18 | type Service interface { 19 | CreateChannel(string, bool) (*string, error) 20 | SetDescription(string, string) (*slack.Channel, error) 21 | SetTopic(string, string) (*slack.Channel, error) 22 | RenameChannel(string, string) (*slack.Channel, error) 23 | ArchiveChannel(string) error 24 | InviteUsers(string, []string) []error 25 | RemoveUsers(string, []string) error 26 | GetChannel(string) (*slack.Channel, error) 27 | GetUsersInChannel(channelID string) ([]string, error) 28 | GetChannelCRFromChannel(*slack.Channel) *slackv1alpha1.Channel 29 | IsChannelUpdated(*slackv1alpha1.Channel) (bool, error) 30 | IsValidChannel(*slackv1alpha1.Channel) error 31 | GetChannelByName(string) (*slack.Channel, error) 32 | UnArchiveChannel(*slack.Channel) error 33 | } 34 | 35 | // SlackService structure 36 | type SlackService struct { 37 | log logr.Logger 38 | api *slack.Client 39 | } 40 | 41 | // New creates a new SlackService 42 | func New(APIToken string, logger logr.Logger) *SlackService { 43 | return &SlackService{ 44 | api: slack.New(APIToken), 45 | log: logger, 46 | } 47 | } 48 | 49 | // GetChannel gets a channel on slack 50 | func (s *SlackService) GetChannel(channelID string) (*slack.Channel, error) { 51 | log := s.log.WithValues("channelID", channelID) 52 | 53 | channel, err := s.api.GetConversationInfo(channelID, false) 54 | if err != nil { 55 | log.Error(err, "Error fetching channel") 56 | return nil, err 57 | } 58 | 59 | return channel, err 60 | } 61 | 62 | // CreateChannel creates a public or private channel on slack with the given name 63 | func (s *SlackService) CreateChannel(name string, isPrivate bool) (*string, error) { 64 | s.log.Info("Creating Slack Channel", "name", name, "isPrivate", isPrivate) 65 | 66 | channel, err := s.api.CreateConversation(name, isPrivate) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | s.log.V(1).Info("Created Slack Channel", "channel", channel) 72 | 73 | return &channel.ID, nil 74 | } 75 | 76 | // SetDescription sets description/"purpose" of the slack channel 77 | func (s *SlackService) SetDescription(channelID string, description string) (*slack.Channel, error) { 78 | log := s.log.WithValues("channelID", channelID) 79 | 80 | channel, err := s.api.GetConversationInfo(channelID, false) 81 | 82 | if err != nil { 83 | log.Error(err, "Error fetching channel") 84 | return nil, err 85 | } 86 | 87 | if html.UnescapeString(channel.Purpose.Value) == description { 88 | return channel, nil 89 | } 90 | 91 | log.V(1).Info("Setting Description of the Slack Channel") 92 | 93 | channel, err = s.api.SetPurposeOfConversation(channelID, description) 94 | 95 | if err != nil { 96 | log.Error(err, "Error setting description of the channel") 97 | return nil, err 98 | } 99 | return channel, nil 100 | } 101 | 102 | // SetTopic sets "topic" of the slack channel 103 | func (s *SlackService) SetTopic(channelID string, topic string) (*slack.Channel, error) { 104 | log := s.log.WithValues("channelID", channelID) 105 | 106 | channel, err := s.api.GetConversationInfo(channelID, false) 107 | 108 | if err != nil { 109 | log.Error(err, "Error fetching channel") 110 | return nil, err 111 | } 112 | 113 | if html.UnescapeString(channel.Topic.Value) == topic { 114 | return channel, nil 115 | } 116 | 117 | log.V(1).Info("Setting Topic of the Slack Channel") 118 | 119 | channel, err = s.api.SetTopicOfConversation(channelID, topic) 120 | 121 | if err != nil { 122 | log.Error(err, "Error setting topic of the channel") 123 | return nil, err 124 | } 125 | return channel, nil 126 | } 127 | 128 | // RenameChannel renames the slack channel 129 | func (s *SlackService) RenameChannel(channelID string, newName string) (*slack.Channel, error) { 130 | log := s.log.WithValues("channelID", channelID) 131 | 132 | channel, err := s.api.GetConversationInfo(channelID, false) 133 | 134 | if err != nil { 135 | log.Error(err, "Error fetching channel") 136 | return nil, err 137 | } 138 | if html.UnescapeString(channel.Name) == newName { 139 | return channel, nil 140 | } 141 | 142 | log.V(1).Info("Renaming Slack Channel", "newName", newName) 143 | 144 | channel, err = s.api.RenameConversation(channelID, newName) 145 | 146 | if err != nil { 147 | log.Error(err, "Error renaming channel") 148 | return nil, err 149 | } 150 | return channel, nil 151 | } 152 | 153 | // ArchiveChannel archives the slack channel 154 | func (s *SlackService) ArchiveChannel(channelID string) error { 155 | log := s.log.WithValues("channelID", channelID) 156 | 157 | log.V(1).Info("Archiving channel") 158 | err := s.api.ArchiveConversation(channelID) 159 | 160 | if err != nil { 161 | log.Error(err, "Error archiving channel") 162 | return err 163 | } 164 | 165 | return nil 166 | } 167 | 168 | // GetUsersInChannel get all the users in the slack channel 169 | func (s *SlackService) GetUsersInChannel(channelID string) ([]string, error) { 170 | userIDs, _, err := s.api.GetUsersInConversation(&slack.GetUsersInConversationParameters{ 171 | ChannelID: channelID, 172 | Limit: 100000, 173 | }) 174 | 175 | return userIDs, err 176 | } 177 | 178 | // InviteUsers invites users to the slack channel 179 | func (s *SlackService) InviteUsers(channelID string, userEmails []string) []error { 180 | log := s.log.WithValues("channelID", channelID) 181 | 182 | var errorlist []error 183 | 184 | for _, email := range userEmails { 185 | user, err := s.api.GetUserByEmail(email) 186 | 187 | if err != nil { 188 | errorlist = append(errorlist, fmt.Errorf(fmt.Sprintf("Error fetching user by Email %s", email))) 189 | continue 190 | } 191 | 192 | log.V(1).Info("Inviting user to Slack Channel", "userID", user.ID) 193 | _, err = s.api.InviteUsersToConversation(channelID, user.ID) 194 | 195 | if err != nil && err.Error() != "already_in_channel" { 196 | log.Error(err, "Error Inviting user to channel", "userID", user.ID) 197 | errorlist = append(errorlist, err) 198 | } 199 | } 200 | 201 | return errorlist 202 | } 203 | 204 | // RemoveUsers remove users from the slack channel 205 | func (s *SlackService) RemoveUsers(channelID string, userEmails []string) error { 206 | log := s.log.WithValues("channelID", channelID) 207 | 208 | channelUserIDs, err := s.GetUsersInChannel(channelID) 209 | if err != nil { 210 | log.Error(err, "Error getting users in a conversation") 211 | return err 212 | } 213 | 214 | for _, userId := range channelUserIDs { 215 | user, err := s.api.GetUserInfo(userId) 216 | if err != nil { 217 | log.Error(err, "Error fetching user info") 218 | return err 219 | } 220 | 221 | if !user.IsBot { 222 | found := false 223 | for _, email := range userEmails { 224 | if email == user.Profile.Email { 225 | found = true 226 | break 227 | } 228 | } 229 | 230 | if !found { 231 | err = s.api.KickUserFromConversation(channelID, user.ID) 232 | if err != nil { 233 | log.Error(err, "Error removing user from the conversation") 234 | return err 235 | } 236 | } 237 | } 238 | } 239 | 240 | return nil 241 | } 242 | 243 | func (s *SlackService) GetChannelCRFromChannel(existingChannel *slack.Channel) *slackv1alpha1.Channel { 244 | var channel slackv1alpha1.Channel 245 | 246 | channel.Spec.Name = existingChannel.Name 247 | channel.Spec.Description = existingChannel.Purpose.Value 248 | channel.Spec.Topic = existingChannel.Topic.Value 249 | channel.Spec.Private = existingChannel.IsPrivate 250 | channel.Spec.Users = existingChannel.Members 251 | 252 | return &channel 253 | } 254 | 255 | func (s *SlackService) IsChannelUpdated(channel *slackv1alpha1.Channel) (bool, error) { 256 | log := s.log.WithValues("channelID", channel.Status.ID) 257 | 258 | channelID := channel.Status.ID 259 | name := channel.Spec.Name 260 | topic := channel.Spec.Topic 261 | description := channel.Spec.Description 262 | userEmails := channel.Spec.Users 263 | 264 | existingChannel, err := s.api.GetConversationInfo(channel.Status.ID, false) 265 | if err != nil { 266 | log.Error(err, "Error fetching channel") 267 | return false, err 268 | } 269 | 270 | if html.UnescapeString(existingChannel.Name) != name { 271 | return true, nil 272 | } 273 | if html.UnescapeString(existingChannel.Topic.Value) != topic { 274 | return true, nil 275 | } 276 | if html.UnescapeString(existingChannel.Purpose.Value) != description { 277 | return true, nil 278 | } 279 | 280 | channelUserIDs, err := s.GetUsersInChannel(channelID) 281 | if err != nil { 282 | log.Error(err, "Error getting users in a conversation") 283 | return false, err 284 | } 285 | 286 | // Checking if the user is added 287 | for _, email := range userEmails { 288 | user, err := s.api.GetUserByEmail(email) 289 | if err != nil { 290 | log.Error(err, fmt.Sprintf("Error fetching user by Email %s", email)) 291 | return false, err 292 | } 293 | 294 | found := false 295 | for _, id := range channelUserIDs { 296 | if user.ID == id { 297 | found = true 298 | break 299 | } 300 | } 301 | 302 | if !found { 303 | return true, nil 304 | } 305 | } 306 | 307 | // Checking if the user is removed 308 | for _, userId := range channelUserIDs { 309 | user, err := s.api.GetUserInfo(userId) 310 | if err != nil { 311 | log.Error(err, "Error fetching user info") 312 | return false, err 313 | } 314 | 315 | if !user.IsBot { 316 | found := false 317 | for _, email := range userEmails { 318 | if email == user.Profile.Email { 319 | found = true 320 | break 321 | } 322 | } 323 | 324 | if !found { 325 | return true, nil 326 | } 327 | } 328 | } 329 | 330 | return false, nil 331 | } 332 | 333 | func (s *SlackService) IsValidChannel(channel *slackv1alpha1.Channel) error { 334 | if len(channel.Spec.Users) < 1 { 335 | return fmt.Errorf("Users can not be empty") 336 | } 337 | 338 | return nil 339 | } 340 | 341 | // GetChannelByName search for the channel on slack by name 342 | func (s *SlackService) GetChannelByName(name string) (*slack.Channel, error) { 343 | var cursor string 344 | 345 | for { 346 | channels, nextCursor, err := s.api.GetConversations(&slack.GetConversationsParameters{ 347 | Types: []string{ 348 | "private_channel", 349 | "public_channel", 350 | }, 351 | Cursor: cursor, 352 | Limit: 200, 353 | ExcludeArchived: "false", 354 | }) 355 | if err != nil { 356 | return nil, err 357 | } 358 | 359 | for _, channel := range channels { 360 | if channel.Name == name { 361 | return &channel, nil 362 | } 363 | } 364 | 365 | if nextCursor == "" { 366 | break 367 | } 368 | cursor = nextCursor 369 | } 370 | 371 | return nil, fmt.Errorf(ChannelAlreadyExistsError) 372 | } 373 | 374 | // UnArchiveChannel unarchives the channel 375 | func (s *SlackService) UnArchiveChannel(channel *slack.Channel) error { 376 | err := s.api.UnArchiveConversation(channel.ID) 377 | if err != nil { 378 | return err 379 | } 380 | return nil 381 | } 382 | --------------------------------------------------------------------------------