├── .gitignore ├── CODEOWNERS ├── MAINTAINERS.md ├── internal └── pkg │ ├── argocd │ ├── diff │ │ ├── testdata │ │ │ ├── same.txt │ │ │ ├── eof.txt │ │ │ ├── allnew.txt │ │ │ ├── allold.txt │ │ │ ├── eof1.txt │ │ │ ├── eof2.txt │ │ │ ├── start.txt │ │ │ ├── dups.txt │ │ │ ├── end.txt │ │ │ ├── basic.txt │ │ │ ├── triv.txt │ │ │ └── long.txt │ │ ├── README.md │ │ └── diff_test.go │ ├── testdata │ │ ├── TestDiffLiveVsTargetObject │ │ │ ├── 1.want │ │ │ ├── 1.live │ │ │ └── 1.target │ │ ├── TestRenderDiff.md │ │ ├── TestRenderDiff.live │ │ └── TestRenderDiff.target │ └── argocd_copied_from_upstream.go │ ├── githubapi │ ├── testdata │ │ ├── custom_commit_status_invalid_template.gotmpl │ │ ├── custom_commit_status_valid_template.gotmpl │ │ ├── pr_body_multi_component.golden.md │ │ ├── pr_body.golden.md │ │ ├── output │ │ │ └── TestMarkdownGenerator │ │ │ │ ├── Concice_templeting.md │ │ │ │ ├── OutOfSync.md │ │ │ │ ├── Unhealthy.md │ │ │ │ ├── Show_Sync_from_Branch_checkbox.md │ │ │ │ ├── Temp_app_should_not_show_sync_or_unhealthy_warnings.md │ │ │ │ ├── Basic_templating.md │ │ │ │ └── Part_of_splitted_comment_.md │ │ ├── data │ │ │ └── TestMarkdownGenerator │ │ │ │ ├── OutOfSync.json │ │ │ │ ├── Unhealthy.json │ │ │ │ ├── Show_Sync_from_Branch_checkbox.json │ │ │ │ └── Temp_app_should_not_show_sync_or_unhealthy_warnings.json │ │ └── diff_comment_data_test.json │ ├── pr_metrics_test.go │ ├── webhook_proxy_test.go │ ├── github_graphql.go │ ├── pr_metrics.go │ ├── webhook_proxy.go │ └── drift_detection.go │ ├── testutils │ └── testutils.go │ ├── mocks │ ├── mocks.go │ └── argocd_settings.go │ ├── configuration │ ├── tests │ │ └── testConfigurationParsing.yaml │ ├── config_test.go │ └── config.go │ └── prometheus │ ├── prometheus_test.go │ └── prometheus.go ├── templates ├── auto-merge-comment.gotmpl ├── dry-run-pr-comment.gotmpl └── drift-pr-comment.gotmpl ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── FEATURE_REQUEST.md │ └── BUG_REPORT.md ├── workflows │ ├── stale.yml │ ├── lint.yml │ ├── docker-publish-on-comment.yml │ └── docker-publish.yml └── PULL_REQUEST_TEMPLATE.md ├── main.go ├── testdata └── TestTelefonistka │ ├── pr │ ├── live │ │ └── demo │ │ │ ├── Chart.yaml │ │ │ ├── templates │ │ │ ├── service.yaml │ │ │ ├── serviceaccount.yaml │ │ │ ├── _helpers.tpl │ │ │ └── deployment.yaml │ │ │ └── values.yaml │ ├── workspace │ │ └── demo │ │ │ ├── Chart.yaml │ │ │ ├── templates │ │ │ ├── service.yaml │ │ │ ├── serviceaccount.yaml │ │ │ ├── _helpers.tpl │ │ │ └── deployment.yaml │ │ │ └── values.yaml │ └── telefonistka.yaml │ ├── start │ ├── live │ │ └── demo │ │ │ ├── Chart.yaml │ │ │ ├── templates │ │ │ ├── service.yaml │ │ │ ├── serviceaccount.yaml │ │ │ ├── _helpers.tpl │ │ │ └── deployment.yaml │ │ │ └── values.yaml │ ├── workspace │ │ └── demo │ │ │ ├── Chart.yaml │ │ │ ├── templates │ │ │ ├── service.yaml │ │ │ ├── serviceaccount.yaml │ │ │ ├── _helpers.tpl │ │ │ └── deployment.yaml │ │ │ └── values.yaml │ └── telefonistka.yaml │ ├── argo.values.yaml │ └── additional.yaml ├── .markdownlint.json ├── CHANGELOG.md ├── Dockerfile ├── mirrord.json ├── Makefile ├── .golangci.yml ├── LICENSE ├── cmd └── telefonistka │ ├── root.go │ ├── event.go │ ├── bump-version-yaml_test.go │ ├── server.go │ ├── bump-version-regex.go │ ├── bump-version-overwrite.go │ └── bump-version-yaml.go ├── SECURITY.md ├── logging_test.go ├── docs ├── webhook_multiplexing.md ├── modeling_environments_in_gitops_repo.md ├── observability.md ├── version_bumping.md └── argocd.md ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── telefonistka_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /telefonistka 2 | vendor/ 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @commercetools/special-delivery @Oded-B 2 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # Maintainers 2 | 3 | - [Oded Ben Ozer](https://github.com/Oded-B) 4 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/same.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | hello world 3 | -- new -- 4 | hello world 5 | -- diff -- 6 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/custom_commit_status_invalid_template.gotmpl: -------------------------------------------------------------------------------- 1 | https://custom-url.com?time={{.InvalidField}} 2 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/eof.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | a 3 | b 4 | c^D 5 | -- new -- 6 | a 7 | b 8 | c^D 9 | -- diff -- 10 | -------------------------------------------------------------------------------- /templates/auto-merge-comment.gotmpl: -------------------------------------------------------------------------------- 1 | {{define "autoMerge"}} 2 | ✅ Auto merge is enabled 3 | 🚀 Merging promotion PR: #{{.prNumber}} 4 | {{ end }} 5 | 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/commercetools/telefonistka/cmd/telefonistka" 5 | ) 6 | 7 | func main() { 8 | telefonistka.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/live/demo/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: demo 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.16.0" 7 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/live/demo/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: demo 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.16.0" 7 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/allnew.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | -- new -- 3 | a 4 | b 5 | c 6 | -- diff -- 7 | diff old new 8 | --- old 9 | +++ new 10 | @@ -0,0 +1,3 @@ 11 | +a 12 | +b 13 | +c 14 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/allold.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | a 3 | b 4 | c 5 | -- new -- 6 | -- diff -- 7 | diff old new 8 | --- old 9 | +++ new 10 | @@ -1,3 +0,0 @@ 11 | -a 12 | -b 13 | -c 14 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/workspace/demo/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: demo 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.16.0" 7 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/workspace/demo/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: demo 3 | description: A Helm chart for Kubernetes 4 | type: application 5 | version: 0.1.0 6 | appVersion: "1.16.0" 7 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/custom_commit_status_valid_template.gotmpl: -------------------------------------------------------------------------------- 1 | {{ $calculated_time := .CommitTime.Add -600000000000 }}https://custom-url.com?time={{.CommitTime.UnixMilli}}&calculated_time={{$calculated_time.UnixMilli}} 2 | -------------------------------------------------------------------------------- /internal/pkg/argocd/testdata/TestDiffLiveVsTargetObject/1.want: -------------------------------------------------------------------------------- 1 | apiVersion: commercetools.io/v1alpha1 2 | kind: Bar 3 | metadata: 4 | name: example-baz-bar 5 | 6 | @@ spec.replicas @@ 7 | ! ± value change 8 | - 63 9 | + 42 10 | 11 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/eof1.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | a 3 | b 4 | c 5 | -- new -- 6 | a 7 | b 8 | c^D 9 | -- diff -- 10 | diff old new 11 | --- old 12 | +++ new 13 | @@ -1,3 +1,3 @@ 14 | a 15 | b 16 | -c 17 | +c 18 | \ No newline at end of file 19 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/eof2.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | a 3 | b 4 | c^D 5 | -- new -- 6 | a 7 | b 8 | c 9 | -- diff -- 10 | diff old new 11 | --- old 12 | +++ new 13 | @@ -1,3 +1,3 @@ 14 | a 15 | b 16 | -c 17 | \ No newline at end of file 18 | +c 19 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/pr_body_multi_component.golden.md: -------------------------------------------------------------------------------- 1 | ↘️ #1 `sourcePath1` ➡️ 2 |     `targetPath1` 3 |     ↘️ #2 `sourcePath2` ➡️ 4 |         `targetPath2` 5 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/argo.values.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | image: 3 | tag: "v2.11.13" 4 | logging: 5 | format: "json" 6 | level: "debug" 7 | crds: 8 | install: true 9 | configs: 10 | cm: 11 | users.anonymous.enabled: true 12 | rbac: 13 | policy.default: role:admin 14 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/README.md: -------------------------------------------------------------------------------- 1 | This package has been pulled from the internal 2 | [diff](https://github.com/golang/go/tree/master/src/internal/diff) package. 3 | 4 | Minor changes were done to allow a custom number of context lines, import a 5 | public `txtar` package and adhere to the local linter settings. 6 | -------------------------------------------------------------------------------- /internal/pkg/testutils/testutils.go: -------------------------------------------------------------------------------- 1 | package testutils 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Quiet suppresses logs when running go test. 11 | func Quiet() func() { 12 | log.SetOutput(io.Discard) 13 | return func() { 14 | log.SetOutput(os.Stdout) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/start.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | e 3 | pi 4 | 4 5 | 5 6 | 6 7 | 7 8 | 8 9 | 9 10 | 10 11 | -- new -- 12 | 1 13 | 2 14 | 3 15 | 4 16 | 5 17 | 6 18 | 7 19 | 8 20 | 9 21 | 10 22 | -- diff -- 23 | diff old new 24 | --- old 25 | +++ new 26 | @@ -1,5 +1,6 @@ 27 | -e 28 | -pi 29 | +1 30 | +2 31 | +3 32 | 4 33 | 5 34 | 6 35 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/telefonistka.yaml: -------------------------------------------------------------------------------- 1 | promotionPaths: 2 | - sourcePath: "workspace/" 3 | promotionPrs: 4 | - targetDescription: "Production" 5 | targetPaths: 6 | - "live/" 7 | argocd: 8 | commentDiffonPR: true 9 | autoMergeNoDiffPRs: true 10 | allowSyncfromBranchPathRegex: '.*' 11 | createTempAppObjectFromNewApps: true 12 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/dups.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | a 3 | 4 | b 5 | 6 | c 7 | 8 | d 9 | 10 | e 11 | 12 | f 13 | -- new -- 14 | a 15 | 16 | B 17 | 18 | C 19 | 20 | d 21 | 22 | e 23 | 24 | f 25 | -- diff -- 26 | diff old new 27 | --- old 28 | +++ new 29 | @@ -1,8 +1,8 @@ 30 | a 31 | $ 32 | -b 33 | - 34 | -c 35 | +B 36 | + 37 | +C 38 | $ 39 | d 40 | $ 41 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/telefonistka.yaml: -------------------------------------------------------------------------------- 1 | promotionPaths: 2 | - sourcePath: "workspace/" 3 | promotionPrs: 4 | - targetDescription: "Production" 5 | targetPaths: 6 | - "live/" 7 | argocd: 8 | commentDiffonPR: true 9 | autoMergeNoDiffPRs: true 10 | allowSyncfromBranchPathRegex: '.*' 11 | createTempAppObjectFromNewApps: true 12 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD013": { 4 | "line_length": 10000, 5 | "headings": false, 6 | "code_blocks": false, 7 | "tables": false 8 | }, 9 | "MD024": { 10 | "siblings_only": true 11 | }, 12 | "MD025": { 13 | "front_matter_title": "" 14 | }, 15 | "MD041": false, 16 | "MD034": false 17 | } 18 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/end.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | 1 3 | 2 4 | 3 5 | 4 6 | 5 7 | 6 8 | 7 9 | eight 10 | nine 11 | ten 12 | eleven 13 | -- new -- 14 | 1 15 | 2 16 | 3 17 | 4 18 | 5 19 | 6 20 | 7 21 | 8 22 | 9 23 | 10 24 | -- diff -- 25 | diff old new 26 | --- old 27 | +++ new 28 | @@ -5,7 +5,6 @@ 29 | 5 30 | 6 31 | 7 32 | -eight 33 | -nine 34 | -ten 35 | -eleven 36 | +8 37 | +9 38 | +10 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### TBA 11 | 12 | ## [0.0.1] - 2023-01-26 13 | 14 | ### Added 15 | 16 | - Initial code commit 17 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/basic.txt: -------------------------------------------------------------------------------- 1 | Example from Hunt and McIlroy, “An Algorithm for Differential File Comparison.” 2 | https://www.cs.dartmouth.edu/~doug/diff.pdf 3 | 4 | -- old -- 5 | a 6 | b 7 | c 8 | d 9 | e 10 | f 11 | g 12 | -- new -- 13 | w 14 | a 15 | b 16 | x 17 | y 18 | z 19 | e 20 | -- diff -- 21 | diff old new 22 | --- old 23 | +++ new 24 | @@ -1,7 +1,7 @@ 25 | +w 26 | a 27 | b 28 | -c 29 | -d 30 | +x 31 | +y 32 | +z 33 | e 34 | -f 35 | -g 36 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/live/demo/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "demo.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/live/demo/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "demo.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/workspace/demo/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "demo.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/workspace/demo/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "demo.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS alpine-release 2 | WORKDIR /srv 3 | COPY templates/ /srv/templates/ 4 | COPY telefonistka /usr/local/bin/ 5 | USER 1001 6 | ENTRYPOINT ["/usr/local/bin/telefonistka"] 7 | CMD ["server"] 8 | 9 | FROM scratch 10 | WORKDIR /srv 11 | COPY --from=alpine-release /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 12 | COPY templates/ /srv/templates/ 13 | COPY telefonistka /usr/local/bin/ 14 | USER 1001 15 | ENTRYPOINT ["/usr/local/bin/telefonistka"] 16 | CMD ["server"] 17 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/live/demo/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "demo.serviceAccountName" . }} 6 | labels: 7 | {{- include "demo.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /internal/pkg/argocd/testdata/TestRenderDiff.md: -------------------------------------------------------------------------------- 1 | ```diff 2 | apiVersion: commercetools.io/v1alpha1 3 | kind: Bar 4 | metadata: 5 | name: example-baz-bar 6 | 7 | @@ rbacBindings.security-audit-viewer-vault.subjects @@ 8 | ! - one list entry removed: 9 | - - name: "vault:some-team@domain.tld" 10 | - kind: Group 11 | ! + one list entry added: 12 | + - name: "vault:some-team-name@domain.tld" 13 | + kind: Group 14 | 15 | @@ spec.replicas @@ 16 | ! ± value change 17 | - 63 18 | + 42 19 | 20 | ``` 21 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/workspace/demo/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "demo.serviceAccountName" . }} 6 | labels: 7 | {{- include "demo.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/live/demo/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "demo.serviceAccountName" . }} 6 | labels: 7 | {{- include "demo.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/workspace/demo/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "demo.serviceAccountName" . }} 6 | labels: 7 | {{- include "demo.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/pr_body.golden.md: -------------------------------------------------------------------------------- 1 | ↘️ #1 `sourcePath1` ➡️ 2 |     `targetPath1` 3 |     `targetPath2` 4 |     ↘️ #2 `sourcePath2` ➡️ 5 |         `targetPath4` 6 |         ↘️ #3 `sourcePath3` ➡️ 7 |             `targetPath5` 8 |             `targetPath6` 9 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/triv.txt: -------------------------------------------------------------------------------- 1 | Another example from Hunt and McIlroy, 2 | “An Algorithm for Differential File Comparison.” 3 | https://www.cs.dartmouth.edu/~doug/diff.pdf 4 | 5 | Anchored diff gives up on finding anything, 6 | since there are no unique lines. 7 | 8 | -- old -- 9 | a 10 | b 11 | c 12 | a 13 | b 14 | b 15 | a 16 | -- new -- 17 | c 18 | a 19 | b 20 | a 21 | b 22 | c 23 | -- diff -- 24 | diff old new 25 | --- old 26 | +++ new 27 | @@ -1,7 +1,6 @@ 28 | -a 29 | -b 30 | -c 31 | -a 32 | -b 33 | -b 34 | -a 35 | +c 36 | +a 37 | +b 38 | +a 39 | +b 40 | +c 41 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/testdata/long.txt: -------------------------------------------------------------------------------- 1 | -- old -- 2 | 1 3 | 2 4 | 3 5 | 4 6 | 5 7 | 6 8 | 7 9 | 8 10 | 9 11 | 10 12 | 11 13 | 12 14 | 13 15 | 14 16 | 14½ 17 | 15 18 | 16 19 | 17 20 | 18 21 | 19 22 | 20 23 | -- new -- 24 | 1 25 | 2 26 | 3 27 | 4 28 | 5 29 | 6 30 | 8 31 | 9 32 | 10 33 | 11 34 | 12 35 | 13 36 | 14 37 | 17 38 | 18 39 | 19 40 | 20 41 | -- diff -- 42 | diff old new 43 | --- old 44 | +++ new 45 | @@ -4,7 +4,6 @@ 46 | 4 47 | 5 48 | 6 49 | -7 50 | 8 51 | 9 52 | 10 53 | @@ -12,9 +11,6 @@ 54 | 12 55 | 13 56 | 14 57 | -14½ 58 | -15 59 | -16 60 | 17 61 | 18 62 | 19 63 | -------------------------------------------------------------------------------- /mirrord.json: -------------------------------------------------------------------------------- 1 | { 2 | "target": { 3 | "path": "deployment/telefonistka", 4 | "namespace": "telefonistka" 5 | }, 6 | "agent": { 7 | "namespace": "mirrord" 8 | }, 9 | "feature": { 10 | "fs": { 11 | "mode": "read", 12 | "read_write": ".+\\.json" , 13 | "read_only": [ "^/etc/telefonistka-gh-app-creds/.*", "^/etc/telefonistka-gh-app-config/.*" ] 14 | }, 15 | "network": { 16 | "incoming": "steal", 17 | "outgoing": true 18 | } 19 | }, 20 | "operator": false, 21 | "kubeconfig": "~/.kube/config", 22 | "sip_binaries": "bash", 23 | "telemetry": true 24 | } 25 | 26 | -------------------------------------------------------------------------------- /internal/pkg/mocks/mocks.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | // This package contains generated mocks 4 | 5 | //go:generate go run go.uber.org/mock/mockgen@v0.5.0 -destination=argocd_application.go -package=mocks github.com/argoproj/argo-cd/v2/pkg/apiclient/application ApplicationServiceClient 6 | 7 | //go:generate go run go.uber.org/mock/mockgen@v0.5.0 -destination=argocd_settings.go -package=mocks github.com/argoproj/argo-cd/v2/pkg/apiclient/settings SettingsServiceClient 8 | 9 | //go:generate go run go.uber.org/mock/mockgen@v0.5.0 -destination=argocd_project.go -package=mocks github.com/argoproj/argo-cd/v2/pkg/apiclient/project ProjectServiceClient 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Makefile 3 | # 4 | # Simple makefile to build binary. 5 | # 6 | # @author Kubernetes Team 7 | # @copyright 2019 Wayfair, LLC. -- All rights reserved. 8 | 9 | VENDOR_DIR = vendor 10 | 11 | .PHONY: get-deps 12 | get-deps: $(VENDOR_DIR) 13 | 14 | $(VENDOR_DIR): 15 | go mod download 16 | 17 | .PHONY: build 18 | build: $(VENDOR_DIR) 19 | GOOS=linux CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o telefonistka . 20 | 21 | .PHONY: clean 22 | clean: 23 | rm -f telefonistka 24 | 25 | .PHONY: test 26 | test: $(VENDOR_DIR) 27 | TEMPLATES_PATH=../../../templates/ go test -v -timeout 30s ./... 28 | 29 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/additional.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: argoproj.io/v1alpha1 2 | kind: Application 3 | metadata: 4 | name: demo 5 | namespace: argocd 6 | spec: 7 | project: default 8 | source: 9 | repoURL: "{{.RepoURL}}" 10 | targetRevision: main 11 | path: live/demo 12 | destination: 13 | namespace: demo 14 | name: in-cluster 15 | syncPolicy: 16 | automated: {} 17 | syncOptions: 18 | - CreateNamespace=true 19 | - ServerSideApply=true 20 | - RespectIgnoreDifferences=true 21 | retry: 22 | limit: 3 23 | backoff: 24 | duration: 1m 25 | factor: 2 26 | maxDuration: 15m 27 | -------------------------------------------------------------------------------- /templates/dry-run-pr-comment.gotmpl: -------------------------------------------------------------------------------- 1 | {{define "dryRunMsg"}} 2 | ## Telefonistka Promotion Dry Run Message: 3 | 4 | This is the plan for opening promotion PRs: 5 | 6 | 7 | {{ range $key, $value := . }} 8 | 9 | 10 | ``` 11 | PR :{{ $value.Metadata.SourcePath }} 12 | {{- range $trgt, $src := $value.ComputedSyncPaths }} 13 | ✅ {{ $src }} ➡️ {{ $trgt }} 14 | {{- end }} 15 | {{- if $value.Metadata.PerComponentSkippedTargetPaths}} 16 | Skipped target paths: 17 | {{- range $k, $v := $value.Metadata.PerComponentSkippedTargetPaths}} 18 | 🚫 {{ $value.Metadata.SourcePath }}/{{$k}} ➡️ {{$v}} 19 | {{- end}} 20 | {{- end}} 21 | ``` 22 | 23 | {{- end }} 24 | {{ end }} 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a feature for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Problem Statement 11 | 12 | Please describe the problem to be addressed by the proposed feature. 13 | 14 | ## Proposed Solution 15 | 16 | Please describe what you envision the solution to this problem would look like. 17 | 18 | ## Alternatives Considered 19 | 20 | Please briefly describe which alternatives, if any, have been considered, including merits of alternate approaches and 21 | tradeoffs being made. 22 | 23 | ## Additional Context 24 | 25 | Please provide any other information that may be relevant. 26 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - asasalint 4 | - asciicheck 5 | - bidichk 6 | - bodyclose 7 | - containedctx 8 | - decorder 9 | - dogsled 10 | - durationcheck 11 | - errcheck 12 | - errname 13 | - copyloopvar 14 | - gci 15 | - gochecknoinits 16 | - gofmt 17 | - gofumpt 18 | - goimports 19 | - goprintffuncname 20 | - gosec 21 | - gosimple 22 | - govet 23 | - grouper 24 | - importas 25 | - ineffassign 26 | - makezero 27 | - misspell 28 | - noctx 29 | - nolintlint 30 | - nosprintfhostport 31 | - paralleltest 32 | - staticcheck 33 | - tenv 34 | - thelper 35 | - tparallel 36 | - typecheck 37 | - unconvert 38 | - unused 39 | - whitespace 40 | 41 | run: 42 | timeout: 10m 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | Please provide a description of the problem. 13 | 14 | ## Expected Behaviour 15 | 16 | Please describe what you expected would happen. 17 | 18 | ## Actual Behaviour 19 | 20 | Please describe what happened instead. 21 | 22 | ## Affected Version 23 | 24 | Please provide the version number where this issue was encountered. 25 | 26 | ## Steps to Reproduce 27 | 28 | 1. First step 29 | 1. Second step 30 | 1. etc. 31 | 32 | ## Checklist 33 | 34 | 35 | - [ ] I have read the [contributing guidelines](https://github.com/commercetools/telefonistka/blob/main/CONTRIBUTING.md) 36 | - [ ] I have verified this does not duplicate an existing issue 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: stale 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 0" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - name: 📆 mark stale PRs # Automatically marks inactive PRs as stale 15 | uses: actions/stale@v9 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | days-before-stale: 60 19 | stale-issue-label: 'stale' 20 | stale-pr-label: 'stale' 21 | stale-issue-message: 'Automatically marking issue as stale due to lack of activity' 22 | stale-pr-message: 'Automatically marking pull request as stale due to lack of activity' 23 | days-before-close: 7 24 | close-issue-message: 'Automatically closing this issue as stale' 25 | close-pr-message: 'Automatically closing this pull request as stale' 26 | -------------------------------------------------------------------------------- /internal/pkg/configuration/tests/testConfigurationParsing.yaml: -------------------------------------------------------------------------------- 1 | promotionPaths: 2 | - sourcePath: "workspace/" 3 | conditions: 4 | prHasLabels: 5 | - "some-label" 6 | autoMerge: true 7 | promotionPrs: 8 | - targetPaths: 9 | - "env/staging/us-east4/c1/" 10 | - targetPaths: 11 | - "env/staging/europe-west4/c1/" 12 | - sourcePath: "env/staging/us-east4/c1/" 13 | conditions: 14 | autoMerge: false 15 | promotionPrs: 16 | - targetPaths: 17 | - "env/prod/us-central1/c2/" 18 | - sourcePath: "env/prod/us-central1/c2/" 19 | conditions: 20 | promotionPrs: 21 | - targetPaths: 22 | - "env/prod/us-west1/c2/" 23 | - "env/prod/us-central1/c3/" 24 | 25 | promtionPrLables: 26 | - "promotion" 27 | promotionBranchNameTemplte: "promotions/{{.safeBranchName}}" 28 | promtionPrBodyTemplate: | 29 | This is a promotion of {{ .originalPrNumber }} 30 | Bla Bla 31 | -------------------------------------------------------------------------------- /templates/drift-pr-comment.gotmpl: -------------------------------------------------------------------------------- 1 | {{define "driftMsg"}} 2 | # ⚠️ Found drift between environments ⚠️ 3 | 4 | ## Intro 5 | Drift detection runs on the files in the main branch irrespective of the changes of the PR. 6 | 7 | This could happen in two scenarios: 8 | 1. A promotion that affects these components is still in progress or was cancelled before completion. 9 | This means that your automated promotion PR will **include these changes** in addition to your changes! 10 | The "Blame Links" at the bottom of this comment can be a good place to start looking for the culprit. 11 | 12 | 13 | 2. Someone made a change directly to one of the directories representing promotion targets. 14 | These change will be **overridden** by the automated promotion PRs unless changes are made to their respective branches. 15 | 16 | 17 | ## Diffs 18 | 19 | 20 | {{- range $title, $diffOutput := . }} 21 | 22 | {{ $title }} 23 | 24 |
Diff (Click to expand) 25 | 26 | {{ $diffOutput }} 27 | 28 |
29 | 30 | {{- end }} 31 | 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/live/demo/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | image: 3 | repository: nginx 4 | pullPolicy: IfNotPresent 5 | tag: "" 6 | imagePullSecrets: [] 7 | nameOverride: "" 8 | fullnameOverride: "" 9 | serviceAccount: 10 | create: true 11 | automount: true 12 | annotations: {} 13 | name: "" 14 | podAnnotations: {} 15 | podLabels: {} 16 | podSecurityContext: {} 17 | securityContext: {} 18 | service: 19 | type: ClusterIP 20 | port: 80 21 | ingress: 22 | enabled: false 23 | className: "" 24 | annotations: {} 25 | hosts: 26 | - host: chart-example.local 27 | paths: 28 | - path: / 29 | pathType: ImplementationSpecific 30 | tls: [] 31 | resources: {} 32 | livenessProbe: 33 | httpGet: 34 | path: / 35 | port: http 36 | readinessProbe: 37 | httpGet: 38 | path: / 39 | port: http 40 | autoscaling: 41 | enabled: false 42 | minReplicas: 1 43 | maxReplicas: 100 44 | targetCPUUtilizationPercentage: 80 45 | volumes: [] 46 | volumeMounts: [] 47 | nodeSelector: {} 48 | tolerations: [] 49 | affinity: {} 50 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/live/demo/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | image: 3 | repository: nginx 4 | pullPolicy: IfNotPresent 5 | tag: "" 6 | imagePullSecrets: [] 7 | nameOverride: "" 8 | fullnameOverride: "" 9 | serviceAccount: 10 | create: true 11 | automount: true 12 | annotations: {} 13 | name: "" 14 | podAnnotations: {} 15 | podLabels: {} 16 | podSecurityContext: {} 17 | securityContext: {} 18 | service: 19 | type: ClusterIP 20 | port: 80 21 | ingress: 22 | enabled: false 23 | className: "" 24 | annotations: {} 25 | hosts: 26 | - host: chart-example.local 27 | paths: 28 | - path: / 29 | pathType: ImplementationSpecific 30 | tls: [] 31 | resources: {} 32 | livenessProbe: 33 | httpGet: 34 | path: / 35 | port: http 36 | readinessProbe: 37 | httpGet: 38 | path: / 39 | port: http 40 | autoscaling: 41 | enabled: false 42 | minReplicas: 1 43 | maxReplicas: 100 44 | targetCPUUtilizationPercentage: 80 45 | volumes: [] 46 | volumeMounts: [] 47 | nodeSelector: {} 48 | tolerations: [] 49 | affinity: {} 50 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/workspace/demo/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | image: 3 | repository: nginx 4 | pullPolicy: IfNotPresent 5 | tag: "1.28.0" 6 | imagePullSecrets: [] 7 | nameOverride: "" 8 | fullnameOverride: "" 9 | serviceAccount: 10 | create: true 11 | automount: true 12 | annotations: {} 13 | name: "" 14 | podAnnotations: {} 15 | podLabels: {} 16 | podSecurityContext: {} 17 | securityContext: {} 18 | service: 19 | type: ClusterIP 20 | port: 80 21 | ingress: 22 | enabled: false 23 | className: "" 24 | annotations: {} 25 | hosts: 26 | - host: chart-example.local 27 | paths: 28 | - path: / 29 | pathType: ImplementationSpecific 30 | tls: [] 31 | resources: {} 32 | livenessProbe: 33 | httpGet: 34 | path: / 35 | port: http 36 | readinessProbe: 37 | httpGet: 38 | path: / 39 | port: http 40 | autoscaling: 41 | enabled: false 42 | minReplicas: 1 43 | maxReplicas: 100 44 | targetCPUUtilizationPercentage: 80 45 | volumes: [] 46 | volumeMounts: [] 47 | nodeSelector: {} 48 | tolerations: [] 49 | affinity: {} 50 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/workspace/demo/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | image: 3 | repository: nginx 4 | pullPolicy: IfNotPresent 5 | tag: "" 6 | imagePullSecrets: [] 7 | nameOverride: "" 8 | fullnameOverride: "" 9 | serviceAccount: 10 | create: true 11 | automount: true 12 | annotations: {} 13 | name: "" 14 | podAnnotations: {} 15 | podLabels: {} 16 | podSecurityContext: {} 17 | securityContext: {} 18 | service: 19 | type: ClusterIP 20 | port: 80 21 | ingress: 22 | enabled: false 23 | className: "" 24 | annotations: {} 25 | hosts: 26 | - host: chart-example.local 27 | paths: 28 | - path: / 29 | pathType: ImplementationSpecific 30 | tls: [] 31 | resources: {} 32 | livenessProbe: 33 | httpGet: 34 | path: / 35 | port: http 36 | readinessProbe: 37 | httpGet: 38 | path: / 39 | port: http 40 | autoscaling: 41 | enabled: false 42 | minReplicas: 1 43 | maxReplicas: 100 44 | targetCPUUtilizationPercentage: 80 45 | volumes: [] 46 | volumeMounts: [] 47 | nodeSelector: {} 48 | tolerations: [] 49 | affinity: {} 50 | -------------------------------------------------------------------------------- /internal/pkg/argocd/testdata/TestDiffLiveVsTargetObject/1.live: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "commercetools.io/v1alpha1", 3 | "kind": "Bar", 4 | "metadata": { 5 | "generation": 46, 6 | "labels": { 7 | "argocd.argoproj.io/instance": "foobar-plg-gcp-eu-west1-v1" 8 | }, 9 | "managedFields": [ 10 | { 11 | "apiVersion": "commercetools.io/v1alpha1", 12 | "fieldsType": "FieldsV1", 13 | "fieldsV1": { 14 | "f:metadata": { 15 | "f:labels": { 16 | "f:argocd.argoproj.io/instance": {} 17 | } 18 | }, 19 | "f:spec": { 20 | "f:deploymentName": {}, 21 | "f:replicas": {} 22 | } 23 | }, 24 | "manager": "argocd-controller", 25 | "operation": "Apply", 26 | "time": "2024-08-30T15:08:52Z" 27 | } 28 | ], 29 | "name": "example-baz-bar", 30 | "namespace": "gitops-demo-ns2", 31 | "resourceVersion": "2168525640", 32 | "uid": "f845fd72-d6d9-48f2-b0f2-2def6807deb8" 33 | }, 34 | "spec": { 35 | "deploymentName": "example-baz-bar", 36 | "replicas": 63 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/pkg/argocd/testdata/TestDiffLiveVsTargetObject/1.target: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "commercetools.io/v1alpha1", 3 | "kind": "Bar", 4 | "metadata": { 5 | "generation": 46, 6 | "labels": { 7 | "argocd.argoproj.io/instance": "foobar-plg-gcp-eu-west1-v1" 8 | }, 9 | "managedFields": [ 10 | { 11 | "apiVersion": "commercetools.io/v1alpha1", 12 | "fieldsType": "FieldsV1", 13 | "fieldsV1": { 14 | "f:metadata": { 15 | "f:labels": { 16 | "f:argocd.argoproj.io/instance": {} 17 | } 18 | }, 19 | "f:spec": { 20 | "f:deploymentName": {}, 21 | "f:replicas": {} 22 | } 23 | }, 24 | "manager": "argocd-controller", 25 | "operation": "Apply", 26 | "time": "2024-08-30T15:08:52Z" 27 | } 28 | ], 29 | "name": "example-baz-bar", 30 | "namespace": "gitops-demo-ns2", 31 | "resourceVersion": "2168525640", 32 | "uid": "f845fd72-d6d9-48f2-b0f2-2def6807deb8" 33 | }, 34 | "spec": { 35 | "deploymentName": "example-baz-bar", 36 | "replicas": 42 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Wayfair Tech – Incubator 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please provide a meaningful description of what this change will do, or is for. Bonus points for including links to related issues, other PRs, or technical references. 4 | 5 | Note that by _not_ including a description, you are asking reviewers to do extra work to understand the context of this change, which may lead to your PR taking much longer to review, or result in it not being reviewed at all. 6 | 7 | ## Type of Change 8 | 9 | - [ ] Bug Fix 10 | - [ ] New Feature 11 | - [ ] Breaking Change 12 | - [ ] Refactor 13 | - [ ] Documentation 14 | - [ ] Other (please describe) 15 | 16 | ## Checklist 17 | 18 | 19 | - [ ] I have read the [contributing guidelines](https://github.com/commercetools/telefonistka/blob/main/CONTRIBUTING.md) 20 | - [ ] Existing issues have been referenced (where applicable) 21 | - [ ] I have verified this change is not present in other open pull requests 22 | - [ ] Functionality is documented 23 | - [ ] All code style checks pass 24 | - [ ] New code contribution is covered by automated tests 25 | - [ ] All new and existing tests pass 26 | -------------------------------------------------------------------------------- /internal/pkg/argocd/diff/diff_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package diff 6 | 7 | import ( 8 | "bytes" 9 | "path/filepath" 10 | "testing" 11 | 12 | "golang.org/x/tools/txtar" 13 | ) 14 | 15 | func clean(text []byte) []byte { 16 | text = bytes.ReplaceAll(text, []byte("$\n"), []byte("\n")) 17 | text = bytes.TrimSuffix(text, []byte("^D\n")) 18 | return text 19 | } 20 | 21 | func TestDiff(t *testing.T) { 22 | t.Parallel() 23 | files, _ := filepath.Glob("testdata/*.txt") 24 | if len(files) == 0 { 25 | t.Fatalf("no testdata") 26 | } 27 | 28 | for _, file := range files { 29 | t.Run(filepath.Base(file), func(t *testing.T) { 30 | t.Parallel() 31 | a, err := txtar.ParseFile(file) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | if len(a.Files) != 3 || a.Files[2].Name != "diff" { 36 | t.Fatalf("%s: want three files, third named \"diff\"", file) 37 | } 38 | diffs := Diff(3, a.Files[0].Name, clean(a.Files[0].Data), a.Files[1].Name, clean(a.Files[1].Data)) 39 | want := clean(a.Files[2].Data) 40 | if !bytes.Equal(diffs, want) { 41 | t.Fatalf("%s: have:\n%s\nwant:\n%s\n%s", file, 42 | diffs, want, Diff(3, "have", diffs, "want", want)) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/telefonistka/root.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var rootCmd = &cobra.Command{ 12 | Use: "telefonistka", 13 | Version: "0.0.0", 14 | Short: "telefonistka - Safe and Controlled GitOps Promotion Across Environments/Failure-Domains", 15 | Long: `Telefonistka is a Github webhook server/CLI tool that facilitates change promotion across environments/failure domains in Infrastructure as Code GitOps repos 16 | 17 | see https://github.com/commercetools/telefonistka`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | }, 20 | } 21 | 22 | func Execute() { 23 | switch getEnv("LOG_LEVEL", "info") { 24 | case "debug": 25 | log.SetLevel(log.DebugLevel) 26 | log.SetReportCaller(true) 27 | case "info": 28 | log.SetLevel(log.InfoLevel) 29 | case "warn": 30 | log.SetLevel(log.WarnLevel) 31 | case "error": 32 | log.SetLevel(log.ErrorLevel) 33 | case "fatal": 34 | log.SetLevel(log.FatalLevel) 35 | case "panic": 36 | log.SetLevel(log.PanicLevel) 37 | } 38 | 39 | log.SetFormatter(&log.TextFormatter{ 40 | DisableColors: false, 41 | // ForceColors: true, 42 | FullTimestamp: true, 43 | }) // TimestampFormat 44 | if err := rootCmd.Execute(); err != nil { 45 | fmt.Fprintf(os.Stderr, "Whoops. There was an error while executing your CLI '%s'", err) 46 | os.Exit(1) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/pkg/argocd/testdata/TestRenderDiff.live: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "commercetools.io/v1alpha1", 3 | "kind": "Bar", 4 | "rbacBindings": [ 5 | { 6 | "clusterRoleBindings": [ 7 | { 8 | "clusterRole": "view" 9 | } 10 | ], 11 | "name": "security-audit-viewer-vault", 12 | "subjects": [ 13 | { 14 | "kind": "Group", 15 | "name": "vault:some-team@domain.tld" 16 | } 17 | ] 18 | } 19 | ], 20 | "metadata": { 21 | "generation": 46, 22 | "labels": { 23 | "argocd.argoproj.io/instance": "foobar-plg-gcp-eu-west1-v1" 24 | }, 25 | "managedFields": [ 26 | { 27 | "apiVersion": "commercetools.io/v1alpha1", 28 | "fieldsType": "FieldsV1", 29 | "fieldsV1": { 30 | "f:metadata": { 31 | "f:labels": { 32 | "f:argocd.argoproj.io/instance": {} 33 | } 34 | }, 35 | "f:spec": { 36 | "f:deploymentName": {}, 37 | "f:replicas": {} 38 | } 39 | }, 40 | "manager": "argocd-controller", 41 | "operation": "Apply", 42 | "time": "2024-08-30T15:08:52Z" 43 | } 44 | ], 45 | "name": "example-baz-bar", 46 | "namespace": "gitops-demo-ns2", 47 | "resourceVersion": "2168525640", 48 | "uid": "f845fd72-d6d9-48f2-b0f2-2def6807deb8" 49 | }, 50 | "spec": { 51 | "deploymentName": "example-baz-bar", 52 | "replicas": 63 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/pkg/argocd/testdata/TestRenderDiff.target: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "commercetools.io/v1alpha1", 3 | "kind": "Bar", 4 | "rbacBindings": [ 5 | { 6 | "clusterRoleBindings": [ 7 | { 8 | "clusterRole": "view" 9 | } 10 | ], 11 | "name": "security-audit-viewer-vault", 12 | "subjects": [ 13 | { 14 | "kind": "Group", 15 | "name": "vault:some-team-name@domain.tld" 16 | } 17 | ] 18 | } 19 | ], 20 | "metadata": { 21 | "generation": 46, 22 | "labels": { 23 | "argocd.argoproj.io/instance": "foobar-plg-gcp-eu-west1-v1" 24 | }, 25 | "managedFields": [ 26 | { 27 | "apiVersion": "commercetools.io/v1alpha1", 28 | "fieldsType": "FieldsV1", 29 | "fieldsV1": { 30 | "f:metadata": { 31 | "f:labels": { 32 | "f:argocd.argoproj.io/instance": {} 33 | } 34 | }, 35 | "f:spec": { 36 | "f:deploymentName": {}, 37 | "f:replicas": {} 38 | } 39 | }, 40 | "manager": "argocd-controller", 41 | "operation": "Apply", 42 | "time": "2024-08-30T15:08:52Z" 43 | } 44 | ], 45 | "name": "example-baz-bar", 46 | "namespace": "gitops-demo-ns2", 47 | "resourceVersion": "2168525640", 48 | "uid": "f845fd72-d6d9-48f2-b0f2-2def6807deb8" 49 | }, 50 | "spec": { 51 | "deploymentName": "example-baz-bar", 52 | "replicas": 42 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /cmd/telefonistka/event.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/commercetools/telefonistka/internal/pkg/githubapi" 7 | lru "github.com/hashicorp/golang-lru/v2" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // This is still(https://github.com/spf13/cobra/issues/1862) the documented way to use cobra 12 | func init() { //nolint:gochecknoinits 13 | var eventType string 14 | var eventFilePath string 15 | eventCmd := &cobra.Command{ 16 | Use: "event", 17 | Short: "Handles a GitHub event based on event JSON file", 18 | Long: "Handles a GitHub event based on event JSON file.\nThis operation mode was was built with GitHub Actions in mind", 19 | Args: cobra.ExactArgs(0), 20 | Run: func(cmd *cobra.Command, args []string) { 21 | event(eventType, eventFilePath) 22 | }, 23 | } 24 | eventCmd.Flags().StringVarP(&eventType, "type", "t", getEnv("GITHUB_EVENT_NAME", ""), "Event type, defaults to GITHUB_EVENT_NAME env var") 25 | eventCmd.Flags().StringVarP(&eventFilePath, "file", "f", getEnv("GITHUB_EVENT_PATH", ""), "File path for event JSON, defaults to GITHUB_EVENT_PATH env var") 26 | rootCmd.AddCommand(eventCmd) 27 | } 28 | 29 | func event(eventType string, eventFilePath string) { 30 | mainGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 31 | prApproverGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 32 | githubapi.ReciveEventFile(eventType, eventFilePath, mainGhClientCache, prApproverGhClientCache) 33 | } 34 | 35 | func getEnv(key, fallback string) string { 36 | if value, ok := os.LookupEnv(key); ok { 37 | return value 38 | } 39 | return fallback 40 | } 41 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/Concice_templeting.md: -------------------------------------------------------------------------------- 1 | Diff of ArgoCD applications (concise view, full diff didn't fit GH comment): 2 | 3 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 4 | 5 |
ArgoCD Diff(Click to expand): 6 | 7 | ```diff 8 | 9 | /Service/ssllabs-exporter 10 | /Deployment/ssllabs-exporter 11 | 12 | 13 | ``` 14 | 15 |
16 | 17 | **[temp-ssllab-test-plg-aws-eu-central1-v2](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v2/special-delivery/ssllab-test/ssllab-test` 18 | 19 |
ArgoCD Diff(Click to expand): 20 | 21 | ```diff 22 | 23 | /Service/ssllabs-exporter 24 | /Deployment/ssllabs-exporter 25 | 26 | 27 | ``` 28 | 29 |
30 | 31 | **[temp-ssllab-test-plg-aws-eu-central1-v3](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v3/special-delivery/ssllab-test/ssllab-test` 32 | 33 |
ArgoCD Diff(Click to expand): 34 | 35 | ```diff 36 | 37 | /Service/ssllabs-exporter 38 | /Deployment/ssllabs-exporter 39 | 40 | 41 | ``` 42 | 43 |
44 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policies and Procedures 2 | 3 | This document outlines security procedures and general policies for the 4 | Telefonistka project. 5 | 6 | - [Reporting a Bug](#reporting-a-bug) 7 | - [Disclosure Policy](#disclosure-policy) 8 | - [Comments on this Policy](#comments-on-this-policy) 9 | 10 | ## Reporting a Bug 11 | 12 | The Telefonistka team and community take all security bugs in 13 | Telefonistka seriously. Thank you for improving the security of 14 | Telefonistka. We appreciate your efforts and responsible disclosure and 15 | will make every effort to acknowledge your contributions. 16 | 17 | Report security bugs by emailing `OpenSource@wayfair.com`. 18 | 19 | The lead maintainer will acknowledge your email within 48 hours, and will send a 20 | more detailed response within 48 hours indicating the next steps in handling 21 | your report. After the initial reply to your report, the security team will 22 | endeavor to keep you informed of the progress towards a fix and full 23 | announcement, and may ask for additional information or guidance. 24 | 25 | ## Disclosure Policy 26 | 27 | When the security team receives a security bug report, they will assign it to a 28 | primary handler. This person will coordinate the fix and release process, 29 | involving the following steps: 30 | 31 | - Confirm the problem and determine the affected versions. 32 | - Audit code to find any potential similar problems. 33 | - Prepare fixes for all releases still under maintenance. These fixes will be 34 | released as quickly as possible. 35 | 36 | ## Comments on this Policy 37 | 38 | If you have suggestions on how this process could be improved please submit a 39 | pull request. 40 | -------------------------------------------------------------------------------- /cmd/telefonistka/bump-version-yaml_test.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestUpdateYaml(t *testing.T) { 8 | t.Parallel() 9 | 10 | tests := []struct { 11 | name string 12 | yamlContent string 13 | address string 14 | value string 15 | want string 16 | }{ 17 | { 18 | name: "Test simple", 19 | yamlContent: ` 20 | tag: "16.1" 21 | `, 22 | address: `.tag`, 23 | value: "16.2", 24 | want: ` 25 | tag: "16.2" 26 | `, 27 | }, 28 | { 29 | name: "Test nested", 30 | yamlContent: ` 31 | image: 32 | repository: "postgres" 33 | tag: "16.1" 34 | `, 35 | address: `.image.tag`, 36 | value: "16.2", 37 | want: ` 38 | image: 39 | repository: "postgres" 40 | tag: "16.2" 41 | `, 42 | }, 43 | { 44 | name: "Test nested select", 45 | yamlContent: ` 46 | db: 47 | - name: "postgres" 48 | image: 49 | repository: "postgres" 50 | tag: "16.1" 51 | `, 52 | address: `.db.[] | select(.name == "postgres").image.tag`, 53 | value: "16.2", 54 | want: ` 55 | db: 56 | - name: "postgres" 57 | image: 58 | repository: "postgres" 59 | tag: "16.2" 60 | `, 61 | }, 62 | { 63 | name: "Test add missing", 64 | yamlContent: ` 65 | image: 66 | repository: "postgres" 67 | `, 68 | address: `.image.tag`, 69 | value: "16.2", 70 | want: ` 71 | image: 72 | repository: "postgres" 73 | tag: "16.2" 74 | `, 75 | }, 76 | } 77 | 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | t.Parallel() 81 | got, err := updateYaml(tt.yamlContent, tt.address, tt.value) 82 | if err != nil { 83 | t.Errorf("updateYaml() error = %v", err) 84 | return 85 | } 86 | if got != tt.want { 87 | t.Errorf("updateYaml() got = %v, want %v", got, tt.want) 88 | } 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /internal/pkg/configuration/config_test.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/go-test/deep" 8 | ) 9 | 10 | func TestConfigurationParse(t *testing.T) { 11 | t.Parallel() 12 | 13 | configurationFileContent, _ := os.ReadFile("tests/testConfigurationParsing.yaml") 14 | 15 | config, err := ParseConfigFromYaml(string(configurationFileContent)) 16 | if err != nil { 17 | t.Fatalf("config parsing failed: err=%s", err) 18 | } 19 | 20 | if config.PromotionPaths == nil { 21 | t.Fatalf("config is missing PromotionPaths, %v", config.PromotionPaths) 22 | } 23 | 24 | expectedConfig := &Config{ 25 | PromotionPaths: []PromotionPath{ 26 | { 27 | SourcePath: "workspace/", 28 | Conditions: Condition{ 29 | PrHasLabels: []string{ 30 | "some-label", 31 | }, 32 | AutoMerge: true, 33 | }, 34 | PromotionPrs: []PromotionPr{ 35 | { 36 | TargetPaths: []string{ 37 | "env/staging/us-east4/c1/", 38 | }, 39 | }, 40 | { 41 | TargetPaths: []string{ 42 | "env/staging/europe-west4/c1/", 43 | }, 44 | }, 45 | }, 46 | }, 47 | { 48 | SourcePath: "env/staging/us-east4/c1/", 49 | Conditions: Condition{ 50 | AutoMerge: false, 51 | }, 52 | PromotionPrs: []PromotionPr{ 53 | { 54 | TargetPaths: []string{ 55 | "env/prod/us-central1/c2/", 56 | }, 57 | }, 58 | }, 59 | }, 60 | { 61 | SourcePath: "env/prod/us-central1/c2/", 62 | Conditions: Condition{ 63 | AutoMerge: false, 64 | }, 65 | PromotionPrs: []PromotionPr{ 66 | { 67 | TargetPaths: []string{ 68 | "env/prod/us-west1/c2/", 69 | "env/prod/us-central1/c3/", 70 | }, 71 | }, 72 | }, 73 | }, 74 | }, 75 | } 76 | 77 | if diff := deep.Equal(expectedConfig, config); diff != nil { 78 | t.Error(diff) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: # Rebuild any PRs and main branch changes 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | markdown: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: ⬇️ lint markdown files # Lints all markdown (.md) files 20 | uses: avto-dev/markdown-lint@v1 21 | with: 22 | config: '.markdownlint.json' 23 | args: '*.md docs/*.md .github/**/*.md' 24 | golangci: 25 | name: golangci-lint 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-go@v5 30 | with: 31 | go-version-file: go.mod 32 | - run: make get-deps 33 | - run: go mod tidy -diff 34 | - name: golangci-lint 35 | uses: golangci/golangci-lint-action@v6 36 | with: 37 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 38 | version: v1.64.7 39 | 40 | # Optional: working directory, useful for monorepos 41 | # working-directory: somedir 42 | 43 | # Optional: golangci-lint command line arguments. 44 | # args: --issues-exit-code=0 45 | 46 | # Optional: show only new issues if it's a pull request. The default value is `false`. 47 | # only-new-issues: true 48 | 49 | # Optional: if set to true then the all caching functionality will be complete disabled, 50 | # takes precedence over all other caching options. 51 | # skip-cache: true 52 | 53 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 54 | # skip-pkg-cache: true 55 | 56 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 57 | # skip-build-cache: true 58 | 59 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/live/demo/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "demo.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "demo.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "demo.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "demo.labels" -}} 37 | helm.sh/chart: {{ include "demo.chart" . }} 38 | {{ include "demo.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "demo.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "demo.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "demo.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "demo.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/live/demo/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "demo.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "demo.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "demo.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "demo.labels" -}} 37 | helm.sh/chart: {{ include "demo.chart" . }} 38 | {{ include "demo.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "demo.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "demo.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "demo.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "demo.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/workspace/demo/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "demo.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "demo.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "demo.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "demo.labels" -}} 37 | helm.sh/chart: {{ include "demo.chart" . }} 38 | {{ include "demo.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "demo.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "demo.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "demo.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "demo.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/workspace/demo/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "demo.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "demo.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "demo.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "demo.labels" -}} 37 | helm.sh/chart: {{ include "demo.chart" . }} 38 | {{ include "demo.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "demo.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "demo.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "demo.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "demo.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/OutOfSync.md: -------------------------------------------------------------------------------- 1 | Diff of ArgoCD applications: 2 | 3 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 4 | > [!WARNING] 5 | > The ArgoCD app sync status is currently OutOdSync 6 | 7 |
ArgoCD Diff(Click to expand): 8 | 9 | ```diff 10 | 11 | /Service/ssllabs-exporter: 12 | --- old-lorem-ipsum.yaml 13 | +++ new-lorem-ipsum.yaml 14 | @@ -11,7 +11,7 @@ 15 | consectetur: 16 | adipiscing: "elit" 17 | sed: 18 | - - "do eiusmod" 19 | + - "do eiusmod tempor" 20 | - "tempor incididunt" 21 | - "ut labore" 22 | et: 23 | @@ -17,7 +17,7 @@ 24 | enim: "ad minim veniam" 25 | quis: "nostrud exercitation" 26 | ullamco: 27 | - laboris: "nisi ut aliquip" 28 | + laboris: "nisi ut aliquip ex" 29 | ex: 30 | ea: "commodo consequat" 31 | duis: 32 | @@ -31,7 +31,7 @@ 33 | excepteur: 34 | sint: "occaecat cupidatat non proident" 35 | sunt: 36 | - in: "culpa qui officia" 37 | + in: "culpa qui officia deserunt" 38 | deserunt: "mollit anim id est laborum" 39 | /Deployment/ssllabs-exporter: 40 | --- old-lorem-ipsum.yaml 41 | +++ new-lorem-ipsum.yaml 42 | @@ -11,7 +11,7 @@ 43 | consectetur: 44 | adipiscing: "elit" 45 | sed: 46 | - - "do eiusmod" 47 | + - "do eiusmod tempor" 48 | - "tempor incididunt" 49 | - "ut labore" 50 | et: 51 | @@ -17,7 +17,7 @@ 52 | enim: "ad minim veniam" 53 | quis: "nostrud exercitation" 54 | ullamco: 55 | - laboris: "nisi ut aliquip" 56 | + laboris: "nisi ut aliquip ex" 57 | ex: 58 | ea: "commodo consequat" 59 | duis: 60 | @@ -31,7 +31,7 @@ 61 | excepteur: 62 | sint: "occaecat cupidatat non proident" 63 | sunt: 64 | - in: "culpa qui officia" 65 | + in: "culpa qui officia deserunt" 66 | deserunt: "mollit anim id est laborum" 67 | 68 | 69 | ``` 70 | 71 |
72 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/Unhealthy.md: -------------------------------------------------------------------------------- 1 | Diff of ArgoCD applications: 2 | 3 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 4 | > [!CAUTION] 5 | > The ArgoCD app health status is currently Unhealthy 6 | 7 |
ArgoCD Diff(Click to expand): 8 | 9 | ```diff 10 | 11 | /Service/ssllabs-exporter: 12 | --- old-lorem-ipsum.yaml 13 | +++ new-lorem-ipsum.yaml 14 | @@ -11,7 +11,7 @@ 15 | consectetur: 16 | adipiscing: "elit" 17 | sed: 18 | - - "do eiusmod" 19 | + - "do eiusmod tempor" 20 | - "tempor incididunt" 21 | - "ut labore" 22 | et: 23 | @@ -17,7 +17,7 @@ 24 | enim: "ad minim veniam" 25 | quis: "nostrud exercitation" 26 | ullamco: 27 | - laboris: "nisi ut aliquip" 28 | + laboris: "nisi ut aliquip ex" 29 | ex: 30 | ea: "commodo consequat" 31 | duis: 32 | @@ -31,7 +31,7 @@ 33 | excepteur: 34 | sint: "occaecat cupidatat non proident" 35 | sunt: 36 | - in: "culpa qui officia" 37 | + in: "culpa qui officia deserunt" 38 | deserunt: "mollit anim id est laborum" 39 | /Deployment/ssllabs-exporter: 40 | --- old-lorem-ipsum.yaml 41 | +++ new-lorem-ipsum.yaml 42 | @@ -11,7 +11,7 @@ 43 | consectetur: 44 | adipiscing: "elit" 45 | sed: 46 | - - "do eiusmod" 47 | + - "do eiusmod tempor" 48 | - "tempor incididunt" 49 | - "ut labore" 50 | et: 51 | @@ -17,7 +17,7 @@ 52 | enim: "ad minim veniam" 53 | quis: "nostrud exercitation" 54 | ullamco: 55 | - laboris: "nisi ut aliquip" 56 | + laboris: "nisi ut aliquip ex" 57 | ex: 58 | ea: "commodo consequat" 59 | duis: 60 | @@ -31,7 +31,7 @@ 61 | excepteur: 62 | sint: "occaecat cupidatat non proident" 63 | sunt: 64 | - in: "culpa qui officia" 65 | + in: "culpa qui officia deserunt" 66 | deserunt: "mollit anim id est laborum" 67 | 68 | 69 | ``` 70 | 71 |
72 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/Show_Sync_from_Branch_checkbox.md: -------------------------------------------------------------------------------- 1 | Diff of ArgoCD applications: 2 | 3 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 4 | 5 |
ArgoCD Diff(Click to expand): 6 | 7 | ```diff 8 | 9 | /Service/ssllabs-exporter: 10 | --- old-lorem-ipsum.yaml 11 | +++ new-lorem-ipsum.yaml 12 | @@ -11,7 +11,7 @@ 13 | consectetur: 14 | adipiscing: "elit" 15 | sed: 16 | - - "do eiusmod" 17 | + - "do eiusmod tempor" 18 | - "tempor incididunt" 19 | - "ut labore" 20 | et: 21 | @@ -17,7 +17,7 @@ 22 | enim: "ad minim veniam" 23 | quis: "nostrud exercitation" 24 | ullamco: 25 | - laboris: "nisi ut aliquip" 26 | + laboris: "nisi ut aliquip ex" 27 | ex: 28 | ea: "commodo consequat" 29 | duis: 30 | @@ -31,7 +31,7 @@ 31 | excepteur: 32 | sint: "occaecat cupidatat non proident" 33 | sunt: 34 | - in: "culpa qui officia" 35 | + in: "culpa qui officia deserunt" 36 | deserunt: "mollit anim id est laborum" 37 | /Deployment/ssllabs-exporter: 38 | --- old-lorem-ipsum.yaml 39 | +++ new-lorem-ipsum.yaml 40 | @@ -11,7 +11,7 @@ 41 | consectetur: 42 | adipiscing: "elit" 43 | sed: 44 | - - "do eiusmod" 45 | + - "do eiusmod tempor" 46 | - "tempor incididunt" 47 | - "ut labore" 48 | et: 49 | @@ -17,7 +17,7 @@ 50 | enim: "ad minim veniam" 51 | quis: "nostrud exercitation" 52 | ullamco: 53 | - laboris: "nisi ut aliquip" 54 | + laboris: "nisi ut aliquip ex" 55 | ex: 56 | ea: "commodo consequat" 57 | duis: 58 | @@ -31,7 +31,7 @@ 59 | excepteur: 60 | sint: "occaecat cupidatat non proident" 61 | sunt: 62 | - in: "culpa qui officia" 63 | + in: "culpa qui officia deserunt" 64 | deserunt: "mollit anim id est laborum" 65 | 66 | 67 | ``` 68 | 69 |
70 | 71 | - [ ] Set ArgoCD apps Target Revision to `promotions/284-simulate-error-5c159151017f` -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/Temp_app_should_not_show_sync_or_unhealthy_warnings.md: -------------------------------------------------------------------------------- 1 | Diff of ArgoCD applications: 2 | 3 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 4 | > [!NOTE] 5 | > Telefonistka has temporarily created an ArgoCD app object to render manifest previews. 6 | > Please be aware: 7 | > * The app will only appear in the ArgoCD UI for a few seconds. 8 | 9 |
ArgoCD Diff(Click to expand): 10 | 11 | ```diff 12 | 13 | /Service/ssllabs-exporter: 14 | --- old-lorem-ipsum.yaml 15 | +++ new-lorem-ipsum.yaml 16 | @@ -11,7 +11,7 @@ 17 | consectetur: 18 | adipiscing: "elit" 19 | sed: 20 | - - "do eiusmod" 21 | + - "do eiusmod tempor" 22 | - "tempor incididunt" 23 | - "ut labore" 24 | et: 25 | @@ -17,7 +17,7 @@ 26 | enim: "ad minim veniam" 27 | quis: "nostrud exercitation" 28 | ullamco: 29 | - laboris: "nisi ut aliquip" 30 | + laboris: "nisi ut aliquip ex" 31 | ex: 32 | ea: "commodo consequat" 33 | duis: 34 | @@ -31,7 +31,7 @@ 35 | excepteur: 36 | sint: "occaecat cupidatat non proident" 37 | sunt: 38 | - in: "culpa qui officia" 39 | + in: "culpa qui officia deserunt" 40 | deserunt: "mollit anim id est laborum" 41 | /Deployment/ssllabs-exporter: 42 | --- old-lorem-ipsum.yaml 43 | +++ new-lorem-ipsum.yaml 44 | @@ -11,7 +11,7 @@ 45 | consectetur: 46 | adipiscing: "elit" 47 | sed: 48 | - - "do eiusmod" 49 | + - "do eiusmod tempor" 50 | - "tempor incididunt" 51 | - "ut labore" 52 | et: 53 | @@ -17,7 +17,7 @@ 54 | enim: "ad minim veniam" 55 | quis: "nostrud exercitation" 56 | ullamco: 57 | - laboris: "nisi ut aliquip" 58 | + laboris: "nisi ut aliquip ex" 59 | ex: 60 | ea: "commodo consequat" 61 | duis: 62 | @@ -31,7 +31,7 @@ 63 | excepteur: 64 | sint: "occaecat cupidatat non proident" 65 | sunt: 66 | - in: "culpa qui officia" 67 | + in: "culpa qui officia deserunt" 68 | deserunt: "mollit anim id est laborum" 69 | 70 | 71 | ``` 72 | 73 |
74 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/pr_metrics_test.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/google/go-github/v62/github" 8 | ) 9 | 10 | func TestIsPrStalePending(t *testing.T) { 11 | t.Parallel() 12 | timeToDefineStale := 15 * time.Minute 13 | 14 | currentTime := time.Now() 15 | tests := map[string]struct { 16 | input github.CombinedStatus 17 | result bool 18 | }{ 19 | "All Success": { 20 | input: github.CombinedStatus{ 21 | Statuses: []*github.RepoStatus{ 22 | { 23 | State: github.String("success"), 24 | Context: github.String("telefonistka"), 25 | UpdatedAt: &github.Timestamp{ 26 | Time: currentTime.Add(-10 * time.Minute), 27 | }, 28 | }, 29 | { 30 | State: github.String("success"), 31 | Context: github.String("circleci"), 32 | UpdatedAt: &github.Timestamp{ 33 | Time: currentTime.Add(-10 * time.Minute), 34 | }, 35 | }, 36 | { 37 | State: github.String("success"), 38 | Context: github.String("foobar"), 39 | UpdatedAt: &github.Timestamp{ 40 | Time: currentTime.Add(-10 * time.Minute), 41 | }, 42 | }, 43 | }, 44 | }, 45 | result: false, 46 | }, 47 | "Pending but not stale": { 48 | input: github.CombinedStatus{ 49 | Statuses: []*github.RepoStatus{ 50 | { 51 | State: github.String("pending"), 52 | Context: github.String("telefonistka"), 53 | UpdatedAt: &github.Timestamp{ 54 | Time: currentTime.Add(-1 * time.Minute), 55 | }, 56 | }, 57 | }, 58 | }, 59 | result: false, 60 | }, 61 | 62 | "Pending and stale": { 63 | input: github.CombinedStatus{ 64 | Statuses: []*github.RepoStatus{ 65 | { 66 | State: github.String("pending"), 67 | Context: github.String("telefonistka"), 68 | UpdatedAt: &github.Timestamp{ 69 | Time: currentTime.Add(-20 * time.Minute), 70 | }, 71 | }, 72 | }, 73 | }, 74 | result: true, 75 | }, 76 | } 77 | 78 | for name, tc := range tests { 79 | t.Run(name, func(t *testing.T) { 80 | t.Parallel() 81 | result := isPrStalePending(&tc.input, timeToDefineStale) 82 | if result != tc.result { 83 | t.Errorf("(%s)Expected %v, got %v", name, tc.result, result) 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/pkg/configuration/config.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | yaml "gopkg.in/yaml.v2" 5 | ) 6 | 7 | type WebhookEndpointRegex struct { 8 | Expression string `yaml:"expression"` 9 | Replacements []string `yaml:"replacements"` 10 | } 11 | 12 | type ComponentConfig struct { 13 | PromotionTargetAllowList []string `yaml:"promotionTargetAllowList"` 14 | PromotionTargetBlockList []string `yaml:"promotionTargetBlockList"` 15 | DisableArgoCDDiff bool `yaml:"disableArgoCDDiff"` 16 | } 17 | 18 | type Condition struct { 19 | PrHasLabels []string `yaml:"prHasLabels"` 20 | AutoMerge bool `yaml:"autoMerge"` 21 | } 22 | 23 | type PromotionPr struct { 24 | TargetDescription string `yaml:"targetDescription"` 25 | TargetPaths []string `yaml:"targetPaths"` 26 | } 27 | 28 | type PromotionPath struct { 29 | Conditions Condition `yaml:"conditions"` 30 | ComponentPathExtraDepth int `yaml:"componentPathExtraDepth"` 31 | SourcePath string `yaml:"sourcePath"` 32 | PromotionPrs []PromotionPr `yaml:"promotionPrs"` 33 | } 34 | 35 | type Config struct { 36 | // What paths trigger promotion to which paths 37 | PromotionPaths []PromotionPath `yaml:"promotionPaths"` 38 | 39 | // Generic configuration 40 | PromtionPrLables []string `yaml:"promtionPRlables"` 41 | DryRunMode bool `yaml:"dryRunMode"` 42 | AutoApprovePromotionPrs bool `yaml:"autoApprovePromotionPrs"` 43 | ToggleCommitStatus map[string]string `yaml:"toggleCommitStatus"` 44 | WebhookEndpointRegexs []WebhookEndpointRegex `yaml:"webhookEndpointRegexs"` 45 | WhProxtSkipTLSVerifyUpstream bool `yaml:"whProxtSkipTLSVerifyUpstream"` 46 | Argocd ArgocdConfig `yaml:"argocd"` 47 | } 48 | 49 | type ArgocdConfig struct { 50 | CommentDiffonPR bool `yaml:"commentDiffonPR"` 51 | AutoMergeNoDiffPRs bool `yaml:"autoMergeNoDiffPRs"` 52 | AllowSyncfromBranchPathRegex string `yaml:"allowSyncfromBranchPathRegex"` 53 | UseSHALabelForAppDiscovery bool `yaml:"useSHALabelForAppDiscovery"` 54 | CreateTempAppObjectFroNewApps bool `yaml:"createTempAppObjectFromNewApps"` 55 | } 56 | 57 | func ParseConfigFromYaml(y string) (*Config, error) { 58 | config := &Config{} 59 | 60 | err := yaml.Unmarshal([]byte(y), config) 61 | 62 | return config, err 63 | } 64 | -------------------------------------------------------------------------------- /logging_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "sigs.k8s.io/kind/pkg/log" 7 | ) 8 | 9 | func helmLogFunc(t *testing.T) func(format string, values ...interface{}) { 10 | t.Helper() 11 | return func(format string, v ...interface{}) { 12 | t.Helper() 13 | t.Logf(format, v...) 14 | } 15 | } 16 | 17 | // testLogger implements a logger to use when running kind in a test. 18 | type testLogger struct{ *testing.T } 19 | 20 | // Write implements io.Writer and logs the data using the test instance. 21 | func (t testLogger) Write(b []byte) (int, error) { 22 | t.Helper() 23 | t.Log(string(b)) 24 | return len(b), nil 25 | } 26 | 27 | // Warn implements log.Logger and logs the message using the test instance. 28 | func (t testLogger) Warn(message string) { 29 | t.Helper() 30 | t.Log(message) 31 | } 32 | 33 | // Warnf implements log.Logger and logs the message using the test instance. 34 | func (t testLogger) Warnf(format string, args ...interface{}) { 35 | t.Helper() 36 | t.Logf(format, args...) 37 | } 38 | 39 | // Error implements log.Logger and logs the message using the test instance. 40 | func (t testLogger) Error(message string) { 41 | t.Helper() 42 | t.Log(message) 43 | } 44 | 45 | // Errorf implements log.Logger and logs the message using the test instance. 46 | func (t testLogger) Errorf(format string, args ...interface{}) { 47 | t.Helper() 48 | if t.T != nil { 49 | t.T.Errorf(format, args...) 50 | } 51 | } 52 | 53 | // V implements log.Logger and returns an InfoLogger for the specified verbosity level. 54 | func (t testLogger) V(log.Level) log.InfoLogger { return &t } 55 | 56 | // Info implements log.InfoLogger and logs the message using the test instance. 57 | func (t testLogger) Info(message string) { 58 | t.Helper() 59 | t.Log(message) 60 | } 61 | 62 | // Infof implements log.InfoLogger and logs the message using the test instance. 63 | func (t testLogger) Infof(format string, args ...interface{}) { 64 | t.Helper() 65 | t.Logf(format, args...) 66 | } 67 | 68 | // Enabled implements log.InfoLogger and always return true. 69 | func (t testLogger) Enabled() bool { return true } 70 | 71 | // ioLogger implements io.Writer and logs written bytes using t.Logf. 72 | // Unlike testLogger, which is designed for structured logging in tests, 73 | // ioLogger is focused on handling raw byte streams. 74 | type ioLogger struct { 75 | *testing.T 76 | } 77 | 78 | // Implements io.Writer but logs written bytes to t.Logf. 79 | func (t ioLogger) Write(b []byte) (int, error) { 80 | t.Helper() 81 | t.Log(string(b)) 82 | return len(b), nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/webhook_proxy_test.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | // @Title 4 | // @Description 5 | // @Author 6 | // @Update 7 | import ( 8 | "sort" 9 | "testing" 10 | 11 | cfg "github.com/commercetools/telefonistka/internal/pkg/configuration" 12 | "github.com/go-test/deep" 13 | "github.com/google/go-github/v62/github" 14 | ) 15 | 16 | func TestGenerateListOfEndpoints(t *testing.T) { 17 | t.Parallel() 18 | config := &cfg.Config{ 19 | WebhookEndpointRegexs: []cfg.WebhookEndpointRegex{ 20 | { 21 | Expression: `^workspace\/[^/]*\/.*`, 22 | Replacements: []string{ 23 | `https://blabla.com/webhook`, 24 | }, 25 | }, 26 | { 27 | Expression: `^clusters\/([^/]*)\/([^/]*)\/([^/]*)\/.*`, 28 | Replacements: []string{ 29 | `https://ingress-a-${1}-${2}-${3}.example.com/webhook`, 30 | `https://ingress-b-${1}-${2}-${3}.example.com/webhook`, 31 | }, 32 | }, 33 | }, 34 | } 35 | listOfFiles := []string{ 36 | "workspace/csi-verify/values/global.yaml", 37 | "clusters/sdeprod/dsm1/c1/csi-verify/values/global.yaml", 38 | } 39 | 40 | endpoints := generateListOfEndpoints(listOfFiles, config) 41 | expectedEndpoints := []string{ 42 | "https://blabla.com/webhook", 43 | "https://ingress-a-sdeprod-dsm1-c1.example.com/webhook", 44 | "https://ingress-b-sdeprod-dsm1-c1.example.com/webhook", 45 | } 46 | 47 | sort.Strings(endpoints) 48 | sort.Strings(expectedEndpoints) 49 | if diff := deep.Equal(endpoints, expectedEndpoints); diff != nil { 50 | t.Error(diff) 51 | } 52 | } 53 | 54 | func TestGenerateListOfChangedFiles(t *testing.T) { 55 | t.Parallel() 56 | eventPayload := &github.PushEvent{ 57 | Commits: []*github.HeadCommit{ 58 | { 59 | Added: []string{ 60 | "workspace/csi-verify/values/global-new.yaml", 61 | }, 62 | Removed: []string{ 63 | "workspace/csi-verify/values/global-old.yaml", 64 | }, 65 | SHA: github.String("000001"), 66 | }, 67 | { 68 | Modified: []string{ 69 | "clusters/sdeprod/dsm1/c1/csi-verify/values/global.yaml", 70 | }, 71 | SHA: github.String("000002"), 72 | }, 73 | }, 74 | } 75 | 76 | listOfFiles := generateListOfChangedFiles(eventPayload) 77 | expectedListOfFiles := []string{ 78 | "workspace/csi-verify/values/global-new.yaml", 79 | "workspace/csi-verify/values/global-old.yaml", 80 | "clusters/sdeprod/dsm1/c1/csi-verify/values/global.yaml", 81 | } 82 | 83 | sort.Strings(listOfFiles) 84 | sort.Strings(expectedListOfFiles) 85 | 86 | if diff := deep.Equal(listOfFiles, expectedListOfFiles); diff != nil { 87 | t.Error(diff) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/data/TestMarkdownGenerator/OutOfSync.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "DiffOfChangedComponents": [ 4 | { 5 | "ComponentPath": "clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test", 6 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v1", 7 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 8 | "DiffElements": [ 9 | { 10 | "ObjectGroup": "", 11 | "ObjectName": "ssllabs-exporter", 12 | "ObjectKind": "Service", 13 | "ObjectNamespace": "", 14 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 15 | }, 16 | { 17 | "ObjectGroup": "apps", 18 | "ObjectName": "ssllabs-exporter", 19 | "ObjectKind": "Deployment", 20 | "ObjectNamespace": "", 21 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 22 | } 23 | ], 24 | "HasDiff": true, 25 | "DiffError": null, 26 | "AppWasTemporarilyCreated": false, 27 | "ArgoCdAppHealthStatus": "Healthy", 28 | "ArgoCdAppSyncStatus": "OutOdSync", 29 | "ArgoCdAppAutoSyncEnabled": true 30 | } 31 | ], 32 | "DisplaySyncBranchCheckBox": false, 33 | "BranchName": "promotions/284-simulate-error-5c159151017f" 34 | } 35 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/data/TestMarkdownGenerator/Unhealthy.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "DiffOfChangedComponents": [ 4 | { 5 | "ComponentPath": "clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test", 6 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v1", 7 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 8 | "DiffElements": [ 9 | { 10 | "ObjectGroup": "", 11 | "ObjectName": "ssllabs-exporter", 12 | "ObjectKind": "Service", 13 | "ObjectNamespace": "", 14 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 15 | }, 16 | { 17 | "ObjectGroup": "apps", 18 | "ObjectName": "ssllabs-exporter", 19 | "ObjectKind": "Deployment", 20 | "ObjectNamespace": "", 21 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 22 | } 23 | ], 24 | "HasDiff": true, 25 | "DiffError": null, 26 | "AppWasTemporarilyCreated": false, 27 | "ArgoCdAppHealthStatus": "Unhealthy", 28 | "ArgoCdAppSyncStatus": "Synced", 29 | "ArgoCdAppAutoSyncEnabled": true 30 | } 31 | ], 32 | "DisplaySyncBranchCheckBox": false, 33 | "BranchName": "promotions/284-simulate-error-5c159151017f" 34 | } 35 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/data/TestMarkdownGenerator/Show_Sync_from_Branch_checkbox.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "DiffOfChangedComponents": [ 4 | { 5 | "ComponentPath": "clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test", 6 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v1", 7 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 8 | "DiffElements": [ 9 | { 10 | "ObjectGroup": "", 11 | "ObjectName": "ssllabs-exporter", 12 | "ObjectKind": "Service", 13 | "ObjectNamespace": "", 14 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 15 | }, 16 | { 17 | "ObjectGroup": "apps", 18 | "ObjectName": "ssllabs-exporter", 19 | "ObjectKind": "Deployment", 20 | "ObjectNamespace": "", 21 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 22 | } 23 | ], 24 | "HasDiff": true, 25 | "DiffError": null, 26 | "AppWasTemporarilyCreated": false, 27 | "ArgoCdAppHealthStatus": "Healthy", 28 | "ArgoCdAppSyncStatus": "Synced", 29 | "ArgoCdAppAutoSyncEnabled": true 30 | } 31 | ], 32 | "DisplaySyncBranchCheckBox": true, 33 | "BranchName": "promotions/284-simulate-error-5c159151017f" 34 | } 35 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/data/TestMarkdownGenerator/Temp_app_should_not_show_sync_or_unhealthy_warnings.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "DiffOfChangedComponents": [ 4 | { 5 | "ComponentPath": "clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test", 6 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v1", 7 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 8 | "DiffElements": [ 9 | { 10 | "ObjectGroup": "", 11 | "ObjectName": "ssllabs-exporter", 12 | "ObjectKind": "Service", 13 | "ObjectNamespace": "", 14 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 15 | }, 16 | { 17 | "ObjectGroup": "apps", 18 | "ObjectName": "ssllabs-exporter", 19 | "ObjectKind": "Deployment", 20 | "ObjectNamespace": "", 21 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 22 | } 23 | ], 24 | "HasDiff": true, 25 | "DiffError": null, 26 | "AppWasTemporarilyCreated": true, 27 | "ArgoCdAppHealthStatus": "", 28 | "ArgoCdAppSyncStatus": "", 29 | "ArgoCdAppAutoSyncEnabled": true 30 | } 31 | ], 32 | "DisplaySyncBranchCheckBox": false, 33 | "BranchName": "promotions/284-simulate-error-5c159151017f" 34 | } 35 | -------------------------------------------------------------------------------- /docs/webhook_multiplexing.md: -------------------------------------------------------------------------------- 1 | # GitHub Push events fanout/multiplexing 2 | 3 | GitOps operators like ArgoCD can listen for GitHub webhooks to ensure short delays in their reconciliation loop. 4 | 5 | But in some scenarios the number of needed webhooks endpoint exceed the maximum supported by GitHub(think 10 cluster each with in-cluster ArgoCD server and ArgoCD applicationSet controller). 6 | Additionally, configuring said webhooks manually is time consuming and error prone. 7 | 8 | Telefonistka can forward these HTTP requests to multiple endpoint and can even filter or dynamically choose the endpoint URL based on the file changed in the Commit. 9 | Assuming Telefonistka is deployed as a GitHub Application, this also ease the setup process as the webhook setting(event types, URL, secret) is already a part of the application configuration. 10 | 11 | This configuration example will forward github push events that include changes in `workspace/` dir to the lab ArgoCD server and  applicationset controllers webhook servers and will forward event  that touches `clusters/`to URLs generated with regex, base of first 3 directory elements after `clusters/` 12 | 13 | ```yaml 14 | webhookEndpointRegexs: 15 | - expression: "^workspace/[^/]*/.*" 16 | replacements: 17 | - "https://kube-argocd-c1.service.lab.example.com/api/webhook" 18 | - "https://kube-argocd-applicationset-c1.service.lab.example.com/api/webhook" 19 | - "https://example.com" 20 | - expression: "^clusters/([^/]*)/([^/]*)/([^/]*)/.*" 21 | replacements: 22 | - "https://kube-argocd-${3}.${1}.service.{2}.example.com/api/webhook" 23 | - "https://kube-argocd-applicationset-${2}.service.${1}.example.com/api/webhook" 24 | 25 | ``` 26 | 27 | Telefonistka checks the regex per each file affected by a commit, but stops after the first expression match(per file). 28 | 29 | So ordering of the `webhookEndpointRegexs` elements is significant. 30 | 31 | This simpeller configuration will and push event to 7 hardcoded servers 32 | 33 | ```yaml 34 | webhookEndpointRegexs: 35 | - expression: "^.*$" 36 | replacements: 37 | - "https://argocd-server1.example.com/api/webhook" 38 | - "https://argocd-server2.example.com/api/webhook" 39 | - "https://argocd-server3.example.com/api/webhook" 40 | - "https://argocd-server4.example.com/api/webhook" 41 | - "https://argocd-server5.example.com/api/webhook" 42 | - "https://argocd-server6.example.com/api/webhook" 43 | - "https://argocd-server6.example.com/api/webhook" 44 | ``` 45 | 46 | > [!IMPORTANT] 47 | > When configuring ingress you can (and should!) use IP filltering based on GitHubs list of IPs for outbound webhooks (see [here](https://api.github.com/meta)). 48 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/live/demo/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "demo.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "demo.labels" . | nindent 8 }} 22 | {{- with .Values.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "demo.serviceAccountName" . }} 31 | {{- with .Values.podSecurityContext }} 32 | securityContext: 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | containers: 36 | - name: {{ .Chart.Name }} 37 | {{- with .Values.securityContext }} 38 | securityContext: 39 | {{- toYaml . | nindent 12 }} 40 | {{- end }} 41 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 42 | imagePullPolicy: {{ .Values.image.pullPolicy }} 43 | ports: 44 | - name: http 45 | containerPort: {{ .Values.service.port }} 46 | protocol: TCP 47 | {{- with .Values.livenessProbe }} 48 | livenessProbe: 49 | {{- toYaml . | nindent 12 }} 50 | {{- end }} 51 | {{- with .Values.readinessProbe }} 52 | readinessProbe: 53 | {{- toYaml . | nindent 12 }} 54 | {{- end }} 55 | {{- with .Values.resources }} 56 | resources: 57 | {{- toYaml . | nindent 12 }} 58 | {{- end }} 59 | {{- with .Values.volumeMounts }} 60 | volumeMounts: 61 | {{- toYaml . | nindent 12 }} 62 | {{- end }} 63 | {{- with .Values.volumes }} 64 | volumes: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | {{- with .Values.nodeSelector }} 68 | nodeSelector: 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} 71 | {{- with .Values.affinity }} 72 | affinity: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.tolerations }} 76 | tolerations: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/live/demo/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "demo.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "demo.labels" . | nindent 8 }} 22 | {{- with .Values.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "demo.serviceAccountName" . }} 31 | {{- with .Values.podSecurityContext }} 32 | securityContext: 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | containers: 36 | - name: {{ .Chart.Name }} 37 | {{- with .Values.securityContext }} 38 | securityContext: 39 | {{- toYaml . | nindent 12 }} 40 | {{- end }} 41 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 42 | imagePullPolicy: {{ .Values.image.pullPolicy }} 43 | ports: 44 | - name: http 45 | containerPort: {{ .Values.service.port }} 46 | protocol: TCP 47 | {{- with .Values.livenessProbe }} 48 | livenessProbe: 49 | {{- toYaml . | nindent 12 }} 50 | {{- end }} 51 | {{- with .Values.readinessProbe }} 52 | readinessProbe: 53 | {{- toYaml . | nindent 12 }} 54 | {{- end }} 55 | {{- with .Values.resources }} 56 | resources: 57 | {{- toYaml . | nindent 12 }} 58 | {{- end }} 59 | {{- with .Values.volumeMounts }} 60 | volumeMounts: 61 | {{- toYaml . | nindent 12 }} 62 | {{- end }} 63 | {{- with .Values.volumes }} 64 | volumes: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | {{- with .Values.nodeSelector }} 68 | nodeSelector: 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} 71 | {{- with .Values.affinity }} 72 | affinity: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.tolerations }} 76 | tolerations: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/pr/workspace/demo/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "demo.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "demo.labels" . | nindent 8 }} 22 | {{- with .Values.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "demo.serviceAccountName" . }} 31 | {{- with .Values.podSecurityContext }} 32 | securityContext: 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | containers: 36 | - name: {{ .Chart.Name }} 37 | {{- with .Values.securityContext }} 38 | securityContext: 39 | {{- toYaml . | nindent 12 }} 40 | {{- end }} 41 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 42 | imagePullPolicy: {{ .Values.image.pullPolicy }} 43 | ports: 44 | - name: http 45 | containerPort: {{ .Values.service.port }} 46 | protocol: TCP 47 | {{- with .Values.livenessProbe }} 48 | livenessProbe: 49 | {{- toYaml . | nindent 12 }} 50 | {{- end }} 51 | {{- with .Values.readinessProbe }} 52 | readinessProbe: 53 | {{- toYaml . | nindent 12 }} 54 | {{- end }} 55 | {{- with .Values.resources }} 56 | resources: 57 | {{- toYaml . | nindent 12 }} 58 | {{- end }} 59 | {{- with .Values.volumeMounts }} 60 | volumeMounts: 61 | {{- toYaml . | nindent 12 }} 62 | {{- end }} 63 | {{- with .Values.volumes }} 64 | volumes: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | {{- with .Values.nodeSelector }} 68 | nodeSelector: 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} 71 | {{- with .Values.affinity }} 72 | affinity: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.tolerations }} 76 | tolerations: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | -------------------------------------------------------------------------------- /testdata/TestTelefonistka/start/workspace/demo/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "demo.fullname" . }} 5 | labels: 6 | {{- include "demo.labels" . | nindent 4 }} 7 | spec: 8 | {{- if not .Values.autoscaling.enabled }} 9 | replicas: {{ .Values.replicaCount }} 10 | {{- end }} 11 | selector: 12 | matchLabels: 13 | {{- include "demo.selectorLabels" . | nindent 6 }} 14 | template: 15 | metadata: 16 | {{- with .Values.podAnnotations }} 17 | annotations: 18 | {{- toYaml . | nindent 8 }} 19 | {{- end }} 20 | labels: 21 | {{- include "demo.labels" . | nindent 8 }} 22 | {{- with .Values.podLabels }} 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | spec: 26 | {{- with .Values.imagePullSecrets }} 27 | imagePullSecrets: 28 | {{- toYaml . | nindent 8 }} 29 | {{- end }} 30 | serviceAccountName: {{ include "demo.serviceAccountName" . }} 31 | {{- with .Values.podSecurityContext }} 32 | securityContext: 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | containers: 36 | - name: {{ .Chart.Name }} 37 | {{- with .Values.securityContext }} 38 | securityContext: 39 | {{- toYaml . | nindent 12 }} 40 | {{- end }} 41 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 42 | imagePullPolicy: {{ .Values.image.pullPolicy }} 43 | ports: 44 | - name: http 45 | containerPort: {{ .Values.service.port }} 46 | protocol: TCP 47 | {{- with .Values.livenessProbe }} 48 | livenessProbe: 49 | {{- toYaml . | nindent 12 }} 50 | {{- end }} 51 | {{- with .Values.readinessProbe }} 52 | readinessProbe: 53 | {{- toYaml . | nindent 12 }} 54 | {{- end }} 55 | {{- with .Values.resources }} 56 | resources: 57 | {{- toYaml . | nindent 12 }} 58 | {{- end }} 59 | {{- with .Values.volumeMounts }} 60 | volumeMounts: 61 | {{- toYaml . | nindent 12 }} 62 | {{- end }} 63 | {{- with .Values.volumes }} 64 | volumes: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | {{- with .Values.nodeSelector }} 68 | nodeSelector: 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} 71 | {{- with .Values.affinity }} 72 | affinity: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.tolerations }} 76 | tolerations: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | -------------------------------------------------------------------------------- /cmd/telefonistka/server.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "time" 7 | 8 | "github.com/alexliesenfeld/health" 9 | "github.com/commercetools/telefonistka/internal/pkg/githubapi" 10 | lru "github.com/hashicorp/golang-lru/v2" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | log "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func getCrucialEnv(key string) string { 17 | if value, ok := os.LookupEnv(key); ok { 18 | return value 19 | } 20 | log.Fatalf("%s environment variable is required", key) 21 | os.Exit(3) 22 | return "" 23 | } 24 | 25 | var serveCmd = &cobra.Command{ 26 | Use: "server", 27 | Short: "Runs the web server that listens to GitHub webhooks", 28 | Args: cobra.ExactArgs(0), 29 | Run: func(cmd *cobra.Command, args []string) { 30 | serve() 31 | }, 32 | } 33 | 34 | // This is still(https://github.com/spf13/cobra/issues/1862) the documented way to use cobra 35 | func init() { //nolint:gochecknoinits 36 | rootCmd.AddCommand(serveCmd) 37 | } 38 | 39 | func handleWebhook(githubWebhookSecret []byte, mainGhClientCache *lru.Cache[string, githubapi.GhClientPair], prApproverGhClientCache *lru.Cache[string, githubapi.GhClientPair]) func(http.ResponseWriter, *http.Request) { 40 | return func(w http.ResponseWriter, r *http.Request) { 41 | err := githubapi.ReciveWebhook(r, mainGhClientCache, prApproverGhClientCache, githubWebhookSecret) 42 | if err != nil { 43 | log.Errorf("error handling webhook: %v", err) 44 | http.Error(w, "Internal server error", http.StatusInternalServerError) 45 | return 46 | } 47 | w.WriteHeader(http.StatusOK) 48 | } 49 | } 50 | 51 | func serve() { 52 | githubWebhookSecret := []byte(getCrucialEnv("GITHUB_WEBHOOK_SECRET")) 53 | livenessChecker := health.NewChecker() // No checks for the moment, other then the http server availability 54 | readinessChecker := health.NewChecker() 55 | 56 | // mainGhClientCache := map[string]githubapi.GhClientPair{} //GH apps use a per-account/org client 57 | mainGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 58 | prApproverGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 59 | 60 | go githubapi.MainGhMetricsLoop(mainGhClientCache) 61 | 62 | mux := http.NewServeMux() 63 | mux.HandleFunc("/webhook", handleWebhook(githubWebhookSecret, mainGhClientCache, prApproverGhClientCache)) 64 | mux.Handle("/metrics", promhttp.Handler()) 65 | mux.Handle("/live", health.NewHandler(livenessChecker)) 66 | mux.Handle("/ready", health.NewHandler(readinessChecker)) 67 | 68 | srv := &http.Server{ 69 | Handler: mux, 70 | Addr: ":8080", 71 | ReadTimeout: 10 * time.Second, 72 | WriteTimeout: 10 * time.Second, 73 | } 74 | 75 | log.Infoln("server started") 76 | log.Fatal(srv.ListenAndServe()) 77 | } 78 | -------------------------------------------------------------------------------- /internal/pkg/mocks/argocd_settings.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/argoproj/argo-cd/v2/pkg/apiclient/settings (interfaces: SettingsServiceClient) 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=argocd_settings.go -package=mocks github.com/argoproj/argo-cd/v2/pkg/apiclient/settings SettingsServiceClient 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | settings "github.com/argoproj/argo-cd/v2/pkg/apiclient/settings" 17 | gomock "go.uber.org/mock/gomock" 18 | grpc "google.golang.org/grpc" 19 | ) 20 | 21 | // MockSettingsServiceClient is a mock of SettingsServiceClient interface. 22 | type MockSettingsServiceClient struct { 23 | ctrl *gomock.Controller 24 | recorder *MockSettingsServiceClientMockRecorder 25 | isgomock struct{} 26 | } 27 | 28 | // MockSettingsServiceClientMockRecorder is the mock recorder for MockSettingsServiceClient. 29 | type MockSettingsServiceClientMockRecorder struct { 30 | mock *MockSettingsServiceClient 31 | } 32 | 33 | // NewMockSettingsServiceClient creates a new mock instance. 34 | func NewMockSettingsServiceClient(ctrl *gomock.Controller) *MockSettingsServiceClient { 35 | mock := &MockSettingsServiceClient{ctrl: ctrl} 36 | mock.recorder = &MockSettingsServiceClientMockRecorder{mock} 37 | return mock 38 | } 39 | 40 | // EXPECT returns an object that allows the caller to indicate expected use. 41 | func (m *MockSettingsServiceClient) EXPECT() *MockSettingsServiceClientMockRecorder { 42 | return m.recorder 43 | } 44 | 45 | // Get mocks base method. 46 | func (m *MockSettingsServiceClient) Get(ctx context.Context, in *settings.SettingsQuery, opts ...grpc.CallOption) (*settings.Settings, error) { 47 | m.ctrl.T.Helper() 48 | varargs := []any{ctx, in} 49 | for _, a := range opts { 50 | varargs = append(varargs, a) 51 | } 52 | ret := m.ctrl.Call(m, "Get", varargs...) 53 | ret0, _ := ret[0].(*settings.Settings) 54 | ret1, _ := ret[1].(error) 55 | return ret0, ret1 56 | } 57 | 58 | // Get indicates an expected call of Get. 59 | func (mr *MockSettingsServiceClientMockRecorder) Get(ctx, in any, opts ...any) *gomock.Call { 60 | mr.mock.ctrl.T.Helper() 61 | varargs := append([]any{ctx, in}, opts...) 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSettingsServiceClient)(nil).Get), varargs...) 63 | } 64 | 65 | // GetPlugins mocks base method. 66 | func (m *MockSettingsServiceClient) GetPlugins(ctx context.Context, in *settings.SettingsQuery, opts ...grpc.CallOption) (*settings.SettingsPluginsResponse, error) { 67 | m.ctrl.T.Helper() 68 | varargs := []any{ctx, in} 69 | for _, a := range opts { 70 | varargs = append(varargs, a) 71 | } 72 | ret := m.ctrl.Call(m, "GetPlugins", varargs...) 73 | ret0, _ := ret[0].(*settings.SettingsPluginsResponse) 74 | ret1, _ := ret[1].(error) 75 | return ret0, ret1 76 | } 77 | 78 | // GetPlugins indicates an expected call of GetPlugins. 79 | func (mr *MockSettingsServiceClientMockRecorder) GetPlugins(ctx, in any, opts ...any) *gomock.Call { 80 | mr.mock.ctrl.T.Helper() 81 | varargs := append([]any{ctx, in}, opts...) 82 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlugins", reflect.TypeOf((*MockSettingsServiceClient)(nil).GetPlugins), varargs...) 83 | } 84 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | Thanks for your interest in contributing to Telefonistka! Here are a few general guidelines on contributing and 4 | reporting bugs that we ask you to review. Following these guidelines helps to communicate that you respect the time of 5 | the contributors managing and developing this open source project. In return, they should reciprocate that respect in 6 | addressing your issue, assessing changes, and helping you finalize your pull requests. In that spirit of mutual respect, 7 | we endeavour to review incoming issues and pull requests within 10 days, and will close any lingering issues or pull 8 | requests after 60 days of inactivity. 9 | 10 | Please note that all of your interactions in the project are subject to our [Code of Conduct](CODE_OF_CONDUCT.md). This 11 | includes creation of issues or pull requests, commenting on issues or pull requests, and extends to all interactions in 12 | any real-time space (eg. Slack, Discord, etc). 13 | 14 | ## Reporting Issues 15 | 16 | Before reporting a new issue, please ensure that the issue was not already reported or fixed by searching through our 17 | [issues list](https://github.com/commercetools/telefonistka/issues). 18 | 19 | When creating a new issue, please be sure to include a **title and clear description**, as much relevant information as 20 | possible, and, if possible, a test case. 21 | 22 | **If you discover a security bug, please do not report it through GitHub. Instead, please see security procedures in 23 | [SECURITY.md](SECURITY.md).** 24 | 25 | ## Sending Pull Requests 26 | 27 | Before sending a new pull request, take a look at existing pull requests and issues to see if the proposed change or fix 28 | has been discussed in the past, or if the change was already implemented but not yet released. 29 | 30 | We expect new pull requests to include tests for any affected behavior, and, as we follow semantic versioning, we may 31 | reserve breaking changes until the next major version release. 32 | 33 | Signed commits are required. Follow [Github 34 | documentation](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) 35 | to set it up, and sign your commits so that your pull request can be merged. 36 | 37 | ### Generated files 38 | 39 | Please make sure that generated files are checked in. This is done mainly to 40 | avoid broken builds and dependency issues. See #126 for more context. 41 | 42 | Mocks are generated in [mocks.go](./internal/pkg/mocks/mocks.go). They can be 43 | generated using `go generate ./...`. 44 | 45 | ## Other Ways to Contribute 46 | 47 | We welcome anyone that wants to contribute to Telefonistka to triage and reply to open issues to help troubleshoot 48 | and fix existing bugs. Here is what you can do: 49 | 50 | - Help ensure that existing issues follows the recommendations from the _[Reporting Issues](#reporting-issues)_ section, 51 | providing feedback to the issue's author on what might be missing. 52 | - Review and update the existing content of our [Wiki](https://github.com/commercetools/telefonistka/wiki) with up-to-date 53 | instructions and code samples. 54 | - Review existing pull requests, and testing patches against real existing applications that use Telefonistka. 55 | - Write a test, or add a missing test case to an existing test. 56 | 57 | Thanks again for your interest on contributing to Telefonistka! 58 | 59 | :heart: 60 | -------------------------------------------------------------------------------- /docs/modeling_environments_in_gitops_repo.md: -------------------------------------------------------------------------------- 1 | # Modeling Distinct Environments/Failure Domains In Gitops Repo 2 | 3 | This document give a short overview on the available methods to model multiple environments/failure domains in a GitOps IaC repo. 4 | More thorough articles can be found in the [external resources section](#external-resources). 5 | 6 | ## Terminology 7 | 8 | `Environment(env)`: a distinct part of your Infrastructure that is used to run your services and cloud resources to support some goal. For example, `Production` is used to serve actual customers, `Staging` could be used to test how new version of services interact with the rest of the platform, and `Lab` is where new and untested changes are initial deployed to. 9 | Having a production and at least one non-production environment is practically assumed. 10 | 11 | `Failure domain(FD)`: These represent a repeatable part of your infrastructure that you *choose* to deploy in gradual steps to control the "blast radius" of a bad deploy/configuration. A classic example could be cloud regions like `us-east-1` or `us-west-1` for a company that runs a multi-region setup. 12 | Smaller or younger companies might not have such distinct failure domains as the extra "safety" they provide might not be worth the investment. 13 | 14 | In some cases I will use the term `envs` to refer to both Environments and Failure domains as from the perspective of the IaC tool, the GitOps pipeline/controller and Telefonistka they are the same. 15 | 16 | `Drift`: in this context, drift describes an unwanted/unintended difference between environment/FDs that is present in the Git state, for example a change that was made to the `Staging` environment but wasn't promoted to `Prod` 17 | 18 | ## Available Methods 19 | 20 | ### Single instance 21 | 22 | All envs are controlled from a single file/folders, a single git commit change them all *at once*. 23 | Even if you have per env/FD parameter override files(e.g. Helm value files/Terraform `.tfvars`), any change to the shared code(or a reference to a versioned artifact hosting the code) will be applied the all envs at once(GitOps!), somewhat negating the benefits of maintaining multiple envs. 24 | 25 | ### Git Branch per Env/FD 26 | 27 | This allows using git native tools for promoting changes(`git merge`) and inspecting drift(`git diff`) but it quickly becomes cumbersome as the number of distinct environment/FDs grows. Additionally, syncing all your infrastructure from the main branch keeps the GitOps side of things more intuitive and make the promotion side more observable. 28 | 29 | ### Directory per Env/FD 30 | 31 | This is our chosen approach and what Telefonistka currently supports. 32 | 33 | See [section in README.md](../README.md#modeling-environmentsfailure-domains-in-an-iac-gitops-repo) 34 | 35 | ### Git Repo per Env/FD 36 | 37 | This is the most complex but flexible solution, providing the strongest isolation in permission and policy enforcement. 38 | This feels a bit too much considering the added complexity, especially if the number of envs is high or dynamic. 39 | Telefonistka doesn't support this model currently. 40 | 41 | ## External resources 42 | 43 | [Stop Using Branches for Deploying to Different GitOps Environments](https://codefresh.io/blog/stop-using-branches-deploying-different-gitops-environments/) 44 | 45 | [How to Model Your Gitops Environments and Promote Releases between Them](https://codefresh.io/blog/how-to-model-your-gitops-environments-and-promote-releases-between-them/) 46 | 47 | [Promoting changes and releases with GitOps](https://www.sokube.io/en/blog/promoting-changes-and-releases-with-gitops) 48 | -------------------------------------------------------------------------------- /internal/pkg/prometheus/prometheus_test.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/go-test/deep" 9 | "github.com/google/go-github/v62/github" 10 | "github.com/prometheus/client_golang/prometheus" 11 | ) 12 | 13 | func TestUserGetUrl(t *testing.T) { 14 | t.Parallel() 15 | expectedLabels := prometheus.Labels{ 16 | "api_group": "user", 17 | "api_path": "", 18 | "repo_slug": "", 19 | "status": "404", 20 | "method": "GET", 21 | } 22 | instrumentGhCallTestHelper(t, "/api/v3/user", expectedLabels) 23 | } 24 | 25 | func TestRepoGetUrl(t *testing.T) { 26 | t.Parallel() 27 | expectedLabels := prometheus.Labels{ 28 | "api_group": "repos", 29 | "api_path": "", 30 | "repo_slug": "shared/k8s-helmfile", 31 | "status": "404", 32 | "method": "GET", 33 | } 34 | instrumentGhCallTestHelper(t, "/api/v3/repos/shared/k8s-helmfile", expectedLabels) 35 | } 36 | 37 | func TestContentUrl(t *testing.T) { 38 | t.Parallel() 39 | expectedLabels := prometheus.Labels{ 40 | "api_group": "repos", 41 | "api_path": "contents", 42 | "repo_slug": "shared/k8s-helmfile", 43 | "status": "404", 44 | "method": "GET", 45 | } 46 | instrumentGhCallTestHelper(t, "/api/v3/repos/shared/k8s-helmfile/contents/workspace/telefonistka/telefonistka.yaml", expectedLabels) 47 | } 48 | 49 | func TestPullUrl(t *testing.T) { 50 | t.Parallel() 51 | expectedLabels := prometheus.Labels{ 52 | "api_group": "repos", 53 | "api_path": "pulls", 54 | "repo_slug": "AnOwner/Arepo", 55 | "status": "404", 56 | "method": "GET", 57 | } 58 | instrumentGhCallTestHelper(t, "/repos/AnOwner/Arepo/pulls/33", expectedLabels) 59 | } 60 | 61 | func TestShortUrl(t *testing.T) { 62 | t.Parallel() 63 | expectedLabels := prometheus.Labels{ 64 | "api_group": "repos", 65 | "api_path": "contents", 66 | "repo_slug": "AnOwner/Arepo", 67 | "status": "404", 68 | "method": "GET", 69 | } 70 | instrumentGhCallTestHelper(t, "/repos/AnOwner/Arepo/contents/telefonistka.yaml", expectedLabels) 71 | } 72 | 73 | func TestApiUrl(t *testing.T) { 74 | t.Parallel() 75 | expectedLabels := prometheus.Labels{ 76 | "api_group": "repos", 77 | "api_path": "contents", 78 | "repo_slug": "AnOwner/Arepo", 79 | "status": "404", 80 | "method": "GET", 81 | } 82 | instrumentGhCallTestHelper(t, "/api/v3/repos/AnOwner/Arepo/contents/telefonistka.yaml", expectedLabels) 83 | } 84 | 85 | func TestInstrumentProxyUpstreamRequestLables(t *testing.T) { 86 | t.Parallel() 87 | 88 | mockURL, _ := url.Parse("https://argocd.example.com/webhook") 89 | 90 | httpReq := &http.Request{ 91 | URL: mockURL, 92 | Method: "POST", 93 | } 94 | 95 | httpResp := &http.Response{ 96 | Request: httpReq, 97 | StatusCode: 200, 98 | } 99 | 100 | expectedLabels := prometheus.Labels{ 101 | "status": "200", 102 | "method": "POST", 103 | "url": "https://argocd.example.com/webhook", 104 | } 105 | labels := InstrumentProxyUpstreamRequest(httpResp) 106 | if diff := deep.Equal(expectedLabels, labels); diff != nil { 107 | t.Error(diff) 108 | } 109 | } 110 | 111 | func instrumentGhCallTestHelper(t *testing.T, httpURL string, expectedLabels prometheus.Labels) { 112 | t.Helper() 113 | mockURL, _ := url.Parse("https://github.com/api/v3/content/foo/bar/file.txt") 114 | 115 | httpReq := &http.Request{ 116 | URL: mockURL, 117 | Method: "GET", 118 | } 119 | 120 | httpResp := &http.Response{ 121 | Request: httpReq, 122 | StatusCode: 404, 123 | } 124 | 125 | resp := &github.Response{ 126 | Response: httpResp, 127 | } 128 | resp.Request.URL.Path = httpURL 129 | labels := InstrumentGhCall(resp) 130 | 131 | if diff := deep.Equal(expectedLabels, labels); diff != nil { 132 | t.Error(diff) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/github_graphql.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/shurcooL/githubv4" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // go-github is my peffered way to interact with GitHub because of the better developer expirience(pre made types, easy API mocking). 12 | // But some functionality is not availalble in GH V3 rest API, like PR comment minimization, so here we are: 13 | func GetBotGhIdentity(githubGraphQlClient *githubv4.Client, ctx context.Context) (string, error) { 14 | var getBotGhIdentityQuery struct { 15 | Viewer struct { 16 | Login githubv4.String 17 | } 18 | } 19 | 20 | err := githubGraphQlClient.Query(ctx, &getBotGhIdentityQuery, nil) 21 | botIdentity := getBotGhIdentityQuery.Viewer.Login 22 | if err != nil { 23 | log.Errorf("Failed to fetch token owner name: err=%s\n", err) 24 | return "", err 25 | } 26 | return string(botIdentity), nil 27 | } 28 | 29 | func MimizeStalePrComments(ghPrClientDetails GhPrClientDetails, githubGraphQlClient *githubv4.Client, botIdentity string) error { 30 | var getCommentNodeIdsQuery struct { 31 | Repository struct { 32 | PullRequest struct { 33 | Title githubv4.String 34 | Comments struct { 35 | Edges []struct { 36 | Node struct { 37 | Id githubv4.ID 38 | IsMinimized githubv4.Boolean 39 | Body githubv4.String 40 | Author struct { 41 | Login githubv4.String 42 | } 43 | } 44 | } 45 | } `graphql:"comments(last: 100)"` 46 | } `graphql:"pullRequest(number: $prNumber )"` 47 | } `graphql:"repository(owner: $owner, name: $repo)"` 48 | } // Mimizing stale comment is not crutial so only taking the last 100 comments, should cover most cases. 49 | // Would be nice if I could filter based on Author and isMinized here, in the query, to get just the relevant ones, 50 | // but I don't think GH graphQL supports it, so for now I just filter in code, see conditioanl near the end of this function. 51 | 52 | getCommentNodeIdsParams := map[string]interface{}{ 53 | "owner": githubv4.String(ghPrClientDetails.Owner), 54 | "repo": githubv4.String(ghPrClientDetails.Repo), 55 | "prNumber": githubv4.Int(ghPrClientDetails.PrNumber), //nolint:gosec // G115: type mismatch between shurcooL/githubv4 and google/go-github. Number taken from latter for use in query using former. 56 | } 57 | 58 | var minimizeCommentMutation struct { 59 | MinimizeComment struct { 60 | ClientMutationId githubv4.ID 61 | MinimizedComment struct { 62 | IsMinimized githubv4.Boolean 63 | } 64 | } `graphql:"minimizeComment(input: $input)"` 65 | } 66 | 67 | err := githubGraphQlClient.Query(ghPrClientDetails.Ctx, &getCommentNodeIdsQuery, getCommentNodeIdsParams) 68 | if err != nil { 69 | ghPrClientDetails.PrLogger.Errorf("Failed to minimize stale comments: err=%s\n", err) 70 | } 71 | bi := githubv4.String(strings.TrimSuffix(botIdentity, "[bot]")) 72 | for _, prComment := range getCommentNodeIdsQuery.Repository.PullRequest.Comments.Edges { 73 | if !prComment.Node.IsMinimized && prComment.Node.Author.Login == bi { 74 | if strings.Contains(string(prComment.Node.Body), "") { 75 | ghPrClientDetails.PrLogger.Infof("Minimizing Comment %s", prComment.Node.Id) 76 | minimizeCommentInput := githubv4.MinimizeCommentInput{ 77 | SubjectID: prComment.Node.Id, 78 | Classifier: githubv4.ReportedContentClassifiers("OUTDATED"), 79 | ClientMutationID: &bi, 80 | } 81 | err := githubGraphQlClient.Mutate(ghPrClientDetails.Ctx, &minimizeCommentMutation, minimizeCommentInput, nil) 82 | // As far as I can tell minimizeComment Github's grpahQL method doesn't accept list do doing one call per comment 83 | if err != nil { 84 | ghPrClientDetails.PrLogger.Errorf("Failed to minimize comment ID %s\n err=%s", prComment.Node.Id, err) 85 | // Handle error. 86 | } 87 | } else { 88 | ghPrClientDetails.PrLogger.Debugln("Ignoring comment without identification tag") 89 | } 90 | } 91 | } 92 | 93 | return err 94 | } 95 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/pr_metrics.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | prom "github.com/commercetools/telefonistka/internal/pkg/prometheus" 8 | "github.com/google/go-github/v62/github" 9 | lru "github.com/hashicorp/golang-lru/v2" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | const ( 14 | timeToDefineStale = 20 * time.Minute 15 | metricRefreshTime = 60 * time.Second 16 | ) 17 | 18 | func MainGhMetricsLoop(mainGhClientCache *lru.Cache[string, GhClientPair]) { 19 | for t := range time.Tick(metricRefreshTime) { 20 | log.Debugf("Updating pr metrics at %v", t) 21 | getPrMetrics(mainGhClientCache) 22 | } 23 | } 24 | 25 | func getRepoPrMetrics(ctx context.Context, ghClient GhClientPair, repo *github.Repository) (pc prom.PrCounters, err error) { 26 | log.Debugf("Checking repo %s", repo.GetName()) 27 | ghOwner := repo.GetOwner().GetLogin() 28 | prListOpts := &github.PullRequestListOptions{ 29 | State: "open", 30 | } 31 | prs := []*github.PullRequest{} 32 | 33 | // paginate through PRs, there might be lots of them. 34 | for { 35 | perPagePrs, resp, err := ghClient.v3Client.PullRequests.List(ctx, ghOwner, repo.GetName(), prListOpts) 36 | _ = prom.InstrumentGhCall(resp) 37 | if err != nil { 38 | log.Errorf("error getting PRs for %s/%s: %v", ghOwner, repo.GetName(), err) 39 | } 40 | prs = append(prs, perPagePrs...) 41 | if resp.NextPage == 0 { 42 | break 43 | } 44 | prListOpts.Page = resp.NextPage 45 | } 46 | 47 | for _, pr := range prs { 48 | if DoesPrHasLabel(pr.Labels, "promotion") { 49 | pc.OpenPromotionPrs++ 50 | } 51 | 52 | log.Debugf("Checking PR %d", pr.GetNumber()) 53 | commitStatuses, resp, err := ghClient.v3Client.Repositories.GetCombinedStatus(ctx, ghOwner, repo.GetName(), pr.GetHead().GetSHA(), nil) 54 | _ = prom.InstrumentGhCall(resp) 55 | if err != nil { 56 | log.Errorf("error getting statuses for %s/%s/%d: %v", ghOwner, repo.GetName(), pr.GetNumber(), err) 57 | continue 58 | } 59 | if isPrStalePending(commitStatuses, timeToDefineStale) { 60 | pc.PrWithStaleChecks++ 61 | } 62 | } 63 | pc.OpenPrs = len(prs) 64 | 65 | return 66 | } 67 | 68 | // isPrStalePending checks if the a combinedStatus has a "telefonistka" context pending status that is older than timeToDefineStale and is in pending state 69 | func isPrStalePending(commitStatuses *github.CombinedStatus, timeToDefineStale time.Duration) bool { 70 | for _, status := range commitStatuses.Statuses { 71 | if *status.Context == "telefonistka" && 72 | *status.State == "pending" && 73 | status.UpdatedAt.GetTime().Before(time.Now().Add(timeToDefineStale*-1)) { 74 | log.Debugf("Adding status %s-%v-%s !!!", *status.Context, status.UpdatedAt.GetTime(), *status.State) 75 | return true 76 | } else { 77 | log.Debugf("Ignoring status %s-%v-%s", *status.Context, status.UpdatedAt.GetTime(), *status.State) 78 | } 79 | } 80 | 81 | return false 82 | } 83 | 84 | // getPrMetrics iterates through all clients , gets all repos and then all PRs and calculates metrics 85 | // getPrMetrics assumes Telefonsitka uses a GitHub App style of authentication as it uses the Apps.ListRepos call 86 | // When using personal access token authentication, Telefonistka is unaware of the "relevant" repos (at least it get a webhook from them). 87 | func getPrMetrics(mainGhClientCache *lru.Cache[string, GhClientPair]) { 88 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 89 | defer cancel() 90 | for _, ghOwner := range mainGhClientCache.Keys() { 91 | log.Debugf("Checking gh Owner %s", ghOwner) 92 | ghClient, _ := mainGhClientCache.Get(ghOwner) 93 | repos, resp, err := ghClient.v3Client.Apps.ListRepos(ctx, nil) 94 | _ = prom.InstrumentGhCall(resp) 95 | if err != nil { 96 | log.Errorf("error getting repos for %s: %v", ghOwner, err) 97 | continue 98 | } 99 | for _, repo := range repos.Repositories { 100 | pc, err := getRepoPrMetrics(ctx, ghClient, repo) 101 | if err != nil { 102 | log.Errorf("error getting repos for %s: %v", ghOwner, err) 103 | continue 104 | } 105 | prom.PublishPrMetrics(pc, repo.GetFullName()) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /docs/observability.md: -------------------------------------------------------------------------------- 1 | ## Metrics 2 | 3 | |name|type|description|labels| 4 | |---|---|---|---| 5 | |telefonistka_github_github_operations_total|counter|"The total number of Github API operations|`api_group`, `api_path`, `repo_slug`, `status`, `method`| 6 | |telefonistka_github_github_rest_api_client_rate_remaining|gauge|The number of remaining requests the client can make this hour|| 7 | |telefonistka_github_github_rest_api_client_rate_limit|gauge|The number of requests per hour the client is currently limited to|| 8 | |telefonistka_webhook_server_webhook_hits_total|counter|The total number of validated webhook hits|`parsing`| 9 | |telefonistka_github_open_prs|gauge|The number of open PRs|`repo_slug`| 10 | |telefonistka_github_open_promotion_prs|gauge|The number of open promotion PRs|`repo_slug`| 11 | |telefonistka_github_open_prs_with_pending_telefonistka_checks|gauge|The number of open PRs with pending Telefonistka checks(excluding PRs with very recent commits)|`repo_slug`| 12 | |telefonistka_github_commit_status_updates_total|counter|The total number of commit status updates, and their status (success/pending/failure)|`repo_slug`, `status`| 13 | 14 | > [!NOTE] 15 | > telefonistka_github_*_prs metrics are only supported on installtions that uses GitHub App authentication as it provides an easy way to query the relevant GH repos. 16 | 17 | Example metrics snippet: 18 | 19 | ```text 20 | # HELP telefonistka_github_github_operations_total The total number of Github operations 21 | # TYPE telefonistka_github_github_operations_total counter 22 | telefonistka_github_github_operations_total{api_group="repos",api_path="",method="GET",repo_slug="Oded-B/telefonistka-example",status="200"} 8 23 | telefonistka_github_github_operations_total{api_group="repos",api_path="contents",method="GET",repo_slug="Oded-B/telefonistka-example",status="200"} 76 24 | telefonistka_github_github_operations_total{api_group="repos",api_path="contents",method="GET",repo_slug="Oded-B/telefonistka-example",status="404"} 13 25 | telefonistka_github_github_operations_total{api_group="repos",api_path="issues",method="POST",repo_slug="Oded-B/telefonistka-example",status="201"} 3 26 | telefonistka_github_github_operations_total{api_group="repos",api_path="pulls",method="GET",repo_slug="Oded-B/telefonistka-example",status="200"} 8 27 | # HELP telefonistka_github_github_rest_api_client_rate_limit The number of requests per hour the client is currently limited to 28 | # TYPE telefonistka_github_github_rest_api_client_rate_limit gauge 29 | telefonistka_github_github_rest_api_client_rate_limit 100000 30 | # HELP telefonistka_github_github_rest_api_client_rate_remaining The number of remaining requests the client can make this hour 31 | # TYPE telefonistka_github_github_rest_api_client_rate_remaining gauge 32 | telefonistka_github_github_rest_api_client_rate_remaining 99668 33 | # HELP telefonistka_webhook_server_webhook_hits_total The total number of validated webhook hits 34 | # TYPE telefonistka_webhook_server_webhook_hits_total counter 35 | telefonistka_webhook_server_webhook_hits_total{parsing="successful"} 8 36 | # HELP telefonistka_github_commit_status_updates_total The total number of commit status updates, and their status (success/pending/failure) 37 | # TYPE telefonistka_github_commit_status_updates_total counter 38 | telefonistka_github_commit_status_updates_total{repo_slug="foo/bar2",status="error"} 1 39 | telefonistka_github_commit_status_updates_total{repo_slug="foo/bar2",status="pending"} 1 40 | # HELP telefonistka_github_open_promotion_prs The total number of open PRs with promotion label 41 | # TYPE telefonistka_github_open_promotion_prs gauge 42 | telefonistka_github_open_promotion_prs{repo_slug="foo/bar1"} 0 43 | telefonistka_github_open_promotion_prs{repo_slug="foo/bar2"} 10 44 | # HELP telefonistka_github_open_prs The total number of open PRs 45 | # TYPE telefonistka_github_open_prs gauge 46 | telefonistka_github_open_prs{repo_slug="foo/bar1"} 0 47 | telefonistka_github_open_prs{repo_slug="foo/bar2"} 21 48 | # HELP telefonistka_github_open_prs_with_pending_telefonistka_checks The total number of open PRs with pending Telefonistka checks(excluding PRs with very recent commits) 49 | # TYPE telefonistka_github_open_prs_with_pending_telefonistka_checks gauge 50 | telefonistka_github_open_prs_with_pending_telefonistka_checks{repo_slug="foo/bar1"} 0 51 | telefonistka_github_open_prs_with_pending_telefonistka_checks{repo_slug="foo/bar2"} 0 52 | ``` 53 | -------------------------------------------------------------------------------- /cmd/telefonistka/bump-version-regex.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/commercetools/telefonistka/internal/pkg/githubapi" 10 | lru "github.com/hashicorp/golang-lru/v2" 11 | "github.com/hexops/gotextdiff" 12 | "github.com/hexops/gotextdiff/myers" 13 | "github.com/hexops/gotextdiff/span" 14 | log "github.com/sirupsen/logrus" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | // This is still(https://github.com/spf13/cobra/issues/1862) the documented way to use cobra 19 | func init() { //nolint:gochecknoinits 20 | var targetRepo string 21 | var targetFile string 22 | var regex string 23 | var replacement string 24 | var githubHost string 25 | var triggeringRepo string 26 | var triggeringRepoSHA string 27 | var triggeringActor string 28 | var autoMerge bool 29 | eventCmd := &cobra.Command{ 30 | Use: "bump-regex", 31 | Short: "Bump artifact version in a file using regex", 32 | Long: "Bump artifact version in a file using regex.\nThis open a pull request in the target repo.\n", 33 | Args: cobra.ExactArgs(0), 34 | Run: func(cmd *cobra.Command, args []string) { 35 | bumpVersionRegex(targetRepo, targetFile, regex, replacement, githubHost, triggeringRepo, triggeringRepoSHA, triggeringActor, autoMerge) 36 | }, 37 | } 38 | eventCmd.Flags().StringVarP(&targetRepo, "target-repo", "t", getEnv("TARGET_REPO", ""), "Target Git repository slug(e.g. org-name/repo-name), defaults to TARGET_REPO env var.") 39 | eventCmd.Flags().StringVarP(&targetFile, "target-file", "f", getEnv("TARGET_FILE", ""), "Target file path(from repo root), defaults to TARGET_FILE env var.") 40 | eventCmd.Flags().StringVarP(®ex, "regex-string", "r", "", "Regex used to replace artifact version, e.g. 'tag:\\s*(\\S*)'.") 41 | eventCmd.Flags().StringVarP(&replacement, "replacement-string", "n", "", "Replacement string that includes the version of new artifact, e.g. 'tag: v2.7.1'.") 42 | eventCmd.Flags().StringVarP(&githubHost, "github-host", "g", "", "GitHub instance HOSTNAME, defaults to \"github.com\". This is used for GitHub Enterprise Server instances.") 43 | eventCmd.Flags().StringVarP(&triggeringRepo, "triggering-repo", "p", getEnv("GITHUB_REPOSITORY", ""), "Github repo triggering the version bump(e.g. `octocat/Hello-World`) defaults to GITHUB_REPOSITORY env var.") 44 | eventCmd.Flags().StringVarP(&triggeringRepoSHA, "triggering-repo-sha", "s", getEnv("GITHUB_SHA", ""), "Git SHA of triggering repo, defaults to GITHUB_SHA env var.") 45 | eventCmd.Flags().StringVarP(&triggeringActor, "triggering-actor", "a", getEnv("GITHUB_ACTOR", ""), "GitHub user of the person/bot who triggered the bump, defaults to GITHUB_ACTOR env var.") 46 | eventCmd.Flags().BoolVar(&autoMerge, "auto-merge", false, "Automatically merges the created PR, defaults to false.") 47 | rootCmd.AddCommand(eventCmd) 48 | } 49 | 50 | func bumpVersionRegex(targetRepo string, targetFile string, regex string, replacement string, githubHost string, triggeringRepo string, triggeringRepoSHA string, triggeringActor string, autoMerge bool) { 51 | ctx := context.Background() 52 | var githubRestAltURL string 53 | 54 | if githubHost != "" { 55 | githubRestAltURL = "https://" + githubHost + "/api/v3" 56 | log.Infof("Github REST API endpoint is configured to %s", githubRestAltURL) 57 | } 58 | var mainGithubClientPair githubapi.GhClientPair 59 | mainGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 60 | 61 | mainGithubClientPair.GetAndCache(mainGhClientCache, "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_OAUTH_TOKEN", strings.Split(targetRepo, "/")[0], ctx) 62 | 63 | var ghPrClientDetails githubapi.GhPrClientDetails 64 | 65 | ghPrClientDetails.GhClientPair = &mainGithubClientPair 66 | ghPrClientDetails.Ctx = ctx 67 | ghPrClientDetails.Owner = strings.Split(targetRepo, "/")[0] 68 | ghPrClientDetails.Repo = strings.Split(targetRepo, "/")[1] 69 | ghPrClientDetails.PrLogger = log.WithFields(log.Fields{}) // TODO what fields should be here? 70 | 71 | r := regexp.MustCompile(regex) 72 | defaultBranch, _ := ghPrClientDetails.GetDefaultBranch() 73 | 74 | initialFileContent, _, err := githubapi.GetFileContent(ghPrClientDetails, defaultBranch, targetFile) 75 | if err != nil { 76 | ghPrClientDetails.PrLogger.Errorf("Fail to fetch file content:%s\n", err) 77 | os.Exit(1) 78 | } 79 | newFileContent := r.ReplaceAllString(initialFileContent, replacement) 80 | 81 | edits := myers.ComputeEdits(span.URIFromPath(""), initialFileContent, newFileContent) 82 | ghPrClientDetails.PrLogger.Infof("Diff:\n%s", gotextdiff.ToUnified("Before", "After", initialFileContent, edits)) 83 | 84 | err = githubapi.BumpVersion(ghPrClientDetails, "main", targetFile, newFileContent, triggeringRepo, triggeringRepoSHA, triggeringActor, autoMerge) 85 | if err != nil { 86 | log.Errorf("Failed to bump version: %v", err) 87 | os.Exit(1) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /cmd/telefonistka/bump-version-overwrite.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strings" 7 | 8 | "github.com/commercetools/telefonistka/internal/pkg/githubapi" 9 | lru "github.com/hashicorp/golang-lru/v2" 10 | "github.com/hexops/gotextdiff" 11 | "github.com/hexops/gotextdiff/myers" 12 | "github.com/hexops/gotextdiff/span" 13 | log "github.com/sirupsen/logrus" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | // This is still(https://github.com/spf13/cobra/issues/1862) the documented way to use cobra 18 | func init() { //nolint:gochecknoinits 19 | var targetRepo string 20 | var targetFile string 21 | var file string 22 | var githubHost string 23 | var triggeringRepo string 24 | var triggeringRepoSHA string 25 | var triggeringActor string 26 | var autoMerge bool 27 | eventCmd := &cobra.Command{ 28 | Use: "bump-overwrite", 29 | Short: "Bump artifact version based on provided file content.", 30 | Long: "Bump artifact version based on provided file content.\nThis open a pull request in the target repo.", 31 | Args: cobra.ExactArgs(0), 32 | Run: func(cmd *cobra.Command, args []string) { 33 | bumpVersionOverwrite(targetRepo, targetFile, file, githubHost, triggeringRepo, triggeringRepoSHA, triggeringActor, autoMerge) 34 | }, 35 | } 36 | eventCmd.Flags().StringVarP(&targetRepo, "target-repo", "t", getEnv("TARGET_REPO", ""), "Target Git repository slug(e.g. org-name/repo-name), defaults to TARGET_REPO env var.") 37 | eventCmd.Flags().StringVarP(&targetFile, "target-file", "f", getEnv("TARGET_FILE", ""), "Target file path(from repo root), defaults to TARGET_FILE env var.") 38 | eventCmd.Flags().StringVarP(&file, "file", "c", "", "File that holds the content the target file will be overwritten with, like \"version.yaml\" or '<(echo -e \"image:\\n tag: ${VERSION}\")'.") 39 | eventCmd.Flags().StringVarP(&githubHost, "github-host", "g", "", "GitHub instance HOSTNAME, defaults to \"github.com\". This is used for GitHub Enterprise Server instances.") 40 | eventCmd.Flags().StringVarP(&triggeringRepo, "triggering-repo", "p", getEnv("GITHUB_REPOSITORY", ""), "Github repo triggering the version bump(e.g. `octocat/Hello-World`) defaults to GITHUB_REPOSITORY env var.") 41 | eventCmd.Flags().StringVarP(&triggeringRepoSHA, "triggering-repo-sha", "s", getEnv("GITHUB_SHA", ""), "Git SHA of triggering repo, defaults to GITHUB_SHA env var.") 42 | eventCmd.Flags().StringVarP(&triggeringActor, "triggering-actor", "a", getEnv("GITHUB_ACTOR", ""), "GitHub user of the person/bot who triggered the bump, defaults to GITHUB_ACTOR env var.") 43 | eventCmd.Flags().BoolVar(&autoMerge, "auto-merge", false, "Automatically merges the created PR, defaults to false.") 44 | rootCmd.AddCommand(eventCmd) 45 | } 46 | 47 | func bumpVersionOverwrite(targetRepo string, targetFile string, file string, githubHost string, triggeringRepo string, triggeringRepoSHA string, triggeringActor string, autoMerge bool) { 48 | b, err := os.ReadFile(file) 49 | if err != nil { 50 | log.Errorf("Failed to read file %s, %v", file, err) 51 | os.Exit(1) 52 | } 53 | newFileContent := string(b) 54 | 55 | ctx := context.Background() 56 | var githubRestAltURL string 57 | 58 | if githubHost != "" { 59 | githubRestAltURL = "https://" + githubHost + "/api/v3" 60 | log.Infof("Github REST API endpoint is configured to %s", githubRestAltURL) 61 | } 62 | var mainGithubClientPair githubapi.GhClientPair 63 | mainGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 64 | 65 | mainGithubClientPair.GetAndCache(mainGhClientCache, "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_OAUTH_TOKEN", strings.Split(targetRepo, "/")[0], ctx) 66 | 67 | var ghPrClientDetails githubapi.GhPrClientDetails 68 | 69 | ghPrClientDetails.GhClientPair = &mainGithubClientPair 70 | ghPrClientDetails.Ctx = ctx 71 | ghPrClientDetails.Owner = strings.Split(targetRepo, "/")[0] 72 | ghPrClientDetails.Repo = strings.Split(targetRepo, "/")[1] 73 | ghPrClientDetails.PrLogger = log.WithFields(log.Fields{}) // TODO what fields should be here? 74 | 75 | defaultBranch, _ := ghPrClientDetails.GetDefaultBranch() 76 | initialFileContent, statusCode, err := githubapi.GetFileContent(ghPrClientDetails, defaultBranch, targetFile) 77 | if statusCode == 404 { 78 | ghPrClientDetails.PrLogger.Infof("File %s was not found\n", targetFile) 79 | } else if err != nil { 80 | ghPrClientDetails.PrLogger.Errorf("Fail to fetch file content:%s\n", err) 81 | os.Exit(1) 82 | } 83 | 84 | edits := myers.ComputeEdits(span.URIFromPath(""), initialFileContent, newFileContent) 85 | ghPrClientDetails.PrLogger.Infof("Diff:\n%s", gotextdiff.ToUnified("Before", "After", initialFileContent, edits)) 86 | 87 | err = githubapi.BumpVersion(ghPrClientDetails, "main", targetFile, newFileContent, triggeringRepo, triggeringRepoSHA, triggeringActor, autoMerge) 88 | if err != nil { 89 | log.Errorf("Failed to bump version: %v", err) 90 | os.Exit(1) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/pkg/argocd/argocd_copied_from_upstream.go: -------------------------------------------------------------------------------- 1 | package argocd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/argoproj/argo-cd/v2/controller" 8 | "github.com/argoproj/argo-cd/v2/pkg/apiclient/application" 9 | "github.com/argoproj/argo-cd/v2/pkg/apiclient/settings" 10 | argoappv1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" 11 | repoapiclient "github.com/argoproj/argo-cd/v2/reposerver/apiclient" 12 | "github.com/argoproj/argo-cd/v2/util/argo" 13 | "github.com/argoproj/gitops-engine/pkg/sync/hook" 14 | "github.com/argoproj/gitops-engine/pkg/sync/ignore" 15 | "github.com/argoproj/gitops-engine/pkg/utils/kube" 16 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 17 | "k8s.io/apimachinery/pkg/runtime/schema" 18 | ) 19 | 20 | // DifferenceOption struct to store diff options 21 | type DifferenceOption struct { 22 | revision string 23 | res *repoapiclient.ManifestResponse 24 | } 25 | 26 | type resourceInfoProvider struct { 27 | namespacedByGk map[schema.GroupKind]bool 28 | } 29 | 30 | type objKeyLiveTarget struct { 31 | key kube.ResourceKey 32 | live *unstructured.Unstructured 33 | target *unstructured.Unstructured 34 | } 35 | 36 | // Infer if obj is namespaced or not from corresponding live objects list. If corresponding live object has namespace then target object is also namespaced. 37 | // If live object is missing then it does not matter if target is namespaced or not. 38 | func (p *resourceInfoProvider) IsNamespaced(gk schema.GroupKind) (bool, error) { 39 | return p.namespacedByGk[gk], nil 40 | } 41 | 42 | // This function creates a map of objects by key(object name/kind/ns) from the rendered manifests. 43 | // That map is used to compare the objects in the application with the objects in the cluster. 44 | // copied from https://github.com/argoproj/argo-cd/blob/4f6a8dce80f0accef7ed3b5510e178a6b398b331/cmd/argocd/commands/app.go#L1091-L1109 45 | func groupObjsByKey(localObs []*unstructured.Unstructured, liveObjs []*unstructured.Unstructured, appNamespace string) (map[kube.ResourceKey]*unstructured.Unstructured, error) { 46 | namespacedByGk := make(map[schema.GroupKind]bool) 47 | for i := range liveObjs { 48 | if liveObjs[i] != nil { 49 | key := kube.GetResourceKey(liveObjs[i]) 50 | namespacedByGk[schema.GroupKind{Group: key.Group, Kind: key.Kind}] = key.Namespace != "" 51 | } 52 | } 53 | localObs, _, err := controller.DeduplicateTargetObjects(appNamespace, localObs, &resourceInfoProvider{namespacedByGk: namespacedByGk}) 54 | if err != nil { 55 | return nil, fmt.Errorf("Failed to DeDuplicate target objects: %v", err) 56 | } 57 | objByKey := make(map[kube.ResourceKey]*unstructured.Unstructured) 58 | for i := range localObs { 59 | obj := localObs[i] 60 | if !(hook.IsHook(obj) || ignore.Ignore(obj)) { 61 | objByKey[kube.GetResourceKey(obj)] = obj 62 | } 63 | } 64 | return objByKey, nil 65 | } 66 | 67 | // This function create a slice of objects to be "diff'ed", each element contains the key, live(in-cluster API state) and target(rended manifest from git) object. 68 | // Copied from https://github.com/argoproj/argo-cd/blob/4f6a8dce80f0accef7ed3b5510e178a6b398b331/cmd/argocd/commands/app.go#L1341-L1372 69 | func groupObjsForDiff(resources *application.ManagedResourcesResponse, objs map[kube.ResourceKey]*unstructured.Unstructured, items []objKeyLiveTarget, argoSettings *settings.Settings, appName, namespace string) ([]objKeyLiveTarget, error) { 70 | resourceTracking := argo.NewResourceTracking() 71 | for _, res := range resources.Items { 72 | live := &unstructured.Unstructured{} 73 | err := json.Unmarshal([]byte(res.NormalizedLiveState), &live) 74 | if err != nil { 75 | return nil, fmt.Errorf("Failed to unmarshal live object(%v): %v", res.Name, err) 76 | } 77 | 78 | key := kube.ResourceKey{Name: res.Name, Namespace: res.Namespace, Group: res.Group, Kind: res.Kind} 79 | if key.Kind == kube.SecretKind && key.Group == "" { 80 | // Don't bother comparing secrets, argo-cd doesn't have access to k8s secret data 81 | delete(objs, key) 82 | continue 83 | } 84 | if local, ok := objs[key]; ok || live != nil { 85 | if local != nil && !kube.IsCRD(local) { 86 | err = resourceTracking.SetAppInstance(local, argoSettings.AppLabelKey, appName, namespace, argoappv1.TrackingMethod(argoSettings.GetTrackingMethod()), argoSettings.GetInstallationID()) 87 | if err != nil { 88 | return nil, fmt.Errorf("Failed to set app instance label: %v", err) 89 | } 90 | } 91 | 92 | items = append(items, objKeyLiveTarget{key, live, local}) 93 | delete(objs, key) 94 | } 95 | } 96 | for key, local := range objs { 97 | if key.Kind == kube.SecretKind && key.Group == "" { 98 | // Don't bother comparing secrets, argo-cd doesn't have access to k8s secret data 99 | delete(objs, key) 100 | continue 101 | } 102 | items = append(items, objKeyLiveTarget{key, nil, local}) 103 | } 104 | return items, nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/webhook_proxy.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/commercetools/telefonistka/internal/pkg/configuration" 14 | prom "github.com/commercetools/telefonistka/internal/pkg/prometheus" 15 | "github.com/google/go-github/v62/github" 16 | log "github.com/sirupsen/logrus" 17 | "golang.org/x/exp/maps" 18 | ) 19 | 20 | func generateListOfChangedFiles(eventPayload *github.PushEvent) []string { 21 | fileList := map[string]bool{} // using map for uniqueness 22 | 23 | for _, commit := range eventPayload.Commits { 24 | for _, file := range commit.Added { 25 | fileList[file] = true 26 | } 27 | for _, file := range commit.Modified { 28 | fileList[file] = true 29 | } 30 | for _, file := range commit.Removed { 31 | fileList[file] = true 32 | } 33 | } 34 | 35 | return maps.Keys(fileList) 36 | } 37 | 38 | func generateListOfEndpoints(listOfChangedFiles []string, config *configuration.Config) []string { 39 | endpoints := map[string]bool{} // using map for uniqueness 40 | for _, file := range listOfChangedFiles { 41 | for _, regex := range config.WebhookEndpointRegexs { 42 | m := regexp.MustCompile(regex.Expression) 43 | 44 | if m.MatchString(file) { 45 | for _, replacement := range regex.Replacements { 46 | endpoints[m.ReplaceAllString(file, replacement)] = true 47 | } 48 | break 49 | } 50 | } 51 | } 52 | 53 | return maps.Keys(endpoints) 54 | } 55 | 56 | func proxyRequest(ctx context.Context, skipTLSVerify bool, originalHttpRequest *http.Request, body []byte, endpoint string, responses chan<- string) { 57 | tr := &http.Transport{} 58 | if skipTLSVerify { 59 | tr = &http.Transport{ 60 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - letting the user decide if they want to skip TLS verification, for some in-cluster scenarios its a reasonable compromise 61 | } 62 | } 63 | client := &http.Client{Transport: tr} 64 | req, err := http.NewRequestWithContext(ctx, originalHttpRequest.Method, endpoint, bytes.NewBuffer(body)) 65 | if err != nil { 66 | log.Errorf("Error creating request to %s: %v", endpoint, err) 67 | responses <- fmt.Sprintf("Failed to create request to %s", endpoint) 68 | return 69 | } 70 | req.Header = originalHttpRequest.Header.Clone() 71 | // Because payload and headers are passed as-is, I'm hoping webhook signature validation will "just work" 72 | 73 | resp, err := client.Do(req) 74 | if err != nil { 75 | log.Errorf("Error proxying request to %s: %v", endpoint, err) 76 | responses <- fmt.Sprintf("Failed to proxy request to %s", endpoint) 77 | return 78 | } else { 79 | log.Debugf("Webhook successfully forwarded to %s", endpoint) 80 | } 81 | defer resp.Body.Close() 82 | 83 | _ = prom.InstrumentProxyUpstreamRequest(resp) 84 | 85 | respBody, err := io.ReadAll(resp.Body) 86 | 87 | if !strings.HasPrefix(resp.Status, "2") { 88 | log.Errorf("Got non 2XX HTTP status from %s: status=%s body=%v", endpoint, resp.Status, body) 89 | } 90 | 91 | if err != nil { 92 | log.Errorf("Error reading response body from %s: %v", endpoint, err) 93 | responses <- fmt.Sprintf("Failed to read response from %s", endpoint) 94 | return 95 | } 96 | 97 | responses <- string(respBody) 98 | } 99 | 100 | func handlePushEvent(ctx context.Context, eventPayload *github.PushEvent, httpRequest *http.Request, payload []byte, ghPrClientDetails GhPrClientDetails) { 101 | listOfChangedFiles := generateListOfChangedFiles(eventPayload) 102 | log.Debugf("Changed files in push event: %v", listOfChangedFiles) 103 | 104 | defaultBranch := eventPayload.Repo.DefaultBranch 105 | 106 | if *eventPayload.Ref == "refs/heads/"+*defaultBranch { 107 | // TODO this need to be cached with TTL + invalidate if configfile in listOfChangedFiles? 108 | // This is possible because these webhooks are defined as "best effort" for the designed use case: 109 | // Speeding up ArgoCD reconcile loops 110 | config, _ := GetInRepoConfig(ghPrClientDetails, *defaultBranch) 111 | endpoints := generateListOfEndpoints(listOfChangedFiles, config) 112 | 113 | // Create a channel to receive responses from the goroutines 114 | responses := make(chan string) 115 | 116 | // Use a buffered channel with the same size as the number of endpoints 117 | // to prevent goroutines from blocking in case of slow endpoints 118 | results := make(chan string, len(endpoints)) 119 | 120 | // Start a goroutine for each endpoint 121 | for _, endpoint := range endpoints { 122 | go proxyRequest(ctx, config.WhProxtSkipTLSVerifyUpstream, httpRequest, payload, endpoint, responses) 123 | } 124 | 125 | // Wait for all goroutines to finish and collect the responses 126 | for i := 0; i < len(endpoints); i++ { 127 | result := <-responses 128 | results <- result 129 | } 130 | 131 | close(responses) 132 | close(results) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /docs/version_bumping.md: -------------------------------------------------------------------------------- 1 | # Version Bumping 2 | 3 | If your IaC repo deploys software you maintain internally you probably want to automate artifact version bumping. 4 | Telefonistka can automate opening the IaC repo PR for the version change from the Code repo pipeline. 5 | 6 | Currently, three modes of operation are supported: 7 | 8 | ## Whole file overwrite 9 | 10 | ```shell 11 | Bump artifact version based on provided file content. 12 | This open a pull request in the target repo. 13 | 14 | Usage: 15 | telefonistka bump-overwrite [flags] 16 | 17 | Flags: 18 | --auto-merge Automatically merges the created PR, defaults to false. 19 | -c, --file string File that holds the content the target file will be overwritten with, like "version.yaml" or '<(echo -e "image:\n tag: ${VERSION}")'. 20 | -g, --github-host string GitHub instance HOSTNAME, defaults to "github.com". This is used for GitHub Enterprise Server instances. 21 | -h, --help help for bump-overwrite. 22 | -f, --target-file string Target file path(from repo root), defaults to TARGET_FILE env var. 23 | -t, --target-repo string Target Git repository slug(e.g. org-name/repo-name), defaults to TARGET_REPO env var. 24 | -a, --triggering-actor string GitHub user of the person/bot who triggered the bump, defaults to GITHUB_ACTOR env var. 25 | -p, --triggering-repo octocat/Hello-World Github repo triggering the version bump(e.g. octocat/Hello-World) defaults to GITHUB_REPOSITORY env var. 26 | -s, --triggering-repo-sha string Git SHA of triggering repo, defaults to GITHUB_SHA env var. 27 | ``` 28 | 29 | notes: 30 | 31 | * This can create new files in the target repo. 32 | * This was intended for cases where the IaC configuration allows adding additional minimal parameter/values file that only includes version information. 33 | 34 | ## Regex based search and replace 35 | 36 | ```shell 37 | Bump artifact version in a file using regex. 38 | This open a pull request in the target repo. 39 | 40 | Usage: 41 | telefonistka bump-regex [flags] 42 | 43 | Flags: 44 | --auto-merge Automatically merges the created PR, defaults to false. 45 | -g, --github-host string GitHub instance HOSTNAME, defaults to "github.com". This is used for GitHub Enterprise Server instances. 46 | -h, --help help for bump-regex. 47 | -r, --regex-string string Regex used to replace artifact version, e.g. 'tag:\s*(\S*)', 48 | -n, --replacement-string string Replacement string that includes the version of new artifact, e.g. 'tag: v2.7.1'. 49 | -f, --target-file string Target file path(from repo root), defaults to TARGET_FILE env var. 50 | -t, --target-repo string Target Git repository slug(e.g. org-name/repo-name), defaults to TARGET_REPO env var. 51 | -a, --triggering-actor string GitHub user of the person/bot who triggered the bump, defaults to GITHUB_ACTOR env var. 52 | -p, --triggering-repo octocat/Hello-World Github repo triggering the version bump(e.g. octocat/Hello-World) defaults to GITHUB_REPOSITORY env var. 53 | -s, --triggering-repo-sha string Git SHA of triggering repo, defaults to GITHUB_SHA env var. 54 | ``` 55 | 56 | notes: 57 | 58 | * This assumes the target file already exist in the target repo. 59 | 60 | ## YAML based value replace 61 | 62 | ```shell 63 | Bump artifact version in a file using yaml selector. 64 | This will open a pull request in the target repo. 65 | This command uses yq selector to find the yaml value to replace. 66 | 67 | Usage: 68 | telefonistka bump-yaml [flags] 69 | 70 | Flags: 71 | --address string Yaml value address described as a yq selector, e.g. '.db.[] | select(.name == "postgres").image.tag'. 72 | --auto-merge Automatically merges the created PR, defaults to false. 73 | -g, --github-host string GitHub instance HOSTNAME, defaults to "github.com". This is used for GitHub Enterprise Server instances. 74 | -h, --help help for bump-yaml 75 | -n, --replacement-string string Replacement string that includes the version value of new artifact, e.g. 'v2.7.1'. 76 | -f, --target-file string Target file path(from repo root), defaults to TARGET_FILE env var. 77 | -t, --target-repo string Target Git repository slug(e.g. org-name/repo-name), defaults to TARGET_REPO env var. 78 | -a, --triggering-actor string GitHub user of the person/bot who triggered the bump, defaults to GITHUB_ACTOR env var. 79 | -p, --triggering-repo octocat/Hello-World Github repo triggering the version bump(e.g. octocat/Hello-World) defaults to GITHUB_REPOSITORY env var. 80 | -s, --triggering-repo-sha string Git SHA of triggering repo, defaults to GITHUB_SHA env var. 81 | ``` 82 | 83 | notes: 84 | 85 | * This assumes the target file already exist in the target repo. 86 | -------------------------------------------------------------------------------- /docs/argocd.md: -------------------------------------------------------------------------------- 1 | # ArgoCD-specific features 2 | 3 | While Telefonistka was initially written to be agnostic of the IaC stack some ArgoCD specific features where added recently, this document describes them. 4 | 5 | ## Commenting diff on PRs 6 | 7 | In most cases users directly manipulate Kubernetes manifests in their DRY form (Helm chart/value files or Kustomize configuration), causing a change to have unexpected results in the rendered manifests. Additionally, the state of the in-cluster objects is not always known in advance which prevents the users from knowing the exact change that will happen in the cluster after merging a PR. 8 | 9 | Posting the differences between the cluster objects and the manifests rendered from the PR branch helps the PR author and reviewer better understand the effects of a PR merge. 10 | 11 | In cases where the rendered diff output goes over the maximum GitHub comment size limit, Telefonistka will try to split each ArgoCD application diff into a separate comment. 12 | 13 | If a single application diff is still bigger that the max comment size, Telefonistka will only list the changed objects instead of showing the entire changed content. 14 | 15 | If the list of changed objects pushed the comment size beyond the max size Telefonistka will fail. 16 | 17 | Telefonistka can even "diff" new applications, ones that do not yet have an ArgoCD application object (e.g. the application has not been merged to main yet). But this feature is currently implemented in a somewhat opinionated way and only support applications created by `ApplicationSets` with a Git Directory Generator or a Custom Plugin Generator that accept a `Path` parameter. 18 | 19 | This behavior is gated behind the `argocd.createTempAppObjectFromNewApps` [configuration key](installation.md). 20 | 21 | Example: 22 | 23 | 24 | image 25 | 26 | 27 | ### Re-trigger the Telefonistka diff 28 | 29 | It is possible to trigger a diff comment from Telefonistka by either 30 | 31 | * pushing a new, potentially empty, commit; or 32 | * commenting `/retrigger` on the pull request 33 | 34 | Doing this would have Telefonistka add a new comment with the contents of the found diff calculated as per above. 35 | 36 | ## Warn user on changes to unhealthy/OutOfSync apps 37 | 38 | Telefonistka checks the state of the ArgoCD application and adds warning for this states: 39 | 40 | 1) App is "Unhealthy" 41 | 42 | 2) App is "OutOfSync" 43 | 44 | 3) `auto-sync` is not enabled 45 | 46 | Example: 47 | 48 | 49 | image 50 | 51 | 52 | ## Selectively allow temporary syncing of applications from non main branch 53 | 54 | While displaying a diff in the PR can catch most templating issues, sometime testing a change in a non production environment is needed. If you want to test the configuration before merging the PR you can selectively allow a PR that manipulate files in specific folders to include the `Set ArgoCD apps Target Revision to ` checkbox. 55 | 56 | ![image](https://github.com/user-attachments/assets/c2b5c56b-865f-411d-9b72-e8cc0001151f) 57 | 58 | If the checkbox is marked Telefonistka will set the ArgoCD application object `/spec/source/targetRevision` key to the PR branch. If you have `auto-sync` enabled ArgoCD will sync the workload object from the branch. 59 | 60 | On PR merge, Telefonistka will revert `/spec/source/targetRevision` back to the main branch. 61 | 62 | > [!Note] 63 | > As of the time of this writing, Telefonistka will **not** revert `/spec/source/targetRevision` to the main branch when you uncheck the checkbox, only on PR merge. 64 | 65 | This feature is gated with the `argocd.allowSyncfromBranchPathRegex` configuration key. 66 | 67 | This example configuration will enable synchronising from a non-main branch feature for PRs that only manipulate files under the `env/staging/` folder: 68 | 69 | ```yaml 70 | argocd: 71 | allowSyncfromBranchPathRegex: '^env/staging/.*$' 72 | ``` 73 | 74 | > [!NOTE] 75 | > The `ApplicationSet` controller might need to be configured to ignore changes to this specific key, like so: 76 | > 77 | > ```yaml 78 | > spec: 79 | > ignoreApplicationDifferences: 80 | > - jsonPointers: 81 | > - /spec/source/targetRevision 82 | > ``` 83 | 84 | ## AutoMerge "no diff" Promotion PRs 85 | 86 | When Telefonistka promote a change it copies the component folder in its entirety. This can lead to situations where a promotion PR is opened but does not affect a promotion target, either because the nature of the change (whitespace/doc) or because the resulting rendered manifests does not change **for the target clusters** (like when you change a target-specific Helm value/Kustomize configuration). 87 | 88 | In those cases Telefonistka can auto-merge the promotion PR, saving the effort of merging the PR and preventing future changes from getting an environment drift warning (TODO link). 89 | 90 | This behavior is gated behind the `argocd.autoMergeNoDiffPRs` [configuration key](installation.md). 91 | 92 | ## Proxy github webhooks 93 | 94 | While not strictly an "ArgoCD feature" Telefonistka's ability to proxy webhooks can provide greater flexibility in configuration and securing webhook delivery to ArgoCD server and the ApplicationSet controller, see [here](webhook_multiplexing.md) 95 | -------------------------------------------------------------------------------- /cmd/telefonistka/bump-version-yaml.go: -------------------------------------------------------------------------------- 1 | package telefonistka 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/commercetools/telefonistka/internal/pkg/githubapi" 10 | lru "github.com/hashicorp/golang-lru/v2" 11 | "github.com/hexops/gotextdiff" 12 | "github.com/hexops/gotextdiff/myers" 13 | "github.com/hexops/gotextdiff/span" 14 | "github.com/mikefarah/yq/v4/pkg/yqlib" 15 | log "github.com/sirupsen/logrus" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | // This is still(https://github.com/spf13/cobra/issues/1862) the documented way to use cobra 20 | func init() { //nolint:gochecknoinits 21 | var targetRepo string 22 | var targetFile string 23 | var address string 24 | var replacement string 25 | var githubHost string 26 | var triggeringRepo string 27 | var triggeringRepoSHA string 28 | var triggeringActor string 29 | var autoMerge bool 30 | eventCmd := &cobra.Command{ 31 | Use: "bump-yaml", 32 | Short: "Bump artifact version in a file using yaml selector", 33 | Long: `Bump artifact version in a file using yaml selector. 34 | This will open a pull request in the target repo. 35 | This command uses yq selector to find the yaml value to replace. 36 | `, 37 | Args: cobra.ExactArgs(0), 38 | Run: func(cmd *cobra.Command, args []string) { 39 | bumpVersionYaml(targetRepo, targetFile, address, replacement, githubHost, triggeringRepo, triggeringRepoSHA, triggeringActor, autoMerge) 40 | }, 41 | } 42 | eventCmd.Flags().StringVarP(&targetRepo, "target-repo", "t", getEnv("TARGET_REPO", ""), "Target Git repository slug(e.g. org-name/repo-name), defaults to TARGET_REPO env var.") 43 | eventCmd.Flags().StringVarP(&targetFile, "target-file", "f", getEnv("TARGET_FILE", ""), "Target file path(from repo root), defaults to TARGET_FILE env var.") 44 | eventCmd.Flags().StringVar(&address, "address", "", "Yaml value address described as a yq selector, e.g. '.db.[] | select(.name == \"postgres\").image.tag'.") 45 | eventCmd.Flags().StringVarP(&replacement, "replacement-string", "n", "", "Replacement string that includes the version value of new artifact, e.g. 'v2.7.1'.") 46 | eventCmd.Flags().StringVarP(&githubHost, "github-host", "g", "", "GitHub instance HOSTNAME, defaults to \"github.com\". This is used for GitHub Enterprise Server instances.") 47 | eventCmd.Flags().StringVarP(&triggeringRepo, "triggering-repo", "p", getEnv("GITHUB_REPOSITORY", ""), "Github repo triggering the version bump(e.g. `octocat/Hello-World`) defaults to GITHUB_REPOSITORY env var.") 48 | eventCmd.Flags().StringVarP(&triggeringRepoSHA, "triggering-repo-sha", "s", getEnv("GITHUB_SHA", ""), "Git SHA of triggering repo, defaults to GITHUB_SHA env var.") 49 | eventCmd.Flags().StringVarP(&triggeringActor, "triggering-actor", "a", getEnv("GITHUB_ACTOR", ""), "GitHub user of the person/bot who triggered the bump, defaults to GITHUB_ACTOR env var.") 50 | eventCmd.Flags().BoolVar(&autoMerge, "auto-merge", false, "Automatically merges the created PR, defaults to false.") 51 | rootCmd.AddCommand(eventCmd) 52 | } 53 | 54 | func bumpVersionYaml(targetRepo string, targetFile string, address string, value string, githubHost string, triggeringRepo string, triggeringRepoSHA string, triggeringActor string, autoMerge bool) { 55 | ctx := context.Background() 56 | var githubRestAltURL string 57 | 58 | if githubHost != "" { 59 | githubRestAltURL = "https://" + githubHost + "/api/v3" 60 | log.Infof("Github REST API endpoint is configured to %s", githubRestAltURL) 61 | } 62 | var mainGithubClientPair githubapi.GhClientPair 63 | mainGhClientCache, _ := lru.New[string, githubapi.GhClientPair](128) 64 | 65 | mainGithubClientPair.GetAndCache(mainGhClientCache, "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_OAUTH_TOKEN", strings.Split(targetRepo, "/")[0], ctx) 66 | 67 | var ghPrClientDetails githubapi.GhPrClientDetails 68 | 69 | ghPrClientDetails.GhClientPair = &mainGithubClientPair 70 | ghPrClientDetails.Ctx = ctx 71 | ghPrClientDetails.Owner = strings.Split(targetRepo, "/")[0] 72 | ghPrClientDetails.Repo = strings.Split(targetRepo, "/")[1] 73 | ghPrClientDetails.PrLogger = log.WithFields(log.Fields{}) // TODO what fields should be here? 74 | 75 | defaultBranch, _ := ghPrClientDetails.GetDefaultBranch() 76 | 77 | initialFileContent, _, err := githubapi.GetFileContent(ghPrClientDetails, defaultBranch, targetFile) 78 | if err != nil { 79 | ghPrClientDetails.PrLogger.Errorf("Fail to fetch file content:%s\n", err) 80 | os.Exit(1) 81 | } 82 | newFileContent, err := updateYaml(initialFileContent, address, value) 83 | if err != nil { 84 | ghPrClientDetails.PrLogger.Errorf("Fail to update yaml:%s\n", err) 85 | os.Exit(1) 86 | } 87 | 88 | edits := myers.ComputeEdits(span.URIFromPath(""), initialFileContent, newFileContent) 89 | ghPrClientDetails.PrLogger.Infof("Diff:\n%s", gotextdiff.ToUnified("Before", "After", initialFileContent, edits)) 90 | 91 | err = githubapi.BumpVersion(ghPrClientDetails, "main", targetFile, newFileContent, triggeringRepo, triggeringRepoSHA, triggeringActor, autoMerge) 92 | if err != nil { 93 | log.Errorf("Failed to bump version: %v", err) 94 | os.Exit(1) 95 | } 96 | } 97 | 98 | func updateYaml(yamlContent string, address string, value string) (string, error) { 99 | yqExpression := fmt.Sprintf("(%s)=\"%s\"", address, value) 100 | 101 | preferences := yqlib.NewDefaultYamlPreferences() 102 | evaluate, err := yqlib.NewStringEvaluator().Evaluate(yqExpression, yamlContent, yqlib.NewYamlEncoder(preferences), yqlib.NewYamlDecoder(preferences)) 103 | if err != nil { 104 | return "", err 105 | } 106 | return evaluate, nil 107 | } 108 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/drift_detection.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | 8 | prom "github.com/commercetools/telefonistka/internal/pkg/prometheus" 9 | "github.com/google/go-github/v62/github" 10 | "github.com/hexops/gotextdiff" 11 | "github.com/hexops/gotextdiff/myers" 12 | "github.com/hexops/gotextdiff/span" 13 | ) 14 | 15 | func generateDiffOutput(ghPrClientDetails GhPrClientDetails, defaultBranch string, sourceFilesSHAs map[string]string, targetFilesSHAs map[string]string, sourcePath string, targetPath string) (bool, string, error) { 16 | var hasDiff bool 17 | var diffOutput bytes.Buffer 18 | var filesWithDiff []string 19 | diffOutput.WriteString("\n```diff\n") 20 | 21 | // staring with collecting files with different content and file only present in the source dir 22 | for filename, sha := range sourceFilesSHAs { 23 | ghPrClientDetails.PrLogger.Debugf("Looking at file %s", filename) 24 | if targetPathfileSha, found := targetFilesSHAs[filename]; found { 25 | if sha != targetPathfileSha { 26 | ghPrClientDetails.PrLogger.Debugf("%s is different from %s", sourcePath+"/"+filename, targetPath+"/"+filename) 27 | hasDiff = true 28 | sourceFileContent, _, _ := GetFileContent(ghPrClientDetails, defaultBranch, sourcePath+"/"+filename) 29 | targetFileContent, _, _ := GetFileContent(ghPrClientDetails, defaultBranch, targetPath+"/"+filename) 30 | 31 | edits := myers.ComputeEdits(span.URIFromPath(filename), sourceFileContent, targetFileContent) 32 | diffOutput.WriteString(fmt.Sprint(gotextdiff.ToUnified(sourcePath+"/"+filename, targetPath+"/"+filename, sourceFileContent, edits))) 33 | filesWithDiff = append(filesWithDiff, sourcePath+"/"+filename) 34 | } else { 35 | ghPrClientDetails.PrLogger.Debugf("%s is identical to %s", sourcePath+"/"+filename, targetPath+"/"+filename) 36 | } 37 | } else { 38 | hasDiff = true 39 | diffOutput.WriteString(fmt.Sprintf("--- %s/%s (missing from target dir %s)\n", sourcePath, filename, targetPath)) 40 | } 41 | } 42 | 43 | // then going over the target to check files that only exists there 44 | for filename := range targetFilesSHAs { 45 | if _, found := sourceFilesSHAs[filename]; !found { 46 | diffOutput.WriteString(fmt.Sprintf("+++ %s/%s (missing from source dir %s)\n", targetPath, filename, sourcePath)) 47 | hasDiff = true 48 | } 49 | } 50 | 51 | diffOutput.WriteString("\n```\n") 52 | 53 | if len(filesWithDiff) != 0 { 54 | diffOutput.WriteString("\n### Blame Links:\n") 55 | blameUrlPrefix := ghPrClientDetails.getBlameURLPrefix() 56 | 57 | for _, f := range filesWithDiff { 58 | diffOutput.WriteString("[" + f + "](" + blameUrlPrefix + "/HEAD/" + f + ")\n") // TODO consider switching HEAD to specific SHA 59 | } 60 | } 61 | 62 | return hasDiff, diffOutput.String(), nil 63 | } 64 | 65 | func CompareRepoDirectories(ghPrClientDetails GhPrClientDetails, sourcePath string, targetPath string, defaultBranch string) (bool, string, error) { 66 | // Compares two directories content 67 | 68 | // comparing sourcePath targetPath Git object SHA to avoid costly tree compare: 69 | sourcePathGitObjectSha, err := getDirecotyGitObjectSha(ghPrClientDetails, sourcePath, defaultBranch) 70 | if err != nil { 71 | ghPrClientDetails.PrLogger.Errorf("Couldn't get %v, Git object sha: %v", sourcePath, err) 72 | return false, "", err 73 | } 74 | targetPathGitObjectSha, err := getDirecotyGitObjectSha(ghPrClientDetails, targetPath, defaultBranch) 75 | if err != nil { 76 | ghPrClientDetails.PrLogger.Errorf("Couldn't get %v, Git object sha: %v", targetPath, err) 77 | return false, "", err 78 | } 79 | 80 | if sourcePathGitObjectSha == targetPathGitObjectSha { 81 | ghPrClientDetails.PrLogger.Debugf("%s(%s) vs %s(%s) git object SHA matched.", sourcePath, sourcePathGitObjectSha, targetPath, targetPathGitObjectSha) 82 | return false, "", nil 83 | } else { 84 | ghPrClientDetails.PrLogger.Debugf("%s(%s) vs %s(%s) git object SHA didn't match! Will do a full tree compare", sourcePath, sourcePathGitObjectSha, targetPath, targetPathGitObjectSha) 85 | sourceFilesSHAs := make(map[string]string) 86 | targetFilesSHAs := make(map[string]string) 87 | hasDiff := false 88 | 89 | generateFlatMapfromFileTree(&ghPrClientDetails, &sourcePath, &sourcePath, &defaultBranch, sourceFilesSHAs) 90 | generateFlatMapfromFileTree(&ghPrClientDetails, &targetPath, &targetPath, &defaultBranch, targetFilesSHAs) 91 | // ghPrClientDetails.PrLogger.Infoln(sourceFilesSHAs) 92 | hasDiff, diffOutput, err := generateDiffOutput(ghPrClientDetails, defaultBranch, sourceFilesSHAs, targetFilesSHAs, sourcePath, targetPath) 93 | 94 | return hasDiff, diffOutput, err 95 | } 96 | } 97 | 98 | func generateFlatMapfromFileTree(ghPrClientDetails *GhPrClientDetails, workingPath *string, rootPath *string, branch *string, listOfFiles map[string]string) { 99 | getContentOpts := &github.RepositoryContentGetOptions{ 100 | Ref: *branch, 101 | } 102 | _, directoryContent, resp, _ := ghPrClientDetails.GhClientPair.v3Client.Repositories.GetContents(ghPrClientDetails.Ctx, ghPrClientDetails.Owner, ghPrClientDetails.Repo, *workingPath, getContentOpts) 103 | prom.InstrumentGhCall(resp) 104 | for _, elementInDir := range directoryContent { 105 | if *elementInDir.Type == "file" { 106 | relativeName := strings.TrimPrefix(*elementInDir.Path, *rootPath+"/") 107 | listOfFiles[relativeName] = *elementInDir.SHA 108 | } else if *elementInDir.Type == "dir" { 109 | generateFlatMapfromFileTree(ghPrClientDetails, elementInDir.Path, rootPath, branch, listOfFiles) 110 | } else { 111 | ghPrClientDetails.PrLogger.Infof("Ignoring type %s for path %s", *elementInDir.Type, *elementInDir.Path) 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/Basic_templating.md: -------------------------------------------------------------------------------- 1 | Diff of ArgoCD applications: 2 | 3 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 4 | 5 |
ArgoCD Diff(Click to expand): 6 | 7 | ```diff 8 | 9 | /Service/ssllabs-exporter: 10 | --- old-lorem-ipsum.yaml 11 | +++ new-lorem-ipsum.yaml 12 | @@ -11,7 +11,7 @@ 13 | consectetur: 14 | adipiscing: "elit" 15 | sed: 16 | - - "do eiusmod" 17 | + - "do eiusmod tempor" 18 | - "tempor incididunt" 19 | - "ut labore" 20 | et: 21 | @@ -17,7 +17,7 @@ 22 | enim: "ad minim veniam" 23 | quis: "nostrud exercitation" 24 | ullamco: 25 | - laboris: "nisi ut aliquip" 26 | + laboris: "nisi ut aliquip ex" 27 | ex: 28 | ea: "commodo consequat" 29 | duis: 30 | @@ -31,7 +31,7 @@ 31 | excepteur: 32 | sint: "occaecat cupidatat non proident" 33 | sunt: 34 | - in: "culpa qui officia" 35 | + in: "culpa qui officia deserunt" 36 | deserunt: "mollit anim id est laborum" 37 | /Deployment/ssllabs-exporter: 38 | --- old-lorem-ipsum.yaml 39 | +++ new-lorem-ipsum.yaml 40 | @@ -11,7 +11,7 @@ 41 | consectetur: 42 | adipiscing: "elit" 43 | sed: 44 | - - "do eiusmod" 45 | + - "do eiusmod tempor" 46 | - "tempor incididunt" 47 | - "ut labore" 48 | et: 49 | @@ -17,7 +17,7 @@ 50 | enim: "ad minim veniam" 51 | quis: "nostrud exercitation" 52 | ullamco: 53 | - laboris: "nisi ut aliquip" 54 | + laboris: "nisi ut aliquip ex" 55 | ex: 56 | ea: "commodo consequat" 57 | duis: 58 | @@ -31,7 +31,7 @@ 59 | excepteur: 60 | sint: "occaecat cupidatat non proident" 61 | sunt: 62 | - in: "culpa qui officia" 63 | + in: "culpa qui officia deserunt" 64 | deserunt: "mollit anim id est laborum" 65 | 66 | 67 | ``` 68 | 69 |
70 | 71 | **[temp-ssllab-test-plg-aws-eu-central1-v2](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v2/special-delivery/ssllab-test/ssllab-test` 72 | 73 |
ArgoCD Diff(Click to expand): 74 | 75 | ```diff 76 | 77 | /Service/ssllabs-exporter: 78 | --- old-lorem-ipsum.yaml 79 | +++ new-lorem-ipsum.yaml 80 | @@ -11,7 +11,7 @@ 81 | consectetur: 82 | adipiscing: "elit" 83 | sed: 84 | - - "do eiusmod" 85 | + - "do eiusmod tempor" 86 | - "tempor incididunt" 87 | - "ut labore" 88 | et: 89 | @@ -17,7 +17,7 @@ 90 | enim: "ad minim veniam" 91 | quis: "nostrud exercitation" 92 | ullamco: 93 | - laboris: "nisi ut aliquip" 94 | + laboris: "nisi ut aliquip ex" 95 | ex: 96 | ea: "commodo consequat" 97 | duis: 98 | @@ -31,7 +31,7 @@ 99 | excepteur: 100 | sint: "occaecat cupidatat non proident" 101 | sunt: 102 | - in: "culpa qui officia" 103 | + in: "culpa qui officia deserunt" 104 | deserunt: "mollit anim id est laborum" 105 | /Deployment/ssllabs-exporter: 106 | --- old-lorem-ipsum.yaml 107 | +++ new-lorem-ipsum.yaml 108 | @@ -11,7 +11,7 @@ 109 | consectetur: 110 | adipiscing: "elit" 111 | sed: 112 | - - "do eiusmod" 113 | + - "do eiusmod tempor" 114 | - "tempor incididunt" 115 | - "ut labore" 116 | et: 117 | @@ -17,7 +17,7 @@ 118 | enim: "ad minim veniam" 119 | quis: "nostrud exercitation" 120 | ullamco: 121 | - laboris: "nisi ut aliquip" 122 | + laboris: "nisi ut aliquip ex" 123 | ex: 124 | ea: "commodo consequat" 125 | duis: 126 | @@ -31,7 +31,7 @@ 127 | excepteur: 128 | sint: "occaecat cupidatat non proident" 129 | sunt: 130 | - in: "culpa qui officia" 131 | + in: "culpa qui officia deserunt" 132 | deserunt: "mollit anim id est laborum" 133 | 134 | 135 | ``` 136 | 137 |
138 | 139 | **[temp-ssllab-test-plg-aws-eu-central1-v3](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v3/special-delivery/ssllab-test/ssllab-test` 140 | 141 |
ArgoCD Diff(Click to expand): 142 | 143 | ```diff 144 | 145 | /Service/ssllabs-exporter: 146 | --- old-lorem-ipsum.yaml 147 | +++ new-lorem-ipsum.yaml 148 | @@ -11,7 +11,7 @@ 149 | consectetur: 150 | adipiscing: "elit" 151 | sed: 152 | - - "do eiusmod" 153 | + - "do eiusmod tempor" 154 | - "tempor incididunt" 155 | - "ut labore" 156 | et: 157 | @@ -17,7 +17,7 @@ 158 | enim: "ad minim veniam" 159 | quis: "nostrud exercitation" 160 | ullamco: 161 | - laboris: "nisi ut aliquip" 162 | + laboris: "nisi ut aliquip ex" 163 | ex: 164 | ea: "commodo consequat" 165 | duis: 166 | @@ -31,7 +31,7 @@ 167 | excepteur: 168 | sint: "occaecat cupidatat non proident" 169 | sunt: 170 | - in: "culpa qui officia" 171 | + in: "culpa qui officia deserunt" 172 | deserunt: "mollit anim id est laborum" 173 | /Deployment/ssllabs-exporter: 174 | --- old-lorem-ipsum.yaml 175 | +++ new-lorem-ipsum.yaml 176 | @@ -11,7 +11,7 @@ 177 | consectetur: 178 | adipiscing: "elit" 179 | sed: 180 | - - "do eiusmod" 181 | + - "do eiusmod tempor" 182 | - "tempor incididunt" 183 | - "ut labore" 184 | et: 185 | @@ -17,7 +17,7 @@ 186 | enim: "ad minim veniam" 187 | quis: "nostrud exercitation" 188 | ullamco: 189 | - laboris: "nisi ut aliquip" 190 | + laboris: "nisi ut aliquip ex" 191 | ex: 192 | ea: "commodo consequat" 193 | duis: 194 | @@ -31,7 +31,7 @@ 195 | excepteur: 196 | sint: "occaecat cupidatat non proident" 197 | sunt: 198 | - in: "culpa qui officia" 199 | + in: "culpa qui officia deserunt" 200 | deserunt: "mollit anim id est laborum" 201 | 202 | 203 | ``` 204 | 205 |
206 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the [community leaders](MAINTAINERS.md) responsible for enforcement. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 125 | at [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 129 | [Mozilla CoC]: https://github.com/mozilla/diversity 130 | [FAQ]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/output/TestMarkdownGenerator/Part_of_splitted_comment_.md: -------------------------------------------------------------------------------- 1 | Component 3/8: clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test (Split for comment size) 2 | 3 | Diff of ArgoCD applications: 4 | 5 | **[temp-ssllab-test-plg-aws-eu-central1-v1](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test` 6 | 7 |
ArgoCD Diff(Click to expand): 8 | 9 | ```diff 10 | 11 | /Service/ssllabs-exporter: 12 | --- old-lorem-ipsum.yaml 13 | +++ new-lorem-ipsum.yaml 14 | @@ -11,7 +11,7 @@ 15 | consectetur: 16 | adipiscing: "elit" 17 | sed: 18 | - - "do eiusmod" 19 | + - "do eiusmod tempor" 20 | - "tempor incididunt" 21 | - "ut labore" 22 | et: 23 | @@ -17,7 +17,7 @@ 24 | enim: "ad minim veniam" 25 | quis: "nostrud exercitation" 26 | ullamco: 27 | - laboris: "nisi ut aliquip" 28 | + laboris: "nisi ut aliquip ex" 29 | ex: 30 | ea: "commodo consequat" 31 | duis: 32 | @@ -31,7 +31,7 @@ 33 | excepteur: 34 | sint: "occaecat cupidatat non proident" 35 | sunt: 36 | - in: "culpa qui officia" 37 | + in: "culpa qui officia deserunt" 38 | deserunt: "mollit anim id est laborum" 39 | /Deployment/ssllabs-exporter: 40 | --- old-lorem-ipsum.yaml 41 | +++ new-lorem-ipsum.yaml 42 | @@ -11,7 +11,7 @@ 43 | consectetur: 44 | adipiscing: "elit" 45 | sed: 46 | - - "do eiusmod" 47 | + - "do eiusmod tempor" 48 | - "tempor incididunt" 49 | - "ut labore" 50 | et: 51 | @@ -17,7 +17,7 @@ 52 | enim: "ad minim veniam" 53 | quis: "nostrud exercitation" 54 | ullamco: 55 | - laboris: "nisi ut aliquip" 56 | + laboris: "nisi ut aliquip ex" 57 | ex: 58 | ea: "commodo consequat" 59 | duis: 60 | @@ -31,7 +31,7 @@ 61 | excepteur: 62 | sint: "occaecat cupidatat non proident" 63 | sunt: 64 | - in: "culpa qui officia" 65 | + in: "culpa qui officia deserunt" 66 | deserunt: "mollit anim id est laborum" 67 | 68 | 69 | ``` 70 | 71 |
72 | 73 | **[temp-ssllab-test-plg-aws-eu-central1-v2](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v2/special-delivery/ssllab-test/ssllab-test` 74 | 75 |
ArgoCD Diff(Click to expand): 76 | 77 | ```diff 78 | 79 | /Service/ssllabs-exporter: 80 | --- old-lorem-ipsum.yaml 81 | +++ new-lorem-ipsum.yaml 82 | @@ -11,7 +11,7 @@ 83 | consectetur: 84 | adipiscing: "elit" 85 | sed: 86 | - - "do eiusmod" 87 | + - "do eiusmod tempor" 88 | - "tempor incididunt" 89 | - "ut labore" 90 | et: 91 | @@ -17,7 +17,7 @@ 92 | enim: "ad minim veniam" 93 | quis: "nostrud exercitation" 94 | ullamco: 95 | - laboris: "nisi ut aliquip" 96 | + laboris: "nisi ut aliquip ex" 97 | ex: 98 | ea: "commodo consequat" 99 | duis: 100 | @@ -31,7 +31,7 @@ 101 | excepteur: 102 | sint: "occaecat cupidatat non proident" 103 | sunt: 104 | - in: "culpa qui officia" 105 | + in: "culpa qui officia deserunt" 106 | deserunt: "mollit anim id est laborum" 107 | /Deployment/ssllabs-exporter: 108 | --- old-lorem-ipsum.yaml 109 | +++ new-lorem-ipsum.yaml 110 | @@ -11,7 +11,7 @@ 111 | consectetur: 112 | adipiscing: "elit" 113 | sed: 114 | - - "do eiusmod" 115 | + - "do eiusmod tempor" 116 | - "tempor incididunt" 117 | - "ut labore" 118 | et: 119 | @@ -17,7 +17,7 @@ 120 | enim: "ad minim veniam" 121 | quis: "nostrud exercitation" 122 | ullamco: 123 | - laboris: "nisi ut aliquip" 124 | + laboris: "nisi ut aliquip ex" 125 | ex: 126 | ea: "commodo consequat" 127 | duis: 128 | @@ -31,7 +31,7 @@ 129 | excepteur: 130 | sint: "occaecat cupidatat non proident" 131 | sunt: 132 | - in: "culpa qui officia" 133 | + in: "culpa qui officia deserunt" 134 | deserunt: "mollit anim id est laborum" 135 | 136 | 137 | ``` 138 | 139 |
140 | 141 | **[temp-ssllab-test-plg-aws-eu-central1-v3](https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1)** @ `clusters/playground/aws/eu-central-1/v3/special-delivery/ssllab-test/ssllab-test` 142 | 143 |
ArgoCD Diff(Click to expand): 144 | 145 | ```diff 146 | 147 | /Service/ssllabs-exporter: 148 | --- old-lorem-ipsum.yaml 149 | +++ new-lorem-ipsum.yaml 150 | @@ -11,7 +11,7 @@ 151 | consectetur: 152 | adipiscing: "elit" 153 | sed: 154 | - - "do eiusmod" 155 | + - "do eiusmod tempor" 156 | - "tempor incididunt" 157 | - "ut labore" 158 | et: 159 | @@ -17,7 +17,7 @@ 160 | enim: "ad minim veniam" 161 | quis: "nostrud exercitation" 162 | ullamco: 163 | - laboris: "nisi ut aliquip" 164 | + laboris: "nisi ut aliquip ex" 165 | ex: 166 | ea: "commodo consequat" 167 | duis: 168 | @@ -31,7 +31,7 @@ 169 | excepteur: 170 | sint: "occaecat cupidatat non proident" 171 | sunt: 172 | - in: "culpa qui officia" 173 | + in: "culpa qui officia deserunt" 174 | deserunt: "mollit anim id est laborum" 175 | /Deployment/ssllabs-exporter: 176 | --- old-lorem-ipsum.yaml 177 | +++ new-lorem-ipsum.yaml 178 | @@ -11,7 +11,7 @@ 179 | consectetur: 180 | adipiscing: "elit" 181 | sed: 182 | - - "do eiusmod" 183 | + - "do eiusmod tempor" 184 | - "tempor incididunt" 185 | - "ut labore" 186 | et: 187 | @@ -17,7 +17,7 @@ 188 | enim: "ad minim veniam" 189 | quis: "nostrud exercitation" 190 | ullamco: 191 | - laboris: "nisi ut aliquip" 192 | + laboris: "nisi ut aliquip ex" 193 | ex: 194 | ea: "commodo consequat" 195 | duis: 196 | @@ -31,7 +31,7 @@ 197 | excepteur: 198 | sint: "occaecat cupidatat non proident" 199 | sunt: 200 | - in: "culpa qui officia" 201 | + in: "culpa qui officia deserunt" 202 | deserunt: "mollit anim id est laborum" 203 | 204 | 205 | ``` 206 | 207 |
208 | -------------------------------------------------------------------------------- /telefonistka_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "net" 6 | "os" 7 | "os/signal" 8 | "path" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | corev1 "k8s.io/api/core/v1" 15 | ) 16 | 17 | // TestTelefonistka spins up a full integration environment. It requires that 18 | // INTEGRATE=1 is set in the environment. 19 | // 20 | // * Creates a cluster running in Docker 21 | // * Installs Argo CD 22 | // * Creates a Github repository 23 | // * Creates initial commits setting the state of main branch 24 | // * Adds a tracked demo application to Argo CD 25 | // * Creates a pull request with a change to the demo application 26 | // * Starts an up-to-date version of Telefonistka server, connecting it to Argo CD and Github 27 | // * Forwards webhook requests from the Github repository to Telefonistka 28 | // 29 | // Note that it is currently interactive meaning it will run until receiving an 30 | // interrupt signal, or it times out but in this case it will not gracefully 31 | // cleanup resources. Otherwise it will clean up everything that it has created. 32 | // 33 | // Each invocation starts a fresh isolated setup. It is (should be) possible to 34 | // run multiple invocations at the same time. 35 | // 36 | // As of now, a suggested invocation might be 37 | // 38 | // INTEGRATE=1 GITHUB_TOKEN=$(gh auth token) go test -run Telefonistka -v -timeout=30m 39 | // 40 | // When printing logging information, details about a saved kubeconfig copy, 41 | // and Argo CD login details are shown. They can be used to connect to the 42 | // cluster or to login to the Argo CD web UI. 43 | func TestTelefonistka(t *testing.T) { 44 | t.Parallel() 45 | if enabled, _ := strconv.ParseBool(os.Getenv("INTEGRATE")); !enabled { 46 | t.Skip("This is an interactive test; set INTEGRATE explicitly to run it") 47 | } 48 | 49 | // Make test interactive by waiting for explicit interrupt before 50 | // finishing. This allows setting things up so that caller can interact 51 | // with resources before they're all cleaned up. 52 | ctx, cancel := signal.NotifyContext(t.Context(), os.Interrupt) 53 | defer cancel() 54 | defer waitFor(ctx) 55 | 56 | var ( 57 | argoNamespace = "argocd" 58 | argoLocalPort = "8083" 59 | argoContainerPort = "8080" 60 | argoServer = "argocd-server" 61 | argoForwardPorts = []string{strings.Join([]string{argoLocalPort, argoContainerPort}, ":")} 62 | argoForwardAddr = []string{"127.0.0.1"} 63 | argoServerAddr = net.JoinHostPort("localhost", argoLocalPort) 64 | argoUsername = "admin" 65 | argoInitialPasswordSecretName = "argocd-initial-admin-secret" //nolint:gosec // not a password 66 | argoInitialPasswordSecretKey = "password" 67 | ) 68 | 69 | var ( 70 | // TODO make sure this aligns with what is configured when starting the 71 | // server. For now it is hardcoded. 72 | forwardTarget = "http://localhost:8080/webhook" 73 | webhookSecret = rand.Text() 74 | ) 75 | 76 | cluster := newCluster(t) 77 | clientset := newClientset(t, cluster.Config) 78 | gh := newGithubClient(t) 79 | repository := createRepository(t, gh) 80 | conn := newArgoGRPCConnection(t, cluster.Config, argoServerAddr) 81 | 82 | createNamespace(t, clientset.CoreV1().Namespaces(), argoNamespace) 83 | 84 | // Alternatively install without Helm. 85 | // 86 | // installYamlFile := getFile(t, "https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml") 87 | // applyResource(t, cl.Config, argoNamespace, installYamlFile) 88 | 89 | vals := readValuesFile(t, "argo.values.yaml") 90 | releaseExternalChart(t, cluster.TemporaryConfigFile, argoNamespace, "https://argoproj.github.io/argo-helm", "argo-cd", vals) 91 | 92 | waitForReady(t, clientset.CoreV1().RESTClient(), argoNamespace, "Pod", "app.kubernetes.io/name="+argoServer, "", func(o any) { 93 | switch o := o.(type) { 94 | case *corev1.Pod: 95 | portForward(t, cluster.Config, argoNamespace, o.Name, argoForwardAddr, argoForwardPorts) 96 | } 97 | }) 98 | 99 | // Template a demo application, setting it to track the created repository. 100 | var data struct{ RepoURL string } 101 | data.RepoURL = repository.GetHTMLURL() 102 | templated := readTemplate(t, "additional.yaml", data) 103 | applyResource(t, cluster.Config, argoNamespace, templated) 104 | 105 | // At this point we have an initial connection to use, so let's get the 106 | // default password, and login so we get a token we can use. 107 | adminPassword := getDecodedSecret(t, clientset.CoreV1(), argoNamespace, argoInitialPasswordSecretName, argoInitialPasswordSecretKey) 108 | token := newArgoToken(t, conn, argoUsername, adminPassword.String()) 109 | t.Logf("You can log into Argo CD on %q using %q and %q as the password", "https://"+argoServerAddr, argoUsername, adminPassword) 110 | 111 | startTelefonistka(t, token, argoServerAddr, webhookSecret) 112 | 113 | // There is no good way to wait for execution and start of server; TODO: as 114 | // mentioned above, refactor entrypoint such that it will be just as easy 115 | // to spin up an isolated instance in code 116 | time.Sleep(5 * time.Second) 117 | 118 | wsURL := createRepoHook(t, gh, repository, webhookSecret) 119 | 120 | // dst, src 121 | forwardData(t, ctx, forwardTarget, wsURL) 122 | 123 | // Setup initial state of repository based on testdata. 124 | first := createCommit(t, gh, repository, "heads/main", "Initial", os.DirFS(path.Join("testdata", t.Name(), "start"))) 125 | updateRef(t, gh, repository, "heads/main", first.GetSHA()) 126 | 127 | // Create a PR with some changes based on testdata. 128 | branch := createBranch(t, gh, repository, first, "upgrade") 129 | createCommit(t, gh, repository, "heads/upgrade", "Upgrade application", os.DirFS(path.Join("testdata", t.Name(), "pr"))) 130 | 131 | var n TestPR 132 | n.Title = "Upgrade to vX.X.X" 133 | n.Ref = branch.GetRef() 134 | n.Base = "main" 135 | n.Body = "This upgrades to the bleeding edge." 136 | 137 | createPR(t, gh, repository, &n) 138 | } 139 | -------------------------------------------------------------------------------- /internal/pkg/prometheus/prometheus.go: -------------------------------------------------------------------------------- 1 | package prometheus 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/google/go-github/v62/github" 9 | "github.com/prometheus/client_golang/prometheus" 10 | "github.com/prometheus/client_golang/prometheus/promauto" 11 | ) 12 | 13 | type PrCounters struct { 14 | OpenPrs int 15 | OpenPromotionPrs int 16 | PrWithStaleChecks int 17 | } 18 | 19 | var ( 20 | webhookHitsVec = promauto.NewCounterVec(prometheus.CounterOpts{ 21 | Name: "webhook_hits_total", 22 | Help: "The total number of validated webhook hits", 23 | Namespace: "telefonistka", 24 | Subsystem: "webhook_server", 25 | }, []string{"parsing"}) 26 | 27 | ghRateLimitCounter = promauto.NewGaugeVec(prometheus.GaugeOpts{ 28 | Name: "github_rest_api_client_rate_limit", 29 | Help: "The number of requests per hour the client is currently limited to", 30 | Namespace: "telefonistka", 31 | Subsystem: "github", 32 | }, []string{"repo_owner"}) 33 | 34 | ghRateRemainingCounter = promauto.NewGaugeVec(prometheus.GaugeOpts{ 35 | Name: "github_rest_api_client_rate_remaining", 36 | Help: "The number of remaining requests the client can make this hour", 37 | Namespace: "telefonistka", 38 | Subsystem: "github", 39 | }, []string{"repo_owner"}) 40 | 41 | githubOpsCountVec = promauto.NewCounterVec(prometheus.CounterOpts{ 42 | Name: "github_operations_total", 43 | Help: "The total number of Github API operations", 44 | Namespace: "telefonistka", 45 | Subsystem: "github", 46 | }, []string{"api_group", "api_path", "repo_slug", "status", "method"}) 47 | 48 | ghOpenPrsGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ 49 | Name: "open_prs", 50 | Help: "The total number of open PRs", 51 | Namespace: "telefonistka", 52 | Subsystem: "github", 53 | }, []string{"repo_slug"}) 54 | 55 | ghOpenPromotionPrsGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ 56 | Name: "open_promotion_prs", 57 | Help: "The total number of open PRs with promotion label", 58 | Namespace: "telefonistka", 59 | Subsystem: "github", 60 | }, []string{"repo_slug"}) 61 | 62 | ghOpenPrsWithPendingCheckGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ 63 | Name: "open_prs_with_pending_telefonistka_checks", 64 | Help: "The total number of open PRs with pending Telefonistka checks(excluding PRs with very recent commits)", 65 | Namespace: "telefonistka", 66 | Subsystem: "github", 67 | }, []string{"repo_slug"}) 68 | 69 | commitStatusUpdates = promauto.NewCounterVec(prometheus.CounterOpts{ 70 | Name: "commit_status_updates_total", 71 | Help: "The total number of commit status updates, and their status (success/pending/failure)", 72 | Namespace: "telefonistka", 73 | Subsystem: "github", 74 | }, []string{"repo_slug", "status"}) 75 | 76 | whUpstreamRequestsCountVec = promauto.NewCounterVec(prometheus.CounterOpts{ 77 | Name: "upstream_requests_total", 78 | Help: "The total number of requests forwarded upstream servers", 79 | Namespace: "telefonistka", 80 | Subsystem: "webhook_proxy", 81 | }, []string{"status", "method", "url"}) 82 | ) 83 | 84 | func IncCommitStatusUpdateCounter(repoSlug string, status string) { 85 | commitStatusUpdates.With(prometheus.Labels{ 86 | "repo_slug": repoSlug, 87 | "status": status, 88 | }).Inc() 89 | } 90 | 91 | func PublishPrMetrics(pc PrCounters, repoSlug string) { 92 | metricLables := prometheus.Labels{ 93 | "repo_slug": repoSlug, 94 | } 95 | ghOpenPrsGauge.With(metricLables).Set(float64(pc.OpenPrs)) 96 | ghOpenPromotionPrsGauge.With(metricLables).Set(float64(pc.OpenPromotionPrs)) 97 | ghOpenPrsWithPendingCheckGauge.With(metricLables).Set(float64(pc.PrWithStaleChecks)) 98 | } 99 | 100 | // This function instrument Webhook hits and parsing of their content 101 | func InstrumentWebhookHit(parsing_status string) { 102 | webhookHitsVec.With(prometheus.Labels{"parsing": parsing_status}).Inc() 103 | } 104 | 105 | // This function instrument API calls to GitHub API 106 | func InstrumentGhCall(resp *github.Response) prometheus.Labels { 107 | if resp == nil { 108 | return prometheus.Labels{} 109 | } 110 | requestPathSlice := strings.Split(resp.Request.URL.Path, "/") 111 | var relevantRequestPathSlice []string 112 | // GitHub enterprise API as an additional "api/v3" perfix 113 | if requestPathSlice[1] == "api" && requestPathSlice[2] == "v3" { 114 | relevantRequestPathSlice = requestPathSlice[3:] 115 | } else { 116 | relevantRequestPathSlice = requestPathSlice[1:] 117 | } 118 | var apiPath string 119 | var repoSlug string 120 | var repoOwner string 121 | 122 | if len(relevantRequestPathSlice) < 4 { 123 | apiPath = "" 124 | if len(relevantRequestPathSlice) < 3 { 125 | repoSlug = "" 126 | repoOwner = "" 127 | } else { 128 | repoSlug = strings.Join(relevantRequestPathSlice[1:3], "/") 129 | repoOwner = relevantRequestPathSlice[1] 130 | } 131 | } else { 132 | apiPath = relevantRequestPathSlice[3] 133 | repoSlug = strings.Join(relevantRequestPathSlice[1:3], "/") 134 | repoOwner = relevantRequestPathSlice[1] 135 | } 136 | 137 | labels := prometheus.Labels{ 138 | "api_group": relevantRequestPathSlice[0], 139 | "api_path": apiPath, 140 | "repo_slug": repoSlug, 141 | "method": resp.Request.Method, 142 | "status": strconv.Itoa(resp.Response.StatusCode), 143 | } 144 | 145 | rateLimitLables := prometheus.Labels{ 146 | "repo_owner": repoOwner, 147 | } 148 | ghRateLimitCounter.With(rateLimitLables).Set(float64(resp.Rate.Limit)) 149 | ghRateRemainingCounter.With(rateLimitLables).Set(float64(resp.Rate.Remaining)) 150 | 151 | githubOpsCountVec.With(labels).Inc() 152 | // resp.Request. 153 | 154 | return labels 155 | } 156 | 157 | // This function instrument upstream webhooks for the WH forwarding/multiplexing feature 158 | func InstrumentProxyUpstreamRequest(resp *http.Response) prometheus.Labels { 159 | if resp == nil { 160 | return prometheus.Labels{} 161 | } 162 | 163 | labels := prometheus.Labels{ 164 | "method": resp.Request.Method, 165 | "status": strconv.Itoa(resp.StatusCode), 166 | "url": resp.Request.URL.String(), 167 | } 168 | whUpstreamRequestsCountVec.With(labels).Inc() 169 | return labels 170 | } 171 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish-on-comment.yml: -------------------------------------------------------------------------------- 1 | name: oci-image-publish-on-comment 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | workflow_dispatch: 10 | issue_comment: 11 | types: [created] 12 | 13 | 14 | env: 15 | DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} 16 | IMAGE_NAME: ${{ vars.IMAGE_NAME }} 17 | REGISTRY: ${{ vars.REGISTRY }} 18 | 19 | jobs: 20 | build: 21 | env: 22 | CGO_ENABLED: 0 23 | runs-on: ubuntu-latest 24 | if: github.event.issue.pull_request && contains(github.event.comment.body, '/publish') 25 | permissions: 26 | contents: read 27 | packages: write 28 | # This is used to complete the identity challenge 29 | # with sigstore/fulcio when running outside of PRs. 30 | id-token: write 31 | statuses: write 32 | pull-requests: write 33 | issues: write 34 | 35 | steps: 36 | - name: Get PR branch 37 | uses: xt0rted/pull-request-comment-branch@v3 38 | id: comment-branch 39 | - name: Checkout repository 40 | uses: actions/checkout@v4 41 | with: 42 | ref: ${{ steps.comment-branch.outputs.head_ref }} 43 | - name: Set latest commit status as pending 44 | uses: myrotvorets/set-commit-status-action@master 45 | with: 46 | sha: ${{ steps.comment-branch.outputs.head_sha }} 47 | token: ${{ secrets.GITHUB_TOKEN }} 48 | status: pending 49 | 50 | - run: echo epoch=$(date +%s) > $GITHUB_OUTPUT 51 | id: time 52 | - uses: actions/cache/restore@v4 53 | with: 54 | path: | 55 | ~/.cache/go-build 56 | ~/go/pkg/mod 57 | key: go-mod-v1-${{ github.ref_name }}-${{ hashFiles('go.mod') }}-${{ steps.time.outputs.epoch }} 58 | restore-keys: | 59 | go-mod-v1-${{ github.ref_name }}-${{ hashFiles('go.mod') }} 60 | go-mod-v1-${{ github.ref_name }}- 61 | go-mod-v1-${{ github.event.repository.default_branch }}-${{ hashFiles('go.mod') }} 62 | go-mod-v1-${{ github.event.repository.default_branch }}- 63 | - uses: actions/setup-go@v5 64 | with: 65 | go-version-file: go.mod 66 | cache: false 67 | - run: go mod tidy -diff 68 | - run: go test -v ./... 69 | - run: go test -v -bench . -run "^$" ./... 70 | - run: go build -o telefonistka . 71 | - uses: actions/cache/save@v4 72 | with: 73 | key: go-mod-v1-${{ github.ref_name }}-${{ hashFiles('go.mod') }}-${{ steps.time.outputs.epoch }} 74 | path: | 75 | ~/.cache/go-build 76 | ~/go/pkg/mod 77 | 78 | # Workaround: https://github.com/docker/build-push-action/issues/461 79 | - name: Setup Docker buildx 80 | uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 81 | 82 | # Login against a Docker registry except on PR 83 | # https://github.com/docker/login-action 84 | - name: Log into GH registry (ghcr.io) 85 | uses: docker/login-action@7ca345011ac4304463197fac0e56eab1bc7e6af0 86 | with: 87 | registry: ghcr.io 88 | username: ${{ github.actor }} 89 | password: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: Log into Docker Hub registry 92 | if: env.DOCKERHUB_USERNAME != '' 93 | uses: docker/login-action@7ca345011ac4304463197fac0e56eab1bc7e6af0 94 | with: 95 | username: ${{ env.DOCKERHUB_USERNAME }} 96 | password: ${{ secrets.DOCKERHUB_TOKEN }} 97 | 98 | # Extract metadata (tags, labels) for Docker 99 | # https://github.com/docker/metadata-action 100 | # 1st image name is for GH package repo 101 | # 2nd image name is for DockerHub image 102 | - name: Extract Docker metadata 103 | id: meta 104 | uses: docker/metadata-action@906ecf0fc0a80f9110f79d9e6c04b1080f4a2621 105 | with: 106 | context: git 107 | images: | 108 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 109 | tags: | 110 | type=ref,event=branch 111 | type=ref,event=pr 112 | type=sha 113 | 114 | 115 | # Build and push Docker image with Buildx (don't push on PR) 116 | # https://github.com/docker/build-push-action 117 | - name: Build and push Docker image 118 | id: build-and-push 119 | uses: docker/build-push-action@7e094594beda23fc8f21fa31049f4b203e51096b 120 | with: 121 | context: . 122 | push: true 123 | tags: ${{ steps.meta.outputs.tags }} 124 | labels: ${{ steps.meta.outputs.labels }} 125 | cache-from: type=gha 126 | cache-to: type=gha,mode=max 127 | 128 | - name: Extract Docker metadata - alpine 129 | id: meta-alpine 130 | uses: docker/metadata-action@906ecf0fc0a80f9110f79d9e6c04b1080f4a2621 131 | with: 132 | context: git 133 | images: | 134 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 135 | tags: | 136 | type=ref,event=branch 137 | type=ref,event=pr 138 | type=sha 139 | flavor: prefix=alpine-,onlatest=true 140 | - name: Build and push Docker image - alpine 141 | id: build-and-push-alpine 142 | uses: docker/build-push-action@7e094594beda23fc8f21fa31049f4b203e51096b 143 | with: 144 | context: . 145 | target: alpine-release 146 | push: true 147 | tags: ${{ steps.meta-alpine.outputs.tags }} 148 | labels: ${{ steps.meta-alpine.outputs.labels }} 149 | cache-from: type=gha 150 | cache-to: type=gha,mode=max 151 | - name: Set latest commit status as ${{ job.status }} 152 | uses: myrotvorets/set-commit-status-action@master 153 | if: always() 154 | with: 155 | sha: ${{ steps.comment-branch.outputs.head_sha }} 156 | token: ${{ secrets.GITHUB_TOKEN }} 157 | status: ${{ job.status }} 158 | - name: Add comment to PR 159 | uses: actions/github-script@v7 160 | if: always() 161 | with: 162 | script: | 163 | const name = '${{ github.workflow }}'; 164 | const url = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'; 165 | const success = '${{ job.status }}' === 'success'; 166 | const body = `${name}: ${success ? 'succeeded ✅' : 'failed ❌'}\n${url}\n${{ steps.meta.outputs.tags }}`; 167 | 168 | await github.rest.issues.createComment({ 169 | issue_number: context.issue.number, 170 | owner: context.repo.owner, 171 | repo: context.repo.repo, 172 | body: body 173 | }) 174 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | # push: 10 | # branches: [ "main" ] 11 | # Publish semver tags as releases. 12 | # tags: [ 'v*.*.*' ] 13 | release: 14 | types: [published] 15 | pull_request: 16 | branches: [ "main" ] 17 | 18 | env: 19 | DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }} 20 | IMAGE_NAME: ${{ vars.IMAGE_NAME }} 21 | REGISTRY: ${{ vars.REGISTRY }} 22 | 23 | jobs: 24 | build: 25 | env: 26 | CGO_ENABLED: 0 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: read 30 | packages: write 31 | # This is used to complete the identity challenge 32 | # with sigstore/fulcio when running outside of PRs. 33 | id-token: write 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v4 38 | 39 | - run: echo epoch=$(date +%s) > $GITHUB_OUTPUT 40 | id: time 41 | - uses: actions/cache/restore@v4 42 | with: 43 | path: | 44 | ~/.cache/go-build 45 | ~/go/pkg/mod 46 | key: go-mod-v1-${{ github.ref_name }}-${{ hashFiles('go.mod') }}-${{ steps.time.outputs.epoch }} 47 | restore-keys: | 48 | go-mod-v1-${{ github.ref_name }}-${{ hashFiles('go.mod') }} 49 | go-mod-v1-${{ github.ref_name }}- 50 | go-mod-v1-${{ github.event.repository.default_branch }}-${{ hashFiles('go.mod') }} 51 | go-mod-v1-${{ github.event.repository.default_branch }}- 52 | - uses: actions/setup-go@v5 53 | with: 54 | go-version-file: go.mod 55 | cache: false 56 | - run: go mod tidy -diff 57 | - run: go test -v ./... 58 | - run: go test -v -bench . -run "^$" ./... 59 | - run: go build -o telefonistka . 60 | - uses: actions/cache/save@v4 61 | with: 62 | key: go-mod-v1-${{ github.ref_name }}-${{ hashFiles('go.mod') }}-${{ steps.time.outputs.epoch }} 63 | path: | 64 | ~/.cache/go-build 65 | ~/go/pkg/mod 66 | 67 | # Install the cosign tool except on PR 68 | # https://github.com/sigstore/cosign-installer 69 | # TODO enable once the repo goes public 70 | # - name: Install cosign 71 | # if: github.event_name != 'pull_request' 72 | # uses: sigstore/cosign-installer@f3c664df7af409cb4873aa5068053ba9d61a57b6 #v2.6.0 73 | # with: 74 | # cosign-release: 'v1.11.0' 75 | 76 | 77 | # Workaround: https://github.com/docker/build-push-action/issues/461 78 | - name: Setup Docker buildx 79 | uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 80 | 81 | # Login against a Docker registry except on PR 82 | # https://github.com/docker/login-action 83 | - name: Log into GH registry (ghcr.io) 84 | if: github.event_name != 'pull_request' 85 | uses: docker/login-action@7ca345011ac4304463197fac0e56eab1bc7e6af0 86 | with: 87 | registry: ghcr.io 88 | username: ${{ github.actor }} 89 | password: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: Log into Docker Hub registry 92 | if: github.event_name != 'pull_request' && env.DOCKERHUB_USERNAME != '' 93 | uses: docker/login-action@7ca345011ac4304463197fac0e56eab1bc7e6af0 94 | with: 95 | username: ${{ env.DOCKERHUB_USERNAME }} 96 | password: ${{ secrets.DOCKERHUB_TOKEN }} 97 | 98 | # Extract metadata (tags, labels) for Docker 99 | # https://github.com/docker/metadata-action 100 | # 1st image name is for GH package repo 101 | # 2nd image name is for DockerHub image 102 | - name: Extract Docker metadata 103 | id: meta 104 | uses: docker/metadata-action@906ecf0fc0a80f9110f79d9e6c04b1080f4a2621 105 | with: 106 | images: | 107 | ${{ env.REGISTRY != '' && env.REGISTRY || 'ghcr.io' }}/${{ env.IMAGE_NAME != '' && env.IMAGE_NAME || 'commercetools/telefonistka'}} 108 | 109 | 110 | # Build and push Docker image with Buildx (don't push on PR) 111 | # https://github.com/docker/build-push-action 112 | - name: Build and push Docker image 113 | id: build-and-push 114 | uses: docker/build-push-action@7e094594beda23fc8f21fa31049f4b203e51096b 115 | with: 116 | context: . 117 | push: ${{ github.event_name != 'pull_request' }} 118 | tags: ${{ steps.meta.outputs.tags }} 119 | labels: ${{ steps.meta.outputs.labels }} 120 | cache-from: type=gha 121 | cache-to: type=gha,mode=max 122 | 123 | # Extract metadata (tags, labels) for Docker 124 | # https://github.com/docker/metadata-action 125 | # 1st image name is for GH package repo 126 | # 2nd image name is for DockerHub image 127 | - name: Extract Docker metadata - alpine 128 | id: meta-alpine 129 | uses: docker/metadata-action@906ecf0fc0a80f9110f79d9e6c04b1080f4a2621 130 | with: 131 | images: | 132 | ${{ env.REGISTRY != '' && env.REGISTRY || 'ghcr.io' }}/${{ env.IMAGE_NAME != '' && env.IMAGE_NAME || 'commercetools/telefonistka'}} 133 | flavor: prefix=alpine-,onlatest=true 134 | 135 | # Build and push Docker image with Buildx (don't push on PR) 136 | # https://github.com/docker/build-push-action 137 | - name: Build and push Docker image - alpine 138 | id: build-and-push-alpine 139 | uses: docker/build-push-action@7e094594beda23fc8f21fa31049f4b203e51096b 140 | with: 141 | context: . 142 | target: alpine-release 143 | push: ${{ github.event_name != 'pull_request' }} 144 | tags: ${{ steps.meta-alpine.outputs.tags }} 145 | labels: ${{ steps.meta-alpine.outputs.labels }} 146 | cache-from: type=gha 147 | cache-to: type=gha,mode=max 148 | 149 | 150 | # Sign the resulting Docker image digest except on PRs. 151 | # This will only write to the public Rekor transparency log when the Docker 152 | # repository is public to avoid leaking data. If you would like to publish 153 | # transparency data even for private images, pass --force to cosign below. 154 | # https://github.com/sigstore/cosign 155 | # TODO enable once the repo goes public 156 | # - name: Sign the published Docker image 157 | # if: ${{ github.event_name != 'pull_request' }} 158 | # env: 159 | # COSIGN_EXPERIMENTAL: "true" 160 | # This step uses the identity token to provision an ephemeral certificate 161 | # against the sigstore community Fulcio instance. 162 | # run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign {}@${{ steps.build-and-push.outputs.digest }} 163 | 164 | -------------------------------------------------------------------------------- /internal/pkg/githubapi/testdata/diff_comment_data_test.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "DiffOfChangedComponents": [ 4 | { 5 | "ComponentPath": "clusters/playground/aws/eu-central-1/v1/special-delivery/ssllab-test/ssllab-test", 6 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v1", 7 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 8 | "DiffElements": [ 9 | { 10 | "ObjectGroup": "", 11 | "ObjectName": "ssllabs-exporter", 12 | "ObjectKind": "Service", 13 | "ObjectNamespace": "", 14 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 15 | }, 16 | { 17 | "ObjectGroup": "apps", 18 | "ObjectName": "ssllabs-exporter", 19 | "ObjectKind": "Deployment", 20 | "ObjectNamespace": "", 21 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 22 | } 23 | ], 24 | "HasDiff": true, 25 | "DiffError": null, 26 | "AppWasTemporarilyCreated": false, 27 | "ArgoCdAppHealthStatus": "Healthy", 28 | "ArgoCdAppSyncStatus": "Synced", 29 | "ArgoCdAppAutoSyncEnabled": true 30 | }, 31 | { 32 | "ComponentPath": "clusters/playground/aws/eu-central-1/v2/special-delivery/ssllab-test/ssllab-test", 33 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v2", 34 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 35 | "DiffElements": [ 36 | { 37 | "ObjectGroup": "", 38 | "ObjectName": "ssllabs-exporter", 39 | "ObjectKind": "Service", 40 | "ObjectNamespace": "", 41 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 42 | }, 43 | { 44 | "ObjectGroup": "apps", 45 | "ObjectName": "ssllabs-exporter", 46 | "ObjectKind": "Deployment", 47 | "ObjectNamespace": "", 48 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 49 | } 50 | ], 51 | "HasDiff": true, 52 | "DiffError": null, 53 | "AppWasTemporarilyCreated": false, 54 | "ArgoCdAppHealthStatus": "Healthy", 55 | "ArgoCdAppSyncStatus": "Synced", 56 | "ArgoCdAppAutoSyncEnabled": true 57 | }, 58 | { 59 | "ComponentPath": "clusters/playground/aws/eu-central-1/v3/special-delivery/ssllab-test/ssllab-test", 60 | "ArgoCdAppName": "temp-ssllab-test-plg-aws-eu-central1-v3", 61 | "ArgoCdAppURL": "https://argocd-lab.example.com/applications/temp-ssllab-test-plg-aws-eu-central1-v1", 62 | "DiffElements": [ 63 | { 64 | "ObjectGroup": "", 65 | "ObjectName": "ssllabs-exporter", 66 | "ObjectKind": "Service", 67 | "ObjectNamespace": "", 68 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 69 | }, 70 | { 71 | "ObjectGroup": "apps", 72 | "ObjectName": "ssllabs-exporter", 73 | "ObjectKind": "Deployment", 74 | "ObjectNamespace": "", 75 | "Diff": "--- old-lorem-ipsum.yaml\n+++ new-lorem-ipsum.yaml\n@@ -11,7 +11,7 @@\n\tconsectetur:\n\t\tadipiscing: \"elit\"\n\t\tsed: \n-\t\t\t- \"do eiusmod\"\n+\t\t\t- \"do eiusmod tempor\"\n\t\t\t- \"tempor incididunt\"\n\t\t\t- \"ut labore\"\n\tet: \n@@ -17,7 +17,7 @@\n\t\tenim: \"ad minim veniam\"\n\t\tquis: \"nostrud exercitation\"\n\tullamco:\n-\t\tlaboris: \"nisi ut aliquip\"\n+\t\tlaboris: \"nisi ut aliquip ex\"\n\t\tex: \n\t\t\tea: \"commodo consequat\"\n\tduis: \n@@ -31,7 +31,7 @@\n\texcepteur: \n\t\tsint: \"occaecat cupidatat non proident\"\n\t\tsunt: \n-\t\t\tin: \"culpa qui officia\"\n+\t\t\tin: \"culpa qui officia deserunt\"\n\t\t\tdeserunt: \"mollit anim id est laborum\"" 76 | } 77 | ], 78 | "HasDiff": true, 79 | "DiffError": null, 80 | "AppWasTemporarilyCreated": false, 81 | "ArgoCdAppHealthStatus": "Healthy", 82 | "ArgoCdAppSyncStatus": "Synced", 83 | "ArgoCdAppAutoSyncEnabled": true 84 | } 85 | ], 86 | "DisplaySyncBranchCheckBox": false, 87 | "BranchName": "promotions/284-simulate-error-5c159151017f" 88 | } 89 | --------------------------------------------------------------------------------