├── config ├── webhook │ ├── manifests.yaml │ ├── kustomization.yaml │ ├── service.yaml │ └── kustomizeconfig.yaml ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── certmanager │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ └── certificate.yaml ├── rbac │ ├── service_account.yaml │ ├── auth_proxy_client_clusterrole.yaml │ ├── role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── role_binding.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_service.yaml │ ├── leader_election_role_binding.yaml │ ├── template_viewer_role.yaml │ ├── kustomization.yaml │ ├── template_editor_role.yaml │ └── leader_election_role.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── crd │ ├── patches │ │ ├── cainjection_in_templates.yaml │ │ └── webhook_in_templates.yaml │ ├── kustomizeconfig.yaml │ ├── kustomization.yaml │ └── bases │ │ └── templating.flanksource.com_rests.yaml ├── samples │ ├── example_ingress.yaml │ └── templating.flanksource.com_v1_template.yaml ├── default │ ├── manager_webhook_patch.yaml │ ├── webhookcainjection_patch.yaml │ └── kustomization.yaml └── base │ ├── kustomization.yaml │ └── deploy.yml ├── .gitignore ├── examples ├── namespace-request-a.yml ├── for-each-test.yml ├── postgresqldb.yml ├── git-repository.yaml ├── static-secret.yaml ├── rest.yml ├── when.yaml ├── namespacerequest.yml ├── tutorial-crd.yaml ├── for-each.yml └── postgres-operator.yml ├── PROJECT ├── test ├── fixtures │ ├── git-repository.yaml │ ├── copy-to-namespace.yml │ ├── mockserver.yml │ └── depends-on.yaml ├── config.yaml ├── e2e.sh └── patch1.yaml ├── hack └── boilerplate.go.txt ├── .releaserc ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── Dockerfile ├── example-public-apis.yaml ├── controllers ├── client.go ├── suite_test.go ├── crd_controller.go ├── template_controller.go └── rest_controller.go ├── k8s ├── suite_test.go ├── template_manager_test.go ├── watcher.go ├── schema_cache.go ├── patches.go ├── rest_manager.go └── patches_test.go ├── api └── v1 │ ├── groupversion_info.go │ ├── rest_types.go │ ├── template_types.go │ └── zz_generated.deepcopy.go ├── Makefile ├── main.go ├── README.md ├── go.mod ├── docs └── template-operator-intro-part-1.md └── LICENSE /config/webhook/manifests.yaml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | cover.out 3 | .env 4 | /.bin 5 | /.certs 6 | /karina* 7 | 8 | #intelij project files 9 | .idea/ 10 | *.iml -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: manager 5 | namespace: system 6 | -------------------------------------------------------------------------------- /config/webhook/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manifests.yaml 3 | - service.yaml 4 | 5 | configurations: 6 | - kustomizeconfig.yaml 7 | -------------------------------------------------------------------------------- /examples/namespace-request-a.yml: -------------------------------------------------------------------------------- 1 | apiVersion: acmp.corp/v1 2 | kind: NamespaceRequest 3 | metadata: 4 | name: a 5 | spec: 6 | team: blue-team 7 | memory: 16 8 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: flanksource.com 2 | repo: github.com/flanksource/template-operator 3 | resources: 4 | - group: templating.flanksource.com 5 | kind: Template 6 | version: v1 7 | version: "2" 8 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1beta1 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: flanksource/template-operator 8 | newTag: dev 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/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 | - '*' 13 | verbs: 14 | - '*' 15 | -------------------------------------------------------------------------------- /examples/for-each-test.yml: -------------------------------------------------------------------------------- 1 | apiVersion: abcd.flanksource.com/v1 2 | kind: ABCD 3 | metadata: 4 | name: test 5 | namespace: default 6 | spec: 7 | topics: 8 | - a 9 | - b 10 | - c 11 | - d 12 | topicsMap: 13 | a1: a2 14 | b1: b2 15 | c1: c2 16 | d1: d2 -------------------------------------------------------------------------------- /test/fixtures/git-repository.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: source.toolkit.fluxcd.io/v1beta1 2 | kind: GitRepository 3 | metadata: 4 | name: template-operator-dashboards 5 | namespace: default 6 | spec: 7 | interval: 5m 8 | url: https://github.com/flanksource/template-operator-e2e-test 9 | ref: 10 | branch: master -------------------------------------------------------------------------------- /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: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /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: 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/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: controller-manager-metrics-service 7 | namespace: system 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | targetPort: https 13 | selector: 14 | control-plane: controller-manager 15 | -------------------------------------------------------------------------------- /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: manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /examples/postgresqldb.yml: -------------------------------------------------------------------------------- 1 | apiVersion: db.flanksource.com/v1 2 | kind: PostgresqlDB 3 | metadata: 4 | name: test1 5 | namespace: postgres-operator 6 | spec: 7 | replicas: 2 8 | parameters: 9 | max_connections: "1024" 10 | shared_buffers: 4759MB 11 | work_mem: 475MB 12 | maintenance_work_mem: 634MB 13 | storage: 14 | storageClass: vsan 15 | backup: 16 | bucket: foo -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_templates.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/v1beta1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 8 | name: templates.templating.flanksource.com 9 | -------------------------------------------------------------------------------- /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 | selector: 15 | matchLabels: 16 | control-plane: controller-manager 17 | -------------------------------------------------------------------------------- /config/rbac/template_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view templates. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: template-viewer-role 6 | rules: 7 | - apiGroups: 8 | - templating.flanksource.com 9 | resources: 10 | - templates 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - templating.flanksource.com 17 | resources: 18 | - templates/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/samples/example_ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: example-ingress 5 | namespace: template-operator 6 | labels: 7 | "platform.flanksource.com/autocomplete-ingress-domain": "true" 8 | spec: 9 | rules: 10 | - host: example-ingress 11 | http: 12 | paths: 13 | - backend: 14 | serviceName: podinfo 15 | servicePort: 9898 16 | tls: 17 | - hosts: 18 | - example-ingress 19 | secretName: example-ingress-tls -------------------------------------------------------------------------------- /test/fixtures/copy-to-namespace.yml: -------------------------------------------------------------------------------- 1 | apiVersion: templating.flanksource.com/v1 2 | kind: Template 3 | metadata: 4 | name: copy-secret-e2e 5 | spec: 6 | source: 7 | apiVersion: v1 8 | kind: Secret 9 | namespaceSelector: 10 | matchLabels: 11 | e2e-namespace-role: "copy-to-namespace-source" 12 | labelSelector: 13 | matchLabels: 14 | e2e-test: "copy-to-namespace" 15 | copyToNamespaces: 16 | namespaces: 17 | - template-operator-e2e-dest-1 18 | - template-operator-e2e-dest-2 -------------------------------------------------------------------------------- /config/rbac/template_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit templates. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: template-editor-role 6 | rules: 7 | - apiGroups: 8 | - templating.flanksource.com 9 | resources: 10 | - templates 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - templating.flanksource.com 21 | resources: 22 | - templates/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/webhookClientConfig/service/name 9 | 10 | namespace: 11 | - kind: CustomResourceDefinition 12 | group: apiextensions.k8s.io 13 | path: spec/conversion/webhookClientConfig/service/namespace 14 | create: false 15 | 16 | varReference: 17 | - path: metadata/annotations 18 | -------------------------------------------------------------------------------- /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 | - "" 29 | resources: 30 | - events 31 | verbs: 32 | - create 33 | -------------------------------------------------------------------------------- /examples/git-repository.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: templating.flanksource.com/v1 2 | kind: Template 3 | metadata: 4 | name: git-repository 5 | spec: 6 | source: 7 | gitRepository: 8 | namespace: default 9 | name: template-operator-dashboards 10 | glob: "/grafana/dashboards/*.json" 11 | resources: 12 | - apiVersion: integreatly.org/v1alpha1 13 | kind: GrafanaDashboard 14 | metadata: 15 | name: "{{ .filename | filepath.Base }}" 16 | namespace: monitoring 17 | labels: 18 | app: grafana 19 | spec: 20 | json: "{{ .content }}" -------------------------------------------------------------------------------- /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 | */ -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/v1beta1 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/v1beta1 11 | kind: ValidatingWebhookConfiguration 12 | metadata: 13 | name: validating-webhook-configuration 14 | annotations: 15 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 16 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | plugins: 2 | - - "@semantic-release/commit-analyzer" 3 | - releaseRules: 4 | - { type: doc, scope: README, release: patch } 5 | - { type: fix, release: patch } 6 | - { type: chore, release: patch } 7 | - { type: refactor, release: patch } 8 | - { type: feat, release: minor } 9 | - { type: ci, release: patch } 10 | - { type: style, release: patch } 11 | parserOpts: 12 | noteKeywords: 13 | - MAJOR RELEASE 14 | - "@semantic-release/release-notes-generator" 15 | - - "@semantic-release/github" 16 | - assets: 17 | - path: ./config/default/operator.yml 18 | name: operator.yml 19 | branches: 20 | - main 21 | - master 22 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_templates.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables conversion webhook for CRD 2 | # CRD conversion requires k8s 1.13 or later. 3 | apiVersion: apiextensions.k8s.io/v1beta1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | name: templates.templating.flanksource.com 7 | spec: 8 | conversion: 9 | strategy: Webhook 10 | webhookClientConfig: 11 | # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank, 12 | # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager) 13 | caBundle: Cg== 14 | service: 15 | namespace: system 16 | name: webhook-service 17 | path: /convert 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.19.x] 8 | platform: [ubuntu-latest] 9 | k8s: 10 | - v1.18.6 11 | - v1.20.7 12 | runs-on: ${{ matrix.platform }} 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v2 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | - name: Test 21 | env: 22 | KUBERNETES_VERSION: ${{matrix.k8s}} 23 | run: ./test/e2e.sh 24 | - name: Export logs 25 | if: always() 26 | run: kind --name kind-kind export logs ./logs 27 | - name: Upload logs 28 | if: always() 29 | uses: actions/upload-artifact@v2 30 | with: 31 | name: log 32 | path: ./logs 33 | -------------------------------------------------------------------------------- /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.19 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 k8s/ k8s/ 17 | 18 | # Build 19 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -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 nonroot:nonroot 27 | 28 | ENTRYPOINT ["/manager"] 29 | -------------------------------------------------------------------------------- /examples/static-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: test-secrets 5 | labels: 6 | template-operator-test: secrets 7 | --- 8 | apiVersion: v1 9 | kind: Secret 10 | metadata: 11 | name: template-default-secrets 12 | namespace: test-secrets 13 | labels: 14 | secrets.flanksource.com/label: defaults 15 | stringData: 16 | domain: 127.0.0.1.nip.io 17 | foo: bar 18 | type: Opaque 19 | --- 20 | apiVersion: templating.flanksource.com/v1 21 | kind: Template 22 | metadata: 23 | name: copy-secret 24 | spec: 25 | source: 26 | apiVersion: v1 27 | kind: Secret 28 | namespaceSelector: 29 | matchLabels: 30 | template-operator-test: secrets 31 | labelSelector: 32 | matchLabels: 33 | secrets.flanksource.com/label: defaults 34 | copyToNamespaces: 35 | namespaces: 36 | - minio 37 | - monitoring 38 | - template-operator 39 | - quack -------------------------------------------------------------------------------- /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/v1alpha2 6 | kind: Issuer 7 | metadata: 8 | name: selfsigned-issuer 9 | namespace: system 10 | spec: 11 | selfSigned: {} 12 | --- 13 | apiVersion: cert-manager.io/v1alpha2 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 | -------------------------------------------------------------------------------- /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/templating.flanksource.com_templates.yaml 6 | - bases/templating.flanksource.com_rests.yaml 7 | # +kubebuilder:scaffold:crdkustomizeresource 8 | 9 | patchesStrategicMerge: 10 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 11 | # patches here are for enabling the conversion webhook for each CRD 12 | #- patches/webhook_in_templates.yaml 13 | # +kubebuilder:scaffold:crdkustomizewebhookpatch 14 | 15 | # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. 16 | # patches here are for enabling the CA injection for each CRD 17 | #- patches/cainjection_in_templates.yaml 18 | # +kubebuilder:scaffold:crdkustomizecainjectionpatch 19 | 20 | # the following config is for teaching kustomize how to do kustomization for CRDs. 21 | configurations: 22 | - kustomizeconfig.yaml 23 | -------------------------------------------------------------------------------- /example-public-apis.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: source.toolkit.fluxcd.io/v1beta1 2 | kind: GitRepository 3 | metadata: 4 | name: public-apis 5 | namespace: default 6 | spec: 7 | interval: 5m 8 | url: https://github.com/public-apis/public-apis 9 | ref: 10 | branch: master 11 | --- 12 | apiVersion: templating.flanksource.com/v1 13 | kind: Template 14 | metadata: 15 | name: git-repository 16 | spec: 17 | source: 18 | gitRepository: 19 | namespace: default 20 | name: public-apis 21 | glob: "/README.md" 22 | resourcesTemplate: | 23 | {{- range $table := (.content | parseMarkdownTables) }} 24 | {{- range $row := $table.Rows }} 25 | apiVersion: canaries.flanksource.com/v1 26 | kind: Canary 27 | metadata: 28 | name: {{ (index $row 0) | strings.Slug | strings.ReplaceAll "_" "-" }} 29 | namespace: default 30 | labels: 31 | app: public-apis 32 | spec: 33 | interval: 60 34 | http: 35 | - description: {{ index $row 1 | strings.ReplaceAll "\"" "'" | strings.ReplaceAll ":" "" }} 36 | endpoint: "{{ index $row 0 }}" 37 | responseCodes: [200, 201, 202, 301, 302] 38 | thresholdMillis: 2000 39 | --- 40 | {{- end }} 41 | {{- end }} -------------------------------------------------------------------------------- /test/fixtures/mockserver.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: mockserver 5 | --- 6 | apiVersion: apps/v1 7 | kind: Deployment 8 | metadata: 9 | name: mockserver 10 | namespace: mockserver 11 | labels: 12 | app: mockserver 13 | spec: 14 | replicas: 1 15 | selector: 16 | matchLabels: 17 | app: mockserver 18 | template: 19 | metadata: 20 | labels: 21 | app: mockserver 22 | spec: 23 | containers: 24 | - name: mockserver 25 | image: mockserver/mockserver:latest 26 | ports: 27 | - containerPort: 1080 28 | --- 29 | apiVersion: v1 30 | kind: Service 31 | metadata: 32 | name: mockserver 33 | namespace: mockserver 34 | spec: 35 | type: NodePort 36 | selector: 37 | app: mockserver 38 | ports: 39 | - port: 80 40 | targetPort: 1080 41 | --- 42 | apiVersion: networking.k8s.io/v1beta1 43 | kind: Ingress 44 | metadata: 45 | name: mockserver 46 | namespace: mockserver 47 | annotations: 48 | kubernetes.io/tls-acme: "true" 49 | spec: 50 | tls: 51 | - secretName: mockserver-tls 52 | hosts: 53 | - mockserver.127.0.0.1.nip.io 54 | rules: 55 | - host: mockserver.127.0.0.1.nip.io 56 | http: 57 | paths: 58 | - backend: 59 | serviceName: mockserver 60 | servicePort: 80 61 | -------------------------------------------------------------------------------- /controllers/client.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/flanksource/kommons" 7 | "github.com/flanksource/template-operator/k8s" 8 | "github.com/go-logr/logr" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | "k8s.io/client-go/discovery" 11 | "k8s.io/client-go/tools/record" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | ) 14 | 15 | const ( 16 | CRDV1Version = "v1" 17 | CRDV1Group = "apiextensions.k8s.io" 18 | ) 19 | 20 | type Client struct { 21 | ControllerClient client.Client 22 | KommonsClient *kommons.Client 23 | Events record.EventRecorder 24 | Log logr.Logger 25 | Scheme *runtime.Scheme 26 | Cache *k8s.SchemaCache 27 | Discovery discovery.DiscoveryInterface 28 | Watcher k8s.WatcherInterface 29 | } 30 | 31 | // HasKind detects if the given api group with specified version is supported by the server 32 | func (c *Client) HasKind(groupName, version string) (bool, error) { 33 | if c.Discovery != nil { 34 | groups, err := c.Discovery.ServerGroups() 35 | if err != nil { 36 | return false, err 37 | } 38 | for _, group := range groups.Groups { 39 | for _, groupVersion := range group.Versions { 40 | if groupVersion.GroupVersion == groupName+"/"+version { 41 | return true, nil 42 | } 43 | } 44 | } 45 | return false, nil 46 | } 47 | return false, fmt.Errorf("discovery API is not available") 48 | } 49 | -------------------------------------------------------------------------------- /k8s/suite_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "k8s.io/apimachinery/pkg/runtime" 10 | ) 11 | 12 | func TestK8s(t *testing.T) { 13 | RegisterFailHandler(Fail) 14 | RunSpecs(t, "K8S Suite") 15 | } 16 | 17 | type TestEventRecorder struct{} 18 | 19 | func (r *TestEventRecorder) Event(object runtime.Object, eventtype, reason, message string) { 20 | r.event(object, eventtype, reason, message, map[string]string{}) 21 | } 22 | 23 | // Eventf is just like Event, but with Sprintf for the message field. 24 | func (r *TestEventRecorder) Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{}) { 25 | msg := fmt.Sprintf(messageFmt, args...) 26 | r.event(object, eventtype, reason, msg, map[string]string{}) 27 | } 28 | 29 | // AnnotatedEventf is just like eventf, but with annotations attached 30 | func (r *TestEventRecorder) AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{}) { 31 | msg := fmt.Sprintf(messageFmt, args...) 32 | r.event(object, eventtype, reason, msg, annotations) 33 | } 34 | 35 | func (r *TestEventRecorder) event(object runtime.Object, eventtype, reason, message string, annotations map[string]string) { 36 | fmt.Printf("Received event type=%s reason=%s message='%s' on object %v\n", eventtype, reason, message, object) 37 | } 38 | -------------------------------------------------------------------------------- /api/v1/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 v1 contains API Schema definitions for the templating.flanksource.com v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=templating.flanksource.com 20 | package v1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | const ApiVersion = "templating.flanksource.com/v1" 28 | 29 | var ( 30 | // GroupVersion is group version used to register these objects 31 | GroupVersion = schema.GroupVersion{Group: "templating.flanksource.com", Version: "v1"} 32 | 33 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 34 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 35 | 36 | // AddToScheme adds the types in this group-version to the given scheme. 37 | AddToScheme = SchemeBuilder.AddToScheme 38 | ) 39 | -------------------------------------------------------------------------------- /config/samples/templating.flanksource.com_v1_template.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: templating.flanksource.com/v1 2 | kind: Template 3 | metadata: 4 | name: dynamic-ingress-hostnames 5 | namespace: template-operator 6 | spec: 7 | onceoff: true # need to set this flag as otherwise it will trigger an endless loop appending the domain to iteself. 8 | source: 9 | apiVersion: extensions/v1beta1 10 | kind: Ingress 11 | namespaceSelector: 12 | matchLabels: 13 | quack.pusher.com/enabled: "true" 14 | control-plane: controller-manager 15 | labelSelector: 16 | matchLabels: 17 | "platform.flanksource.com/autocomplete-ingress-domain": "true" 18 | patches: 19 | - | 20 | apiVersion: extensions/v1beta1 21 | kind: Ingress 22 | metadata: 23 | annotations: 24 | "platform.flanksource.com/applied-domain": "{{- kget "cm/quack/quack-config" "data.domain" -}}" 25 | jsonPatches: 26 | - object: 27 | apiVersion: extensions/v1beta1 28 | kind: Ingress 29 | patch: | 30 | [ 31 | { 32 | "op": "replace", 33 | "path": "/spec/rules/0/host", 34 | "value": "{{ jsonPath .source "spec.rules.0.host" }}.{{- kget "cm/quack/quack-config" "data.domain" -}}" 35 | }, 36 | { 37 | "op": "replace", 38 | "path": "/spec/tls/0/hosts/0", 39 | "value": "{{ jsonPath .source "spec.tls.0.hosts.0" }}.{{- kget "cm/quack/quack-config" "data.domain" -}}" 40 | } 41 | ] 42 | -------------------------------------------------------------------------------- /examples/rest.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: "example-alertmanager-http-auth" 5 | namespace: default 6 | stringData: 7 | username: foo 8 | password: bar 9 | --- 10 | apiVersion: templating.flanksource.com/v1 11 | kind: REST 12 | metadata: 13 | name: "example-alertmanager" 14 | spec: 15 | auth: 16 | username: 17 | secretKeyRef: 18 | name: example-alertmanager-http-auth 19 | key: username 20 | password: 21 | secretKeyRef: 22 | name: example-alertmanager-http-auth 23 | key: password 24 | namespace: default 25 | headers: 26 | Content-Type: application/json 27 | update: 28 | url: http://alertmanager-main.monitoring:9093/api/v2/silences 29 | method: POST 30 | body: | 31 | { 32 | "matchers": [ 33 | { 34 | "name": "alertname", 35 | "value": "ExcessivePodCPURatio", 36 | "isRegex": false, 37 | "isEqual": true 38 | } 39 | ], 40 | {{ if .status.silenceID }} 41 | "id": "{{ .status.silenceID }}", 42 | {{ end }} 43 | "startsAt": "2021-07-14T10:19:19.862Z", 44 | "endsAt": "2021-11-14T10:19:19.862Z", 45 | "createdBy": "template-operator", 46 | "comment": "Automatically created by template operator REST" 47 | } 48 | status: 49 | silenceID: "{{ .response.silenceID }}" 50 | remove: 51 | method: DELETE 52 | url: http://alertmanager-main.monitoring:9093/api/v2/silence/{{.status.silenceID }} -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | kind: Namespace 2 | apiVersion: v1 3 | metadata: 4 | name: template-operator 5 | --- 6 | apiVersion: apps/v1 7 | kind: Deployment 8 | metadata: 9 | name: controller-manager 10 | namespace: system 11 | labels: 12 | control-plane: template-operator 13 | spec: 14 | selector: 15 | matchLabels: 16 | control-plane: template-operator 17 | replicas: 1 18 | template: 19 | metadata: 20 | labels: 21 | control-plane: template-operator 22 | spec: 23 | containers: 24 | - image: controller:latest 25 | args: 26 | - "--metrics-addr=0.0.0.0:8080" 27 | - "--enable-leader-election" 28 | - "--sync-period=20s" 29 | name: manager 30 | resources: 31 | limits: 32 | cpu: 100m 33 | memory: 130Mi 34 | requests: 35 | cpu: 100m 36 | memory: 120Mi 37 | - name: kube-rbac-proxy 38 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 39 | args: 40 | - "--secure-listen-address=0.0.0.0:8443" 41 | - "--upstream=http://127.0.0.1:8080/" 42 | - "--logtostderr=true" 43 | - "--v=2" 44 | ports: 45 | - containerPort: 8443 46 | name: https 47 | terminationGracePeriodSeconds: 10 48 | serviceAccount: template-operator-manager 49 | --- 50 | apiVersion: v1 51 | kind: Service 52 | metadata: 53 | name: template-operator 54 | namespace: template-operator 55 | annotations: 56 | prometheus.io/scrape: "true" 57 | labels: 58 | control-plane: template-operator 59 | spec: 60 | selector: 61 | control-plane: template-operator 62 | ports: 63 | - name: prometheus 64 | protocol: TCP 65 | port: 8080 -------------------------------------------------------------------------------- /test/config.yaml: -------------------------------------------------------------------------------- 1 | name: kind-kind 2 | patches: 3 | - ./patch1.yaml 4 | domain: 127.0.0.1.nip.io 5 | dex: 6 | disabled: true 7 | ldap: 8 | disabled: true 9 | kubernetes: 10 | version: !!env KUBERNETES_VERSION 11 | kubeletExtraArgs: 12 | node-labels: "ingress-ready=true" 13 | authorization-mode: "AlwaysAllow" 14 | containerRuntime: containerd 15 | versions: 16 | sonobuoy: 0.16.4 17 | ketall: v1.3.0 18 | apacheds: 0.7.0 19 | podSubnet: 100.200.0.0/16 20 | serviceSubnet: 100.100.0.0/16 21 | calico: 22 | ipip: Never 23 | vxlan: Never 24 | version: v3.8.2 25 | s3: 26 | endpoint: http://minio.minio.svc:9000 27 | access_key: minio 28 | secret_key: minio123 29 | region: us-east1 30 | usePathStyle: true 31 | skipTLSVerify: true 32 | minio: 33 | version: RELEASE.2020-09-02T18-19-50Z 34 | access_key: minio 35 | secret_key: minio123 36 | replicas: 1 37 | ca: 38 | cert: ../.certs/root-ca.crt 39 | privateKey: ../.certs/root-ca.key 40 | password: foobar 41 | ingressCA: 42 | cert: ../.certs/ingress-ca.crt 43 | privateKey: ../.certs/ingress-ca.key 44 | password: foobar 45 | monitoring: 46 | disabled: false 47 | templateOperator: 48 | disabled: true 49 | canaryChecker: 50 | disabled: true 51 | postgresOperator: 52 | version: v1.6.2 53 | defaultBackupBucket: cicd-pg-backup 54 | backupPassword: password123456 55 | defaultBackupRetention: 56 | keepLast: 5 57 | keepHourly: 2 58 | keepDaily: 1 59 | platformOperator: 60 | version: v0.7.0 61 | enableClusterResourceQuota: true 62 | whitelistedPodAnnotations: 63 | # used by filebeat 64 | - com.flanksource.infra.logs/enabled 65 | - co.elastic.logs/enabled 66 | flux: 67 | enabled: true 68 | test: 69 | exclude: 70 | - configmap-reloader 71 | - dex 72 | - audit 73 | - encryption -------------------------------------------------------------------------------- /test/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | export KARINA_VERSION=v0.50.1 6 | export KARINA="./karina -c test/config.yaml" 7 | export KUBECONFIG=~/.kube/config 8 | export DOCKER_API_VERSION=1.39 9 | 10 | if [[ "$OSTYPE" == "linux-gnu" ]]; then 11 | wget -q https://github.com/flanksource/karina/releases/download/$KARINA_VERSION/karina 12 | chmod +x karina 13 | elif [[ "$OSTYPE" == "darwin"* ]]; then 14 | wget -q https://github.com/flanksource/karina/releases/download/$KARINA_VERSION/karina_osx 15 | cp karina_osx karina 16 | chmod +x karina 17 | else 18 | echo "OS $OSTYPE not supported" 19 | exit 1 20 | fi 21 | 22 | mkdir -p .bin 23 | 24 | KUSTOMIZE=./.bin/kustomize 25 | if [ ! -f "$KUSTOMIZE" ]; then 26 | curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash 27 | mv kustomize .bin 28 | fi 29 | export PATH=$(pwd)/.bin:$PATH 30 | 31 | $KARINA ca generate --name root-ca --cert-path .certs/root-ca.crt --private-key-path .certs/root-ca.key --password foobar --expiry 1 32 | $KARINA ca generate --name ingress-ca --cert-path .certs/ingress-ca.crt --private-key-path .certs/ingress-ca.key --password foobar --expiry 1 33 | $KARINA provision kind-cluster -vvvvv 34 | 35 | $KARINA deploy bootstrap 36 | $KARINA deploy postgres-operator 37 | $KARINA deploy flux 38 | export IMG=flanksource/template-operator:v1 39 | make docker-build 40 | kind load docker-image $IMG --name kind-kind 41 | 42 | make deploy 43 | 44 | kubectl apply -f examples/postgres-operator.yml 45 | kubectl apply -f examples/namespacerequest.yml 46 | kubectl apply -f examples/for-each.yml 47 | kubectl apply -f examples/when.yaml 48 | kubectl apply -f test/fixtures/awx-operator.yml 49 | kubectl apply -f test/fixtures/depends-on.yaml 50 | kubectl apply -f test/fixtures/mockserver.yml 51 | kubectl apply -f test/fixtures/git-repository.yaml 52 | 53 | go run test/e2e.go 54 | 55 | go test ./k8s 56 | -------------------------------------------------------------------------------- /examples/when.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: apps.example.flanksource.com 5 | spec: 6 | group: example.flanksource.com 7 | names: 8 | kind: App 9 | listKind: AppList 10 | plural: apps 11 | singular: app 12 | scope: Namespaced 13 | subresources: 14 | status: {} 15 | version: v1 16 | versions: 17 | - name: v1 18 | served: true 19 | storage: true 20 | validation: 21 | openAPIV3Schema: 22 | description: Schema validation for the App Crds 23 | type: object 24 | properties: 25 | spec: 26 | type: object 27 | --- 28 | apiVersion: templating.flanksource.com/v1 29 | kind: Template 30 | metadata: 31 | name: app-example 32 | spec: 33 | source: 34 | apiVersion: apps.example.flanksource.com/v1 35 | kind: App 36 | resources: 37 | - apiVersion: apps/v1 38 | kind: Deployment 39 | metadata: 40 | name: "{{.metadata.name}}" 41 | namespace: "{{.metadata.namespace}}" 42 | labels: 43 | app: "{{.metadata.name}}" 44 | spec: 45 | replicas: "{{.spec.replicas | default 1}}" 46 | selector: 47 | matchLabels: 48 | app: "{{.metadata.name}}" 49 | template: 50 | metadata: 51 | labels: 52 | app: "{{.metadata.name}}" 53 | spec: 54 | containers: 55 | - name: web 56 | image: "{{.spec.image}}" 57 | ports: 58 | - containerPort: 80 59 | - when: "{{.spec.exposeService}}" 60 | apiVersion: v1 61 | kind: Service 62 | metadata: 63 | name: "{{.metadata.name}}" 64 | namespace: "{{.metadata.namespace}}" 65 | spec: 66 | selector: 67 | app: "{{.metadata.name}}" 68 | ports: 69 | - protocol: TCP 70 | port: 80 71 | targetPort: 80 -------------------------------------------------------------------------------- /examples/namespacerequest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1beta1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: namespacerequests.acmp.corp 6 | spec: 7 | group: acmp.corp 8 | names: 9 | kind: NamespaceRequest 10 | listKind: NamespaceRequestList 11 | plural: namespacerequests 12 | singular: namespacerequest 13 | scope: Cluster 14 | version: v1 15 | versions: 16 | - name: v1 17 | served: true 18 | storage: true 19 | --- 20 | apiVersion: templating.flanksource.com/v1 21 | kind: Template 22 | metadata: 23 | name: namespacerequest 24 | spec: 25 | source: 26 | apiVersion: acmp.corp/v1 27 | kind: NamespaceRequest 28 | resources: 29 | - apiVersion: v1 30 | kind: Namespace 31 | metadata: 32 | name: "{{.metadata.name}}" 33 | annotations: 34 | team: "{{.spec.team}}" 35 | service: "{{.spec.service}}" 36 | company: "{{.spec.company}}" 37 | environment: "{{.spec.environment}}" 38 | 39 | - apiVersion: v1 40 | kind: ResourceQuota 41 | metadata: 42 | name: compute-resources 43 | namespace: "{{.metadata.name}}" 44 | spec: 45 | hard: 46 | requests.cpu: "1" 47 | requests.memory: 10Gi 48 | limits.cpu: "{{ math.Div .spec.memory 8 }}m" 49 | limits.memory: "{{.spec.memory}}Gi" 50 | pods: "{{ math.Mul .spec.memory 6 }}" 51 | services.loadbalancers: "0" 52 | services.nodeports: "0" 53 | 54 | - apiVersion: rbac.authorization.k8s.io/v1 55 | kind: RoleBinding 56 | metadata: 57 | name: creator 58 | namespace: "{{.metadata.name}}" 59 | subjects: 60 | - kind: Group 61 | name: "{{.spec.team}}" 62 | apiGroup: rbac.authorization.k8s.io 63 | roleRef: 64 | apiGroup: rbac.authorization.k8s.io 65 | kind: ClusterRole 66 | name: namespace-admin 67 | -------------------------------------------------------------------------------- /config/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: template-operator 3 | 4 | namePrefix: template-operator- 5 | 6 | bases: 7 | - ../rbac 8 | - ../manager 9 | 10 | # patchesStrategicMerge: 11 | # Protect the /metrics endpoint by putting it behind auth. 12 | # If you want your controller-manager to expose the /metrics 13 | # endpoint w/o any authn/z, please comment the following line. 14 | # - ../default/manager_auth_proxy_patch.yaml 15 | 16 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 17 | # crd/kustomization.yaml 18 | #- manager_webhook_patch.yaml 19 | 20 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 21 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 22 | # 'CERTMANAGER' needs to be enabled to use ca injection 23 | #- webhookcainjection_patch.yaml 24 | 25 | # the following config is for teaching kustomize how to do var substitution 26 | vars: 27 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 28 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 29 | # objref: 30 | # kind: Certificate 31 | # group: cert-manager.io 32 | # version: v1alpha2 33 | # name: serving-cert # this name should match the one in certificate.yaml 34 | # fieldref: 35 | # fieldpath: metadata.namespace 36 | #- name: CERTIFICATE_NAME 37 | # objref: 38 | # kind: Certificate 39 | # group: cert-manager.io 40 | # version: v1alpha2 41 | # name: serving-cert # this name should match the one in certificate.yaml 42 | #- name: SERVICE_NAMESPACE # namespace of the service 43 | # objref: 44 | # kind: Service 45 | # version: v1 46 | # name: webhook-service 47 | # fieldref: 48 | # fieldpath: metadata.namespace 49 | #- name: SERVICE_NAME 50 | # objref: 51 | # kind: Service 52 | # version: v1 53 | # name: webhook-service 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - master 7 | 8 | jobs: 9 | semantic-release: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | release-version: ${{ steps.semantic.outputs.release-version }} 13 | new-release-published: ${{ steps.semantic.outputs.new-release-published }} 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: codfish/semantic-release-action@v1 17 | id: semantic 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | operator: 22 | needs: semantic-release 23 | runs-on: ubuntu-latest 24 | if: needs.semantic-release.outputs.new-release-published == 'true' 25 | env: 26 | VERSION: v${{ needs.semantic-release.outputs.release-version }} 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Build operator 30 | working-directory: ./config/default/ 31 | run: | 32 | kustomize edit set image flanksource/template-operator:v${{ needs.semantic-release.outputs.release-version }} 33 | kustomize build . > operator.yml 34 | - name: Upload binaries to release 35 | uses: svenstaro/upload-release-action@v2 36 | with: 37 | repo_token: ${{ secrets.GITHUB_TOKEN }} 38 | file: ./config/default/operator.yml 39 | tag: v${{ needs.semantic-release.outputs.release-version }} 40 | asset_name: operator.yml 41 | overwrite: true 42 | 43 | docker: 44 | needs: semantic-release 45 | runs-on: ubuntu-latest 46 | if: needs.semantic-release.outputs.new-release-published == 'true' 47 | steps: 48 | - uses: actions/checkout@v2 49 | - name: Publish to Registry 50 | uses: elgohr/Publish-Docker-Github-Action@v5 51 | env: 52 | VERSION: v${{ needs.semantic-release.outputs.release-version }}" 53 | with: 54 | name: flanksource/template-operator 55 | username: ${{ secrets.DOCKER_USERNAME }} 56 | password: ${{ secrets.DOCKER_PASSWORD }} 57 | snapshot: true 58 | tags: "latest,v${{ needs.semantic-release.outputs.release-version }}" 59 | -------------------------------------------------------------------------------- /examples/tutorial-crd.yaml: -------------------------------------------------------------------------------- 1 | # This file is for use by blogposts and examples 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: tutorialservices.tutorial.tutorial 10 | spec: 11 | group: tutorial.tutorial 12 | names: 13 | kind: TutorialService 14 | listKind: TutorialServiceList 15 | plural: tutorialservices 16 | singular: tutorialservice 17 | scope: Namespaced 18 | versions: 19 | - name: v1 20 | schema: 21 | openAPIV3Schema: 22 | description: TutorialService is the Schema for the tutorialservices 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: TutorialServiceSpec defines the desired state of TutorialService 38 | properties: 39 | domain: 40 | type: string 41 | image: 42 | type: string 43 | replicas: 44 | type: integer 45 | required: 46 | - domain 47 | - image 48 | type: object 49 | type: object 50 | served: true 51 | storage: true 52 | subresources: 53 | status: {} 54 | status: 55 | acceptedNames: 56 | kind: "" 57 | plural: "" 58 | conditions: [] 59 | storedVersions: [] 60 | -------------------------------------------------------------------------------- /examples/for-each.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1beta1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | name: abcds.abcd.flanksource.com 5 | spec: 6 | group: abcd.flanksource.com 7 | names: 8 | kind: ABCD 9 | listKind: ABCDList 10 | plural: abcds 11 | singular: abcd 12 | scope: Namespaced 13 | subresources: 14 | status: {} 15 | version: v1 16 | versions: 17 | - name: v1 18 | served: true 19 | storage: true 20 | validation: 21 | openAPIV3Schema: 22 | description: Schema validation for the ABCD CRDs 23 | type: object 24 | properties: 25 | spec: 26 | type: object 27 | --- 28 | apiVersion: apiextensions.k8s.io/v1beta1 29 | kind: CustomResourceDefinition 30 | metadata: 31 | name: abcdtopics.abcd.flanksource.com 32 | spec: 33 | group: abcd.flanksource.com 34 | names: 35 | kind: ABCDTopic 36 | listKind: ABCDTopicList 37 | plural: abcdtopics 38 | singular: abcdtopic 39 | scope: Namespaced 40 | subresources: 41 | status: {} 42 | version: v1 43 | versions: 44 | - name: v1 45 | served: true 46 | storage: true 47 | validation: 48 | openAPIV3Schema: 49 | description: Schema validation for the ABCD Topic CRDs 50 | type: object 51 | properties: 52 | spec: 53 | type: object 54 | --- 55 | apiVersion: templating.flanksource.com/v1 56 | kind: Template 57 | metadata: 58 | name: abcd-topic 59 | spec: 60 | source: 61 | apiVersion: abcd.flanksource.com/v1 62 | kind: ABCD 63 | resources: 64 | - forEach: "{{.spec.topics}}" 65 | apiVersion: abcd.flanksource.com/v1 66 | kind: ABCDTopic 67 | metadata: 68 | name: "{{.metadata.name}}-{{.each}}" 69 | namespace: "{{.metadata.namespace}}" 70 | spec: 71 | topicName: "{{.each}}" 72 | --- 73 | apiVersion: templating.flanksource.com/v1 74 | kind: Template 75 | metadata: 76 | name: abcd-topic-map 77 | spec: 78 | source: 79 | apiVersion: abcd.flanksource.com/v1 80 | kind: ABCD 81 | resources: 82 | - forEach: "{{.spec.topicsMap}}" 83 | apiVersion: abcd.flanksource.com/v1 84 | kind: ABCDTopic 85 | metadata: 86 | name: "{{.metadata.name}}-{{.each.key}}" 87 | namespace: "{{.metadata.namespace}}" 88 | spec: 89 | "{{.each.key}}": "{{.each.value}}" -------------------------------------------------------------------------------- /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 | "path/filepath" 21 | "testing" 22 | 23 | . "github.com/onsi/ginkgo" 24 | . "github.com/onsi/gomega" 25 | "k8s.io/client-go/kubernetes/scheme" 26 | "k8s.io/client-go/rest" 27 | "sigs.k8s.io/controller-runtime/pkg/client" 28 | "sigs.k8s.io/controller-runtime/pkg/envtest" 29 | logf "sigs.k8s.io/controller-runtime/pkg/log" 30 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 31 | 32 | templatingflanksourcecomv1 "github.com/flanksource/template-operator/api/v1" 33 | // +kubebuilder:scaffold:imports 34 | ) 35 | 36 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 37 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 38 | 39 | var cfg *rest.Config 40 | var k8sClient client.Client 41 | var testEnv *envtest.Environment 42 | 43 | func TestAPIs(t *testing.T) { 44 | RegisterFailHandler(Fail) 45 | 46 | RunSpecs(t, "Controller Suite") 47 | } 48 | 49 | var _ = BeforeSuite(func(done Done) { 50 | logf.SetLogger(zap.LoggerTo(GinkgoWriter, true)) 51 | 52 | By("bootstrapping test environment") 53 | testEnv = &envtest.Environment{ 54 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 55 | } 56 | 57 | var err error 58 | cfg, err = testEnv.Start() 59 | Expect(err).ToNot(HaveOccurred()) 60 | Expect(cfg).ToNot(BeNil()) 61 | 62 | err = templatingflanksourcecomv1.AddToScheme(scheme.Scheme) 63 | Expect(err).NotTo(HaveOccurred()) 64 | 65 | // +kubebuilder:scaffold:scheme 66 | 67 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 68 | Expect(err).ToNot(HaveOccurred()) 69 | Expect(k8sClient).ToNot(BeNil()) 70 | 71 | close(done) 72 | }, 60) 73 | 74 | var _ = AfterSuite(func() { 75 | By("tearing down the test environment") 76 | err := testEnv.Stop() 77 | Expect(err).ToNot(HaveOccurred()) 78 | }) 79 | -------------------------------------------------------------------------------- /k8s/template_manager_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/flanksource/template-operator/k8s" 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ctrl "sigs.k8s.io/controller-runtime" 12 | "sigs.k8s.io/yaml" 13 | ) 14 | 15 | var testLog = ctrl.Log.WithName("test") 16 | 17 | var _ = Describe("TemplateManager", func() { 18 | Describe("Template", func() { 19 | It("converts PostgresqlDb to zalando Postgresql", func() { 20 | 21 | db := map[string]interface{}{ 22 | "apiVersion": "db.flanksource.com/v1", 23 | "kind": "PostgresqlDB", 24 | "metadata": map[string]interface{}{ 25 | "name": "test1", 26 | "namespace": "postgres-operator", 27 | }, 28 | "spec": map[string]interface{}{ 29 | "replicas": 2, 30 | "parameters": map[string]interface{}{ 31 | "max_connections": "1024", 32 | "shared_buffers": "4759MB", 33 | "work_mem": "475MB", 34 | "maintenance_work_mem": "634MB", 35 | }, 36 | }, 37 | } 38 | 39 | template := ` 40 | apiVersion: acid.zalan.do/v1 41 | kind: postgresql 42 | metadata: 43 | name: postgres-{{.metadata.name}} 44 | namespace: postgres-operator 45 | spec: 46 | numberOfInstances: "{{ .spec.replicas }}" 47 | clone: null 48 | postgresql: 49 | parameters: "{{ .spec.parameters | data.ToJSON }}" 50 | synchronous_mode: false 51 | ` 52 | templateJSON, err := yaml.YAMLToJSON([]byte(template)) 53 | Expect(err).ToNot(HaveOccurred()) 54 | 55 | expectedYaml := ` 56 | apiVersion: acid.zalan.do/v1 57 | kind: postgresql 58 | metadata: 59 | name: postgres-test1 60 | namespace: postgres-operator 61 | spec: 62 | clone: null 63 | numberOfInstances: 2 64 | postgresql: 65 | parameters: 66 | maintenance_work_mem: 634MB 67 | max_connections: "1024" 68 | shared_buffers: 4759MB 69 | work_mem: 475MB 70 | synchronous_mode: false 71 | ` 72 | 73 | eventsRecorder := &TestEventRecorder{} 74 | cache := k8s.NewSchemaCache(clientset(), crdClient(), 5*time.Minute, testLog) 75 | templateManager, err := k8s.NewTemplateManager(kommonsClient(), testLog, cache, eventsRecorder, &k8s.NullWatcher{}) 76 | Expect(err).ToNot(HaveOccurred()) 77 | 78 | result, err := templateManager.Template([]byte(templateJSON), db) 79 | Expect(err).ToNot(HaveOccurred()) 80 | 81 | yml := string(result) 82 | 83 | fmt.Printf("Expected:\n%v\n=======Actual:\n%v\n==========", expectedYaml, string(yml)) 84 | Expect(strings.TrimSpace(string(yml))).To(Equal(strings.TrimSpace(expectedYaml))) 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: template-operator 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: template-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 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 34 | # crd/kustomization.yaml 35 | #- manager_webhook_patch.yaml 36 | 37 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 38 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 39 | # 'CERTMANAGER' needs to be enabled to use ca injection 40 | #- webhookcainjection_patch.yaml 41 | 42 | # the following config is for teaching kustomize how to do var substitution 43 | vars: 44 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 45 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 46 | # objref: 47 | # kind: Certificate 48 | # group: cert-manager.io 49 | # version: v1alpha2 50 | # name: serving-cert # this name should match the one in certificate.yaml 51 | # fieldref: 52 | # fieldpath: metadata.namespace 53 | #- name: CERTIFICATE_NAME 54 | # objref: 55 | # kind: Certificate 56 | # group: cert-manager.io 57 | # version: v1alpha2 58 | # name: serving-cert # this name should match the one in certificate.yaml 59 | #- name: SERVICE_NAMESPACE # namespace of the service 60 | # objref: 61 | # kind: Service 62 | # version: v1 63 | # name: webhook-service 64 | # fieldref: 65 | # fieldpath: metadata.namespace 66 | #- name: SERVICE_NAME 67 | # objref: 68 | # kind: Service 69 | # version: v1 70 | # name: webhook-service 71 | -------------------------------------------------------------------------------- /api/v1/rest_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 v1 18 | 19 | import ( 20 | "github.com/flanksource/kommons" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | // RESTSpec defines the desired state of REST 25 | type RESTSpec struct { 26 | // URL represents the URL address used to send requests 27 | URL string `json:"url,omitempty"` 28 | 29 | // Auth may be used for http basic authentication 30 | // +optional 31 | Auth *RESTAuth `json:"auth,omitempty"` 32 | 33 | // Headers are optional http headers to be sent on the request 34 | // +optional 35 | Headers map[string]string `json:"headers,omitempty"` 36 | 37 | // Update defines the payload to be sent when CRD item is updated 38 | Update RESTAction `json:"update,omitempty"` 39 | 40 | // Remove defines the payload to be sent when CRD item is deleted 41 | Remove RESTAction `json:"remove,omitempty"` 42 | } 43 | 44 | type RESTAuth struct { 45 | // Username represents the HTTP Basic Auth username 46 | Username kommons.EnvVarSource `json:"username,omitempty"` 47 | // Password represents the HTTP Basic Auth password 48 | Password kommons.EnvVarSource `json:"password,omitempty"` 49 | // Namespace where secret / config map is present 50 | Namespace string `json:"namespace,omitempty"` 51 | } 52 | 53 | type RESTAction struct { 54 | // Method represents HTTP method to be used for the request. Example: POST 55 | Method string `json:"method,omitempty"` 56 | // URL represents the URL used for the request 57 | // +optional 58 | URL string `json:"url,omitempty"` 59 | // Body represents the HTTP Request body 60 | // +optional 61 | Body string `json:"body,omitempty"` 62 | // Status defines the status fields which will be updated based on response status 63 | // +optional 64 | Status map[string]string `json:"status,omitempty"` 65 | } 66 | 67 | // +kubebuilder:object:root=true 68 | // +genclient 69 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 70 | // +kubebuilder:resource:scope="Cluster" 71 | // +kubebuilder:subresource:status 72 | // REST is the Schema for the rest API 73 | type REST struct { 74 | metav1.TypeMeta `json:",inline"` 75 | metav1.ObjectMeta `json:"metadata,omitempty"` 76 | 77 | Spec RESTSpec `json:"spec"` 78 | // +kubebuilder:pruning:PreserveUnknownFields 79 | Status map[string]string `json:"status,omitempty"` 80 | } 81 | 82 | // +kubebuilder:object:root=true 83 | 84 | // TemplateList contains a list of Template 85 | type RESTList struct { 86 | metav1.TypeMeta `json:",inline"` 87 | metav1.ListMeta `json:"metadata,omitempty"` 88 | Items []REST `json:"items"` 89 | } 90 | 91 | func init() { 92 | SchemeBuilder.Register(&REST{}, &RESTList{}) 93 | } 94 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ifeq ($(VERSION),) 5 | VERSION_TAG=$(shell git describe --abbrev=0 --tags --exact-match 2>/dev/null || echo dev) 6 | else 7 | VERSION_TAG=$(VERSION) 8 | endif 9 | 10 | # Image URL to use all building/pushing image targets 11 | IMG ?= flanksource/template-operator:${VERSION_TAG} 12 | # Produce CRDs that work back to Kubernetes 1.11 (no version conversion) 13 | CRD_OPTIONS ?= "crd:trivialVersions=false" 14 | 15 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 16 | ifeq (,$(shell go env GOBIN)) 17 | GOBIN=$(shell go env GOPATH)/bin 18 | else 19 | GOBIN=$(shell go env GOBIN) 20 | endif 21 | 22 | all: manager 23 | 24 | # Run tests 25 | test: generate fmt vet manifests 26 | go test ./... -coverprofile cover.out 27 | 28 | # Build manager binary 29 | # manager: generate fmt vet 30 | manager: 31 | go build -o bin/manager main.go 32 | 33 | .PHONY: linux 34 | linux: 35 | GOOS=linux go build -o bin/manager main.go 36 | 37 | # Run against the configured Kubernetes cluster in ~/.kube/config 38 | run: generate fmt vet manifests 39 | go run ./main.go 40 | 41 | # Install CRDs into a cluster 42 | install: manifests 43 | kustomize build config/crd | kubectl apply -f - 44 | 45 | # Uninstall CRDs from a cluster 46 | uninstall: manifests 47 | kustomize build config/crd | kubectl delete -f - 48 | 49 | # Deploy controller in the configured Kubernetes cluster in ~/.kube/config 50 | deploy: manifests 51 | cd config/manager && kustomize edit set image controller=${IMG} 52 | kustomize build config/default | kubectl apply -f - 53 | 54 | # Generate manifests e.g. CRD, RBAC etc. 55 | manifests: controller-gen .bin/yq 56 | $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases 57 | $(YQ) eval -I2 -i '.spec.versions.0.schema.openAPIV3Schema.properties.spec.properties.resources.items.x-kubernetes-preserve-unknown-fields = true' config/crd/bases/templating.flanksource.com_templates.yaml 58 | 59 | static: manifests 60 | mkdir -p config/deploy 61 | cd config/manager && kustomize edit set image controller=${IMG} 62 | kustomize build config/crd > config/deploy/crd.yml 63 | kustomize build config/default > config/deploy/operator.yml 64 | kustomize build config/base > config/base/deploy.yml 65 | 66 | # Run go fmt against code 67 | fmt: 68 | go fmt ./... 69 | 70 | # Run go vet against code 71 | vet: 72 | go vet ./... 73 | 74 | # Generate code 75 | generate: controller-gen 76 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 77 | 78 | # Build the docker image 79 | docker-build: 80 | docker build . -t ${IMG} 81 | 82 | # Push the docker image 83 | docker-push: 84 | docker push ${IMG} 85 | 86 | # find or download controller-gen 87 | # download controller-gen if necessary 88 | controller-gen: 89 | ifeq (, $(shell which controller-gen)) 90 | @{ \ 91 | set -e ;\ 92 | CONTROLLER_GEN_TMP_DIR=$$(mktemp -d) ;\ 93 | cd $$CONTROLLER_GEN_TMP_DIR ;\ 94 | go mod init tmp ;\ 95 | go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.4.0 ;\ 96 | rm -rf $$CONTROLLER_GEN_TMP_DIR ;\ 97 | } 98 | CONTROLLER_GEN=$(GOBIN)/controller-gen 99 | else 100 | CONTROLLER_GEN=$(shell which controller-gen) 101 | endif 102 | 103 | OS = $(shell uname -s | tr '[:upper:]' '[:lower:]') 104 | ARCH = $(shell uname -m | sed 's/x86_64/amd64/') 105 | 106 | .bin/yq: 107 | mkdir -p .bin 108 | curl -sSLo .bin/yq https://github.com/mikefarah/yq/releases/download/v4.9.6/yq_$(OS)_$(ARCH) && chmod +x .bin/yq 109 | YQ = $(realpath ./.bin/yq) 110 | -------------------------------------------------------------------------------- /config/base/deploy.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: template-operator 5 | --- 6 | apiVersion: v1 7 | kind: ServiceAccount 8 | metadata: 9 | name: template-operator-manager 10 | namespace: template-operator 11 | --- 12 | apiVersion: rbac.authorization.k8s.io/v1 13 | kind: Role 14 | metadata: 15 | name: template-operator-leader-election-role 16 | namespace: template-operator 17 | rules: 18 | - apiGroups: 19 | - "" 20 | resources: 21 | - configmaps 22 | verbs: 23 | - get 24 | - list 25 | - watch 26 | - create 27 | - update 28 | - patch 29 | - delete 30 | - apiGroups: 31 | - "" 32 | resources: 33 | - configmaps/status 34 | verbs: 35 | - get 36 | - update 37 | - patch 38 | - apiGroups: 39 | - "" 40 | resources: 41 | - events 42 | verbs: 43 | - create 44 | --- 45 | apiVersion: rbac.authorization.k8s.io/v1 46 | kind: ClusterRole 47 | metadata: 48 | creationTimestamp: null 49 | name: template-operator-manager-role 50 | rules: 51 | - apiGroups: 52 | - '*' 53 | resources: 54 | - '*' 55 | verbs: 56 | - '*' 57 | --- 58 | apiVersion: rbac.authorization.k8s.io/v1 59 | kind: RoleBinding 60 | metadata: 61 | name: template-operator-leader-election-rolebinding 62 | namespace: template-operator 63 | roleRef: 64 | apiGroup: rbac.authorization.k8s.io 65 | kind: Role 66 | name: template-operator-leader-election-role 67 | subjects: 68 | - kind: ServiceAccount 69 | name: template-operator-manager 70 | namespace: template-operator 71 | --- 72 | apiVersion: rbac.authorization.k8s.io/v1 73 | kind: ClusterRoleBinding 74 | metadata: 75 | name: template-operator-manager-rolebinding 76 | roleRef: 77 | apiGroup: rbac.authorization.k8s.io 78 | kind: ClusterRole 79 | name: template-operator-manager-role 80 | subjects: 81 | - kind: ServiceAccount 82 | name: template-operator-manager 83 | namespace: template-operator 84 | --- 85 | apiVersion: v1 86 | kind: Service 87 | metadata: 88 | annotations: 89 | prometheus.io/scrape: "true" 90 | labels: 91 | control-plane: template-operator 92 | name: template-operator-template-operator 93 | namespace: template-operator 94 | spec: 95 | ports: 96 | - name: prometheus 97 | port: 8080 98 | protocol: TCP 99 | selector: 100 | control-plane: template-operator 101 | --- 102 | apiVersion: apps/v1 103 | kind: Deployment 104 | metadata: 105 | labels: 106 | control-plane: template-operator 107 | name: template-operator-controller-manager 108 | namespace: template-operator 109 | spec: 110 | replicas: 1 111 | selector: 112 | matchLabels: 113 | control-plane: template-operator 114 | template: 115 | metadata: 116 | labels: 117 | control-plane: template-operator 118 | spec: 119 | containers: 120 | - args: 121 | - --metrics-addr=0.0.0.0:8080 122 | - --enable-leader-election 123 | - --sync-period=20s 124 | image: flanksource/template-operator:dev 125 | name: manager 126 | resources: 127 | limits: 128 | cpu: 100m 129 | memory: 130Mi 130 | requests: 131 | cpu: 100m 132 | memory: 120Mi 133 | - args: 134 | - --secure-listen-address=0.0.0.0:8443 135 | - --upstream=http://127.0.0.1:8080/ 136 | - --logtostderr=true 137 | - --v=2 138 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 139 | name: kube-rbac-proxy 140 | ports: 141 | - containerPort: 8443 142 | name: https 143 | serviceAccount: template-operator-manager 144 | terminationGracePeriodSeconds: 10 145 | -------------------------------------------------------------------------------- /test/patch1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: minio 5 | namespace: minio 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: minio 11 | image: minio/minio:RELEASE.2020-03-06T22-23-56Z 12 | --- 13 | apiVersion: apps/v1 14 | kind: StatefulSet 15 | metadata: 16 | name: vault 17 | namespace: vault 18 | spec: 19 | replicas: 1 20 | --- 21 | apiVersion: apps/v1 22 | kind: Deployment 23 | metadata: 24 | name: dex 25 | namespace: dex 26 | spec: 27 | replicas: 1 28 | template: 29 | spec: 30 | resources: 31 | requests: 32 | cpu: 10m 33 | --- 34 | apiVersion: monitoring.coreos.com/v1 35 | kind: Prometheus 36 | metadata: 37 | name: k8s 38 | namespace: monitoring 39 | spec: 40 | replicas: 1 41 | storage: 42 | emptyDir: 43 | sizeLimit: 10Gi 44 | volumeClaimTemplate: {} 45 | resources: 46 | requests: 47 | memory: 100Mi 48 | cpu: 10m 49 | retention: 10h 50 | --- 51 | apiVersion: monitoring.coreos.com/v1 52 | kind: Alertmanager 53 | metadata: 54 | name: main 55 | namespace: monitoring 56 | spec: 57 | replicas: 1 58 | resources: 59 | requests: 60 | cpu: 10m 61 | --- 62 | apiVersion: integreatly.org/v1alpha1 63 | kind: Grafana 64 | metadata: 65 | name: grafana 66 | namespace: monitoring 67 | spec: 68 | resources: 69 | requests: 70 | cpu: 10m 71 | 72 | --- 73 | kind: DaemonSet 74 | apiVersion: apps/v1 75 | metadata: 76 | name: calico-node 77 | namespace: kube-system 78 | spec: 79 | template: 80 | spec: 81 | containers: 82 | - name: calico-node 83 | resources: 84 | requests: 85 | cpu: 10m 86 | --- 87 | apiVersion: apps/v1 88 | kind: Deployment 89 | metadata: 90 | name: kube-state-metrics 91 | namespace: monitoring 92 | spec: 93 | template: 94 | spec: 95 | containers: 96 | - name: kube-state-metrics 97 | resources: 98 | requests: 99 | cpu: 10m 100 | 101 | --- 102 | apiVersion: apps/v1 103 | kind: Deployment 104 | metadata: 105 | # Disable reload/all in tests 106 | annotations: 107 | $patch: delete 108 | name: quack 109 | namespace: quack 110 | spec: 111 | replicas: 1 112 | template: 113 | metadata: 114 | annotations: 115 | $patch: delete 116 | spec: 117 | containers: 118 | - name: quack 119 | resources: 120 | requests: 121 | cpu: 10m 122 | memory: 10Mi 123 | 124 | --- 125 | apiVersion: apps/v1 126 | kind: Deployment 127 | metadata: 128 | # Disable reload/all in tests 129 | annotations: null 130 | name: platform-operator 131 | namespace: template-operator 132 | spec: 133 | replicas: 1 134 | template: 135 | metadata: 136 | annotations: 137 | $patch: delete 138 | spec: 139 | containers: 140 | - name: manager 141 | resources: 142 | requests: 143 | cpu: 10m 144 | --- 145 | apiVersion: apps/v1 146 | kind: Deployment 147 | metadata: 148 | # Disable reload/all in tests 149 | annotations: null 150 | name: canary-checker 151 | namespace: template-operator 152 | spec: 153 | template: 154 | metadata: 155 | annotations: 156 | $patch: delete 157 | spec: 158 | containers: 159 | - name: canary-checker 160 | resources: 161 | requests: 162 | cpu: 10m 163 | --- 164 | apiVersion: apps/v1 165 | kind: Deployment 166 | metadata: 167 | name: cert-manager-webhook 168 | namespace: cert-manager 169 | spec: 170 | replicas: 1 -------------------------------------------------------------------------------- /test/fixtures/depends-on.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: depends.apps.flanksource.com 6 | spec: 7 | group: apps.flanksource.com 8 | versions: 9 | - name: v1 10 | served: true 11 | storage: true 12 | schema: 13 | openAPIV3Schema: 14 | type: object 15 | properties: 16 | spec: 17 | type: object 18 | properties: 19 | replicas: 20 | type: integer 21 | image: 22 | type: string 23 | type: 24 | type: string 25 | status: 26 | type: string 27 | status: 28 | type: object 29 | properties: 30 | conditions: 31 | type: array 32 | items: 33 | type: object 34 | properties: 35 | type: 36 | type: string 37 | status: 38 | type: string 39 | scope: Namespaced 40 | names: 41 | plural: depends 42 | singular: depend 43 | kind: Depend 44 | 45 | --- 46 | apiVersion: apiextensions.k8s.io/v1 47 | kind: CustomResourceDefinition 48 | metadata: 49 | name: samples.apps.flanksource.com 50 | spec: 51 | group: apps.flanksource.com 52 | versions: 53 | - name: v1 54 | served: true 55 | storage: true 56 | schema: 57 | openAPIV3Schema: 58 | type: object 59 | properties: 60 | spec: 61 | type: object 62 | status: 63 | type: object 64 | properties: 65 | conditions: 66 | type: array 67 | items: 68 | type: object 69 | properties: 70 | type: 71 | type: string 72 | status: 73 | type: string 74 | scope: Namespaced 75 | names: 76 | plural: samples 77 | singular: sample 78 | kind: Sample 79 | --- 80 | 81 | apiVersion: templating.flanksource.com/v1 82 | kind: Template 83 | metadata: 84 | name: depend-example 85 | spec: 86 | source: 87 | apiVersion: apps.flanksource.com/v1 88 | kind: Depend 89 | resources: 90 | - id: test 91 | apiVersion: apps.flanksource.com/v1 92 | kind: Sample 93 | metadata: 94 | name: "{{.metadata.name}}" 95 | namespace: "{{.metadata.namespace}}" 96 | spec: {} 97 | status: 98 | conditions: 99 | - type: '{{.spec.type | default "NotReady"}}' 100 | status: '{{.spec.status | default "False"}}' 101 | # will not be created as the dependent object never becomes ready 102 | - depends: ["test"] 103 | apiVersion: apps/v1 104 | kind: Deployment 105 | metadata: 106 | name: "{{.metadata.name}}" 107 | namespace: "{{.metadata.namespace}}" 108 | labels: 109 | app: "{{.metadata.name}}" 110 | spec: 111 | replicas: "{{.spec.replicas | default 1}}" 112 | selector: 113 | matchLabels: 114 | app: "{{.metadata.name}}" 115 | template: 116 | metadata: 117 | labels: 118 | app: "{{.metadata.name}}" 119 | spec: 120 | containers: 121 | - name: web 122 | image: "{{.spec.image}}" 123 | ports: 124 | - containerPort: 80 125 | # will be created as it does not depend on any other object 126 | - id: secret 127 | apiVersion: v1 128 | kind: Secret 129 | metadata: 130 | name: "{{.metadata.name}}" 131 | namespace: "{{.metadata.namespace}}" 132 | data: 133 | some-key: c29tZS12YWx1ZQ== 134 | type: Opaque -------------------------------------------------------------------------------- /api/v1/template_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 v1 18 | 19 | import ( 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | runtime "k8s.io/apimachinery/pkg/runtime" 22 | ) 23 | 24 | // TemplateSpec defines the desired state of Template 25 | type TemplateSpec struct { 26 | // Source selects objects on which to use as a templating object 27 | Source ResourceSelector `json:"source,omitempty"` 28 | 29 | // Target optionally allows to lookup related resources to patch, defaults 30 | // to the source object selected 31 | // +optional 32 | PatchTarget ResourceSelector `json:"patchTarget,omitempty"` 33 | 34 | // Resources is a list of new resources to create for each source object found 35 | // Must specify at least resources or patches or both 36 | // +optional 37 | Resources []runtime.RawExtension `json:"resources,omitempty"` 38 | 39 | // Resources template is a template of resources to be created for each source object found 40 | // +optional 41 | ResourcesTemplate string `json:"resourcesTemplate,omitempty"` 42 | 43 | // Patches is list of strategic merge patches to apply to to the targets 44 | // Must specify at least resources or patches or both 45 | // +optional 46 | Patches []string `json:"patches,omitempty"` 47 | 48 | JsonPatches []JsonPatch `json:"jsonPatches,omitempty"` 49 | 50 | // Copy this object to other namespaces 51 | CopyToNamespaces *CopyToNamespaces `json:"copyToNamespaces,omitempty"` 52 | 53 | // Onceoff will not apply templating more than once (usually at admission stage) 54 | Onceoff bool `json:"onceoff,omitempty"` 55 | } 56 | 57 | // TemplateStatus defines the observed state of Template 58 | type TemplateStatus struct { 59 | } 60 | 61 | type ResourceSelector struct { 62 | GitRepository *GitRepository `json:"gitRepository,omitempty"` 63 | LabelSelector metav1.LabelSelector `json:"labelSelector,omitempty"` 64 | NamespaceSelector metav1.LabelSelector `json:"namespaceSelector,omitempty"` 65 | AnnotationSelector map[string]string `json:"annotationSelector,omitempty"` 66 | FieldSelector string `json:"fieldSelector,omitempty"` 67 | APIVersion string `json:"apiVersion,omitempty"` 68 | Kind string `json:"kind,omitempty"` 69 | } 70 | 71 | type ObjectSelector struct { 72 | Kind string `json:"kind,omitempty"` 73 | APIVersion string `json:"apiVersion,omitempty"` 74 | } 75 | 76 | type JsonPatch struct { 77 | Object metav1.TypeMeta `json:"object,omitempty"` 78 | Patch string `json:"patch,omitempty"` 79 | } 80 | 81 | type CopyToNamespaces struct { 82 | Namespaces []string `json:"namespaces,omitempty"` 83 | NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"` 84 | } 85 | 86 | type GitRepository struct { 87 | Name string `json:"name,omitempty"` 88 | Namespace string `json:"namespace,omitempty"` 89 | Glob string `json:"glob,omitempty"` 90 | } 91 | 92 | // +kubebuilder:object:root=true 93 | // +genclient 94 | // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object 95 | // +kubebuilder:resource:scope="Cluster" 96 | // Template is the Schema for the templates API 97 | type Template struct { 98 | metav1.TypeMeta `json:",inline"` 99 | metav1.ObjectMeta `json:"metadata,omitempty"` 100 | 101 | Spec TemplateSpec `json:"spec,omitempty"` 102 | Status TemplateStatus `json:"status,omitempty"` 103 | } 104 | 105 | // +kubebuilder:object:root=true 106 | 107 | // TemplateList contains a list of Template 108 | type TemplateList struct { 109 | metav1.TypeMeta `json:",inline"` 110 | metav1.ListMeta `json:"metadata,omitempty"` 111 | Items []Template `json:"items"` 112 | } 113 | 114 | func init() { 115 | SchemeBuilder.Register(&Template{}, &TemplateList{}) 116 | } 117 | -------------------------------------------------------------------------------- /controllers/crd_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 | "k8s.io/client-go/discovery" 22 | controllercliconfig "sigs.k8s.io/controller-runtime/pkg/client/config" 23 | "strconv" 24 | 25 | "github.com/go-logr/logr" 26 | apiv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 27 | apiv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" 28 | ctrl "sigs.k8s.io/controller-runtime" 29 | "sigs.k8s.io/controller-runtime/pkg/controller" 30 | "sigs.k8s.io/controller-runtime/pkg/handler" 31 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 32 | "sigs.k8s.io/controller-runtime/pkg/source" 33 | ) 34 | 35 | // CRDReconciler reconciles changes to CRD objects 36 | type CRDReconciler struct { 37 | Client 38 | ResourceVersion int 39 | } 40 | 41 | // +kubebuilder:rbac:groups="*",resources="*",verbs="*" 42 | 43 | func (r *CRDReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 44 | log := r.Log.WithValues("crd", req.NamespacedName) 45 | log.V(2).Info("crd update detected, checking cache state") 46 | v1, err := r.HasKind(CRDV1Group, CRDV1Version) 47 | if err != nil { 48 | return ctrl.Result{}, err 49 | } 50 | if v1 { 51 | return r.reconcileV1(ctx, req, log) 52 | } 53 | return r.reconcileV1beta1(ctx, req, log) 54 | } 55 | 56 | func (r *CRDReconciler) reconcileV1(ctx context.Context, req ctrl.Request, log logr.Logger) (ctrl.Result, error) { 57 | crd := &apiv1.CustomResourceDefinition{} 58 | if err := r.ControllerClient.Get(ctx, req.NamespacedName, crd); err != nil { 59 | return reconcile.Result{}, err 60 | } 61 | resourceVersion, err := strconv.Atoi(crd.ResourceVersion) 62 | if err != nil { 63 | return reconcile.Result{}, err 64 | } 65 | 66 | if resourceVersion > r.ResourceVersion { 67 | log.V(2).Info("Newer resourceVersion detected, resetting cache") 68 | if err := r.resetCache(); err != nil { 69 | return reconcile.Result{}, err 70 | } 71 | r.ResourceVersion = resourceVersion 72 | } 73 | return reconcile.Result{}, nil 74 | } 75 | 76 | func (r *CRDReconciler) reconcileV1beta1(ctx context.Context, req ctrl.Request, log logr.Logger) (ctrl.Result, error) { 77 | crd := &apiv1beta1.CustomResourceDefinition{} 78 | if err := r.ControllerClient.Get(ctx, req.NamespacedName, crd); err != nil { 79 | return reconcile.Result{}, err 80 | } 81 | resourceVersion, err := strconv.Atoi(crd.ResourceVersion) 82 | if err != nil { 83 | return reconcile.Result{}, err 84 | } 85 | 86 | if resourceVersion > r.ResourceVersion { 87 | log.V(2).Info("Newer resourceVersion detected, resetting cache") 88 | if err := r.resetCache(); err != nil { 89 | return reconcile.Result{}, err 90 | } 91 | r.ResourceVersion = resourceVersion 92 | } 93 | return reconcile.Result{}, nil 94 | } 95 | 96 | func (r *CRDReconciler) resetCache() error { 97 | if err := r.Cache.ExpireSchema(); err != nil { 98 | return err 99 | } 100 | return nil 101 | } 102 | 103 | func (r *CRDReconciler) SetupWithManager(mgr ctrl.Manager) error { 104 | r.ControllerClient = mgr.GetClient() 105 | r.Events = mgr.GetEventRecorderFor("template-operator") 106 | c, err := controller.New("crd-monitor", mgr, controller.Options{Reconciler: r}) 107 | if err != nil { 108 | return err 109 | } 110 | config, err := controllercliconfig.GetConfig() 111 | if err != nil { 112 | return err 113 | } 114 | r.Discovery, err = discovery.NewDiscoveryClientForConfig(config) 115 | if err != nil { 116 | return err 117 | } 118 | v1, err := r.HasKind(CRDV1Group, CRDV1Version) 119 | if err != nil { 120 | return err 121 | } 122 | if v1 { 123 | return c.Watch(source.Kind(mgr.GetCache(), &apiv1.CustomResourceDefinition{}), &handler.EnqueueRequestForObject{}) 124 | } 125 | return c.Watch(source.Kind(mgr.GetCache(), &apiv1beta1.CustomResourceDefinition{}), &handler.EnqueueRequestForObject{}) 126 | } 127 | -------------------------------------------------------------------------------- /k8s/watcher.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/flanksource/commons/logger" 11 | "github.com/flanksource/kommons" 12 | templatev1 "github.com/flanksource/template-operator/api/v1" 13 | "github.com/go-logr/logr" 14 | "github.com/pkg/errors" 15 | v1 "k8s.io/api/core/v1" 16 | "k8s.io/apimachinery/pkg/api/meta" 17 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 18 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 19 | "k8s.io/apimachinery/pkg/runtime" 20 | "k8s.io/apimachinery/pkg/watch" 21 | "k8s.io/client-go/dynamic" 22 | "k8s.io/client-go/informers" 23 | "k8s.io/client-go/kubernetes" 24 | "k8s.io/client-go/tools/cache" 25 | ) 26 | 27 | type CallbackFunc func(unstructured.Unstructured) error 28 | 29 | type WatcherInterface interface { 30 | Watch(exampleObject *unstructured.Unstructured, template *templatev1.Template, cb CallbackFunc) error 31 | } 32 | 33 | type NullWatcher struct{} 34 | 35 | func (w *NullWatcher) Watch(exampleObject *unstructured.Unstructured, template *templatev1.Template, cb CallbackFunc) error { 36 | return nil 37 | } 38 | 39 | type Watcher struct { 40 | clientset *kubernetes.Clientset 41 | client *kommons.Client 42 | mtx *sync.Mutex 43 | cache map[string]bool 44 | log logr.Logger 45 | } 46 | 47 | func NewWatcher(client *kommons.Client, log logr.Logger) (WatcherInterface, error) { 48 | clientset, err := client.GetClientset() 49 | if err != nil { 50 | return nil, errors.Wrap(err, "failed to get clientset") 51 | } 52 | 53 | watcher := &Watcher{ 54 | clientset: clientset, 55 | client: client, 56 | mtx: &sync.Mutex{}, 57 | cache: map[string]bool{}, 58 | log: log, 59 | } 60 | 61 | return watcher, nil 62 | } 63 | 64 | func (w *Watcher) Watch(exampleObject *unstructured.Unstructured, template *templatev1.Template, cb CallbackFunc) error { 65 | cacheKey := getCacheKey(exampleObject, template) 66 | w.mtx.Lock() 67 | defer w.mtx.Unlock() 68 | if w.cache[cacheKey] { 69 | return nil 70 | } 71 | w.cache[cacheKey] = true 72 | 73 | logger.Debugf("Deploying new watcher for object=%s", exampleObject.GetObjectKind().GroupVersionKind().Kind) 74 | 75 | factory := informers.NewSharedInformerFactory(w.clientset, 0) 76 | 77 | di, err := w.getDynamicClient(exampleObject) 78 | if err != nil { 79 | return errors.Wrap(err, "failed to get dynamic client") 80 | } 81 | 82 | labelSelector, err := labelSelectorToString(template.Spec.Source.LabelSelector) 83 | if err != nil { 84 | return errors.Wrap(err, "failed to get label selector") 85 | } 86 | 87 | listOptions := metav1.ListOptions{ 88 | LabelSelector: labelSelector, 89 | FieldSelector: template.Spec.Source.FieldSelector, 90 | } 91 | 92 | informer := factory.InformerFor(exampleObject, func(i kubernetes.Interface, d time.Duration) cache.SharedIndexInformer { 93 | c := cache.NewSharedIndexInformer( 94 | &cache.ListWatch{ 95 | ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { 96 | options.FieldSelector = listOptions.FieldSelector 97 | options.LabelSelector = listOptions.LabelSelector 98 | return di.List(context.TODO(), options) 99 | }, 100 | WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { 101 | options.FieldSelector = listOptions.FieldSelector 102 | options.LabelSelector = listOptions.LabelSelector 103 | return di.Watch(context.TODO(), options) 104 | }, 105 | }, 106 | exampleObject, 107 | d, 108 | cache.Indexers{}, 109 | ) 110 | return c 111 | }) 112 | 113 | informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ 114 | AddFunc: func(obj interface{}) { 115 | w.log.V(2).Info("Received callback for object added:", "object", obj) 116 | w.onUpdate(obj, cb) 117 | }, 118 | UpdateFunc: func(oldObj interface{}, obj interface{}) { 119 | w.log.V(2).Info("Received callback for object updated:", "object", obj) 120 | w.onUpdate(obj, cb) 121 | }, 122 | // When a pod gets deleted 123 | DeleteFunc: func(obj interface{}) { 124 | w.log.V(2).Info("Received callback for object deleted:", "object", obj) 125 | }, 126 | }) 127 | 128 | stopper := make(chan struct{}) 129 | go informer.Run(stopper) 130 | 131 | return nil 132 | } 133 | 134 | func (w *Watcher) onUpdate(obj interface{}, cb CallbackFunc) { 135 | js, err := json.Marshal(obj) 136 | if err != nil { 137 | w.log.Error(err, "failed to marshal object for update") 138 | return 139 | } 140 | unstr := &unstructured.Unstructured{} 141 | if err := json.Unmarshal(js, &unstr.Object); err != nil { 142 | w.log.Error(err, "failed to unmarshal into unstructured for update") 143 | return 144 | } 145 | 146 | if err := cb(*unstr); err != nil { 147 | w.log.Error(err, "failed to run callback") 148 | } 149 | } 150 | 151 | func (w *Watcher) getDynamicClient(obj *unstructured.Unstructured) (dynamic.ResourceInterface, error) { 152 | dynamicClient, err := w.client.GetDynamicClient() 153 | if err != nil { 154 | return nil, errors.Wrap(err, "failed to get dynamic client") 155 | } 156 | 157 | mapping, err := w.client.WaitForRestMapping(obj, 2*time.Minute) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | if mapping.Scope == meta.RESTScopeRoot { 163 | return dynamicClient.Resource(mapping.Resource), nil 164 | } 165 | return dynamicClient.Resource(mapping.Resource).Namespace(v1.NamespaceAll), nil 166 | } 167 | 168 | func getCacheKey(obj runtime.Object, template *templatev1.Template) string { 169 | kind := obj.GetObjectKind().GroupVersionKind().Kind 170 | return fmt.Sprintf("kind=%s;template=%s", kind, template.Name) 171 | } 172 | -------------------------------------------------------------------------------- /controllers/template_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 | templatev1 "github.com/flanksource/template-operator/api/v1" 23 | "github.com/flanksource/template-operator/k8s" 24 | "github.com/prometheus/client_golang/prometheus" 25 | v1 "k8s.io/api/core/v1" 26 | kerrors "k8s.io/apimachinery/pkg/api/errors" 27 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 28 | "k8s.io/apimachinery/pkg/types" 29 | ctrl "sigs.k8s.io/controller-runtime" 30 | "sigs.k8s.io/controller-runtime/pkg/metrics" 31 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 32 | ) 33 | 34 | var ( 35 | templateCount = prometheus.NewGaugeVec( 36 | prometheus.GaugeOpts{ 37 | Name: "template_operator_template_count", 38 | Help: "Total template runs count", 39 | }, 40 | []string{"template"}, 41 | ) 42 | templateSuccess = prometheus.NewGaugeVec( 43 | prometheus.GaugeOpts{ 44 | Name: "template_operator_template_success", 45 | Help: "Total successful template runs count", 46 | }, 47 | []string{"template"}, 48 | ) 49 | templateFailed = prometheus.NewGaugeVec( 50 | prometheus.GaugeOpts{ 51 | Name: "template_operator_template_failed", 52 | Help: "Total failed template runs count", 53 | }, 54 | []string{"template"}, 55 | ) 56 | ) 57 | 58 | func init() { 59 | metrics.Registry.MustRegister(templateCount, templateSuccess, templateFailed) 60 | } 61 | 62 | // TemplateReconciler reconciles a Template object 63 | type TemplateReconciler struct { 64 | Client 65 | } 66 | 67 | // +kubebuilder:rbac:groups="*",resources="*",verbs="*" 68 | 69 | func (r *TemplateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 70 | log := r.Log.WithValues("template", req.NamespacedName) 71 | name := req.NamespacedName.String() 72 | 73 | template := &templatev1.Template{} 74 | if err := r.ControllerClient.Get(ctx, req.NamespacedName, template); err != nil { 75 | if kerrors.IsNotFound(err) { 76 | log.Error(err, "template not found") 77 | return reconcile.Result{}, nil 78 | } 79 | log.Error(err, "failed to get template") 80 | incFailed(name) 81 | return reconcile.Result{}, err 82 | } 83 | //If the TemplateManager will fetch a new schema, ensure the kommons.client also does so in order to ensure they contain the same information 84 | if r.Cache.SchemaHasExpired() { 85 | r.KommonsClient.ResetRestMapper() 86 | } 87 | tm, err := k8s.NewTemplateManager(r.KommonsClient, log, r.Cache, r.Events, r.Watcher) 88 | if err != nil { 89 | incFailed(name) 90 | return reconcile.Result{}, err 91 | } 92 | result, err := tm.Run(ctx, template, r.reconcileObject(req.NamespacedName)) 93 | if err != nil { 94 | incFailed(name) 95 | return reconcile.Result{}, err 96 | } 97 | incSuccess(name) 98 | return result, nil 99 | } 100 | 101 | func (r *TemplateReconciler) SetupWithManager(mgr ctrl.Manager) error { 102 | r.ControllerClient = mgr.GetClient() 103 | r.Events = mgr.GetEventRecorderFor("template-operator") 104 | 105 | return ctrl.NewControllerManagedBy(mgr). 106 | For(&templatev1.Template{}). 107 | Complete(r) 108 | } 109 | 110 | func (r *TemplateReconciler) reconcileObject(namespacedName types.NamespacedName) k8s.CallbackFunc { 111 | return func(obj unstructured.Unstructured) error { 112 | ctx := context.Background() 113 | log := r.Log.WithValues("template", namespacedName) 114 | name := namespacedName.String() 115 | template := &templatev1.Template{} 116 | if err := r.ControllerClient.Get(ctx, namespacedName, template); err != nil { 117 | if kerrors.IsNotFound(err) { 118 | log.Error(err, "template not found") 119 | return err 120 | } 121 | log.Error(err, "failed to get template") 122 | incFailed(name) 123 | return err 124 | } 125 | 126 | //If the TemplateManager will fetch a new schema, ensure the kommons.client also does so in order to ensure they contain the same information 127 | if r.Cache.SchemaHasExpired() { 128 | r.KommonsClient.ResetRestMapper() 129 | } 130 | tm, err := k8s.NewTemplateManager(r.KommonsClient, log, r.Cache, r.Events, r.Watcher) 131 | if err != nil { 132 | log.Error(err, "failed to create template manager") 133 | incFailed(name) 134 | return err 135 | } 136 | 137 | namespaces, err := tm.GetSourceNamespaces(ctx, template) 138 | if err != nil { 139 | log.Error(err, "failed to get source namespaces") 140 | incFailed(name) 141 | return err 142 | } 143 | if len(namespaces) != 1 || namespaces[0] != v1.NamespaceAll { 144 | found := false 145 | for _, n := range namespaces { 146 | if n == obj.GetNamespace() { 147 | found = true 148 | break 149 | } 150 | } 151 | if !found { 152 | log.V(2).Info("Namespace %s not found in namespaces %v\n", obj.GetNamespace(), namespaces) 153 | return nil 154 | } 155 | } 156 | 157 | _, err = tm.HandleSource(ctx, template, obj) 158 | if err != nil { 159 | incFailed(name) 160 | return err 161 | } 162 | incSuccess(name) 163 | 164 | return nil 165 | } 166 | } 167 | 168 | func incSuccess(name string) { 169 | templateCount.WithLabelValues(name).Inc() 170 | templateSuccess.WithLabelValues(name).Inc() 171 | } 172 | 173 | func incFailed(name string) { 174 | templateCount.WithLabelValues(name).Inc() 175 | templateFailed.WithLabelValues(name).Inc() 176 | } 177 | -------------------------------------------------------------------------------- /k8s/schema_cache.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | "github.com/go-logr/logr" 11 | "github.com/go-openapi/spec" 12 | lru "github.com/hashicorp/golang-lru" 13 | "github.com/pkg/errors" 14 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 15 | extapi "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/runtime/schema" 18 | "k8s.io/client-go/kubernetes" 19 | ) 20 | 21 | type SchemaCache struct { 22 | clientset *kubernetes.Clientset 23 | expire time.Duration 24 | lock *sync.Mutex 25 | crdClient extapi.ApiextensionsV1Interface 26 | 27 | resources []*metav1.APIResourceList 28 | resourcesExpireTimestamp time.Time 29 | 30 | schema *spec.Swagger 31 | schemaExpireTimestamp time.Time 32 | 33 | crds []extv1.CustomResourceDefinition 34 | crdsExpireTimestamp time.Time 35 | 36 | schemaUnmarshalCache *lru.Cache 37 | 38 | log logr.Logger 39 | } 40 | 41 | func NewSchemaCache(clientset *kubernetes.Clientset, crdClient extapi.ApiextensionsV1Interface, expire time.Duration, log logr.Logger) *SchemaCache { 42 | schemaUnmarshalCache, _ := lru.New(100) 43 | 44 | sc := &SchemaCache{ 45 | clientset: clientset, 46 | crdClient: crdClient, 47 | expire: expire, 48 | lock: &sync.Mutex{}, 49 | log: log, 50 | schemaUnmarshalCache: schemaUnmarshalCache, 51 | 52 | resources: nil, 53 | } 54 | return sc 55 | } 56 | 57 | func (sc *SchemaCache) ExpireSchema() error { 58 | sc.lock.Lock() 59 | defer sc.lock.Unlock() 60 | if sc.schemaExpireTimestamp.After(time.Now()) { 61 | sc.schemaExpireTimestamp = time.Now() 62 | } 63 | if sc.crdsExpireTimestamp.After(time.Now()) { 64 | sc.crdsExpireTimestamp = time.Now() 65 | } 66 | return nil 67 | } 68 | 69 | func (sc *SchemaCache) ExpireResources() error { 70 | sc.lock.Lock() 71 | defer sc.lock.Unlock() 72 | if sc.resourcesExpireTimestamp.After(time.Now()) { 73 | sc.resourcesExpireTimestamp = time.Now() 74 | } 75 | return nil 76 | } 77 | 78 | func (sc *SchemaCache) SchemaHasExpired() bool { 79 | return sc.schemaExpireTimestamp.Before(time.Now()) 80 | } 81 | 82 | func (sc *SchemaCache) ResourceHasExpired() bool { 83 | return sc.resourcesExpireTimestamp.Before(time.Now()) 84 | } 85 | 86 | func (sc *SchemaCache) FetchSchema() (*spec.Swagger, error) { 87 | sc.lock.Lock() 88 | defer sc.lock.Unlock() 89 | 90 | if sc.resources == nil || time.Now().After(sc.schemaExpireTimestamp) { 91 | sc.log.V(3).Info("before fetch schema") 92 | if err := sc.fetchAndSetSchema(); err != nil { 93 | return nil, errors.Wrap(err, "failed to refetch API schema") 94 | } 95 | sc.log.V(3).Info("after fetch schema") 96 | } 97 | 98 | return sc.schema, nil 99 | } 100 | 101 | func (sc *SchemaCache) FetchResources() ([]*metav1.APIResourceList, error) { 102 | sc.lock.Lock() 103 | defer sc.lock.Unlock() 104 | 105 | if sc.resources == nil || time.Now().After(sc.resourcesExpireTimestamp) { 106 | sc.log.V(3).Info("before fetch resources") 107 | if err := sc.fetchAndSetResources(); err != nil { 108 | return nil, errors.Wrap(err, "failed to refetch API resources") 109 | } 110 | sc.log.V(3).Info("after fetch resources") 111 | } 112 | return sc.resources, nil 113 | } 114 | 115 | func (sc *SchemaCache) FetchCRD() ([]extv1.CustomResourceDefinition, error) { 116 | sc.lock.Lock() 117 | defer sc.lock.Unlock() 118 | 119 | if sc.crds == nil || time.Now().After(sc.crdsExpireTimestamp) { 120 | sc.log.V(3).Info("before fetch crds") 121 | crds, err := sc.crdClient.CustomResourceDefinitions().List(context.Background(), metav1.ListOptions{}) 122 | if err != nil { 123 | return nil, errors.Wrap(err, "failed to list customresourcedefinitions") 124 | } 125 | sc.crds = crds.Items 126 | sc.crdsExpireTimestamp = time.Now().Add(sc.expire) 127 | sc.log.V(3).Info("after fetch crds") 128 | } 129 | 130 | return sc.crds, nil 131 | } 132 | 133 | func (sc *SchemaCache) CachedConvertSchema(gvk schema.GroupVersionKind, crd extv1.CustomResourceDefinitionVersion) (*spec.Schema, error) { 134 | key := fmt.Sprintf("group=%s;version=%s;kind=%s", gvk.Group, gvk.Version, gvk.Kind) 135 | 136 | sc.lock.Lock() 137 | defer sc.lock.Unlock() 138 | 139 | schemaI, found := sc.schemaUnmarshalCache.Get(key) 140 | if found { 141 | schema, ok := schemaI.(*spec.Schema) 142 | if ok { 143 | return schema, nil 144 | } 145 | sc.log.Info("failed to fetch schema from lru cache") 146 | } 147 | 148 | schemaBytes, err := json.Marshal(crd.Schema.OpenAPIV3Schema) 149 | if err != nil { 150 | return nil, errors.Wrap(err, "failed to encode crd schema to json") 151 | } 152 | 153 | schema := &spec.Schema{} 154 | if err := json.Unmarshal(schemaBytes, schema); err != nil { 155 | return nil, errors.Wrap(err, "failed to decode json into spec.Schema") 156 | } 157 | 158 | sc.schemaUnmarshalCache.Add(key, schema) 159 | return schema, nil 160 | } 161 | 162 | func (sc *SchemaCache) fetchAndSetSchema() error { 163 | bs, err := sc.clientset.RESTClient().Get().AbsPath("openapi", "v2").DoRaw(context.TODO()) 164 | if err != nil { 165 | return errors.Wrap(err, "failed to fetch schema from server") 166 | } 167 | s := &spec.Swagger{} 168 | 169 | if err := json.Unmarshal(bs, &s); err != nil { 170 | return errors.Wrap(err, "failed to unmarshal openapi") 171 | } 172 | 173 | sc.schema = s 174 | sc.schemaExpireTimestamp = time.Now().Add(sc.expire) 175 | 176 | return nil 177 | } 178 | 179 | func (sc *SchemaCache) fetchAndSetResources() error { 180 | serverResources, err := sc.clientset.ServerPreferredResources() 181 | if err != nil { 182 | return errors.Wrap(err, "failed to list server resources") 183 | } 184 | sc.resources = serverResources 185 | sc.resourcesExpireTimestamp = time.Now().Add(sc.expire) 186 | return nil 187 | } 188 | -------------------------------------------------------------------------------- /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 | "os" 22 | "time" 23 | 24 | apiv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" 25 | 26 | "github.com/flanksource/commons/logger" 27 | "github.com/flanksource/kommons" 28 | templatingflanksourcecomv1 "github.com/flanksource/template-operator/api/v1" 29 | "github.com/flanksource/template-operator/controllers" 30 | "github.com/flanksource/template-operator/k8s" 31 | zaplogfmt "github.com/sykesm/zap-logfmt" 32 | uzap "go.uber.org/zap" 33 | "go.uber.org/zap/zapcore" 34 | "gopkg.in/yaml.v2" 35 | apiv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 36 | extapi "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" 37 | "k8s.io/apimachinery/pkg/runtime" 38 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 39 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 40 | ctrl "sigs.k8s.io/controller-runtime" 41 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 42 | // +kubebuilder:scaffold:imports 43 | ) 44 | 45 | var ( 46 | scheme = runtime.NewScheme() 47 | setupLog = ctrl.Log.WithName("setup") 48 | ) 49 | 50 | func init() { 51 | _ = clientgoscheme.AddToScheme(scheme) 52 | 53 | _ = templatingflanksourcecomv1.AddToScheme(scheme) 54 | apiv1.AddToScheme(scheme) 55 | apiv1beta1.AddToScheme(scheme) 56 | // +kubebuilder:scaffold:scheme 57 | 58 | yaml.FutureLineWrap() 59 | } 60 | 61 | func setupLogger(opts zap.Options) { 62 | configLog := uzap.NewProductionEncoderConfig() 63 | configLog.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) { 64 | encoder.AppendString(ts.UTC().Format(time.RFC3339Nano)) 65 | } 66 | logfmtEncoder := zaplogfmt.NewEncoder(configLog) 67 | 68 | logger := zap.New(zap.UseFlagOptions(&opts), zap.Encoder(logfmtEncoder)) 69 | ctrl.SetLogger(logger) 70 | } 71 | 72 | func main() { 73 | var metricsAddr string 74 | var enableLeaderElection bool 75 | var syncPeriod, expire time.Duration 76 | flag.DurationVar(&syncPeriod, "sync-period", 5*time.Minute, "The time duration to run a full reconcile") 77 | flag.DurationVar(&expire, "expire", 15*time.Minute, "The time duration to expire API resources cache") 78 | flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") 79 | flag.BoolVar(&enableLeaderElection, "enable-leader-election", false, 80 | "Enable leader election for controller manager. "+ 81 | "Enabling this will ensure there is only one active controller manager.") 82 | 83 | opts := zap.Options{} 84 | opts.BindFlags(flag.CommandLine) 85 | flag.Parse() 86 | setupLogger(opts) 87 | 88 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 89 | Scheme: scheme, 90 | MetricsBindAddress: metricsAddr, 91 | Port: 9443, 92 | SyncPeriod: &syncPeriod, 93 | LeaderElection: enableLeaderElection, 94 | LeaderElectionID: "ba344e13.flanksource.com", 95 | }) 96 | if err != nil { 97 | setupLog.Error(err, "unable to start manager") 98 | os.Exit(1) 99 | } 100 | 101 | client := kommons.NewClient(mgr.GetConfig(), logger.StandardLogger()) 102 | clientset, err := client.GetClientset() 103 | if err != nil { 104 | setupLog.Error(err, "failed to get clientset") 105 | os.Exit(1) 106 | } 107 | restConfig, err := client.GetRESTConfig() 108 | if err != nil { 109 | setupLog.Error(err, "failed to get rest config") 110 | os.Exit(1) 111 | } 112 | crdClient, err := extapi.NewForConfig(restConfig) 113 | if err != nil { 114 | setupLog.Error(err, "failed to get crd client") 115 | os.Exit(1) 116 | } 117 | schemaCache := k8s.NewSchemaCache(clientset, crdClient, expire, ctrl.Log.WithName("schema-cache")) 118 | 119 | watcher, err := k8s.NewWatcher(client, ctrl.Log.WithName("watcher")) 120 | if err != nil { 121 | setupLog.Error(err, "failed to setup watcher") 122 | os.Exit(1) 123 | } 124 | 125 | if err = (&controllers.TemplateReconciler{ 126 | Client: controllers.Client{ 127 | KommonsClient: client, 128 | Cache: schemaCache, 129 | Log: ctrl.Log.WithName("controllers").WithName("Template"), 130 | Scheme: mgr.GetScheme(), 131 | Watcher: watcher, 132 | }, 133 | }).SetupWithManager(mgr); err != nil { 134 | setupLog.Error(err, "unable to create controller", "controller", "Template") 135 | os.Exit(1) 136 | } 137 | //CRDReconciler shares a SchemaCache with TemplateReconciler, and resets it if changes to CRDs are reported, so that the TemplateReconciler will pick them up 138 | if err = (&controllers.CRDReconciler{ 139 | Client: controllers.Client{ 140 | KommonsClient: client, 141 | Cache: schemaCache, 142 | Log: ctrl.Log.WithName("controllers").WithName("Template"), 143 | Scheme: mgr.GetScheme(), 144 | }, 145 | ResourceVersion: 0, 146 | }).SetupWithManager(mgr); err != nil { 147 | setupLog.Error(err, "unable to create controller", "controller", "Template") 148 | os.Exit(1) 149 | } 150 | if err = (&controllers.RESTReconciler{ 151 | Client: controllers.Client{ 152 | KommonsClient: client, 153 | Cache: schemaCache, 154 | Log: ctrl.Log.WithName("controllers").WithName("Template"), 155 | Scheme: mgr.GetScheme(), 156 | Watcher: watcher, 157 | }, 158 | }).SetupWithManager(mgr); err != nil { 159 | setupLog.Error(err, "unable to create controller", "controller", "REST") 160 | os.Exit(1) 161 | } 162 | // +kubebuilder:scaffold:builder 163 | 164 | setupLog.Info("starting manager") 165 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 166 | setupLog.Error(err, "problem running manager") 167 | os.Exit(1) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /k8s/patches.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path/filepath" 7 | osruntime "runtime" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/flanksource/kommons/ktemplate" 12 | "github.com/go-logr/logr" 13 | "github.com/pkg/errors" 14 | fyaml "gopkg.in/flanksource/yaml.v3" 15 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 16 | "k8s.io/apimachinery/pkg/runtime/schema" 17 | "k8s.io/client-go/kubernetes" 18 | "sigs.k8s.io/kustomize/api/krusty" 19 | "sigs.k8s.io/kustomize/kyaml/filesys" 20 | "sigs.k8s.io/kustomize/pkg/gvk" 21 | "sigs.k8s.io/kustomize/pkg/patch" 22 | "sigs.k8s.io/kustomize/pkg/types" 23 | "sigs.k8s.io/yaml" 24 | ) 25 | 26 | type PatchType string 27 | 28 | var ( 29 | PatchTypeYaml PatchType = "yaml" 30 | PatchTypeJSON PatchType = "json" 31 | ) 32 | 33 | type PatchApplier struct { 34 | Clientset *kubernetes.Clientset 35 | Log logr.Logger 36 | FuncMap template.FuncMap 37 | SchemaManager *SchemaManager 38 | } 39 | 40 | func NewPatchApplier(clientset *kubernetes.Clientset, schemaManager *SchemaManager, log logr.Logger) (*PatchApplier, error) { 41 | p := &PatchApplier{ 42 | Clientset: clientset, 43 | Log: log, 44 | SchemaManager: schemaManager, 45 | } 46 | 47 | functions := ktemplate.NewFunctions(clientset) 48 | p.FuncMap = functions.FuncMap() 49 | return p, nil 50 | } 51 | 52 | func (p *PatchApplier) Apply(resource *unstructured.Unstructured, patchStr string, patchType PatchType) (*unstructured.Unstructured, error) { 53 | // fmt.Printf("Template patch:\n%s\n====\n", patchStr) 54 | t, err := template.New("patch").Funcs(p.FuncMap).Parse(patchStr) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "failed to create template from patch") 57 | } 58 | 59 | var tpl bytes.Buffer 60 | var data = map[string]interface{}{ 61 | "source": resource.Object, 62 | } 63 | if err := t.Execute(&tpl, data); err != nil { 64 | return nil, errors.Wrap(err, "failed to execute template") 65 | } 66 | 67 | // create an in memory fs to use for the kustomization 68 | memFS := filesys.MakeFsInMemory() 69 | 70 | fakeDir := "/" 71 | // for Windows we need this to be a drive because kustomize uses filepath.Abs() 72 | // which will add a drive letter if there is none. which drive letter is 73 | // unimportant as the path is on the fake filesystem anyhow 74 | if osruntime.GOOS == "windows" { 75 | fakeDir = `C:\` 76 | } 77 | 78 | // writes the resource to a file in the temp file system 79 | b, err := yaml.Marshal(resource.Object) 80 | if err != nil { 81 | return nil, errors.Wrap(err, "failed to marshal resource object") 82 | } 83 | name := "resource.yaml" 84 | memFS.WriteFile(filepath.Join(fakeDir, name), b) // nolint: errcheck 85 | 86 | kustomizationFile := &types.Kustomization{Resources: []string{name}} 87 | 88 | version := resource.GetAPIVersion() 89 | parts := strings.Split(version, "/") 90 | var apiVersion, apiGroup string 91 | if len(parts) == 1 { 92 | apiGroup = "" 93 | apiVersion = parts[0] 94 | } else { 95 | apiGroup = parts[0] 96 | apiVersion = parts[1] 97 | } 98 | groupVersionKind := schema.GroupVersionKind{Group: apiGroup, Version: apiVersion, Kind: resource.GetKind()} 99 | 100 | if patchType == PatchTypeYaml { 101 | finalPatch := map[string]interface{}{} 102 | templateBytes := tpl.Bytes() 103 | if err := fyaml.Unmarshal(templateBytes, &finalPatch); err != nil { 104 | return nil, errors.Wrap(err, "failed to unmarshal template yaml") 105 | } 106 | patchObject := &unstructured.Unstructured{Object: finalPatch} 107 | if patchObject.GetName() == "" { 108 | patchObject.SetName(resource.GetName()) 109 | } 110 | if patchObject.GetNamespace() == "" { 111 | patchObject.SetNamespace(resource.GetNamespace()) 112 | } 113 | 114 | if err := p.SchemaManager.DuckType(groupVersionKind, patchObject); err != nil { 115 | p.Log.Error(err, "failed to duck type object") 116 | } 117 | 118 | // writes strategic merge patches to files in the temp file system 119 | kustomizationFile.PatchesStrategicMerge = []patch.StrategicMerge{} 120 | b, err = yaml.Marshal(patchObject.Object) 121 | if err != nil { 122 | return nil, errors.Wrap(err, "failed to marshal patch object") 123 | } 124 | 125 | name = fmt.Sprintf("patch-0.yaml") 126 | memFS.WriteFile(filepath.Join(fakeDir, name), b) // nolint: errcheck 127 | kustomizationFile.PatchesStrategicMerge = []patch.StrategicMerge{patch.StrategicMerge(name)} 128 | 129 | } else if patchType == PatchTypeJSON { 130 | name = fmt.Sprintf("patch-0.json") 131 | templateBytes := tpl.Bytes() 132 | memFS.WriteFile(filepath.Join(fakeDir, name), templateBytes) // nolint: errcheck 133 | // writes json patches to files in the temp file system 134 | 135 | kustomizationFile.PatchesJson6902 = []patch.Json6902{ 136 | { 137 | Target: &patch.Target{ 138 | Gvk: gvk.Gvk{ 139 | Group: apiGroup, 140 | Version: apiVersion, 141 | Kind: resource.GetKind(), 142 | }, 143 | Name: resource.GetName(), 144 | Namespace: resource.GetNamespace(), 145 | }, 146 | Path: name, 147 | }, 148 | } 149 | 150 | } else { 151 | return nil, errors.Errorf("Invalid patch type %s", patchType) 152 | } 153 | 154 | // writes the kustomization file to the temp file system 155 | kbytes, err := yaml.Marshal(kustomizationFile) 156 | if err != nil { 157 | return nil, errors.Wrap(err, "failed to marshal kustomization file") 158 | } 159 | memFS.WriteFile(filepath.Join(fakeDir, "kustomization.yaml"), kbytes) // nolint: errcheck 160 | 161 | // Finally kustomize the target resource 162 | out, err := krusty.MakeKustomizer(krusty.MakeDefaultOptions()).Run(memFS, fakeDir) 163 | if err != nil { 164 | return nil, errors.Wrap(err, "failed to run kustomize build") 165 | } 166 | 167 | for _, r := range out.Resources() { 168 | if b, err := r.AsYAML(); err == nil { 169 | if err := yaml.Unmarshal(b, &resource); err != nil { 170 | return nil, errors.Wrap(err, "failed to unmarshal kustomize output into resource "+string(b)) 171 | } 172 | } 173 | } 174 | 175 | return resource, nil 176 | } 177 | 178 | var annotationsBlacklist = []string{ 179 | "metadata.annotations.serving.knative.dev/creator", 180 | "metadata.annotations.serving.knative.dev/lastModifier", 181 | } 182 | 183 | func stripAnnotations(obj *unstructured.Unstructured) { 184 | annotations := obj.GetAnnotations() 185 | for _, a := range annotationsBlacklist { 186 | delete(annotations, a) 187 | } 188 | obj.SetAnnotations(annotations) 189 | } 190 | -------------------------------------------------------------------------------- /config/crd/bases/templating.flanksource.com_rests.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.4.0 8 | creationTimestamp: null 9 | name: rests.templating.flanksource.com 10 | spec: 11 | group: templating.flanksource.com 12 | names: 13 | kind: REST 14 | listKind: RESTList 15 | plural: rests 16 | singular: rest 17 | scope: Cluster 18 | versions: 19 | - name: v1 20 | schema: 21 | openAPIV3Schema: 22 | description: REST is the Schema for the rest 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: RESTSpec defines the desired state of REST 38 | properties: 39 | auth: 40 | description: Auth may be used for http basic authentication 41 | properties: 42 | namespace: 43 | description: Namespace where secret / config map is present 44 | type: string 45 | password: 46 | description: Password represents the HTTP Basic Auth password 47 | properties: 48 | configMapKeyRef: 49 | properties: 50 | key: 51 | type: string 52 | name: 53 | type: string 54 | optional: 55 | type: boolean 56 | required: 57 | - key 58 | type: object 59 | secretKeyRef: 60 | properties: 61 | key: 62 | type: string 63 | name: 64 | type: string 65 | optional: 66 | type: boolean 67 | required: 68 | - key 69 | type: object 70 | type: object 71 | username: 72 | description: Username represents the HTTP Basic Auth username 73 | properties: 74 | configMapKeyRef: 75 | properties: 76 | key: 77 | type: string 78 | name: 79 | type: string 80 | optional: 81 | type: boolean 82 | required: 83 | - key 84 | type: object 85 | secretKeyRef: 86 | properties: 87 | key: 88 | type: string 89 | name: 90 | type: string 91 | optional: 92 | type: boolean 93 | required: 94 | - key 95 | type: object 96 | type: object 97 | type: object 98 | headers: 99 | additionalProperties: 100 | type: string 101 | description: Headers are optional http headers to be sent on the request 102 | type: object 103 | remove: 104 | description: Remove defines the payload to be sent when CRD item is 105 | deleted 106 | properties: 107 | body: 108 | description: Body represents the HTTP Request body 109 | type: string 110 | method: 111 | description: 'Method represents HTTP method to be used for the 112 | request. Example: POST' 113 | type: string 114 | status: 115 | additionalProperties: 116 | type: string 117 | description: Status defines the status fields which will be updated 118 | based on response status 119 | type: object 120 | url: 121 | description: URL represents the URL used for the request 122 | type: string 123 | type: object 124 | update: 125 | description: Update defines the payload to be sent when CRD item is 126 | updated 127 | properties: 128 | body: 129 | description: Body represents the HTTP Request body 130 | type: string 131 | method: 132 | description: 'Method represents HTTP method to be used for the 133 | request. Example: POST' 134 | type: string 135 | status: 136 | additionalProperties: 137 | type: string 138 | description: Status defines the status fields which will be updated 139 | based on response status 140 | type: object 141 | url: 142 | description: URL represents the URL used for the request 143 | type: string 144 | type: object 145 | url: 146 | description: URL represents the URL address used to send requests 147 | type: string 148 | type: object 149 | status: 150 | additionalProperties: 151 | type: string 152 | type: object 153 | x-kubernetes-preserve-unknown-fields: true 154 | required: 155 | - spec 156 | type: object 157 | served: true 158 | storage: true 159 | subresources: 160 | status: {} 161 | status: 162 | acceptedNames: 163 | kind: "" 164 | plural: "" 165 | conditions: [] 166 | storedVersions: [] 167 | -------------------------------------------------------------------------------- /k8s/rest_manager.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | "strconv" 12 | "strings" 13 | "text/template" 14 | 15 | "github.com/flanksource/kommons" 16 | "github.com/flanksource/kommons/ktemplate" 17 | templatev1 "github.com/flanksource/template-operator/api/v1" 18 | "github.com/go-logr/logr" 19 | "github.com/pkg/errors" 20 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 21 | "k8s.io/client-go/kubernetes" 22 | ) 23 | 24 | type RESTManager struct { 25 | Client *kommons.Client 26 | kubernetes.Interface 27 | Log logr.Logger 28 | FuncMap template.FuncMap 29 | } 30 | 31 | func NewRESTManager(c *kommons.Client, log logr.Logger) (*RESTManager, error) { 32 | clientset, _ := c.GetClientset() 33 | 34 | functions := ktemplate.NewFunctions(clientset) 35 | 36 | tm := &RESTManager{ 37 | Client: c, 38 | Interface: clientset, 39 | Log: log, 40 | FuncMap: functions.FuncMap(), 41 | } 42 | return tm, nil 43 | } 44 | 45 | func (r *RESTManager) Update(ctx context.Context, rest *templatev1.REST) (map[string]string, error) { 46 | if sameGeneration(rest) { 47 | return nil, nil 48 | } 49 | 50 | url := rest.Spec.Update.URL 51 | method := rest.Spec.Update.Method 52 | body := rest.Spec.Update.Body 53 | 54 | resp, err := r.doRequest(ctx, rest, url, method, body) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "failed to send request") 57 | } 58 | 59 | respBody := map[string]interface{}{} 60 | if err := json.Unmarshal(resp, &respBody); err != nil { 61 | r.Log.Info("failed to unmarshal response body", "error", err) 62 | } 63 | 64 | statusUpdates := map[string]string{} 65 | 66 | if rest.Spec.Update.Status != nil { 67 | for k, v := range rest.Spec.Update.Status { 68 | value, err := r.templateStatus(rest, respBody, v) 69 | if err != nil { 70 | return nil, errors.Wrapf(err, "failed to template status field %s", k) 71 | } 72 | statusUpdates[k] = value 73 | } 74 | } 75 | 76 | statusUpdates["observedGeneration"] = strconv.FormatInt(rest.ObjectMeta.Generation, 10) 77 | 78 | return statusUpdates, nil 79 | } 80 | 81 | func (r *RESTManager) Delete(ctx context.Context, rest *templatev1.REST) error { 82 | url := rest.Spec.Remove.URL 83 | method := rest.Spec.Remove.Method 84 | body := rest.Spec.Remove.Body 85 | 86 | _, err := r.doRequest(ctx, rest, url, method, body) 87 | if err != nil { 88 | return errors.Wrap(err, "failed to send request") 89 | } 90 | 91 | fmt.Printf("Received delete request\n") 92 | return nil 93 | } 94 | 95 | func (r *RESTManager) doRequest(ctx context.Context, rest *templatev1.REST, url, method, body string) ([]byte, error) { 96 | newBody, err := r.templateField(rest, body) 97 | if err != nil { 98 | return nil, errors.Wrap(err, "failed to template body") 99 | } 100 | 101 | newURL, err := r.templateField(rest, url) 102 | if err != nil { 103 | return nil, errors.Wrap(err, "failed to template url") 104 | } 105 | if newURL == "" { 106 | if rest.Spec.URL == "" { 107 | return nil, errors.Wrap(err, "url cannot be empty") 108 | } 109 | newURL = rest.Spec.URL 110 | } 111 | 112 | client := &http.Client{} 113 | 114 | // set the HTTP method, url, and request body 115 | req, err := http.NewRequest(method, newURL, bytes.NewBuffer([]byte(newBody))) 116 | if err != nil { 117 | return nil, errors.Wrap(err, "failed to create request") 118 | } 119 | 120 | if rest.Spec.Headers != nil { 121 | for k, v := range rest.Spec.Headers { 122 | req.Header.Set(k, v) 123 | } 124 | } 125 | 126 | if rest.Spec.Auth != nil { 127 | basicAuth, err := getRestAuthorization(r.Client, rest.Spec.Auth) 128 | if err != nil { 129 | return nil, errors.Wrap(err, "failed to generate basic auth") 130 | } 131 | req.Header.Set("Authorization", basicAuth) 132 | } 133 | 134 | r.Log.V(3).Info("Sending Request:", "url", newURL, "method", method, "body", newBody) 135 | 136 | resp, err := client.Do(req) 137 | if err != nil { 138 | return nil, errors.Wrap(err, "http request failed") 139 | } 140 | defer resp.Body.Close() 141 | 142 | r.Log.V(3).Info("Response:", "statusCode", resp.StatusCode) 143 | 144 | bodyBytes, err := ioutil.ReadAll(resp.Body) 145 | if err != nil { 146 | return nil, errors.Wrap(err, "failed to read response body") 147 | } 148 | 149 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 150 | return nil, errors.Errorf("expected response status 2xx, received status=%d body=%s", resp.StatusCode, string(bodyBytes)) 151 | } 152 | 153 | return bodyBytes, nil 154 | } 155 | 156 | func (r *RESTManager) templateField(rest *templatev1.REST, field string) (string, error) { 157 | t, err := template.New("patch").Option("missingkey=zero").Funcs(r.FuncMap).Parse(field) 158 | // supress/ignore error if it contains text "map has no entry for key" as missingkey=zero doesn't work currently on map[string]interface{} 159 | // workaround for: https://github.com/golang/go/issues/24963 160 | if err != nil && !strings.Contains(err.Error(), "map has no entry for key") { 161 | return "", errors.Wrap(err, "failed to create template from field") 162 | } 163 | 164 | var tpl bytes.Buffer 165 | unstructuredData, err := kommons.ToUnstructured(&unstructured.Unstructured{}, rest) 166 | if err != nil { 167 | return "", errors.Wrap(err, "failed to convert rest to unstructured") 168 | } 169 | data := unstructuredData.Object 170 | 171 | if data["status"] == nil { 172 | data["status"] = map[string]interface{}{} 173 | } 174 | 175 | if err := t.Execute(&tpl, data); err != nil { 176 | return "", errors.Wrap(err, "failed to execute template") 177 | } 178 | 179 | return tpl.String(), nil 180 | } 181 | 182 | func (r *RESTManager) templateStatus(rest *templatev1.REST, response map[string]interface{}, field string) (string, error) { 183 | t, err := template.New("patch").Option("missingkey=zero").Funcs(r.FuncMap).Parse(field) 184 | // supress/ignore error if it contains text "map has no entry for key" as missingkey=zero doesn't work currently on map[string]interface{} 185 | // workaround for: https://github.com/golang/go/issues/24963 186 | if err != nil && !strings.Contains(err.Error(), "map has no entry for key") { 187 | return "", errors.Wrap(err, "failed to create template from field") 188 | } 189 | 190 | var tpl bytes.Buffer 191 | 192 | unstructuredData, err := kommons.ToUnstructured(&unstructured.Unstructured{}, rest) 193 | if err != nil { 194 | return "", errors.Wrap(err, "failed to convert rest to unstructured") 195 | } 196 | data := unstructuredData.Object 197 | data["response"] = response 198 | 199 | if err := t.Execute(&tpl, data); err != nil { 200 | return "", errors.Wrap(err, "failed to execute template") 201 | } 202 | 203 | return tpl.String(), nil 204 | } 205 | 206 | func sameGeneration(rest *templatev1.REST) bool { 207 | if rest.Status == nil { 208 | return false 209 | } 210 | 211 | observedGeneration := rest.Status["observedGeneration"] 212 | 213 | if observedGeneration == "" { 214 | return false 215 | } 216 | 217 | gen, err := strconv.ParseInt(observedGeneration, 10, 64) 218 | if err != nil { 219 | return false 220 | } 221 | 222 | return gen == rest.ObjectMeta.Generation 223 | } 224 | 225 | func getRestAuthorization(client *kommons.Client, auth *templatev1.RESTAuth) (string, error) { 226 | _, username, err := client.GetEnvValue(kommons.EnvVar{Name: "username", ValueFrom: &auth.Username}, auth.Namespace) 227 | if err != nil { 228 | return "", errors.Wrap(err, "failed to get username value") 229 | } 230 | _, password, err := client.GetEnvValue(kommons.EnvVar{Name: "password", ValueFrom: &auth.Password}, auth.Namespace) 231 | if err != nil { 232 | return "", errors.Wrap(err, "failed to get username value") 233 | } 234 | 235 | basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) 236 | return basicAuth, nil 237 | } 238 | -------------------------------------------------------------------------------- /k8s/patches_test.go: -------------------------------------------------------------------------------- 1 | package k8s_test 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/flanksource/template-operator/k8s" 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | ctrl "sigs.k8s.io/controller-runtime" 11 | "sigs.k8s.io/yaml" 12 | ) 13 | 14 | var _ = Describe("Patches", func() { 15 | It("Merges json patch Ingress", func() { 16 | resource := &unstructured.Unstructured{ 17 | Object: map[string]interface{}{ 18 | "kind": "Ingress", 19 | "apiVersion": "extensions/v1beta1", 20 | "metadata": map[string]interface{}{ 21 | "name": "podinfo", 22 | "namespace": "example", 23 | }, 24 | "spec": map[string]interface{}{ 25 | "rules": []map[string]interface{}{ 26 | { 27 | "host": "pod-info", 28 | "http": map[string]interface{}{ 29 | "paths": []map[string]interface{}{ 30 | { 31 | "backend": map[string]interface{}{ 32 | "serviceName": "podinfo", 33 | "servicePort": 9898, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | "tls": []map[string]interface{}{ 41 | { 42 | "hosts": []string{ 43 | "pod-info", 44 | }, 45 | "secretName": "podinfo-tls", 46 | }, 47 | }, 48 | }, 49 | }, 50 | } 51 | 52 | patch := ` 53 | [ 54 | { 55 | "op": "replace", 56 | "path": "/spec/rules/0/host", 57 | "value": "{{ jsonPath .source "spec.rules.0.host" }}.{{- kget "cm/quack/quack-config" "data.domain" -}}" 58 | }, 59 | { 60 | "op": "replace", 61 | "path": "/spec/tls/0/hosts/0", 62 | "value": "{{ jsonPath .source "spec.tls.0.hosts.0" }}.{{- kget "cm/quack/quack-config" "data.domain" -}}" 63 | } 64 | ] 65 | ` 66 | log := ctrl.Log.WithName("test") 67 | patchApplier, err := k8s.NewPatchApplier(clientset(), newSchemaManager(), log) 68 | Expect(err).ToNot(HaveOccurred()) 69 | patchApplier.FuncMap["kget"] = func(path, jsonPath string) string { 70 | return "1.2.3.4.nip.io" 71 | } 72 | 73 | newResource, err := patchApplier.Apply(resource, patch, k8s.PatchTypeJSON) 74 | Expect(err).To(BeNil()) 75 | 76 | specYaml, err := yaml.Marshal(newResource.Object) 77 | Expect(err).To(BeNil()) 78 | 79 | foundYaml := strings.TrimSpace(string(specYaml)) 80 | 81 | expectedYaml := strings.TrimSpace(` 82 | apiVersion: extensions/v1beta1 83 | kind: Ingress 84 | metadata: 85 | name: podinfo 86 | namespace: example 87 | spec: 88 | rules: 89 | - host: pod-info.1.2.3.4.nip.io 90 | http: 91 | paths: 92 | - backend: 93 | serviceName: podinfo 94 | servicePort: 9898 95 | tls: 96 | - hosts: 97 | - pod-info.1.2.3.4.nip.io 98 | secretName: podinfo-tls 99 | `) 100 | // fmt.Printf("Found:\n%s\n", foundYaml) 101 | // fmt.Printf("Expected:\n%s\n", expectedYaml) 102 | Expect(foundYaml).To(Equal(expectedYaml)) 103 | }) 104 | 105 | It("Merges json patch Service", func() { 106 | resource := &unstructured.Unstructured{ 107 | Object: map[string]interface{}{ 108 | "kind": "Service", 109 | "apiVersion": "v1", 110 | "metadata": map[string]interface{}{ 111 | "name": "podinfo", 112 | "namespace": "example", 113 | }, 114 | "spec": map[string]interface{}{ 115 | "ports": []interface{}{ 116 | map[string]interface{}{ 117 | "protocol": "TCP", 118 | "port": "80", 119 | "targetPort": "9376", 120 | }, 121 | }, 122 | }, 123 | }, 124 | } 125 | 126 | patch := ` 127 | [ 128 | { 129 | "op": "replace", 130 | "path": "/spec/ports/0/port", 131 | "value": 443 132 | } 133 | ] 134 | ` 135 | log := ctrl.Log.WithName("test") 136 | patchApplier, err := k8s.NewPatchApplier(clientset(), newSchemaManager(), log) 137 | Expect(err).ToNot(HaveOccurred()) 138 | patchApplier.FuncMap["kget"] = func(path, jsonPath string) string { 139 | return "1.2.3.4.nip.io" 140 | } 141 | 142 | newResource, err := patchApplier.Apply(resource, patch, k8s.PatchTypeJSON) 143 | Expect(err).ToNot(HaveOccurred()) 144 | 145 | specYaml, err := yaml.Marshal(newResource.Object) 146 | Expect(err).ToNot(HaveOccurred()) 147 | 148 | foundYaml := strings.TrimSpace(string(specYaml)) 149 | 150 | expectedYaml := strings.TrimSpace(` 151 | apiVersion: v1 152 | kind: Service 153 | metadata: 154 | name: podinfo 155 | namespace: example 156 | spec: 157 | ports: 158 | - port: 443 159 | protocol: TCP 160 | targetPort: "9376" 161 | `) 162 | // fmt.Printf("Found:\n%s\n", foundYaml) 163 | // fmt.Printf("Expected:\n%s\n", expectedYaml) 164 | Expect(foundYaml).To(Equal(expectedYaml)) 165 | }) 166 | 167 | It("Merges annotations and labels", func() { 168 | resource := &unstructured.Unstructured{ 169 | Object: map[string]interface{}{ 170 | "kind": "Ingress", 171 | "apiVersion": "extensions/v1beta1", 172 | "metadata": map[string]interface{}{ 173 | "name": "podinfo", 174 | "namespace": "example", 175 | "annotations": map[string]interface{}{ 176 | "annotation1.example.com": "value1", 177 | "annotation2.example.com": "value2", 178 | }, 179 | "labels": map[string]interface{}{ 180 | "label1": "value1", 181 | "label2": "value2", 182 | }, 183 | }, 184 | "spec": map[string]interface{}{}, 185 | }, 186 | } 187 | 188 | patch := ` 189 | apiVersion: extensions/v1beta1 190 | kind: Ingress 191 | metadata: 192 | labels: 193 | label2: value22 194 | label3: value33 195 | annotations: 196 | annotation2.example.com: value22 197 | annotation3.example.com: foo.{{- kget "cm/quack/quack-config" "data.domain" -}} 198 | ` 199 | 200 | log := ctrl.Log.WithName("test") 201 | patchApplier, err := k8s.NewPatchApplier(clientset(), newSchemaManager(), log) 202 | Expect(err).ToNot(HaveOccurred()) 203 | patchApplier.FuncMap["kget"] = func(path, jsonPath string) string { 204 | return "1.2.3.4.nip.io" 205 | } 206 | 207 | newResource, err := patchApplier.Apply(resource, patch, k8s.PatchTypeYaml) 208 | Expect(err).ToNot(HaveOccurred()) 209 | 210 | specYaml, err := yaml.Marshal(newResource.Object) 211 | Expect(err).ToNot(HaveOccurred()) 212 | foundYaml := strings.TrimSpace(string(specYaml)) 213 | 214 | expectedYaml := strings.TrimSpace(` 215 | apiVersion: extensions/v1beta1 216 | kind: Ingress 217 | metadata: 218 | annotations: 219 | annotation1.example.com: value1 220 | annotation2.example.com: value22 221 | annotation3.example.com: foo.1.2.3.4.nip.io 222 | labels: 223 | label1: value1 224 | label2: value22 225 | label3: value33 226 | name: podinfo 227 | namespace: example 228 | spec: {} 229 | `) 230 | // fmt.Printf("Found:\n%s\n", foundYaml) 231 | // fmt.Printf("Expected:\n%s\n", expectedYaml) 232 | Expect(foundYaml).To(Equal(expectedYaml)) 233 | }) 234 | 235 | It("Encodes as json", func() { 236 | resource := &unstructured.Unstructured{ 237 | Object: map[string]interface{}{ 238 | "kind": "postgresql", 239 | "apiVersion": "acid.zalan.do/v1", 240 | "metadata": map[string]interface{}{ 241 | "name": "test", 242 | "namespace": "example", 243 | }, 244 | "spec": map[string]interface{}{ 245 | "replicas": 1, 246 | }, 247 | }, 248 | } 249 | 250 | patch := ` 251 | apiVersion: acid.zalan.do/v1 252 | kind: postgresql 253 | spec: 254 | postgresql: 255 | parameters: "{{ kget "postgresqldb/postgres-operator/test" "spec.parameters" }}" 256 | ` 257 | 258 | log := ctrl.Log.WithName("test") 259 | patchApplier, err := k8s.NewPatchApplier(clientset(), newSchemaManager(), log) 260 | Expect(err).ToNot(HaveOccurred()) 261 | patchApplier.FuncMap["kget"] = func(path, jsonPath string) string { 262 | str := "{\"max_connections\":\"1024\",\"shared_buffers\":\"4759MB\",\"work_mem\":\"475MB\",\"maintenance_work_mem\":\"634M\"}" 263 | return strings.ReplaceAll(str, "\"", "\\\"") 264 | } 265 | 266 | newResource, err := patchApplier.Apply(resource, patch, k8s.PatchTypeYaml) 267 | Expect(err).ToNot(HaveOccurred()) 268 | 269 | specYaml, err := yaml.Marshal(newResource.Object) 270 | Expect(err).ToNot(HaveOccurred()) 271 | foundYaml := strings.TrimSpace(string(specYaml)) 272 | 273 | expectedYaml := strings.TrimSpace(` 274 | apiVersion: acid.zalan.do/v1 275 | kind: postgresql 276 | metadata: 277 | name: test 278 | namespace: example 279 | spec: 280 | postgresql: 281 | parameters: 282 | maintenance_work_mem: 634M 283 | max_connections: "1024" 284 | shared_buffers: 4759MB 285 | work_mem: 475MB 286 | replicas: 1 287 | `) 288 | Expect(foundYaml).To(Equal(expectedYaml)) 289 | }) 290 | }) 291 | -------------------------------------------------------------------------------- /controllers/rest_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 | "encoding/json" 22 | "reflect" 23 | "strings" 24 | "time" 25 | 26 | "github.com/flanksource/commons/utils" 27 | templatev1 "github.com/flanksource/template-operator/api/v1" 28 | "github.com/flanksource/template-operator/k8s" 29 | "github.com/pkg/errors" 30 | "github.com/prometheus/client_golang/prometheus" 31 | kerrors "k8s.io/apimachinery/pkg/api/errors" 32 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 33 | "k8s.io/apimachinery/pkg/types" 34 | "k8s.io/apimachinery/pkg/util/wait" 35 | ctrl "sigs.k8s.io/controller-runtime" 36 | "sigs.k8s.io/controller-runtime/pkg/metrics" 37 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 38 | ) 39 | 40 | const ( 41 | objectModifiedError = "the object has been modified; please apply your changes to the latest version and try again" 42 | ) 43 | 44 | var ( 45 | RESTDeleteFinalizer = "termination.flanksource.com/protect" 46 | ) 47 | 48 | var ( 49 | restCount = prometheus.NewGaugeVec( 50 | prometheus.GaugeOpts{ 51 | Name: "template_operator_rest_count", 52 | Help: "Total rest runs count", 53 | }, 54 | []string{"rest"}, 55 | ) 56 | restSuccess = prometheus.NewGaugeVec( 57 | prometheus.GaugeOpts{ 58 | Name: "template_operator_rest_success", 59 | Help: "Total successful rest runs count", 60 | }, 61 | []string{"rest"}, 62 | ) 63 | restFailed = prometheus.NewGaugeVec( 64 | prometheus.GaugeOpts{ 65 | Name: "template_operator_rest_failed", 66 | Help: "Total failed rest runs count", 67 | }, 68 | []string{"test"}, 69 | ) 70 | ) 71 | 72 | func init() { 73 | metrics.Registry.MustRegister(restCount, restSuccess, restFailed) 74 | } 75 | 76 | // RESTReconciler reconciles a REST object 77 | type RESTReconciler struct { 78 | Client 79 | } 80 | 81 | // +kubebuilder:rbac:groups="*",resources="*",verbs="*" 82 | 83 | func (r *RESTReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 84 | log := r.Log.WithValues("rest", req.NamespacedName, "requestID", utils.RandomString(10)) 85 | name := req.NamespacedName.String() 86 | 87 | log.V(2).Info("Started reconciling") 88 | 89 | rest := &templatev1.REST{} 90 | if err := r.ControllerClient.Get(ctx, req.NamespacedName, rest); err != nil { 91 | if kerrors.IsNotFound(err) { 92 | log.Error(err, "rest not found") 93 | return reconcile.Result{}, nil 94 | } 95 | log.Error(err, "failed to get template") 96 | incRESTFailed(name) 97 | return reconcile.Result{}, err 98 | } 99 | 100 | if rest.Status == nil { 101 | rest.Status = map[string]string{} 102 | } 103 | oldStatus := cloneMap(rest.Status) 104 | 105 | //If the TemplateManager will fetch a new schema, ensure the kommons.client also does so in order to ensure they contain the same information 106 | if r.Cache.SchemaHasExpired() { 107 | r.KommonsClient.ResetRestMapper() 108 | } 109 | tm, err := k8s.NewRESTManager(r.KommonsClient, log) 110 | if err != nil { 111 | incRESTFailed(name) 112 | return reconcile.Result{}, err 113 | } 114 | 115 | hasFinalizer := false 116 | for _, finalizer := range rest.ObjectMeta.Finalizers { 117 | if finalizer == RESTDeleteFinalizer { 118 | hasFinalizer = true 119 | } 120 | } 121 | 122 | if rest.ObjectMeta.DeletionTimestamp != nil { 123 | log.V(2).Info("Object marked as deleted") 124 | if err = tm.Delete(ctx, rest); err != nil { 125 | return reconcile.Result{}, err 126 | } 127 | if err := r.removeFinalizers(rest); err != nil { 128 | return ctrl.Result{}, err 129 | } 130 | return ctrl.Result{}, nil 131 | } 132 | 133 | if !hasFinalizer { 134 | log.V(2).Info("Setting finalizer") 135 | rest.ObjectMeta.Finalizers = append(rest.ObjectMeta.Finalizers, RESTDeleteFinalizer) 136 | if err := r.ControllerClient.Update(ctx, rest); err != nil { 137 | log.Error(err, "failed to add finalizer to object") 138 | return ctrl.Result{}, err 139 | } 140 | log.V(2).Info("Finalizer set, exiting reconcile") 141 | 142 | return ctrl.Result{}, nil 143 | } 144 | 145 | statusUpdates, err := tm.Update(ctx, rest) 146 | if err != nil { 147 | log.Error(err, "Failed to run update REST") 148 | incRESTFailed(name) 149 | return reconcile.Result{}, err 150 | } 151 | 152 | if err := r.updateStatus(ctx, rest, statusUpdates, oldStatus); err != nil { 153 | return reconcile.Result{}, err 154 | } 155 | 156 | incRESTSuccess(name) 157 | log.V(2).Info("Finished reconciling", "generation", rest.ObjectMeta.Generation) 158 | return ctrl.Result{}, nil 159 | } 160 | 161 | func (r *RESTReconciler) updateStatus(ctx context.Context, rest *templatev1.REST, statusUpdates, oldStatus map[string]string) error { 162 | backoff := wait.Backoff{ 163 | Duration: 50 * time.Millisecond, 164 | Factor: 1.5, 165 | Jitter: 2, 166 | Steps: 10, 167 | Cap: 5 * time.Second, 168 | } 169 | var err error 170 | 171 | r.addStatusUpdates(rest, statusUpdates) 172 | 173 | if reflect.DeepEqual(rest.Status, oldStatus) { 174 | r.Log.V(2).Info("REST status did not change, skipping") 175 | return nil 176 | } 177 | 178 | setRestStatus(rest) 179 | 180 | js, _ := json.Marshal(rest.Status) 181 | js2, _ := json.Marshal(oldStatus) 182 | r.Log.V(2).Info("Checking:", "status", string(js), "oldStatus", string(js2)) 183 | 184 | for backoff.Steps > 0 { 185 | js, err := json.Marshal(statusUpdates) 186 | r.Log.V(2).Info("Updating status: setting", "statusUpdates", string(js), "err", err) 187 | if err = r.ControllerClient.Status().Update(ctx, rest); err == nil { 188 | return nil 189 | } 190 | sleepDuration := backoff.Step() 191 | r.Log.Info("update status failed, sleeping", "duration", sleepDuration, "err", err) 192 | time.Sleep(sleepDuration) 193 | if strings.Contains(err.Error(), objectModifiedError) { 194 | if err := r.ControllerClient.Get(context.Background(), types.NamespacedName{Name: rest.Name}, rest); err != nil { 195 | return errors.Wrap(err, "failed to refetch object") 196 | } 197 | r.addStatusUpdates(rest, statusUpdates) 198 | if reflect.DeepEqual(rest.Status, oldStatus) { 199 | return nil 200 | } 201 | setRestStatus(rest) 202 | } 203 | } 204 | 205 | return err 206 | } 207 | 208 | func (r *RESTReconciler) removeFinalizers(rest *templatev1.REST) error { 209 | backoff := wait.Backoff{ 210 | Duration: 50 * time.Millisecond, 211 | Factor: 1.5, 212 | Jitter: 2, 213 | Steps: 10, 214 | Cap: 5 * time.Second, 215 | } 216 | var err error 217 | 218 | rest.ObjectMeta.Finalizers = r.removeFinalizer(rest) 219 | 220 | for backoff.Steps > 0 { 221 | if err = r.ControllerClient.Update(context.Background(), rest); err == nil { 222 | return nil 223 | } 224 | sleepDuration := backoff.Step() 225 | r.Log.Info("remove finalizers failed, sleeping", "duration", sleepDuration, "err", err) 226 | time.Sleep(sleepDuration) 227 | if strings.Contains(err.Error(), objectModifiedError) { 228 | if err := r.ControllerClient.Get(context.Background(), types.NamespacedName{Name: rest.Name}, rest); err != nil { 229 | return errors.Wrap(err, "failed to refetch object") 230 | } 231 | rest.ObjectMeta.Finalizers = r.removeFinalizer(rest) 232 | } 233 | } 234 | 235 | return nil 236 | } 237 | 238 | func (r *RESTReconciler) removeFinalizer(rest *templatev1.REST) []string { 239 | finalizers := []string{} 240 | for _, finalizer := range rest.ObjectMeta.Finalizers { 241 | if finalizer != RESTDeleteFinalizer { 242 | finalizers = append(finalizers, finalizer) 243 | } 244 | } 245 | return finalizers 246 | } 247 | 248 | func (r *RESTReconciler) addStatusUpdates(rest *templatev1.REST, statusUpdates map[string]string) { 249 | if rest.Status == nil { 250 | rest.Status = map[string]string{} 251 | } 252 | for k, v := range statusUpdates { 253 | rest.Status[k] = v 254 | } 255 | } 256 | 257 | func (r *RESTReconciler) SetupWithManager(mgr ctrl.Manager) error { 258 | r.ControllerClient = mgr.GetClient() 259 | r.Events = mgr.GetEventRecorderFor("template-operator") 260 | 261 | return ctrl.NewControllerManagedBy(mgr). 262 | For(&templatev1.REST{}). 263 | Complete(r) 264 | } 265 | 266 | func incRESTSuccess(name string) { 267 | restCount.WithLabelValues(name).Inc() 268 | restSuccess.WithLabelValues(name).Inc() 269 | } 270 | 271 | func incRESTFailed(name string) { 272 | restCount.WithLabelValues(name).Inc() 273 | restFailed.WithLabelValues(name).Inc() 274 | } 275 | 276 | func setRestStatus(rest *templatev1.REST) { 277 | rest.Status["lastUpdated"] = metav1.Now().String() 278 | } 279 | 280 | func cloneMap(m map[string]string) map[string]string { 281 | x := map[string]string{} 282 | for k, v := range m { 283 | x[k] = v 284 | } 285 | return x 286 | } 287 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Template Operator 2 | 3 | 4 | **Simple, reconciliation-based runtime templating** 5 | 6 | 7 | The Template Operator is for platform engineers needing an easy and reliable way to create, copy and update kubernetes resources. 8 | 9 | ## Design principles 10 | 11 | - **100% YAML** – `Templates` are valid YAML and IDE validation and autocomplete of k8s resources works as normal. 12 | - **Simple** – Easy to use and quick to get started. 13 | - **Reconciliation based** – Changes are applied quickly and resiliently (unlike webhooks) at runtime. 14 | 15 | ## Further reading 16 | 17 | This README replicates much of the content from [Simple, reconciliation-based runtime templating](/docs/template-operator-intro-part-1.md). 18 | 19 | For further examples, see part 2 in the series: [Powering up with Custom Resource Definitions (CRDs)](/docs/template-operator-intro-part-2.md). 20 | 21 | ### Alternatives 22 | 23 | There are alternative templating systems in use by the k8s community – each has valid use cases and noting the downsides for runtime templating is not intended as an indictment – all are excellent choices under the right conditions. 24 | 25 | 26 | | Alternative | Downside for templating | 27 | | ------------------------ | :------------------------------------------------------- | 28 | | [crossplane][crossplane] | Complex due to design for infrastructure composition | 29 | | [kyverno][kyverno] | Webhook based
Designed as a policy engine | 30 | | [helm][helm] | Not 100% YAML
Not reconciliation based (build time) | 31 | 32 | 33 | ## Installation 34 | 35 | API documentation available [here](https://pkg.go.dev/github.com/flanksource/template-operator/api/v1). 36 | 37 | ### Prerequisites 38 | 39 | This guide assumes you have either a [kind cluster](https://kind.sigs.k8s.io/docs/user/quick-start/) or [minikube cluster](https://minikube.sigs.k8s.io/docs/start/) running, or have some other way of interacting with a cluster via [kubectl](https://kubernetes.io/docs/tasks/tools/). 40 | 41 | ### Install 42 | 43 | ```bash 44 | export VERSION=0.4.0 45 | # For the latest release version: https://github.com/flanksource/template-operator/releases 46 | 47 | # Apply the operator 48 | kubectl apply -f https://github.com/flanksource/template-operator/releases/download/v${VERSION}/operator.yml 49 | ``` 50 | 51 | Run `kubectl get pods -A` and you should see something similar to the following in your terminal output: 52 | 53 | ```bash 54 | NAMESPACE NAME READY 55 | template-operator template-operator-controller-manager-6bd8c5ff58-sz8q6 2/2 56 | ``` 57 | 58 | ### Following the logs 59 | 60 | To follow the manager logs, open a new terminal and, changing what needs to be changed, run : 61 | 62 | ```bash 63 | kubectl logs -f --since 10m -n template-operator deploy/template-operator-controller-manager 64 | -c manager 65 | ``` 66 | 67 | These logs are where reconciliation successes and errors show up – and the best place to look when debugging. 68 | 69 | ## Use case: Creating resources per namespace 70 | 71 | > *As a platform engineer, I need to quickly provision Namespaces for application teams so that they are able to spin up environments quickly.* 72 | 73 | As organisations grow, platform teams are often tasked with creating `Namespaces` for continuous integration or for development. 74 | 75 | To configure a `Namespace`, platform teams may need to commit or apply many boilerplate objects. 76 | 77 | For this example, suppose you need a set of `Roles` and `RoleBindings` to automatically deploy for a `Namespace` . 78 | 79 | ### Step 1: Adding a namespace and a template 80 | 81 | Add a `Namespace`. You might add this after applying the `Template`, but it's helpful to see that the Template Operator doesn't care when objects are applied – a feature of the reconciliation-based approach. Note the label – this tags the `Namespace` as one that should produce `RoleBindings`. 82 | 83 | ```yaml 84 | cat < *As a platform engineer, I need to automatically copy appropriate Secrets to newly created Namespaces so that application teams have access to the Secrets they need by default.* 201 | 202 | Suppose you have a `Namespace` containing `Secrets` you want to copy to every development `Namespace`. 203 | 204 | ### Step 1: Add secrets and namespace 205 | 206 | Apply the following manifests to set up the `Namespace` with the `Secrets`. 207 | 208 | ```yaml 209 | cat < github.com/Azure/go-autorest v14.2.0+incompatible 207 | // github.com/go-openapi/spec => github.com/go-openapi/spec v0.19.3 208 | // google.golang.org/grpc => google.golang.org/grpc v1.29.1 209 | // k8s.io/cli-runtime => k8s.io/cli-runtime v0.20.15 210 | k8s.io/client-go => k8s.io/client-go v0.27.2 211 | ) 212 | -------------------------------------------------------------------------------- /docs/template-operator-intro-part-1.md: -------------------------------------------------------------------------------- 1 | --- 2 | Author: Saul Nachman & Moshe Immerman 3 | Last updated: 22/07/2021 4 | --- 5 | 6 | 7 | *This is part 1 of a series demonstrating the Template Operator's capabilities, starting in this post with [Creating resources per namespace](#Use-case-creating-resources-per-namespace) and [Copying secrets between namespaces](#Use-case-Copying-secrets-between-namespaces).* 8 | 9 | 10 | # Template Operator 11 | 12 | 13 | **Simple, reconciliation-based runtime templating** 14 | 15 | 16 | The Template Operator is for platform engineers needing an easy and reliable way to create, copy and update kubernetes resources. 17 | 18 | ## Design principles 19 | 20 | - **100% YAML** – `Templates` are valid YAML and IDE validation and autocomplete of k8s resources works as normal. 21 | - **Simple** – Easy to use and quick to get started. 22 | - **Reconciliation based** – Changes are applied quickly and resiliently (unlike webhooks) at runtime. 23 | 24 | ### Alternatives 25 | 26 | There are alternative templating systems in use by the k8s community – each has valid use cases and noting the downsides for runtime templating is not intended as an indictment – all are excellent choices under the right conditions. 27 | 28 | 29 | | Alternative | Downside for templating | 30 | | ------------------------ | :------------------------------------------------------- | 31 | | [crossplane][crossplane] | Complex due to design for infrastructure composition | 32 | | [kyverno][kyverno] | Webhook based
Designed as a policy engine | 33 | | [helm][helm] | Not 100% YAML
Not reconciliation based (build time) | 34 | 35 | 36 | [crossplane]: https://crossplane.io/ "Crossplane" 37 | [kyverno]: https://kyverno.io/ "Kyverno" 38 | [helm]: https://helm.sh/ "Helm" 39 | 40 | ## Installation 41 | 42 | API documentation available [here](https://pkg.go.dev/github.com/flanksource/template-operator/api/v1). 43 | 44 | ### Prerequisites 45 | 46 | This guide assumes you have either a [kind cluster](https://kind.sigs.k8s.io/docs/user/quick-start/) or [minikube cluster](https://minikube.sigs.k8s.io/docs/start/) running, or have some other way of interacting with a cluster via [kubectl](https://kubernetes.io/docs/tasks/tools/). 47 | 48 | ### Install 49 | 50 | ```bash 51 | export VERSION=0.4.0 52 | # For the latest release version: https://github.com/flanksource/template-operator/releases 53 | 54 | # Apply the operator 55 | kubectl apply -f https://github.com/flanksource/template-operator/releases/download/v${VERSION}/operator.yml 56 | ``` 57 | 58 | Run `kubectl get pods -A` and you should see something similar to the following in your terminal output: 59 | 60 | ```bash 61 | NAMESPACE NAME READY 62 | template-operator template-operator-controller-manager-6bd8c5ff58-sz8q6 2/2 63 | ``` 64 | 65 | ### Following the logs 66 | 67 | To follow the manager logs, open a new terminal and, changing what needs to be changed, run : 68 | 69 | ```bash 70 | kubectl logs -f --since 10m -n template-operator \ 71 | template-operator-controller-manager--6bd8c5ff58-sz8q6 manager 72 | ``` 73 | 74 | These logs are where reconciliation successes and errors show up – and the best place to look when debugging. 75 | 76 | ## Use case: Creating resources per namespace 77 | 78 | > *As a platform engineer, I need to quickly provision Namespaces for application teams so that they are able to spin up environments quickly.* 79 | 80 | As organisations grow, platform teams are often tasked with creating `Namespaces` for continuous integration or for development. 81 | 82 | To configure a `Namespace`, platform teams may need to commit or apply many boilerplate objects. 83 | 84 | For this example, suppose you need a set of `Roles` and `RoleBindings` to automatically deploy for a `Namespace` . 85 | 86 | ### Step 1: Adding a namespace and a template 87 | 88 | Add a `Namespace`. You might add this after applying the `Template`, but it's helpful to see that the Template Operator doesn't care when objects are applied – a feature of the reconciliation-based approach. Note the label – this tags the `Namespace` as one that should produce `RoleBindings`. 89 | 90 | ```yaml 91 | cat <