diff --git a/Taskfile.yaml b/Taskfile.yaml index 4d1a12ef..6f928055 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -21,6 +21,9 @@ includes: artifact: taskfile: ./images/operator-helm-controller/Taskfile.dist.yaml dir: ./images/operator-helm-controller + chart-values-artifact: + taskfile: ./images/chart-values-controller/Taskfile.dist.yaml + dir: ./images/chart-values-controller deps: taskfile: https://raw.githubusercontent.com/werf/common-ci/refs/heads/main/Taskfile.deps.yml @@ -114,6 +117,7 @@ tasks: - task: api:format - task: hooks:format - task: artifact:format + - task: chart-values-artifact:format - task: e2e:format lint: @@ -121,6 +125,7 @@ tasks: - task: api:lint - task: hooks:lint - task: artifact:lint + - task: chart-values-artifact:lint - task: e2e:lint - task: lint:doc-ru diff --git a/images/chart-values-controller/.golangci.yaml b/images/chart-values-controller/.golangci.yaml new file mode 100644 index 00000000..9e052264 --- /dev/null +++ b/images/chart-values-controller/.golangci.yaml @@ -0,0 +1,109 @@ +# https://golangci-lint.run/usage/configuration/ +version: "2" + +run: + concurrency: 4 + timeout: 10m + +issues: + # Show all errors. + max-issues-per-linter: 0 + max-same-issues: 0 + exclude: + - "don't use an underscore in package name" + +output: + sort-results: true + +exclusions: + paths: + - "^zz_generated.*" + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/deckhouse/) + gofumpt: + extra-rules: true + goimports: + local-prefixes: github.com/deckhouse/ + +linters: + default: none + enable: + - asciicheck # checks that your code does not contain non-ASCII identifiers + - bidichk # checks for dangerous unicode character sequences + - bodyclose # checks whether HTTP response body is closed successfully + - contextcheck # [maybe too many false positives] checks the function whether use a non-inherited context + - dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) + - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases + - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error + - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 + - copyloopvar # detects places where loop variables are copied (Go 1.22+) + - gocritic # provides diagnostics that check for bugs, performance and style issues + - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string + - ineffassign # detects when assignments to existing variables are not used + - misspell # finds commonly misspelled English words in comments + - nolintlint # reports ill-formed or insufficient nolint directives + - reassign # checks that package variables are not reassigned + - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint + - staticcheck # is a go vet on steroids, applying a ton of static analysis checks + - testifylint # checks usage of github.com/stretchr/testify + - unconvert # removes unnecessary type conversions + - unparam # reports unused function parameters + - unused # checks for unused constants, variables, functions and types + - usetesting # reports uses of functions with replacement inside the testing package + - testableexamples # checks if examples are testable (have an expected output) + - thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes + - whitespace # detects leading and trailing whitespace + - wastedassign # finds wasted assignment statements + - importas # checks import aliases against the configured convention + settings: + errcheck: + exclude-functions: + - "(*os.File).Close" + - "(*net.TCPConn).Close" + - "(io.ReadCloser).Close" + - "(net.Listener).Close" + - "(net.Conn).Close" + - "(net.Conn).Close" + - "(*golang.org/x/crypto/ssh.Session).Close" + - "(*github.com/fsnotify/fsnotify.Watcher).Close" + staticcheck: + dot-import-whitelist: + - github.com/onsi/ginkgo/v2 + - github.com/onsi/gomega + revive: + rules: + - name: dot-imports + disabled: true + - name: exported + disabled: true + - name: package-comments + disabled: true + nolintlint: + # Exclude following linters from requiring an explanation. + # Default: [] + allow-no-explanation: [funlen, gocognit, lll] + # Enable to require an explanation of nonzero length after each nolint directive. + # Default: false + require-explanation: true + # Enable to require nolint directives to mention the specific linter being suppressed. + # Default: false + require-specific: true + importas: + # Do not allow unaliased imports of aliased packages. + # Default: false + no-unaliased: true + # Do not allow non-required aliases. + # Default: false + no-extra-aliases: false diff --git a/images/chart-values-controller/Taskfile.dist.yaml b/images/chart-values-controller/Taskfile.dist.yaml new file mode 100644 index 00000000..27664273 --- /dev/null +++ b/images/chart-values-controller/Taskfile.dist.yaml @@ -0,0 +1,15 @@ +version: "3" + +silent: true + +includes: + artifact: + taskfile: https://raw.githubusercontent.com/werf/common-ci/refs/heads/main/Taskfile.format_lint.yml + flatten: true + vars: + gciPrefix: '{{.gciPrefix | default "github.com/deckhouse/"}}' + golangciConfigPath: '{{.golangciConfigPath | default "./.golangci.yaml"}}' + golangciLintBinDir: '{{.golangciLintBinDir | default "../../bin"}}' + golangciLintVersion: '{{.golangciLintVersion | default "v2.8.0"}}' + golangciPaths: '{{.golangciPaths | default "./..."}}' + paths: '{{.paths | default "."}}' diff --git a/images/chart-values-controller/cmd/chart-values-controller/main.go b/images/chart-values-controller/cmd/chart-values-controller/main.go new file mode 100644 index 00000000..3a0646c5 --- /dev/null +++ b/images/chart-values-controller/cmd/chart-values-controller/main.go @@ -0,0 +1,134 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "flag" + "net/http" + "os" + "time" + + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/deckhouse/chart-values-controller/internal/cache" + "github.com/deckhouse/chart-values-controller/internal/controller" + "github.com/deckhouse/chart-values-controller/internal/resolver" + "github.com/deckhouse/chart-values-controller/internal/server" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// artifactDownloadTimeout bounds a single artifact download from the in-cluster +// source-controller. +const artifactDownloadTimeout = 60 * time.Second + +var scheme = runtime.NewScheme() + +func init() { + _ = clientgoscheme.AddToScheme(scheme) + _ = helmv1alpha1.AddToScheme(scheme) + _ = sourcev1.AddToScheme(scheme) +} + +func main() { + var ( + metricsAddr string + healthProbeAddr string + apiAddr string + cacheDir string + cacheTTL time.Duration + sourceInterval time.Duration + maxChartSizeMB int + ) + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metrics endpoint binds to.") + flag.StringVar(&healthProbeAddr, "health-probe-bind-address", ":9440", "The address the health probe endpoint binds to.") + flag.StringVar(&apiAddr, "api-bind-address", "127.0.0.1:8081", "The address the chart-values HTTP API binds to.") + flag.StringVar(&cacheDir, "cache-dir", "/cache", "Directory used to cache extracted values.yaml files.") + flag.DurationVar(&cacheTTL, "chart-values-cache-ttl", time.Hour, "Lifetime of auxiliary source resources and their cache entries; refreshed on each request.") + flag.DurationVar(&sourceInterval, "source-interval", 10*time.Minute, "Reconcile interval set on auxiliary source resources.") + flag.IntVar(&maxChartSizeMB, "max-chart-size-mb", 100, "Maximum accepted chart artifact size in MiB; larger artifacts are rejected.") + opts := zap.Options{Development: false} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + logger := ctrl.Log.WithName("setup") + + if maxChartSizeMB <= 0 { + logger.Error(nil, "invalid --max-chart-size-mb, must be greater than 0", "value", maxChartSizeMB) + os.Exit(1) + } + maxArtifactBytes := int64(maxChartSizeMB) << 20 + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: healthProbeAddr, + LeaderElection: false, + }) + if err != nil { + logger.Error(err, "unable to create manager") + os.Exit(1) + } + + valuesCache := cache.New(cacheDir) + httpClient := &http.Client{Timeout: artifactDownloadTimeout} + + res := resolver.New( + mgr.GetClient(), + valuesCache, + httpClient, + helmv1alpha1.TargetNamespace, + cacheTTL, + sourceInterval, + maxArtifactBytes, + ) + + if err := mgr.Add(server.New(apiAddr, res)); err != nil { + logger.Error(err, "unable to add HTTP server") + os.Exit(1) + } + + if err := controller.SetupWithManager(mgr, valuesCache, httpClient, maxArtifactBytes); err != nil { + logger.Error(err, "unable to setup auxiliary-resource controllers") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + logger.Error(err, "unable to set up ready check") + os.Exit(1) + } + + logger.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + logger.Error(err, "manager exited with error") + os.Exit(1) + } +} diff --git a/images/chart-values-controller/go.mod b/images/chart-values-controller/go.mod new file mode 100644 index 00000000..ea9c1768 --- /dev/null +++ b/images/chart-values-controller/go.mod @@ -0,0 +1,72 @@ +module github.com/deckhouse/chart-values-controller + +go 1.26.3 + +replace github.com/deckhouse/operator-helm/api => ../../api + +require ( + github.com/deckhouse/operator-helm/api v0.0.0-00010101000000-000000000000 + github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 + github.com/werf/nelm-source-controller/api v0.1.5 + k8s.io/api v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 + sigs.k8s.io/controller-runtime v0.23.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.12.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/images/chart-values-controller/go.sum b/images/chart-values-controller/go.sum new file mode 100644 index 00000000..171f5a90 --- /dev/null +++ b/images/chart-values-controller/go.sum @@ -0,0 +1,167 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= +github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1 h1:b1P4avYWjjWuzPSOv6QZtk1ffl/iBfWBGK4qNAxaA94= +github.com/werf/3p-fluxcd-pkg/apis/acl v0.9.0-nelm.1/go.mod h1:00dBUg4SN+4Xu4LWrbQm5LdmRKVP9Fjbvb+rvqjHrVI= +github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1 h1:rYX8cMeryBHH7sNPVSQm1IAVES08TiWvADaZsDj98Wk= +github.com/werf/3p-fluxcd-pkg/apis/meta v1.23.0-nelm.1/go.mod h1:14co1+Ub5rW0Bp3Qo4IzCHwEcaw06StyMu7Rv5pMVCY= +github.com/werf/nelm-source-controller/api v0.1.5 h1:pxAyCjM4/MtD6KeSJg4FbnkpCXZ2A1AThUfmznspeDo= +github.com/werf/nelm-source-controller/api v0.1.5/go.mod h1:S6nafnB16Bo+ftoRVDCBJEN0H5mWLfKAAvw7p51l5Yc= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/images/chart-values-controller/internal/artifact/artifact.go b/images/chart-values-controller/internal/artifact/artifact.go new file mode 100644 index 00000000..f24e2bfa --- /dev/null +++ b/images/chart-values-controller/internal/artifact/artifact.go @@ -0,0 +1,139 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package artifact + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "path" + "strings" +) + +// ErrValuesNotFound is returned when the chart archive contains no top-level +// values.yaml file. +var ErrValuesNotFound = errors.New("values.yaml not found in chart archive") + +// ErrArtifactTooLarge is returned when a chart artifact exceeds the configured +// size limit. It guards against memory exhaustion from a malicious or broken +// source. +var ErrArtifactTooLarge = errors.New("chart artifact exceeds the configured size limit") + +// FetchValues downloads the chart artifact from url, verifies its integrity +// against digest (format "sha256:"), and returns the raw bytes of the +// chart's top-level values.yaml. Artifacts larger than maxBytes are rejected +// with ErrArtifactTooLarge. +func FetchValues(ctx context.Context, httpClient *http.Client, url, digest string, maxBytes int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building artifact request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading artifact: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("downloading artifact: unexpected status %s", resp.Status) + } + + // Fast reject when the server advertises a size over the limit. + if resp.ContentLength > maxBytes { + return nil, fmt.Errorf("%w (%d bytes > %d bytes)", ErrArtifactTooLarge, resp.ContentLength, maxBytes) + } + + // Read one byte past the limit so an oversized body is detected rather than + // silently truncated (which would otherwise surface as a digest mismatch). + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading artifact body: %w", err) + } + if int64(len(data)) > maxBytes { + return nil, fmt.Errorf("%w (> %d bytes)", ErrArtifactTooLarge, maxBytes) + } + + if err := verifyDigest(data, digest); err != nil { + return nil, err + } + + return extractValues(data, maxBytes) +} + +func verifyDigest(data []byte, digest string) error { + algo, want, ok := strings.Cut(digest, ":") + if !ok || algo != "sha256" { + return fmt.Errorf("unsupported artifact digest %q", digest) + } + + got := fmt.Sprintf("%x", sha256.Sum256(data)) + if got != want { + return fmt.Errorf("artifact digest mismatch: want %s, got %s", want, got) + } + + return nil +} + +func extractValues(data []byte, maxBytes int64) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("opening gzip stream: %w", err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("reading tar stream: %w", err) + } + + if header.Typeflag != tar.TypeReg { + continue + } + + // A packaged chart stores files under a single top-level directory, + // e.g. "podinfo/values.yaml". Match values.yaml at exactly that depth. + parts := strings.Split(path.Clean(header.Name), "/") + if len(parts) == 2 && parts[1] == "values.yaml" { + // Bound the decompressed read too, so a small archive cannot expand + // into an oversized values.yaml (decompression bomb). + content, err := io.ReadAll(io.LimitReader(tr, maxBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading values.yaml: %w", err) + } + if int64(len(content)) > maxBytes { + return nil, fmt.Errorf("%w (values.yaml > %d bytes)", ErrArtifactTooLarge, maxBytes) + } + + return content, nil + } + } + + return nil, ErrValuesNotFound +} diff --git a/images/chart-values-controller/internal/artifact/artifact_test.go b/images/chart-values-controller/internal/artifact/artifact_test.go new file mode 100644 index 00000000..f6d0b458 --- /dev/null +++ b/images/chart-values-controller/internal/artifact/artifact_test.go @@ -0,0 +1,150 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package artifact + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func buildChartArchive(t *testing.T, files map[string]string) []byte { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + }); err != nil { + t.Fatalf("writing tar header: %v", err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("writing tar content: %v", err) + } + } + + if err := tw.Close(); err != nil { + t.Fatalf("closing tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("closing gzip: %v", err) + } + + return buf.Bytes() +} + +func digestOf(data []byte) string { + return fmt.Sprintf("sha256:%x", sha256.Sum256(data)) +} + +func serve(t *testing.T, data []byte) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(data) + })) + t.Cleanup(srv.Close) + + return srv.URL +} + +const testMaxBytes = 10 << 20 + +func TestFetchValuesSuccess(t *testing.T) { + want := "replicaCount: 1\nimage:\n tag: latest\n" + archive := buildChartArchive(t, map[string]string{ + "podinfo/Chart.yaml": "name: podinfo\n", + "podinfo/values.yaml": want, + }) + + url := serve(t, archive) + + got, err := FetchValues(context.Background(), http.DefaultClient, url, digestOf(archive), testMaxBytes) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != want { + t.Fatalf("values mismatch:\ngot: %q\nwant: %q", got, want) + } +} + +func TestFetchValuesMissing(t *testing.T) { + archive := buildChartArchive(t, map[string]string{ + "podinfo/Chart.yaml": "name: podinfo\n", + }) + + url := serve(t, archive) + + _, err := FetchValues(context.Background(), http.DefaultClient, url, digestOf(archive), testMaxBytes) + if !errors.Is(err, ErrValuesNotFound) { + t.Fatalf("expected ErrValuesNotFound, got %v", err) + } +} + +func TestFetchValuesNestedValuesIgnored(t *testing.T) { + // values.yaml of a subchart must not be mistaken for the chart's own. + archive := buildChartArchive(t, map[string]string{ + "podinfo/charts/dep/values.yaml": "nested: true\n", + }) + + url := serve(t, archive) + + _, err := FetchValues(context.Background(), http.DefaultClient, url, digestOf(archive), testMaxBytes) + if !errors.Is(err, ErrValuesNotFound) { + t.Fatalf("expected ErrValuesNotFound for nested-only values, got %v", err) + } +} + +func TestFetchValuesDigestMismatch(t *testing.T) { + archive := buildChartArchive(t, map[string]string{ + "podinfo/values.yaml": "a: b\n", + }) + + url := serve(t, archive) + + _, err := FetchValues(context.Background(), http.DefaultClient, url, "sha256:deadbeef", testMaxBytes) + if err == nil || errors.Is(err, ErrValuesNotFound) { + t.Fatalf("expected digest mismatch error, got %v", err) + } +} + +func TestFetchValuesTooLarge(t *testing.T) { + archive := buildChartArchive(t, map[string]string{ + "podinfo/values.yaml": "a: b\n", + }) + + url := serve(t, archive) + + // A limit smaller than the archive must be rejected before digest checks. + _, err := FetchValues(context.Background(), http.DefaultClient, url, digestOf(archive), 8) + if !errors.Is(err, ErrArtifactTooLarge) { + t.Fatalf("expected ErrArtifactTooLarge, got %v", err) + } +} diff --git a/images/chart-values-controller/internal/cache/cache.go b/images/chart-values-controller/internal/cache/cache.go new file mode 100644 index 00000000..95bc1ef1 --- /dev/null +++ b/images/chart-values-controller/internal/cache/cache.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cache + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Cache is an on-disk cache for extracted values.yaml files, backed by an +// emptyDir volume (per-pod, non-persistent). Entries are keyed by the auxiliary +// resource name (a deterministic hash of the repository/chart/version triple), +// so both the HTTP handler and the auxiliary-resource controller derive the same +// key; the controller keeps each entry in sync with the current artifact. +type Cache struct { + dir string +} + +func New(dir string) *Cache { + return &Cache{dir: dir} +} + +func (c *Cache) path(key string) string { + return filepath.Join(c.dir, strings.ReplaceAll(key, ":", "_")) +} + +// Get returns the cached content for a key, or ok=false on a miss. +func (c *Cache) Get(key string) ([]byte, bool) { + data, err := os.ReadFile(c.path(key)) + if err != nil { + return nil, false + } + + return data, true +} + +// Put stores content for a key, writing atomically via a unique temp file + +// rename. Concurrent writers of the same key each use their own temp file, so no +// external locking is needed: the atomic rename publishes a whole file and, for a +// given key, the content is identical (same artifact digest) regardless of order. +func (c *Cache) Put(key string, content []byte) error { + if err := os.MkdirAll(c.dir, 0o755); err != nil { + return fmt.Errorf("creating cache dir: %w", err) + } + + tmp, err := os.CreateTemp(c.dir, ".tmp-*") + if err != nil { + return fmt.Errorf("creating temp cache file: %w", err) + } + tmpName := tmp.Name() + // Best-effort cleanup: harmless no-op once the temp file has been renamed. + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(content); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing cache entry: %w", err) + } + + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing cache entry: %w", err) + } + + if err := os.Rename(tmpName, c.path(key)); err != nil { + return fmt.Errorf("committing cache entry: %w", err) + } + + return nil +} + +// Delete removes the cache entry for a key. A missing entry is not an error. +func (c *Cache) Delete(key string) error { + if err := os.Remove(c.path(key)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing cache entry: %w", err) + } + + return nil +} diff --git a/images/chart-values-controller/internal/cache/cache_test.go b/images/chart-values-controller/internal/cache/cache_test.go new file mode 100644 index 00000000..857e53f5 --- /dev/null +++ b/images/chart-values-controller/internal/cache/cache_test.go @@ -0,0 +1,81 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cache + +import ( + "bytes" + "sync" + "testing" +) + +func TestPutGetRoundTrip(t *testing.T) { + c := New(t.TempDir()) + + if _, ok := c.Get("cv-abc"); ok { + t.Fatal("expected miss on empty cache") + } + + want := []byte("replicaCount: 1\n") + if err := c.Put("cv-abc", want); err != nil { + t.Fatalf("put: %v", err) + } + + got, ok := c.Get("cv-abc") + if !ok || !bytes.Equal(got, want) { + t.Fatalf("get = (%q, %v), want (%q, true)", got, ok, want) + } + + if err := c.Delete("cv-abc"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, ok := c.Get("cv-abc"); ok { + t.Fatal("expected miss after delete") + } +} + +// TestConcurrentSameKey exercises the -race detector: many writers of the same +// key (same content, as the digest guarantees) plus concurrent readers must +// never observe a partial or corrupted file. +func TestConcurrentSameKey(t *testing.T) { + c := New(t.TempDir()) + + const key = "cv-concurrent" + want := bytes.Repeat([]byte("a: b\n"), 4096) + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(2) + go func() { + defer wg.Done() + if err := c.Put(key, want); err != nil { + t.Errorf("put: %v", err) + } + }() + go func() { + defer wg.Done() + if got, ok := c.Get(key); ok && !bytes.Equal(got, want) { + t.Errorf("get returned corrupted content: %d bytes", len(got)) + } + }() + } + wg.Wait() + + got, ok := c.Get(key) + if !ok || !bytes.Equal(got, want) { + t.Fatalf("final get = (%d bytes, %v), want (%d bytes, true)", len(got), ok, len(want)) + } +} diff --git a/images/chart-values-controller/internal/controller/controller.go b/images/chart-values-controller/internal/controller/controller.go new file mode 100644 index 00000000..d9b23e41 --- /dev/null +++ b/images/chart-values-controller/internal/controller/controller.go @@ -0,0 +1,244 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/chart-values-controller/internal/artifact" + "github.com/deckhouse/chart-values-controller/internal/cache" + "github.com/deckhouse/chart-values-controller/internal/labels" +) + +type ( + artifactFunc func(client.Object) *meta.Artifact + conditionsFunc func(client.Object) []metav1.Condition +) + +// reconciler keeps the values cache in sync with an auxiliary source resource +// (HelmChart or OCIRepository) and deletes the resource once its expires-at +// annotation has passed. It downloads and re-caches values.yaml whenever the +// artifact revision changes, so HTTP requests can be served straight from cache. +type reconciler struct { + client client.Client + cache *cache.Cache + httpClient *http.Client + maxArtifactBytes int64 + newObject func() client.Object + artifactOf artifactFunc + conditionsOf conditionsFunc + + mu sync.Mutex + digests map[string]string +} + +func (r *reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + logger := log.FromContext(ctx) + + obj := r.newObject() + if err := r.client.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // Resource gone (external or TTL deletion): drop its cache entry. + _ = r.cache.Delete(req.Name) + r.forgetDigest(req.Name) + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("getting %s: %w", req.Name, err) + } + + if obj.GetLabels()[labels.ManagedBy] != labels.ManagedByValue { + return reconcile.Result{}, nil + } + + name := obj.GetName() + + expiresAt, hasExpiry := parseExpiry(obj, logger) + if hasExpiry && !time.Now().Before(expiresAt) { + _ = r.cache.Delete(name) + r.forgetDigest(name) + if err := r.client.Delete(ctx, obj); err != nil { + return reconcile.Result{}, client.IgnoreNotFound(err) + } + logger.Info("deleted expired auxiliary resource", "name", name) + return reconcile.Result{}, nil + } + + r.refreshCache(ctx, obj, name) + + if hasExpiry { + return reconcile.Result{RequeueAfter: time.Until(expiresAt)}, nil + } + + return reconcile.Result{}, nil +} + +func (r *reconciler) refreshCache(ctx context.Context, obj client.Object, name string) { + art := r.artifactOf(obj) + if art == nil || !isReady(r.conditionsOf(obj)) { + return + } + + if r.lastDigest(name) == art.Digest { + return + } + + values, err := artifact.FetchValues(ctx, r.httpClient, art.URL, art.Digest, r.maxArtifactBytes) + if err != nil { + if !errors.Is(err, artifact.ErrValuesNotFound) { + log.FromContext(ctx).Error(err, "failed to refresh cached values.yaml", "name", name) + } + return + } + + if err := r.cache.Put(name, values); err != nil { + log.FromContext(ctx).Error(err, "failed to write cache entry", "name", name) + return + } + + r.setLastDigest(name, art.Digest) +} + +func (r *reconciler) lastDigest(name string) string { + r.mu.Lock() + defer r.mu.Unlock() + return r.digests[name] +} + +func (r *reconciler) setLastDigest(name, digest string) { + r.mu.Lock() + defer r.mu.Unlock() + r.digests[name] = digest +} + +func (r *reconciler) forgetDigest(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.digests, name) +} + +func parseExpiry(obj client.Object, logger interface{ Error(error, string, ...any) }) (time.Time, bool) { + raw := obj.GetAnnotations()[labels.ExpiresAtAnnotation] + if raw == "" { + return time.Time{}, false + } + + expiresAt, err := time.Parse(time.RFC3339, raw) + if err != nil { + logger.Error(err, "ignoring malformed expires-at annotation", "value", raw) + return time.Time{}, false + } + + return expiresAt, true +} + +func isReady(conditions []metav1.Condition) bool { + c := apimeta.FindStatusCondition(conditions, "Ready") + return c != nil && c.Status == metav1.ConditionTrue +} + +// SetupWithManager registers cache-sync/cleanup reconcilers for both auxiliary +// resource kinds, filtered to resources managed by this controller and triggered +// on artifact-revision or expires-at changes. +func SetupWithManager(mgr manager.Manager, valuesCache *cache.Cache, httpClient *http.Client, maxArtifactBytes int64) error { + helmChart := &reconciler{ + client: mgr.GetClient(), + cache: valuesCache, + httpClient: httpClient, + maxArtifactBytes: maxArtifactBytes, + digests: map[string]string{}, + newObject: func() client.Object { return &sourcev1.HelmChart{} }, + artifactOf: func(o client.Object) *meta.Artifact { return o.(*sourcev1.HelmChart).Status.Artifact }, + conditionsOf: func(o client.Object) []metav1.Condition { return o.(*sourcev1.HelmChart).Status.Conditions }, + } + + if err := builder.ControllerManagedBy(mgr). + Named("chart-values-helmchart"). + For(&sourcev1.HelmChart{}, builder.WithPredicates(changePredicate(helmChart.artifactOf))). + Complete(helmChart); err != nil { + return fmt.Errorf("setting up helm chart controller: %w", err) + } + + ociRepo := &reconciler{ + client: mgr.GetClient(), + cache: valuesCache, + httpClient: httpClient, + maxArtifactBytes: maxArtifactBytes, + digests: map[string]string{}, + newObject: func() client.Object { return &sourcev1.OCIRepository{} }, + artifactOf: func(o client.Object) *meta.Artifact { return o.(*sourcev1.OCIRepository).Status.Artifact }, + conditionsOf: func(o client.Object) []metav1.Condition { return o.(*sourcev1.OCIRepository).Status.Conditions }, + } + + if err := builder.ControllerManagedBy(mgr). + Named("chart-values-ocirepository"). + For(&sourcev1.OCIRepository{}, builder.WithPredicates(changePredicate(ociRepo.artifactOf))). + Complete(ociRepo); err != nil { + return fmt.Errorf("setting up oci repository controller: %w", err) + } + + return nil +} + +// changePredicate limits reconciliation to managed resources and, for updates, +// to changes that matter here: a new artifact revision (content changed) or a +// refreshed expires-at (TTL extended). +func changePredicate(artifactOf artifactFunc) predicate.Predicate { + managed := func(o client.Object) bool { + return o.GetLabels()[labels.ManagedBy] == labels.ManagedByValue + } + + digest := func(o client.Object) string { + if a := artifactOf(o); a != nil { + return a.Digest + } + return "" + } + + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { return managed(e.Object) }, + DeleteFunc: func(e event.DeleteEvent) bool { return managed(e.Object) }, + GenericFunc: func(e event.GenericEvent) bool { return managed(e.Object) }, + UpdateFunc: func(e event.UpdateEvent) bool { + if !managed(e.ObjectNew) { + return false + } + if digest(e.ObjectOld) != digest(e.ObjectNew) { + return true + } + return e.ObjectOld.GetAnnotations()[labels.ExpiresAtAnnotation] != + e.ObjectNew.GetAnnotations()[labels.ExpiresAtAnnotation] + }, + } +} diff --git a/images/chart-values-controller/internal/labels/labels.go b/images/chart-values-controller/internal/labels/labels.go new file mode 100644 index 00000000..7837c93c --- /dev/null +++ b/images/chart-values-controller/internal/labels/labels.go @@ -0,0 +1,30 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package labels + +const ( + // ManagedBy marks auxiliary source resources created by this controller so + // they can be distinguished from resources managed by operator-helm-controller. + ManagedBy = "app.kubernetes.io/managed-by" + + // ManagedByValue is the value of the ManagedBy label. + ManagedByValue = "chart-values-controller" + + // ExpiresAtAnnotation holds the RFC3339 UTC timestamp after which an auxiliary + // resource may be deleted by the cleanup reconciler. + ExpiresAtAnnotation = "chart-values.deckhouse.io/expires-at" +) diff --git a/images/chart-values-controller/internal/naming/naming.go b/images/chart-values-controller/internal/naming/naming.go new file mode 100644 index 00000000..8c96831d --- /dev/null +++ b/images/chart-values-controller/internal/naming/naming.go @@ -0,0 +1,65 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package naming + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +const ( + resourcePrefix = "tmp" + + // maxPartLen bounds each human-readable name part so the whole name stays + // within the 63-character DNS-1123 label limit: + // "tmp-" (4) + repo (<=20) + "-" + chart (<=20) + "-" + hash (16) = 62. + maxPartLen = 20 +) + +// AuxResourceName returns a deterministic DNS-1123 name (<=63 chars) for the +// auxiliary source resource backing a (kind, repository, chart, version) tuple: +// "tmp---". The same tuple always maps to the same name, +// which makes polling requests idempotent and lets concurrent requests converge +// on one resource. The repo/chart parts are only human-readable hints; the hash +// over the full tuple (including kind) guarantees uniqueness even if those parts +// collide across repository kinds. +func AuxResourceName(kind, repository, chart, version string) string { + sum := sha256.Sum256([]byte(kind + "\x00" + repository + "/" + chart + "@" + version)) + + return fmt.Sprintf("%s-%s-%s-%x", resourcePrefix, sanitizePart(repository), sanitizePart(chart), sum[:8]) +} + +// sanitizePart lowercases s, replaces characters invalid in a DNS-1123 label +// with '-', truncates to maxPartLen, and trims leading/trailing '-'. +func sanitizePart(s string) string { + s = strings.ToLower(s) + + var b strings.Builder + for _, r := range s { + if b.Len() >= maxPartLen { + break + } + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteRune('-') + } + } + + return strings.Trim(b.String(), "-") +} diff --git a/images/chart-values-controller/internal/naming/naming_test.go b/images/chart-values-controller/internal/naming/naming_test.go new file mode 100644 index 00000000..7267bd91 --- /dev/null +++ b/images/chart-values-controller/internal/naming/naming_test.go @@ -0,0 +1,74 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package naming + +import ( + "regexp" + "strings" + "testing" +) + +var dns1123 = regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`) + +const testKind = "HelmClusterAddonRepository" + +func TestAuxResourceNameReadableHints(t *testing.T) { + name := AuxResourceName(testKind, "GitHub", "Pod.Info", "6.7.1") + if !strings.HasPrefix(name, "tmp-") { + t.Fatalf("expected tmp- prefix, got %q", name) + } + if !strings.Contains(name, "github") || !strings.Contains(name, "pod-info") { + t.Fatalf("expected sanitized repo/chart hints in %q", name) + } +} + +func TestAuxResourceNameDeterministic(t *testing.T) { + a := AuxResourceName(testKind, "github", "podinfo", "6.7.1") + b := AuxResourceName(testKind, "github", "podinfo", "6.7.1") + if a != b { + t.Fatalf("expected deterministic name, got %q and %q", a, b) + } +} + +func TestAuxResourceNameDistinct(t *testing.T) { + cases := [][4]string{ + {testKind, "github", "podinfo", "6.7.1"}, + {testKind, "github", "podinfo", "6.7.2"}, + {testKind, "github", "nginx", "6.7.1"}, + {testKind, "gitlab", "podinfo", "6.7.1"}, + {"FutureRepository", "github", "podinfo", "6.7.1"}, // same name/chart/version, different kind + } + + seen := map[string]bool{} + for _, c := range cases { + name := AuxResourceName(c[0], c[1], c[2], c[3]) + if seen[name] { + t.Fatalf("name collision for %v: %q", c, name) + } + seen[name] = true + } +} + +func TestAuxResourceNameValidDNS1123(t *testing.T) { + name := AuxResourceName(testKind, "really-long-repository-name", "really-long-chart-name", "1.2.3-alpha.1+build") + if len(name) > 63 { + t.Fatalf("name too long (%d): %q", len(name), name) + } + if !dns1123.MatchString(name) { + t.Fatalf("name is not a valid DNS-1123 label: %q", name) + } +} diff --git a/images/chart-values-controller/internal/resolver/resolver.go b/images/chart-values-controller/internal/resolver/resolver.go new file mode 100644 index 00000000..1b296a60 --- /dev/null +++ b/images/chart-values-controller/internal/resolver/resolver.go @@ -0,0 +1,357 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resolver + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/werf/3p-fluxcd-pkg/apis/meta" + sourcev1 "github.com/werf/nelm-source-controller/api/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/deckhouse/chart-values-controller/internal/artifact" + "github.com/deckhouse/chart-values-controller/internal/cache" + "github.com/deckhouse/chart-values-controller/internal/labels" + "github.com/deckhouse/chart-values-controller/internal/naming" + helmv1alpha1 "github.com/deckhouse/operator-helm/api/v1alpha1" +) + +// helmChartLayerMediaType is the OCI media type of the layer that holds a +// packaged Helm chart. +const helmChartLayerMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + +// RepositoryKind identifies the kind of repository a chart lives in. New +// repository kinds are added as new constants plus a case in Resolve. +type RepositoryKind string + +const ( + // RepositoryKindHelmClusterAddon is a chart referenced by a + // HelmClusterAddonRepository custom resource. Kind values are stored and + // compared in lower case, so the request casing does not matter. + RepositoryKindHelmClusterAddon RepositoryKind = "helmclusteraddonrepository" +) + +// Outcome enumerates the possible results of resolving a chart-values request. +type Outcome string + +const ( + OutcomeReady Outcome = "ready" + OutcomePending Outcome = "pending" + OutcomeRepositoryNotFound Outcome = "repository_not_found" + OutcomeUnsupportedRepositoryKind Outcome = "unsupported_repository_kind" + OutcomeFetchFailed Outcome = "fetch_failed" + OutcomeValuesNotFound Outcome = "values_not_found" +) + +// Request identifies a chart by repository kind, repository name, chart name and +// chart version. +type Request struct { + Kind RepositoryKind + RepositoryName string + Chart string + Version string +} + +// Result is the outcome of a Resolve call. Values is populated only when +// Outcome is OutcomeReady; Message carries human-readable detail for failures. +type Result struct { + Outcome Outcome + Values []byte + Message string +} + +type Resolver struct { + client client.Client + cache *cache.Cache + httpClient *http.Client + namespace string + ttl time.Duration + sourceInterval metav1.Duration + maxArtifactBytes int64 +} + +func New(c client.Client, valuesCache *cache.Cache, httpClient *http.Client, namespace string, ttl, sourceInterval time.Duration, maxArtifactBytes int64) *Resolver { + return &Resolver{ + client: c, + cache: valuesCache, + httpClient: httpClient, + namespace: namespace, + ttl: ttl, + sourceInterval: metav1.Duration{Duration: sourceInterval}, + maxArtifactBytes: maxArtifactBytes, + } +} + +// Resolve dispatches on the repository kind and, when the artifact is ready, +// returns the chart's values.yaml. A returned error indicates an internal +// failure (HTTP 500); all expected states are reported via Result.Outcome. +func (r *Resolver) Resolve(ctx context.Context, req Request) (Result, error) { + // Kind is case-insensitive: normalize to lower case so it dispatches and + // contributes to the resource name hash consistently. The original casing is + // kept only for the error message. + requested := req.Kind + req.Kind = RepositoryKind(strings.ToLower(string(req.Kind))) + + switch req.Kind { + case RepositoryKindHelmClusterAddon: + return r.resolveHelmClusterAddon(ctx, req) + default: + return Result{Outcome: OutcomeUnsupportedRepositoryKind, Message: fmt.Sprintf("unsupported repository kind %q", requested)}, nil + } +} + +// resolveHelmClusterAddon ensures the auxiliary source resource for a chart from +// a HelmClusterAddonRepository exists, inspects its status and returns the +// chart's values.yaml once the artifact is ready. +func (r *Resolver) resolveHelmClusterAddon(ctx context.Context, req Request) (Result, error) { + name := naming.AuxResourceName(string(req.Kind), req.RepositoryName, req.Chart, req.Version) + + // Fast path: the cache (keyed by the auxiliary resource name) is kept fresh by + // the auxiliary-resource controller via a watch with a revision-change predicate, + // so a hit is served without any Kubernetes calls. + if values, ok := r.cache.Get(name); ok { + return Result{Outcome: OutcomeReady, Values: values}, nil + } + + repo := &helmv1alpha1.HelmClusterAddonRepository{} + if err := r.client.Get(ctx, types.NamespacedName{Name: req.RepositoryName}, repo); err != nil { + if apierrors.IsNotFound(err) { + return Result{Outcome: OutcomeRepositoryNotFound, Message: fmt.Sprintf("repository %q not found", req.RepositoryName)}, nil + } + return Result{}, fmt.Errorf("getting repository: %w", err) + } + + expiresAt := time.Now().UTC().Add(r.ttl).Format(time.RFC3339) + + var conditions []metav1.Condition + var art *meta.Artifact + + switch { + case isOCI(repo.Spec.URL): + ociRepo, err := r.ensureOCIRepository(ctx, repo, req, name, expiresAt) + if err != nil { + return Result{}, err + } + conditions, art = ociRepo.Status.Conditions, ociRepo.Status.Artifact + case isHelm(repo.Spec.URL): + chart, pending, err := r.ensureHelmChart(ctx, repo, req, name, expiresAt) + if err != nil { + return Result{}, err + } + if pending { + return Result{Outcome: OutcomePending}, nil + } + conditions, art = chart.Status.Conditions, chart.Status.Artifact + default: + return Result{Outcome: OutcomeFetchFailed, Message: fmt.Sprintf("unsupported repository URL scheme: %q", repo.Spec.URL)}, nil + } + + switch outcome, message := classify(conditions, art); outcome { + case OutcomeFetchFailed: + return Result{Outcome: OutcomeFetchFailed, Message: message}, nil + case OutcomePending: + return Result{Outcome: OutcomePending}, nil + } + + return r.readValues(ctx, name, art) +} + +func (r *Resolver) readValues(ctx context.Context, name string, art *meta.Artifact) (Result, error) { + values, err := artifact.FetchValues(ctx, r.httpClient, art.URL, art.Digest, r.maxArtifactBytes) + if err != nil { + if errors.Is(err, artifact.ErrValuesNotFound) { + return Result{Outcome: OutcomeValuesNotFound, Message: "chart has no values.yaml"}, nil + } + return Result{Outcome: OutcomeFetchFailed, Message: err.Error()}, nil + } + + if err := r.cache.Put(name, values); err != nil { + log.FromContext(ctx).Error(err, "failed to cache values.yaml") + } + + return Result{Outcome: OutcomeReady, Values: values}, nil +} + +func (r *Resolver) ensureHelmChart(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, req Request, name, expiresAt string) (*sourcev1.HelmChart, bool, error) { + helmRepoName, err := r.findHelmRepositoryName(ctx, repo.Name) + if err != nil { + return nil, false, err + } + if helmRepoName == "" { + // The backing HelmRepository is created by operator-helm-controller when + // it reconciles the HelmClusterAddonRepository; until then, wait. + return nil, true, nil + } + + chart := &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: r.namespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, r.client, chart, func() error { + applyManagedMeta(chart, expiresAt) + chart.Spec.Chart = req.Chart + chart.Spec.Version = req.Version + chart.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: helmRepoName, + } + chart.Spec.Interval = r.sourceInterval + + return nil + }); err != nil { + return nil, false, fmt.Errorf("ensuring helm chart: %w", err) + } + + return chart, false, nil +} + +func (r *Resolver) ensureOCIRepository(ctx context.Context, repo *helmv1alpha1.HelmClusterAddonRepository, req Request, name, expiresAt string) (*sourcev1.OCIRepository, error) { + authSecret, tlsSecret, err := r.findRepositorySecretNames(ctx, repo.Name) + if err != nil { + return nil, err + } + + ociRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: r.namespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, r.client, ociRepo, func() error { + applyManagedMeta(ociRepo, expiresAt) + ociRepo.Spec.URL = repo.Spec.URL + ociRepo.Spec.Reference = &sourcev1.OCIRepositoryRef{Tag: req.Version} + ociRepo.Spec.Interval = r.sourceInterval + ociRepo.Spec.Insecure = repo.Spec.InsecureSkipVerify + ociRepo.Spec.LayerSelector = &sourcev1.OCILayerSelector{ + MediaType: helmChartLayerMediaType, + Operation: "copy", + } + + ociRepo.Spec.SecretRef = nil + ociRepo.Spec.CertSecretRef = nil + if repo.Spec.Auth != nil && authSecret != "" { + ociRepo.Spec.SecretRef = &meta.LocalObjectReference{Name: authSecret} + } + if repo.Spec.CACertificate != "" && tlsSecret != "" { + ociRepo.Spec.CertSecretRef = &meta.LocalObjectReference{Name: tlsSecret} + } + + return nil + }); err != nil { + return nil, fmt.Errorf("ensuring oci repository: %w", err) + } + + return ociRepo, nil +} + +func (r *Resolver) findHelmRepositoryName(ctx context.Context, repoName string) (string, error) { + var list sourcev1.HelmRepositoryList + if err := r.client.List( + ctx, &list, + client.InNamespace(r.namespace), + client.MatchingLabels{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repoName}, + ); err != nil { + return "", fmt.Errorf("listing helm repositories: %w", err) + } + + if len(list.Items) == 0 { + return "", nil + } + + return list.Items[0].Name, nil +} + +func (r *Resolver) findRepositorySecretNames(ctx context.Context, repoName string) (auth, tls string, err error) { + var list corev1.SecretList + if err := r.client.List( + ctx, &list, + client.InNamespace(r.namespace), + client.MatchingLabels{helmv1alpha1.HelmClusterAddonRepositoryLabelSourceName: repoName}, + ); err != nil { + return "", "", fmt.Errorf("listing repository secrets: %w", err) + } + + for i := range list.Items { + secret := &list.Items[i] + if _, ok := secret.Data["username"]; ok { + auth = secret.Name + } + if _, ok := secret.Data["ca.crt"]; ok { + tls = secret.Name + } + } + + return auth, tls, nil +} + +// classify maps the source resource conditions and artifact to an outcome. +// Only OutcomeReady, OutcomeFetchFailed and OutcomePending are returned here. +func classify(conditions []metav1.Condition, art *meta.Artifact) (Outcome, string) { + if ready := apimeta.FindStatusCondition(conditions, "Ready"); ready != nil && + ready.Status == metav1.ConditionTrue && art != nil { + return OutcomeReady, "" + } + + for _, conditionType := range []string{"FetchFailed", "StorageOperationFailed", "BuildFailed"} { + if c := apimeta.FindStatusCondition(conditions, conditionType); c != nil && c.Status == metav1.ConditionTrue { + return OutcomeFetchFailed, c.Message + } + } + + return OutcomePending, "" +} + +func applyManagedMeta(obj client.Object, expiresAt string) { + objLabels := obj.GetLabels() + if objLabels == nil { + objLabels = map[string]string{} + } + objLabels[labels.ManagedBy] = labels.ManagedByValue + obj.SetLabels(objLabels) + + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[labels.ExpiresAtAnnotation] = expiresAt + obj.SetAnnotations(annotations) +} + +func isOCI(url string) bool { + return strings.HasPrefix(url, "oci://") +} + +func isHelm(url string) bool { + return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") +} diff --git a/images/chart-values-controller/internal/server/server.go b/images/chart-values-controller/internal/server/server.go new file mode 100644 index 00000000..a884a3fc --- /dev/null +++ b/images/chart-values-controller/internal/server/server.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/deckhouse/chart-values-controller/internal/resolver" +) + +// retryAfterSeconds is advertised to clients while an artifact is still being +// prepared, so polling clients back off consistently. +const retryAfterSeconds = 3 + +type chartValuesResolver interface { + Resolve(ctx context.Context, req resolver.Request) (resolver.Result, error) +} + +// Server exposes the chart-values HTTP API as a controller-runtime Runnable. +// It does not require leader election so it can serve from any replica. +type Server struct { + addr string + resolver chartValuesResolver +} + +func New(addr string, res chartValuesResolver) *Server { + return &Server{addr: addr, resolver: res} +} + +// NeedLeaderElection reports that the HTTP server runs on every replica. +func (s *Server) NeedLeaderElection() bool { + return false +} + +func (s *Server) Start(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/chart-values", s.handleChartValues) + + srv := &http.Server{ + Addr: s.addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + <-ctx.Done() + // The manager context is already cancelled here, so shutdown must run on a + // fresh context with its own deadline. + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) //nolint:contextcheck // parent ctx is done, a fresh one is required for graceful shutdown + }() + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + + return nil +} + +type chartValuesRequest struct { + RepositoryKind string `json:"repositoryKind"` + RepositoryName string `json:"repositoryName"` + Chart string `json:"chart"` + Version string `json:"version"` +} + +func (s *Server) handleChartValues(w http.ResponseWriter, r *http.Request) { + logger := log.FromContext(r.Context()) + + var req chartValuesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "request body is not valid JSON") + return + } + + if req.RepositoryKind == "" || req.RepositoryName == "" || req.Chart == "" || req.Version == "" { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", + "repositoryKind, repositoryName, chart and version are required") + return + } + + result, err := s.resolver.Resolve(r.Context(), resolver.Request{ + Kind: resolver.RepositoryKind(req.RepositoryKind), + RepositoryName: req.RepositoryName, + Chart: req.Chart, + Version: req.Version, + }) + if err != nil { + logger.Error(err, "failed to resolve chart values") + writeError(w, http.StatusInternalServerError, "INTERNAL", "internal server error") + return + } + + switch result.Outcome { + case resolver.OutcomeReady: + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]string{"values.yaml": string(result.Values)}, + }) + case resolver.OutcomePending: + w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds)) + writeJSON(w, http.StatusAccepted, map[string]any{ + "status": "pending", + "code": "CHART_PENDING", + "message": "chart artifact is being prepared, retry later", + }) + case resolver.OutcomeRepositoryNotFound: + writeError(w, http.StatusNotFound, "REPOSITORY_NOT_FOUND", result.Message) + case resolver.OutcomeUnsupportedRepositoryKind: + writeError(w, http.StatusBadRequest, "UNSUPPORTED_REPOSITORY_KIND", result.Message) + case resolver.OutcomeValuesNotFound: + writeError(w, http.StatusUnprocessableEntity, "VALUES_NOT_FOUND", result.Message) + case resolver.OutcomeFetchFailed: + writeError(w, http.StatusBadGateway, "CHART_FETCH_FAILED", result.Message) + default: + logger.Info("unexpected resolve outcome", "outcome", result.Outcome) + writeError(w, http.StatusInternalServerError, "INTERNAL", "internal server error") + } +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]any{ + "error": message, + "code": code, + }) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/images/chart-values-controller/internal/server/server_test.go b/images/chart-values-controller/internal/server/server_test.go new file mode 100644 index 00000000..90a01b98 --- /dev/null +++ b/images/chart-values-controller/internal/server/server_test.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 Flant JSC. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/deckhouse/chart-values-controller/internal/resolver" +) + +type fakeResolver struct { + result resolver.Result + err error +} + +func (f fakeResolver) Resolve(_ context.Context, _ resolver.Request) (resolver.Result, error) { + return f.result, f.err +} + +func do(t *testing.T, res chartValuesResolver, body string) *httptest.ResponseRecorder { + t.Helper() + + srv := New("", res) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/chart-values", strings.NewReader(body)) + srv.handleChartValues(rec, req) + + return rec +} + +const validBody = `{"repositoryKind":"HelmClusterAddonRepository","repositoryName":"github","chart":"podinfo","version":"6.7.1"}` + +func TestHandleReady(t *testing.T) { + rec := do(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomeReady, Values: []byte("a: b\n")}}, validBody) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var resp struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Data["values.yaml"] != "a: b\n" { + t.Fatalf("values.yaml = %q", resp.Data["values.yaml"]) + } +} + +func TestHandlePending(t *testing.T) { + rec := do(t, fakeResolver{result: resolver.Result{Outcome: resolver.OutcomePending}}, validBody) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Fatal("missing Retry-After header") + } + assertCode(t, rec.Body.Bytes(), "code", "CHART_PENDING") +} + +func TestHandleOutcomeStatusCodes(t *testing.T) { + cases := []struct { + outcome resolver.Outcome + wantStatus int + wantCode string + }{ + {resolver.OutcomeRepositoryNotFound, http.StatusNotFound, "REPOSITORY_NOT_FOUND"}, + {resolver.OutcomeUnsupportedRepositoryKind, http.StatusBadRequest, "UNSUPPORTED_REPOSITORY_KIND"}, + {resolver.OutcomeValuesNotFound, http.StatusUnprocessableEntity, "VALUES_NOT_FOUND"}, + {resolver.OutcomeFetchFailed, http.StatusBadGateway, "CHART_FETCH_FAILED"}, + } + + for _, c := range cases { + rec := do(t, fakeResolver{result: resolver.Result{Outcome: c.outcome, Message: "detail"}}, validBody) + if rec.Code != c.wantStatus { + t.Fatalf("outcome %s: status = %d, want %d", c.outcome, rec.Code, c.wantStatus) + } + assertErrorCode(t, rec.Body.Bytes(), c.wantCode) + } +} + +func TestHandleInvalidJSON(t *testing.T) { + rec := do(t, fakeResolver{}, "not-json") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertErrorCode(t, rec.Body.Bytes(), "INVALID_REQUEST") +} + +func TestHandleMissingFields(t *testing.T) { + rec := do(t, fakeResolver{}, `{"repositoryName":"github","chart":"podinfo"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertErrorCode(t, rec.Body.Bytes(), "INVALID_REQUEST") +} + +func TestHandleInternalError(t *testing.T) { + rec := do(t, fakeResolver{err: context.DeadlineExceeded}, validBody) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + assertErrorCode(t, rec.Body.Bytes(), "INTERNAL") +} + +func assertErrorCode(t *testing.T, body []byte, want string) { + t.Helper() + + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Error.Code != want { + t.Fatalf("error code = %q, want %q", resp.Error.Code, want) + } +} + +func assertCode(t *testing.T, body []byte, field, want string) { + t.Helper() + + var resp map[string]any + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp[field] != want { + t.Fatalf("%s = %v, want %q", field, resp[field], want) + } +} diff --git a/images/chart-values-controller/mount-points.yaml b/images/chart-values-controller/mount-points.yaml new file mode 100644 index 00000000..eefff43e --- /dev/null +++ b/images/chart-values-controller/mount-points.yaml @@ -0,0 +1 @@ +dirs: [] diff --git a/images/chart-values-controller/werf.inc.yaml b/images/chart-values-controller/werf.inc.yaml new file mode 100644 index 00000000..7fb28e9a --- /dev/null +++ b/images/chart-values-controller/werf.inc.yaml @@ -0,0 +1,64 @@ +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact +final: false +fromImage: builder/src +git: +- add: {{ .ModuleDir }}/api + to: /src/api + stageDependencies: + install: + - go.mod + - go.sum + setup: + - "**/*.go" +- add: {{ .ModuleDir }}/images/{{ .ImageName }} + to: /src/images/{{ .ImageName }}-artifact + stageDependencies: + install: + - go.mod + - go.sum + setup: + - "**/*.go" +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact +final: false +fromImage: builder/golang +import: +- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-src-artifact + add: /src + to: /src + before: install +secrets: +- id: GOPROXY + value: {{ .GOPROXY }} +mount: + - fromPath: ~/go-pkg-cache + to: /go/pkg +shell: + install: + - cd /src/images/{{ .ImageName }}-artifact + - GOPROXY=$(cat /run/secrets/GOPROXY) go mod download + - mkdir /out + - export GOOS=linux + - export GOARCH=amd64 + - export CGO_ENABLED=0 + - | + echo "Build chart-values-controller binary" + {{- $_ := set $ "ProjectName" (list $.ImageName "chart-values-controller" | join "/") }} + + {{- $buildCommand := printf "go build -ldflags=\"-s -w\" -tags %s -v -a -o /out/chart-values-controller ./cmd/chart-values-controller" .MODULE_EDITION -}} + {{- include "image-build.build" (set $ "BuildCommand" $buildCommand) | nindent 4 }} +--- +image: {{ .ModuleNamePrefix }}{{ .ImageName }} +fromImage: base/distroless +git: + {{- include "image mount points" . }} +import: +- image: {{ .ModuleNamePrefix }}{{ .ImageName }}-artifact + add: /out/chart-values-controller + to: /app/chart-values-controller + after: install +imageSpec: + config: + workingDir: "/app" + entrypoint: ["/app/chart-values-controller"] diff --git a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go index b8ea9813..5017a45a 100644 --- a/images/hooks/pkg/hooks/tls-certificates-controller/hook.go +++ b/images/hooks/pkg/hooks/tls-certificates-controller/hook.go @@ -18,10 +18,9 @@ package tls_certificates_controller import ( "fmt" + "hooks/pkg/settings" tlscertificate "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" - - "hooks/pkg/settings" ) var _ = tlscertificate.RegisterInternalTLSHookEM(tlscertificate.GenSelfSignedTLSHookConf{ diff --git a/images/hooks/pkg/settings/certificate.go b/images/hooks/pkg/settings/certificate.go index 7c437fc2..46f1f0a3 100644 --- a/images/hooks/pkg/settings/certificate.go +++ b/images/hooks/pkg/settings/certificate.go @@ -17,5 +17,6 @@ limitations under the License. package settings const ( - ControllerCertCN string = "operator-helm-controller" + ControllerCertCN string = "operator-helm-controller" + ChartValuesControllerCertCN string = "chart-values-controller" ) diff --git a/openapi/values.yaml b/openapi/values.yaml index 47187f8f..0691814c 100644 --- a/openapi/values.yaml +++ b/openapi/values.yaml @@ -23,6 +23,23 @@ properties: key: type: string default: "" + chartValuesController: + type: object + default: {} + properties: + cert: + type: object + default: {} + properties: + ca: + type: string + default: "" + crt: + type: string + default: "" + key: + type: string + default: "" rootCA: type: object default: {} diff --git a/templates/admision-policy.yaml b/templates/admision-policy.yaml index 93393791..5b7b86a0 100644 --- a/templates/admision-policy.yaml +++ b/templates/admision-policy.yaml @@ -44,6 +44,7 @@ spec: "system:serviceaccount:d8-operator-helm:operator-helm-controller", "system:serviceaccount:d8-operator-helm:nelm-source-controller", "system:serviceaccount:d8-operator-helm:helm-controller", + "system:serviceaccount:d8-operator-helm:chart-values-controller", ] message: "Operation forbidden for this user." --- @@ -59,4 +60,4 @@ spec: matchResources: namespaceSelector: {} objectSelector: {} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/templates/chart-values-controller/deployment.yaml b/templates/chart-values-controller/deployment.yaml new file mode 100644 index 00000000..7cf8c422 --- /dev/null +++ b/templates/chart-values-controller/deployment.yaml @@ -0,0 +1,139 @@ +{{- $priorityClassName := include "priorityClassName" . }} + +{{- define "operator_helm_chart_values_controller_resources" }} +cpu: 50m +memory: 64Mi +{{- end }} + +{{- if (.Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} +--- +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: chart-values-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller" "workload-resource-policy.deckhouse.io" "master")) | nindent 2 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: chart-values-controller + updatePolicy: + updateMode: {{ include "vpa.policyUpdateMode" . }} + resourcePolicy: + containerPolicies: + {{- include "kube_api_rewriter.vpa_container_policy" . | nindent 4 }} + {{- include "kube_rbac_proxy.vpa_container_policy" . | nindent 4 }} + - containerName: chart-values-controller + minAllowed: + {{- include "operator_helm_chart_values_controller_resources" . | nindent 8 }} + maxAllowed: + cpu: 1000m + memory: 1Gi +{{- end }} + +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: chart-values-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller" )) | nindent 2 }} +spec: + minAvailable: {{ include "helm_lib_is_ha_to_value" (list . 1 0) }} + selector: + matchLabels: + app: chart-values-controller + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: chart-values-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller")) | nindent 2 }} +spec: + replicas: 1 + strategy: + type: Recreate + revisionHistoryLimit: 2 + selector: + matchLabels: + app: chart-values-controller + template: + metadata: + labels: + app: chart-values-controller + annotations: + kubectl.kubernetes.io/default-container: chart-values-controller + spec: + containers: + {{- include "kube_api_rewriter.sidecar_container" . | nindent 8 }} + - name: chart-values-controller + {{- include "helm_lib_module_container_security_context_read_only_root_filesystem_capabilities_drop_all_pss_restricted" . | nindent 10 }} + image: {{ include "helm_lib_module_image" (list . "chartValuesController") }} + imagePullPolicy: IfNotPresent + args: + - --metrics-bind-address=:8080 + - --health-probe-bind-address=:9440 + - --api-bind-address=127.0.0.1:8081 + - --cache-dir=/cache + - --chart-values-cache-ttl=1h + - --max-chart-size-mb=100 + volumeMounts: + - mountPath: /cache + name: cache + {{- include "kube_api_rewriter.kubeconfig_volume_mount" . | nindent 12 }} + ports: + - containerPort: 8080 + name: metrics + protocol: TCP + - containerPort: 8081 + name: api + protocol: TCP + - containerPort: 9440 + name: healthz + protocol: TCP + resources: + requests: + {{- include "helm_lib_module_ephemeral_storage_only_logs" . | nindent 14 }} + {{- if not ( .Values.global.enabledModules | has "vertical-pod-autoscaler-crd") }} + {{- include "operator_helm_chart_values_controller_resources" . | nindent 14 }} + {{- end }} + env: + {{- include "kube_api_rewriter.kubeconfig_env" . | nindent 12 }} + livenessProbe: + httpGet: + path: /healthz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: healthz + scheme: HTTP + initialDelaySeconds: 10 + {{- $kubeRbacProxySettings := dict }} + {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} + {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "listenPort" 8443 }} + {{- $_ := set $kubeRbacProxySettings "portName" "rbac-proxy" }} + {{- $_ := set $kubeRbacProxySettings "upstreams" (list + (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "chart-values-controller") + (dict "upstream" "http://127.0.0.1:8081/v1/chart-values" "path" "/v1/chart-values" "name" "chart-values-controller" "subresource" "api") + (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/healthz" "path" "/proxy/healthz" "name" "kube-api-rewriter") + (dict "upstream" "http://127.0.0.1:9090/readyz" "path" "/proxy/readyz" "name" "kube-api-rewriter") + ) }} + {{- include "kube_rbac_proxy.sidecar_container" (tuple . $kubeRbacProxySettings) | nindent 8 }} + dnsPolicy: ClusterFirst + serviceAccountName: chart-values-controller + {{- include "helm_lib_module_pod_security_context_run_as_user_deckhouse" . | nindent 6 }} + {{- include "helm_lib_priority_class" (tuple . $priorityClassName) | nindent 6 }} + {{- include "helm_lib_node_selector" (tuple . "system") | nindent 6 }} + {{- include "helm_lib_tolerations" (tuple . "system") | nindent 6 }} + volumes: + - name: cache + emptyDir: {} + {{- include "kube_api_rewriter.kubeconfig_volume" . | nindent 8 }} diff --git a/templates/chart-values-controller/rbac-for-us.yaml b/templates/chart-values-controller/rbac-for-us.yaml new file mode 100644 index 00000000..c447991f --- /dev/null +++ b/templates/chart-values-controller/rbac-for-us.yaml @@ -0,0 +1,88 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: chart-values-controller + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller")) | nindent 2 }} +imagePullSecrets: +- name: operator-helm-module-registry +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:chart-values-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: d8:{{ .Chart.Name }}:chart-values-controller +subjects: +- kind: ServiceAccount + name: chart-values-controller + namespace: d8-{{ .Chart.Name }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8:{{ .Chart.Name }}:chart-values-controller +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch +- apiGroups: + - helm.deckhouse.io + resources: + - helmclusteraddonrepositories + - helmclusteraddonrepositories/status + verbs: + - get + - list + - watch +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmcharts + - internalnelmoperatorhelmrepositories + - internalnelmoperatorocirepositories + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - source.internal.operator-helm.deckhouse.io + resources: + - internalnelmoperatorhelmcharts/status + - internalnelmoperatorhelmrepositories/status + - internalnelmoperatorocirepositories/status + verbs: + - get diff --git a/templates/chart-values-controller/service-metrics.yaml b/templates/chart-values-controller/service-metrics.yaml new file mode 100644 index 00000000..d5932bd8 --- /dev/null +++ b/templates/chart-values-controller/service-metrics.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: chart-values-controller-metrics + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller")) | nindent 2 }} +spec: + ports: + - name: metrics + port: 8080 + protocol: TCP + targetPort: rbac-proxy + selector: + app: chart-values-controller diff --git a/templates/chart-values-controller/service-monitor.yaml b/templates/chart-values-controller/service-monitor.yaml new file mode 100644 index 00000000..a743bd8b --- /dev/null +++ b/templates/chart-values-controller/service-monitor.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ .Chart.Name }}-chart-values-controller + namespace: d8-monitoring + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller" "prometheus" "main")) | nindent 2 }} +spec: + endpoints: + - bearerTokenSecret: + key: token + name: prometheus-token + path: /metrics + port: metrics + scheme: https + tlsConfig: + insecureSkipVerify: true + namespaceSelector: + matchNames: + - d8-{{ .Chart.Name }} + selector: + matchLabels: + app: "chart-values-controller" diff --git a/templates/chart-values-controller/service.yaml b/templates/chart-values-controller/service.yaml new file mode 100644 index 00000000..7ae63864 --- /dev/null +++ b/templates/chart-values-controller/service.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: chart-values-api + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list . (dict "app" "chart-values-controller")) | nindent 2 }} +spec: + ports: + - name: rbac-proxy + port: 443 + targetPort: rbac-proxy + protocol: TCP + selector: + app: chart-values-controller diff --git a/templates/helm-controller/deployment.yaml b/templates/helm-controller/deployment.yaml index 5add5f94..14927016 100644 --- a/templates/helm-controller/deployment.yaml +++ b/templates/helm-controller/deployment.yaml @@ -116,6 +116,8 @@ spec: {{- $kubeRbacProxySettings := dict }} {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "listenPort" 8443 }} + {{- $_ := set $kubeRbacProxySettings "portName" "rbac-proxy" }} {{- $_ := set $kubeRbacProxySettings "upstreams" (list (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "helm-controller") (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") diff --git a/templates/helm-controller/service-metrics.yaml b/templates/helm-controller/service-metrics.yaml index 1f2652c8..b2406930 100644 --- a/templates/helm-controller/service-metrics.yaml +++ b/templates/helm-controller/service-metrics.yaml @@ -10,6 +10,6 @@ spec: - name: metrics port: 8080 protocol: TCP - targetPort: https-metrics + targetPort: rbac-proxy selector: app: helm-controller diff --git a/templates/kube-api-rewriter/_sidecar_helpers.tpl b/templates/kube-api-rewriter/_sidecar_helpers.tpl index 2ae379c1..0dda0bf0 100644 --- a/templates/kube-api-rewriter/_sidecar_helpers.tpl +++ b/templates/kube-api-rewriter/_sidecar_helpers.tpl @@ -174,13 +174,13 @@ spec: livenessProbe: httpGet: path: /proxy/healthz - port: 8082 + port: 8443 scheme: HTTPS initialDelaySeconds: 10 readinessProbe: httpGet: path: /proxy/readyz - port: 8082 + port: 8443 scheme: HTTPS initialDelaySeconds: 10 terminationMessagePath: /dev/termination-log diff --git a/templates/kube-rbac-proxy/_helpers.tpl b/templates/kube-rbac-proxy/_helpers.tpl index ee21a1a8..b2e49201 100644 --- a/templates/kube-rbac-proxy/_helpers.tpl +++ b/templates/kube-rbac-proxy/_helpers.tpl @@ -13,7 +13,7 @@ terminationMessagePath: /dev/termination-log terminationMessagePolicy: File args: - - "--secure-listen-address=$(KUBE_RBAC_PROXY_LISTEN_ADDRESS):{{ $settings.listenPort | default "8082" }}" + - "--secure-listen-address=0.0.0.0:{{ $settings.listenPort | default "8082" }}" - "--v={{ $settings.logLevel | default "2" }}" - "--logtostderr=true" - "--stale-cache-interval={{ $settings.staleCacheInterval | default "1h30m" }}" @@ -21,11 +21,6 @@ - "--ignore-paths={{ $settings.ignorePaths }}" {{- end }} env: - - name: KUBE_RBAC_PROXY_LISTEN_ADDRESS - valueFrom: - fieldRef: - apiVersion: v1 - fieldPath: status.podIP - name: KUBE_RBAC_PROXY_CONFIG value: | excludePaths: diff --git a/templates/nelm-source-controller/deployment.yaml b/templates/nelm-source-controller/deployment.yaml index d53b00b3..450d1e12 100644 --- a/templates/nelm-source-controller/deployment.yaml +++ b/templates/nelm-source-controller/deployment.yaml @@ -120,6 +120,8 @@ spec: {{- $kubeRbacProxySettings := dict }} {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "listenPort" 8443 }} + {{- $_ := set $kubeRbacProxySettings "portName" "rbac-proxy" }} {{- $_ := set $kubeRbacProxySettings "upstreams" (list (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "nelm-source-controller") (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") diff --git a/templates/nelm-source-controller/service-metrics.yaml b/templates/nelm-source-controller/service-metrics.yaml index dff6d722..361f572b 100644 --- a/templates/nelm-source-controller/service-metrics.yaml +++ b/templates/nelm-source-controller/service-metrics.yaml @@ -10,6 +10,6 @@ spec: - name: metrics port: 8080 protocol: TCP - targetPort: https-metrics + targetPort: rbac-proxy selector: app: nelm-source-controller diff --git a/templates/operator-helm-controller/deployment.yaml b/templates/operator-helm-controller/deployment.yaml index 521a640a..ee259811 100644 --- a/templates/operator-helm-controller/deployment.yaml +++ b/templates/operator-helm-controller/deployment.yaml @@ -119,6 +119,8 @@ spec: {{- $kubeRbacProxySettings := dict }} {{- $_ := set $kubeRbacProxySettings "runAsUserNobody" false }} {{- $_ := set $kubeRbacProxySettings "ignorePaths" "/proxy/healthz,/proxy/readyz" }} + {{- $_ := set $kubeRbacProxySettings "listenPort" 8443 }} + {{- $_ := set $kubeRbacProxySettings "portName" "rbac-proxy" }} {{- $_ := set $kubeRbacProxySettings "upstreams" (list (dict "upstream" "http://127.0.0.1:8080/metrics" "path" "/metrics" "name" "operator-helm-controller") (dict "upstream" "http://127.0.0.1:9090/metrics" "path" "/proxy/metrics" "name" "kube-api-rewriter") diff --git a/templates/operator-helm-controller/service-metrics.yaml b/templates/operator-helm-controller/service-metrics.yaml index 89b93457..5a919c34 100644 --- a/templates/operator-helm-controller/service-metrics.yaml +++ b/templates/operator-helm-controller/service-metrics.yaml @@ -10,7 +10,7 @@ spec: - name: metrics port: 8080 protocol: TCP - targetPort: https-metrics + targetPort: rbac-proxy selector: app: operator-helm-controller diff --git a/templates/operator-helm-controller/service.yaml b/templates/operator-helm-controller/service.yaml index b1356b65..316f6445 100644 --- a/templates/operator-helm-controller/service.yaml +++ b/templates/operator-helm-controller/service.yaml @@ -11,9 +11,5 @@ spec: port: 443 targetPort: controller protocol: TCP - - name: controller - port: 9443 - targetPort: controller - protocol: TCP selector: app: operator-helm-controller diff --git a/templates/rbac-to-us.yaml b/templates/rbac-to-us.yaml index ad776f6d..c5e27764 100644 --- a/templates/rbac-to-us.yaml +++ b/templates/rbac-to-us.yaml @@ -1,28 +1,27 @@ +{{- if (.Values.global.enabledModules | has "prometheus") }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: - name: access-to-operator-helm + name: prometheus-access-to-operator-helm namespace: d8-{{ .Chart.Name }} {{- include "helm_lib_module_labels" (list .) | nindent 2 }} rules: - apiGroups: ["apps"] resources: ["deployments/prometheus-metrics"] - resourceNames: ["operator-helm-controller", "helm-controller", "nelm-source-controller"] + resourceNames: ["operator-helm-controller", "chart-values-controller", "helm-controller", "nelm-source-controller"] verbs: ["get"] - -{{- if (.Values.global.enabledModules | has "prometheus") }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: - name: access-to-operator-helm + name: prometheus-access-to-operator-helm namespace: d8-{{ .Chart.Name }} {{- include "helm_lib_module_labels" (list .) | nindent 2 }} roleRef: apiGroup: rbac.authorization.k8s.io kind: Role - name: access-to-operator-helm + name: prometheus-access-to-operator-helm subjects: - kind: User name: d8-monitoring:scraper @@ -30,3 +29,32 @@ subjects: name: prometheus namespace: d8-monitoring {{- end }} +{{- if (.Values.global.enabledModules | has "console") }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: console-access-to-operator-helm + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +rules: +- apiGroups: ["apps"] + resources: ["deployments/api"] + resourceNames: ["chart-values-controller"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: console-access-to-operator-helm + namespace: d8-{{ .Chart.Name }} + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: console-access-to-operator-helm +subjects: +- kind: ServiceAccount + name: backend + namespace: d8-console +{{- end }}