├── config ├── operator │ ├── prometheus │ │ ├── kustomization.yaml │ │ └── monitor.yaml │ ├── certmanager │ │ ├── kustomization.yaml │ │ ├── kustomizeconfig.yaml │ │ └── certificate.yaml │ ├── rbac │ │ ├── service_account.yaml │ │ ├── kustomization.yaml │ │ ├── leader_election_role_binding.yaml │ │ ├── role_binding.yaml │ │ ├── leader_election_role.yaml │ │ └── role.yaml │ ├── webhook │ │ ├── kustomization.yaml │ │ ├── service.yaml │ │ └── kustomizeconfig.yaml │ ├── manager │ │ ├── kustomization.yaml │ │ └── manager.yaml │ ├── base │ │ └── kustomization.yaml │ └── default │ │ └── kustomization.yaml ├── crd │ ├── kustomization.yaml │ ├── patches │ │ ├── cainjection_in_gitopsapis.yaml │ │ └── webhook_in_gitopsapis.yaml │ ├── kustomizeconfig.yaml │ └── bases │ │ └── git.flanksource.com_gitopsapis.yaml ├── samples │ ├── namespace.yaml │ ├── git_v1_gitdeployment.yaml │ ├── req.json │ └── git_v1_gitopsapi.yaml ├── rbac │ ├── gitopsapi_viewer_role.yaml │ ├── gitdeployment_viewer_role.yaml │ ├── gitopsapi_editor_role.yaml │ └── gitdeployment_editor_role.yaml └── deploy │ ├── base.yml │ ├── crd.yml │ └── operator.yml ├── PROJECT ├── .github ├── pull_request_template.md ├── semantic.yml └── workflows │ ├── docker.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── hack └── boilerplate.go.txt ├── README.md ├── test ├── config.yaml ├── e2e.sh └── e2e.go ├── .golangci.yml ├── Dockerfile ├── examples └── gitopsapi-example.yaml ├── api └── v1 │ ├── groupversion_info.go │ ├── gitopsapi_types.go │ └── zz_generated.deepcopy.go ├── controllers ├── utils.go ├── suite_test.go └── gitopsapi_controller.go ├── connectors ├── gitssh.go ├── connector.go ├── util.go └── github.go ├── Makefile ├── main.go └── go.mod /config/operator/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - bases/git.flanksource.com_gitopsapis.yaml 3 | -------------------------------------------------------------------------------- /config/samples/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: gitops-e2e-test -------------------------------------------------------------------------------- /config/operator/certmanager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - certificate.yaml 3 | 4 | configurations: 5 | - kustomizeconfig.yaml 6 | -------------------------------------------------------------------------------- /config/operator/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: operator 5 | namespace: system 6 | -------------------------------------------------------------------------------- /config/operator/webhook/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manifests.yaml 3 | - service.yaml 4 | 5 | configurations: 6 | - kustomizeconfig.yaml 7 | -------------------------------------------------------------------------------- /config/operator/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 -------------------------------------------------------------------------------- /config/operator/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/git-operator 8 | newTag: v1 9 | -------------------------------------------------------------------------------- /config/operator/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: git-operator 13 | -------------------------------------------------------------------------------- /config/samples/git_v1_gitdeployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: git.flanksource.com/v1 2 | kind: GitDeployment 3 | metadata: 4 | name: gitdeployment-sample 5 | labels: 6 | git.flanksource.com/repository: gitrepository-sample 7 | spec: 8 | # Add fields here 9 | ref: git-deployment-branch-1 10 | -------------------------------------------------------------------------------- /config/operator/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: operator-leader-election 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: operator-leader-election 9 | subjects: 10 | - kind: ServiceAccount 11 | name: operator 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/samples/req.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "acmp.corp/v1", 3 | "kind": "NamespaceRequest", 4 | "metadata": { 5 | "name": "tenant4", 6 | "annotations": { 7 | "a": "b" 8 | }, 9 | "labels": { 10 | "c": "d" 11 | } 12 | }, 13 | "spec": { 14 | "memory": 8, 15 | "cluster": "dev02" 16 | }, 17 | "cluster": "dev02" 18 | } 19 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_gitopsapis.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: gitopsapis.git.flanksource.com 9 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: flanksource.com 2 | repo: github.com/flanksource/git-operator 3 | resources: 4 | - group: git 5 | kind: GitRepository 6 | version: v1 7 | - group: git 8 | kind: GitPullRequest 9 | version: v1 10 | - group: git 11 | kind: GitBranch 12 | version: v1 13 | - group: git 14 | kind: GitopsAPI 15 | version: v1 16 | - group: git 17 | kind: GitDeployment 18 | version: v1 19 | version: "2" 20 | -------------------------------------------------------------------------------- /config/operator/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: operator 8 | name: operator-metrics-monitor 9 | namespace: system 10 | spec: 11 | endpoints: 12 | - path: /metrics 13 | port: https 14 | selector: 15 | matchLabels: 16 | control-plane: operator 17 | -------------------------------------------------------------------------------- /config/operator/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: platform-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: git- 10 | 11 | bases: 12 | - ../rbac 13 | - ../manager 14 | -------------------------------------------------------------------------------- /config/rbac/gitopsapi_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view gitopsapis. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: gitopsapi-viewer-role 6 | rules: 7 | - apiGroups: 8 | - git.flanksource.com 9 | resources: 10 | - gitopsapis 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - git.flanksource.com 17 | resources: 18 | - gitopsapis/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /config/rbac/gitdeployment_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view gitdeployments. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: gitdeployment-viewer-role 6 | rules: 7 | - apiGroups: 8 | - git.flanksource.com 9 | resources: 10 | - gitdeployments 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - git.flanksource.com 17 | resources: 18 | - gitdeployments/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | _Short summary of what is the issue and the solution._ 4 | 5 | ### Dependencies 6 | 7 | - _Ex. Other PRs._ 8 | 9 | ### Breaking Change 10 | 11 | - [ ] Yes 12 | - [ ] No 13 | 14 | ### Testing Notes 15 | 16 | **What I did:** 17 | _What did you do to validate this PR?_ 18 | 19 | **How you can replicate my testing:** 20 | _How can the reviewer validate this PR?_ 21 | 22 | ### **Links** 23 | 24 | _Issues links or other related resources_ -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate all commits, and ignore the PR title 2 | commitsOnly: true 3 | 4 | # Allow use of Merge commits (eg on github: "Merge branch 'master' into feature/ride-unicorns") 5 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 6 | allowMergeCommits: true 7 | 8 | # Allow use of Revert commits (eg on github: "Revert "feat: ride unicorns"") 9 | # this is only relevant when using commitsOnly: true (or titleAndCommits: true) 10 | allowRevertCommits: true -------------------------------------------------------------------------------- /config/operator/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/gitopsapi_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit gitopsapis. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: gitopsapi-editor-role 6 | rules: 7 | - apiGroups: 8 | - git.flanksource.com 9 | resources: 10 | - gitopsapis 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - git.flanksource.com 21 | resources: 22 | - gitopsapis/status 23 | verbs: 24 | - get 25 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@master 11 | - name: Publish to Registry 12 | uses: elgohr/Publish-Docker-Github-Action@master 13 | with: 14 | name: flanksource/git-operator 15 | username: ${{ secrets.DOCKER_USERNAME }} 16 | password: ${{ secrets.DOCKER_PASSWORD }} 17 | tag_names: true 18 | snapshot: true 19 | -------------------------------------------------------------------------------- /config/rbac/gitdeployment_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit gitdeployments. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: gitdeployment-editor-role 6 | rules: 7 | - apiGroups: 8 | - git.flanksource.com 9 | resources: 10 | - gitdeployments 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - git.flanksource.com 21 | resources: 22 | - gitdeployments/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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Kubernetes Generated files - skip generated files, except for vendored files 17 | 18 | !vendor/**/zz_generated.* 19 | 20 | # editor and IDE paraphernalia 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | .DS_Store 26 | .env 27 | 28 | # karina artifacts 29 | /.bin 30 | /.certs 31 | /.kube 32 | /karina_osx 33 | /karina 34 | /.vscode 35 | -------------------------------------------------------------------------------- /config/operator/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: operator 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: operator 9 | subjects: 10 | - kind: ServiceAccount 11 | name: operator 12 | namespace: system 13 | --- 14 | apiVersion: rbac.authorization.k8s.io/v1 15 | kind: RoleBinding 16 | metadata: 17 | name: operator 18 | roleRef: 19 | apiGroup: rbac.authorization.k8s.io 20 | kind: Role 21 | name: operator 22 | subjects: 23 | - kind: ServiceAccount 24 | name: operator 25 | namespace: system 26 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Kubernetes authors. 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/operator/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: operator-leader-election 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 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_gitopsapis.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: gitopsapis.git.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 | -------------------------------------------------------------------------------- /config/operator/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: rbac.authorization.k8s.io/v1 4 | kind: ClusterRole 5 | metadata: 6 | creationTimestamp: null 7 | name: operator 8 | rules: 9 | - apiGroups: 10 | - git.flanksource.com 11 | resources: 12 | - gitopsapis 13 | verbs: 14 | - create 15 | - delete 16 | - get 17 | - list 18 | - patch 19 | - update 20 | - watch 21 | - apiGroups: 22 | - git.flanksource.com 23 | resources: 24 | - gitopsapis/status 25 | verbs: 26 | - get 27 | - patch 28 | - update 29 | 30 | --- 31 | apiVersion: rbac.authorization.k8s.io/v1 32 | kind: Role 33 | metadata: 34 | creationTimestamp: null 35 | name: operator 36 | namespace: system 37 | rules: 38 | - apiGroups: 39 | - "" 40 | resources: 41 | - secrets 42 | verbs: 43 | - get 44 | - list 45 | - watch 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-operator 2 | 3 | Git Operator is a Kubernetes operator designed to mirror the state of a Git repository as CRD's including: 4 | 5 | * Branches 6 | * Tags 7 | * Pull Requests 8 | * Reviewers 9 | * Comments 10 | * Checks 11 | 12 | 13 | The operator has the following charecteristics: 14 | 15 | 16 | * Eventual consistency - will poll the repositories periodically to update its state 17 | * Bi-Directional - 18 | * Creating a tag CRD object should create the tag in git 19 | * Deleting a PullRequest should close it 20 | * Adding comments to a PullRequest via the CRD should reflect in the UI 21 | 22 | This operator is not meant to be used in isolation but rather as part of a larger workflow where for example a new Pull Request triggers the creation of a Tekton Pipeline run 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/config.yaml: -------------------------------------------------------------------------------- 1 | name: kind-kind 2 | domain: 127.0.0.1.nip.io 3 | dex: 4 | disabled: true 5 | ldap: 6 | disabled: true 7 | kubernetes: 8 | version: !!env KUBERNETES_VERSION 9 | kubeletExtraArgs: 10 | node-labels: "ingress-ready=true" 11 | authorization-mode: "AlwaysAllow" 12 | containerRuntime: containerd 13 | versions: 14 | sonobuoy: 0.16.4 15 | ketall: v1.3.0 16 | apacheds: 0.7.0 17 | podSubnet: 100.200.0.0/16 18 | serviceSubnet: 100.100.0.0/16 19 | calico: 20 | version: v3.8.2 21 | ca: 22 | cert: ../.certs/root-ca.crt 23 | privateKey: ../.certs/root-ca.key 24 | password: foobar 25 | ingressCA: 26 | cert: ../.certs/ingress-ca.crt 27 | privateKey: ../.certs/ingress-ca.key 28 | password: foobar 29 | test: 30 | exclude: 31 | - configmap-reloader 32 | - dex 33 | - audit 34 | - encryption -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | paths: 8 | - "**.go" 9 | jobs: 10 | golangci: 11 | name: lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-go@v2 16 | - name: golangci-lint 17 | uses: golangci/golangci-lint-action@v2 18 | with: 19 | version: v1.36 20 | - name: Check auto-generated files 21 | run: | 22 | which controller-gen && rm $(which controller-gen) 23 | make resources 24 | git diff 25 | changed_files=$(git status -s) 26 | [[ -z "$changed_files" ]] || (printf "Change is detected in some files: \n$changed_files\n Did you run 'make resources' before sending the PR?" && exit 1) 27 | -------------------------------------------------------------------------------- /config/operator/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 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | # timeout for analysis, e.g. 30s, 5m, default is 1m 3 | timeout: 20m 4 | 5 | linters: 6 | # please, do not use `enable-all`: it's deprecated and will be removed soon. 7 | # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint 8 | disable-all: true 9 | enable: 10 | - bodyclose 11 | - deadcode 12 | - depguard 13 | - dogsled 14 | - errcheck 15 | - goconst 16 | - gofmt 17 | - goimports 18 | - golint 19 | - goprintffuncname 20 | - gosimple 21 | - govet 22 | - ineffassign 23 | - interfacer 24 | - misspell 25 | - nakedret 26 | - rowserrcheck 27 | - staticcheck 28 | - structcheck 29 | - stylecheck 30 | - typecheck 31 | - unconvert 32 | - unparam 33 | - unused 34 | - varcheck 35 | - whitespace 36 | 37 | linters-settings: 38 | gofmt: 39 | simplify: false 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.17 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 connectors/ connectors/ 16 | COPY controllers/ controllers/ 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 ./git-operator 26 | USER nonroot:nonroot 27 | 28 | ENTRYPOINT ["/git-operator"] 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.17.x] 8 | platform: [ubuntu-latest] 9 | k8s: 10 | - v1.20.7 11 | runs-on: ${{ matrix.platform }} 12 | steps: 13 | - name: Install Go 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Test 20 | env: 21 | KUBERNETES_VERSION: ${{matrix.k8s}} 22 | GITHUB_TOKEN: ${{secrets.E2E_TOKEN}} 23 | GITHUB_USERNAME: flankbot 24 | run: ./test/e2e.sh 25 | - name: export logs 26 | if: always() 27 | run: kind --name kind-kind export logs ./logs 28 | - name: Upload logs 29 | if: always() 30 | uses: actions/upload-artifact@v2 31 | with: 32 | name: log 33 | path: ./logs 34 | -------------------------------------------------------------------------------- /config/samples/git_v1_gitopsapi.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: git.flanksource.com/v1 2 | kind: GitopsAPI 3 | metadata: 4 | name: gitopsapi-sample 5 | spec: 6 | gitRepository: https://github.com/moshloop/test-gitops-api.git 7 | path: clusters/{{.spec.cluster}}/{{.metadata.name}}.yaml 8 | kustomization: clusters/{{.spec.cluster}}/kustomization.yaml 9 | secretRef: 10 | name: github 11 | --- 12 | apiVersion: apiextensions.k8s.io/v1beta1 13 | kind: CustomResourceDefinition 14 | metadata: 15 | name: namespacerequests.acmp.corp 16 | spec: 17 | group: acmp.corp 18 | names: 19 | kind: NamespaceRequest 20 | listKind: NamespaceRequestList 21 | plural: namespacerequests 22 | singular: namespacerequest 23 | scope: Namespaced 24 | version: v1 25 | versions: 26 | - name: v1 27 | served: true 28 | storage: true 29 | --- 30 | apiVersion: acmp.corp/v1 31 | kind: NamespaceRequest 32 | metadata: 33 | name: tenant 34 | annotations: 35 | a: b 36 | labels: 37 | c: d 38 | spec: 39 | memory: 8 40 | -------------------------------------------------------------------------------- /config/operator/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/operator/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: operator 5 | namespace: system 6 | labels: 7 | control-plane: git-operator 8 | spec: 9 | selector: 10 | matchLabels: 11 | control-plane: git-operator 12 | replicas: 1 13 | template: 14 | metadata: 15 | labels: 16 | control-plane: git-operator 17 | spec: 18 | serviceAccountName: git-operator 19 | containers: 20 | - name: git-operator 21 | image: controller:latest 22 | imagePullPolicy: IfNotPresent 23 | command: 24 | - /git-operator 25 | args: 26 | - "--metrics-addr=127.0.0.1:8080" 27 | - --enable-leader-election 28 | - --log-level=debug 29 | resources: 30 | limits: 31 | cpu: 100m 32 | memory: 150Mi 33 | requests: 34 | cpu: 100m 35 | memory: 128Mi 36 | - name: kube-rbac-proxy 37 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 38 | args: 39 | - "--secure-listen-address=0.0.0.0:8443" 40 | - "--upstream=http://127.0.0.1:8080/" 41 | - "--logtostderr=true" 42 | - "--v=10" 43 | ports: 44 | - containerPort: 8443 45 | name: https 46 | terminationGracePeriodSeconds: 10 47 | -------------------------------------------------------------------------------- /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 | 34 | $KARINA provision kind-cluster 35 | $KARINA deploy phases --bootstrap --stubs 36 | 37 | export IMG=flanksource/git-operator:v1 38 | make docker-build 39 | kind load docker-image $IMG --name kind-kind 40 | 41 | make deploy 42 | 43 | kubectl create secret generic git-operator-test -n platform-system --from-literal=PROVIDER=github --from-literal=GITHUB_TOKEN=$GITHUB_TOKEN 44 | kubectl create secret generic github -n platform-system --from-literal=GITHUB_TOKEN=$GITHUB_TOKEN 45 | 46 | go run test/e2e.go 47 | -------------------------------------------------------------------------------- /examples/gitopsapi-example.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: git-operator-ing 5 | namespace: platform-system 6 | annotations: 7 | kubernetes.io/tls-acme: "true" 8 | spec: 9 | tls: 10 | - secretName: git-operator-tls 11 | hosts: 12 | - git-operator.127.0.0.1.nip.io 13 | rules: 14 | - host: git-operator.127.0.0.1.nip.io 15 | http: 16 | paths: 17 | - backend: 18 | serviceName: git-operator 19 | servicePort: 8888 20 | --- 21 | apiVersion: v1 22 | kind: Service 23 | metadata: 24 | namespace: platform-system 25 | labels: 26 | control-plane: git-operator 27 | name: git-operator 28 | spec: 29 | selector: 30 | control-plane: git-operator 31 | ports: 32 | - port: 8888 33 | targetPort: 8888 34 | --- 35 | apiVersion: git.flanksource.com/v1 36 | kind: GitopsAPI 37 | metadata: 38 | name: configmap-add 39 | namespace: platform-system 40 | spec: 41 | gitRepository: https://github.com/flanksource/git-operator-test.git 42 | path: spec/configmaps/{{.metadata.name}}.yaml 43 | kustomization: spec/configmaps/kustomization.yaml 44 | pullRequest: true 45 | reviewers: ["flankbot"] 46 | secretRef: 47 | name: github-token 48 | tokenRef: 49 | name: configmap-add-token 50 | --- 51 | apiVersion: v1 52 | kind: Secret 53 | metadata: 54 | name: configmap-add-token 55 | namespace: platform-system 56 | stringData: 57 | TOKEN: "abcdefgh12345" 58 | --- 59 | # kubectl create secret generic github-token -n platform-system --from-literal=GITHUB_TOKEN=$GITHUB_TOKEN 60 | 61 | # Example api call: 62 | 63 | # curl -XPOST -k -v --data "{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"config1\",\"namespace\":\"platform-system\"},\"data\":{\"foo\":\"bar\"}}" -H "Content-Type: application/json" "https://git-operator.127.0.0.1.nip.io/platform-system/configmap-add?token=abcdefgh12345" -------------------------------------------------------------------------------- /api/v1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Kubernetes authors. 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 git.flanksource.com v1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=git.flanksource.com 20 | package v1 21 | 22 | import ( 23 | "github.com/weaveworks/libgitops/pkg/serializer" 24 | runtime "k8s.io/apimachinery/pkg/runtime" 25 | "k8s.io/apimachinery/pkg/runtime/schema" 26 | k8sserializer "k8s.io/apimachinery/pkg/runtime/serializer" 27 | "sigs.k8s.io/controller-runtime/pkg/scheme" 28 | ) 29 | 30 | var ( 31 | // GroupVersion is group version used to register these objects 32 | GroupVersion = schema.GroupVersion{Group: "git.flanksource.com", Version: "v1"} 33 | 34 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 35 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 36 | 37 | // AddToScheme adds the types in this group-version to the given scheme. 38 | AddToScheme = SchemeBuilder.AddToScheme 39 | 40 | // Scheme is the runtime.Scheme to which all types are registered. 41 | Scheme = runtime.NewScheme() 42 | 43 | // codecs provides access to encoding and decoding for the scheme. 44 | // codecs is private, as Serializer will be used for all higher-level encoding/decoding 45 | codecs = k8sserializer.NewCodecFactory(Scheme) 46 | 47 | // Serializer provides high-level encoding/decoding functions 48 | Serializer = serializer.NewSerializer(Scheme, &codecs) 49 | ) 50 | -------------------------------------------------------------------------------- /controllers/utils.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "unicode" 10 | 11 | "github.com/go-git/go-billy/v5" 12 | gitv5 "github.com/go-git/go-git/v5" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | func copy(data []byte, path string, fs billy.Filesystem, work *gitv5.Worktree) error { 17 | dst, err := openOrCreate(path, fs) 18 | if err != nil { 19 | return errors.Wrap(err, "failed to open") 20 | } 21 | src := bytes.NewBuffer(data) 22 | if _, err := io.Copy(dst, src); err != nil { 23 | return errors.Wrap(err, "failed to copy") 24 | } 25 | if err := dst.Close(); err != nil { 26 | return errors.Wrap(err, "failed to close") 27 | } 28 | _, err = work.Add(path) 29 | return errors.Wrap(err, "failed to add to git") 30 | } 31 | 32 | func deleteFile(path string, work *gitv5.Worktree, repoRoot string) error { 33 | fullPath := filepath.Join(repoRoot, path) 34 | err := os.Remove(fullPath) 35 | if err != nil { 36 | return errors.Wrap(err, "failed to delete file") 37 | } 38 | _, err = work.Add(path) 39 | if err != nil { 40 | return errors.Wrap(err, "failed to add to git") 41 | } 42 | return nil 43 | } 44 | 45 | func openOrCreate(path string, fs billy.Filesystem) (billy.File, error) { 46 | return fs.Create(path) 47 | } 48 | 49 | func findElement(list []string, element string) int { 50 | for i := range list { 51 | if list[i] == element { 52 | return i 53 | } 54 | } 55 | return -1 56 | } 57 | 58 | func removeElement(list []string, indext int) []string { 59 | return append(list[:indext], list[indext+1:]...) 60 | } 61 | 62 | func TabToSpace(input string) string { 63 | var result []string 64 | 65 | for _, i := range input { 66 | switch { 67 | // all these considered as space, including tab \t 68 | // '\t', '\n', '\v', '\f', '\r',' ', 0x85, 0xA0 69 | case unicode.IsSpace(i): 70 | result = append(result, " ") // replace tab with space 71 | case !unicode.IsSpace(i): 72 | result = append(result, string(i)) 73 | } 74 | } 75 | return strings.Join(result, "") 76 | } 77 | -------------------------------------------------------------------------------- /connectors/gitssh.go: -------------------------------------------------------------------------------- 1 | package connectors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | gitv1 "github.com/flanksource/git-operator/api/v1" 9 | "github.com/go-git/go-billy/v5" 10 | "github.com/go-git/go-billy/v5/memfs" 11 | git "github.com/go-git/go-git/v5" 12 | "github.com/go-git/go-git/v5/plumbing/transport" 13 | "github.com/go-git/go-git/v5/plumbing/transport/ssh" 14 | "github.com/go-git/go-git/v5/storage/memory" 15 | "github.com/go-logr/logr" 16 | "github.com/jenkins-x/go-scm/scm" 17 | "github.com/pkg/errors" 18 | ssh2 "golang.org/x/crypto/ssh" 19 | "sigs.k8s.io/controller-runtime/pkg/client" 20 | ) 21 | 22 | type GitSSH struct { 23 | *scm.Client 24 | k8sCrd client.Client 25 | log logr.Logger 26 | url string 27 | auth transport.AuthMethod 28 | } 29 | 30 | func (g *GitSSH) OpenPullRequest(ctx context.Context, base string, head string, spec *gitv1.PullRequestTemplate) (int, error) { 31 | return 0, fmt.Errorf("open pull request not implemented for git ssh") 32 | } 33 | 34 | func (g *GitSSH) ClosePullRequest(ctx context.Context, id int) error { 35 | return fmt.Errorf("close pull request not implemented for git ssh") 36 | } 37 | 38 | func (g *GitSSH) Push(ctx context.Context, branch string) error { 39 | return fmt.Errorf("push not implemented for git ssh") 40 | } 41 | 42 | func (g *GitSSH) Clone(ctx context.Context, branch, local string) (billy.Filesystem, *git.Worktree, error) { 43 | // Filesystem abstraction based on memory 44 | fs := memfs.New() 45 | 46 | repo, err := git.Clone(memory.NewStorage(), fs, &git.CloneOptions{ 47 | URL: g.url, 48 | Progress: os.Stdout, 49 | Auth: g.auth, 50 | }) 51 | if err != nil { 52 | return nil, nil, err 53 | } 54 | 55 | work, err := repo.Worktree() 56 | if err != nil { 57 | return nil, nil, err 58 | } 59 | 60 | return fs, work, nil 61 | } 62 | 63 | func NewGitSSH(client client.Client, log logr.Logger, url, user string, privateKey []byte, password string) (Connector, error) { 64 | publicKeys, err := ssh.NewPublicKeys(user, privateKey, password) 65 | if err != nil { 66 | return nil, errors.Wrap(err, "failed to create public keys") 67 | } 68 | publicKeys.HostKeyCallback = ssh2.InsecureIgnoreHostKey() 69 | 70 | github := &GitSSH{ 71 | k8sCrd: client, 72 | log: log.WithName("connector").WithName("GitSSH"), 73 | url: url, 74 | auth: publicKeys, 75 | } 76 | return github, nil 77 | } 78 | -------------------------------------------------------------------------------- /connectors/connector.go: -------------------------------------------------------------------------------- 1 | package connectors 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | gitv1 "github.com/flanksource/git-operator/api/v1" 8 | "github.com/go-git/go-billy/v5" 9 | git "github.com/go-git/go-git/v5" 10 | "github.com/go-logr/logr" 11 | "github.com/pkg/errors" 12 | v1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/client-go/kubernetes" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | ) 17 | 18 | type Connector interface { 19 | Clone(ctx context.Context, branch, local string) (billy.Filesystem, *git.Worktree, error) 20 | Push(ctx context.Context, branch string) error 21 | OpenPullRequest(ctx context.Context, base string, head string, spec *gitv1.PullRequestTemplate) (int, error) 22 | ClosePullRequest(ctx context.Context, id int) error 23 | } 24 | 25 | func NewConnector(ctx context.Context, crdClient client.Client, k8sClient *kubernetes.Clientset, log logr.Logger, namespace string, url string, secretRef *v1.LocalObjectReference) (Connector, error) { 26 | if k8sClient == nil { 27 | return nil, errors.New("nil k8s client") 28 | } 29 | secret, err := k8sClient.CoreV1().Secrets(namespace).Get(ctx, secretRef.Name, metav1.GetOptions{}) 30 | if err != nil { 31 | return nil, errors.Wrapf(err, "failed to get secret %s in namespace %s", secretRef.Name, namespace) 32 | } 33 | 34 | if strings.HasPrefix(url, "https://github.com/") { 35 | path := url[19:] 36 | parts := strings.Split(path, "/") 37 | if len(parts) != 2 { 38 | return nil, errors.Errorf("invalid repository url: %s", url) 39 | } 40 | owner := parts[0] 41 | repoName := parts[1] 42 | repoName = strings.TrimSuffix(repoName, ".git") 43 | githubToken, found := secret.Data["GITHUB_TOKEN"] 44 | if !found { 45 | return nil, ErrGithubTokenNotFoundInSecret 46 | } 47 | return NewGithub(crdClient, log, owner, repoName, string(githubToken)) 48 | } else if strings.HasPrefix(url, "ssh://") { 49 | sshURL := url[6:] 50 | user := strings.Split(sshURL, "@")[0] 51 | 52 | privateKey, found := secret.Data["SSH_PRIVATE_KEY"] 53 | if !found { 54 | return nil, ErrSSHPrivateKeyNotFoundInSecret 55 | } 56 | password, found := secret.Data["SSH_PRIVATE_KEY_PASSWORD"] 57 | if !found { 58 | password = []byte{} 59 | } 60 | return NewGitSSH(crdClient, log, sshURL, user, privateKey, string(password)) 61 | } 62 | return nil, errors.New("no connector settings found") 63 | } 64 | -------------------------------------------------------------------------------- /config/operator/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: platform-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: git- 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 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 28 | # crd/kustomization.yaml 29 | #- manager_webhook_patch.yaml 30 | 31 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 32 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 33 | # 'CERTMANAGER' needs to be enabled to use ca injection 34 | #- webhookcainjection_patch.yaml 35 | 36 | # the following config is for teaching kustomize how to do var substitution 37 | vars: 38 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 39 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 40 | # objref: 41 | # kind: Certificate 42 | # group: cert-manager.io 43 | # version: v1alpha2 44 | # name: serving-cert # this name should match the one in certificate.yaml 45 | # fieldref: 46 | # fieldpath: metadata.namespace 47 | #- name: CERTIFICATE_NAME 48 | # objref: 49 | # kind: Certificate 50 | # group: cert-manager.io 51 | # version: v1alpha2 52 | # name: serving-cert # this name should match the one in certificate.yaml 53 | #- name: SERVICE_NAMESPACE # namespace of the service 54 | # objref: 55 | # kind: Service 56 | # version: v1 57 | # name: webhook-service 58 | # fieldref: 59 | # fieldpath: metadata.namespace 60 | #- name: SERVICE_NAME 61 | # objref: 62 | # kind: Service 63 | # version: v1 64 | # name: webhook-service 65 | -------------------------------------------------------------------------------- /connectors/util.go: -------------------------------------------------------------------------------- 1 | package connectors 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pkg/errors" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/client-go/kubernetes" 9 | ) 10 | 11 | var ( 12 | // ErrGithubFieldMissing is returned when spec.github is missing 13 | ErrGithubFieldMissing = errors.New("Github field in type spec is missing") 14 | // ErrProviderNotFoundInSecret is returned if PROVIDER=github is not present in credentials secret 15 | ErrProviderNotFoundInSecret = errors.New("PROVIDER field not found in credentials secret") 16 | // ErrGithubTokenNotFoundInSecret is returned if GITHUB_TOKEN field is not present in credentials secret 17 | ErrGithubTokenNotFoundInSecret = errors.New("GITHUB_TOKEN field not found in credentials secret") 18 | // ErrProviderNotSupported is returned when PROVIDER field in credentials secret does not match any known provider 19 | ErrProviderNotSupported = errors.New("PROVIDER not supported, valid providers are: github") 20 | // ErrSSHUserNotFoundInSecret is returned when SSH_USER is not present in credentials secret 21 | ErrSSHUserNotFoundInSecret = errors.New("SSH_USER field not found in credentials secret") 22 | // ErrSSHPrivateKeyNotFoundInSecret is returned when SSH_PRIVATE_KEY is not present in credentials secret 23 | ErrSSHPrivateKeyNotFoundInSecret = errors.New("SSH_PRIVATE_KEY field not found in credentials secret") 24 | // ErrGitSSHURLIsEmpty is returned when gitSSH.url is not present 25 | ErrGitSSHURLIsEmpty = errors.New("GitSSH url was not provided") 26 | ) 27 | 28 | type RepositoryCredentials struct { 29 | Provider string 30 | AuthToken string 31 | } 32 | 33 | // +kubebuilder:rbac:groups="",namespace=system,resources=secrets,verbs=get;list;watch 34 | 35 | func GetRepositoryCredentials(ctx context.Context, k8s *kubernetes.Clientset, secretName, namespace string) (*RepositoryCredentials, error) { 36 | secret, err := k8s.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) 37 | if err != nil { 38 | return nil, errors.Wrapf(err, "failed to get secret %s in namespace %s", secretName, namespace) 39 | } 40 | 41 | provider, found := secret.Data["PROVIDER"] 42 | if !found { 43 | return nil, ErrProviderNotFoundInSecret 44 | } 45 | if string(provider) == "github" { 46 | githubToken, found := secret.Data["GITHUB_TOKEN"] 47 | if !found { 48 | return nil, ErrGithubTokenNotFoundInSecret 49 | } 50 | return &RepositoryCredentials{Provider: string(provider), AuthToken: string(githubToken)}, nil 51 | } 52 | 53 | return nil, ErrProviderNotSupported 54 | } 55 | -------------------------------------------------------------------------------- /controllers/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Kubernetes authors. 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 | "sigs.k8s.io/controller-runtime/pkg/envtest/printer" 30 | logf "sigs.k8s.io/controller-runtime/pkg/log" 31 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 32 | 33 | gitv1 "github.com/flanksource/git-operator/api/v1" 34 | // +kubebuilder:scaffold:imports 35 | ) 36 | 37 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 38 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 39 | 40 | var cfg *rest.Config 41 | var k8sClient client.Client 42 | var testEnv *envtest.Environment 43 | 44 | func TestAPIs(t *testing.T) { 45 | RegisterFailHandler(Fail) 46 | 47 | RunSpecsWithDefaultAndCustomReporters(t, 48 | "Controller Suite", 49 | []Reporter{printer.NewlineReporter{}}) 50 | } 51 | 52 | var _ = BeforeSuite(func(done Done) { 53 | //TODO zap.LoggerTo is deprecated, need to move to Zap.New() 54 | logf.SetLogger(zap.LoggerTo(GinkgoWriter, true)) //nolint: staticcheck 55 | 56 | By("bootstrapping test environment") 57 | testEnv = &envtest.Environment{ 58 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 59 | } 60 | 61 | var err error 62 | cfg, err = testEnv.Start() 63 | Expect(err).ToNot(HaveOccurred()) 64 | Expect(cfg).ToNot(BeNil()) 65 | 66 | err = gitv1.AddToScheme(scheme.Scheme) 67 | Expect(err).NotTo(HaveOccurred()) 68 | 69 | err = gitv1.AddToScheme(scheme.Scheme) 70 | Expect(err).NotTo(HaveOccurred()) 71 | 72 | // +kubebuilder:scaffold:scheme 73 | 74 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 75 | Expect(err).ToNot(HaveOccurred()) 76 | Expect(k8sClient).ToNot(BeNil()) 77 | 78 | close(done) 79 | }, 60) 80 | 81 | var _ = AfterSuite(func() { 82 | By("tearing down the test environment") 83 | err := testEnv.Stop() 84 | Expect(err).ToNot(HaveOccurred()) 85 | }) 86 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Image URL to use all building/pushing image targets 3 | IMG ?= flanksource/git-operator:v1 4 | 5 | CRD_OPTIONS ?= "crd:crdVersions=v1" 6 | 7 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 8 | ifeq (,$(shell go env GOBIN)) 9 | GOBIN=$(shell go env GOPATH)/bin 10 | else 11 | GOBIN=$(shell go env GOBIN) 12 | endif 13 | 14 | all: manager 15 | 16 | # Run tests 17 | test: generate fmt vet manifests 18 | go test ./... -coverprofile cover.out 19 | 20 | # Build manager binary 21 | manager: generate fmt vet 22 | go build -o bin/manager main.go 23 | 24 | # Run against the configured Kubernetes cluster in ~/.kube/config 25 | run: generate fmt vet manifests 26 | go run ./main.go 27 | 28 | # Install CRDs into a cluster 29 | install: manifests kustomize 30 | $(KUSTOMIZE) build config/crd | kubectl apply -f - 31 | 32 | # Uninstall CRDs from a cluster 33 | uninstall: manifests kustomize 34 | $(KUSTOMIZE) build config/crd | kubectl delete -f - 35 | 36 | static: manifests kustomize generate 37 | mkdir -p config/deploy 38 | cd config/operator/manager && $(KUSTOMIZE) edit set image controller=${IMG} 39 | $(KUSTOMIZE) build config/crd > config/deploy/crd.yml 40 | $(KUSTOMIZE) build config/operator/base > config/deploy/base.yml 41 | $(KUSTOMIZE) build config/operator/default > config/deploy/operator.yml 42 | 43 | # Deploy controller in the configured Kubernetes cluster in ~/.kube/config 44 | deploy: manifests kustomize 45 | cd config/operator/manager && $(KUSTOMIZE) edit set image controller=${IMG} 46 | $(KUSTOMIZE) build config/operator/default | kubectl apply -f - 47 | 48 | # Generate manifests e.g. CRD, RBAC etc. 49 | manifests: controller-gen 50 | $(CONTROLLER_GEN) $(CRD_OPTIONS) rbac:roleName=operator webhook paths="./..." output:crd:artifacts:config=config/crd/bases output:rbac:artifacts:config=config/operator/rbac 51 | 52 | # Run go fmt against code 53 | fmt: 54 | go fmt ./... 55 | 56 | # Run go vet against code 57 | vet: 58 | go vet ./... 59 | 60 | # Generate code 61 | generate: controller-gen 62 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 63 | 64 | # Build the docker image 65 | #docker-build: test 66 | docker-build: 67 | docker build . -t ${IMG} 68 | 69 | # Push the docker image 70 | docker-push: 71 | docker push ${IMG} 72 | 73 | # find or download controller-gen 74 | # download controller-gen if necessary 75 | controller-gen: 76 | ifeq (, $(shell which controller-gen)) 77 | go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.6.0 78 | CONTROLLER_GEN=$(GOBIN)/controller-gen 79 | else 80 | CONTROLLER_GEN=$(shell which controller-gen) 81 | endif 82 | 83 | # find or download kustomize if necessary 84 | kustomize: 85 | ifeq (, $(shell which kustomize)) 86 | @{ \ 87 | set -e ;\ 88 | KUSTOMIZE_GEN_TMP_DIR=$$(mktemp -d) ;\ 89 | cd $$KUSTOMIZE_GEN_TMP_DIR ;\ 90 | go mod init tmp ;\ 91 | go get sigs.k8s.io/kustomize/kustomize/v3@v3.5.4 ;\ 92 | rm -rf $$KUSTOMIZE_GEN_TMP_DIR ;\ 93 | } 94 | KUSTOMIZE=$(GOBIN)/kustomize 95 | else 96 | KUSTOMIZE=$(shell which kustomize) 97 | endif 98 | 99 | 100 | # Generate all the resources and formats your code, i.e: CRDs, controller-gen, static 101 | .PHONY: resources 102 | resources: static fmt vet 103 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Kubernetes authors. 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 | zapu "go.uber.org/zap" 25 | "go.uber.org/zap/zapcore" 26 | "k8s.io/apimachinery/pkg/runtime" 27 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 28 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 29 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 30 | ctrl "sigs.k8s.io/controller-runtime" 31 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 32 | 33 | gitv1 "github.com/flanksource/git-operator/api/v1" 34 | "github.com/flanksource/git-operator/controllers" 35 | // +kubebuilder:scaffold:imports 36 | ) 37 | 38 | var ( 39 | scheme = runtime.NewScheme() 40 | setupLog = ctrl.Log.WithName("setup") 41 | logLevel string 42 | ) 43 | 44 | func init() { 45 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 46 | 47 | utilruntime.Must(gitv1.AddToScheme(scheme)) 48 | // +kubebuilder:scaffold:scheme 49 | } 50 | 51 | func logLevelFromString(logLevel string) *zapu.AtomicLevel { 52 | var level zapcore.Level 53 | 54 | switch logLevel { 55 | case "debug": 56 | level = zapu.DebugLevel 57 | case "info": 58 | level = zapu.InfoLevel 59 | case "error": 60 | level = zapu.ErrorLevel 61 | default: 62 | level = zapu.ErrorLevel 63 | } 64 | 65 | ll := zapu.NewAtomicLevelAt(level) 66 | return &ll 67 | } 68 | 69 | func main() { 70 | var metricsAddr string 71 | var enableLeaderElection bool 72 | var syncPeriod time.Duration 73 | flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") 74 | flag.BoolVar(&enableLeaderElection, "enable-leader-election", false, 75 | "Enable leader election for controller manager. "+ 76 | "Enabling this will ensure there is only one active controller manager.") 77 | flag.DurationVar(&syncPeriod, "sync-period", 60*time.Second, "The resync period used to check Github for new resources") 78 | flag.StringVar(&logLevel, "log-level", "error", "Logging level: debug, info, error") 79 | flag.Parse() 80 | 81 | ctrl.SetLogger(zap.New(zap.UseDevMode(true), zap.Level(logLevelFromString(logLevel)))) 82 | 83 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 84 | Scheme: scheme, 85 | MetricsBindAddress: metricsAddr, 86 | Port: 9443, 87 | LeaderElection: enableLeaderElection, 88 | LeaderElectionID: "bc88107d.flanksource.com", 89 | SyncPeriod: &syncPeriod, 90 | }) 91 | if err != nil { 92 | setupLog.Error(err, "unable to start manager") 93 | os.Exit(1) 94 | } 95 | 96 | if err = (&controllers.GitopsAPIReconciler{ 97 | Client: mgr.GetClient(), 98 | Log: ctrl.Log.WithName("controllers").WithName("GitopsAPI"), 99 | Scheme: mgr.GetScheme(), 100 | }).SetupWithManager(mgr); err != nil { 101 | setupLog.Error(err, "unable to create controller", "controller", "GitopsAPI") 102 | os.Exit(1) 103 | } 104 | 105 | // +kubebuilder:scaffold:builder 106 | 107 | setupLog.Info("starting manager") 108 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 109 | setupLog.Error(err, "problem running manager") 110 | os.Exit(1) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /config/deploy/base.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: git-operator 5 | namespace: platform-system 6 | --- 7 | apiVersion: rbac.authorization.k8s.io/v1 8 | kind: Role 9 | metadata: 10 | creationTimestamp: null 11 | name: git-operator 12 | namespace: platform-system 13 | rules: 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - secrets 18 | verbs: 19 | - get 20 | - list 21 | - watch 22 | --- 23 | apiVersion: rbac.authorization.k8s.io/v1 24 | kind: Role 25 | metadata: 26 | name: git-operator-leader-election 27 | namespace: platform-system 28 | rules: 29 | - apiGroups: 30 | - "" 31 | resources: 32 | - configmaps 33 | verbs: 34 | - get 35 | - list 36 | - watch 37 | - create 38 | - update 39 | - patch 40 | - delete 41 | - apiGroups: 42 | - "" 43 | resources: 44 | - configmaps/status 45 | verbs: 46 | - get 47 | - update 48 | - patch 49 | - apiGroups: 50 | - "" 51 | resources: 52 | - events 53 | verbs: 54 | - create 55 | --- 56 | apiVersion: rbac.authorization.k8s.io/v1 57 | kind: ClusterRole 58 | metadata: 59 | creationTimestamp: null 60 | name: git-operator 61 | rules: 62 | - apiGroups: 63 | - git.flanksource.com 64 | resources: 65 | - gitopsapis 66 | verbs: 67 | - create 68 | - delete 69 | - get 70 | - list 71 | - patch 72 | - update 73 | - watch 74 | - apiGroups: 75 | - git.flanksource.com 76 | resources: 77 | - gitopsapis/status 78 | verbs: 79 | - get 80 | - patch 81 | - update 82 | --- 83 | apiVersion: rbac.authorization.k8s.io/v1 84 | kind: RoleBinding 85 | metadata: 86 | name: git-operator 87 | namespace: platform-system 88 | roleRef: 89 | apiGroup: rbac.authorization.k8s.io 90 | kind: Role 91 | name: git-operator 92 | subjects: 93 | - kind: ServiceAccount 94 | name: git-operator 95 | namespace: platform-system 96 | --- 97 | apiVersion: rbac.authorization.k8s.io/v1 98 | kind: RoleBinding 99 | metadata: 100 | name: git-operator-leader-election 101 | namespace: platform-system 102 | roleRef: 103 | apiGroup: rbac.authorization.k8s.io 104 | kind: Role 105 | name: git-operator-leader-election 106 | subjects: 107 | - kind: ServiceAccount 108 | name: git-operator 109 | namespace: platform-system 110 | --- 111 | apiVersion: rbac.authorization.k8s.io/v1 112 | kind: ClusterRoleBinding 113 | metadata: 114 | name: git-operator 115 | roleRef: 116 | apiGroup: rbac.authorization.k8s.io 117 | kind: ClusterRole 118 | name: git-operator 119 | subjects: 120 | - kind: ServiceAccount 121 | name: git-operator 122 | namespace: platform-system 123 | --- 124 | apiVersion: apps/v1 125 | kind: Deployment 126 | metadata: 127 | labels: 128 | control-plane: git-operator 129 | name: git-operator 130 | namespace: platform-system 131 | spec: 132 | replicas: 1 133 | selector: 134 | matchLabels: 135 | control-plane: git-operator 136 | template: 137 | metadata: 138 | labels: 139 | control-plane: git-operator 140 | spec: 141 | containers: 142 | - args: 143 | - --metrics-addr=127.0.0.1:8080 144 | - --enable-leader-election 145 | - --log-level=debug 146 | command: 147 | - /git-operator 148 | image: flanksource/git-operator:v1 149 | imagePullPolicy: IfNotPresent 150 | name: git-operator 151 | resources: 152 | limits: 153 | cpu: 100m 154 | memory: 150Mi 155 | requests: 156 | cpu: 100m 157 | memory: 128Mi 158 | - args: 159 | - --secure-listen-address=0.0.0.0:8443 160 | - --upstream=http://127.0.0.1:8080/ 161 | - --logtostderr=true 162 | - --v=10 163 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 164 | name: kube-rbac-proxy 165 | ports: 166 | - containerPort: 8443 167 | name: https 168 | serviceAccountName: git-operator 169 | terminationGracePeriodSeconds: 10 170 | -------------------------------------------------------------------------------- /api/v1/gitopsapi_types.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Kubernetes authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package v1 18 | 19 | import ( 20 | corev1 "k8s.io/api/core/v1" 21 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 22 | ) 23 | 24 | // GitopsAPISpec defines the desired state of GitopsAPI 25 | type GitopsAPISpec struct { 26 | // The repository URL, can be a HTTP or SSH address. 27 | // +kubebuilder:validation:Pattern="^(http|https|ssh)://" 28 | // +required 29 | GitRepository string `json:"gitRepository,omitempty"` 30 | GitUser string `json:"gitUser,omitempty"` 31 | GitEmail string `json:"gitEmail,omitempty"` 32 | // The branch to use as a baseline for the new branch, defaults to master 33 | Base string `json:"base,omitempty"` 34 | // The branch to push updates back to, defaults to master 35 | Branch string `json:"branch,omitempty"` 36 | 37 | // Open a new Pull request from the branch back to the base 38 | PullRequest *PullRequestTemplate `json:"pullRequest,omitempty"` 39 | 40 | // The secret name containing the Git credentials. 41 | // For SSH repositories the secret must contain SSH_PRIVATE_KEY, SSH_PRIVATE_KEY_PASSORD 42 | // For Github repositories it must contain GITHUB_TOKEN 43 | // +optional 44 | SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` 45 | 46 | // The secret name containing the static credential to authenticate agaist either 47 | // as a `Authorization: Bearer` header or as a `?token=` argument 48 | // Must contain a key called TOKEN 49 | // +optional 50 | TokenRef *corev1.LocalObjectReference `json:"tokenRef,omitempty"` 51 | 52 | // List of github users which should approve the namespace request 53 | Reviewers []string `json:"reviewers,omitempty"` 54 | 55 | // The path to a kustomization file to insert or remove the resource, can included templated values .e.g `specs/clusters/{{.cluster}}/kustomization.yaml` 56 | // +required 57 | Kustomization string `json:"kustomization,omitempty"` 58 | 59 | // The path to save the resource into, should including templating to make it unique per cluster/namespace/kind/name tuple e.g. `specs/clusters/{{.cluster}}/{{.name}}.yaml` 60 | Path string `json:"path,omitempty"` 61 | // SearchPath defines the subdir in which the matching object needs to be searched. In case Path and SearchPath both are defined SearchPath takes precedence 62 | SearchPath string `json:"searchPath,omitempty"` 63 | } 64 | 65 | type PullRequestTemplate struct { 66 | Body string `json:"body,omitempty"` 67 | Title string `json:"title,omitempty"` 68 | Reviewers []string `json:"reviewers,omitempty"` 69 | Assignees []string `json:"assignees,omitempty"` 70 | Tags []string `json:"tags,omitempty"` 71 | } 72 | 73 | // GitopsAPIStatus defines the observed state of GitopsAPI 74 | type GitopsAPIStatus struct { 75 | } 76 | 77 | // +kubebuilder:object:root=true 78 | 79 | // GitopsAPI is the Schema for the gitopsapis API 80 | type GitopsAPI struct { 81 | metav1.TypeMeta `json:",inline"` 82 | metav1.ObjectMeta `json:"metadata,omitempty"` 83 | 84 | Spec GitopsAPISpec `json:"spec,omitempty"` 85 | Status GitopsAPIStatus `json:"status,omitempty"` 86 | } 87 | 88 | // +kubebuilder:object:root=true 89 | 90 | // GitopsAPIList contains a list of GitopsAPI 91 | type GitopsAPIList struct { 92 | metav1.TypeMeta `json:",inline"` 93 | metav1.ListMeta `json:"metadata,omitempty"` 94 | Items []GitopsAPI `json:"items"` 95 | } 96 | 97 | func init() { 98 | SchemeBuilder.Register(&GitopsAPI{}, &GitopsAPIList{}) 99 | } 100 | -------------------------------------------------------------------------------- /connectors/github.go: -------------------------------------------------------------------------------- 1 | package connectors 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | 9 | gitv1 "github.com/flanksource/git-operator/api/v1" 10 | "github.com/go-git/go-billy/v5" 11 | "github.com/go-git/go-billy/v5/osfs" 12 | git "github.com/go-git/go-git/v5" 13 | "github.com/go-git/go-git/v5/plumbing" 14 | "github.com/go-git/go-git/v5/plumbing/transport" 15 | "github.com/go-git/go-git/v5/plumbing/transport/http" 16 | "github.com/go-logr/logr" 17 | "github.com/jenkins-x/go-scm/scm" 18 | "github.com/jenkins-x/go-scm/scm/factory" 19 | "github.com/pkg/errors" 20 | "sigs.k8s.io/controller-runtime/pkg/client" 21 | ) 22 | 23 | type Github struct { 24 | k8sCrd client.Client 25 | logr.Logger 26 | scm *scm.Client 27 | repo *git.Repository 28 | auth transport.AuthMethod 29 | owner string 30 | repoName string 31 | repository string 32 | } 33 | 34 | func NewGithub(client client.Client, log logr.Logger, owner, repoName, githubToken string) (Connector, error) { 35 | scmClient, err := factory.NewClient("github", "", githubToken) 36 | if err != nil { 37 | return nil, errors.Wrap(err, "failed to create github client") 38 | } 39 | 40 | github := &Github{ 41 | k8sCrd: client, 42 | Logger: log.WithName("Github").WithName(owner + "/" + repoName), 43 | scm: scmClient, 44 | owner: owner, 45 | repoName: repoName, 46 | repository: owner + "/" + repoName, 47 | auth: &http.BasicAuth{Password: githubToken, Username: githubToken}, 48 | } 49 | return github, nil 50 | } 51 | 52 | func (g *Github) Push(ctx context.Context, branch string) error { 53 | if g.repo == nil { 54 | return errors.New("Need to clone first, before pushing ") 55 | } 56 | 57 | g.V(1).Info("Pushing", "branch", branch) 58 | 59 | if err := g.repo.Push(&git.PushOptions{ 60 | Auth: g.auth, 61 | Progress: os.Stdout, 62 | }); err != nil { 63 | return err 64 | } 65 | ref, _ := g.repo.Head() 66 | g.Info("Pushed", "branch", branch, "ref", ref.String()) 67 | return nil 68 | } 69 | 70 | func (g *Github) OpenPullRequest(ctx context.Context, base string, head string, spec *gitv1.PullRequestTemplate) (int, error) { 71 | if spec.Title == "" { 72 | spec.Title = head 73 | } 74 | g.V(1).Info("Creating PR", "title", spec.Title, "head", head, "base", base) 75 | pr, _, err := g.scm.PullRequests.Create(ctx, g.repository, &scm.PullRequestInput{ 76 | Title: spec.Title, 77 | Body: spec.Body, 78 | Head: head, 79 | Base: base, 80 | }) 81 | 82 | if err != nil { 83 | return 0, errors.Wrapf(err, "failed to create pr repo=%s title=%s, head=%s base=%s", g.repository, spec.Title, head, base) 84 | } 85 | g.Info("PR created", "pr", pr.Number, "repository", g.repository) 86 | 87 | if len(spec.Reviewers) > 0 { 88 | g.Info("Requesting Reviews", "pr", pr.Number, "repository", g.repository, "reviewers", spec.Reviewers) 89 | if _, err := g.scm.PullRequests.RequestReview(ctx, g.repository, pr.Number, spec.Reviewers); err != nil { 90 | return 0, err 91 | } 92 | } 93 | 94 | if len(spec.Assignees) > 0 { 95 | g.Info("Assigning PR", "pr", pr.Number, "repository", g.repoName, "assignees", spec.Assignees) 96 | if _, err := g.scm.PullRequests.AssignIssue(ctx, g.repository, pr.Number, spec.Assignees); err != nil { 97 | return 0, err 98 | } 99 | } 100 | 101 | return pr.Number, nil 102 | } 103 | 104 | func (g *Github) ClosePullRequest(ctx context.Context, id int) error { 105 | if _, err := g.scm.PullRequests.Close(ctx, g.repository, id); err != nil { 106 | return errors.Wrap(err, "failed to close github pull request") 107 | } 108 | 109 | return nil 110 | } 111 | 112 | func (g *Github) Clone(ctx context.Context, branch, local string) (billy.Filesystem, *git.Worktree, error) { 113 | dir, _ := ioutil.TempDir("", "git-*") 114 | url := fmt.Sprintf("https://github.com/%s/%s.git", g.owner, g.repoName) 115 | g.Info("Cloning", "temp", dir) 116 | repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ 117 | ReferenceName: plumbing.NewBranchReferenceName(branch), 118 | URL: url, 119 | Progress: os.Stdout, 120 | Auth: g.auth, 121 | }) 122 | if err != nil { 123 | return nil, nil, err 124 | } 125 | g.repo = repo 126 | 127 | work, err := repo.Worktree() 128 | if err != nil { 129 | return nil, nil, err 130 | } 131 | if branch != local { 132 | // nolint: errcheck 133 | work.Checkout(&git.CheckoutOptions{ 134 | Branch: plumbing.NewBranchReferenceName(local), 135 | Create: true, 136 | }) 137 | } 138 | return osfs.New(dir), work, nil 139 | } 140 | -------------------------------------------------------------------------------- /api/v1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | // +build !ignore_autogenerated 2 | 3 | /* 4 | Copyright 2020 The Kubernetes authors. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | // Code generated by controller-gen. DO NOT EDIT. 20 | 21 | package v1 22 | 23 | import ( 24 | corev1 "k8s.io/api/core/v1" 25 | "k8s.io/apimachinery/pkg/runtime" 26 | ) 27 | 28 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 29 | func (in *GitopsAPI) DeepCopyInto(out *GitopsAPI) { 30 | *out = *in 31 | out.TypeMeta = in.TypeMeta 32 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 33 | in.Spec.DeepCopyInto(&out.Spec) 34 | out.Status = in.Status 35 | } 36 | 37 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitopsAPI. 38 | func (in *GitopsAPI) DeepCopy() *GitopsAPI { 39 | if in == nil { 40 | return nil 41 | } 42 | out := new(GitopsAPI) 43 | in.DeepCopyInto(out) 44 | return out 45 | } 46 | 47 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 48 | func (in *GitopsAPI) DeepCopyObject() runtime.Object { 49 | if c := in.DeepCopy(); c != nil { 50 | return c 51 | } 52 | return nil 53 | } 54 | 55 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 56 | func (in *GitopsAPIList) DeepCopyInto(out *GitopsAPIList) { 57 | *out = *in 58 | out.TypeMeta = in.TypeMeta 59 | in.ListMeta.DeepCopyInto(&out.ListMeta) 60 | if in.Items != nil { 61 | in, out := &in.Items, &out.Items 62 | *out = make([]GitopsAPI, len(*in)) 63 | for i := range *in { 64 | (*in)[i].DeepCopyInto(&(*out)[i]) 65 | } 66 | } 67 | } 68 | 69 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitopsAPIList. 70 | func (in *GitopsAPIList) DeepCopy() *GitopsAPIList { 71 | if in == nil { 72 | return nil 73 | } 74 | out := new(GitopsAPIList) 75 | in.DeepCopyInto(out) 76 | return out 77 | } 78 | 79 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 80 | func (in *GitopsAPIList) DeepCopyObject() runtime.Object { 81 | if c := in.DeepCopy(); c != nil { 82 | return c 83 | } 84 | return nil 85 | } 86 | 87 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 88 | func (in *GitopsAPISpec) DeepCopyInto(out *GitopsAPISpec) { 89 | *out = *in 90 | if in.PullRequest != nil { 91 | in, out := &in.PullRequest, &out.PullRequest 92 | *out = new(PullRequestTemplate) 93 | (*in).DeepCopyInto(*out) 94 | } 95 | if in.SecretRef != nil { 96 | in, out := &in.SecretRef, &out.SecretRef 97 | *out = new(corev1.LocalObjectReference) 98 | **out = **in 99 | } 100 | if in.TokenRef != nil { 101 | in, out := &in.TokenRef, &out.TokenRef 102 | *out = new(corev1.LocalObjectReference) 103 | **out = **in 104 | } 105 | if in.Reviewers != nil { 106 | in, out := &in.Reviewers, &out.Reviewers 107 | *out = make([]string, len(*in)) 108 | copy(*out, *in) 109 | } 110 | } 111 | 112 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitopsAPISpec. 113 | func (in *GitopsAPISpec) DeepCopy() *GitopsAPISpec { 114 | if in == nil { 115 | return nil 116 | } 117 | out := new(GitopsAPISpec) 118 | in.DeepCopyInto(out) 119 | return out 120 | } 121 | 122 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 123 | func (in *GitopsAPIStatus) DeepCopyInto(out *GitopsAPIStatus) { 124 | *out = *in 125 | } 126 | 127 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitopsAPIStatus. 128 | func (in *GitopsAPIStatus) DeepCopy() *GitopsAPIStatus { 129 | if in == nil { 130 | return nil 131 | } 132 | out := new(GitopsAPIStatus) 133 | in.DeepCopyInto(out) 134 | return out 135 | } 136 | 137 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 138 | func (in *PullRequestTemplate) DeepCopyInto(out *PullRequestTemplate) { 139 | *out = *in 140 | if in.Reviewers != nil { 141 | in, out := &in.Reviewers, &out.Reviewers 142 | *out = make([]string, len(*in)) 143 | copy(*out, *in) 144 | } 145 | if in.Assignees != nil { 146 | in, out := &in.Assignees, &out.Assignees 147 | *out = make([]string, len(*in)) 148 | copy(*out, *in) 149 | } 150 | if in.Tags != nil { 151 | in, out := &in.Tags, &out.Tags 152 | *out = make([]string, len(*in)) 153 | copy(*out, *in) 154 | } 155 | } 156 | 157 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PullRequestTemplate. 158 | func (in *PullRequestTemplate) DeepCopy() *PullRequestTemplate { 159 | if in == nil { 160 | return nil 161 | } 162 | out := new(PullRequestTemplate) 163 | in.DeepCopyInto(out) 164 | return out 165 | } 166 | -------------------------------------------------------------------------------- /config/deploy/crd.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | controller-gen.kubebuilder.io/version: v0.6.0 6 | creationTimestamp: null 7 | name: gitopsapis.git.flanksource.com 8 | spec: 9 | group: git.flanksource.com 10 | names: 11 | kind: GitopsAPI 12 | listKind: GitopsAPIList 13 | plural: gitopsapis 14 | singular: gitopsapi 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: GitopsAPI is the Schema for the gitopsapis API 21 | properties: 22 | apiVersion: 23 | description: 'APIVersion defines the versioned schema of this representation 24 | of an object. Servers should convert recognized schemas to the latest 25 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 26 | type: string 27 | kind: 28 | description: 'Kind is a string value representing the REST resource this 29 | object represents. Servers may infer this from the endpoint the client 30 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 31 | type: string 32 | metadata: 33 | type: object 34 | spec: 35 | description: GitopsAPISpec defines the desired state of GitopsAPI 36 | properties: 37 | base: 38 | description: The branch to use as a baseline for the new branch, defaults 39 | to master 40 | type: string 41 | branch: 42 | description: The branch to push updates back to, defaults to master 43 | type: string 44 | gitEmail: 45 | type: string 46 | gitRepository: 47 | description: The repository URL, can be a HTTP or SSH address. 48 | pattern: ^(http|https|ssh):// 49 | type: string 50 | gitUser: 51 | type: string 52 | kustomization: 53 | description: The path to a kustomization file to insert or remove 54 | the resource, can included templated values .e.g `specs/clusters/{{.cluster}}/kustomization.yaml` 55 | type: string 56 | path: 57 | description: The path to save the resource into, should including 58 | templating to make it unique per cluster/namespace/kind/name tuple 59 | e.g. `specs/clusters/{{.cluster}}/{{.name}}.yaml` 60 | type: string 61 | pullRequest: 62 | description: Open a new Pull request from the branch back to the base 63 | properties: 64 | assignees: 65 | items: 66 | type: string 67 | type: array 68 | body: 69 | type: string 70 | reviewers: 71 | items: 72 | type: string 73 | type: array 74 | tags: 75 | items: 76 | type: string 77 | type: array 78 | title: 79 | type: string 80 | type: object 81 | reviewers: 82 | description: List of github users which should approve the namespace 83 | request 84 | items: 85 | type: string 86 | type: array 87 | searchPath: 88 | description: SearchPath defines the subdir in which the matching object 89 | needs to be searched. In case Path and SearchPath both are defined 90 | SearchPath takes precedence 91 | type: string 92 | secretRef: 93 | description: The secret name containing the Git credentials. For SSH 94 | repositories the secret must contain SSH_PRIVATE_KEY, SSH_PRIVATE_KEY_PASSORD 95 | For Github repositories it must contain GITHUB_TOKEN 96 | properties: 97 | name: 98 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 99 | TODO: Add other useful fields. apiVersion, kind, uid?' 100 | type: string 101 | type: object 102 | tokenRef: 103 | description: 'The secret name containing the static credential to 104 | authenticate agaist either as a `Authorization: Bearer` header or 105 | as a `?token=` argument Must contain a key called TOKEN' 106 | properties: 107 | name: 108 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 109 | TODO: Add other useful fields. apiVersion, kind, uid?' 110 | type: string 111 | type: object 112 | type: object 113 | status: 114 | description: GitopsAPIStatus defines the observed state of GitopsAPI 115 | type: object 116 | type: object 117 | served: true 118 | storage: true 119 | status: 120 | acceptedNames: 121 | kind: "" 122 | plural: "" 123 | conditions: [] 124 | storedVersions: [] 125 | -------------------------------------------------------------------------------- /config/crd/bases/git.flanksource.com_gitopsapis.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: apiextensions.k8s.io/v1 4 | kind: CustomResourceDefinition 5 | metadata: 6 | annotations: 7 | controller-gen.kubebuilder.io/version: v0.6.0 8 | creationTimestamp: null 9 | name: gitopsapis.git.flanksource.com 10 | spec: 11 | group: git.flanksource.com 12 | names: 13 | kind: GitopsAPI 14 | listKind: GitopsAPIList 15 | plural: gitopsapis 16 | singular: gitopsapi 17 | scope: Namespaced 18 | versions: 19 | - name: v1 20 | schema: 21 | openAPIV3Schema: 22 | description: GitopsAPI is the Schema for the gitopsapis 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: GitopsAPISpec defines the desired state of GitopsAPI 38 | properties: 39 | base: 40 | description: The branch to use as a baseline for the new branch, defaults 41 | to master 42 | type: string 43 | branch: 44 | description: The branch to push updates back to, defaults to master 45 | type: string 46 | gitEmail: 47 | type: string 48 | gitRepository: 49 | description: The repository URL, can be a HTTP or SSH address. 50 | pattern: ^(http|https|ssh):// 51 | type: string 52 | gitUser: 53 | type: string 54 | kustomization: 55 | description: The path to a kustomization file to insert or remove 56 | the resource, can included templated values .e.g `specs/clusters/{{.cluster}}/kustomization.yaml` 57 | type: string 58 | path: 59 | description: The path to save the resource into, should including 60 | templating to make it unique per cluster/namespace/kind/name tuple 61 | e.g. `specs/clusters/{{.cluster}}/{{.name}}.yaml` 62 | type: string 63 | pullRequest: 64 | description: Open a new Pull request from the branch back to the base 65 | properties: 66 | assignees: 67 | items: 68 | type: string 69 | type: array 70 | body: 71 | type: string 72 | reviewers: 73 | items: 74 | type: string 75 | type: array 76 | tags: 77 | items: 78 | type: string 79 | type: array 80 | title: 81 | type: string 82 | type: object 83 | reviewers: 84 | description: List of github users which should approve the namespace 85 | request 86 | items: 87 | type: string 88 | type: array 89 | searchPath: 90 | description: SearchPath defines the subdir in which the matching object 91 | needs to be searched. In case Path and SearchPath both are defined 92 | SearchPath takes precedence 93 | type: string 94 | secretRef: 95 | description: The secret name containing the Git credentials. For SSH 96 | repositories the secret must contain SSH_PRIVATE_KEY, SSH_PRIVATE_KEY_PASSORD 97 | For Github repositories it must contain GITHUB_TOKEN 98 | properties: 99 | name: 100 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 101 | TODO: Add other useful fields. apiVersion, kind, uid?' 102 | type: string 103 | type: object 104 | tokenRef: 105 | description: 'The secret name containing the static credential to 106 | authenticate agaist either as a `Authorization: Bearer` header or 107 | as a `?token=` argument Must contain a key called TOKEN' 108 | properties: 109 | name: 110 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 111 | TODO: Add other useful fields. apiVersion, kind, uid?' 112 | type: string 113 | type: object 114 | type: object 115 | status: 116 | description: GitopsAPIStatus defines the observed state of GitopsAPI 117 | type: object 118 | type: object 119 | served: true 120 | storage: true 121 | status: 122 | acceptedNames: 123 | kind: "" 124 | plural: "" 125 | conditions: [] 126 | storedVersions: [] 127 | -------------------------------------------------------------------------------- /config/deploy/operator.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.k8s.io/v1 2 | kind: CustomResourceDefinition 3 | metadata: 4 | annotations: 5 | controller-gen.kubebuilder.io/version: v0.6.0 6 | creationTimestamp: null 7 | name: gitopsapis.git.flanksource.com 8 | spec: 9 | group: git.flanksource.com 10 | names: 11 | kind: GitopsAPI 12 | listKind: GitopsAPIList 13 | plural: gitopsapis 14 | singular: gitopsapi 15 | scope: Namespaced 16 | versions: 17 | - name: v1 18 | schema: 19 | openAPIV3Schema: 20 | description: GitopsAPI is the Schema for the gitopsapis API 21 | properties: 22 | apiVersion: 23 | description: 'APIVersion defines the versioned schema of this representation 24 | of an object. Servers should convert recognized schemas to the latest 25 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 26 | type: string 27 | kind: 28 | description: 'Kind is a string value representing the REST resource this 29 | object represents. Servers may infer this from the endpoint the client 30 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 31 | type: string 32 | metadata: 33 | type: object 34 | spec: 35 | description: GitopsAPISpec defines the desired state of GitopsAPI 36 | properties: 37 | base: 38 | description: The branch to use as a baseline for the new branch, defaults 39 | to master 40 | type: string 41 | branch: 42 | description: The branch to push updates back to, defaults to master 43 | type: string 44 | gitEmail: 45 | type: string 46 | gitRepository: 47 | description: The repository URL, can be a HTTP or SSH address. 48 | pattern: ^(http|https|ssh):// 49 | type: string 50 | gitUser: 51 | type: string 52 | kustomization: 53 | description: The path to a kustomization file to insert or remove 54 | the resource, can included templated values .e.g `specs/clusters/{{.cluster}}/kustomization.yaml` 55 | type: string 56 | path: 57 | description: The path to save the resource into, should including 58 | templating to make it unique per cluster/namespace/kind/name tuple 59 | e.g. `specs/clusters/{{.cluster}}/{{.name}}.yaml` 60 | type: string 61 | pullRequest: 62 | description: Open a new Pull request from the branch back to the base 63 | properties: 64 | assignees: 65 | items: 66 | type: string 67 | type: array 68 | body: 69 | type: string 70 | reviewers: 71 | items: 72 | type: string 73 | type: array 74 | tags: 75 | items: 76 | type: string 77 | type: array 78 | title: 79 | type: string 80 | type: object 81 | reviewers: 82 | description: List of github users which should approve the namespace 83 | request 84 | items: 85 | type: string 86 | type: array 87 | searchPath: 88 | description: SearchPath defines the subdir in which the matching object 89 | needs to be searched. In case Path and SearchPath both are defined 90 | SearchPath takes precedence 91 | type: string 92 | secretRef: 93 | description: The secret name containing the Git credentials. For SSH 94 | repositories the secret must contain SSH_PRIVATE_KEY, SSH_PRIVATE_KEY_PASSORD 95 | For Github repositories it must contain GITHUB_TOKEN 96 | properties: 97 | name: 98 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 99 | TODO: Add other useful fields. apiVersion, kind, uid?' 100 | type: string 101 | type: object 102 | tokenRef: 103 | description: 'The secret name containing the static credential to 104 | authenticate agaist either as a `Authorization: Bearer` header or 105 | as a `?token=` argument Must contain a key called TOKEN' 106 | properties: 107 | name: 108 | description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names 109 | TODO: Add other useful fields. apiVersion, kind, uid?' 110 | type: string 111 | type: object 112 | type: object 113 | status: 114 | description: GitopsAPIStatus defines the observed state of GitopsAPI 115 | type: object 116 | type: object 117 | served: true 118 | storage: true 119 | status: 120 | acceptedNames: 121 | kind: "" 122 | plural: "" 123 | conditions: [] 124 | storedVersions: [] 125 | --- 126 | apiVersion: v1 127 | kind: ServiceAccount 128 | metadata: 129 | name: git-operator 130 | namespace: platform-system 131 | --- 132 | apiVersion: rbac.authorization.k8s.io/v1 133 | kind: Role 134 | metadata: 135 | creationTimestamp: null 136 | name: git-operator 137 | namespace: platform-system 138 | rules: 139 | - apiGroups: 140 | - "" 141 | resources: 142 | - secrets 143 | verbs: 144 | - get 145 | - list 146 | - watch 147 | --- 148 | apiVersion: rbac.authorization.k8s.io/v1 149 | kind: Role 150 | metadata: 151 | name: git-operator-leader-election 152 | namespace: platform-system 153 | rules: 154 | - apiGroups: 155 | - "" 156 | resources: 157 | - configmaps 158 | verbs: 159 | - get 160 | - list 161 | - watch 162 | - create 163 | - update 164 | - patch 165 | - delete 166 | - apiGroups: 167 | - "" 168 | resources: 169 | - configmaps/status 170 | verbs: 171 | - get 172 | - update 173 | - patch 174 | - apiGroups: 175 | - "" 176 | resources: 177 | - events 178 | verbs: 179 | - create 180 | --- 181 | apiVersion: rbac.authorization.k8s.io/v1 182 | kind: ClusterRole 183 | metadata: 184 | creationTimestamp: null 185 | name: git-operator 186 | rules: 187 | - apiGroups: 188 | - git.flanksource.com 189 | resources: 190 | - gitopsapis 191 | verbs: 192 | - create 193 | - delete 194 | - get 195 | - list 196 | - patch 197 | - update 198 | - watch 199 | - apiGroups: 200 | - git.flanksource.com 201 | resources: 202 | - gitopsapis/status 203 | verbs: 204 | - get 205 | - patch 206 | - update 207 | --- 208 | apiVersion: rbac.authorization.k8s.io/v1 209 | kind: RoleBinding 210 | metadata: 211 | name: git-operator 212 | namespace: platform-system 213 | roleRef: 214 | apiGroup: rbac.authorization.k8s.io 215 | kind: Role 216 | name: git-operator 217 | subjects: 218 | - kind: ServiceAccount 219 | name: git-operator 220 | namespace: platform-system 221 | --- 222 | apiVersion: rbac.authorization.k8s.io/v1 223 | kind: RoleBinding 224 | metadata: 225 | name: git-operator-leader-election 226 | namespace: platform-system 227 | roleRef: 228 | apiGroup: rbac.authorization.k8s.io 229 | kind: Role 230 | name: git-operator-leader-election 231 | subjects: 232 | - kind: ServiceAccount 233 | name: git-operator 234 | namespace: platform-system 235 | --- 236 | apiVersion: rbac.authorization.k8s.io/v1 237 | kind: ClusterRoleBinding 238 | metadata: 239 | name: git-operator 240 | roleRef: 241 | apiGroup: rbac.authorization.k8s.io 242 | kind: ClusterRole 243 | name: git-operator 244 | subjects: 245 | - kind: ServiceAccount 246 | name: git-operator 247 | namespace: platform-system 248 | --- 249 | apiVersion: apps/v1 250 | kind: Deployment 251 | metadata: 252 | labels: 253 | control-plane: git-operator 254 | name: git-operator 255 | namespace: platform-system 256 | spec: 257 | replicas: 1 258 | selector: 259 | matchLabels: 260 | control-plane: git-operator 261 | template: 262 | metadata: 263 | labels: 264 | control-plane: git-operator 265 | spec: 266 | containers: 267 | - args: 268 | - --metrics-addr=127.0.0.1:8080 269 | - --enable-leader-election 270 | - --log-level=debug 271 | command: 272 | - /git-operator 273 | image: flanksource/git-operator:v1 274 | imagePullPolicy: IfNotPresent 275 | name: git-operator 276 | resources: 277 | limits: 278 | cpu: 100m 279 | memory: 150Mi 280 | requests: 281 | cpu: 100m 282 | memory: 128Mi 283 | - args: 284 | - --secure-listen-address=0.0.0.0:8443 285 | - --upstream=http://127.0.0.1:8080/ 286 | - --logtostderr=true 287 | - --v=10 288 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 289 | name: kube-rbac-proxy 290 | ports: 291 | - containerPort: 8443 292 | name: https 293 | serviceAccountName: git-operator 294 | terminationGracePeriodSeconds: 10 295 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/flanksource/git-operator 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/flanksource/commons v1.5.6 7 | github.com/flanksource/kommons v0.20.1 8 | github.com/go-git/go-billy/v5 v5.0.0 9 | github.com/go-git/go-git/v5 v5.1.0 10 | github.com/go-logr/logr v0.3.0 11 | github.com/go-logr/zapr v0.2.0 12 | github.com/gosimple/slug v1.9.0 13 | github.com/imdario/mergo v0.3.9 14 | github.com/jenkins-x/go-scm v1.5.224 15 | github.com/labstack/echo v3.3.10+incompatible 16 | github.com/labstack/gommon v0.3.0 17 | github.com/onsi/ginkgo v1.14.0 18 | github.com/onsi/gomega v1.10.1 19 | github.com/pkg/errors v0.9.1 20 | github.com/weaveworks/libgitops v0.0.3 21 | go.uber.org/zap v1.15.0 22 | golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 23 | k8s.io/api v0.20.4 24 | k8s.io/apimachinery v0.20.4 25 | k8s.io/client-go v12.0.0+incompatible 26 | sigs.k8s.io/controller-runtime v0.6.0 27 | sigs.k8s.io/kustomize/api v0.4.1 28 | sigs.k8s.io/yaml v1.2.0 29 | ) 30 | 31 | require ( 32 | cloud.google.com/go v0.54.0 // indirect 33 | cloud.google.com/go/storage v1.6.0 // indirect 34 | code.gitea.io/sdk/gitea v0.13.1-0.20210217150345-a968e32ca15c // indirect 35 | github.com/AlekSi/pointer v1.1.0 // indirect 36 | github.com/BurntSushi/toml v0.3.1 // indirect 37 | github.com/Masterminds/goutils v1.1.0 // indirect 38 | github.com/PuerkitoBio/purell v1.1.1 // indirect 39 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 40 | github.com/Shopify/ejson v1.2.1 // indirect 41 | github.com/VividCortex/ewma v1.1.1 // indirect 42 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 43 | github.com/armon/go-metrics v0.3.0 // indirect 44 | github.com/aws/aws-sdk-go v1.29.25 // indirect 45 | github.com/beorn7/perks v1.0.1 // indirect 46 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 47 | github.com/bluekeyes/go-gitdiff v0.4.0 // indirect 48 | github.com/boltdb/bolt v1.3.1 // indirect 49 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 50 | github.com/coreos/go-semver v0.3.0 // indirect 51 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e // indirect 52 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect 53 | github.com/davecgh/go-spew v1.1.1 // indirect 54 | github.com/docker/libkv v0.2.1 // indirect 55 | github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96 // indirect 56 | github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect 57 | github.com/emicklei/go-restful v2.9.5+incompatible // indirect 58 | github.com/emirpasic/gods v1.12.0 // indirect 59 | github.com/evanphx/json-patch v4.9.0+incompatible // indirect 60 | github.com/fatih/color v1.9.0 // indirect 61 | github.com/ghodss/yaml v1.0.0 // indirect 62 | github.com/go-errors/errors v1.0.1 // indirect 63 | github.com/go-git/gcfg v1.5.0 // indirect 64 | github.com/go-openapi/jsonpointer v0.19.3 // indirect 65 | github.com/go-openapi/jsonreference v0.19.3 // indirect 66 | github.com/go-openapi/spec v0.19.8 // indirect 67 | github.com/go-openapi/swag v0.19.5 // indirect 68 | github.com/gogo/protobuf v1.3.1 // indirect 69 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 70 | github.com/golang/protobuf v1.5.2 // indirect 71 | github.com/golang/snappy v0.0.1 // indirect 72 | github.com/google/btree v1.0.0 // indirect 73 | github.com/google/go-cmp v0.5.5 // indirect 74 | github.com/google/gofuzz v1.1.0 // indirect 75 | github.com/google/uuid v1.1.2 // indirect 76 | github.com/google/wire v0.3.0 // indirect 77 | github.com/googleapis/gax-go v2.0.2+incompatible // indirect 78 | github.com/googleapis/gax-go/v2 v2.0.5 // indirect 79 | github.com/googleapis/gnostic v0.4.1 // indirect 80 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 81 | github.com/hairyhenderson/gomplate/v3 v3.6.0 // indirect 82 | github.com/hairyhenderson/toml v0.3.1-0.20191004034452-2a4f3b6160f2 // indirect 83 | github.com/hashicorp/consul/api v1.4.0 // indirect 84 | github.com/hashicorp/errwrap v1.0.0 // indirect 85 | github.com/hashicorp/go-cleanhttp v0.5.1 // indirect 86 | github.com/hashicorp/go-getter v1.4.1 // indirect 87 | github.com/hashicorp/go-hclog v0.12.0 // indirect 88 | github.com/hashicorp/go-immutable-radix v1.1.0 // indirect 89 | github.com/hashicorp/go-multierror v1.0.0 // indirect 90 | github.com/hashicorp/go-retryablehttp v0.6.4 // indirect 91 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 92 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 93 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 94 | github.com/hashicorp/go-version v1.2.1 // indirect 95 | github.com/hashicorp/golang-lru v0.5.3 // indirect 96 | github.com/hashicorp/hcl v1.0.0 // indirect 97 | github.com/hashicorp/serf v0.8.5 // indirect 98 | github.com/hashicorp/vault/api v1.0.4 // indirect 99 | github.com/hashicorp/vault/sdk v0.1.13 // indirect 100 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 101 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 102 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af // indirect 103 | github.com/joho/godotenv v1.3.0 // indirect 104 | github.com/json-iterator/go v1.1.10 // indirect 105 | github.com/jstemmer/go-junit-report v0.9.1 // indirect 106 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect 107 | github.com/kr/pretty v0.2.0 // indirect 108 | github.com/kr/text v0.2.0 // indirect 109 | github.com/mailru/easyjson v0.7.0 // indirect 110 | github.com/mattn/go-colorable v0.1.4 // indirect 111 | github.com/mattn/go-isatty v0.0.12 // indirect 112 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect 113 | github.com/mitchellh/copystructure v1.0.0 // indirect 114 | github.com/mitchellh/go-homedir v1.1.0 // indirect 115 | github.com/mitchellh/go-testing-interface v1.0.0 // indirect 116 | github.com/mitchellh/mapstructure v1.3.3 // indirect 117 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 118 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 119 | github.com/modern-go/reflect2 v1.0.1 // indirect 120 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 121 | github.com/pierrec/lz4 v2.3.0+incompatible // indirect 122 | github.com/prometheus/client_golang v1.7.1 // indirect 123 | github.com/prometheus/client_model v0.2.0 // indirect 124 | github.com/prometheus/common v0.10.0 // indirect 125 | github.com/prometheus/procfs v0.2.0 // indirect 126 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect 127 | github.com/ryanuber/go-glob v1.0.0 // indirect 128 | github.com/sergi/go-diff v1.1.0 // indirect 129 | github.com/shurcooL/githubv4 v0.0.0-20190718010115-4ba037080260 // indirect 130 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect 131 | github.com/sirupsen/logrus v1.7.0 // indirect 132 | github.com/spf13/afero v1.2.2 // indirect 133 | github.com/spf13/cobra v1.1.1 // indirect 134 | github.com/spf13/pflag v1.0.5 // indirect 135 | github.com/src-d/gcfg v1.4.0 // indirect 136 | github.com/ugorji/go/codec v1.1.7 // indirect 137 | github.com/ulikunitz/xz v0.5.5 // indirect 138 | github.com/valyala/bytebufferpool v1.0.0 // indirect 139 | github.com/valyala/fasttemplate v1.0.1 // indirect 140 | github.com/vbauerster/mpb/v5 v5.0.3 // indirect 141 | github.com/xanzy/ssh-agent v0.2.1 // indirect 142 | github.com/zealic/xignore v0.3.3 // indirect 143 | go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489 // indirect 144 | go.opencensus.io v0.22.3 // indirect 145 | go.uber.org/atomic v1.6.0 // indirect 146 | go.uber.org/multierr v1.5.0 // indirect 147 | gocloud.dev v0.18.0 // indirect 148 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 149 | golang.org/x/mod v0.4.2 // indirect 150 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 // indirect 151 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect 152 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect 153 | golang.org/x/text v0.3.5 // indirect 154 | golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect 155 | golang.org/x/tools v0.1.4 // indirect 156 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 157 | gomodules.xyz/jsonpatch/v2 v2.0.1 // indirect 158 | google.golang.org/api v0.20.0 // indirect 159 | google.golang.org/appengine v1.6.5 // indirect 160 | google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a // indirect 161 | google.golang.org/grpc v1.39.0 // indirect 162 | google.golang.org/protobuf v1.27.1 // indirect 163 | gopkg.in/flanksource/yaml.v3 v3.1.1 // indirect 164 | gopkg.in/fsnotify.v1 v1.4.7 // indirect 165 | gopkg.in/inf.v0 v0.9.1 // indirect 166 | gopkg.in/square/go-jose.v2 v2.4.0 // indirect 167 | gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect 168 | gopkg.in/src-d/go-git.v4 v4.13.1 // indirect 169 | gopkg.in/warnings.v0 v0.1.2 // indirect 170 | gopkg.in/yaml.v2 v2.3.0 // indirect 171 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 172 | honnef.co/go/tools v0.0.1-2020.1.3 // indirect 173 | k8s.io/apiextensions-apiserver v0.20.4 // indirect 174 | k8s.io/cli-runtime v0.20.4 // indirect 175 | k8s.io/klog v1.0.0 // indirect 176 | k8s.io/klog/v2 v2.4.0 // indirect 177 | k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd // indirect 178 | k8s.io/utils v0.0.0-20201110183641-67b214c5f920 // indirect 179 | sigs.k8s.io/kustomize v2.0.3+incompatible // indirect 180 | sigs.k8s.io/kustomize/kyaml v0.1.11 // indirect 181 | sigs.k8s.io/structured-merge-diff/v4 v4.0.2 // indirect 182 | ) 183 | 184 | replace ( 185 | github.com/Azure/go-autorest => github.com/Azure/go-autorest v14.2.0+incompatible 186 | github.com/go-logr/logr => github.com/go-logr/logr v0.2.1 187 | google.golang.org/genproto => google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea 188 | google.golang.org/grpc => google.golang.org/grpc v1.29.1 189 | k8s.io/client-go => k8s.io/client-go v0.20.4 190 | ) 191 | -------------------------------------------------------------------------------- /test/e2e.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "time" 11 | 12 | "github.com/flanksource/commons/console" 13 | "github.com/flanksource/commons/utils" 14 | gitv1 "github.com/flanksource/git-operator/api/v1" 15 | "github.com/flanksource/git-operator/connectors" 16 | "github.com/flanksource/git-operator/controllers" 17 | "github.com/pkg/errors" 18 | zapu "go.uber.org/zap" 19 | v1 "k8s.io/api/core/v1" 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | "k8s.io/apimachinery/pkg/runtime" 22 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 23 | "k8s.io/client-go/kubernetes" 24 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 25 | "k8s.io/client-go/tools/clientcmd" 26 | ctrl "sigs.k8s.io/controller-runtime" 27 | crdclient "sigs.k8s.io/controller-runtime/pkg/client" 28 | "sigs.k8s.io/controller-runtime/pkg/client/apiutil" 29 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 30 | ) 31 | 32 | const ( 33 | namespace = "platform-system" 34 | repository = "flanksource/git-operator-test" 35 | ) 36 | 37 | var ( 38 | k8s *kubernetes.Clientset 39 | crdK8s crdclient.Client 40 | tests = map[string]Test{ 41 | "git-operator-is-running": TestGitOperatorIsRunning, 42 | "github-gitops-api-create": TestGitopsAPICreate, 43 | "github-gitops-api-update": TestGitopsAPIOrUpdate, 44 | "github-gitops-api-delete-multiple": TestGitopsAPIDeleteMultiple, 45 | "github-gitops-api-delete": TestGitopsAPIDelete, 46 | } 47 | scheme = runtime.NewScheme() 48 | log = ctrl.Log.WithName("e2e") 49 | ) 50 | 51 | type Test func(context.Context, *console.TestResults) error 52 | 53 | func main() { 54 | var timeout *time.Duration 55 | var err error 56 | ctrl.SetLogger(zap.New(zap.UseDevMode(true), zap.Level(zapu.DebugLevel))) 57 | 58 | kubeconfig := os.Getenv("KUBECONFIG") 59 | if kubeconfig == "" { 60 | kubeconfig = os.ExpandEnv("$HOME/.kube/config") 61 | } 62 | 63 | data, err := ioutil.ReadFile(kubeconfig) 64 | if err != nil { 65 | log.Error(err, "failed to read kubeconfig") 66 | os.Exit(1) 67 | } 68 | restConfig, err := clientcmd.RESTConfigFromKubeConfig(data) 69 | if err != nil { 70 | log.Error(err, "failed to create clientset") 71 | os.Exit(1) 72 | } 73 | 74 | timeout = flag.Duration("timeout", 15*time.Minute, "Global timeout for all tests") 75 | flag.Parse() 76 | 77 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 78 | 79 | utilruntime.Must(gitv1.AddToScheme(scheme)) 80 | 81 | if err != nil { 82 | log.Error(err, "failed to create k8s config") 83 | os.Exit(1) 84 | } 85 | 86 | k8s, err = kubernetes.NewForConfig(restConfig) 87 | if err != nil { 88 | log.Error(err, "failed to create clientset") 89 | os.Exit(1) 90 | } 91 | 92 | mapper, err := apiutil.NewDynamicRESTMapper(restConfig) 93 | if err != nil { 94 | log.Error(err, "failed to create mapper") 95 | os.Exit(1) 96 | } 97 | if crdK8s, err = crdclient.New(restConfig, crdclient.Options{Scheme: scheme, Mapper: mapper}); err != nil { 98 | log.Error(err, "failed to create mapper") 99 | os.Exit(1) 100 | } 101 | 102 | test := &console.TestResults{ 103 | Writer: os.Stdout, 104 | } 105 | 106 | errors := map[string]error{} 107 | deadline, cancelFunc := context.WithTimeout(context.Background(), *timeout) 108 | defer cancelFunc() 109 | 110 | for name, t := range tests { 111 | log.Info("testing", "name", name) 112 | err := t(deadline, test) 113 | if err != nil { 114 | errors[name] = err 115 | } 116 | } 117 | 118 | if len(errors) > 0 { 119 | for _, err := range errors { 120 | log.Info(err.Error()) 121 | } 122 | os.Exit(1) 123 | } 124 | 125 | log.Info("All tests passed !!!") 126 | } 127 | 128 | func TestGitopsAPICreate(ctx context.Context, test *console.TestResults) error { 129 | git, err := connectors.NewConnector(ctx, crdK8s, k8s, log, "platform-system", "https://github.com/"+repository, &v1.LocalObjectReference{ 130 | Name: "github", 131 | }) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | body := fmt.Sprintf(` 137 | [ 138 | { 139 | "apiVersion": "acmp.corp/v1", 140 | "kind": "NamespaceRequest", 141 | "metadata": { 142 | "name": "%s" 143 | }, 144 | "spec": { 145 | "cpu": 4, 146 | "memory": 32 147 | } 148 | } 149 | ] 150 | `, getBranchName()) 151 | 152 | log.Info("json", "value", body) 153 | api := &gitv1.GitopsAPI{ 154 | Spec: gitv1.GitopsAPISpec{ 155 | GitRepository: repository, 156 | PullRequest: &gitv1.PullRequestTemplate{ 157 | Title: "Automated PR: Created new object {{.metadata.name}}", 158 | Body: "Somebody created a new PR {{.metadata.name}}", 159 | }, 160 | }, 161 | } 162 | work, title, err := controllers.CreateOrUpdateObject(ctx, log, git, api, bytes.NewReader([]byte(body)), "application/json") 163 | if err != nil { 164 | return err 165 | } 166 | _, err = controllers.CreateCommit(api, work, title) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | if err = git.Push(ctx, fmt.Sprintf("%s:%s", api.Spec.Branch, api.Spec.Base)); err != nil { 172 | return err 173 | } 174 | pr, err := git.OpenPullRequest(ctx, api.Spec.Base, api.Spec.Branch, api.Spec.PullRequest) 175 | if err != nil { 176 | return err 177 | } 178 | if pr != 0 { 179 | if err = git.ClosePullRequest(ctx, pr); err != nil { 180 | return err 181 | } 182 | } 183 | return err 184 | } 185 | 186 | func TestGitopsAPIOrUpdate(ctx context.Context, test *console.TestResults) error { 187 | git, err := connectors.NewConnector(ctx, crdK8s, k8s, log, "platform-system", "https://github.com/"+repository, &v1.LocalObjectReference{ 188 | Name: "github", 189 | }) 190 | if err != nil { 191 | return err 192 | } 193 | branchName := getBranchName() 194 | body := ` 195 | [ 196 | { 197 | "apiVersion": "v1", 198 | "data": { 199 | "some-key": "some-value", 200 | "new-key": "new-value" 201 | }, 202 | "kind": "ConfigMap", 203 | "metadata": { 204 | "name": "test-configmap", 205 | "namespace": "default" 206 | } 207 | }, 208 | { 209 | "apiVersion": "acmp.corp/v1", 210 | "kind": "NamespaceRequest", 211 | "metadata": { 212 | "name": "tenant8" 213 | }, 214 | "spec": { 215 | "cluster": "dev01", 216 | "memory": 11, 217 | "some-new-key": "test-value" 218 | } 219 | }, 220 | { 221 | "apiVersion": "v1", 222 | "data": { 223 | "some-key": "some-value" 224 | }, 225 | "kind": "ConfigMap", 226 | "metadata": { 227 | "name": "sample-configmap" 228 | } 229 | } 230 | ] 231 | ` 232 | api := &gitv1.GitopsAPI{ 233 | Spec: gitv1.GitopsAPISpec{ 234 | GitRepository: repository, 235 | Branch: branchName, 236 | SearchPath: "resources/", 237 | Kustomization: "resources/kustomization.yaml", 238 | PullRequest: &gitv1.PullRequestTemplate{ 239 | Title: "Updating/Creating multiple objects", 240 | Body: "Somebody created a new PR {{.metadata.name}}", 241 | }, 242 | }, 243 | } 244 | log.Info("json", "value", body) 245 | work, title, err := controllers.CreateOrUpdateObject(ctx, log, git, api, bytes.NewReader([]byte(body)), "application/json") 246 | if err != nil { 247 | return err 248 | } 249 | _, err = controllers.CreateCommit(api, work, title) 250 | if err != nil { 251 | return err 252 | } 253 | 254 | if err = git.Push(ctx, fmt.Sprintf("%s:%s", api.Spec.Branch, api.Spec.Base)); err != nil { 255 | return err 256 | } 257 | pr, err := git.OpenPullRequest(ctx, api.Spec.Base, api.Spec.Branch, api.Spec.PullRequest) 258 | if err != nil { 259 | return err 260 | } 261 | if pr != 0 { 262 | if err := git.ClosePullRequest(ctx, pr); err != nil { 263 | return err 264 | } 265 | } 266 | return err 267 | } 268 | 269 | func TestGitopsAPIDelete(ctx context.Context, test *console.TestResults) error { 270 | git, err := connectors.NewConnector(ctx, crdK8s, k8s, log, "platform-system", "https://github.com/"+repository, &v1.LocalObjectReference{ 271 | Name: "github", 272 | }) 273 | if err != nil { 274 | return err 275 | } 276 | branchName := getBranchName() 277 | body := ` 278 | [ 279 | { 280 | "apiVersion": "v1", 281 | "data": { 282 | "some-key": "some-value" 283 | }, 284 | "kind": "ConfigMap", 285 | "metadata": { 286 | "name": "some-configmap" 287 | } 288 | } 289 | ] 290 | ` 291 | log.Info("json", "value", body) 292 | api := &gitv1.GitopsAPI{ 293 | Spec: gitv1.GitopsAPISpec{ 294 | GitRepository: repository, 295 | Branch: branchName, 296 | SearchPath: "resources/", 297 | PullRequest: &gitv1.PullRequestTemplate{ 298 | Title: "Automated PR: Delete single object", 299 | Body: "Somebody created a new PR {{.metadata.name}}", 300 | }, 301 | }, 302 | } 303 | work, title, err := controllers.DeleteObject(ctx, log, git, api, bytes.NewReader([]byte(body)), "application/json") 304 | if err != nil { 305 | return err 306 | } 307 | _, err = controllers.CreateCommit(api, work, title) 308 | if err != nil { 309 | return err 310 | } 311 | 312 | if err = git.Push(ctx, fmt.Sprintf("%s:%s", api.Spec.Branch, api.Spec.Base)); err != nil { 313 | return err 314 | } 315 | pr, err := git.OpenPullRequest(ctx, api.Spec.Base, api.Spec.Branch, api.Spec.PullRequest) 316 | if err != nil { 317 | return err 318 | } 319 | if pr != 0 { 320 | if err := git.ClosePullRequest(ctx, pr); err != nil { 321 | return err 322 | } 323 | } 324 | return err 325 | } 326 | 327 | func TestGitopsAPIDeleteMultiple(ctx context.Context, test *console.TestResults) error { 328 | git, err := connectors.NewConnector(ctx, crdK8s, k8s, log, "platform-system", "https://github.com/"+repository, &v1.LocalObjectReference{ 329 | Name: "github", 330 | }) 331 | if err != nil { 332 | return err 333 | } 334 | branchName := getBranchName() 335 | body := ` 336 | [ 337 | { 338 | "apiVersion": "v1", 339 | "data": { 340 | "some-key": "some-value" 341 | }, 342 | "kind": "ConfigMap", 343 | "metadata": { 344 | "name": "some-configmap" 345 | } 346 | }, 347 | { 348 | "apiVersion": "acmp.corp/v1", 349 | "kind": "NamespaceRequest", 350 | "metadata": { 351 | "name": "tenant8" 352 | }, 353 | "spec": { 354 | "cluster": "dev01", 355 | "memory": 11, 356 | "some-new-key": "test-value" 357 | } 358 | } 359 | ] 360 | ` 361 | log.Info("json", "value", body) 362 | api := &gitv1.GitopsAPI{ 363 | Spec: gitv1.GitopsAPISpec{ 364 | GitRepository: repository, 365 | Branch: branchName, 366 | SearchPath: "resources/", 367 | PullRequest: &gitv1.PullRequestTemplate{ 368 | Title: "Automated PR: Delete multiple objects", 369 | Body: "Somebody created a new PR {{.metadata.name}}", 370 | }, 371 | }, 372 | } 373 | work, title, err := controllers.DeleteObject(ctx, log, git, api, bytes.NewReader([]byte(body)), "application/json") 374 | if err != nil { 375 | return err 376 | } 377 | _, err = controllers.CreateCommit(api, work, title) 378 | if err != nil { 379 | return err 380 | } 381 | 382 | if err = git.Push(ctx, fmt.Sprintf("%s:%s", api.Spec.Branch, api.Spec.Base)); err != nil { 383 | return err 384 | } 385 | pr, err := git.OpenPullRequest(ctx, api.Spec.Base, api.Spec.Branch, api.Spec.PullRequest) 386 | if err != nil { 387 | return err 388 | } 389 | if pr != 0 { 390 | if err := git.ClosePullRequest(ctx, pr); err != nil { 391 | return err 392 | } 393 | } 394 | return err 395 | } 396 | 397 | func TestGitOperatorIsRunning(ctx context.Context, test *console.TestResults) error { 398 | pods, err := k8s.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{LabelSelector: "control-plane=git-operator"}) 399 | if err != nil { 400 | test.Failf("TestGitOperatorIsRunning", "failed to list git-operator pods: %v", err) 401 | return err 402 | } 403 | if len(pods.Items) != 1 { 404 | test.Failf("TestGitOperatorIsRunning", "expected 1 pod got %d", len(pods.Items)) 405 | return errors.Errorf("Expected 1 pod got %d", len(pods.Items)) 406 | } 407 | test.Passf("TestGitOperatorIsRunning", "%s pod is running", pods.Items[0].Name) 408 | return nil 409 | } 410 | 411 | func getBranchName() string { 412 | date := time.Now().Format("20060201150405") 413 | hash := utils.RandomString(4) 414 | return fmt.Sprintf("test-%s-%s", date, hash) 415 | } 416 | -------------------------------------------------------------------------------- /controllers/gitopsapi_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Kubernetes authors. 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 | "fmt" 22 | "io" 23 | "io/ioutil" 24 | "net/http" 25 | "os" 26 | "path" 27 | "path/filepath" 28 | "strings" 29 | "time" 30 | 31 | "github.com/gosimple/slug" 32 | "github.com/labstack/gommon/random" 33 | 34 | "github.com/flanksource/kommons" 35 | "github.com/go-git/go-billy/v5" 36 | gitv5 "github.com/go-git/go-git/v5" 37 | "github.com/go-git/go-git/v5/plumbing/object" 38 | "github.com/go-logr/logr" 39 | "github.com/imdario/mergo" 40 | "github.com/labstack/echo" 41 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 42 | "k8s.io/apimachinery/pkg/runtime" 43 | "k8s.io/client-go/kubernetes" 44 | ctrl "sigs.k8s.io/controller-runtime" 45 | "sigs.k8s.io/controller-runtime/pkg/client" 46 | "sigs.k8s.io/kustomize/api/types" 47 | "sigs.k8s.io/yaml" 48 | 49 | "github.com/flanksource/commons/text" 50 | gitv1 "github.com/flanksource/git-operator/api/v1" 51 | "github.com/flanksource/git-operator/connectors" 52 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 53 | ) 54 | 55 | // GitopsAPIReconciler reconciles a GitopsAPI object 56 | type GitopsAPIReconciler struct { 57 | client.Client 58 | Clientset *kubernetes.Clientset 59 | Log logr.Logger 60 | Scheme *runtime.Scheme 61 | } 62 | 63 | // +kubebuilder:rbac:groups=git.flanksource.com,resources=gitopsapis,verbs=get;list;watch;create;update;patch;delete 64 | // +kubebuilder:rbac:groups=git.flanksource.com,resources=gitopsapis/status,verbs=get;update;patch 65 | 66 | func (r *GitopsAPIReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { 67 | _ = context.Background() 68 | _ = r.Log.WithValues("gitopsapi", req.NamespacedName) 69 | 70 | return ctrl.Result{}, nil 71 | } 72 | 73 | func serve(c echo.Context, r *GitopsAPIReconciler) error { 74 | ctx := context.Background() 75 | name := c.Param("name") 76 | namespace := c.Param("namespace") 77 | token := c.Param("token") 78 | deleteObj := strings.HasPrefix(c.Path(), "/_delete") 79 | if token == "" { 80 | token = c.QueryParam("token") 81 | } 82 | if token == "" { 83 | token = c.Request().Header.Get("Authorization") 84 | } 85 | contentType := c.Request().Header.Get("Content-Type") 86 | api := gitv1.GitopsAPI{} 87 | if err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, &api); err != nil { 88 | return c.String(http.StatusNotFound, "") 89 | } 90 | 91 | if api.Spec.TokenRef != nil { 92 | tokenValue, err := r.Clientset.CoreV1().Secrets(namespace).Get(ctx, api.Spec.TokenRef.Name, metav1.GetOptions{}) 93 | if err != nil { 94 | return c.String(http.StatusInternalServerError, err.Error()) 95 | } 96 | if token != string(tokenValue.Data["TOKEN"]) { 97 | return c.String(http.StatusForbidden, "") 98 | } 99 | } 100 | 101 | r.Log.Info("Found API", "name", name, "namespace", namespace, "repo", api.Spec.GitRepository, "secret", *api.Spec.SecretRef, "client", r.Client, "ctx", ctx) 102 | 103 | git, err := connectors.NewConnector(ctx, r.Client, r.Clientset, r.Log, namespace, api.Spec.GitRepository, api.Spec.SecretRef) 104 | if err != nil { 105 | return c.String(http.StatusInternalServerError, err.Error()) 106 | } 107 | var work *gitv5.Worktree 108 | var title, hash string 109 | var pr int 110 | if deleteObj { 111 | work, title, err = DeleteObject(ctx, r.Log, git, &api, c.Request().Body, contentType) 112 | } else { 113 | work, title, err = CreateOrUpdateObject(ctx, r.Log, git, &api, c.Request().Body, contentType) 114 | } 115 | if err != nil { 116 | r.Log.Error(err, "error updating files") 117 | return c.String(http.StatusInternalServerError, err.Error()) 118 | } 119 | hash, err = CreateCommit(&api, work, title) 120 | if err != nil { 121 | r.Log.Error(err, "error creating commit") 122 | return c.String(http.StatusInternalServerError, err.Error()) 123 | } 124 | if err = git.Push(ctx, fmt.Sprintf("%s:%s", api.Spec.Branch, api.Spec.Base)); err != nil { 125 | return c.String(http.StatusInternalServerError, err.Error()) 126 | } 127 | 128 | if api.Spec.PullRequest != nil { 129 | pr, err = git.OpenPullRequest(ctx, api.Spec.Base, api.Spec.Branch, api.Spec.PullRequest) 130 | if err != nil { 131 | return c.String(http.StatusInternalServerError, err.Error()) 132 | } 133 | } 134 | return c.String(http.StatusAccepted, fmt.Sprintf("Committed %s, PR: %d ", hash, pr)) 135 | } 136 | 137 | func GetKustomizaton(fs billy.Filesystem, path string) (*types.Kustomization, error) { 138 | kustomization := types.Kustomization{} 139 | 140 | if _, err := fs.Stat(path); err != nil { 141 | return &kustomization, nil 142 | } 143 | existing, err := fs.Open(path) 144 | if err != nil { 145 | return nil, err 146 | } 147 | existingKustomization, err := ioutil.ReadAll(existing) 148 | if err != nil { 149 | return nil, err 150 | } 151 | if err := yaml.Unmarshal(existingKustomization, &kustomization); err != nil { 152 | return nil, err 153 | } 154 | return &kustomization, nil 155 | } 156 | 157 | func CreateOrUpdateObject(ctx context.Context, logger logr.Logger, git connectors.Connector, api *gitv1.GitopsAPI, contents io.Reader, contentType string) (work *gitv5.Worktree, title string, err error) { 158 | addDefaults(api) 159 | body, err := ioutil.ReadAll(contents) 160 | if err != nil { 161 | return 162 | } 163 | body = []byte(TabToSpace(string(body))) 164 | var objs []*unstructured.Unstructured 165 | if strings.Contains(contentType, "yaml") || strings.Contains(contentType, "yml") { 166 | objs, err = kommons.GetUnstructuredObjects(body) 167 | if err != nil { 168 | return 169 | } 170 | } else { 171 | objs, err = kommons.GetUnstructuredObjectsFromJson(body) 172 | if err != nil { 173 | return 174 | } 175 | } 176 | fs, work, err := git.Clone(ctx, api.Spec.Base, api.Spec.Branch) 177 | if err != nil { 178 | return nil, "", err 179 | } 180 | var contentPaths map[string]string 181 | if api.Spec.SearchPath != "" { 182 | contentPaths, err = getContentPaths(fs.Root(), api.Spec.SearchPath) 183 | if err != nil { 184 | return nil, "", err 185 | } 186 | } 187 | title = "Add/Update " 188 | for _, obj := range objs { 189 | if err = templateAPIObject(api, obj); err != nil { 190 | return 191 | } 192 | contentPath, err := getContentPath(api, obj, contentPaths) 193 | if err != nil { 194 | return nil, "", err 195 | } 196 | if contentPath == "" { 197 | // need to create a new file with the content 198 | contentPath = filepath.Join(api.Spec.SearchPath, fmt.Sprintf("%s-%s-%s.yaml", obj.GetKind(), obj.GetNamespace(), obj.GetName())) 199 | body, err = yaml.Marshal(obj) 200 | if err != nil { 201 | return nil, "", err 202 | } 203 | } else { 204 | // file already exists performing merge 205 | body, err = performStrategicMerge(filepath.Join(fs.Root(), contentPath), obj) 206 | if err != nil { 207 | return nil, "", err 208 | } 209 | } 210 | title = title + fmt.Sprintf("%s/%s/%s ", obj.GetKind(), obj.GetNamespace(), obj.GetName()) 211 | logger.Info("Received", "name", api.GetName(), "namespace", api.GetNamespace(), "object", title) 212 | logger.Info("Saving to", "path", contentPath, "kustomization", api.Spec.Kustomization, "object", title) 213 | kustomization, err := GetKustomizaton(fs, api.Spec.Kustomization) 214 | if err != nil { 215 | return nil, "", err 216 | } 217 | relativePath := strings.Replace(contentPath, path.Dir(api.Spec.Kustomization)+"/", "", -1) 218 | if err = copy(body, contentPath, fs, work); err != nil { 219 | return nil, "", err 220 | } 221 | index := findElement(kustomization.Resources, relativePath) 222 | if index == -1 { 223 | kustomization.Resources = append(kustomization.Resources, relativePath) 224 | } 225 | existingKustomization, err := yaml.Marshal(kustomization) 226 | if err != nil { 227 | return nil, "", err 228 | } 229 | if err = copy(existingKustomization, api.Spec.Kustomization, fs, work); err != nil { 230 | return nil, "", err 231 | } 232 | } 233 | 234 | return work, title, nil 235 | } 236 | 237 | func DeleteObject(ctx context.Context, logger logr.Logger, git connectors.Connector, api *gitv1.GitopsAPI, contents io.Reader, contentType string) (work *gitv5.Worktree, title string, err error) { 238 | addDefaults(api) 239 | body, err := ioutil.ReadAll(contents) 240 | if err != nil { 241 | return 242 | } 243 | body = []byte(TabToSpace(string(body))) 244 | var objs []*unstructured.Unstructured 245 | if strings.Contains(contentType, "yaml") || strings.Contains(contentType, "yml") { 246 | objs, err = kommons.GetUnstructuredObjects(body) 247 | if err != nil { 248 | return 249 | } 250 | } else { 251 | objs, err = kommons.GetUnstructuredObjectsFromJson(body) 252 | if err != nil { 253 | return 254 | } 255 | } 256 | fs, work, err := git.Clone(ctx, api.Spec.Base, api.Spec.Branch) 257 | if err != nil { 258 | return 259 | } 260 | var contentPaths map[string]string 261 | if api.Spec.SearchPath != "" { 262 | contentPaths, err = getContentPaths(fs.Root(), api.Spec.SearchPath) 263 | if err != nil { 264 | return nil, "", err 265 | } 266 | } 267 | title = "Delete " 268 | for _, obj := range objs { 269 | if err = templateAPIObject(api, obj); err != nil { 270 | return nil, "", err 271 | } 272 | contentPath, err := getContentPath(api, obj, contentPaths) 273 | if err != nil { 274 | return nil, "", err 275 | } 276 | if contentPath == "" { 277 | return nil, "", fmt.Errorf("could not find the object %v to delete", getObjectKey(obj)) 278 | } 279 | title = title + fmt.Sprintf("%s/%s/%s ", obj.GetKind(), obj.GetNamespace(), obj.GetName()) 280 | logger.Info("Received", "name", api.GetName(), "namespace", api.GetNamespace(), "object", title) 281 | 282 | logger.Info("Saving to", "path", contentPath, "kustomization", api.Spec.Kustomization, "object", title) 283 | kustomization, err := GetKustomizaton(fs, api.Spec.Kustomization) 284 | if err != nil { 285 | return nil, "", err 286 | } 287 | relativePath := strings.Replace(contentPath, path.Dir(api.Spec.Kustomization)+"/", "", -1) 288 | body, err = deleteObjectFromFile(filepath.Join(fs.Root(), contentPath), obj) 289 | if err != nil { 290 | return nil, "", err 291 | } 292 | if err = copy(body, contentPath, fs, work); err != nil { 293 | return nil, "", err 294 | } 295 | delete, err := isFileEmpty(body) 296 | if err != nil { 297 | return nil, "", err 298 | } 299 | if delete { 300 | if err = deleteFile(contentPath, work, fs.Root()); err != nil { 301 | return nil, "", err 302 | } 303 | index := findElement(kustomization.Resources, relativePath) 304 | if index != -1 { 305 | kustomization.Resources = removeElement(kustomization.Resources, index) 306 | existingKustomization, err := yaml.Marshal(kustomization) 307 | if err != nil { 308 | return nil, "", err 309 | } 310 | if err = copy(existingKustomization, api.Spec.Kustomization, fs, work); err != nil { 311 | return nil, "", err 312 | } 313 | } 314 | } 315 | } 316 | return work, title, nil 317 | } 318 | 319 | func (r *GitopsAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { 320 | clientset, err := kubernetes.NewForConfig(mgr.GetConfig()) 321 | if err != nil { 322 | return err 323 | } 324 | 325 | r.Clientset = clientset 326 | r.Client = mgr.GetClient() 327 | if err := ctrl.NewControllerManagedBy(mgr). 328 | For(&gitv1.GitopsAPI{}). 329 | Complete(r); err != nil { 330 | return err 331 | } 332 | e := echo.New() 333 | e.POST("/:namespace/:name/:token", func(c echo.Context) error { 334 | return serve(c, r) 335 | }) 336 | e.POST("/:namespace/:name", func(c echo.Context) error { 337 | return serve(c, r) 338 | }) 339 | e.POST("/_delete/:namespace/:name", func(c echo.Context) error { 340 | return serve(c, r) 341 | }) 342 | e.POST("/_delete/:namespace/:name/:token", func(c echo.Context) error { 343 | return serve(c, r) 344 | }) 345 | go func() { 346 | e.Logger.Fatal(e.Start(":8888")) 347 | }() 348 | 349 | return nil 350 | } 351 | 352 | func addDefaults(api *gitv1.GitopsAPI) { 353 | if api.Spec.Base == "" { 354 | api.Spec.Base = "master" 355 | } 356 | if api.Spec.Branch == "" { 357 | if api.Spec.PullRequest != nil { 358 | api.Spec.Branch = slug.Make(api.Spec.PullRequest.Title) + "-" + random.String(4) 359 | } else { 360 | api.Spec.Branch = api.Spec.Base 361 | } 362 | } 363 | if api.Spec.Kustomization == "" { 364 | if api.Spec.SearchPath != "" { 365 | api.Spec.Kustomization = filepath.Join(api.Spec.SearchPath, "kustomization.yaml") 366 | } else { 367 | api.Spec.Kustomization = "kustomization.yaml" 368 | } 369 | } 370 | } 371 | 372 | func templateAPIObject(api *gitv1.GitopsAPI, obj *unstructured.Unstructured) (err error) { 373 | api.Spec.Kustomization, err = text.Template(api.Spec.Kustomization, obj.Object) 374 | if err != nil { 375 | return 376 | } 377 | api.Spec.PullRequest.Body, err = text.Template(api.Spec.PullRequest.Body, obj.Object) 378 | if err != nil { 379 | return 380 | } 381 | api.Spec.PullRequest.Title, err = text.Template(api.Spec.PullRequest.Title, obj.Object) 382 | if err != nil { 383 | return 384 | } 385 | return nil 386 | } 387 | 388 | func getContentPath(api *gitv1.GitopsAPI, obj *unstructured.Unstructured, contentPaths map[string]string) (contentPath string, err error) { 389 | if api.Spec.SearchPath != "" { 390 | contentPath = contentPaths[getObjectKey(obj)] 391 | } else { 392 | if api.Spec.Path != "" { 393 | contentPath = api.Spec.Path 394 | } 395 | } 396 | contentPath, err = text.Template(contentPath, obj.Object) 397 | if err != nil { 398 | return "", err 399 | } 400 | return contentPath, nil 401 | } 402 | 403 | func CreateCommit(api *gitv1.GitopsAPI, work *gitv5.Worktree, title string) (hash string, err error) { 404 | author := &object.Signature{ 405 | Name: api.Spec.GitUser, 406 | Email: api.Spec.GitEmail, 407 | When: time.Now(), 408 | } 409 | if author.Name == "" { 410 | author.Name = "Git Operator" 411 | } 412 | if author.Email == "" { 413 | author.Email = "noreply@git-operator" 414 | } 415 | _hash, err := work.Commit(title, &gitv5.CommitOptions{ 416 | Author: author, 417 | All: true, 418 | }) 419 | 420 | if err != nil { 421 | return 422 | } 423 | hash = _hash.String() 424 | return 425 | } 426 | 427 | func getObjectKey(obj *unstructured.Unstructured) string { 428 | return fmt.Sprintf("%s-%s-%s", obj.GetName(), obj.GetNamespace(), obj.GetKind()) 429 | } 430 | 431 | func performStrategicMerge(file string, obj *unstructured.Unstructured) (body []byte, err error) { 432 | data, err := ioutil.ReadFile(file) 433 | if err != nil { 434 | return nil, err 435 | } 436 | fileObjs, err := kommons.GetUnstructuredObjects(data) 437 | if err != nil { 438 | return 439 | } 440 | index := getObjectIndex(obj, fileObjs) 441 | oldObj, err := yaml.Marshal(fileObjs[index]) 442 | if err != nil { 443 | return nil, err 444 | } 445 | err = mergo.Merge(&fileObjs[index].Object, obj.Object, mergo.WithOverride) 446 | if err != nil { 447 | return nil, err 448 | } 449 | newObj, err := yaml.Marshal(fileObjs[index]) 450 | if err != nil { 451 | return nil, err 452 | } 453 | body = []byte(strings.Replace(string(data), string(oldObj), string(newObj), -1)) 454 | return 455 | } 456 | 457 | func deleteObjectFromFile(file string, obj *unstructured.Unstructured) (body []byte, err error) { 458 | data, err := ioutil.ReadFile(file) 459 | if err != nil { 460 | return nil, err 461 | } 462 | fileObjs, err := kommons.GetUnstructuredObjects(data) 463 | if err != nil { 464 | return 465 | } 466 | index := getObjectIndex(obj, fileObjs) 467 | oldObj, err := yaml.Marshal(fileObjs[index]) 468 | if err != nil { 469 | return nil, err 470 | } 471 | body = []byte(strings.Replace(string(data), string(oldObj), "", -1)) 472 | return 473 | } 474 | 475 | func getObjectIndex(obj *unstructured.Unstructured, fileObjs []*unstructured.Unstructured) (index int) { 476 | index = -1 477 | for i, fileObj := range fileObjs { 478 | if getObjectKey(fileObj) == getObjectKey(obj) { 479 | index = i 480 | break 481 | } 482 | } 483 | return index 484 | } 485 | 486 | func isFileEmpty(data []byte) (bool, error) { 487 | fileObjs, err := kommons.GetUnstructuredObjects(data) 488 | if err != nil { 489 | return false, err 490 | } 491 | return len(fileObjs) == 0, nil 492 | } 493 | 494 | func getContentPaths(repoRoot, searchPath string) (map[string]string, error) { 495 | contentPaths := make(map[string]string) 496 | walkPath := filepath.Join(repoRoot, searchPath) 497 | if err := filepath.Walk(walkPath, func(filePath string, info os.FileInfo, err error) error { 498 | if err != nil { 499 | return err 500 | } 501 | if info.Name() == "kustomization.yaml" || info.IsDir() { 502 | return nil 503 | } 504 | if path.Ext(filePath) == ".yaml" || path.Ext(filePath) == ".yml" { 505 | buf, err := ioutil.ReadFile(filePath) 506 | if err != nil { 507 | return err 508 | } 509 | resources, err := kommons.GetUnstructuredObjects(buf) 510 | if err != nil { 511 | return err 512 | } 513 | for _, resource := range resources { 514 | contentPaths[getObjectKey(resource)], err = filepath.Rel(repoRoot, filePath) 515 | if err != nil { 516 | return err 517 | } 518 | } 519 | } 520 | return nil 521 | }); err != nil { 522 | return nil, err 523 | } 524 | return contentPaths, nil 525 | } 526 | --------------------------------------------------------------------------------