├── .github ├── pull_request_template.md └── workflows │ ├── zz_generated.gitleaks.yaml │ ├── zz_generated.validate_changelog.yaml │ ├── zz_generated.check_values_schema.yaml │ ├── zz_generated.fix_vulnerabilities.yaml │ ├── zz_generated.run_ossf_scorecard.yaml │ ├── zz_generated.create_release_pr.yaml │ ├── pre_commit_go.yaml │ ├── zz_generated.add-team-labels.yaml │ └── zz_generated.add-to-project-board.yaml ├── flag ├── service │ ├── image │ │ └── image.go │ ├── helm │ │ ├── http │ │ │ └── http.go │ │ ├── kubernetes │ │ │ └── kubernetes.go │ │ └── helm.go │ ├── controller │ │ └── controller.go │ └── service.go └── flag.go ├── service ├── collector │ ├── collector.go │ ├── error.go │ ├── set.go │ ├── orphan_secret.go │ └── orphan_configmap.go ├── controller │ └── chart │ │ ├── resource │ │ ├── releasemaxhistory │ │ │ ├── delete.go │ │ │ ├── error.go │ │ │ ├── resource.go │ │ │ └── create.go │ │ ├── status │ │ │ ├── delete.go │ │ │ ├── types.go │ │ │ ├── error.go │ │ │ ├── resource.go │ │ │ ├── resource_test.go │ │ │ └── create.go │ │ ├── namespace │ │ │ ├── delete.go │ │ │ ├── error.go │ │ │ ├── resource.go │ │ │ └── create.go │ │ └── release │ │ │ ├── types.go │ │ │ ├── error.go │ │ │ ├── current.go │ │ │ ├── create_test.go │ │ │ ├── delete_test.go │ │ │ ├── delete.go │ │ │ ├── desired.go │ │ │ ├── current_test.go │ │ │ └── update_test.go │ │ ├── controllercontext │ │ ├── error.go │ │ └── context.go │ │ ├── error.go │ │ ├── key │ │ ├── error.go │ │ └── key.go │ │ ├── chart.go │ │ └── resources.go ├── error.go ├── internal │ └── clientpair │ │ ├── error.go │ │ ├── clientpair.go │ │ └── clientpair_test.go ├── service_test.go └── service.go ├── CODEOWNERS ├── integration ├── setup │ ├── setup.sh │ ├── config.go │ └── setup.go ├── key │ ├── namespace.go │ ├── release_name.go │ └── catalog.go ├── test │ └── chart │ │ └── basic │ │ ├── error.go │ │ ├── main_test.go │ │ └── basic_test.go ├── release │ ├── error.go │ └── release.go └── env │ └── common.go ├── .nancy-ignore.generated ├── SECURITY.md ├── .gitignore ├── helm └── chart-operator │ ├── templates │ ├── service-account.yaml │ ├── service.yaml │ ├── np.yaml │ ├── priorityclass.yaml │ ├── vpa.yaml │ ├── policy-exceptions.yaml │ ├── configmap.yaml │ ├── rbac.yaml │ ├── _helpers.tpl │ └── deployment.yaml │ ├── Chart.yaml │ └── values.yaml ├── .abs └── main.yaml ├── pkg ├── label │ └── label.go ├── project │ └── project.go └── annotation │ └── annotation.go ├── Dockerfile ├── server ├── error.go ├── endpoint │ ├── error.go │ └── endpoint.go └── server.go ├── renovate.json5 ├── .nancy-ignore ├── Makefile ├── .pre-commit-config.yaml ├── DCO ├── .circleci └── config.yml ├── Makefile.gen.app.mk ├── README.md ├── CONTRIBUTING.md ├── Makefile.gen.go.mk ├── main.go └── go.mod /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Checklist 2 | 3 | - [ ] Update changelog in CHANGELOG.md. 4 | -------------------------------------------------------------------------------- /flag/service/image/image.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | type Image struct { 4 | Registry string 5 | } 6 | -------------------------------------------------------------------------------- /flag/service/helm/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | type HTTP struct { 4 | ClientTimeout string 5 | } 6 | -------------------------------------------------------------------------------- /service/collector/collector.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | const ( 4 | Namespace = "chart_operator" 5 | ) 6 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # generated by giantswarm/github actions - changes will be overwritten 2 | * @giantswarm/team-honeybadger 3 | -------------------------------------------------------------------------------- /flag/service/controller/controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | type Controller struct { 4 | ResyncPeriod string 5 | } 6 | -------------------------------------------------------------------------------- /flag/service/helm/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | type Kubernetes struct { 4 | WaitTimeout string 5 | } 6 | -------------------------------------------------------------------------------- /integration/setup/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | apptestctl bootstrap --kubeconfig="$(kind get kubeconfig)" --install-operators=false 4 | -------------------------------------------------------------------------------- /.nancy-ignore.generated: -------------------------------------------------------------------------------- 1 | # This file is generated by https://github.com/giantswarm/github 2 | # Repository specific ignores should be added to .nancy-ignore 3 | -------------------------------------------------------------------------------- /integration/key/namespace.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package key 5 | 6 | func Namespace() string { 7 | return "giantswarm" 8 | } 9 | -------------------------------------------------------------------------------- /integration/key/release_name.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package key 5 | 6 | func TestAppReleaseName() string { 7 | return "test-app" 8 | } 9 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please visit for information on reporting security issues. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore vim swap files 2 | *.swp 3 | 4 | .idea 5 | 6 | # build result 7 | /chart-operator* 8 | 9 | # don't apply earlier restrictions under vendor 10 | !vendor/** 11 | 12 | .e2e-harness/ 13 | 14 | integration/test/**/*-e2e 15 | -------------------------------------------------------------------------------- /service/controller/chart/resource/releasemaxhistory/delete.go: -------------------------------------------------------------------------------- 1 | package releasemaxhistory 2 | 3 | import "context" 4 | 5 | // EnsureDeleted is a no-op. 6 | func (r *Resource) EnsureDeleted(ctx context.Context, obj interface{}) error { 7 | return nil 8 | } 9 | -------------------------------------------------------------------------------- /service/controller/chart/resource/status/delete.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // EnsureDeleted is not implemented for the status resource. 8 | func (r *Resource) EnsureDeleted(ctx context.Context, obj interface{}) error { 9 | return nil 10 | } 11 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/service-account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ tpl .Values.resource.default.name . }} 5 | namespace: {{ tpl .Values.resource.default.namespace . }} 6 | labels: 7 | {{- include "chart-operator.labels" . | nindent 4 }} 8 | -------------------------------------------------------------------------------- /.abs/main.yaml: -------------------------------------------------------------------------------- 1 | replace-app-version-with-git: true 2 | replace-chart-version-with-git: true 3 | generate-metadata: true 4 | chart-dir: ./helm/chart-operator 5 | destination: ./build 6 | 7 | # CI overwrites this, check .circleci/config.yaml 8 | catalog-base-url: https://giantswarm.github.io/default-catalog/ 9 | -------------------------------------------------------------------------------- /pkg/label/label.go: -------------------------------------------------------------------------------- 1 | // Package label contains common Kubernetes object labels. These are defined in 2 | // https://github.com/giantswarm/fmt/blob/master/kubernetes/annotations_and_labels.md. 3 | package label 4 | 5 | const ( 6 | // App is a standard label for Kubernetes resources. 7 | App = "app" 8 | ) 9 | -------------------------------------------------------------------------------- /service/controller/chart/resource/namespace/delete.go: -------------------------------------------------------------------------------- 1 | package namespace 2 | 3 | import "context" 4 | 5 | // EnsureDeleted is a no-op because the namespace for the app could be used for other apps, too. 6 | func (r *Resource) EnsureDeleted(ctx context.Context, obj interface{}) error { 7 | return nil 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gsoci.azurecr.io/giantswarm/alpine:3.20.3-giantswarm 2 | 3 | USER root 4 | 5 | # bind-tools is required by the init container to use dig. 6 | RUN apk add --no-cache ca-certificates bind-tools 7 | 8 | USER giantswarm 9 | 10 | ADD ./chart-operator /chart-operator 11 | 12 | ENTRYPOINT ["/chart-operator"] 13 | -------------------------------------------------------------------------------- /integration/key/catalog.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package key 5 | 6 | func DefaultCatalogStorageURL() string { 7 | return "https://giantswarm.github.io/default-catalog" 8 | } 9 | 10 | func DefaultTestCatalogStorageURL() string { 11 | return "https://giantswarm.github.io/default-test-catalog" 12 | } 13 | -------------------------------------------------------------------------------- /server/error.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var invalidConfigError = µerror.Error{ 8 | Kind: "invalidConfigError", 9 | } 10 | 11 | // IsInvalidConfig asserts invalidConfigError. 12 | func IsInvalidConfig(err error) bool { 13 | return microerror.Cause(err) == invalidConfigError 14 | } 15 | -------------------------------------------------------------------------------- /service/error.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var invalidConfigError = µerror.Error{ 8 | Kind: "invalidConfigError", 9 | } 10 | 11 | // IsInvalidConfig asserts invalidConfigError. 12 | func IsInvalidConfig(err error) bool { 13 | return microerror.Cause(err) == invalidConfigError 14 | } 15 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | // Base config - https://github.com/giantswarm/renovate-presets/blob/main/default.json5 4 | "github>giantswarm/renovate-presets:default.json5", 5 | // Go specific config - https://github.com/giantswarm/renovate-presets/blob/main/lang-go.json5 6 | "github>giantswarm/renovate-presets:lang-go.json5", 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /server/endpoint/error.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var invalidConfigError = µerror.Error{ 8 | Kind: "invalidConfigError", 9 | } 10 | 11 | // IsInvalidConfig asserts invalidConfigError. 12 | func IsInvalidConfig(err error) bool { 13 | return microerror.Cause(err) == invalidConfigError 14 | } 15 | -------------------------------------------------------------------------------- /service/internal/clientpair/error.go: -------------------------------------------------------------------------------- 1 | package clientpair 2 | 3 | import "github.com/giantswarm/microerror" 4 | 5 | var invalidConfigError = µerror.Error{ 6 | Kind: "invalidConfigError", 7 | } 8 | 9 | // IsInvalidConfig asserts invalidConfigError. 10 | func IsInvalidConfig(err error) bool { 11 | return microerror.Cause(err) == invalidConfigError 12 | } 13 | -------------------------------------------------------------------------------- /service/collector/error.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var invalidConfigError = µerror.Error{ 8 | Kind: "invalidConfigError", 9 | } 10 | 11 | // IsInvalidConfig asserts invalidConfigError. 12 | func IsInvalidConfig(err error) bool { 13 | return microerror.Cause(err) == invalidConfigError 14 | } 15 | -------------------------------------------------------------------------------- /service/controller/chart/controllercontext/error.go: -------------------------------------------------------------------------------- 1 | package controllercontext 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var notFoundError = µerror.Error{ 8 | Kind: "notFoundError", 9 | } 10 | 11 | // IsNotFound asserts notFoundError. 12 | func IsNotFound(err error) bool { 13 | return microerror.Cause(err) == notFoundError 14 | } 15 | -------------------------------------------------------------------------------- /service/controller/chart/error.go: -------------------------------------------------------------------------------- 1 | package chart 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var invalidConfigError = µerror.Error{ 8 | Kind: "invalidConfigError", 9 | } 10 | 11 | // IsInvalidConfig asserts invalidConfigError. 12 | func IsInvalidConfig(err error) bool { 13 | return microerror.Cause(err) == invalidConfigError 14 | } 15 | -------------------------------------------------------------------------------- /service/controller/chart/resource/namespace/error.go: -------------------------------------------------------------------------------- 1 | package namespace 2 | 3 | import "github.com/giantswarm/microerror" 4 | 5 | var invalidConfigError = µerror.Error{ 6 | Kind: "invalidConfigError", 7 | } 8 | 9 | // IsInvalidConfig asserts invalidConfigError. 10 | func IsInvalidConfig(err error) bool { 11 | return microerror.Cause(err) == invalidConfigError 12 | } 13 | -------------------------------------------------------------------------------- /service/controller/chart/resource/status/types.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 4 | 5 | type Request struct { 6 | AppVersion string `json:"app_version"` 7 | LastDeployed v1.Time `json:"last_deployed"` 8 | Reason string `json:"reason"` 9 | Status string `json:"status"` 10 | Version string `json:"version"` 11 | } 12 | -------------------------------------------------------------------------------- /integration/test/chart/basic/error.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package basic 5 | 6 | import "github.com/giantswarm/microerror" 7 | 8 | var notDeployedError = µerror.Error{ 9 | Kind: "notDeployedError", 10 | } 11 | 12 | // IsNotDeployed asserts notDeployedError. 13 | func IsNotDeployed(err error) bool { 14 | return microerror.Cause(err) == notDeployedError 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.gitleaks.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/ad0a25fbf301b2513e169ec964a8785d28f75be4/pkg/gen/input/workflows/internal/file/gitleaks.yaml.template 6 | # 7 | name: gitleaks 8 | 9 | on: 10 | - pull_request 11 | 12 | jobs: 13 | publish: 14 | uses: giantswarm/github-workflows/.github/workflows/gitleaks.yaml@main 15 | -------------------------------------------------------------------------------- /flag/flag.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/giantswarm/microkit/flag" 5 | 6 | "github.com/giantswarm/chart-operator/v4/flag/service" 7 | ) 8 | 9 | // Flag provides data structure for service command line flags. 10 | type Flag struct { 11 | Service service.Service 12 | } 13 | 14 | // New fills new Flag structure with given command line flags. 15 | func New() *Flag { 16 | f := &Flag{} 17 | flag.Init(f) 18 | 19 | return f 20 | } 21 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ tpl .Values.resource.default.name . }} 5 | namespace: {{ tpl .Values.resource.default.namespace . }} 6 | labels: 7 | {{- include "chart-operator.labels" . | nindent 4 }} 8 | annotations: 9 | prometheus.io/scrape: "true" 10 | spec: 11 | ports: 12 | - port: {{ .Values.pod.port }} 13 | selector: 14 | {{- include "chart-operator.selectorLabels" . | nindent 4 }} 15 | -------------------------------------------------------------------------------- /service/controller/chart/resource/releasemaxhistory/error.go: -------------------------------------------------------------------------------- 1 | package releasemaxhistory 2 | 3 | import "github.com/giantswarm/microerror" 4 | 5 | var executionFailedError = µerror.Error{ 6 | Kind: "executionFailedError", 7 | } 8 | 9 | var invalidConfigError = µerror.Error{ 10 | Kind: "invalidConfigError", 11 | } 12 | 13 | // IsInvalidConfig asserts invalidConfigError. 14 | func IsInvalidConfig(err error) bool { 15 | return microerror.Cause(err) == invalidConfigError 16 | } 17 | -------------------------------------------------------------------------------- /.nancy-ignore: -------------------------------------------------------------------------------- 1 | CVE-2019-25210 until=2025-12-31 # golang/helm.sh/helm/v3@v3.14.4 2 | CVE-2025-32386 until=2025-12-31 # golang/helm.sh/helm/v3@v3.14.4 3 | CVE-2025-32387 until=2025-12-31 # golang/helm.sh/helm/v3@v3.14.4 4 | CVE-2020-8561 until=2025-12-31 # k8s.io/apiserver@v0.29.2 5 | CVE-2025-47290 until=2025-12-31 # golang/github.com/containerd/containerd@v1.7.28 6 | CVE-2025-47291 until=2025-12-31 # golang/github.com/containerd/containerd@v1.7.28 7 | CVE-2025-47914 until=2025-12-20 # golang.org/x/crypto@v0.44.0 8 | CVE-2025-58181 until=2025-12-20 # golang.org/x/crypto@v0.44.0 9 | -------------------------------------------------------------------------------- /service/controller/chart/key/error.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import "github.com/giantswarm/microerror" 4 | 5 | var emptyValueError = µerror.Error{ 6 | Kind: "emptyValueError", 7 | } 8 | 9 | // IsEmptyValueError asserts emptyValueError. 10 | func IsEmptyValueError(err error) bool { 11 | return microerror.Cause(err) == emptyValueError 12 | } 13 | 14 | var wrongTypeError = µerror.Error{ 15 | Kind: "wrongTypeError", 16 | } 17 | 18 | // IsWrongTypeError asserts wrongTypeError. 19 | func IsWrongTypeError(err error) bool { 20 | return microerror.Cause(err) == wrongTypeError 21 | } 22 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/np.yaml: -------------------------------------------------------------------------------- 1 | kind: NetworkPolicy 2 | apiVersion: networking.k8s.io/v1 3 | metadata: 4 | name: {{ tpl .Values.resource.default.name . }} 5 | namespace: {{ tpl .Values.resource.default.namespace . }} 6 | labels: 7 | {{- include "chart-operator.labels" . | nindent 4 }} 8 | spec: 9 | podSelector: 10 | matchLabels: 11 | {{- include "chart-operator.selectorLabels" . | nindent 6 }} 12 | ingress: 13 | - ports: 14 | - port: {{ .Values.pod.port }} 15 | protocol: TCP 16 | egress: 17 | - {} 18 | policyTypes: 19 | - Egress 20 | - Ingress 21 | -------------------------------------------------------------------------------- /flag/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/giantswarm/operatorkit/v7/pkg/flag/service/kubernetes" 5 | 6 | "github.com/giantswarm/chart-operator/v4/flag/service/controller" 7 | 8 | "github.com/giantswarm/chart-operator/v4/flag/service/helm" 9 | "github.com/giantswarm/chart-operator/v4/flag/service/image" 10 | ) 11 | 12 | // Service is an intermediate data structure for command line configuration flags. 13 | type Service struct { 14 | Helm helm.Helm 15 | Image image.Image 16 | Kubernetes kubernetes.Kubernetes 17 | Controller controller.Controller 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.validate_changelog.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/ad0a25fbf301b2513e169ec964a8785d28f75be4/pkg/gen/input/workflows/internal/file/validate_changelog.yaml.template 6 | # 7 | name: Validate changelog 8 | 9 | on: 10 | pull_request: 11 | types: [opened, synchronize, reopened] 12 | paths: 13 | - 'CHANGELOG.md' 14 | 15 | permissions: 16 | contents: read 17 | pull-requests: write 18 | 19 | jobs: 20 | validate-changelog: 21 | uses: giantswarm/github-workflows/.github/workflows/validate-changelog.yaml@main 22 | -------------------------------------------------------------------------------- /helm/chart-operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: chart-operator 3 | version: 4.2.0 4 | appVersion: 3.3.1-dev 5 | home: https://github.com/giantswarm/chart-operator 6 | description: A Helm chart for the chart-operator 7 | icon: https://s.giantswarm.io/app-icons/giantswarm/1/dark.svg 8 | annotations: 9 | io.giantswarm.application.restrictions.cluster-singleton: 'true' 10 | io.giantswarm.application.restrictions.fixed-namespace: giantswarm 11 | io.giantswarm.application.restrictions.gpu-instances: 'false' 12 | io.giantswarm.application.restrictions.namespace-singleton: 'true' 13 | io.giantswarm.application.team: honeybadger 14 | -------------------------------------------------------------------------------- /service/controller/chart/resource/status/error.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | ) 6 | 7 | var invalidConfigError = µerror.Error{ 8 | Kind: "invalidConfigError", 9 | } 10 | 11 | // IsInvalidConfig asserts invalidConfigError. 12 | func IsInvalidConfig(err error) bool { 13 | return microerror.Cause(err) == invalidConfigError 14 | } 15 | 16 | var wrongStatusError = µerror.Error{ 17 | Kind: "wrongStatusError", 18 | } 19 | 20 | // IsWrongStatusError asserts wrongStatusError. 21 | func IsWrongStatusError(err error) bool { 22 | return microerror.Cause(err) == wrongStatusError 23 | } 24 | -------------------------------------------------------------------------------- /integration/test/chart/basic/main_test.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package basic 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/giantswarm/chart-operator/v4/integration/setup" 10 | ) 11 | 12 | var ( 13 | config setup.Config 14 | ) 15 | 16 | func init() { 17 | var err error 18 | 19 | { 20 | config, err = setup.NewConfig() 21 | if err != nil { 22 | panic(err.Error()) 23 | } 24 | } 25 | } 26 | 27 | // TestMain allows us to have common setup and teardown steps that are run 28 | // once for all the tests https://golang.org/pkg/testing/#hdr-Main. 29 | func TestMain(m *testing.M) { 30 | setup.Setup(m, config) 31 | } 32 | -------------------------------------------------------------------------------- /flag/service/helm/helm.go: -------------------------------------------------------------------------------- 1 | package helm 2 | 3 | import ( 4 | "github.com/giantswarm/chart-operator/v4/flag/service/helm/http" 5 | "github.com/giantswarm/chart-operator/v4/flag/service/helm/kubernetes" 6 | ) 7 | 8 | type Helm struct { 9 | HTTP http.HTTP 10 | Kubernetes kubernetes.Kubernetes 11 | MaxRollback string 12 | 13 | // SplitClient determines usage of additional pubHelmClient impersonating 14 | // `default:automation` Service Account for App CRs created outside the 15 | // `giantswarm` namespace. When `false` Chart Operator runs under full 16 | // cluster admin permissions no matter the App CR namespace. 17 | SplitClient string 18 | NamespaceWhitelist string 19 | 20 | TillerNamespace string 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.check_values_schema.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/ad0a25fbf301b2513e169ec964a8785d28f75be4/pkg/gen/input/workflows/internal/file/check_values_schema.yaml.template 6 | # 7 | 8 | name: 'Values and schema' 9 | 10 | on: 11 | pull_request: 12 | branches: 13 | - master 14 | - main 15 | paths: 16 | - 'helm/**/values.yaml' # default helm chart values 17 | - 'helm/**/values.schema.json' # schema 18 | - 'helm/**/ci/ci-values.yaml' # overrides for CI (can contain required entries) 19 | 20 | jobs: 21 | check: 22 | uses: giantswarm/github-workflows/.github/workflows/chart-values.yaml@main 23 | -------------------------------------------------------------------------------- /service/controller/chart/controllercontext/context.go: -------------------------------------------------------------------------------- 1 | package controllercontext 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/giantswarm/microerror" 7 | ) 8 | 9 | type contextKey string 10 | 11 | const controllerKey contextKey = "controller" 12 | 13 | type Context struct { 14 | Status Status 15 | } 16 | 17 | type Status struct { 18 | Reason string 19 | Release Release 20 | } 21 | 22 | type Release struct { 23 | FailedMaxAttempts bool 24 | Status string 25 | } 26 | 27 | func NewContext(ctx context.Context, c Context) context.Context { 28 | return context.WithValue(ctx, controllerKey, &c) 29 | } 30 | 31 | func FromContext(ctx context.Context) (*Context, error) { 32 | c, ok := ctx.Value(controllerKey).(*Context) 33 | if !ok { 34 | return nil, microerror.Maskf(notFoundError, "context key %q of type %T", controllerKey, controllerKey) 35 | } 36 | 37 | return c, nil 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.fix_vulnerabilities.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/251fa7d9bd403e23321bad6714c1e26c375fedf3/pkg/gen/input/workflows/internal/file/fix_vulnerabilities.yaml.template 6 | # 7 | 8 | name: Fix Go vulnerabilities 9 | 10 | on: 11 | schedule: 12 | - cron: '0 9 * * 1-5' 13 | workflow_dispatch: 14 | inputs: 15 | branch: 16 | description: Branch on which to fix vulnerabilities 17 | required: true 18 | type: string 19 | 20 | jobs: 21 | fix: 22 | uses: giantswarm/github-workflows/.github/workflows/fix-vulnerabilities.yaml@main 23 | with: 24 | branch: ${{ inputs.branch || github.ref }} 25 | secrets: 26 | HERALD_APP_ID: ${{ secrets.HERALD_APP_ID }} 27 | HERALD_APP_KEY: ${{ secrets.HERALD_APP_KEY }} 28 | NANCY_USER: ${{ secrets.NANCY_USER }} 29 | NANCY_TOKEN: ${{ secrets.NANCY_TOKEN }} 30 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/priorityclass.yaml: -------------------------------------------------------------------------------- 1 | {{- $render := false -}} 2 | {{- $existing := lookup "scheduling.k8s.io/v1" "PriorityClass" "" "giantswarm-critical" -}} 3 | {{- if not $existing -}} 4 | {{- $render = true -}} 5 | {{- else -}} 6 | {{- if $existing.metadata.labels -}} 7 | {{- if eq (index $existing.metadata.labels "app.kubernetes.io/instance") "chart-operator" -}} 8 | {{- $render = true -}} 9 | {{- end -}} 10 | {{- end -}} 11 | {{- end -}} 12 | {{- if $render -}} 13 | apiVersion: scheduling.k8s.io/v1 14 | kind: PriorityClass 15 | metadata: 16 | name: giantswarm-critical 17 | labels: 18 | {{- include "chart-operator.labels" . | nindent 4 }} 19 | annotations: 20 | helm.sh/hook: pre-install,pre-upgrade 21 | helm.sh/resource-policy: keep 22 | helm.sh/hook-delete-policy: hook-failed,before-hook-creation 23 | value: 1000000000 24 | globalDefault: false 25 | description: "This priority class is used by giantswarm kubernetes components." 26 | {{- end }} 27 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/types.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | type Patch struct { 4 | Op string `json:"op"` 5 | Path string `json:"path"` 6 | Value interface{} `json:"value"` 7 | } 8 | 9 | // ReleaseState holds the state of the Helm release to be reconciled. 10 | type ReleaseState struct { 11 | // Name is the name of the Helm release when the chart is deployed. 12 | // e.g. chart-operator 13 | Name string 14 | // Status is the status of the Helm release when the chart is deployed. 15 | // e.g. DEPLOYED 16 | Status string 17 | // ValuesMD5Checksum is the MD5 checksum of the values YAML. It is used for 18 | // comparison since it is more reliable than using the values returned by 19 | // helmclient.GetReleaseContent. 20 | ValuesMD5Checksum string 21 | // Values are any values that have been set when the Helm Chart was 22 | // installed. 23 | Values map[string]interface{} 24 | // Version is the version of the Helm Chart to be deployed. 25 | // e.g. 0.1.2 26 | Version string 27 | } 28 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/error.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import "github.com/giantswarm/microerror" 4 | 5 | var invalidConfigError = µerror.Error{ 6 | Kind: "invalidConfigError", 7 | } 8 | 9 | // IsInvalidConfig asserts invalidConfigError. 10 | func IsInvalidConfig(err error) bool { 11 | return microerror.Cause(err) == invalidConfigError 12 | } 13 | 14 | var notFoundError = µerror.Error{ 15 | Kind: "notFoundError", 16 | } 17 | 18 | // IsNotFound asserts notFoundError. 19 | func IsNotFound(err error) bool { 20 | return microerror.Cause(err) == notFoundError 21 | } 22 | 23 | var waitError = µerror.Error{ 24 | Kind: "waitError", 25 | } 26 | 27 | // IsWait asserts waitError. 28 | func IsWait(err error) bool { 29 | return microerror.Cause(err) == waitError 30 | } 31 | 32 | var wrongTypeError = µerror.Error{ 33 | Kind: "wrongTypeError", 34 | } 35 | 36 | // IsWrongType asserts wrongTypeError. 37 | func IsWrongType(err error) bool { 38 | return microerror.Cause(err) == wrongTypeError 39 | } 40 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/vpa.yaml: -------------------------------------------------------------------------------- 1 | {{ if eq (include "resource.vpa.enabled" .) "true" }} 2 | apiVersion: autoscaling.k8s.io/v1 3 | kind: VerticalPodAutoscaler 4 | metadata: 5 | name: {{ tpl .Values.resource.default.name . }} 6 | namespace: {{ tpl .Values.resource.default.namespace . }} 7 | labels: 8 | {{- include "chart-operator.labels" . | nindent 4 }} 9 | spec: 10 | resourcePolicy: 11 | containerPolicies: 12 | - containerName: {{ .Chart.Name }} 13 | controlledResources: 14 | - cpu 15 | - memory 16 | minAllowed: 17 | cpu: {{ .Values.deployment.requests.cpu }} 18 | memory: {{ .Values.deployment.requests.memory }} 19 | maxAllowed: 20 | cpu: {{ .Values.verticalPodAutoscaler.maxAllowed.cpu }} 21 | memory: {{ .Values.verticalPodAutoscaler.maxAllowed.memory }} 22 | mode: Auto 23 | targetRef: 24 | apiVersion: apps/v1 25 | kind: Deployment 26 | name: {{ tpl .Values.resource.default.name . }} 27 | updatePolicy: 28 | updateMode: Auto 29 | {{ end }} 30 | -------------------------------------------------------------------------------- /integration/release/error.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package release 5 | 6 | import ( 7 | "github.com/giantswarm/microerror" 8 | ) 9 | 10 | var invalidConfigError = µerror.Error{ 11 | Kind: "invalidConfigError", 12 | } 13 | 14 | // IsInvalidConfig asserts invalidConfigError. 15 | func IsInvalidConfig(err error) bool { 16 | return microerror.Cause(err) == invalidConfigError 17 | } 18 | 19 | var releaseStatusNotMatchingError = µerror.Error{ 20 | Kind: "releaseStatusNotMatchingError", 21 | } 22 | 23 | // IsReleaseStatusNotMatching asserts releaseStatusNotMatchingError 24 | func IsReleaseStatusNotMatching(err error) bool { 25 | return microerror.Cause(err) == releaseStatusNotMatchingError 26 | } 27 | 28 | var releaseVersionNotMatchingError = µerror.Error{ 29 | Kind: "releaseVersionNotMatchingError", 30 | } 31 | 32 | // IsReleaseVersionNotMatching asserts releaseVersionNotMatchingError 33 | func IsReleaseVersionNotMatching(err error) bool { 34 | return microerror.Cause(err) == releaseVersionNotMatchingError 35 | } 36 | -------------------------------------------------------------------------------- /pkg/project/project.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | var ( 4 | description = "chart-operator is an agent for deploying Helm charts as releases." 5 | gitSHA = "n/a" 6 | name = "chart-operator" 7 | source = "https://github.com/giantswarm/chart-operator" 8 | version = "4.2.1-dev" 9 | ) 10 | 11 | const ( 12 | // ReleaseFailedMaxAttempts when a release fails this number of times in a 13 | // row we stop updating. This is because the Helm max history setting does 14 | // not apply for failures. 15 | ReleaseFailedMaxAttempts = 5 16 | ) 17 | 18 | // ChartVersion is fixed for chart CRs. This is because they exist in both 19 | // control plane and tenant clusters and their version is not linked to a 20 | // release. We may revisit this in future. 21 | func ChartVersion() string { 22 | return "1.0.0" 23 | } 24 | 25 | func Description() string { 26 | return description 27 | } 28 | 29 | func GitSHA() string { 30 | return gitSHA 31 | } 32 | 33 | func Name() string { 34 | return name 35 | } 36 | 37 | func Source() string { 38 | return source 39 | } 40 | 41 | func Version() string { 42 | return version 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.run_ossf_scorecard.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/ad0a25fbf301b2513e169ec964a8785d28f75be4/pkg/gen/input/workflows/internal/file/run_ossf_scorecard.yaml.template 6 | # 7 | 8 | # This workflow uses actions that are not certified by GitHub. They are provided 9 | # by a third-party and are governed by separate terms of service, privacy 10 | # policy, and support documentation. 11 | 12 | name: Scorecard supply-chain security 13 | on: 14 | # For Branch-Protection check. Only the default branch is supported. See 15 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 16 | branch_protection_rule: {} 17 | # To guarantee Maintained check is occasionally updated. See 18 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 19 | schedule: 20 | - cron: '15 15 15 * *' 21 | push: 22 | branches: 23 | - main 24 | - master 25 | workflow_dispatch: {} 26 | 27 | jobs: 28 | analysis: 29 | uses: giantswarm/github-workflows/.github/workflows/ossf-scorecard.yaml@main 30 | secrets: 31 | scorecard_token: ${{ secrets.SCORECARD_TOKEN }} 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/6a704f7e2a8b0f09e82b5bab88f17971af849711/pkg/gen/input/makefile/internal/file/Makefile.template 6 | # 7 | 8 | include Makefile.*.mk 9 | 10 | ##@ General 11 | 12 | # The help target prints out all targets with their descriptions organized 13 | # beneath their categories. The categories are represented by '##@' and the 14 | # target descriptions by '##'. The awk commands is responsible for reading the 15 | # entire set of makefiles included in this invocation, looking for lines of the 16 | # file as xyz: ## something, and then pretty-format the target and help. Then, 17 | # if there's a line with ##@ something, that gets pretty-printed as a category. 18 | # More info on the usage of ANSI control characters for terminal formatting: 19 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 20 | # More info on the awk command: 21 | # http://linuxcommand.org/lc3_adv_awk.php 22 | 23 | .PHONY: help 24 | help: ## Display this help. 25 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z%\\\/_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 26 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.create_release_pr.yaml: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/ad0a25fbf301b2513e169ec964a8785d28f75be4/pkg/gen/input/workflows/internal/file/create_release_pr.yaml.template 6 | # 7 | name: Create Release PR 8 | on: 9 | push: 10 | branches: 11 | - 'legacy#release#v*.*.*' 12 | - 'main#release#v*.*.*' 13 | - 'main#release#major' 14 | - 'main#release#minor' 15 | - 'main#release#patch' 16 | - 'master#release#v*.*.*' 17 | - 'master#release#major' 18 | - 'master#release#minor' 19 | - 'master#release#patch' 20 | - 'release#v*.*.*' 21 | - 'release#major' 22 | - 'release#minor' 23 | - 'release#patch' 24 | - 'release-v*.*.x#release#v*.*.*' 25 | # "!" negates previous positive patterns so it has to be at the end. 26 | - '!release-v*.x.x#release#v*.*.*' 27 | workflow_call: 28 | inputs: 29 | branch: 30 | required: true 31 | type: string 32 | 33 | jobs: 34 | publish: 35 | uses: giantswarm/github-workflows/.github/workflows/create-release-pr.yaml@main 36 | with: 37 | branch: ${{ inputs.branch }} 38 | secrets: 39 | TAYLORBOT_GITHUB_ACTION: ${{ secrets.TAYLORBOT_GITHUB_ACTION }} 40 | -------------------------------------------------------------------------------- /.github/workflows/pre_commit_go.yaml: -------------------------------------------------------------------------------- 1 | # This file is maintained centrally in 2 | # https://github.com/giantswarm/github/blob/main/languages/go/pre_commit_go.yaml 3 | 4 | name: pre-commit 5 | 6 | on: 7 | pull_request: 8 | push: 9 | branches: [main] 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-24.04 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | - name: Set up Python environment 18 | uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 19 | - name: Set up Go environment 20 | uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 21 | with: 22 | go-version: "1.25" 23 | - name: Install goimports 24 | run: | 25 | go install golang.org/x/tools/cmd/goimports@v0.34.0 26 | - name: Install golangci-lint 27 | uses: giantswarm/install-binary-action@c37eb401e5092993fc76d545030b1d1769e61237 # v3.0.0 28 | with: 29 | binary: golangci-lint 30 | version: "2.4.0" 31 | download_url: "https://github.com/golangci/golangci-lint/releases/download/v${version}/${binary}-${version}-linux-amd64.tar.gz" 32 | - name: Execute pre-commit hooks 33 | uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 34 | env: 35 | GOGC: "20" 36 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/policy-exceptions.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.kyvernoPolicyExceptions.enabled }} 2 | {{- if or (.Capabilities.APIVersions.Has "kyverno.io/v2/PolicyException") (.Capabilities.APIVersions.Has "kyverno.io/v2beta1/PolicyException") }} 3 | {{- if .Capabilities.APIVersions.Has "kyverno.io/v2/PolicyException" -}} 4 | apiVersion: kyverno.io/v2 5 | {{- else -}} 6 | apiVersion: kyverno.io/v2beta1 7 | {{- end }} 8 | kind: PolicyException 9 | 10 | metadata: 11 | name: {{ tpl .Values.resource.default.name . }}-policy-exceptions 12 | namespace: {{ .Values.kyvernoPolicyExceptions.namespace | default "giantswarm" }} 13 | labels: 14 | {{- include "chart-operator.labels" . | nindent 4 }} 15 | annotations: 16 | "helm.sh/hook": "pre-install,pre-upgrade" 17 | spec: 18 | exceptions: 19 | - policyName: disallow-host-ports 20 | ruleNames: 21 | - host-ports-none 22 | - autogen-host-ports-none 23 | - policyName: disallow-host-namespaces 24 | ruleNames: 25 | - host-namespaces 26 | - autogen-host-namespaces 27 | 28 | match: 29 | any: 30 | - resources: 31 | kinds: 32 | - Deployment 33 | - ReplicaSet 34 | - Pod 35 | namespaces: 36 | - {{ tpl .Values.resource.default.namespace . }} 37 | names: 38 | - {{ tpl .Values.resource.default.name . }}* 39 | {{- end -}} 40 | {{- end -}} 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # This file is maintained centrally at 2 | # https://github.com/giantswarm/github/blob/main/languages/go/.pre-commit-config.yaml 3 | 4 | minimum_pre_commit_version: '2.17' 5 | repos: 6 | # shell scripts 7 | - repo: https://github.com/detailyang/pre-commit-shell 8 | rev: '1.0.5' 9 | hooks: 10 | - id: shell-lint 11 | args: [ --format=json ] 12 | exclude: ".*\\.template" 13 | 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v6.0.0 16 | hooks: 17 | - id: check-added-large-files 18 | - id: check-merge-conflict 19 | - id: check-shebang-scripts-are-executable 20 | - id: detect-private-key 21 | - id: end-of-file-fixer 22 | exclude: ".*testdata/.*" 23 | - id: mixed-line-ending 24 | - id: trailing-whitespace 25 | exclude: ".*testdata/.*" 26 | 27 | - repo: https://github.com/dnephin/pre-commit-golang 28 | rev: v0.5.1 29 | hooks: 30 | - id: go-fmt 31 | - id: go-mod-tidy 32 | - id: golangci-lint 33 | args: 34 | - -E=gosec 35 | - -E=goconst 36 | - -E=govet 37 | # timeout is needed for CI 38 | - --timeout=300s 39 | # List all issues found 40 | - --max-same-issues=0 41 | - --max-issues-per-linter=0 42 | - id: go-imports 43 | args: [ -local, github.com/giantswarm/chart-operator ] 44 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ tpl .Values.resource.default.name . }} 5 | namespace: {{ tpl .Values.resource.default.namespace . }} 6 | labels: 7 | {{- include "chart-operator.labels" . | nindent 4 }} 8 | data: 9 | config.yaml: | 10 | server: 11 | enable: 12 | debug: 13 | server: true 14 | listen: 15 | address: 'http://0.0.0.0:{{ .Values.pod.port }}' 16 | service: 17 | controller: 18 | resyncPeriod: '{{ .Values.controller.resyncPeriod }}' 19 | helm: 20 | splitClient: '{{ .Values.helm.splitClient }}' 21 | {{- if empty .Values.helm.namespaceWhitelist }} 22 | namespaceWhitelist: [] 23 | {{- else }} 24 | namespaceWhitelist: 25 | {{- range .Values.helm.namespaceWhitelist }} 26 | - {{ . }} 27 | {{- end }} 28 | {{- end }} 29 | http: 30 | clientTimeout: '{{ .Values.helm.http.clientTimeout }}' 31 | kubernetes: 32 | waitTimeout: '{{ .Values.helm.kubernetes.waitTimeout }}' 33 | maxRollback: '{{ .Values.helm.maxRollback }}' 34 | tillerNamespace: '{{ .Values.tiller.namespace }}' 35 | image: 36 | registry: '{{ .Values.image.registry }}' 37 | kubernetes: 38 | incluster: true 39 | watch: 40 | namespace: '{{ tpl .Values.resource.default.namespace . }}' 41 | -------------------------------------------------------------------------------- /pkg/annotation/annotation.go: -------------------------------------------------------------------------------- 1 | // Package annotation contains common Kubernetes metadata. These are defined in 2 | // https://github.com/giantswarm/fmt/blob/master/kubernetes/annotations_and_labels.md. 3 | package annotation 4 | 5 | const ( 6 | // ChartOperatorPaused annotation when present prevents chart-operator from 7 | // reconciling the resource. 8 | ChartOperatorPaused = "chart-operator.giantswarm.io/paused" 9 | 10 | // CordonReason is the name of the annotation that indicates 11 | // the reason of why chart-operator should not apply any update on this chart CR. 12 | CordonReason = "chart-operator.giantswarm.io/cordon-reason" 13 | 14 | // CordonUntilDate is the name of the annotation that indicates 15 | // the expiration date of rule of this cordon. 16 | CordonUntilDate = "chart-operator.giantswarm.io/cordon-until" 17 | 18 | // ForceHelmUpgrade is the name of the annotation that controls whether 19 | // force is used when upgrading the Helm release. 20 | ForceHelmUpgrade = "chart-operator.giantswarm.io/force-helm-upgrade" 21 | 22 | // RollbackCount is the name of the annotation storing the number of 23 | // rollbacks performed from the previous pending status. 24 | RollbackCount = "chart-operator.giantswarm.io/rollback-count" 25 | 26 | // ValuesMD5Checksum is the name of the annotation storing an MD5 checksum 27 | // of the Helm release values. 28 | ValuesMD5Checksum = "chart-operator.giantswarm.io/values-md5-checksum" 29 | 30 | Webhook = "chart-operator.giantswarm.io/webhook-url" 31 | ) 32 | -------------------------------------------------------------------------------- /service/controller/chart/resource/releasemaxhistory/resource.go: -------------------------------------------------------------------------------- 1 | package releasemaxhistory 2 | 3 | import ( 4 | "github.com/giantswarm/microerror" 5 | "github.com/giantswarm/micrologger" 6 | "k8s.io/client-go/kubernetes" 7 | 8 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 9 | ) 10 | 11 | const ( 12 | Name = "releasemaxhistory" 13 | ) 14 | 15 | type Config struct { 16 | // Dependencies. 17 | HelmClients *clientpair.ClientPair 18 | K8sClient kubernetes.Interface 19 | Logger micrologger.Logger 20 | } 21 | 22 | type Resource struct { 23 | // Dependencies. 24 | helmClients *clientpair.ClientPair 25 | k8sClient kubernetes.Interface 26 | logger micrologger.Logger 27 | } 28 | 29 | // New creates a new configured releasemaxhistory resource. 30 | func New(config Config) (*Resource, error) { 31 | // Dependencies. 32 | if config.HelmClients == nil { 33 | return nil, microerror.Maskf(invalidConfigError, "%T.ClientPair must not be empty", config) 34 | } 35 | if config.K8sClient == nil { 36 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 37 | } 38 | if config.Logger == nil { 39 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 40 | } 41 | 42 | r := &Resource{ 43 | helmClients: config.HelmClients, 44 | k8sClient: config.K8sClient, 45 | logger: config.Logger, 46 | } 47 | 48 | return r, nil 49 | } 50 | 51 | func (r Resource) Name() string { 52 | return Name 53 | } 54 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 660 York Street, Suite 102, 6 | San Francisco, CA 94110 USA 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | 12 | Developer's Certificate of Origin 1.1 13 | 14 | By making a contribution to this project, I certify that: 15 | 16 | (a) The contribution was created in whole or in part by me and I 17 | have the right to submit it under the open source license 18 | indicated in the file; or 19 | 20 | (b) The contribution is based upon previous work that, to the best 21 | of my knowledge, is covered under an appropriate open source 22 | license and I have the right under that license to submit that 23 | work with modifications, whether created in whole or in part 24 | by me, under the same open source license (unless I am 25 | permitted to submit under a different license), as indicated 26 | in the file; or 27 | 28 | (c) The contribution was provided directly to me by some other 29 | person who certified (a), (b) or (c) and I have not modified 30 | it. 31 | 32 | (d) I understand and agree that this project and the contribution 33 | are public and that a record of the contribution (including all 34 | personal information I submit with it, including my sign-off) is 35 | maintained indefinitely and may be redistributed consistent with 36 | this project or the open source license(s) involved. 37 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: {{ tpl .Values.resource.default.name . }}-cluster-admin 5 | labels: 6 | {{- include "chart-operator.labels" . | nindent 4 }} 7 | subjects: 8 | - kind: ServiceAccount 9 | name: {{ tpl .Values.resource.default.name . }} 10 | namespace: {{ tpl .Values.resource.default.namespace . }} 11 | roleRef: 12 | kind: ClusterRole 13 | name: cluster-admin 14 | apiGroup: rbac.authorization.k8s.io 15 | {{- if not (((.Values.global).podSecurityStandards).enforced) }} 16 | --- 17 | apiVersion: rbac.authorization.k8s.io/v1 18 | kind: ClusterRole 19 | metadata: 20 | name: {{ tpl .Values.resource.psp.name . }} 21 | labels: 22 | {{- include "chart-operator.labels" . | nindent 4 }} 23 | rules: 24 | - apiGroups: 25 | - policy 26 | resources: 27 | - podsecuritypolicies 28 | resourceNames: 29 | - {{ tpl .Values.resource.psp.name . }} 30 | verbs: 31 | - use 32 | --- 33 | kind: ClusterRoleBinding 34 | apiVersion: rbac.authorization.k8s.io/v1 35 | metadata: 36 | name: {{ tpl .Values.resource.psp.name . }}-user 37 | labels: 38 | {{- include "chart-operator.labels" . | nindent 4 }} 39 | subjects: 40 | - kind: ServiceAccount 41 | name: {{ tpl .Values.resource.default.name . }} 42 | namespace: {{ tpl .Values.resource.default.namespace . }} 43 | roleRef: 44 | kind: ClusterRole 45 | name: {{ tpl .Values.resource.psp.name . }} 46 | apiGroup: rbac.authorization.k8s.io 47 | {{- end }} 48 | -------------------------------------------------------------------------------- /integration/env/common.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package env 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | ) 10 | 11 | const ( 12 | // EnvVarCircleCI is the process environment variable representing the 13 | // CIRCLECI env var. 14 | EnvVarCircleCI = "CIRCLECI" 15 | // EnvVarCircleSHA is the process environment variable representing the 16 | // CIRCLE_SHA1 env var. 17 | EnvVarCircleSHA = "CIRCLE_SHA1" 18 | // EnvVarE2EKubeconfig is the process environment variable representing the 19 | // E2E_KUBECONFIG env var. 20 | EnvVarE2EKubeconfig = "E2E_KUBECONFIG" 21 | // EnvVarKeepResources is the process environment variable representing the 22 | // KEEP_RESOURCES env var. 23 | EnvVarKeepResources = "KEEP_RESOURCES" 24 | ) 25 | 26 | var ( 27 | circleCI string 28 | circleSHA string 29 | keepResources string 30 | kubeconfig string 31 | ) 32 | 33 | func init() { 34 | circleCI = os.Getenv(EnvVarCircleCI) 35 | keepResources = os.Getenv(EnvVarKeepResources) 36 | 37 | circleSHA = os.Getenv(EnvVarCircleSHA) 38 | if circleSHA == "" { 39 | panic(fmt.Sprintf("env var '%s' must not be empty", EnvVarCircleSHA)) 40 | } 41 | 42 | kubeconfig = os.Getenv(EnvVarE2EKubeconfig) 43 | if kubeconfig == "" { 44 | panic(fmt.Sprintf("env var '%s' must not be empty", EnvVarE2EKubeconfig)) 45 | } 46 | } 47 | 48 | func CircleCI() string { 49 | return circleCI 50 | } 51 | 52 | func CircleSHA() string { 53 | return circleSHA 54 | } 55 | 56 | func KeepResources() string { 57 | return keepResources 58 | } 59 | 60 | func KubeConfigPath() string { 61 | return kubeconfig 62 | } 63 | -------------------------------------------------------------------------------- /service/controller/chart/resource/namespace/resource.go: -------------------------------------------------------------------------------- 1 | package namespace 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/giantswarm/microerror" 7 | "github.com/giantswarm/micrologger" 8 | "k8s.io/client-go/kubernetes" 9 | ) 10 | 11 | const ( 12 | Name = "namespace" 13 | 14 | // defaultK8sWaitTimeout is how long to wait for the Kubernetes API when 15 | // installing or updating a release before moving to process the next CR. 16 | defaultK8sWaitTimeout = 10 * time.Second 17 | ) 18 | 19 | type Config struct { 20 | // Dependencies. 21 | K8sClient kubernetes.Interface 22 | Logger micrologger.Logger 23 | 24 | // Settings. 25 | K8sWaitTimeout time.Duration 26 | } 27 | 28 | type Resource struct { 29 | // Dependencies. 30 | k8sClient kubernetes.Interface 31 | logger micrologger.Logger 32 | 33 | // Settings. 34 | k8sWaitTimeout time.Duration 35 | } 36 | 37 | // New creates a new configured namespace resource. 38 | func New(config Config) (*Resource, error) { 39 | // Dependencies. 40 | if config.K8sClient == nil { 41 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 42 | } 43 | if config.Logger == nil { 44 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 45 | } 46 | 47 | // Settings. 48 | if config.K8sWaitTimeout == 0 { 49 | config.K8sWaitTimeout = defaultK8sWaitTimeout 50 | } 51 | 52 | r := &Resource{ 53 | k8sClient: config.K8sClient, 54 | logger: config.Logger, 55 | 56 | k8sWaitTimeout: config.K8sWaitTimeout, 57 | } 58 | 59 | return r, nil 60 | } 61 | 62 | func (r Resource) Name() string { 63 | return Name 64 | } 65 | -------------------------------------------------------------------------------- /server/endpoint/endpoint.go: -------------------------------------------------------------------------------- 1 | package endpoint 2 | 3 | import ( 4 | "github.com/giantswarm/microendpoint/endpoint/healthz" 5 | "github.com/giantswarm/microendpoint/endpoint/version" 6 | "github.com/giantswarm/microerror" 7 | "github.com/giantswarm/micrologger" 8 | 9 | "github.com/giantswarm/chart-operator/v4/service" 10 | ) 11 | 12 | // Config represents the configuration used to construct an endpoint. 13 | type Config struct { 14 | Logger micrologger.Logger 15 | Service *service.Service 16 | } 17 | 18 | // Endpoint is the endpoint collection. 19 | type Endpoint struct { 20 | Healthz *healthz.Endpoint 21 | Version *version.Endpoint 22 | } 23 | 24 | // New creates a new endpoint with given configuration. 25 | func New(config Config) (*Endpoint, error) { 26 | if config.Logger == nil { 27 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 28 | } 29 | if config.Service == nil { 30 | return nil, microerror.Maskf(invalidConfigError, "%T.Service or it's Healthz descendents must not be empty", config) 31 | } 32 | 33 | var err error 34 | 35 | var healthzEndpoint *healthz.Endpoint 36 | { 37 | c := healthz.Config{ 38 | Logger: config.Logger, 39 | } 40 | 41 | healthzEndpoint, err = healthz.New(c) 42 | if err != nil { 43 | return nil, microerror.Mask(err) 44 | } 45 | } 46 | 47 | var versionEndpoint *version.Endpoint 48 | { 49 | c := version.Config{ 50 | Logger: config.Logger, 51 | Service: config.Service.Version, 52 | } 53 | 54 | versionEndpoint, err = version.New(c) 55 | if err != nil { 56 | return nil, microerror.Mask(err) 57 | } 58 | } 59 | 60 | endpoint := &Endpoint{ 61 | Healthz: healthzEndpoint, 62 | Version: versionEndpoint, 63 | } 64 | 65 | return endpoint, nil 66 | } 67 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "chart-operator.name" -}} 6 | {{- .Chart.Name | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create chart name and version as used by the chart label. 11 | */}} 12 | {{- define "chart-operator.chart" -}} 13 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 14 | {{- end -}} 15 | 16 | {{/* 17 | Common labels 18 | */}} 19 | {{- define "chart-operator.labels" -}} 20 | {{ include "chart-operator.selectorLabels" . }} 21 | app: {{ include "chart-operator.name" . | quote }} 22 | application.giantswarm.io/branch: {{ .Chart.AppVersion | replace "#" "-" | replace "/" "-" | replace "." "-" | trunc 63 | trimSuffix "-" | quote }} 23 | application.giantswarm.io/commit: {{ .Chart.AppVersion | quote }} 24 | app.kubernetes.io/managed-by: {{ .Release.Service | quote }} 25 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 26 | application.giantswarm.io/team: {{ index .Chart.Annotations "io.giantswarm.application.team" | quote }} 27 | helm.sh/chart: {{ include "chart-operator.chart" . | quote }} 28 | {{- end -}} 29 | 30 | {{/* 31 | Selector labels 32 | */}} 33 | {{- define "chart-operator.selectorLabels" -}} 34 | app.kubernetes.io/name: {{ include "chart-operator.name" . | quote }} 35 | app.kubernetes.io/instance: {{ .Release.Name | quote }} 36 | {{- end -}} 37 | 38 | {{- define "resource.vpa.enabled" -}} 39 | {{- if and (.Capabilities.APIVersions.Has "autoscaling.k8s.io/v1") (.Values.verticalPodAutoscaler.enabled) }}true{{ else }}false{{ end }} 40 | {{- end -}} 41 | 42 | {{/* 43 | Define image tag. 44 | */}} 45 | {{- define "image.tag" -}} 46 | {{- if .Values.image.tag }} 47 | {{- .Values.image.tag }} 48 | {{- else }} 49 | {{- .Chart.AppVersion }} 50 | {{- end }} 51 | {{- end }} 52 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | architect: giantswarm/architect@6.11.0 4 | 5 | workflows: 6 | build: 7 | jobs: 8 | - architect/go-build: 9 | context: architect 10 | name: go-build 11 | binary: chart-operator 12 | filters: 13 | tags: 14 | only: /^v.*/ 15 | 16 | - architect/push-to-registries: 17 | context: architect 18 | name: push-to-registries 19 | requires: 20 | - go-build 21 | filters: 22 | tags: 23 | only: /^v.*/ 24 | 25 | branches: 26 | ignore: 27 | - main 28 | - master 29 | - architect/push-to-app-catalog: 30 | context: architect 31 | executor: app-build-suite 32 | name: push-to-cp-app-catalog 33 | app_catalog: control-plane-catalog 34 | app_catalog_test: control-plane-test-catalog 35 | chart: chart-operator 36 | requires: 37 | - push-to-registries 38 | filters: 39 | tags: 40 | only: /^v.*/ 41 | 42 | branches: 43 | ignore: 44 | - main 45 | - master 46 | - architect/push-to-app-catalog: 47 | context: architect 48 | executor: app-build-suite 49 | name: push-to-default-app-catalog 50 | app_catalog: default-catalog 51 | app_catalog_test: default-test-catalog 52 | chart: chart-operator 53 | requires: 54 | - push-to-registries 55 | filters: 56 | tags: 57 | only: /^v.*/ 58 | 59 | branches: 60 | ignore: 61 | - main 62 | - master 63 | - architect/integration-test: 64 | context: architect 65 | name: basic-integration-test 66 | setup-script: integration/setup/setup.sh 67 | test-dir: integration/test/chart/basic 68 | requires: 69 | - go-build 70 | - push-to-default-app-catalog 71 | -------------------------------------------------------------------------------- /Makefile.gen.app.mk: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/eea19f200d7cfd27ded22474b787563bbfdb8ec4/pkg/gen/input/makefile/internal/file/Makefile.gen.app.mk.template 6 | # 7 | 8 | ##@ App 9 | 10 | YQ=docker run --rm -u $$(id -u) -v $${PWD}:/workdir mikefarah/yq:4.29.2 11 | HELM_DOCS=docker run --rm -u $$(id -u) -v $${PWD}:/helm-docs jnorwood/helm-docs:v1.11.0 12 | 13 | ifdef APPLICATION 14 | DEPS := $(shell find $(APPLICATION)/charts -maxdepth 2 -name "Chart.yaml" -printf "%h\n") 15 | endif 16 | 17 | .PHONY: lint-chart check-env update-chart helm-docs update-deps $(DEPS) 18 | 19 | lint-chart: IMAGE := giantswarm/helm-chart-testing:v3.0.0-rc.1 20 | lint-chart: check-env ## Runs ct against the default chart. 21 | @echo "====> $@" 22 | rm -rf /tmp/$(APPLICATION)-test 23 | mkdir -p /tmp/$(APPLICATION)-test/helm 24 | cp -a ./helm/$(APPLICATION) /tmp/$(APPLICATION)-test/helm/ 25 | architect helm template --dir /tmp/$(APPLICATION)-test/helm/$(APPLICATION) 26 | docker run -it --rm -v /tmp/$(APPLICATION)-test:/wd --workdir=/wd --name ct $(IMAGE) ct lint --validate-maintainers=false --charts="helm/$(APPLICATION)" 27 | rm -rf /tmp/$(APPLICATION)-test 28 | 29 | update-chart: check-env ## Sync chart with upstream repo. 30 | @echo "====> $@" 31 | vendir sync 32 | $(MAKE) update-deps 33 | 34 | update-deps: check-env $(DEPS) ## Update Helm dependencies. 35 | cd $(APPLICATION) && helm dependency update 36 | 37 | $(DEPS): check-env ## Update main Chart.yaml with new local dep versions. 38 | dep_name=$(shell basename $@) && \ 39 | new_version=`$(YQ) .version $(APPLICATION)/charts/$$dep_name/Chart.yaml` && \ 40 | $(YQ) -i e "with(.dependencies[]; select(.name == \"$$dep_name\") | .version = \"$$new_version\")" $(APPLICATION)/Chart.yaml 41 | 42 | helm-docs: check-env ## Update $(APPLICATION) README. 43 | $(HELM_DOCS) -c $(APPLICATION) -g $(APPLICATION) 44 | 45 | check-env: 46 | ifndef APPLICATION 47 | $(error APPLICATION is not defined) 48 | endif 49 | -------------------------------------------------------------------------------- /service/collector/set.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "github.com/giantswarm/exporterkit/collector" 5 | "github.com/giantswarm/k8sclient/v7/pkg/k8sclient" 6 | "github.com/giantswarm/microerror" 7 | "github.com/giantswarm/micrologger" 8 | ) 9 | 10 | type SetConfig struct { 11 | K8sClient k8sclient.Interface 12 | Logger micrologger.Logger 13 | 14 | TillerNamespace string 15 | } 16 | 17 | // Set is basically only a wrapper for the operator's collector implementations. 18 | // It eases the iniitialization and prevents some weird import mess so we do not 19 | // have to alias packages. 20 | type Set struct { 21 | *collector.Set 22 | } 23 | 24 | func NewSet(config SetConfig) (*Set, error) { 25 | if config.K8sClient == nil { 26 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 27 | } 28 | if config.Logger == nil { 29 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 30 | } 31 | 32 | if config.TillerNamespace == "" { 33 | return nil, microerror.Maskf(invalidConfigError, "%T.TillerNamespace must not be empty", config) 34 | } 35 | 36 | var err error 37 | 38 | var orphanConfigMapCollector *OrphanConfigMap 39 | { 40 | c := OrphanConfigMapConfig{ 41 | K8sClient: config.K8sClient, 42 | Logger: config.Logger, 43 | } 44 | 45 | orphanConfigMapCollector, err = NewOrphanConfigMap(c) 46 | if err != nil { 47 | return nil, microerror.Mask(err) 48 | } 49 | } 50 | 51 | var orphanSecretCollector *OrphanSecret 52 | { 53 | c := OrphanSecretConfig{ 54 | K8sClient: config.K8sClient, 55 | Logger: config.Logger, 56 | } 57 | 58 | orphanSecretCollector, err = NewOrphanSecret(c) 59 | if err != nil { 60 | return nil, microerror.Mask(err) 61 | } 62 | } 63 | 64 | var collectorSet *collector.Set 65 | { 66 | c := collector.SetConfig{ 67 | Collectors: []collector.Interface{ 68 | orphanConfigMapCollector, 69 | orphanSecretCollector, 70 | }, 71 | Logger: config.Logger, 72 | } 73 | 74 | collectorSet, err = collector.NewSet(c) 75 | if err != nil { 76 | return nil, microerror.Mask(err) 77 | } 78 | } 79 | 80 | s := &Set{ 81 | Set: collectorSet, 82 | } 83 | 84 | return s, nil 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.add-team-labels.yaml: -------------------------------------------------------------------------------- 1 | name: Add appropriate labels to issue 2 | 3 | on: 4 | issues: 5 | types: [assigned] 6 | 7 | jobs: 8 | build_user_list: 9 | name: Get yaml config of GS users 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | steps: 14 | - name: Get user-mapping 15 | env: 16 | GH_TOKEN: ${{ secrets.ISSUE_AUTOMATION }} 17 | run: | 18 | mkdir -p artifacts 19 | gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ 20 | /repos/giantswarm/github/contents/tools/issue-automation/user-mapping.yaml \ 21 | | jq -r '.content' \ 22 | | base64 -d > artifacts/users.yaml 23 | - name: Upload Artifact 24 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 25 | with: 26 | name: users 27 | path: artifacts/users.yaml 28 | retention-days: 1 29 | 30 | add_label: 31 | name: Add team label when assigned 32 | runs-on: ubuntu-latest 33 | needs: build_user_list 34 | steps: 35 | - uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 36 | id: download-users 37 | with: 38 | name: users 39 | - name: Find team label based on user names 40 | run: | 41 | event_assignee=$(cat $GITHUB_EVENT_PATH | jq -r .assignee.login | tr '[:upper:]' '[:lower:]') 42 | echo "Issue assigned to: ${event_assignee}" 43 | 44 | TEAMS=$(cat ${{steps.download-users.outputs.download-path}}/users.yaml | tr '[:upper:]' '[:lower:]' | yq ".${event_assignee}.teams" -o csv | tr ',' ' ') 45 | 46 | echo "LABEL<> $GITHUB_ENV 47 | for team in ${TEAMS}; do 48 | echo "Team: ${team} | Label: team/${team}" 49 | echo "team/${team}" >> $GITHUB_ENV 50 | done 51 | echo "EOF" >> $GITHUB_ENV 52 | - name: Apply label to issue 53 | if: ${{ env.LABEL != '' && env.LABEL != 'null' && env.LABEL != null }} 54 | uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.3 55 | with: 56 | github_token: ${{ secrets.ISSUE_AUTOMATION }} 57 | labels: | 58 | ${{ env.LABEL }} 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/giantswarm/chart-operator/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/giantswarm/chart-operator/tree/main) 2 | 3 | # chart-operator 4 | 5 | The chart-operator deploys Helm charts as [helm] releases. It is implemented 6 | using [operatorkit]. 7 | 8 | ## Branches 9 | 10 | - `main` 11 | - Latest version using Helm 3. 12 | - `helm2` 13 | - Legacy support for Helm 2. 14 | 15 | ## chart CR 16 | 17 | The operator deploys charts hosted in a Helm repository. The chart CRs are 18 | managed by [app-operator] which provides a higher level abstraction for 19 | managing apps via the app CRD. 20 | 21 | ### Example chart CR 22 | 23 | ```yaml 24 | apiVersion: application.giantswarm.io/v1alpha1 25 | kind: Chart 26 | metadata: 27 | name: "prometheus" 28 | labels: 29 | chart-operator.giantswarm.io/version: "1.0.0" 30 | spec: 31 | name: "prometheus" 32 | namespace: "monitoring" 33 | config: 34 | configMap: 35 | name: "prometheus-values" 36 | namespace: "monitoring" 37 | secret: 38 | name: "prometheus-secrets" 39 | namespace: "monitoring" 40 | tarballURL: "https://giantswarm.github.io/app-catalog/prometheus-1-0-0.tgz" 41 | ``` 42 | 43 | ## Getting Project 44 | 45 | Clone the git repository: https://github.com/giantswarm/chart-operator.git 46 | 47 | ### How to build 48 | 49 | Build it using the standard `go build` command. 50 | 51 | ``` 52 | go build github.com/giantswarm/chart-operator 53 | ``` 54 | 55 | ## Contact 56 | 57 | - Mailing list: [giantswarm](https://groups.google.com/forum/!forum/giantswarm) 58 | - IRC: #[giantswarm](irc://irc.freenode.org:6667/#giantswarm) on freenode.org 59 | - Bugs: [issues](https://github.com/giantswarm/chart-operator/issues) 60 | 61 | ## Contributing & Reporting Bugs 62 | 63 | See [CONTRIBUTING](CONTRIBUTING.md) for details on submitting patches, the 64 | contribution workflow as well as reporting bugs. 65 | 66 | ## License 67 | 68 | chart-operator is under the Apache 2.0 license. See the [LICENSE](LICENSE) file for 69 | details. 70 | 71 | 72 | 73 | [app-operator]: https://github.com/giantswarm/app-operator 74 | [helm]: https://github.com/helm/helm 75 | [operatorkit]: https://github.com/giantswarm/operatorkit 76 | -------------------------------------------------------------------------------- /integration/setup/config.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package setup 5 | 6 | import ( 7 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 8 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 9 | "github.com/giantswarm/k8sclient/v7/pkg/k8sclient" 10 | "github.com/giantswarm/microerror" 11 | "github.com/giantswarm/micrologger" 12 | "github.com/spf13/afero" 13 | 14 | "github.com/giantswarm/chart-operator/v4/integration/env" 15 | "github.com/giantswarm/chart-operator/v4/integration/release" 16 | ) 17 | 18 | type Config struct { 19 | HelmClient helmclient.Interface 20 | K8s *k8sclient.Setup 21 | K8sClients k8sclient.Interface 22 | Logger micrologger.Logger 23 | Release *release.Release 24 | } 25 | 26 | func NewConfig() (Config, error) { 27 | var err error 28 | 29 | var logger micrologger.Logger 30 | { 31 | c := micrologger.Config{} 32 | 33 | logger, err = micrologger.New(c) 34 | if err != nil { 35 | return Config{}, microerror.Mask(err) 36 | } 37 | } 38 | 39 | var k8sClients *k8sclient.Clients 40 | { 41 | c := k8sclient.ClientsConfig{ 42 | Logger: logger, 43 | SchemeBuilder: k8sclient.SchemeBuilder{ 44 | v1alpha1.AddToScheme, 45 | }, 46 | 47 | KubeConfigPath: env.KubeConfigPath(), 48 | } 49 | 50 | k8sClients, err = k8sclient.NewClients(c) 51 | if err != nil { 52 | return Config{}, microerror.Mask(err) 53 | } 54 | } 55 | 56 | var k8sSetup *k8sclient.Setup 57 | { 58 | c := k8sclient.SetupConfig{ 59 | Clients: k8sClients, 60 | Logger: logger, 61 | } 62 | 63 | k8sSetup, err = k8sclient.NewSetup(c) 64 | if err != nil { 65 | return Config{}, microerror.Mask(err) 66 | } 67 | } 68 | 69 | fs := afero.NewOsFs() 70 | 71 | var helmClient *helmclient.Client 72 | { 73 | c := helmclient.Config{ 74 | Fs: fs, 75 | K8sClient: k8sClients.K8sClient(), 76 | Logger: logger, 77 | RestClient: k8sClients.RESTClient(), 78 | RestConfig: k8sClients.RESTConfig(), 79 | } 80 | helmClient, err = helmclient.New(c) 81 | if err != nil { 82 | return Config{}, microerror.Mask(err) 83 | } 84 | } 85 | 86 | var newRelease *release.Release 87 | { 88 | c := release.Config{ 89 | HelmClient: helmClient, 90 | Logger: logger, 91 | } 92 | 93 | newRelease, err = release.New(c) 94 | if err != nil { 95 | return Config{}, microerror.Mask(err) 96 | } 97 | } 98 | 99 | c := Config{ 100 | HelmClient: helmClient, 101 | K8s: k8sSetup, 102 | K8sClients: k8sClients, 103 | Logger: logger, 104 | Release: newRelease, 105 | } 106 | 107 | return c, nil 108 | } 109 | -------------------------------------------------------------------------------- /integration/release/release.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package release 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/giantswarm/backoff" 12 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 13 | "github.com/giantswarm/microerror" 14 | "github.com/giantswarm/micrologger" 15 | ) 16 | 17 | type Config struct { 18 | HelmClient *helmclient.Client 19 | Logger micrologger.Logger 20 | } 21 | 22 | type Release struct { 23 | helmClient *helmclient.Client 24 | logger micrologger.Logger 25 | } 26 | 27 | func New(config Config) (*Release, error) { 28 | if config.Logger == nil { 29 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 30 | } 31 | if config.HelmClient == nil { 32 | return nil, microerror.Maskf(invalidConfigError, "%T.HelmClient must not be empty", config) 33 | } 34 | 35 | r := &Release{ 36 | helmClient: config.HelmClient, 37 | logger: config.Logger, 38 | } 39 | 40 | return r, nil 41 | } 42 | 43 | func (r *Release) WaitForChartVersion(ctx context.Context, namespace, release, version string) error { 44 | operation := func() error { 45 | rh, err := r.helmClient.GetReleaseContent(ctx, namespace, release) 46 | if err != nil { 47 | return microerror.Mask(err) 48 | } 49 | if rh.Version != version { 50 | return microerror.Maskf(releaseVersionNotMatchingError, "waiting for '%s', current '%s'", version, rh.Version) 51 | } 52 | return nil 53 | } 54 | 55 | notify := func(err error, t time.Duration) { 56 | r.logger.Log("level", "debug", "message", fmt.Sprintf("failed to get release version '%s': retrying in %s", version, t), "stack", fmt.Sprintf("%v", err)) 57 | } 58 | 59 | b := backoff.NewExponential(backoff.ShortMaxWait, backoff.LongMaxInterval) 60 | err := backoff.RetryNotify(operation, b, notify) 61 | if err != nil { 62 | return microerror.Mask(err) 63 | } 64 | return nil 65 | } 66 | 67 | func (r *Release) WaitForStatus(ctx context.Context, namespace, release, status string) error { 68 | operation := func() error { 69 | rc, err := r.helmClient.GetReleaseContent(ctx, namespace, release) 70 | if helmclient.IsReleaseNotFound(err) && status == helmclient.StatusUninstalled { 71 | // Error is expected because we purge releases when deleting. 72 | return nil 73 | } else if err != nil { 74 | return microerror.Mask(err) 75 | } 76 | if rc.Status != status { 77 | return microerror.Maskf(releaseStatusNotMatchingError, "waiting for '%s', current '%s'", status, rc.Status) 78 | } 79 | return nil 80 | } 81 | 82 | notify := func(err error, t time.Duration) { 83 | r.logger.Log("level", "debug", "message", fmt.Sprintf("failed to get release status '%s': retrying in %s", status, t), "stack", fmt.Sprintf("%v", err)) 84 | } 85 | 86 | b := backoff.NewExponential(backoff.MediumMaxWait, backoff.LongMaxInterval) 87 | err := backoff.RetryNotify(operation, b, notify) 88 | if err != nil { 89 | return microerror.Mask(err) 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/giantswarm/microerror" 9 | microserver "github.com/giantswarm/microkit/server" 10 | "github.com/giantswarm/micrologger" 11 | "github.com/spf13/viper" 12 | 13 | "github.com/giantswarm/chart-operator/v4/pkg/project" 14 | "github.com/giantswarm/chart-operator/v4/server/endpoint" 15 | "github.com/giantswarm/chart-operator/v4/service" 16 | ) 17 | 18 | // Config represents the configuration used to construct server object. 19 | type Config struct { 20 | Logger micrologger.Logger 21 | Service *service.Service 22 | 23 | Viper *viper.Viper 24 | } 25 | 26 | // New creates a new server object with given configuration. 27 | func New(config Config) (microserver.Server, error) { 28 | var err error 29 | 30 | if config.Logger == nil { 31 | return nil, microerror.Maskf(invalidConfigError, "%T.Service must not be empty", config) 32 | } 33 | if config.Service == nil { 34 | return nil, microerror.Maskf(invalidConfigError, "%T.Service must not be empty", config) 35 | } 36 | 37 | if config.Viper == nil { 38 | return nil, microerror.Maskf(invalidConfigError, "%T.Viper must not be empty", config) 39 | } 40 | 41 | var endpointCollection *endpoint.Endpoint 42 | { 43 | c := endpoint.Config{ 44 | Logger: config.Logger, 45 | Service: config.Service, 46 | } 47 | 48 | endpointCollection, err = endpoint.New(c) 49 | if err != nil { 50 | return nil, microerror.Mask(err) 51 | } 52 | } 53 | 54 | newServer := &Server{ 55 | // Dependencies 56 | logger: config.Logger, 57 | 58 | // Internals 59 | bootOnce: sync.Once{}, 60 | config: microserver.Config{ 61 | Logger: config.Logger, 62 | ServiceName: project.Name(), 63 | Viper: config.Viper, 64 | Endpoints: []microserver.Endpoint{ 65 | endpointCollection.Healthz, 66 | endpointCollection.Version, 67 | }, 68 | ErrorEncoder: errorEncoder, 69 | }, 70 | shutdownOnce: sync.Once{}, 71 | } 72 | 73 | return newServer, nil 74 | } 75 | 76 | type Server struct { 77 | // Dependencies 78 | logger micrologger.Logger 79 | 80 | // Internals 81 | bootOnce sync.Once 82 | config microserver.Config 83 | shutdownOnce sync.Once 84 | } 85 | 86 | func (s *Server) Boot() { 87 | s.bootOnce.Do(func() { 88 | // Insert here custom boot logic for server/endpoint if needed. 89 | }) 90 | } 91 | 92 | func (s *Server) Config() microserver.Config { 93 | return s.config 94 | } 95 | 96 | func (s *Server) Shutdown() { 97 | s.shutdownOnce.Do(func() { 98 | // Insert here custom shutdown logic for server/endpoint if needed. 99 | }) 100 | } 101 | 102 | func errorEncoder(ctx context.Context, err error, w http.ResponseWriter) { 103 | rErr := err.(microserver.ResponseError) 104 | uErr := rErr.Underlying() 105 | 106 | rErr.SetCode(microserver.CodeInternalError) 107 | rErr.SetMessage(uErr.Error()) 108 | w.WriteHeader(http.StatusInternalServerError) 109 | } 110 | -------------------------------------------------------------------------------- /integration/setup/setup.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package setup 5 | 6 | import ( 7 | "context" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/giantswarm/appcatalog" 13 | "github.com/giantswarm/backoff" 14 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 15 | "github.com/giantswarm/microerror" 16 | "github.com/spf13/afero" 17 | 18 | "github.com/giantswarm/chart-operator/v4/integration/env" 19 | "github.com/giantswarm/chart-operator/v4/integration/key" 20 | "github.com/giantswarm/chart-operator/v4/pkg/project" 21 | ) 22 | 23 | func Setup(m *testing.M, config Config) { 24 | ctx := context.Background() 25 | 26 | var v int 27 | var err error 28 | 29 | err = installResources(ctx, config) 30 | if err != nil { 31 | config.Logger.Errorf(ctx, err, "failed to install resources") 32 | v = 1 33 | } 34 | 35 | if v == 0 { 36 | v = m.Run() 37 | } 38 | 39 | os.Exit(v) 40 | } 41 | 42 | func installResources(ctx context.Context, config Config) error { 43 | var err error 44 | 45 | { 46 | err = config.K8s.EnsureNamespaceCreated(ctx, key.Namespace()) 47 | if err != nil { 48 | return microerror.Mask(err) 49 | } 50 | } 51 | 52 | var operatorTarballURL string 53 | { 54 | config.Logger.Debugf(ctx, "getting %#q tarball URL", project.Name()) 55 | 56 | o := func() error { 57 | operatorTarballURL, err = appcatalog.GetLatestChart(ctx, key.DefaultTestCatalogStorageURL(), project.Name(), env.CircleSHA()) 58 | if err != nil { 59 | return microerror.Mask(err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | b := backoff.NewConstant(5*time.Minute, 10*time.Second) 66 | n := backoff.NewNotifier(config.Logger, ctx) 67 | 68 | err = backoff.RetryNotify(o, b, n) 69 | if err != nil { 70 | return microerror.Mask(err) 71 | } 72 | 73 | config.Logger.Debugf(ctx, "tarball URL is %#q", operatorTarballURL) 74 | } 75 | 76 | var operatorTarballPath string 77 | { 78 | config.Logger.Debugf(ctx, "pulling tarball") 79 | 80 | operatorTarballPath, err = config.HelmClient.PullChartTarball(ctx, operatorTarballURL) 81 | if err != nil { 82 | return microerror.Mask(err) 83 | } 84 | 85 | config.Logger.Debugf(ctx, "tarball path is %#q", operatorTarballPath) 86 | } 87 | 88 | { 89 | defer func() { 90 | fs := afero.NewOsFs() 91 | err := fs.Remove(operatorTarballPath) 92 | if err != nil { 93 | config.Logger.Errorf(ctx, err, "deletion of %#q failed", operatorTarballPath) 94 | } 95 | }() 96 | 97 | config.Logger.Debugf(ctx, "installing %#q", project.Name()) 98 | 99 | opts := helmclient.InstallOptions{ 100 | ReleaseName: project.Name(), 101 | } 102 | values := map[string]interface{}{ 103 | "clusterDNSIP": "10.96.0.10", 104 | } 105 | err = config.HelmClient.InstallReleaseFromTarball(ctx, operatorTarballPath, key.Namespace(), values, opts) 106 | if err != nil { 107 | return microerror.Mask(err) 108 | } 109 | 110 | config.Logger.Debugf(ctx, "installed %#q", project.Name()) 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/current.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 8 | "github.com/giantswarm/microerror" 9 | "github.com/giantswarm/operatorkit/v7/pkg/controller/context/resourcecanceledcontext" 10 | 11 | "github.com/giantswarm/chart-operator/v4/pkg/project" 12 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/controllercontext" 13 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 14 | ) 15 | 16 | func (r *Resource) GetCurrentState(ctx context.Context, obj interface{}) (interface{}, error) { 17 | cr, err := key.ToCustomResource(obj) 18 | if err != nil { 19 | return nil, microerror.Mask(err) 20 | } 21 | cc, err := controllercontext.FromContext(ctx) 22 | if err != nil { 23 | return nil, microerror.Mask(err) 24 | } 25 | 26 | if key.IsCordoned(cr) { 27 | r.logger.Debugf(ctx, "release %#q has been cordoned until %#q due to reason %#q ", key.ReleaseName(cr), key.CordonUntil(cr), key.CordonReason(cr)) 28 | r.logger.Debugf(ctx, "canceling resource") 29 | resourcecanceledcontext.SetCanceled(ctx) 30 | return nil, nil 31 | } 32 | 33 | hasConfigmap, err := r.findHelmV2ConfigMaps(ctx, key.ReleaseName(cr)) 34 | if err != nil { 35 | reason := fmt.Sprintf("release %#q didn't migrate to helm 3", key.ReleaseName(cr)) 36 | addStatusToContext(cc, reason, releaseNotInstalledStatus) 37 | return nil, microerror.Mask(err) 38 | } 39 | 40 | if hasConfigmap { 41 | r.logger.Debugf(ctx, "release %#q has not been migrated from helm 2", key.ReleaseName(cr)) 42 | r.logger.Debugf(ctx, "canceling resource") 43 | resourcecanceledcontext.SetCanceled(ctx) 44 | return nil, nil 45 | } 46 | 47 | releaseName := key.ReleaseName(cr) 48 | releaseContent, err := r.helmClients.Get(ctx, cr, true).GetReleaseContent(ctx, key.Namespace(cr), releaseName) 49 | if helmclient.IsReleaseNotFound(err) { 50 | // Return early as release is not installed. 51 | return nil, nil 52 | } else if helmclient.IsReleaseNameInvalid(err) { 53 | reason := fmt.Sprintf("release name %#q is invalid", releaseName) 54 | addStatusToContext(cc, reason, releaseNotInstalledStatus) 55 | 56 | r.logger.LogCtx(ctx, "level", "warning", "message", reason, "stack", microerror.JSON(err)) 57 | r.logger.Debugf(ctx, "canceling resource") 58 | resourcecanceledcontext.SetCanceled(ctx) 59 | return nil, nil 60 | 61 | } else if err != nil { 62 | return nil, microerror.Mask(err) 63 | } 64 | 65 | if releaseContent.Status == helmclient.StatusFailed && releaseContent.Name == project.Name() { 66 | r.logger.Debugf(ctx, "not updating own release %#q since it's %#q", releaseContent.Name, releaseContent.Status) 67 | r.logger.Debugf(ctx, "canceling resource") 68 | resourcecanceledcontext.SetCanceled(ctx) 69 | return nil, nil 70 | } 71 | 72 | releaseState := &ReleaseState{ 73 | Name: releaseName, 74 | Status: releaseContent.Status, 75 | ValuesMD5Checksum: key.ValuesMD5ChecksumAnnotation(cr), 76 | Version: releaseContent.Version, 77 | } 78 | 79 | return releaseState, nil 80 | } 81 | -------------------------------------------------------------------------------- /service/collector/orphan_secret.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/k8sclient/v7/pkg/k8sclient" 10 | "github.com/giantswarm/k8smetadata/pkg/label" 11 | "github.com/giantswarm/microerror" 12 | "github.com/giantswarm/micrologger" 13 | "github.com/prometheus/client_golang/prometheus" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | 16 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 17 | ) 18 | 19 | var ( 20 | orphanSecretDesc *prometheus.Desc = prometheus.NewDesc( 21 | prometheus.BuildFQName(Namespace, "secret", "orphan"), 22 | "Secrets without a chart CR.", 23 | []string{}, 24 | nil, 25 | ) 26 | ) 27 | 28 | type OrphanSecretConfig struct { 29 | K8sClient k8sclient.Interface 30 | Logger micrologger.Logger 31 | } 32 | 33 | type OrphanSecret struct { 34 | k8sClient k8sclient.Interface 35 | logger micrologger.Logger 36 | } 37 | 38 | func NewOrphanSecret(config OrphanSecretConfig) (*OrphanSecret, error) { 39 | if config.K8sClient == nil { 40 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 41 | } 42 | if config.Logger == nil { 43 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 44 | } 45 | 46 | oc := &OrphanSecret{ 47 | k8sClient: config.K8sClient, 48 | logger: config.Logger, 49 | } 50 | 51 | return oc, nil 52 | } 53 | 54 | func (oc *OrphanSecret) Collect(ch chan<- prometheus.Metric) error { 55 | ctx := context.Background() 56 | 57 | chartList := &v1alpha1.ChartList{} 58 | err := oc.k8sClient.CtrlClient().List( 59 | ctx, 60 | chartList, 61 | ) 62 | if err != nil { 63 | return microerror.Mask(err) 64 | } 65 | 66 | desiredSecrets := make(map[[2]string]bool) 67 | 68 | for _, chart := range chartList.Items { 69 | desiredSecrets[[2]string{key.SecretNamespace(chart), key.SecretName(chart)}] = true 70 | } 71 | 72 | lo := metav1.ListOptions{ 73 | LabelSelector: fmt.Sprintf("%s=%s", label.ManagedBy, "app-operator"), 74 | } 75 | secrets, err := oc.k8sClient.K8sClient().CoreV1().Secrets("").List(ctx, lo) 76 | if err != nil { 77 | return microerror.Mask(err) 78 | } 79 | 80 | var orphanSecrets []string 81 | 82 | for _, cm := range secrets.Items { 83 | if !desiredSecrets[[2]string{cm.Namespace, cm.Name}] { 84 | orphanSecrets = append(orphanSecrets, fmt.Sprintf("%s.%s", cm.Namespace, cm.Name)) 85 | } 86 | } 87 | 88 | ch <- prometheus.MustNewConstMetric( 89 | orphanSecretDesc, 90 | prometheus.GaugeValue, 91 | float64(len(orphanSecrets)), 92 | ) 93 | 94 | if len(orphanSecrets) > 0 { 95 | oc.logger.Log("level", "debug", "message", fmt.Sprintf("found %d orphan secrets %s", len(orphanSecrets), strings.Join(orphanSecrets, " "))) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // Describe emits the description for the metrics collected here. 102 | func (oc *OrphanSecret) Describe(ch chan<- *prometheus.Desc) error { 103 | ch <- orphanSecretDesc 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /service/collector/orphan_configmap.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/k8sclient/v7/pkg/k8sclient" 10 | "github.com/giantswarm/k8smetadata/pkg/label" 11 | "github.com/giantswarm/microerror" 12 | "github.com/giantswarm/micrologger" 13 | "github.com/prometheus/client_golang/prometheus" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | 16 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 17 | ) 18 | 19 | var ( 20 | orphanConfigMapDesc *prometheus.Desc = prometheus.NewDesc( 21 | prometheus.BuildFQName(Namespace, "configmap", "orphan"), 22 | "Configmaps without a chart CR.", 23 | []string{}, 24 | nil, 25 | ) 26 | ) 27 | 28 | type OrphanConfigMapConfig struct { 29 | K8sClient k8sclient.Interface 30 | Logger micrologger.Logger 31 | } 32 | 33 | type OrphanConfigMap struct { 34 | k8sClient k8sclient.Interface 35 | logger micrologger.Logger 36 | } 37 | 38 | func NewOrphanConfigMap(config OrphanConfigMapConfig) (*OrphanConfigMap, error) { 39 | if config.K8sClient == nil { 40 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 41 | } 42 | if config.Logger == nil { 43 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 44 | } 45 | 46 | oc := &OrphanConfigMap{ 47 | k8sClient: config.K8sClient, 48 | logger: config.Logger, 49 | } 50 | 51 | return oc, nil 52 | } 53 | 54 | func (oc *OrphanConfigMap) Collect(ch chan<- prometheus.Metric) error { 55 | ctx := context.Background() 56 | 57 | chartList := &v1alpha1.ChartList{} 58 | err := oc.k8sClient.CtrlClient().List( 59 | ctx, 60 | chartList, 61 | ) 62 | if err != nil { 63 | return microerror.Mask(err) 64 | } 65 | 66 | desiredConfigMaps := make(map[[2]string]bool) 67 | 68 | for _, chart := range chartList.Items { 69 | desiredConfigMaps[[2]string{key.ConfigMapNamespace(chart), key.ConfigMapName(chart)}] = true 70 | } 71 | 72 | lo := metav1.ListOptions{ 73 | LabelSelector: fmt.Sprintf("%s=%s", label.ManagedBy, "app-operator"), 74 | } 75 | configMaps, err := oc.k8sClient.K8sClient().CoreV1().ConfigMaps("").List(ctx, lo) 76 | if err != nil { 77 | return microerror.Mask(err) 78 | } 79 | 80 | var orphanConfigMaps []string 81 | 82 | for _, cm := range configMaps.Items { 83 | if !desiredConfigMaps[[2]string{cm.Namespace, cm.Name}] { 84 | orphanConfigMaps = append(orphanConfigMaps, fmt.Sprintf("%s.%s", cm.Namespace, cm.Name)) 85 | } 86 | } 87 | 88 | ch <- prometheus.MustNewConstMetric( 89 | orphanConfigMapDesc, 90 | prometheus.GaugeValue, 91 | float64(len(orphanConfigMaps)), 92 | ) 93 | 94 | if len(orphanConfigMaps) > 0 { 95 | oc.logger.Log("level", "debug", "message", fmt.Sprintf("found %d orphan configmaps %s", len(orphanConfigMaps), strings.Join(orphanConfigMaps, " "))) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // Describe emits the description for the metrics collected here. 102 | func (oc *OrphanConfigMap) Describe(ch chan<- *prometheus.Desc) error { 103 | ch <- orphanConfigMapDesc 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /service/controller/chart/resource/namespace/create.go: -------------------------------------------------------------------------------- 1 | package namespace 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 8 | "github.com/giantswarm/k8smetadata/pkg/label" 9 | "github.com/giantswarm/microerror" 10 | corev1 "k8s.io/api/core/v1" 11 | apierrors "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | 14 | "github.com/giantswarm/chart-operator/v4/pkg/project" 15 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 16 | ) 17 | 18 | func (r *Resource) EnsureCreated(ctx context.Context, obj interface{}) error { 19 | cr, err := key.ToCustomResource(obj) 20 | if err != nil { 21 | return microerror.Mask(err) 22 | } 23 | 24 | ns := &corev1.Namespace{ 25 | ObjectMeta: metav1.ObjectMeta{ 26 | Annotations: key.NamespaceAnnotations(cr), 27 | Labels: key.NamespaceLabels(cr), 28 | Name: key.Namespace(cr), 29 | }, 30 | } 31 | 32 | if ns.Labels == nil { 33 | ns.Labels = map[string]string{} 34 | } 35 | 36 | ns.Labels[label.ManagedBy] = project.Name() 37 | 38 | r.logger.Debugf(ctx, "creating namespace %#q", ns.Name) 39 | 40 | ch := make(chan error) 41 | 42 | go func() { 43 | _, err = r.k8sClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) 44 | close(ch) 45 | }() 46 | 47 | select { 48 | case <-ch: 49 | // Fall through. 50 | case <-time.After(r.k8sWaitTimeout): 51 | r.logger.Debugf(ctx, "timeout creating namespace %#q", key.Namespace(cr)) 52 | r.logger.Debugf(ctx, "canceling resource") 53 | return nil 54 | } 55 | 56 | if apierrors.IsAlreadyExists(err) { 57 | r.logger.Debugf(ctx, "already created namespace %#q", key.Namespace(cr)) 58 | 59 | err = r.ensureNamespaceUpdated(ctx, cr) 60 | if err != nil { 61 | return microerror.Mask(err) 62 | } 63 | 64 | return nil 65 | } else if err != nil { 66 | return microerror.Mask(err) 67 | } 68 | 69 | r.logger.Debugf(ctx, "created namespace %#q", key.Namespace(cr)) 70 | 71 | return nil 72 | } 73 | 74 | func (r *Resource) ensureNamespaceUpdated(ctx context.Context, cr v1alpha1.Chart) error { 75 | namespace, err := r.k8sClient.CoreV1().Namespaces().Get(ctx, key.Namespace(cr), metav1.GetOptions{}) 76 | if err != nil { 77 | return microerror.Mask(err) 78 | } 79 | 80 | updated := true 81 | 82 | if namespace.GetLabels() == nil { 83 | namespace.Labels = map[string]string{} 84 | } 85 | 86 | for k, v := range key.NamespaceLabels(cr) { 87 | if namespace.GetLabels()[k] != v { 88 | namespace.GetLabels()[k] = v 89 | updated = false 90 | } 91 | } 92 | 93 | if namespace.GetAnnotations() == nil { 94 | namespace.Annotations = map[string]string{} 95 | } 96 | 97 | for k, v := range key.NamespaceAnnotations(cr) { 98 | if namespace.GetAnnotations()[k] != v { 99 | namespace.GetAnnotations()[k] = v 100 | updated = false 101 | } 102 | } 103 | 104 | if updated { 105 | // no-op 106 | return nil 107 | } 108 | 109 | r.logger.Debugf(ctx, "updating namespace %#q", namespace.Name) 110 | 111 | _, err = r.k8sClient.CoreV1().Namespaces().Update(ctx, namespace, metav1.UpdateOptions{}) 112 | if err != nil { 113 | return microerror.Mask(err) 114 | } 115 | 116 | r.logger.Debugf(ctx, "updated namespace %#q", namespace.Name) 117 | 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /service/controller/chart/resource/status/resource.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 7 | "github.com/giantswarm/microerror" 8 | "github.com/giantswarm/micrologger" 9 | "github.com/giantswarm/to" 10 | "k8s.io/client-go/kubernetes" 11 | "sigs.k8s.io/controller-runtime/pkg/client" 12 | 13 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 14 | ) 15 | 16 | const ( 17 | Name = "status" 18 | 19 | authTokenName = "auth-token" 20 | // defaultHTTPClientTimeout is the timeout when updating app status. 21 | defaultHTTPClientTimeout = 5 22 | namespace = "giantswarm" 23 | releaseStatusCordoned = "CORDONED" 24 | token = "token" 25 | ) 26 | 27 | // Config represents the configuration used to create a new status resource. 28 | type Config struct { 29 | CtrlClient client.Client 30 | HelmClients *clientpair.ClientPair 31 | K8sClient kubernetes.Interface 32 | Logger micrologger.Logger 33 | 34 | HTTPClientTimeout time.Duration 35 | } 36 | 37 | // Resource implements the status resource. 38 | type Resource struct { 39 | ctrlClient client.Client 40 | helmClients *clientpair.ClientPair 41 | k8sClient kubernetes.Interface 42 | logger micrologger.Logger 43 | 44 | httpClientTimeout time.Duration 45 | } 46 | 47 | // New creates a new configured status resource. 48 | func New(config Config) (*Resource, error) { 49 | if config.CtrlClient == nil { 50 | return nil, microerror.Maskf(invalidConfigError, "%T.CtrlClient must not be empty", config) 51 | } 52 | if config.HelmClients == nil { 53 | return nil, microerror.Maskf(invalidConfigError, "%T.HelmClient must not be empty", config) 54 | } 55 | if config.K8sClient == nil { 56 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 57 | } 58 | if config.Logger == nil { 59 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 60 | } 61 | 62 | if config.HTTPClientTimeout == 0 { 63 | config.HTTPClientTimeout = defaultHTTPClientTimeout 64 | } 65 | 66 | r := &Resource{ 67 | ctrlClient: config.CtrlClient, 68 | helmClients: config.HelmClients, 69 | k8sClient: config.K8sClient, 70 | logger: config.Logger, 71 | 72 | httpClientTimeout: config.HTTPClientTimeout, 73 | } 74 | 75 | return r, nil 76 | } 77 | 78 | func (r *Resource) Name() string { 79 | return Name 80 | } 81 | 82 | // equals asseses the equality of ChartStatuses with regards to distinguishing 83 | // fields. 84 | func equals(a, b v1alpha1.ChartStatus) bool { 85 | if a.AppVersion != b.AppVersion { 86 | return false 87 | } 88 | 89 | var lastDeployedA, lastDeployedB int64 90 | 91 | if a.Release.LastDeployed != nil { 92 | lastDeployedA = a.Release.LastDeployed.Unix() 93 | } 94 | if b.Release.LastDeployed != nil { 95 | lastDeployedB = b.Release.LastDeployed.Unix() 96 | } 97 | if lastDeployedA != lastDeployedB { 98 | return false 99 | } 100 | 101 | if a.Reason != b.Reason { 102 | return false 103 | } 104 | 105 | var revisionA, revisionB int 106 | 107 | if a.Release.Revision != nil { 108 | revisionA = to.Int(a.Release.Revision) 109 | } 110 | if b.Release.Revision != nil { 111 | revisionB = to.Int(b.Release.Revision) 112 | } 113 | if revisionA != revisionB { 114 | return false 115 | } 116 | 117 | if a.Release.Status != b.Release.Status { 118 | return false 119 | } 120 | if a.Version != b.Version { 121 | return false 122 | } 123 | 124 | return true 125 | } 126 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/create_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/helmclient/v4/pkg/helmclienttest" 10 | "github.com/giantswarm/micrologger/microloggertest" 11 | "github.com/spf13/afero" 12 | k8sfake "k8s.io/client-go/kubernetes/fake" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" //nolint:staticcheck 14 | 15 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 16 | ) 17 | 18 | func Test_Resource_Release_newCreate(t *testing.T) { 19 | testCases := []struct { 20 | name string 21 | obj v1alpha1.Chart 22 | currentState *ReleaseState 23 | desiredState *ReleaseState 24 | expectedReleaseName string 25 | }{ 26 | { 27 | name: "case 0: empty current and desired, expected empty", 28 | currentState: &ReleaseState{}, 29 | desiredState: &ReleaseState{}, 30 | expectedReleaseName: "", 31 | }, 32 | { 33 | name: "case 1: non-empty current, empty desired, expected empty", 34 | currentState: &ReleaseState{ 35 | Name: "current", 36 | }, 37 | desiredState: &ReleaseState{}, 38 | expectedReleaseName: "", 39 | }, 40 | 41 | { 42 | name: "case 2: empty current, non-empty desired, expected desired", 43 | currentState: &ReleaseState{}, 44 | desiredState: &ReleaseState{ 45 | Name: "desired", 46 | }, 47 | expectedReleaseName: "desired", 48 | }, 49 | { 50 | name: "case 3: equal non-empty current and desired, expected desired", 51 | currentState: &ReleaseState{ 52 | Name: "desired", 53 | }, 54 | desiredState: &ReleaseState{ 55 | Name: "desired", 56 | }, 57 | expectedReleaseName: "desired", 58 | }, 59 | { 60 | name: "case 4: different non-empty current and desired, expected desired", 61 | currentState: &ReleaseState{ 62 | Name: "current", 63 | }, 64 | desiredState: &ReleaseState{ 65 | Name: "desired", 66 | }, 67 | expectedReleaseName: "desired", 68 | }, 69 | } 70 | 71 | var newResource *Resource 72 | { 73 | helmClients, err := clientpair.NewClientPair(clientpair.ClientPairConfig{ 74 | Logger: microloggertest.New(), 75 | 76 | PrvHelmClient: helmclienttest.New(helmclienttest.Config{}), 77 | PubHelmClient: helmclienttest.New(helmclienttest.Config{}), 78 | }) 79 | if err != nil { 80 | t.Fatal("expected", nil, "got", err) 81 | } 82 | 83 | c := Config{ 84 | Fs: afero.NewMemMapFs(), 85 | CtrlClient: fake.NewFakeClient(), //nolint:staticcheck 86 | HelmClients: helmClients, 87 | K8sClient: k8sfake.NewSimpleClientset(), 88 | Logger: microloggertest.New(), 89 | 90 | TillerNamespace: "giantswarm", 91 | } 92 | 93 | newResource, err = New(c) 94 | if err != nil { 95 | t.Fatal("expected", nil, "got", err) 96 | } 97 | } 98 | 99 | for i, tc := range testCases { 100 | t.Run(strconv.Itoa(i), func(t *testing.T) { 101 | result, err := newResource.newCreateChange(context.TODO(), tc.obj, tc.currentState, tc.desiredState) 102 | if err != nil { 103 | t.Fatal("expected", nil, "got", err) 104 | } 105 | createChange, ok := result.(*ReleaseState) 106 | if !ok { 107 | t.Fatalf("expected '%T', got '%T'", createChange, result) 108 | } 109 | if createChange.Name != "" && createChange.Name != tc.expectedReleaseName { 110 | t.Fatalf("expected %s, got %s", tc.expectedReleaseName, createChange.Name) 111 | } 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | chart-operator is Apache 2.0 licensed and accepts contributions via GitHub pull requests. This document outlines some of the conventions on commit message formatting, contact points for developers and other resources to make getting your contribution into chart-operator easier. 4 | 5 | # Email and chat 6 | 7 | - Email: [giantswarm](https://groups.google.com/forum/#!forum/giantswarm) 8 | - IRC: #[giantswarm](irc://irc.freenode.org:6667/#giantswarm) IRC channel on freenode.org 9 | 10 | ## Getting started 11 | 12 | - Fork the repository on GitHub 13 | - Read the [README](README.md) for build instructions 14 | 15 | ## Reporting Bugs and Creating Issues 16 | 17 | Reporting bugs is one of the best ways to contribute. If you find bugs or documentation mistakes in the chart-operator project, please let us know by [opening an issue](https://github.com/giantswarm/chart-operator/issues/new). We treat bugs and mistakes very seriously and believe no issue is too small. Before creating a bug report, please check there that one does not already exist. 18 | 19 | To make your bug report accurate and easy to understand, please try to create bug reports that are: 20 | 21 | - Specific. Include as much details as possible: which version, what environment, what configuration, etc. You can also attach logs. 22 | 23 | - Reproducible. Include the steps to reproduce the problem. We understand some issues might be hard to reproduce, please includes the steps that might lead to the problem. If applicable, you can also attach affected data dir(s) and a stack trace to the bug report. 24 | 25 | - Isolated. Please try to isolate and reproduce the bug with minimum dependencies. It would significantly slow down the speed to fix a bug if too many dependencies are involved in a bug report. Debugging external systems that rely on chart-operator is out of scope, but we are happy to point you in the right direction or help you interact with chart-operator in the correct manner. 26 | 27 | - Unique. Do not duplicate existing bug reports. 28 | 29 | - Scoped. One bug per report. Do not follow up with another bug inside one report. 30 | 31 | You might also want to read [Elika Etemad’s article on filing good bug reports](http://fantasai.inkedblade.net/style/talks/filing-good-bugs/) before creating a bug report. 32 | 33 | We might ask you for further information to locate a bug. A duplicated bug report will be closed. 34 | 35 | ## Contribution flow 36 | 37 | This is a rough outline of what a contributor's workflow looks like: 38 | 39 | - Create a feature branch from where you want to base your work. This is usually master. 40 | - Make commits of logical units. 41 | - Make sure your commit messages are in the proper format (see below). 42 | - Push your changes to a topic branch in your fork of the repository. 43 | - Submit a pull request to giantswarm/chart-operator. 44 | - Adding unit tests will greatly improve the chance for getting a quick review and your PR accepted. 45 | - Your PR must receive a LGTM from one maintainer found in the MAINTAINERS file. 46 | - Before merging your PR be sure to squash all commits into one. 47 | 48 | Thanks for your contributions! 49 | 50 | ### Code style 51 | 52 | The coding style suggested by the Golang community is used. See the [style doc](https://github.com/golang/go/wiki/CodeReviewComments) for details. 53 | 54 | Please follow this style to make the code easy to review, maintain, and develop. 55 | 56 | ### Format of the Commit Message 57 | 58 | We follow a rough convention for commit messages that is designed to answer two 59 | questions: what changed and why. The subject line should feature the what and 60 | the body of the commit should describe the why. 61 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/delete_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/helmclient/v4/pkg/helmclienttest" 10 | "github.com/giantswarm/micrologger/microloggertest" 11 | "github.com/spf13/afero" 12 | k8sfake "k8s.io/client-go/kubernetes/fake" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" //nolint:staticcheck 14 | 15 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 16 | ) 17 | 18 | func Test_Resource_Release_newDeleteChange(t *testing.T) { 19 | testCases := []struct { 20 | obj v1alpha1.Chart 21 | currentState *ReleaseState 22 | desiredState *ReleaseState 23 | expectedDeleteChange *ReleaseState 24 | description string 25 | }{ 26 | { 27 | description: "case 0: empty current and desired, expected empty", 28 | currentState: &ReleaseState{}, 29 | desiredState: &ReleaseState{}, 30 | expectedDeleteChange: nil, 31 | }, 32 | { 33 | description: "case 1: non-empty current, empty desired, expected empty", 34 | currentState: &ReleaseState{ 35 | Name: "current", 36 | }, 37 | desiredState: &ReleaseState{}, 38 | expectedDeleteChange: nil, 39 | }, 40 | 41 | { 42 | description: "case 2: empty current, non-empty desired, expected empty", 43 | currentState: &ReleaseState{}, 44 | desiredState: &ReleaseState{ 45 | Name: "desired", 46 | }, 47 | expectedDeleteChange: nil, 48 | }, 49 | { 50 | description: "case 3: equal non-empty current and desired, expected desired", 51 | currentState: &ReleaseState{ 52 | Name: "desired", 53 | }, 54 | desiredState: &ReleaseState{ 55 | Name: "desired", 56 | }, 57 | expectedDeleteChange: &ReleaseState{ 58 | Name: "desired", 59 | }, 60 | }, 61 | { 62 | description: "case 4: different non-empty current and desired, expected empty", 63 | currentState: &ReleaseState{ 64 | Name: "current", 65 | }, 66 | desiredState: &ReleaseState{ 67 | Name: "desired", 68 | }, 69 | expectedDeleteChange: nil, 70 | }, 71 | } 72 | 73 | var newResource *Resource 74 | { 75 | helmClients, err := clientpair.NewClientPair(clientpair.ClientPairConfig{ 76 | Logger: microloggertest.New(), 77 | 78 | PrvHelmClient: helmclienttest.New(helmclienttest.Config{}), 79 | PubHelmClient: helmclienttest.New(helmclienttest.Config{}), 80 | }) 81 | if err != nil { 82 | t.Fatal("expected", nil, "got", err) 83 | } 84 | 85 | c := Config{ 86 | Fs: afero.NewMemMapFs(), 87 | CtrlClient: fake.NewFakeClient(), //nolint:staticcheck 88 | HelmClients: helmClients, 89 | K8sClient: k8sfake.NewSimpleClientset(), 90 | Logger: microloggertest.New(), 91 | 92 | TillerNamespace: "giantswarm", 93 | } 94 | 95 | newResource, err = New(c) 96 | if err != nil { 97 | t.Fatal("expected", nil, "got", err) 98 | } 99 | } 100 | 101 | for i, tc := range testCases { 102 | t.Run(strconv.Itoa(i), func(t *testing.T) { 103 | result, err := newResource.newDeleteChange(context.TODO(), tc.obj, tc.currentState, tc.desiredState) 104 | if err != nil { 105 | t.Fatal("expected", nil, "got", err) 106 | } 107 | if tc.expectedDeleteChange == nil && result != nil { 108 | t.Fatal("expected", nil, "got", result) 109 | } 110 | if result != nil { 111 | deleteChange, ok := result.(*ReleaseState) 112 | if !ok { 113 | t.Fatalf("expected '%T', got '%T'", deleteChange, result) 114 | } 115 | if deleteChange.Name != "" && deleteChange.Name != tc.expectedDeleteChange.Name { 116 | t.Fatalf("expected %s, got %s", tc.expectedDeleteChange, deleteChange.Name) 117 | } 118 | } 119 | }) 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /service/service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/giantswarm/micrologger/microloggertest" 11 | "github.com/spf13/viper" 12 | v1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | 15 | "github.com/giantswarm/chart-operator/v4/flag" 16 | ) 17 | 18 | func Test_Service_New(t *testing.T) { 19 | // fake server to initialize helm client 20 | // there are two calls to this server during initialization, 21 | // getting the name of tiller pod and port forwarding to it 22 | h := func(w http.ResponseWriter, r *http.Request) { 23 | if strings.Contains(r.URL.Path, "portforward") { 24 | // port forward request 25 | w.Header().Set("Connection", "Upgrade") 26 | w.Header().Set("Upgrade", "SPDY/3.1") 27 | w.WriteHeader(http.StatusSwitchingProtocols) 28 | } else { 29 | // tiller pod name request 30 | podList := v1.PodList{ 31 | Items: []v1.Pod{ 32 | v1.Pod{ 33 | ObjectMeta: metav1.ObjectMeta{ 34 | Name: "tiller-pod", 35 | }, 36 | }, 37 | }, 38 | } 39 | pods, err := json.Marshal(podList) 40 | if err != nil { 41 | http.Error(w, err.Error(), http.StatusInternalServerError) 42 | return 43 | } 44 | w.Header().Set("Content-Type", "application/json") 45 | _, err = w.Write(pods) 46 | if err != nil { 47 | t.Fatalf("error == %#v, want nil", err) 48 | } 49 | } 50 | } 51 | ts := httptest.NewServer(http.HandlerFunc(h)) 52 | defer ts.Close() 53 | 54 | testCases := []struct { 55 | name string 56 | config func() Config 57 | errorMatcher func(error) bool 58 | }{ 59 | { 60 | name: "case 0: valid config returns no error", 61 | config: func() Config { 62 | c := Config{ 63 | Flag: flag.New(), 64 | Logger: microloggertest.New(), 65 | Viper: viper.New(), 66 | } 67 | 68 | c.Viper.Set(c.Flag.Service.Helm.HTTP.ClientTimeout, "5s") 69 | c.Viper.Set(c.Flag.Service.Helm.TillerNamespace, "giantswarm") 70 | c.Viper.Set(c.Flag.Service.Helm.NamespaceWhitelist, []string{"giantswarm"}) 71 | c.Viper.Set(c.Flag.Service.Kubernetes.Address, ts.URL) 72 | c.Viper.Set(c.Flag.Service.Kubernetes.InCluster, false) 73 | 74 | return c 75 | }, 76 | errorMatcher: nil, 77 | }, 78 | { 79 | name: "case 1: valid config returns no error", 80 | config: func() Config { 81 | c := Config{ 82 | Flag: flag.New(), 83 | Logger: microloggertest.New(), 84 | Viper: viper.New(), 85 | } 86 | 87 | c.Viper.Set(c.Flag.Service.Helm.HTTP.ClientTimeout, "5s") 88 | c.Viper.Set(c.Flag.Service.Helm.NamespaceWhitelist, []string{"giantswarm", "org-giantswarm"}) 89 | c.Viper.Set(c.Flag.Service.Helm.SplitClient, true) 90 | c.Viper.Set(c.Flag.Service.Helm.TillerNamespace, "giantswarm") 91 | c.Viper.Set(c.Flag.Service.Kubernetes.Address, ts.URL) 92 | c.Viper.Set(c.Flag.Service.Kubernetes.InCluster, false) 93 | 94 | return c 95 | }, 96 | errorMatcher: nil, 97 | }, 98 | { 99 | name: "case 2: invalid config returns error", 100 | config: func() Config { 101 | c := Config{ 102 | Flag: flag.New(), 103 | Viper: viper.New(), 104 | } 105 | 106 | return c 107 | }, 108 | errorMatcher: IsInvalidConfig, 109 | }, 110 | } 111 | 112 | for _, tc := range testCases { 113 | t.Run(tc.name, func(t *testing.T) { 114 | _, err := New(tc.config()) 115 | 116 | switch { 117 | case err != nil && tc.errorMatcher == nil: 118 | t.Fatalf("error == %#v, want nil", err) 119 | case err == nil && tc.errorMatcher != nil: 120 | t.Fatalf("error == nil, want non-nil") 121 | case tc.errorMatcher != nil && !tc.errorMatcher(err): 122 | t.Fatalf("error == %#v, want matching", err) 123 | } 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/delete.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 7 | "github.com/giantswarm/microerror" 8 | "github.com/giantswarm/operatorkit/v7/pkg/controller/context/finalizerskeptcontext" 9 | "github.com/giantswarm/operatorkit/v7/pkg/controller/context/resourcecanceledcontext" 10 | "github.com/giantswarm/operatorkit/v7/pkg/resource/crud" 11 | 12 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 13 | ) 14 | 15 | func (r *Resource) ApplyDeleteChange(ctx context.Context, obj, deleteChange interface{}) error { 16 | cr, err := key.ToCustomResource(obj) 17 | if err != nil { 18 | return microerror.Mask(err) 19 | } 20 | 21 | // We use elevated Helm client when performing deletion-wise operations to 22 | // avoid permissions issues when deleting App Bundles from cluster namespace, 23 | // see: https://github.com/giantswarm/giantswarm/issues/25731 24 | hc := r.helmClients.Get(ctx, cr, true) 25 | 26 | releaseState, err := toReleaseState(deleteChange) 27 | if err != nil { 28 | return microerror.Mask(err) 29 | } 30 | 31 | if releaseState.Name != "" { 32 | r.logger.Debugf(ctx, "deleting release %#q", releaseState.Name) 33 | 34 | opts := helmclient.DeleteOptions{} 35 | timeout := key.UninstallTimeout(cr) 36 | 37 | if timeout != nil { 38 | opts.Timeout = (*timeout).Duration 39 | } 40 | 41 | err = hc.DeleteRelease(ctx, key.Namespace(cr), releaseState.Name, opts) 42 | if helmclient.IsReleaseNotFound(err) { 43 | r.logger.Debugf(ctx, "release %#q already deleted", releaseState.Name) 44 | return nil 45 | } else if err != nil { 46 | return microerror.Mask(err) 47 | } 48 | 49 | rel, err := hc.GetReleaseContent(ctx, key.Namespace(cr), releaseState.Name) 50 | if rel != nil { 51 | // Release still exists. We cancel the resource and keep the finalizer. 52 | // We will retry the delete in the next reconciliation loop. 53 | r.logger.Debugf(ctx, "release %#q still exists", releaseState.Name) 54 | 55 | finalizerskeptcontext.SetKept(ctx) 56 | r.logger.Debugf(ctx, "keeping finalizers") 57 | 58 | resourcecanceledcontext.SetCanceled(ctx) 59 | r.logger.Debugf(ctx, "canceling resource") 60 | 61 | return nil 62 | } else if helmclient.IsReleaseNotFound(err) { 63 | r.logger.Debugf(ctx, "deleted release %#q", releaseState.Name) 64 | } else if err != nil { 65 | return microerror.Mask(err) 66 | } 67 | } else { 68 | r.logger.Debugf(ctx, "not deleting release %#q", releaseState.Name) 69 | } 70 | return nil 71 | } 72 | 73 | func (r *Resource) NewDeletePatch(ctx context.Context, obj, currentState, desiredState interface{}) (*crud.Patch, error) { 74 | delete, err := r.newDeleteChange(ctx, obj, currentState, desiredState) 75 | if err != nil { 76 | return nil, microerror.Mask(err) 77 | } 78 | 79 | patch := crud.NewPatch() 80 | patch.SetDeleteChange(delete) 81 | 82 | return patch, nil 83 | } 84 | 85 | func (r *Resource) newDeleteChange(ctx context.Context, obj, currentState, desiredState interface{}) (interface{}, error) { 86 | currentReleaseState, err := toReleaseState(currentState) 87 | if err != nil { 88 | return nil, microerror.Mask(err) 89 | } 90 | desiredReleaseState, err := toReleaseState(desiredState) 91 | if err != nil { 92 | return nil, microerror.Mask(err) 93 | } 94 | 95 | r.logger.Debugf(ctx, "finding out if the %#q release has to be deleted", desiredReleaseState.Name) 96 | 97 | if !isEmpty(currentReleaseState) && currentReleaseState.Name == desiredReleaseState.Name { 98 | r.logger.Debugf(ctx, "the %#q release needs to be deleted", desiredReleaseState.Name) 99 | 100 | return &desiredReleaseState, nil 101 | } else { 102 | r.logger.Debugf(ctx, "the %#q release does not need to be deleted", desiredReleaseState.Name) 103 | } 104 | 105 | return nil, nil 106 | } 107 | -------------------------------------------------------------------------------- /service/controller/chart/chart.go: -------------------------------------------------------------------------------- 1 | package chart 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 8 | "github.com/giantswarm/k8sclient/v7/pkg/k8sclient" 9 | "github.com/giantswarm/microerror" 10 | "github.com/giantswarm/micrologger" 11 | "github.com/giantswarm/operatorkit/v7/pkg/controller" 12 | "github.com/giantswarm/operatorkit/v7/pkg/resource" 13 | "github.com/spf13/afero" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | 16 | "github.com/giantswarm/chart-operator/v4/pkg/annotation" 17 | "github.com/giantswarm/chart-operator/v4/pkg/project" 18 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/controllercontext" 19 | 20 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 21 | ) 22 | 23 | const chartControllerSuffix = "-chart" 24 | 25 | type Config struct { 26 | Fs afero.Fs 27 | HelmClients *clientpair.ClientPair 28 | K8sClient k8sclient.Interface 29 | Logger micrologger.Logger 30 | 31 | ResyncPeriod time.Duration 32 | 33 | HTTPClientTimeout time.Duration 34 | K8sWaitTimeout time.Duration 35 | K8sWatchNamespace string 36 | MaxRollback int 37 | TillerNamespace string 38 | } 39 | 40 | type Chart struct { 41 | *controller.Controller 42 | } 43 | 44 | func NewChart(config Config) (*Chart, error) { 45 | var err error 46 | 47 | if config.HelmClients == nil { 48 | return nil, microerror.Maskf(invalidConfigError, "%T.HelmClients must not be empty", config) 49 | } 50 | if config.Fs == nil { 51 | return nil, microerror.Maskf(invalidConfigError, "%T.Fs must not be empty", config) 52 | } 53 | if config.K8sClient == nil { 54 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 55 | } 56 | if config.Logger == nil { 57 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 58 | } 59 | 60 | if config.TillerNamespace == "" { 61 | return nil, microerror.Maskf(invalidConfigError, "%T.TillerNamespace must not be empty", config) 62 | } 63 | 64 | // TODO: Remove usage of deprecated controller context. 65 | // 66 | // https://github.com/giantswarm/giantswarm/issues/12324 67 | // 68 | initCtxFunc := func(ctx context.Context, obj interface{}) (context.Context, error) { 69 | cc := controllercontext.Context{} 70 | ctx = controllercontext.NewContext(ctx, cc) 71 | 72 | return ctx, nil 73 | } 74 | 75 | var resources []resource.Interface 76 | { 77 | c := chartResourcesConfig{ 78 | Fs: config.Fs, 79 | CtrlClient: config.K8sClient.CtrlClient(), 80 | HelmClients: config.HelmClients, 81 | K8sClient: config.K8sClient.K8sClient(), 82 | Logger: config.Logger, 83 | 84 | HTTPClientTimeout: config.HTTPClientTimeout, 85 | K8sWaitTimeout: config.K8sWaitTimeout, 86 | MaxRollback: config.MaxRollback, 87 | TillerNamespace: config.TillerNamespace, 88 | } 89 | 90 | resources, err = newChartResources(c) 91 | if err != nil { 92 | return nil, microerror.Mask(err) 93 | } 94 | } 95 | 96 | var chartController *controller.Controller 97 | { 98 | c := controller.Config{ 99 | InitCtx: initCtxFunc, 100 | K8sClient: config.K8sClient, 101 | Logger: config.Logger, 102 | Pause: map[string]string{ 103 | annotation.ChartOperatorPaused: "true", 104 | }, 105 | Resources: resources, 106 | NewRuntimeObjectFunc: func() client.Object { 107 | return new(v1alpha1.Chart) 108 | }, 109 | 110 | Name: project.Name() + chartControllerSuffix, 111 | Namespace: config.K8sWatchNamespace, 112 | 113 | ResyncPeriod: config.ResyncPeriod, 114 | } 115 | 116 | chartController, err = controller.New(c) 117 | if err != nil { 118 | return nil, microerror.Mask(err) 119 | } 120 | } 121 | 122 | c := &Chart{ 123 | Controller: chartController, 124 | } 125 | 126 | return c, nil 127 | } 128 | -------------------------------------------------------------------------------- /.github/workflows/zz_generated.add-to-project-board.yaml: -------------------------------------------------------------------------------- 1 | name: Add Issue to Project when assigned 2 | 3 | on: 4 | issues: 5 | types: 6 | - assigned 7 | - labeled 8 | 9 | jobs: 10 | build_user_list: 11 | name: Get yaml config of GS users 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: read 15 | steps: 16 | - name: Get user-mapping 17 | env: 18 | GH_TOKEN: ${{ secrets.ISSUE_AUTOMATION }} 19 | run: | 20 | mkdir -p artifacts 21 | gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ 22 | /repos/giantswarm/github/contents/tools/issue-automation/user-mapping.yaml \ 23 | | jq -r '.content' \ 24 | | base64 -d > artifacts/users.yaml 25 | - name: Upload Artifact 26 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 27 | with: 28 | name: users 29 | path: artifacts/users.yaml 30 | retention-days: 1 31 | - name: Get label-mapping 32 | env: 33 | GH_TOKEN: ${{ secrets.ISSUE_AUTOMATION }} 34 | run: | 35 | mkdir -p artifacts 36 | gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ 37 | /repos/giantswarm/github/contents/tools/issue-automation/label-mapping.yaml \ 38 | | jq -r '.content' \ 39 | | base64 -d > artifacts/labels.yaml 40 | - name: Upload Artifact 41 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 42 | with: 43 | name: labels 44 | path: artifacts/labels.yaml 45 | retention-days: 1 46 | 47 | add_to_personal_board: 48 | name: Add issue to personal board 49 | runs-on: ubuntu-latest 50 | needs: build_user_list 51 | if: github.event.action == 'assigned' 52 | steps: 53 | - uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 54 | id: download-users 55 | with: 56 | name: users 57 | - name: Find personal board based on user names 58 | run: | 59 | event_assignee=$(cat $GITHUB_EVENT_PATH | jq -r .assignee.login | tr '[:upper:]' '[:lower:]') 60 | echo "Issue assigned to: ${event_assignee}" 61 | 62 | BOARD=($(cat ${{steps.download-users.outputs.download-path}}/users.yaml | tr '[:upper:]' '[:lower:]' | yq ".${event_assignee}.personalboard")) 63 | echo "Personal board URL: ${BOARD}" 64 | 65 | echo "BOARD=${BOARD}" >> $GITHUB_ENV 66 | - name: Add issue to personal board 67 | if: ${{ env.BOARD != 'null' && env.BOARD != '' && env.BOARD != null }} 68 | uses: actions/add-to-project@9bfe908f2eaa7ba10340b31e314148fcfe6a2458 # v1.0.1 69 | with: 70 | project-url: ${{ env.BOARD }} 71 | github-token: ${{ secrets.ISSUE_AUTOMATION }} 72 | 73 | add_to_team_board: 74 | name: Add issue to team board 75 | runs-on: ubuntu-latest 76 | needs: build_user_list 77 | if: github.event.action == 'labeled' 78 | steps: 79 | - uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 80 | id: download-labels 81 | with: 82 | name: labels 83 | - name: Find team board based on label 84 | run: | 85 | event_label=$(cat $GITHUB_EVENT_PATH | jq -r .label.name | tr '[:upper:]' '[:lower:]') 86 | echo "Issue labelled with: ${event_label}" 87 | 88 | BOARD=($(cat ${{steps.download-labels.outputs.download-path}}/labels.yaml | tr '[:upper:]' '[:lower:]' | yq ".[\"${event_label}\"].projectboard")) 89 | echo "Team board URL: ${BOARD}" 90 | 91 | echo "BOARD=${BOARD}" >> $GITHUB_ENV 92 | - name: Add issue to team board 93 | if: ${{ env.BOARD != 'null' && env.BOARD != '' && env.BOARD != null }} 94 | uses: actions/add-to-project@9bfe908f2eaa7ba10340b31e314148fcfe6a2458 # v1.0.1 95 | with: 96 | project-url: ${{ env.BOARD }} 97 | github-token: ${{ secrets.ISSUE_AUTOMATION }} 98 | -------------------------------------------------------------------------------- /helm/chart-operator/values.yaml: -------------------------------------------------------------------------------- 1 | # For CAPI clusters this will be set to true. So charts for CNI apps can be installed. 2 | chartOperator: 3 | cni: 4 | install: false 5 | 6 | cluster: 7 | kubernetes: 8 | domain: cluster.local 9 | # is getting overwritten by the top level proxy if set 10 | # These values are generated via cluster-apps-operator 11 | proxy: 12 | noProxy: "" 13 | http: "" 14 | https: "" 15 | 16 | clusterDNSIP: 172.31.0.10 17 | 18 | deployment: 19 | requests: 20 | cpu: 400m 21 | memory: 256Mi 22 | limits: 23 | cpu: 600m 24 | memory: 512Mi 25 | 26 | verticalPodAutoscaler: 27 | enabled: true 28 | maxAllowed: 29 | cpu: 1000m 30 | memory: 1024Mi 31 | 32 | volumes: 33 | tmp: 34 | enabled: true 35 | # created as emptyDir, so it contributes to the memory limits 36 | sizeLimit: 128Mi 37 | 38 | # if explicitly set to empty string, the dnsPolicy: Default is used and tis expected that the cluster is private/closed cluster 39 | externalDNSIP: 8.8.8.8 40 | 41 | e2e: false 42 | 43 | helm: 44 | splitClient: false 45 | namespaceWhitelist: [] 46 | http: 47 | clientTimeout: "5s" 48 | kubernetes: 49 | waitTimeout: "120s" 50 | watch: 51 | namespace: "giantswarm" 52 | maxRollback: 3 53 | 54 | image: 55 | registry: gsoci.azurecr.io 56 | name: "giantswarm/chart-operator" 57 | tag: "" 58 | 59 | controller: 60 | resyncPeriod: "5m" 61 | 62 | registry: 63 | domain: gsoci.azurecr.io 64 | 65 | pod: 66 | user: 67 | id: 1000 68 | group: 69 | id: 1000 70 | port: 8000 71 | replicas: 1 72 | 73 | # set the HTTP_PROXY, HTTPS_PROXY and NO_PROXY variable 74 | proxy: 75 | noProxy: "" 76 | http: "" 77 | https: "" 78 | 79 | # Resource names are truncated to 47 characters. Kubernetes allows 63 characters 80 | # limit for resource names. When pods for deployments are created they have 81 | # additional 16 characters suffix, e.g. "-957c9d6ff-pkzgw" and we want to have 82 | # room for those suffixes. 83 | # 84 | # NOTE: All values under resource key need to be used with `tpl` to render them 85 | # correctly in the templates. This is because helm doesn't template values.yaml 86 | # file and it has to be a valid json. Example usage: 87 | # 88 | # {{ tpl .Values.resource.default.name . }}. 89 | # 90 | resource: 91 | default: 92 | name: '{{ .Release.Name | replace "." "-" | trunc 47 }}' 93 | namespace: "giantswarm" 94 | psp: 95 | name: '{{ .Release.Name | replace "." "-" | trunc 47 }}-psp' 96 | 97 | tiller: 98 | namespace: "kube-system" 99 | 100 | isManagementCluster: false 101 | 102 | # When this flag is true, chart operator runs in special mode in order to be able to run in partially deployed clusters. 103 | # Main differences are: 104 | # - runs on master nodes 105 | # - runs on hostNetwork 106 | # - tolerates all taints 107 | # - uses API hostname to reach the API to support kube-proxy being missing 108 | # This mode is meant to be used during bootstrap of clusters to be able to deploy basic system services 109 | # (such as the CNI or the out-of-tree cloud controller managers) as a managed app. 110 | # After the cluster is fully deployed, this flag should be switched to false. 111 | bootstrapMode: 112 | apiServerPodPort: 443 113 | enabled: false 114 | 115 | # Pod securityContext 116 | podSecurityContext: 117 | runAsNonRoot: true 118 | seccompProfile: 119 | type: RuntimeDefault 120 | 121 | # Container securityContext 122 | securityContext: 123 | allowPrivilegeEscalation: false 124 | capabilities: 125 | drop: 126 | - ALL 127 | privileged: false 128 | # Should only be enabled when /tmp has a mounted volume in place, 129 | # otherwise artifact pulls will fail to be written to the file system. 130 | readOnlyRootFilesystem: true 131 | runAsNonRoot: true 132 | seccompProfile: 133 | type: RuntimeDefault 134 | 135 | kyvernoPolicyExceptions: 136 | enabled: true 137 | namespace: giantswarm 138 | -------------------------------------------------------------------------------- /Makefile.gen.go.mk: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT. Generated with: 2 | # 3 | # devctl 4 | # 5 | # https://github.com/giantswarm/devctl/blob/bf7f386ac6a4e807dde959892df1369fee6d789f/pkg/gen/input/makefile/internal/file/Makefile.gen.go.mk.template 6 | # 7 | 8 | APPLICATION := $(shell go list -m | cut -d '/' -f 3) 9 | BUILDTIMESTAMP := $(shell date -u '+%FT%TZ') 10 | GITSHA1 := $(shell git rev-parse --verify HEAD) 11 | MODULE := $(shell go list -m) 12 | OS := $(shell go env GOOS) 13 | SOURCES := $(shell find . -name '*.go') 14 | VERSION := $(shell architect project version) 15 | ifeq ($(OS), linux) 16 | EXTLDFLAGS := -static 17 | endif 18 | LDFLAGS ?= -w -linkmode 'auto' -extldflags '$(EXTLDFLAGS)' \ 19 | -X '$(shell go list -m)/pkg/project.buildTimestamp=${BUILDTIMESTAMP}' \ 20 | -X '$(shell go list -m)/pkg/project.gitSHA=${GITSHA1}' 21 | 22 | .DEFAULT_GOAL := build 23 | 24 | ##@ Go 25 | 26 | .PHONY: build build-darwin build-darwin-64 build-linux build-linux-arm64 build-windows-amd64 27 | build: $(APPLICATION) ## Builds a local binary. 28 | @echo "====> $@" 29 | build-darwin: $(APPLICATION)-darwin ## Builds a local binary for darwin/amd64. 30 | @echo "====> $@" 31 | build-darwin-arm64: $(APPLICATION)-darwin-arm64 ## Builds a local binary for darwin/arm64. 32 | @echo "====> $@" 33 | build-linux: $(APPLICATION)-linux ## Builds a local binary for linux/amd64. 34 | @echo "====> $@" 35 | build-linux-arm64: $(APPLICATION)-linux-arm64 ## Builds a local binary for linux/arm64. 36 | @echo "====> $@" 37 | build-windows-amd64: $(APPLICATION)-windows-amd64.exe ## Builds a local binary for windows/amd64. 38 | @echo "====> $@" 39 | 40 | $(APPLICATION): $(APPLICATION)-v$(VERSION)-$(OS)-amd64 41 | @echo "====> $@" 42 | cp -a $< $@ 43 | 44 | $(APPLICATION)-darwin: $(APPLICATION)-v$(VERSION)-darwin-amd64 45 | @echo "====> $@" 46 | cp -a $< $@ 47 | 48 | $(APPLICATION)-darwin-arm64: $(APPLICATION)-v$(VERSION)-darwin-arm64 49 | @echo "====> $@" 50 | cp -a $< $@ 51 | 52 | $(APPLICATION)-linux: $(APPLICATION)-v$(VERSION)-linux-amd64 53 | @echo "====> $@" 54 | cp -a $< $@ 55 | 56 | $(APPLICATION)-linux-arm64: $(APPLICATION)-v$(VERSION)-linux-arm64 57 | @echo "====> $@" 58 | cp -a $< $@ 59 | 60 | $(APPLICATION)-windows-amd64.exe: $(APPLICATION)-v$(VERSION)-windows-amd64.exe 61 | @echo "====> $@" 62 | cp -a $< $@ 63 | 64 | $(APPLICATION)-v$(VERSION)-%-amd64: $(SOURCES) 65 | @echo "====> $@" 66 | CGO_ENABLED=0 GOOS=$* GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $@ . 67 | 68 | $(APPLICATION)-v$(VERSION)-%-arm64: $(SOURCES) 69 | @echo "====> $@" 70 | CGO_ENABLED=0 GOOS=$* GOARCH=arm64 go build -trimpath -ldflags "$(LDFLAGS)" -o $@ . 71 | 72 | $(APPLICATION)-v$(VERSION)-windows-amd64.exe: $(SOURCES) 73 | @echo "====> $@" 74 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(LDFLAGS)" -o $@ . 75 | 76 | .PHONY: install 77 | install: ## Install the application. 78 | @echo "====> $@" 79 | go install -ldflags "$(LDFLAGS)" . 80 | 81 | .PHONY: run 82 | run: ## Runs go run main.go. 83 | @echo "====> $@" 84 | go run -ldflags "$(LDFLAGS)" -race . 85 | 86 | .PHONY: clean 87 | clean: ## Cleans the binary. 88 | @echo "====> $@" 89 | rm -f $(APPLICATION)* 90 | go clean 91 | 92 | .PHONY: imports 93 | imports: ## Runs goimports. 94 | @echo "====> $@" 95 | goimports -local $(MODULE) -w . 96 | 97 | .PHONY: lint 98 | lint: ## Runs golangci-lint. 99 | @echo "====> $@" 100 | golangci-lint run -E gosec -E goconst --timeout=15m ./... 101 | 102 | .PHONY: fmt 103 | fmt: ## Run go fmt against code. 104 | go fmt ./... 105 | 106 | .PHONY: vet 107 | vet: ## Run go vet against code. 108 | go vet ./... 109 | 110 | .PHONY: nancy 111 | nancy: ## Runs nancy (requires v1.0.37 or newer). 112 | @echo "====> $@" 113 | CGO_ENABLED=0 go list -json -deps ./... | nancy sleuth --skip-update-check --quiet --exclude-vulnerability-file ./.nancy-ignore --additional-exclude-vulnerability-files ./.nancy-ignore.generated 114 | 115 | .PHONY: test 116 | test: ## Runs go test with default values. 117 | @echo "====> $@" 118 | go test -ldflags "$(LDFLAGS)" -race ./... 119 | 120 | .PHONY: build-docker 121 | build-docker: build-linux ## Builds docker image to registry. 122 | @echo "====> $@" 123 | cp -a $(APPLICATION)-linux $(APPLICATION) 124 | docker build -t ${APPLICATION}:${VERSION} . 125 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/desired.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" // #nosec 6 | "fmt" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 10 | "github.com/giantswarm/microerror" 11 | "github.com/imdario/mergo" 12 | apierrors "k8s.io/apimachinery/pkg/api/errors" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "sigs.k8s.io/yaml" 15 | 16 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 17 | ) 18 | 19 | func (r *Resource) GetDesiredState(ctx context.Context, obj interface{}) (interface{}, error) { 20 | cr, err := key.ToCustomResource(obj) 21 | if err != nil { 22 | return nil, microerror.Mask(err) 23 | } 24 | 25 | configMapData, err := r.getConfigMapData(ctx, cr) 26 | if err != nil { 27 | return nil, microerror.Mask(err) 28 | } 29 | 30 | secretData, err := r.getSecretData(ctx, cr) 31 | if err != nil { 32 | return nil, microerror.Mask(err) 33 | } 34 | 35 | // Merge configmap and secret to provide a single set of values to Helm. 36 | err = mergo.Merge(&configMapData, secretData, mergo.WithOverride) 37 | if err != nil { 38 | return nil, microerror.Mask(err) 39 | } 40 | 41 | // Convert all floats to integers if they have the same value to return the same md5 hash. 42 | convertFloat(configMapData) 43 | 44 | var valuesMD5Checksum string 45 | 46 | if len(configMapData) > 0 { 47 | // MD5 is only used for comparison but we need to turn off gosec or 48 | // linting errors will occur. 49 | h := md5.New() // #nosec 50 | _, err := fmt.Fprintf(h, "%v", configMapData) 51 | if err != nil { 52 | return nil, microerror.Mask(err) 53 | } 54 | 55 | valuesMD5Checksum = fmt.Sprintf("%x", h.Sum(nil)) 56 | } 57 | 58 | releaseState := &ReleaseState{ 59 | Name: key.ReleaseName(cr), 60 | Status: helmclient.StatusDeployed, 61 | ValuesMD5Checksum: valuesMD5Checksum, 62 | Values: configMapData, 63 | Version: key.Version(cr), 64 | } 65 | 66 | return releaseState, nil 67 | } 68 | 69 | func (r *Resource) getConfigMapData(ctx context.Context, cr v1alpha1.Chart) (map[string]interface{}, error) { 70 | configMapData := map[string]interface{}{} 71 | 72 | // TODO: Improve desired state generation by removing call to key.IsDeleted. 73 | // 74 | // See https://github.com/giantswarm/giantswarm/issues/5719 75 | // 76 | if key.IsDeleted(cr) { 77 | // Return early as configmap has already been deleted. 78 | return configMapData, nil 79 | } 80 | 81 | if key.ConfigMapName(cr) != "" { 82 | configMapName := key.ConfigMapName(cr) 83 | configMapNamespace := key.ConfigMapNamespace(cr) 84 | 85 | configMap, err := r.k8sClient.CoreV1().ConfigMaps(configMapNamespace).Get(ctx, configMapName, metav1.GetOptions{}) 86 | if apierrors.IsNotFound(err) { 87 | return nil, microerror.Maskf(notFoundError, "config map %#q in namespace %#q not found", configMapName, configMapNamespace) 88 | } else if err != nil { 89 | return nil, microerror.Mask(err) 90 | } 91 | 92 | for _, str := range configMap.Data { 93 | err := yaml.Unmarshal([]byte(str), &configMapData) 94 | if err != nil { 95 | return nil, microerror.Mask(err) 96 | } 97 | } 98 | } 99 | 100 | return configMapData, nil 101 | } 102 | 103 | func (r *Resource) getSecretData(ctx context.Context, cr v1alpha1.Chart) (map[string]interface{}, error) { 104 | var secretData map[string]interface{} 105 | 106 | // TODO: Improve desired state generation by removing call to key.IsDeleted. 107 | // 108 | // See https://github.com/giantswarm/giantswarm/issues/5719 109 | // 110 | if key.IsDeleted(cr) { 111 | // Return early as secret has already been deleted. 112 | return secretData, nil 113 | } 114 | 115 | if key.SecretName(cr) != "" { 116 | secretName := key.SecretName(cr) 117 | secretNamespace := key.SecretNamespace(cr) 118 | 119 | secret, err := r.k8sClient.CoreV1().Secrets(secretNamespace).Get(ctx, secretName, metav1.GetOptions{}) 120 | if apierrors.IsNotFound(err) { 121 | return nil, microerror.Maskf(notFoundError, "secret %#q in namespace %#q not found", secretName, secretNamespace) 122 | } else if err != nil { 123 | return nil, microerror.Mask(err) 124 | } 125 | 126 | if len(secret.Data) != 1 { 127 | return nil, microerror.Mask(wrongTypeError) 128 | } 129 | 130 | for _, bytes := range secret.Data { 131 | err := yaml.Unmarshal(bytes, &secretData) 132 | if err != nil { 133 | return nil, microerror.Mask(err) 134 | } 135 | } 136 | } 137 | 138 | return secretData, nil 139 | } 140 | -------------------------------------------------------------------------------- /service/controller/chart/resource/status/resource_test.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | "time" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/to" 10 | "github.com/google/go-cmp/cmp" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | ) 13 | 14 | func Test_StatusResource_equals(t *testing.T) { 15 | testCases := []struct { 16 | name string 17 | statusA v1alpha1.ChartStatus 18 | statusB v1alpha1.ChartStatus 19 | equal bool 20 | }{ 21 | { 22 | name: "case 0: both equal", 23 | statusA: v1alpha1.ChartStatus{ 24 | AppVersion: "1.0.0", 25 | Reason: "", 26 | Release: v1alpha1.ChartStatusRelease{ 27 | LastDeployed: &metav1.Time{Time: time.Date(2020, 12, 1, 9, 0, 0, 0, time.UTC)}, 28 | Revision: to.IntP(1), 29 | Status: "deployed", 30 | }, 31 | Version: "2.1.0", 32 | }, 33 | statusB: v1alpha1.ChartStatus{ 34 | AppVersion: "1.0.0", 35 | Reason: "", 36 | Release: v1alpha1.ChartStatusRelease{ 37 | LastDeployed: &metav1.Time{Time: time.Date(2020, 12, 1, 9, 0, 0, 0, time.UTC)}, 38 | Revision: to.IntP(1), 39 | Status: "deployed", 40 | }, 41 | Version: "2.1.0", 42 | }, 43 | equal: true, 44 | }, 45 | { 46 | name: "case 1: both empty", 47 | statusA: v1alpha1.ChartStatus{}, 48 | statusB: v1alpha1.ChartStatus{}, 49 | equal: true, 50 | }, 51 | { 52 | name: "case 2: empty A, non-empty B", 53 | statusA: v1alpha1.ChartStatus{}, 54 | statusB: v1alpha1.ChartStatus{ 55 | AppVersion: "1.0.0", 56 | Reason: "", 57 | Release: v1alpha1.ChartStatusRelease{ 58 | Revision: to.IntP(1), 59 | Status: "deployed", 60 | }, 61 | Version: "2.1.0", 62 | }, 63 | equal: false, 64 | }, 65 | { 66 | name: "case 3: different version", 67 | statusA: v1alpha1.ChartStatus{ 68 | AppVersion: "1.0.0", 69 | Reason: "", 70 | Release: v1alpha1.ChartStatusRelease{ 71 | Revision: to.IntP(1), 72 | Status: "deployed", 73 | }, 74 | Version: "2.1.0", 75 | }, 76 | statusB: v1alpha1.ChartStatus{ 77 | AppVersion: "1.1.0", 78 | Reason: "", 79 | Release: v1alpha1.ChartStatusRelease{ 80 | Revision: to.IntP(1), 81 | Status: "deployed", 82 | }, 83 | Version: "2.2.0", 84 | }, 85 | equal: false, 86 | }, 87 | { 88 | name: "case 4: different revision", 89 | statusA: v1alpha1.ChartStatus{ 90 | AppVersion: "1.0.0", 91 | Reason: "", 92 | Release: v1alpha1.ChartStatusRelease{ 93 | Revision: to.IntP(1), 94 | Status: "deployed", 95 | }, 96 | Version: "2.1.0", 97 | }, 98 | statusB: v1alpha1.ChartStatus{ 99 | AppVersion: "1.0.0", 100 | Reason: "", 101 | Release: v1alpha1.ChartStatusRelease{ 102 | Revision: to.IntP(2), 103 | Status: "deployed", 104 | }, 105 | Version: "2.1.0", 106 | }, 107 | equal: false, 108 | }, 109 | { 110 | name: "case 5: different last deployd", 111 | statusA: v1alpha1.ChartStatus{ 112 | AppVersion: "1.0.0", 113 | Reason: "", 114 | Release: v1alpha1.ChartStatusRelease{ 115 | LastDeployed: &metav1.Time{Time: time.Date(2020, 12, 1, 9, 0, 0, 0, time.UTC)}, 116 | Revision: to.IntP(1), 117 | Status: "deployed", 118 | }, 119 | Version: "2.1.0", 120 | }, 121 | statusB: v1alpha1.ChartStatus{ 122 | AppVersion: "1.0.0", 123 | Reason: "", 124 | Release: v1alpha1.ChartStatusRelease{ 125 | LastDeployed: &metav1.Time{Time: time.Date(2020, 12, 1, 12, 0, 0, 0, time.UTC)}, 126 | Revision: to.IntP(1), 127 | Status: "deployed", 128 | }, 129 | Version: "2.1.0", 130 | }, 131 | equal: false, 132 | }, 133 | { 134 | name: "case 6: last deployed different nanos same seconds", 135 | statusA: v1alpha1.ChartStatus{ 136 | AppVersion: "1.0.0", 137 | Reason: "", 138 | Release: v1alpha1.ChartStatusRelease{ 139 | LastDeployed: &metav1.Time{Time: time.Date(2020, 12, 1, 9, 0, 30, 0, time.UTC)}, 140 | Revision: to.IntP(1), 141 | Status: "deployed", 142 | }, 143 | Version: "2.1.0", 144 | }, 145 | statusB: v1alpha1.ChartStatus{ 146 | AppVersion: "1.0.0", 147 | Reason: "", 148 | Release: v1alpha1.ChartStatusRelease{ 149 | LastDeployed: &metav1.Time{Time: time.Date(2020, 12, 1, 9, 0, 30, 30, time.UTC)}, 150 | Revision: to.IntP(1), 151 | Status: "deployed", 152 | }, 153 | Version: "2.1.0", 154 | }, 155 | equal: true, 156 | }, 157 | } 158 | 159 | for i, tc := range testCases { 160 | t.Run(strconv.Itoa(i), func(t *testing.T) { 161 | t.Log(tc.name) 162 | 163 | result := equals(tc.statusA, tc.statusB) 164 | if result != tc.equal { 165 | t.Fatalf("result == %t, want %t\n%s", result, tc.equal, cmp.Diff(tc.statusA, tc.statusB)) 166 | } 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/giantswarm/microerror" 9 | "github.com/giantswarm/microkit/command" 10 | microserver "github.com/giantswarm/microkit/server" 11 | "github.com/giantswarm/micrologger" 12 | "github.com/spf13/viper" 13 | 14 | "github.com/giantswarm/chart-operator/v4/flag" 15 | "github.com/giantswarm/chart-operator/v4/pkg/project" 16 | "github.com/giantswarm/chart-operator/v4/server" 17 | "github.com/giantswarm/chart-operator/v4/service" 18 | ) 19 | 20 | var ( 21 | f = flag.New() 22 | ) 23 | 24 | func main() { 25 | err := mainWithError() 26 | if err != nil { 27 | panic(fmt.Sprintf("%#v\n", err)) 28 | } 29 | } 30 | 31 | func mainWithError() error { 32 | var err error 33 | 34 | ctx := context.Background() 35 | 36 | // Create a new logger that is used by all packages. 37 | var newLogger micrologger.Logger 38 | { 39 | c := micrologger.Config{ 40 | IOWriter: os.Stdout, 41 | } 42 | newLogger, err = micrologger.New(c) 43 | if err != nil { 44 | return microerror.Mask(err) 45 | } 46 | } 47 | 48 | // Define server factory to create the custom server once all command line 49 | // flags are parsed and all microservice configuration is processed. 50 | newServerFactory := func(v *viper.Viper) microserver.Server { 51 | // New custom service implements the business logic. 52 | var newService *service.Service 53 | { 54 | c := service.Config{ 55 | Logger: newLogger, 56 | 57 | Flag: f, 58 | Viper: v, 59 | } 60 | newService, err = service.New(c) 61 | if err != nil { 62 | panic(fmt.Sprintf("%#v\n", microerror.Mask(err))) 63 | } 64 | 65 | go newService.Boot(ctx) 66 | } 67 | 68 | // New custom server that bundles microkit endpoints. 69 | var newServer microserver.Server 70 | { 71 | c := server.Config{ 72 | Logger: newLogger, 73 | Service: newService, 74 | 75 | Viper: v, 76 | } 77 | 78 | newServer, err = server.New(c) 79 | if err != nil { 80 | panic(fmt.Sprintf("%#v\n", microerror.Mask(err))) 81 | } 82 | } 83 | 84 | return newServer 85 | } 86 | 87 | // Create a new microkit command that manages operator daemon. 88 | var newCommand command.Command 89 | { 90 | c := command.Config{ 91 | Logger: newLogger, 92 | ServerFactory: newServerFactory, 93 | 94 | Description: project.Description(), 95 | GitCommit: project.GitSHA(), 96 | Name: project.Name(), 97 | Source: project.Source(), 98 | Version: project.Version(), 99 | } 100 | 101 | newCommand, err = command.New(c) 102 | if err != nil { 103 | return microerror.Mask(err) 104 | } 105 | } 106 | 107 | daemonCommand := newCommand.DaemonCommand().CobraCommand() 108 | 109 | daemonCommand.PersistentFlags().String(f.Service.Controller.ResyncPeriod, "5m", "Duration after which a complete sync with all known runtime objects the controller watches is performed.") 110 | daemonCommand.PersistentFlags().String(f.Service.Helm.HTTP.ClientTimeout, "5s", "HTTP timeout for pulling chart tarballs.") 111 | daemonCommand.PersistentFlags().String(f.Service.Helm.Kubernetes.WaitTimeout, "10s", "Wait timeout when calling the Kubernetes API.") 112 | daemonCommand.PersistentFlags().Int(f.Service.Helm.MaxRollback, 3, "the maximum number of rollback attempts for pending apps.") 113 | daemonCommand.PersistentFlags().StringSlice(f.Service.Helm.NamespaceWhitelist, []string{}, "Namespaces to use the privileged Helm Client for.") 114 | daemonCommand.PersistentFlags().Bool(f.Service.Helm.SplitClient, false, "Use separate Helm Client for apps outside Giantswarm-protected namespace.") 115 | daemonCommand.PersistentFlags().String(f.Service.Helm.TillerNamespace, "giantswarm", "Namespace for the Tiller pod.") 116 | daemonCommand.PersistentFlags().String(f.Service.Image.Registry, "gsoci.azurecr.io", "Container image registry.") 117 | daemonCommand.PersistentFlags().String(f.Service.Kubernetes.Address, "", "Address used to connect to Kubernetes. When empty in-cluster config is created.") 118 | daemonCommand.PersistentFlags().Bool(f.Service.Kubernetes.InCluster, false, "Whether to use the in-cluster config to authenticate with Kubernetes.") 119 | daemonCommand.PersistentFlags().String(f.Service.Kubernetes.KubeConfig, "", "KubeConfig used to connect to Kubernetes. When empty other settings are used.") 120 | daemonCommand.PersistentFlags().String(f.Service.Kubernetes.Watch.Namespace, "", "Namespace for watching for Kubernetes resources.") 121 | daemonCommand.PersistentFlags().String(f.Service.Kubernetes.TLS.CAFile, "", "Certificate authority file path to use to authenticate with Kubernetes.") 122 | daemonCommand.PersistentFlags().String(f.Service.Kubernetes.TLS.CrtFile, "", "Certificate file path to use to authenticate with Kubernetes.") 123 | daemonCommand.PersistentFlags().String(f.Service.Kubernetes.TLS.KeyFile, "", "Key file path to use to authenticate with Kubernetes.") 124 | 125 | err = newCommand.CobraCommand().Execute() 126 | if err != nil { 127 | return microerror.Mask(err) 128 | } 129 | 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /service/controller/chart/key/key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "strconv" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/k8smetadata/pkg/annotation" 10 | "github.com/giantswarm/k8smetadata/pkg/label" 11 | "github.com/giantswarm/microerror" 12 | 13 | chartmeta "github.com/giantswarm/chart-operator/v4/pkg/annotation" 14 | ) 15 | 16 | func AppName(customResource v1alpha1.Chart) string { 17 | return customResource.GetAnnotations()[annotation.AppName] 18 | } 19 | 20 | func AppNamespace(customResource v1alpha1.Chart) string { 21 | return customResource.GetAnnotations()[annotation.AppNamespace] 22 | } 23 | 24 | func ChartStatus(customResource v1alpha1.Chart) v1alpha1.ChartStatus { 25 | return customResource.Status 26 | } 27 | 28 | func SkipCRDs(customResource v1alpha1.Chart) bool { 29 | return customResource.Spec.Install.SkipCRDs 30 | } 31 | 32 | func ConfigMapName(customResource v1alpha1.Chart) string { 33 | return customResource.Spec.Config.ConfigMap.Name 34 | } 35 | 36 | func ConfigMapNamespace(customResource v1alpha1.Chart) string { 37 | return customResource.Spec.Config.ConfigMap.Namespace 38 | } 39 | 40 | func CordonReason(customResource v1alpha1.Chart) string { 41 | return customResource.GetAnnotations()[chartmeta.CordonReason] 42 | } 43 | 44 | func CordonUntil(customResource v1alpha1.Chart) string { 45 | return customResource.GetAnnotations()[chartmeta.CordonUntilDate] 46 | } 47 | 48 | func HasForceUpgradeAnnotation(customResource v1alpha1.Chart) bool { 49 | val, ok := customResource.Annotations[chartmeta.ForceHelmUpgrade] 50 | if !ok { 51 | return false 52 | } 53 | 54 | result, err := strconv.ParseBool(val) 55 | if err != nil { 56 | // If we cannot parse the boolean we return false and this is shown 57 | // in the logs. 58 | return false 59 | } 60 | 61 | return result 62 | } 63 | 64 | func InstallTimeout(customResource v1alpha1.Chart) *metav1.Duration { 65 | return customResource.Spec.Install.Timeout 66 | } 67 | 68 | func IsCordoned(customResource v1alpha1.Chart) bool { 69 | _, reasonOk := customResource.Annotations[chartmeta.CordonReason] 70 | _, untilOk := customResource.Annotations[chartmeta.CordonUntilDate] 71 | 72 | if reasonOk && untilOk { 73 | return true 74 | } else { 75 | return false 76 | } 77 | 78 | } 79 | 80 | func IsDeleted(customResource v1alpha1.Chart) bool { 81 | return customResource.GetDeletionTimestamp() != nil 82 | } 83 | 84 | func Namespace(customResource v1alpha1.Chart) string { 85 | return customResource.Spec.Namespace 86 | } 87 | 88 | func NamespaceAnnotations(customResource v1alpha1.Chart) map[string]string { 89 | return customResource.Spec.NamespaceConfig.Annotations 90 | } 91 | 92 | func NamespaceLabels(customResource v1alpha1.Chart) map[string]string { 93 | return customResource.Spec.NamespaceConfig.Labels 94 | } 95 | 96 | func ReleaseName(customResource v1alpha1.Chart) string { 97 | return customResource.Spec.Name 98 | } 99 | 100 | func RollbackTimeout(customResource v1alpha1.Chart) *metav1.Duration { 101 | return customResource.Spec.Rollback.Timeout 102 | } 103 | 104 | func SecretName(customResource v1alpha1.Chart) string { 105 | return customResource.Spec.Config.Secret.Name 106 | } 107 | 108 | func SecretNamespace(customResource v1alpha1.Chart) string { 109 | return customResource.Spec.Config.Secret.Namespace 110 | } 111 | 112 | func TarballURL(customResource v1alpha1.Chart) string { 113 | return customResource.Spec.TarballURL 114 | } 115 | 116 | // ToCustomResource converts value to v1alpha1.Chart and returns it or error 117 | // if type does not match. 118 | func ToCustomResource(v interface{}) (v1alpha1.Chart, error) { 119 | customResourcePointer, ok := v.(*v1alpha1.Chart) 120 | if !ok { 121 | return v1alpha1.Chart{}, microerror.Maskf(wrongTypeError, "expected '%T', got '%T'", &v1alpha1.Chart{}, v) 122 | } 123 | 124 | if customResourcePointer == nil { 125 | return v1alpha1.Chart{}, microerror.Maskf(emptyValueError, "empty value cannot be converted to customResource") 126 | } 127 | 128 | return *customResourcePointer, nil 129 | } 130 | 131 | func UninstallTimeout(customResource v1alpha1.Chart) *metav1.Duration { 132 | return customResource.Spec.Uninstall.Timeout 133 | } 134 | 135 | func UpgradeTimeout(customResource v1alpha1.Chart) *metav1.Duration { 136 | return customResource.Spec.Upgrade.Timeout 137 | } 138 | 139 | // ValuesMD5ChecksumAnnotation returns the annotation value to determine if the 140 | // Helm release values have changed. 141 | func ValuesMD5ChecksumAnnotation(customResource v1alpha1.Chart) string { 142 | if val, ok := customResource.Annotations[chartmeta.ValuesMD5Checksum]; ok { 143 | return val 144 | } else { 145 | return "" 146 | } 147 | } 148 | 149 | func Version(customResource v1alpha1.Chart) string { 150 | return customResource.Spec.Version 151 | } 152 | 153 | // VersionLabel returns the label value to determine if the custom resource is 154 | // supported by this version of the operatorkit resource. 155 | func VersionLabel(customResource v1alpha1.Chart) string { 156 | if val, ok := customResource.Labels[label.ChartOperatorVersion]; ok { 157 | return val 158 | } else { 159 | return "" 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /service/controller/chart/resource/releasemaxhistory/create.go: -------------------------------------------------------------------------------- 1 | package releasemaxhistory 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 11 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 12 | "github.com/giantswarm/microerror" 13 | apierrors "k8s.io/apimachinery/pkg/api/errors" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | 16 | "github.com/giantswarm/chart-operator/v4/pkg/project" 17 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/controllercontext" 18 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 19 | ) 20 | 21 | // EnsureCreated checks if the helm release has failed the max number of 22 | // attempts. If so we delete the oldest revision if it is over 1 minute old. 23 | // So we still retry the update but at a reduced rate. This is needed because 24 | // the max history setting for Helm update does not count failures. 25 | func (r *Resource) EnsureCreated(ctx context.Context, obj interface{}) error { 26 | cr, err := key.ToCustomResource(obj) 27 | if err != nil { 28 | return microerror.Mask(err) 29 | } 30 | cc, err := controllercontext.FromContext(ctx) 31 | if err != nil { 32 | return microerror.Mask(err) 33 | } 34 | 35 | r.logger.Debugf(ctx, "finding out if release %#q in namespace %#q has failed max attempts", key.ReleaseName(cr), key.Namespace(cr)) 36 | 37 | history, err := r.getReleaseHistory(ctx, cr) // key.Namespace(cr), key.ReleaseName(cr)) 38 | if err != nil { 39 | return microerror.Mask(err) 40 | } 41 | 42 | failedMaxAttempts, err := isReleaseFailedMaxAttempts(ctx, history) 43 | if err != nil { 44 | return microerror.Mask(err) 45 | } 46 | 47 | cc.Status.Release.FailedMaxAttempts = failedMaxAttempts 48 | if !failedMaxAttempts { 49 | r.logger.Debugf(ctx, "release %#q has not failed max attempts", key.ReleaseName(cr)) 50 | return nil 51 | } 52 | 53 | r.logger.Debugf(ctx, "release %#q has failed max attempts", key.ReleaseName(cr)) 54 | 55 | secretDeleted, err := r.deleteFailedRelease(ctx, key.Namespace(cr), key.ReleaseName(cr), history) 56 | if err != nil { 57 | return microerror.Mask(err) 58 | } 59 | if secretDeleted { 60 | // We deleted a failed release secret. So we can try to update the release again. 61 | cc.Status.Release.FailedMaxAttempts = false 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (r *Resource) deleteFailedRelease(ctx context.Context, namespace, releaseName string, history []helmclient.ReleaseHistory) (bool, error) { 68 | if len(history) < project.ReleaseFailedMaxAttempts { 69 | // Fall through 70 | return false, nil 71 | } 72 | rev := history[project.ReleaseFailedMaxAttempts-1] 73 | 74 | r.logger.Debugf(ctx, "deleting failed revision %d for release %#q", rev.Revision, releaseName) 75 | 76 | selectors := []string{ 77 | "owner=helm", 78 | "status=failed", 79 | fmt.Sprintf("%s=%s", "name", releaseName), 80 | fmt.Sprintf("%s=%d", "version", rev.Revision), 81 | } 82 | lo := metav1.ListOptions{ 83 | LabelSelector: strings.Join(selectors, ","), 84 | } 85 | secrets, err := r.k8sClient.CoreV1().Secrets(namespace).List(ctx, lo) 86 | if err != nil { 87 | return false, microerror.Mask(err) 88 | } 89 | if len(secrets.Items) != 1 { 90 | return false, microerror.Maskf(executionFailedError, "expected 1 release secret got %d", len(secrets.Items)) 91 | } 92 | 93 | secret := secrets.Items[0] 94 | diff := time.Since(secret.CreationTimestamp.Time) 95 | if diff.Minutes() < 1 { 96 | r.logger.Debugf(ctx, "revision %d for release %#q is < 1 minutes old", rev.Revision, releaseName) 97 | return false, nil 98 | } 99 | 100 | err = r.k8sClient.CoreV1().Secrets(secret.Namespace).Delete(ctx, secret.Name, metav1.DeleteOptions{}) 101 | if apierrors.IsNotFound(err) { 102 | r.logger.Debugf(ctx, "already deleted revision %d for release %#q", rev.Revision, releaseName) 103 | return true, nil 104 | } else if err != nil { 105 | return false, microerror.Mask(err) 106 | } 107 | 108 | r.logger.Debugf(ctx, "deleted failed revision %d for release %#q", rev.Revision, releaseName) 109 | 110 | return true, nil 111 | } 112 | 113 | func (r *Resource) getReleaseHistory(ctx context.Context, cr v1alpha1.Chart) ([]helmclient.ReleaseHistory, error) { 114 | history, err := r.helmClients.Get(ctx, cr, true).GetReleaseHistory(ctx, key.Namespace(cr), key.ReleaseName(cr)) 115 | if helmclient.IsReleaseNotFound(err) { 116 | // Fall through 117 | return nil, nil 118 | } else if err != nil { 119 | return nil, microerror.Mask(err) 120 | } 121 | 122 | // Sort history by descending revision number. 123 | sort.Slice(history, func(i, j int) bool { 124 | return history[i].Revision > history[j].Revision 125 | }) 126 | 127 | return history, nil 128 | } 129 | 130 | func isReleaseFailedMaxAttempts(ctx context.Context, history []helmclient.ReleaseHistory) (bool, error) { 131 | if len(history) < project.ReleaseFailedMaxAttempts { 132 | return false, nil 133 | } 134 | 135 | for i := 0; i < project.ReleaseFailedMaxAttempts; i++ { 136 | if history[i].Status != helmclient.StatusFailed { 137 | return false, nil 138 | } 139 | } 140 | 141 | // All failed so we exceeded the max attempts. 142 | return true, nil 143 | } 144 | -------------------------------------------------------------------------------- /service/internal/clientpair/clientpair.go: -------------------------------------------------------------------------------- 1 | package clientpair 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 8 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 9 | "github.com/giantswarm/microerror" 10 | "github.com/giantswarm/micrologger" 11 | 12 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 13 | ) 14 | 15 | const ( 16 | // privateNamespace defines GS-protected namespace the prvHelmClient 17 | // is meant for. For App CRs created outside this namespace, the 18 | // pubHelmClient should be used. 19 | privateNamespace = "giantswarm" 20 | 21 | // WC App Operators are the only cases where the elevated prvHelmClient 22 | // has to be used outside the `giantswarm` namespace. It is due to these 23 | // apps installing reources the `default:automation` does not have access to. 24 | // However, existance of the App CR for unique App Operator outside the 25 | // `giantswarm` namespace opens a door to scenarios described here: 26 | // https://github.com/giantswarm/giantswarm/issues/22100. 27 | // The appOperatorChart defines legal prefix for what elevated Helm Client 28 | // can install outside the `giantswarm` namespace. 29 | appOperatorChart = "https://giantswarm.github.io/control-plane-catalog/app-operator" 30 | // The appOperatorTestChart defines another legal prefix to use the elevated Helm client 31 | // used when we are testing changes for workload cluster app-operators that are 32 | // generally not located in the giantswarm namespace. 33 | appOperatorTestChart = "https://giantswarm.github.io/control-plane-test-catalog/app-operator" 34 | ) 35 | 36 | type ClientPairConfig struct { 37 | Logger micrologger.Logger 38 | 39 | NamespaceWhitelist []string 40 | 41 | PrvHelmClient helmclient.Interface 42 | PubHelmClient helmclient.Interface 43 | } 44 | 45 | type ClientPair struct { 46 | logger micrologger.Logger 47 | 48 | namespaceWhitelist []string 49 | 50 | prvHelmClient helmclient.Interface 51 | pubHelmClient helmclient.Interface 52 | } 53 | 54 | func NewClientPair(config ClientPairConfig) (*ClientPair, error) { 55 | if config.Logger == nil { 56 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 57 | } 58 | if config.PrvHelmClient == helmclient.Interface(nil) { 59 | return nil, microerror.Maskf(invalidConfigError, "%T.PrvHelmClient must not be empty", config) 60 | } 61 | 62 | cp := &ClientPair{ 63 | logger: config.Logger, 64 | 65 | namespaceWhitelist: config.NamespaceWhitelist, 66 | 67 | prvHelmClient: config.PrvHelmClient, 68 | pubHelmClient: config.PubHelmClient, 69 | } 70 | 71 | return cp, nil 72 | } 73 | 74 | // Get determines which client to use based on the namespace the corresponding App CR 75 | // is located in. For Workload Cluster, chart operator is permitted to operate under 76 | // cluster-wide permissions, so there is only prvHelmClient used. 77 | func (cp *ClientPair) Get(ctx context.Context, cr v1alpha1.Chart, privateClient bool) helmclient.Interface { 78 | // nil pubHelmClient means chart-operator runs in a single-client mode 79 | // under cluster admin privileges. 80 | if cp.pubHelmClient == helmclient.Interface(nil) { 81 | return cp.prvHelmClient 82 | } 83 | 84 | // for App CRs created inside the `giantswarm` namespace, use the prvHelmClient 85 | // that runs under cluster admin privileges. 86 | if key.AppNamespace(cr) == privateNamespace { 87 | cp.logger.Debugf(ctx, "selecting private Helm client for `%s` App in `%s` namespace", key.AppName(cr), key.AppNamespace(cr)) 88 | 89 | return cp.prvHelmClient 90 | } 91 | 92 | // extra check against additional whitelisted namespaces. The privateNamespace 93 | // is hardcoded because it is well-known namespace. 94 | for _, ns := range cp.namespaceWhitelist { 95 | if key.AppNamespace(cr) == ns { 96 | cp.logger.Debugf(ctx, "selecting private Helm client for `%s` App in `%s` namespace", key.AppName(cr), key.AppNamespace(cr)) 97 | 98 | return cp.prvHelmClient 99 | } 100 | } 101 | 102 | // for app operators outside the `giantswarm` namespace, use the prvHelmClient. 103 | if key.AppNamespace(cr) != privateNamespace && (strings.HasPrefix(key.TarballURL(cr), appOperatorChart) || strings.HasPrefix(key.TarballURL(cr), appOperatorTestChart)) { 104 | cp.logger.Debugf(ctx, "selecting private Helm client for `%s` App in `%s` namespace", key.AppName(cr), key.AppNamespace(cr)) 105 | 106 | return cp.prvHelmClient 107 | } 108 | 109 | // select private Helm client when requested. Use it with caution. It has been 110 | // introduce to answer permissions issue when deleting Chart CRs, 111 | // see: https://github.com/giantswarm/giantswarm/issues/25731 112 | if privateClient { 113 | cp.logger.Debugf(ctx, "selecting private Helm client for `%s` App in `%s` namespace on demand", key.AppName(cr), key.AppNamespace(cr)) 114 | 115 | return cp.prvHelmClient 116 | } 117 | 118 | // for App CRs created outside the `giantswarm` namespace, or not carrying the 119 | // annotation in question, use the pubHelmClient that runs under `automation` 120 | // Service Account privileges. 121 | cp.logger.Debugf(ctx, "selecting public Helm client for `%s` App in `%s` namespace", key.AppName(cr), key.AppNamespace(cr)) 122 | 123 | return cp.pubHelmClient 124 | } 125 | -------------------------------------------------------------------------------- /service/controller/chart/resources.go: -------------------------------------------------------------------------------- 1 | package chart 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/giantswarm/microerror" 7 | "github.com/giantswarm/micrologger" 8 | "github.com/giantswarm/operatorkit/v7/pkg/resource" 9 | "github.com/giantswarm/operatorkit/v7/pkg/resource/crud" 10 | "github.com/giantswarm/operatorkit/v7/pkg/resource/wrapper/metricsresource" 11 | "github.com/giantswarm/operatorkit/v7/pkg/resource/wrapper/retryresource" 12 | "github.com/spf13/afero" 13 | "k8s.io/client-go/kubernetes" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | 16 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/resource/namespace" 17 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/resource/release" 18 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/resource/releasemaxhistory" 19 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/resource/status" 20 | 21 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 22 | ) 23 | 24 | type chartResourcesConfig struct { 25 | // Dependencies. 26 | Fs afero.Fs 27 | CtrlClient client.Client 28 | HelmClients *clientpair.ClientPair 29 | K8sClient kubernetes.Interface 30 | Logger micrologger.Logger 31 | 32 | // Settings. 33 | HTTPClientTimeout time.Duration 34 | K8sWaitTimeout time.Duration 35 | MaxRollback int 36 | TillerNamespace string 37 | } 38 | 39 | func newChartResources(config chartResourcesConfig) ([]resource.Interface, error) { 40 | var err error 41 | 42 | // Dependencies. 43 | if config.Fs == nil { 44 | return nil, microerror.Maskf(invalidConfigError, "%T.Fs must not be empty", config) 45 | } 46 | if config.CtrlClient == nil { 47 | return nil, microerror.Maskf(invalidConfigError, "%T.CtrlClient must not be empty", config) 48 | } 49 | if config.HelmClients == nil { 50 | return nil, microerror.Maskf(invalidConfigError, "%T.HelmClients must not be empty", config) 51 | } 52 | if config.K8sClient == nil { 53 | return nil, microerror.Maskf(invalidConfigError, "%T.K8sClient must not be empty", config) 54 | } 55 | if config.Logger == nil { 56 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 57 | } 58 | 59 | if config.TillerNamespace == "" { 60 | return nil, microerror.Maskf(invalidConfigError, "%T.TillerNamespace must not be empty", config) 61 | } 62 | 63 | var namespaceResource resource.Interface 64 | { 65 | c := namespace.Config{ 66 | K8sClient: config.K8sClient, 67 | Logger: config.Logger, 68 | 69 | K8sWaitTimeout: config.K8sWaitTimeout, 70 | } 71 | 72 | namespaceResource, err = namespace.New(c) 73 | if err != nil { 74 | return nil, microerror.Mask(err) 75 | } 76 | } 77 | 78 | var releaseResource resource.Interface 79 | { 80 | c := release.Config{ 81 | // Dependencies 82 | Fs: config.Fs, 83 | CtrlClient: config.CtrlClient, 84 | HelmClients: config.HelmClients, 85 | K8sClient: config.K8sClient, 86 | Logger: config.Logger, 87 | 88 | // Settings 89 | K8sWaitTimeout: config.K8sWaitTimeout, 90 | MaxRollback: config.MaxRollback, 91 | TillerNamespace: config.TillerNamespace, 92 | } 93 | 94 | ops, err := release.New(c) 95 | if err != nil { 96 | return nil, microerror.Mask(err) 97 | } 98 | 99 | releaseResource, err = toCRUDResource(config.Logger, ops) 100 | if err != nil { 101 | return nil, microerror.Mask(err) 102 | } 103 | } 104 | 105 | var releaseMaxHistoryResource resource.Interface 106 | { 107 | c := releasemaxhistory.Config{ 108 | // Dependencies 109 | HelmClients: config.HelmClients, 110 | K8sClient: config.K8sClient, 111 | Logger: config.Logger, 112 | } 113 | 114 | releaseMaxHistoryResource, err = releasemaxhistory.New(c) 115 | if err != nil { 116 | return nil, microerror.Mask(err) 117 | } 118 | } 119 | 120 | var statusResource resource.Interface 121 | { 122 | c := status.Config{ 123 | CtrlClient: config.CtrlClient, 124 | HelmClients: config.HelmClients, 125 | K8sClient: config.K8sClient, 126 | Logger: config.Logger, 127 | 128 | HTTPClientTimeout: config.HTTPClientTimeout, 129 | } 130 | 131 | statusResource, err = status.New(c) 132 | if err != nil { 133 | return nil, microerror.Mask(err) 134 | } 135 | } 136 | 137 | resources := []resource.Interface{ 138 | // namespace creates the release namespace and allows setting metadata. 139 | namespaceResource, 140 | // release max history ensures not too many helm release secrets are created. 141 | releaseMaxHistoryResource, 142 | // release manages Helm releases and is the most important resource. 143 | releaseResource, 144 | // status resource manages the chart CR status. 145 | statusResource, 146 | } 147 | 148 | { 149 | c := retryresource.WrapConfig{ 150 | Logger: config.Logger, 151 | } 152 | 153 | resources, err = retryresource.Wrap(resources, c) 154 | if err != nil { 155 | return nil, microerror.Mask(err) 156 | } 157 | } 158 | 159 | { 160 | c := metricsresource.WrapConfig{} 161 | resources, err = metricsresource.Wrap(resources, c) 162 | if err != nil { 163 | return nil, microerror.Mask(err) 164 | } 165 | } 166 | 167 | return resources, nil 168 | } 169 | 170 | func toCRUDResource(logger micrologger.Logger, ops crud.Interface) (*crud.Resource, error) { 171 | c := crud.ResourceConfig{ 172 | Logger: logger, 173 | CRUD: ops, 174 | } 175 | 176 | r, err := crud.NewResource(c) 177 | if err != nil { 178 | return nil, microerror.Mask(err) 179 | } 180 | 181 | return r, nil 182 | } 183 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/current_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testing" 8 | 9 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 10 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 11 | "github.com/giantswarm/helmclient/v4/pkg/helmclienttest" 12 | "github.com/giantswarm/micrologger/microloggertest" 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/spf13/afero" 15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 16 | k8sfake "k8s.io/client-go/kubernetes/fake" 17 | "sigs.k8s.io/controller-runtime/pkg/client/fake" //nolint:staticcheck 18 | 19 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/controllercontext" 20 | 21 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 22 | ) 23 | 24 | func Test_CurrentState(t *testing.T) { 25 | testCases := []struct { 26 | name string 27 | obj *v1alpha1.Chart 28 | releaseContent *helmclient.ReleaseContent 29 | returnedError error 30 | expectedState ReleaseState 31 | expectedError bool 32 | }{ 33 | { 34 | name: "case 0: basic match", 35 | obj: &v1alpha1.Chart{ 36 | ObjectMeta: metav1.ObjectMeta{ 37 | Annotations: map[string]string{ 38 | "chart-operator.giantswarm.io/values-md5-checksum": "1ee001c5286ca00fdf64d9660c04bde2", 39 | }, 40 | }, 41 | Spec: v1alpha1.ChartSpec{ 42 | Name: "prometheus", 43 | }, 44 | }, 45 | releaseContent: &helmclient.ReleaseContent{ 46 | Name: "prometheus", 47 | Status: "DEPLOYED", 48 | Values: map[string]interface{}{ 49 | "key": "value", 50 | }, 51 | Version: "0.1.2", 52 | }, 53 | expectedState: ReleaseState{ 54 | Name: "prometheus", 55 | Status: "DEPLOYED", 56 | ValuesMD5Checksum: "1ee001c5286ca00fdf64d9660c04bde2", 57 | Version: "0.1.2", 58 | }, 59 | }, 60 | { 61 | name: "case 1: different values", 62 | obj: &v1alpha1.Chart{ 63 | ObjectMeta: metav1.ObjectMeta{ 64 | Annotations: map[string]string{ 65 | "chart-operator.giantswarm.io/values-md5-checksum": "5eb63bbbe01eeed093cb22bb8f5acdc3", 66 | }, 67 | }, 68 | Spec: v1alpha1.ChartSpec{ 69 | Name: "prometheus", 70 | }, 71 | }, 72 | releaseContent: &helmclient.ReleaseContent{ 73 | Name: "prometheus", 74 | Status: "FAILED", 75 | Values: map[string]interface{}{ 76 | "key": "value", 77 | "another": "value", 78 | }, 79 | Version: "1.2.3", 80 | }, 81 | expectedState: ReleaseState{ 82 | Name: "prometheus", 83 | Status: "FAILED", 84 | ValuesMD5Checksum: "5eb63bbbe01eeed093cb22bb8f5acdc3", 85 | Version: "1.2.3", 86 | }, 87 | }, 88 | { 89 | name: "case 2: empty state when error for no release present", 90 | obj: &v1alpha1.Chart{ 91 | Spec: v1alpha1.ChartSpec{ 92 | Name: "prometheus", 93 | }, 94 | }, 95 | releaseContent: &helmclient.ReleaseContent{}, 96 | returnedError: fmt.Errorf("No such release: prometheus"), 97 | expectedError: true, 98 | }, 99 | { 100 | name: "case 3: unexpected error", 101 | obj: &v1alpha1.Chart{ 102 | Spec: v1alpha1.ChartSpec{ 103 | Name: "prometheus", 104 | }, 105 | }, 106 | releaseContent: &helmclient.ReleaseContent{}, 107 | returnedError: fmt.Errorf("Unexpected error"), 108 | expectedError: true, 109 | }, 110 | { 111 | name: "case 4: chart cordoned", 112 | obj: &v1alpha1.Chart{ 113 | ObjectMeta: metav1.ObjectMeta{ 114 | Annotations: map[string]string{ 115 | "chart-operator.giantswarm.io/cordon-reason": "testing upgrade", 116 | "chart-operator.giantswarm.io/cordon-until": "2019-12-31T23:59:59Z", 117 | }, 118 | }, 119 | Spec: v1alpha1.ChartSpec{ 120 | Name: "quay.io/giantswarm/chart-operator-chart", 121 | }, 122 | }, 123 | expectedState: ReleaseState{}, 124 | }, 125 | } 126 | 127 | for i, tc := range testCases { 128 | t.Run(strconv.Itoa(i), func(t *testing.T) { 129 | var ctx context.Context 130 | { 131 | c := controllercontext.Context{} 132 | ctx = controllercontext.NewContext(context.Background(), c) 133 | } 134 | 135 | var helmClient helmclient.Interface 136 | { 137 | c := helmclienttest.Config{ 138 | DefaultReleaseContent: tc.releaseContent, 139 | DefaultError: tc.returnedError, 140 | } 141 | helmClient = helmclienttest.New(c) 142 | } 143 | 144 | helmClients, err := clientpair.NewClientPair(clientpair.ClientPairConfig{ 145 | Logger: microloggertest.New(), 146 | 147 | PrvHelmClient: helmClient, 148 | PubHelmClient: helmClient, 149 | }) 150 | if err != nil { 151 | t.Fatal("expected", nil, "got", err) 152 | } 153 | 154 | c := Config{ 155 | Fs: afero.NewMemMapFs(), 156 | CtrlClient: fake.NewFakeClient(), //nolint:staticcheck 157 | HelmClients: helmClients, 158 | K8sClient: k8sfake.NewSimpleClientset(), 159 | Logger: microloggertest.New(), 160 | 161 | TillerNamespace: "giantswarm", 162 | } 163 | 164 | r, err := New(c) 165 | if err != nil { 166 | t.Fatalf("error == %#v, want nil", err) 167 | } 168 | 169 | result, err := r.GetCurrentState(ctx, tc.obj) 170 | switch { 171 | case err != nil && !tc.expectedError: 172 | t.Fatalf("error == %#v, want nil", err) 173 | case err == nil && tc.expectedError: 174 | t.Fatalf("error == nil, want non-nil") 175 | } 176 | 177 | ReleaseState, err := toReleaseState(result) 178 | if err != nil { 179 | t.Fatalf("error == %#v, want nil", err) 180 | } 181 | 182 | if !cmp.Equal(ReleaseState, tc.expectedState) { 183 | t.Fatalf("want matching ReleaseState \n %s", cmp.Diff(ReleaseState, tc.expectedState)) 184 | } 185 | }) 186 | } 187 | 188 | } 189 | -------------------------------------------------------------------------------- /service/internal/clientpair/clientpair_test.go: -------------------------------------------------------------------------------- 1 | package clientpair 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 10 | "github.com/giantswarm/helmclient/v4/pkg/helmclienttest" 11 | "github.com/giantswarm/k8smetadata/pkg/annotation" 12 | "github.com/giantswarm/micrologger/microloggertest" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | ) 15 | 16 | func Test_NewClientPair(t *testing.T) { 17 | testCases := []struct { 18 | name string 19 | config ClientPairConfig 20 | errorMatcher func(error) bool 21 | }{ 22 | { 23 | name: "flawless, single client", 24 | config: ClientPairConfig{ 25 | Logger: microloggertest.New(), 26 | PrvHelmClient: helmclienttest.New(helmclienttest.Config{}), 27 | PubHelmClient: nil, 28 | }, 29 | }, 30 | { 31 | name: "flawless, split client", 32 | config: ClientPairConfig{ 33 | Logger: microloggertest.New(), 34 | PrvHelmClient: helmclienttest.New(helmclienttest.Config{}), 35 | PubHelmClient: helmclienttest.New(helmclienttest.Config{}), 36 | }, 37 | }, 38 | { 39 | name: "missing private client", 40 | config: ClientPairConfig{ 41 | Logger: microloggertest.New(), 42 | PrvHelmClient: nil, 43 | PubHelmClient: helmclienttest.New(helmclienttest.Config{}), 44 | }, 45 | errorMatcher: IsInvalidConfig, 46 | }, 47 | } 48 | 49 | for i, tc := range testCases { 50 | t.Run(fmt.Sprintf("case %d: %s", i, tc.name), func(t *testing.T) { 51 | _, err := NewClientPair(tc.config) 52 | 53 | switch { 54 | case err != nil && tc.errorMatcher == nil: 55 | t.Fatalf("error == %#v, want nil", err) 56 | case err == nil && tc.errorMatcher != nil: 57 | t.Fatalf("error == nil, want non-nil") 58 | case tc.errorMatcher != nil && !tc.errorMatcher(err): 59 | t.Fatalf("error == %#v, want matching", err) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func Test_Get(t *testing.T) { 66 | prvHC := helmclienttest.New(helmclienttest.Config{}) 67 | pubHC := helmclienttest.New(helmclienttest.Config{}) 68 | 69 | singleClient, err := NewClientPair(ClientPairConfig{ 70 | Logger: microloggertest.New(), 71 | PrvHelmClient: prvHC, 72 | PubHelmClient: nil, 73 | }) 74 | if err != nil { 75 | t.Fatalf("error == %#v, want nil", err) 76 | } 77 | 78 | splitClient, err := NewClientPair(ClientPairConfig{ 79 | Logger: microloggertest.New(), 80 | NamespaceWhitelist: []string{"org-giantswarm"}, 81 | PrvHelmClient: prvHC, 82 | PubHelmClient: pubHC, 83 | }) 84 | if err != nil { 85 | t.Fatalf("error == %#v, want nil", err) 86 | } 87 | 88 | testCases := []struct { 89 | name string 90 | chart v1alpha1.Chart 91 | clientPair *ClientPair 92 | expectedClient helmclient.Interface 93 | }{ 94 | // this is the mode the chart operator runs in for 95 | // Workload Clusters and Management Clusters with older 96 | // chart operator versions. 97 | { 98 | name: "single client, customer app", 99 | chart: v1alpha1.Chart{ 100 | ObjectMeta: metav1.ObjectMeta{ 101 | Annotations: map[string]string{ 102 | annotation.AppNamespace: "org-acme", 103 | annotation.AppName: "test", 104 | }, 105 | }, 106 | }, 107 | clientPair: singleClient, 108 | expectedClient: prvHC, 109 | }, 110 | { 111 | name: "single client, giantswarm app", 112 | chart: v1alpha1.Chart{ 113 | ObjectMeta: metav1.ObjectMeta{ 114 | Annotations: map[string]string{ 115 | annotation.AppNamespace: privateNamespace, 116 | annotation.AppName: "kyverno", 117 | }, 118 | }, 119 | }, 120 | clientPair: singleClient, 121 | expectedClient: prvHC, 122 | }, 123 | // this is the mode the chart operator runs in for the 124 | // Management Clusters with new chart operator version. 125 | { 126 | name: "split client, customer app", 127 | chart: v1alpha1.Chart{ 128 | ObjectMeta: metav1.ObjectMeta{ 129 | Annotations: map[string]string{ 130 | annotation.AppNamespace: "org-acme", 131 | annotation.AppName: "test", 132 | }, 133 | }, 134 | }, 135 | clientPair: splitClient, 136 | expectedClient: pubHC, 137 | }, 138 | { 139 | name: "split client, giantswarm app", 140 | chart: v1alpha1.Chart{ 141 | ObjectMeta: metav1.ObjectMeta{ 142 | Annotations: map[string]string{ 143 | annotation.AppNamespace: privateNamespace, 144 | annotation.AppName: "kyverno", 145 | }, 146 | }, 147 | }, 148 | clientPair: splitClient, 149 | expectedClient: prvHC, 150 | }, 151 | { 152 | name: "split client, org-giantswarm app", 153 | chart: v1alpha1.Chart{ 154 | ObjectMeta: metav1.ObjectMeta{ 155 | Annotations: map[string]string{ 156 | annotation.AppNamespace: "org-giantswarm", 157 | annotation.AppName: "kyverno", 158 | }, 159 | }, 160 | }, 161 | clientPair: splitClient, 162 | expectedClient: prvHC, 163 | }, 164 | { 165 | name: "split client, WC app operator (control-plane-catalog)", 166 | chart: v1alpha1.Chart{ 167 | ObjectMeta: metav1.ObjectMeta{ 168 | Annotations: map[string]string{ 169 | annotation.AppNamespace: "demo0", 170 | annotation.AppName: "app-operator-demo0", 171 | }, 172 | }, 173 | Spec: v1alpha1.ChartSpec{ 174 | TarballURL: appOperatorChart + "-5.9.0.tgz", 175 | }, 176 | }, 177 | clientPair: splitClient, 178 | expectedClient: prvHC, 179 | }, 180 | { 181 | name: "split client, WC app operator (control-plane-test-catalog)", 182 | chart: v1alpha1.Chart{ 183 | ObjectMeta: metav1.ObjectMeta{ 184 | Annotations: map[string]string{ 185 | annotation.AppNamespace: "demo0", 186 | annotation.AppName: "app-operator-demo0", 187 | }, 188 | }, 189 | Spec: v1alpha1.ChartSpec{ 190 | TarballURL: appOperatorTestChart + "-5.9.0.tgz", 191 | }, 192 | }, 193 | clientPair: splitClient, 194 | expectedClient: prvHC, 195 | }, 196 | { 197 | name: "split client, WC app operator modified", 198 | chart: v1alpha1.Chart{ 199 | ObjectMeta: metav1.ObjectMeta{ 200 | Annotations: map[string]string{ 201 | annotation.AppNamespace: "demo0", 202 | annotation.AppName: "app-operator-demo0", 203 | }, 204 | }, 205 | Spec: v1alpha1.ChartSpec{ 206 | TarballURL: "https://demo.github.io/demo-catalog/app-operator-5.9.0.tgz", 207 | }, 208 | }, 209 | clientPair: splitClient, 210 | expectedClient: pubHC, 211 | }, 212 | } 213 | 214 | for i, tc := range testCases { 215 | t.Run(fmt.Sprintf("case %d: %s", i, tc.name), func(t *testing.T) { 216 | client := tc.clientPair.Get(context.TODO(), tc.chart, false) 217 | 218 | if client != tc.expectedClient { 219 | t.Fatalf("got wrong client") 220 | } 221 | }) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /service/controller/chart/resource/release/update_test.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "testing" 7 | 8 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/helmclient/v4/pkg/helmclienttest" 10 | "github.com/giantswarm/micrologger/microloggertest" 11 | "github.com/spf13/afero" 12 | k8sfake "k8s.io/client-go/kubernetes/fake" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" //nolint:staticcheck 14 | 15 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/controllercontext" 16 | 17 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 18 | ) 19 | 20 | func Test_Resource_Release_newUpdateChange(t *testing.T) { 21 | testCases := []struct { 22 | name string 23 | obj v1alpha1.Chart 24 | currentState *ReleaseState 25 | desiredState *ReleaseState 26 | expectedUpdateState *ReleaseState 27 | }{ 28 | { 29 | name: "case 0: empty current state, empty update change", 30 | currentState: &ReleaseState{}, 31 | desiredState: &ReleaseState{ 32 | Name: "desired-release-name", 33 | }, 34 | expectedUpdateState: nil, 35 | }, 36 | { 37 | name: "case 1: nonempty current state, equal desired state, empty update change", 38 | currentState: &ReleaseState{ 39 | Name: "release-name", 40 | Status: "release-status", 41 | Version: "release-version", 42 | }, 43 | desiredState: &ReleaseState{ 44 | Name: "release-name", 45 | Status: "release-status", 46 | Version: "release-version", 47 | }, 48 | expectedUpdateState: nil, 49 | }, 50 | { 51 | name: "case 2: nonempty current state, different release version in desired state, expected desired state", 52 | currentState: &ReleaseState{ 53 | Name: "current-release-name", 54 | Version: "current-release-version", 55 | }, 56 | desiredState: &ReleaseState{ 57 | Name: "desired-release-name", 58 | Version: "desired-release-version", 59 | }, 60 | expectedUpdateState: &ReleaseState{ 61 | Name: "desired-release-name", 62 | }, 63 | }, 64 | { 65 | name: "case 3: nonempty current state, desired state has values, expected desired state", 66 | currentState: &ReleaseState{ 67 | Name: "release-name", 68 | Version: "release-version", 69 | }, 70 | desiredState: &ReleaseState{ 71 | Name: "release-name", 72 | ValuesMD5Checksum: "checksum", 73 | Version: "release-version", 74 | }, 75 | expectedUpdateState: &ReleaseState{ 76 | Name: "release-name", 77 | ValuesMD5Checksum: "checksum", 78 | Version: "release-version", 79 | }, 80 | }, 81 | { 82 | name: "case 4: nonempty current state, desired state has different values, expected desired state", 83 | currentState: &ReleaseState{ 84 | Name: "release-name", 85 | ValuesMD5Checksum: "old-checksum", 86 | Version: "release-version", 87 | }, 88 | desiredState: &ReleaseState{ 89 | Name: "release-name", 90 | ValuesMD5Checksum: "new-checksum", 91 | Version: "release-version", 92 | }, 93 | expectedUpdateState: &ReleaseState{ 94 | Name: "release-name", 95 | ValuesMD5Checksum: "new-checksum", 96 | Version: "release-version", 97 | }, 98 | }, 99 | { 100 | name: "case 5: current state has values, desired state has equal values, empty update change", 101 | currentState: &ReleaseState{ 102 | Name: "release-name", 103 | ValuesMD5Checksum: "checksum", 104 | Version: "release-version", 105 | }, 106 | desiredState: &ReleaseState{ 107 | Name: "release-name", 108 | ValuesMD5Checksum: "checksum", 109 | Version: "release-version", 110 | }, 111 | expectedUpdateState: nil, 112 | }, 113 | { 114 | name: "case 6: current state has values, desired state has new release and equal values, expected desired state", 115 | currentState: &ReleaseState{ 116 | Name: "release-name", 117 | ValuesMD5Checksum: "checksum", 118 | Version: "release-version", 119 | }, 120 | desiredState: &ReleaseState{ 121 | Name: "release-name", 122 | ValuesMD5Checksum: "checksum", 123 | Version: "new-release-version", 124 | }, 125 | expectedUpdateState: &ReleaseState{ 126 | Name: "release-name", 127 | ValuesMD5Checksum: "checksum", 128 | Version: "new-release-version", 129 | }, 130 | }, 131 | { 132 | name: "case 7: nonempty current state, desired state has different status, expected desired state", 133 | currentState: &ReleaseState{ 134 | Name: "release-name", 135 | Status: "release-status", 136 | Version: "release-version", 137 | }, 138 | desiredState: &ReleaseState{ 139 | Name: "release-name", 140 | Status: "desired-status", 141 | Version: "release-version", 142 | }, 143 | expectedUpdateState: &ReleaseState{ 144 | Name: "release-name", 145 | Status: "desired-status", 146 | Version: "release-version", 147 | }, 148 | }, 149 | } 150 | var newResource *Resource 151 | { 152 | helmClients, err := clientpair.NewClientPair(clientpair.ClientPairConfig{ 153 | Logger: microloggertest.New(), 154 | 155 | PrvHelmClient: helmclienttest.New(helmclienttest.Config{}), 156 | PubHelmClient: helmclienttest.New(helmclienttest.Config{}), 157 | }) 158 | if err != nil { 159 | t.Fatal("expected", nil, "got", err) 160 | } 161 | 162 | c := Config{ 163 | Fs: afero.NewMemMapFs(), 164 | CtrlClient: fake.NewFakeClient(), //nolint:staticcheck 165 | HelmClients: helmClients, 166 | K8sClient: k8sfake.NewSimpleClientset(), 167 | Logger: microloggertest.New(), 168 | 169 | TillerNamespace: "giantswarm", 170 | } 171 | 172 | newResource, err = New(c) 173 | if err != nil { 174 | t.Fatal("expected", nil, "got", err) 175 | } 176 | } 177 | 178 | var ctx context.Context 179 | { 180 | c := controllercontext.Context{} 181 | ctx = controllercontext.NewContext(context.Background(), c) 182 | } 183 | 184 | for i, tc := range testCases { 185 | t.Run(strconv.Itoa(i), func(t *testing.T) { 186 | result, err := newResource.newUpdateChange(ctx, &testCases[i].obj, tc.currentState, tc.desiredState) 187 | if err != nil { 188 | t.Fatal("expected", nil, "got", err) 189 | } 190 | if tc.expectedUpdateState == nil && result != nil { 191 | t.Fatal("expected", nil, "got", result) 192 | } 193 | if result != nil { 194 | updateChange, ok := result.(*ReleaseState) 195 | if !ok { 196 | t.Fatalf("expected '%T', got '%T'", updateChange, result) 197 | } 198 | if updateChange.Name != tc.expectedUpdateState.Name { 199 | t.Fatalf("expected Name %q, got %q", tc.expectedUpdateState.Name, updateChange.Name) 200 | } 201 | if updateChange.Status != tc.expectedUpdateState.Status { 202 | t.Fatalf("expected Status %q, got %q", tc.expectedUpdateState.Status, updateChange.Status) 203 | } 204 | } 205 | }) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /service/controller/chart/resource/status/create.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 12 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 13 | "github.com/giantswarm/microerror" 14 | "github.com/giantswarm/to" 15 | apierrors "k8s.io/apimachinery/pkg/api/errors" 16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 17 | "k8s.io/apimachinery/pkg/types" 18 | 19 | "github.com/giantswarm/chart-operator/v4/pkg/annotation" 20 | "github.com/giantswarm/chart-operator/v4/pkg/project" 21 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/controllercontext" 22 | "github.com/giantswarm/chart-operator/v4/service/controller/chart/key" 23 | ) 24 | 25 | func (r *Resource) EnsureCreated(ctx context.Context, obj interface{}) error { 26 | cr, err := key.ToCustomResource(obj) 27 | if err != nil { 28 | return microerror.Mask(err) 29 | } 30 | cc, err := controllercontext.FromContext(ctx) 31 | if err != nil { 32 | return microerror.Mask(err) 33 | } 34 | 35 | releaseName := key.ReleaseName(cr) 36 | r.logger.Debugf(ctx, "getting status for release %#q", releaseName) 37 | 38 | releaseContent, err := r.helmClients.Get(ctx, cr, false).GetReleaseContent(ctx, key.Namespace(cr), releaseName) 39 | if releaseContent != nil { 40 | r.logger.Debugf(ctx, "Helm release information %#q", releaseContent.Status) 41 | } 42 | if helmclient.IsReleaseNotFound(err) { 43 | r.logger.Debugf(ctx, "release %#q not found", releaseName) 44 | 45 | // If something goes wrong outside of Helm we add that to the 46 | // controller context in the release resource. So we include this 47 | // information in the CR status. 48 | if cc.Status.Reason != "" { 49 | status := v1alpha1.ChartStatus{ 50 | Reason: cc.Status.Reason, 51 | Release: v1alpha1.ChartStatusRelease{ 52 | Status: cc.Status.Release.Status, 53 | }, 54 | } 55 | 56 | err = r.setStatus(ctx, cr, status) 57 | if err != nil { 58 | return microerror.Mask(err) 59 | } 60 | 61 | return nil 62 | } 63 | 64 | // There is no Helm release and no information added to the 65 | // context by the release resource. 66 | // 67 | // Return early. We will retry on the next execution. 68 | return nil 69 | } else if err != nil { 70 | return microerror.Mask(err) 71 | } 72 | 73 | var status, reason string 74 | { 75 | if key.IsCordoned(cr) { 76 | status = releaseStatusCordoned 77 | reason = key.CordonReason(cr) 78 | } else { 79 | status = releaseContent.Status 80 | if releaseContent.Status != helmclient.StatusDeployed { 81 | if cc.Status.Release.FailedMaxAttempts { 82 | reason = fmt.Sprintf("Release has failed %d times.\nReason: %s", 83 | project.ReleaseFailedMaxAttempts, 84 | releaseContent.Description) 85 | } else { 86 | reason = releaseContent.Description 87 | } 88 | } else if cc.Status.Reason != "" { 89 | // It can be that there was previous successful deployment and that is the current status of the Helm release 90 | // but a follow-up deployment failed for another version, so we want to set a reason indicating the issue 91 | reason = cc.Status.Reason 92 | status = cc.Status.Release.Status 93 | } 94 | } 95 | } 96 | 97 | desiredStatus := v1alpha1.ChartStatus{ 98 | AppVersion: releaseContent.AppVersion, 99 | Reason: reason, 100 | Release: v1alpha1.ChartStatusRelease{ 101 | Revision: to.IntP(releaseContent.Revision), 102 | Status: status, 103 | }, 104 | Version: releaseContent.Version, 105 | } 106 | if !releaseContent.LastDeployed.IsZero() { 107 | // We convert the timestamp to the nearest second to match the value in 108 | // the chart CR status. 109 | lastDeployed := releaseContent.LastDeployed.Unix() 110 | desiredStatus.Release.LastDeployed = &metav1.Time{Time: time.Unix(lastDeployed, 0)} 111 | } 112 | 113 | if !equals(desiredStatus, key.ChartStatus(cr)) { 114 | err = r.setStatus(ctx, cr, desiredStatus) 115 | if err != nil { 116 | return microerror.Mask(err) 117 | } 118 | } else { 119 | r.logger.Debugf(ctx, "status for release %#q already set to %#q", releaseName, releaseContent.Status) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func (r *Resource) getAuthToken(ctx context.Context) (string, error) { 126 | secret, err := r.k8sClient.CoreV1().Secrets(namespace).Get(ctx, authTokenName, metav1.GetOptions{}) 127 | if apierrors.IsNotFound(err) { 128 | // There is no auth token secret. It may not have been created yet. Or the app CR is using InCluster. 129 | r.logger.Debugf(ctx, "no auth token secret found skip calling webhook") 130 | return "", nil 131 | } else if err != nil { 132 | return "", microerror.Mask(err) 133 | } 134 | 135 | return string(secret.Data[token]), nil 136 | } 137 | 138 | func (r *Resource) setStatus(ctx context.Context, cr v1alpha1.Chart, status v1alpha1.ChartStatus) error { 139 | if url, ok := cr.GetAnnotations()[annotation.Webhook]; ok { 140 | authToken, err := r.getAuthToken(ctx) 141 | if err != nil { 142 | return microerror.Mask(err) 143 | } 144 | 145 | err = updateAppStatus(url, authToken, status, r.httpClientTimeout) 146 | if err != nil { 147 | r.logger.Errorf(ctx, err, "sending webhook to %#q failed", url) 148 | } 149 | } 150 | 151 | r.logger.Debugf(ctx, "setting status for release %#q status to %#q", key.ReleaseName(cr), status.Release.Status) 152 | 153 | // Get chart CR again to ensure the resource version is correct. 154 | var currentCR v1alpha1.Chart 155 | 156 | err := r.ctrlClient.Get( 157 | ctx, 158 | types.NamespacedName{Name: cr.Name, Namespace: cr.Namespace}, 159 | ¤tCR, 160 | ) 161 | if err != nil { 162 | return microerror.Mask(err) 163 | } 164 | 165 | currentCR.Status = status 166 | 167 | err = r.ctrlClient.Status().Update(ctx, ¤tCR) 168 | if err != nil { 169 | return microerror.Mask(err) 170 | } 171 | 172 | r.logger.Debugf(ctx, "set status for release %#q", key.ReleaseName(cr)) 173 | 174 | return nil 175 | } 176 | 177 | func updateAppStatus(webhookURL, authToken string, status v1alpha1.ChartStatus, timeout time.Duration) error { 178 | request := Request{ 179 | AppVersion: status.AppVersion, 180 | Reason: status.Reason, 181 | Status: status.Release.Status, 182 | Version: status.Version, 183 | } 184 | if status.Release.LastDeployed != nil { 185 | request.LastDeployed = *status.Release.LastDeployed 186 | } 187 | 188 | payload, err := json.Marshal(request) 189 | if err != nil { 190 | return microerror.Mask(err) 191 | } 192 | 193 | client := &http.Client{Timeout: timeout} 194 | req, err := http.NewRequest(http.MethodPatch, webhookURL, bytes.NewBuffer(payload)) 195 | if err != nil { 196 | return microerror.Mask(err) 197 | } 198 | 199 | req.Header.Set("Authorization", authToken) 200 | req.Header.Set("Content-Type", "application/json") 201 | 202 | resp, err := client.Do(req) 203 | if err != nil { 204 | return microerror.Mask(err) 205 | } 206 | 207 | defer func() { _ = resp.Body.Close() }() 208 | 209 | if resp.StatusCode != http.StatusOK { 210 | return microerror.Maskf(wrongStatusError, "expected http status '%d', got '%d'", http.StatusOK, resp.StatusCode) 211 | } 212 | 213 | return nil 214 | } 215 | -------------------------------------------------------------------------------- /helm/chart-operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- $proxy := deepCopy .Values.cluster.proxy | mustMerge .Values.proxy -}} 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: {{ tpl .Values.resource.default.name . }} 6 | namespace: {{ tpl .Values.resource.default.namespace . }} 7 | labels: 8 | {{- include "chart-operator.labels" . | nindent 4 }} 9 | spec: 10 | replicas: 1 11 | revisionHistoryLimit: 3 12 | selector: 13 | matchLabels: 14 | {{- include "chart-operator.selectorLabels" . | nindent 6 }} 15 | strategy: 16 | type: Recreate 17 | template: 18 | metadata: 19 | annotations: 20 | app.giantswarm.io/config-checksum: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum | quote }} 21 | labels: 22 | {{- include "chart-operator.labels" . | nindent 8 }} 23 | spec: 24 | affinity: 25 | nodeAffinity: 26 | preferredDuringSchedulingIgnoredDuringExecution: 27 | - weight: 100 28 | preference: 29 | matchExpressions: 30 | - key: role 31 | operator: In 32 | values: 33 | {{- if ge (int .Capabilities.KubeVersion.Minor) 24 }} 34 | - control-plane 35 | {{- else }} 36 | - master 37 | {{- end }} 38 | volumes: 39 | {{- if .Values.volumes.tmp.enabled }} 40 | - name: tmp 41 | emptyDir: 42 | sizeLimit: "{{ .Values.volumes.tmp.sizeLimit }}" 43 | {{- end }} 44 | - name: {{ tpl .Values.resource.default.name . }}-configmap 45 | configMap: 46 | name: {{ tpl .Values.resource.default.name . }} 47 | items: 48 | - key: config.yaml 49 | path: config.yaml 50 | priorityClassName: giantswarm-critical 51 | serviceAccountName: {{ tpl .Values.resource.default.name . }} 52 | securityContext: 53 | runAsUser: {{ .Values.pod.user.id }} 54 | runAsGroup: {{ .Values.pod.group.id }} 55 | {{- with .Values.podSecurityContext }} 56 | {{- . | toYaml | nindent 8 }} 57 | {{- end }} 58 | {{- if .Values.bootstrapMode.enabled }} 59 | {{- if ge (int .Capabilities.KubeVersion.Minor) 24 }} 60 | nodeSelector: 61 | node-role.kubernetes.io/control-plane: "" 62 | {{- else }} 63 | nodeSelector: 64 | node-role.kubernetes.io/master: "" 65 | {{- end }} 66 | {{- end }} 67 | {{- if or .Values.chartOperator.cni.install .Values.bootstrapMode.enabled }} 68 | hostNetwork: true 69 | tolerations: 70 | - key: node.kubernetes.io/not-ready 71 | effect: NoSchedule 72 | operator: Exists 73 | - key: node-role.kubernetes.io/control-plane 74 | effect: NoSchedule 75 | - key: node-role.kubernetes.io/master 76 | effect: NoSchedule 77 | - key: node.cloudprovider.kubernetes.io/uninitialized 78 | effect: NoSchedule 79 | - key: node.cluster.x-k8s.io/uninitialized 80 | effect: NoSchedule 81 | {{- else }} 82 | tolerations: 83 | - key: node-role.kubernetes.io/control-plane 84 | effect: NoSchedule 85 | - key: node-role.kubernetes.io/master 86 | effect: NoSchedule 87 | {{- end }} 88 | {{- if eq .Values.externalDNSIP "" }} 89 | dnsPolicy: Default 90 | {{- else if .Values.isManagementCluster }} 91 | dnsPolicy: ClusterFirst 92 | {{- else }} 93 | dnsPolicy: None 94 | dnsConfig: 95 | nameservers: 96 | - {{ .Values.clusterDNSIP }} 97 | {{- range (splitList "," .Values.externalDNSIP) }} 98 | - {{ . }} 99 | {{- end }} 100 | searches: 101 | - giantswarm.svc.{{ .Values.cluster.kubernetes.domain }} 102 | - svc.{{ .Values.cluster.kubernetes.domain }} 103 | - {{ .Values.cluster.kubernetes.domain }} 104 | options: 105 | - name: ndots 106 | value: "5" 107 | initContainers: 108 | - name: wait-for-quay 109 | image: "{{ .Values.image.registry }}/{{ .Values.image.name }}:{{ include "image.tag" . }}" 110 | command: 111 | - sh 112 | - -c 113 | - until dig {{ .Values.image.registry }}. {{ range (splitList "," .Values.externalDNSIP) }}@{{ . }} {{ end }}; do echo waiting for {{ .Values.image.registry }}; sleep 2; done; 114 | resources: 115 | requests: 116 | cpu: 100m 117 | memory: 128Mi 118 | limits: 119 | cpu: 200m 120 | memory: 256Mi 121 | securityContext: 122 | runAsUser: {{ .Values.pod.user.id }} 123 | runAsGroup: {{ .Values.pod.group.id }} 124 | {{- with .Values.securityContext }} 125 | {{- . | toYaml | nindent 10 }} 126 | {{- end }} 127 | {{- end }} 128 | containers: 129 | - name: {{ .Chart.Name }} 130 | {{- if or (and $proxy.http $proxy.https $proxy.noProxy ) .Values.bootstrapMode.enabled }} 131 | env: 132 | {{- if .Values.bootstrapMode.enabled }} 133 | - name: KUBERNETES_SERVICE_HOST 134 | value: 127.0.0.1 135 | - name: KUBERNETES_SERVICE_PORT 136 | value: {{ .Values.bootstrapMode.apiServerPodPort | quote }} 137 | {{- end }} 138 | {{- if and $proxy.noProxy $proxy.http $proxy.https }} 139 | - name: NO_PROXY 140 | value: {{ $proxy.noProxy }} 141 | - name: no_proxy 142 | value: {{ $proxy.noProxy }} 143 | - name: HTTP_PROXY 144 | value: {{ $proxy.http }} 145 | - name: http_proxy 146 | value: {{ $proxy.http }} 147 | - name: HTTPS_PROXY 148 | value: {{ $proxy.https }} 149 | - name: https_proxy 150 | value: {{ $proxy.https }} 151 | {{- end }} 152 | {{- end }} 153 | {{- if .Values.isManagementCluster }} 154 | image: "{{ .Values.registry.domain }}/{{ .Values.image.name }}:{{ include "image.tag" . }}" 155 | {{- else }} 156 | image: "{{ .Values.image.registry }}/{{ .Values.image.name }}:{{ include "image.tag" . }}" 157 | {{- end }} 158 | volumeMounts: 159 | {{- if .Values.volumes.tmp.enabled }} 160 | - name: tmp 161 | mountPath: "/tmp" 162 | {{- end }} 163 | - name: {{ tpl .Values.resource.default.name . }}-configmap 164 | mountPath: /var/run/{{ .Chart.Name }}/configmap/ 165 | ports: 166 | - name: http 167 | containerPort: {{ .Values.pod.port }} 168 | args: 169 | - daemon 170 | - --config.dirs=/var/run/{{ .Chart.Name }}/configmap/ 171 | - --config.files=config 172 | securityContext: 173 | runAsUser: {{ .Values.pod.user.id }} 174 | runAsGroup: {{ .Values.pod.group.id }} 175 | {{- with .Values.securityContext }} 176 | {{- . | toYaml | nindent 10 }} 177 | {{- end }} 178 | livenessProbe: 179 | httpGet: 180 | path: /healthz 181 | port: {{ .Values.pod.port }} 182 | initialDelaySeconds: 15 183 | timeoutSeconds: 1 184 | readinessProbe: 185 | httpGet: 186 | path: /healthz 187 | port: {{ .Values.pod.port }} 188 | initialDelaySeconds: 15 189 | timeoutSeconds: 1 190 | resources: 191 | requests: 192 | {{- .Values.deployment.requests | toYaml | nindent 12 }} 193 | limits: 194 | {{- .Values.deployment.limits | toYaml | nindent 12 }} 195 | -------------------------------------------------------------------------------- /integration/test/chart/basic/basic_test.go: -------------------------------------------------------------------------------- 1 | //go:build k8srequired 2 | // +build k8srequired 3 | 4 | package basic 5 | 6 | import ( 7 | "context" 8 | "reflect" 9 | "testing" 10 | "time" 11 | 12 | "github.com/giantswarm/apiextensions-application/api/v1alpha1" 13 | "github.com/giantswarm/backoff" 14 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 15 | "github.com/giantswarm/k8smetadata/pkg/label" 16 | "github.com/giantswarm/microerror" 17 | "github.com/google/go-cmp/cmp" 18 | corev1 "k8s.io/api/core/v1" 19 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 | "k8s.io/apimachinery/pkg/types" 21 | "sigs.k8s.io/yaml" 22 | 23 | "github.com/giantswarm/chart-operator/v4/integration/key" 24 | ) 25 | 26 | const ( 27 | configMapName = "test-app-configmap" 28 | secretName = "test-app-secret" 29 | 30 | configmapValue = ` 31 | v1: 32 | activateAdmission: false 33 | username: admin 34 | host: 35 | url: "quay.io" 36 | memory_in_gb: 0 37 | threshold: 2.17 38 | replicas: 3 39 | ` 40 | secretValue = ` 41 | v1: 42 | activateAdmission: true 43 | username: admin 44 | host: 45 | secret: 46 | authToken: xer32wnq 47 | githubToken: nnbhwk1dk 48 | memory_in_gb: 3.14 49 | ` 50 | 51 | mergedValue = ` 52 | v1: 53 | activateAdmission: true 54 | username: admin 55 | host: 56 | url: quay.io 57 | secret: 58 | authToken: xer32wnq 59 | githubToken: nnbhwk1dk 60 | memory_in_gb: 3.14 61 | threshold: 2.17 62 | replicas: 3 63 | ` 64 | ) 65 | 66 | // TestChartLifecycle tests a Helm release can be created, updated and deleted 67 | // uaing a chart CR processed by chart-operator. 68 | // 69 | // - Create chart CR. 70 | // - Ensure test app specified in the chart CR is deployed. 71 | // 72 | // - Update chart CR. 73 | // - Ensure test app is redeployed using updated chart tarball. 74 | // 75 | // - Delete chart CR. 76 | // - Ensure test app is deleted. 77 | func TestChartLifecycle(t *testing.T) { 78 | ctx := context.Background() 79 | 80 | var cr v1alpha1.Chart 81 | var err error 82 | 83 | // Create dependant configmap & secret. 84 | { 85 | config.Logger.Debugf(ctx, "creating configmap %#q", configMapName) 86 | 87 | cr := &corev1.ConfigMap{ 88 | ObjectMeta: metav1.ObjectMeta{ 89 | Name: configMapName, 90 | Namespace: key.Namespace(), 91 | }, 92 | Data: map[string]string{ 93 | "values": configmapValue, 94 | }, 95 | } 96 | 97 | _, err := config.K8sClients.K8sClient().CoreV1().ConfigMaps(key.Namespace()).Create(ctx, cr, metav1.CreateOptions{}) 98 | if err != nil { 99 | t.Fatalf("expected %#v got %#v", nil, err) 100 | } 101 | 102 | config.Logger.Debugf(ctx, "created configmap %#q", configMapName) 103 | 104 | config.Logger.Debugf(ctx, "creating secret %#q", secretName) 105 | 106 | secret := &corev1.Secret{ 107 | ObjectMeta: metav1.ObjectMeta{ 108 | Name: secretName, 109 | Namespace: key.Namespace(), 110 | }, 111 | StringData: map[string]string{ 112 | "values": secretValue, 113 | }, 114 | } 115 | 116 | _, err = config.K8sClients.K8sClient().CoreV1().Secrets(key.Namespace()).Create(ctx, secret, metav1.CreateOptions{}) 117 | if err != nil { 118 | t.Fatalf("expected %#v got %#v", nil, err) 119 | } 120 | 121 | config.Logger.Debugf(ctx, "created secret %#q", secretName) 122 | } 123 | 124 | // Test creation. 125 | { 126 | config.Logger.Debugf(ctx, "creating chart %#q", key.TestAppReleaseName()) 127 | 128 | cr := &v1alpha1.Chart{ 129 | ObjectMeta: metav1.ObjectMeta{ 130 | Name: key.TestAppReleaseName(), 131 | Namespace: key.Namespace(), 132 | Labels: map[string]string{ 133 | label.ChartOperatorVersion: "1.0.0", 134 | }, 135 | }, 136 | Spec: v1alpha1.ChartSpec{ 137 | Name: key.TestAppReleaseName(), 138 | Namespace: key.Namespace(), 139 | Config: v1alpha1.ChartSpecConfig{ 140 | ConfigMap: v1alpha1.ChartSpecConfigConfigMap{ 141 | Name: configMapName, 142 | Namespace: key.Namespace(), 143 | }, 144 | Secret: v1alpha1.ChartSpecConfigSecret{ 145 | Name: secretName, 146 | Namespace: key.Namespace(), 147 | }, 148 | }, 149 | TarballURL: "https://giantswarm.github.io/default-catalog/test-app-1.0.0.tgz", 150 | Version: "1.0.0", 151 | }, 152 | } 153 | err = config.K8sClients.CtrlClient().Create(ctx, cr) 154 | if err != nil { 155 | t.Fatalf("expected %#v got %#v", nil, err) 156 | } 157 | 158 | config.Logger.Debugf(ctx, "created chart %#q", key.TestAppReleaseName()) 159 | 160 | config.Logger.Debugf(ctx, "checking release %#q is deployed", key.TestAppReleaseName()) 161 | 162 | err = config.Release.WaitForStatus(ctx, key.Namespace(), key.TestAppReleaseName(), helmclient.StatusDeployed) 163 | if err != nil { 164 | t.Fatalf("expected %#v got %#v", nil, err) 165 | } 166 | 167 | config.Logger.Debugf(ctx, "release %#q is deployed", key.TestAppReleaseName()) 168 | } 169 | 170 | // Check chart CR status. 171 | { 172 | config.Logger.Debugf(ctx, "checking status for chart CR %#q", key.TestAppReleaseName()) 173 | 174 | operation := func() error { 175 | err = config.K8sClients.CtrlClient().Get( 176 | ctx, 177 | types.NamespacedName{Name: key.TestAppReleaseName(), Namespace: key.Namespace()}, 178 | &cr, 179 | ) 180 | if err != nil { 181 | return microerror.Mask(err) 182 | } 183 | if cr.Status.Release.Status != helmclient.StatusDeployed { 184 | return microerror.Mask(notDeployedError) 185 | } 186 | return nil 187 | } 188 | 189 | b := backoff.NewMaxRetries(10, 3*time.Second) 190 | err := backoff.Retry(operation, b) 191 | if err != nil { 192 | t.Fatalf("expected %#v got %#v", nil, err) 193 | } 194 | 195 | config.Logger.Debugf(ctx, "checked status for chart CR %#q", key.TestAppReleaseName()) 196 | } 197 | 198 | // Test update. 199 | { 200 | config.Logger.Debugf(ctx, "updating chart %#q", key.TestAppReleaseName()) 201 | 202 | err = config.K8sClients.CtrlClient().Get( 203 | ctx, 204 | types.NamespacedName{Name: key.TestAppReleaseName(), Namespace: key.Namespace()}, 205 | &cr, 206 | ) 207 | if err != nil { 208 | t.Fatalf("expected %#v got %#v", nil, err) 209 | } 210 | 211 | cr.Spec.TarballURL = "https://giantswarm.github.io/default-catalog/test-app-1.0.0.tgz" 212 | cr.Spec.Version = "1.0.0" 213 | 214 | err = config.K8sClients.CtrlClient().Update(ctx, &cr) 215 | if err != nil { 216 | t.Fatalf("expected %#v got %#v", nil, err) 217 | } 218 | 219 | config.Logger.Debugf(ctx, "updated chart %#q", key.TestAppReleaseName()) 220 | 221 | config.Logger.Debugf(ctx, "checking release %#q is updated", key.TestAppReleaseName()) 222 | 223 | err = config.Release.WaitForChartVersion(ctx, key.Namespace(), key.TestAppReleaseName(), "1.0.0") 224 | if err != nil { 225 | t.Fatalf("expected %#v got %#v", nil, err) 226 | } 227 | 228 | config.Logger.Debugf(ctx, "release %#q is updated", key.TestAppReleaseName()) 229 | } 230 | 231 | // Check all values are merged correctly 232 | { 233 | config.Logger.Debugf(ctx, "comparing helm values %#q", key.TestAppReleaseName()) 234 | 235 | content, err := config.HelmClient.GetReleaseContent(ctx, key.Namespace(), key.TestAppReleaseName()) 236 | if err != nil { 237 | t.Fatalf("expected %#v got %#v", nil, err) 238 | } 239 | 240 | var mergedMap map[string]interface{} 241 | { 242 | err = yaml.Unmarshal([]byte(mergedValue), &mergedMap) 243 | if err != nil { 244 | t.Fatalf("expected %#v got %#v", nil, err) 245 | } 246 | } 247 | 248 | if !reflect.DeepEqual(content.Values, mergedMap) { 249 | t.Fatalf("expected same got %s", cmp.Diff(content.Values, mergedMap)) 250 | } 251 | 252 | config.Logger.Debugf(ctx, "compared helm values %#q", key.TestAppReleaseName()) 253 | } 254 | 255 | // Test deletion. 256 | { 257 | config.Logger.Debugf(ctx, "deleting chart %#q", key.TestAppReleaseName()) 258 | 259 | err := config.K8sClients.CtrlClient().Delete(ctx, &cr) 260 | if err != nil { 261 | t.Fatalf("expected %#v got %#v", nil, err) 262 | } 263 | 264 | config.Logger.Debugf(ctx, "deleted chart %#q", key.TestAppReleaseName()) 265 | 266 | config.Logger.Debugf(ctx, "checking release %#q is deleted", key.TestAppReleaseName()) 267 | 268 | err = config.Release.WaitForStatus(ctx, key.Namespace(), key.TestAppReleaseName(), helmclient.StatusUninstalled) 269 | if err != nil { 270 | t.Fatalf("expected %#v got %#v", nil, err) 271 | } 272 | 273 | config.Logger.Debugf(ctx, "release %#q is deleted", key.TestAppReleaseName()) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | applicationv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" 9 | "github.com/giantswarm/helmclient/v4/pkg/helmclient" 10 | "github.com/giantswarm/k8sclient/v7/pkg/k8sclient" 11 | "github.com/giantswarm/k8sclient/v7/pkg/k8srestconfig" 12 | "github.com/giantswarm/microendpoint/service/version" 13 | "github.com/giantswarm/microerror" 14 | "github.com/giantswarm/micrologger" 15 | "github.com/spf13/afero" 16 | "github.com/spf13/viper" 17 | "k8s.io/client-go/rest" 18 | cr "sigs.k8s.io/controller-runtime" 19 | "sigs.k8s.io/controller-runtime/pkg/client/apiutil" 20 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 21 | 22 | "github.com/giantswarm/chart-operator/v4/flag" 23 | "github.com/giantswarm/chart-operator/v4/pkg/project" 24 | "github.com/giantswarm/chart-operator/v4/service/collector" 25 | "github.com/giantswarm/chart-operator/v4/service/controller/chart" 26 | 27 | "github.com/giantswarm/chart-operator/v4/service/internal/clientpair" 28 | ) 29 | 30 | const ( 31 | publicClientSAName = "automation" 32 | publicClientSANamespace = "default" 33 | ) 34 | 35 | // Config represents the configuration used to create a new service. 36 | type Config struct { 37 | // Dependencies. 38 | Logger micrologger.Logger 39 | 40 | // Settings. 41 | Flag *flag.Flag 42 | Viper *viper.Viper 43 | } 44 | 45 | // Service is a type providing implementation of microkit service interface. 46 | type Service struct { 47 | Version *version.Service 48 | 49 | // Internals 50 | bootOnce sync.Once 51 | chartController *chart.Chart 52 | operatorCollector *collector.Set 53 | } 54 | 55 | // New creates a new service with given configuration. 56 | func New(config Config) (*Service, error) { 57 | // Dependencies. 58 | if config.Logger == nil { 59 | return nil, microerror.Maskf(invalidConfigError, "%T.Logger must not be empty", config) 60 | } 61 | 62 | // Settings. 63 | if config.Flag == nil { 64 | return nil, microerror.Maskf(invalidConfigError, "%T.Flag must not be empty", config) 65 | } 66 | if config.Viper == nil { 67 | return nil, microerror.Maskf(invalidConfigError, "%T.Viper must not be empty", config) 68 | } 69 | 70 | // Configure controller-runtime logger 71 | opts := zap.Options{ 72 | Development: true, 73 | } 74 | cr.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 75 | var err error 76 | var restConfigPrv *rest.Config 77 | { 78 | c := k8srestconfig.Config{ 79 | Logger: config.Logger, 80 | 81 | Address: config.Viper.GetString(config.Flag.Service.Kubernetes.Address), 82 | InCluster: config.Viper.GetBool(config.Flag.Service.Kubernetes.InCluster), 83 | KubeConfig: config.Viper.GetString(config.Flag.Service.Kubernetes.KubeConfig), 84 | TLS: k8srestconfig.ConfigTLS{ 85 | CAFile: config.Viper.GetString(config.Flag.Service.Kubernetes.TLS.CAFile), 86 | CrtFile: config.Viper.GetString(config.Flag.Service.Kubernetes.TLS.CrtFile), 87 | KeyFile: config.Viper.GetString(config.Flag.Service.Kubernetes.TLS.KeyFile), 88 | }, 89 | } 90 | 91 | restConfigPrv, err = k8srestconfig.New(c) 92 | if err != nil { 93 | return nil, microerror.Mask(err) 94 | } 95 | } 96 | 97 | restConfigPub := rest.CopyConfig(restConfigPrv) 98 | 99 | fs := afero.NewOsFs() 100 | 101 | // k8sPrvClient runs under the chart-operator default permissions and hence 102 | // has elevated privileges in the cluster. It is meant to be used for 103 | // reconciling giantswarm-protected namespaces. 104 | var k8sPrvClient k8sclient.Interface 105 | var prvHelmClient helmclient.Interface 106 | { 107 | k8sPrvClient, err = newK8sClient(config, restConfigPrv) 108 | if err != nil { 109 | return nil, microerror.Mask(err) 110 | } 111 | prvHelmClient, err = newHelmClient(config, k8sPrvClient, fs) 112 | if err != nil { 113 | return nil, microerror.Mask(err) 114 | } 115 | } 116 | 117 | // k8sPubClient runs under `default:automation` Service Account when using 118 | // split client configuration. This client is meant to be used for reconciling 119 | // customer namespaces. For Workload Clusters it is `nil` and only prvHelmClient 120 | // use used. 121 | var k8sPubClient k8sclient.Interface 122 | var pubHelmClient helmclient.Interface 123 | if config.Viper.GetBool(config.Flag.Service.Helm.SplitClient) { 124 | 125 | // Using public client should result in permissions error, like for example 126 | // Upgrade "hello-world" failed: failed to create resource: 127 | // rolebindings.rbac.authorization.k8s.io is forbidden: 128 | // User "system:serviceaccount:default:automation" cannot create resource 129 | // "rolebindings" in API group "rbac.authorization.k8s.io" in the namespace 130 | // "giantswarm". 131 | restConfigPub.Impersonate = rest.ImpersonationConfig{ 132 | UserName: fmt.Sprintf( 133 | "system:serviceaccount:%s:%s", 134 | publicClientSANamespace, 135 | publicClientSAName, 136 | ), 137 | } 138 | 139 | k8sPubClient, err = newK8sClient(config, restConfigPub) 140 | if err != nil { 141 | return nil, microerror.Mask(err) 142 | } 143 | 144 | pubHelmClient, err = newHelmClient(config, k8sPubClient, fs) 145 | if err != nil { 146 | return nil, microerror.Mask(err) 147 | } 148 | } 149 | 150 | cpConfig := clientpair.ClientPairConfig{ 151 | Logger: config.Logger, 152 | 153 | NamespaceWhitelist: config.Viper.GetStringSlice(config.Flag.Service.Helm.NamespaceWhitelist), 154 | 155 | PrvHelmClient: prvHelmClient, 156 | PubHelmClient: pubHelmClient, 157 | } 158 | 159 | helmClients, err := clientpair.NewClientPair(cpConfig) 160 | if err != nil { 161 | return nil, microerror.Mask(err) 162 | } 163 | 164 | var chartController *chart.Chart 165 | { 166 | c := chart.Config{ 167 | Fs: fs, 168 | HelmClients: helmClients, 169 | Logger: config.Logger, 170 | K8sClient: k8sPrvClient, 171 | 172 | ResyncPeriod: config.Viper.GetDuration(config.Flag.Service.Controller.ResyncPeriod), 173 | 174 | HTTPClientTimeout: config.Viper.GetDuration(config.Flag.Service.Helm.HTTP.ClientTimeout), 175 | K8sWaitTimeout: config.Viper.GetDuration(config.Flag.Service.Helm.Kubernetes.WaitTimeout), 176 | K8sWatchNamespace: config.Viper.GetString(config.Flag.Service.Kubernetes.Watch.Namespace), 177 | MaxRollback: config.Viper.GetInt(config.Flag.Service.Helm.MaxRollback), 178 | TillerNamespace: config.Viper.GetString(config.Flag.Service.Helm.TillerNamespace), 179 | } 180 | 181 | chartController, err = chart.NewChart(c) 182 | if err != nil { 183 | return nil, microerror.Mask(err) 184 | } 185 | } 186 | 187 | var operatorCollector *collector.Set 188 | { 189 | c := collector.SetConfig{ 190 | // Collector must use client with elevated privileges in order to 191 | // look for orphaned ConfigMap and Secrets 192 | K8sClient: k8sPrvClient, 193 | Logger: config.Logger, 194 | 195 | TillerNamespace: config.Viper.GetString(config.Flag.Service.Helm.TillerNamespace), 196 | } 197 | 198 | operatorCollector, err = collector.NewSet(c) 199 | if err != nil { 200 | return nil, microerror.Mask(err) 201 | } 202 | } 203 | 204 | var versionService *version.Service 205 | { 206 | versionConfig := version.Config{ 207 | Description: project.Description(), 208 | GitCommit: project.GitSHA(), 209 | Name: project.Name(), 210 | Source: project.Source(), 211 | Version: project.Version(), 212 | } 213 | 214 | versionService, err = version.New(versionConfig) 215 | if err != nil { 216 | return nil, microerror.Mask(err) 217 | } 218 | } 219 | 220 | s := &Service{ 221 | Version: versionService, 222 | 223 | bootOnce: sync.Once{}, 224 | chartController: chartController, 225 | operatorCollector: operatorCollector, 226 | } 227 | 228 | return s, nil 229 | } 230 | 231 | // Boot starts top level service implementation. 232 | func (s *Service) Boot(ctx context.Context) { 233 | s.bootOnce.Do(func() { 234 | go func() { 235 | err := s.operatorCollector.Boot(ctx) 236 | if err != nil { 237 | panic(microerror.JSON(err)) 238 | } 239 | }() 240 | 241 | go s.chartController.Boot(ctx) 242 | }) 243 | } 244 | 245 | func newHelmClient(config Config, k8sClient k8sclient.Interface, fs afero.Fs) (*helmclient.Client, error) { 246 | httpClient, err := rest.HTTPClientFor(rest.CopyConfig(k8sClient.RESTConfig())) 247 | if err != nil { 248 | return nil, microerror.Mask(err) 249 | } 250 | 251 | restMapper, err := apiutil.NewDynamicRESTMapper(rest.CopyConfig(k8sClient.RESTConfig()), httpClient) 252 | if err != nil { 253 | return nil, microerror.Mask(err) 254 | } 255 | 256 | c := helmclient.Config{ 257 | Fs: fs, 258 | K8sClient: k8sClient.K8sClient(), 259 | Logger: config.Logger, 260 | RestClient: k8sClient.RESTClient(), 261 | RestConfig: k8sClient.RESTConfig(), 262 | RestMapper: restMapper, 263 | 264 | HTTPClientTimeout: config.Viper.GetDuration(config.Flag.Service.Helm.HTTP.ClientTimeout), 265 | } 266 | 267 | helmClient, err := helmclient.New(c) 268 | if err != nil { 269 | return nil, microerror.Mask(err) 270 | } 271 | 272 | return helmClient, err 273 | } 274 | 275 | func newK8sClient(config Config, restConfig *rest.Config) (k8sclient.Interface, error) { 276 | c := k8sclient.ClientsConfig{ 277 | Logger: config.Logger, 278 | SchemeBuilder: k8sclient.SchemeBuilder{ 279 | applicationv1alpha1.AddToScheme, 280 | }, 281 | 282 | RestConfig: restConfig, 283 | } 284 | 285 | k8sClient, err := k8sclient.NewClients(c) 286 | if err != nil { 287 | return nil, microerror.Mask(err) 288 | } 289 | 290 | return k8sClient, nil 291 | } 292 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/giantswarm/chart-operator/v4 2 | 3 | go 1.25.0 4 | 5 | toolchain go1.25.5 6 | 7 | require ( 8 | github.com/giantswarm/apiextensions-application v0.6.2 9 | github.com/giantswarm/appcatalog v1.0.1 10 | github.com/giantswarm/backoff v1.0.1 11 | github.com/giantswarm/exporterkit v1.3.0 12 | github.com/giantswarm/helmclient/v4 v4.12.7 13 | github.com/giantswarm/k8sclient/v7 v7.2.0 14 | github.com/giantswarm/k8smetadata v0.25.0 15 | github.com/giantswarm/microendpoint v1.1.2 16 | github.com/giantswarm/microerror v0.4.1 17 | github.com/giantswarm/microkit v1.0.4 18 | github.com/giantswarm/micrologger v1.1.2 19 | github.com/giantswarm/operatorkit/v7 v7.3.0 20 | github.com/giantswarm/to v0.4.2 21 | github.com/google/go-cmp v0.7.0 22 | github.com/imdario/mergo v0.3.16 23 | github.com/prometheus/client_golang v1.23.2 24 | github.com/spf13/afero v1.15.0 25 | github.com/spf13/viper v1.21.0 26 | helm.sh/helm/v3 v3.19.4 27 | k8s.io/api v0.35.0 28 | k8s.io/apimachinery v0.35.0 29 | k8s.io/client-go v0.35.0 30 | sigs.k8s.io/controller-runtime v0.22.4 31 | sigs.k8s.io/yaml v1.6.0 32 | ) 33 | 34 | require ( 35 | dario.cat/mergo v1.0.1 // indirect 36 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 37 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 38 | github.com/BurntSushi/toml v1.5.0 // indirect 39 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 40 | github.com/Masterminds/goutils v1.1.1 // indirect 41 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 42 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 43 | github.com/Masterminds/squirrel v1.5.4 // indirect 44 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 45 | github.com/beorn7/perks v1.0.1 // indirect 46 | github.com/blang/semver/v4 v4.0.0 // indirect 47 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 48 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 49 | github.com/chai2010/gettext-go v1.0.2 // indirect 50 | github.com/containerd/containerd v1.7.29 // indirect 51 | github.com/containerd/errdefs v0.3.0 // indirect 52 | github.com/containerd/log v0.1.0 // indirect 53 | github.com/containerd/platforms v0.2.1 // indirect 54 | github.com/coreos/go-semver v0.3.1 // indirect 55 | github.com/cyphar/filepath-securejoin v0.6.1 // indirect 56 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 57 | github.com/distribution/reference v0.6.0 // indirect 58 | github.com/docker/cli v28.5.1+incompatible // indirect 59 | github.com/docker/distribution v2.8.3+incompatible // indirect 60 | github.com/docker/docker v28.5.1+incompatible // indirect 61 | github.com/docker/docker-credential-helpers v0.8.2 // indirect 62 | github.com/docker/go-connections v0.6.0 // indirect 63 | github.com/docker/go-metrics v0.0.1 // indirect 64 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 65 | github.com/evanphx/json-patch v5.9.11+incompatible // indirect 66 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 67 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 68 | github.com/fatih/color v1.15.0 // indirect 69 | github.com/felixge/httpsnoop v1.0.4 // indirect 70 | github.com/fsnotify/fsnotify v1.9.0 // indirect 71 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 72 | github.com/getsentry/sentry-go v0.31.1 // indirect 73 | github.com/giantswarm/kubeconfig/v4 v4.1.3 // indirect 74 | github.com/giantswarm/versionbundle v1.1.0 // indirect 75 | github.com/go-errors/errors v1.4.2 // indirect 76 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 77 | github.com/go-kit/kit v0.13.0 // indirect 78 | github.com/go-kit/log v0.2.1 // indirect 79 | github.com/go-logfmt/logfmt v0.6.0 // indirect 80 | github.com/go-logr/logr v1.4.3 // indirect 81 | github.com/go-logr/stdr v1.2.2 // indirect 82 | github.com/go-logr/zapr v1.3.0 // indirect 83 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 84 | github.com/go-openapi/jsonreference v0.20.2 // indirect 85 | github.com/go-openapi/swag v0.23.0 // indirect 86 | github.com/go-stack/stack v1.8.1 // indirect 87 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 88 | github.com/gobwas/glob v0.2.3 // indirect 89 | github.com/gogo/protobuf v1.3.2 // indirect 90 | github.com/google/btree v1.1.3 // indirect 91 | github.com/google/gnostic-models v0.7.0 // indirect 92 | github.com/google/uuid v1.6.0 // indirect 93 | github.com/gorilla/mux v1.8.1 // indirect 94 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 95 | github.com/gosuri/uitable v0.0.4 // indirect 96 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 97 | github.com/hashicorp/errwrap v1.1.0 // indirect 98 | github.com/hashicorp/go-multierror v1.1.1 // indirect 99 | github.com/huandu/xstrings v1.5.0 // indirect 100 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 101 | github.com/jmoiron/sqlx v1.4.0 // indirect 102 | github.com/josharian/intern v1.0.0 // indirect 103 | github.com/json-iterator/go v1.1.12 // indirect 104 | github.com/klauspost/compress v1.18.0 // indirect 105 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 106 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 107 | github.com/lib/pq v1.10.9 // indirect 108 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 109 | github.com/mailru/easyjson v0.7.7 // indirect 110 | github.com/mattn/go-colorable v0.1.13 // indirect 111 | github.com/mattn/go-isatty v0.0.19 // indirect 112 | github.com/mattn/go-runewidth v0.0.9 // indirect 113 | github.com/mitchellh/copystructure v1.2.0 // indirect 114 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 115 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 116 | github.com/moby/locker v1.0.1 // indirect 117 | github.com/moby/spdystream v0.5.0 // indirect 118 | github.com/moby/term v0.5.2 // indirect 119 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 120 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 121 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 122 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 123 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 124 | github.com/opencontainers/go-digest v1.0.0 // indirect 125 | github.com/opencontainers/image-spec v1.1.1 // indirect 126 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 127 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 128 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 129 | github.com/pkg/errors v0.9.1 // indirect 130 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 131 | github.com/prometheus/client_model v0.6.2 // indirect 132 | github.com/prometheus/common v0.66.1 // indirect 133 | github.com/prometheus/procfs v0.16.1 // indirect 134 | github.com/rubenv/sql-migrate v1.8.0 // indirect 135 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 136 | github.com/sagikazarmark/locafero v0.11.0 // indirect 137 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect 138 | github.com/shopspring/decimal v1.4.0 // indirect 139 | github.com/sirupsen/logrus v1.9.3 // indirect 140 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 141 | github.com/spf13/cast v1.10.0 // indirect 142 | github.com/spf13/cobra v1.10.1 // indirect 143 | github.com/spf13/pflag v1.0.10 // indirect 144 | github.com/subosito/gotenv v1.6.0 // indirect 145 | github.com/x448/float16 v0.8.4 // indirect 146 | github.com/xlab/treeprint v1.2.0 // indirect 147 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 148 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 149 | go.opentelemetry.io/otel v1.38.0 // indirect 150 | go.opentelemetry.io/otel/metric v1.38.0 // indirect 151 | go.opentelemetry.io/otel/trace v1.38.0 // indirect 152 | go.uber.org/multierr v1.11.0 // indirect 153 | go.uber.org/zap v1.27.0 // indirect 154 | go.yaml.in/yaml/v2 v2.4.3 // indirect 155 | go.yaml.in/yaml/v3 v3.0.4 // indirect 156 | golang.org/x/crypto v0.46.0 // indirect 157 | golang.org/x/net v0.47.0 // indirect 158 | golang.org/x/oauth2 v0.32.0 // indirect 159 | golang.org/x/sync v0.19.0 // indirect 160 | golang.org/x/sys v0.39.0 // indirect 161 | golang.org/x/term v0.38.0 // indirect 162 | golang.org/x/text v0.32.0 // indirect 163 | golang.org/x/time v0.12.0 // indirect 164 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 165 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect 166 | google.golang.org/grpc v1.72.1 // indirect 167 | google.golang.org/protobuf v1.36.10 // indirect 168 | gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 169 | gopkg.in/inf.v0 v0.9.1 // indirect 170 | gopkg.in/resty.v1 v1.12.0 // indirect 171 | gopkg.in/yaml.v3 v3.0.1 // indirect 172 | k8s.io/apiextensions-apiserver v0.34.2 // indirect 173 | k8s.io/apiserver v0.34.2 // indirect 174 | k8s.io/cli-runtime v0.34.2 // indirect 175 | k8s.io/component-base v0.34.2 // indirect 176 | k8s.io/klog/v2 v2.130.1 // indirect 177 | k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect 178 | k8s.io/kubectl v0.34.2 // indirect 179 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 180 | oras.land/oras-go v1.2.7 // indirect 181 | oras.land/oras-go/v2 v2.6.0 // indirect 182 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 183 | sigs.k8s.io/kustomize/api v0.20.1 // indirect 184 | sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect 185 | sigs.k8s.io/randfill v1.0.0 // indirect 186 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 187 | ) 188 | 189 | replace ( 190 | github.com/docker/docker => github.com/moby/moby v28.5.2+incompatible 191 | github.com/gin-gonic/gin v1.4.0 => github.com/gin-gonic/gin v1.9.0 192 | github.com/imdario/mergo => github.com/imdario/mergo v0.3.13 193 | golang.org/x/net => golang.org/x/net v0.48.0 194 | google.golang.org/grpc => google.golang.org/grpc v1.77.0 195 | ) 196 | 197 | replace google.golang.org/protobuf v1.32.0 => google.golang.org/protobuf v1.33.0 198 | --------------------------------------------------------------------------------