[platform] Add flux-plunger controller to fix HelmRelease errors (#1843)
## What this PR does
This PR introduces a new flux-plunger controller that automatically
fixes HelmRelease resources experiencing "has no deployed releases"
errors by cleaning up corrupted Helm release secrets.
### Problem
When Helm releases fail with "has no deployed releases" error, FluxCD
cannot recover automatically. This typically happens when:
- Helm release secrets are corrupted
- Failed upgrades leave the release in an inconsistent state
- Manual intervention is required to delete the problematic secret
### Solution
The flux-plunger controller:
1. Watches HelmRelease resources for "has no deployed releases" errors
2. Suspends the HelmRelease (with `flux-client-side-apply` field
manager)
3. Identifies and deletes the latest (problematic) Helm release secret
4. Tracks processed versions via annotation to prevent recursive
deletion
5. Unsuspends the HelmRelease to allow FluxCD to retry
**Key features:**
- Only deletes one secret at a time (prevents cascading deletions)
- Uses version tracking: if `latest+1 == processed`, skips (already
handled)
- Handles suspended HelmReleases: only unsuspends if it was suspended by
flux-plunger itself
- Gracefully handles invalid annotations by treating them as unprocessed
### Components
- **Controller:** `internal/controller/fluxplunger/flux_plunger.go`
- **Main:** `cmd/flux-plunger/main.go`
- **Helm Chart:** `packages/system/flux-plunger/`
- **Platform Integration:** Added to `paas-full.yaml` bundle
### Release note
```release-note
[platform] Add flux-plunger controller to automatically fix HelmRelease resources with "has no deployed releases" error by cleaning up corrupted Helm secrets
```
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
## Release Notes
* **New Features**
* Introduced a new controller that automatically resolves HelmRelease
resource failures with built-in health probes and graceful recovery
procedures.
* Enabled by default in system installations.
<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
commit
b0b1a1f1f9
12 changed files with 674 additions and 0 deletions
151
cmd/flux-plunger/main.go
Normal file
151
cmd/flux-plunger/main.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 (
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"os"
|
||||
|
||||
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
|
||||
// to ensure that exec-entrypoint and run can make use of them.
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
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/cozystack/cozystack/internal/controller/fluxplunger"
|
||||
// +kubebuilder:scaffold:imports
|
||||
)
|
||||
|
||||
var (
|
||||
scheme = runtime.NewScheme()
|
||||
setupLog = ctrl.Log.WithName("setup")
|
||||
)
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
utilruntime.Must(helmv2.AddToScheme(scheme))
|
||||
|
||||
// +kubebuilder:scaffold:scheme
|
||||
}
|
||||
|
||||
func main() {
|
||||
var metricsAddr string
|
||||
var enableLeaderElection bool
|
||||
var probeAddr string
|
||||
var secureMetrics bool
|
||||
var enableHTTP2 bool
|
||||
var tlsOpts []func(*tls.Config)
|
||||
|
||||
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
|
||||
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
|
||||
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
|
||||
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
|
||||
"Enable leader election for controller manager. "+
|
||||
"Enabling this will ensure there is only one active controller manager.")
|
||||
flag.BoolVar(&secureMetrics, "metrics-secure", true,
|
||||
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
|
||||
flag.BoolVar(&enableHTTP2, "enable-http2", false,
|
||||
"If set, HTTP/2 will be enabled for the metrics server")
|
||||
|
||||
opts := zap.Options{
|
||||
Development: false,
|
||||
}
|
||||
opts.BindFlags(flag.CommandLine)
|
||||
flag.Parse()
|
||||
|
||||
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||
|
||||
// if the enable-http2 flag is false (the default), http/2 should be disabled
|
||||
// due to its vulnerabilities. More specifically, disabling http/2 will
|
||||
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
|
||||
// Rapid Reset CVEs. For more information see:
|
||||
// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
|
||||
// - https://github.com/advisories/GHSA-4374-p667-p6c8
|
||||
disableHTTP2 := func(c *tls.Config) {
|
||||
setupLog.Info("disabling http/2")
|
||||
c.NextProtos = []string{"http/1.1"}
|
||||
}
|
||||
|
||||
if !enableHTTP2 {
|
||||
tlsOpts = append(tlsOpts, disableHTTP2)
|
||||
}
|
||||
|
||||
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
|
||||
// More info:
|
||||
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server
|
||||
// - https://book.kubebuilder.io/reference/metrics.html
|
||||
metricsServerOptions := metricsserver.Options{
|
||||
BindAddress: metricsAddr,
|
||||
SecureServing: secureMetrics,
|
||||
TLSOpts: tlsOpts,
|
||||
}
|
||||
|
||||
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Metrics: metricsServerOptions,
|
||||
HealthProbeBindAddress: probeAddr,
|
||||
LeaderElection: enableLeaderElection,
|
||||
LeaderElectionID: "flux-plunger.cozystack.io",
|
||||
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
|
||||
// when the Manager ends. This requires the binary to immediately end when the
|
||||
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
|
||||
// speeds up voluntary leader transitions as the new leader don't have to wait
|
||||
// LeaseDuration time first.
|
||||
//
|
||||
// In the default scaffold provided, the program ends immediately after
|
||||
// the manager stops, so would be fine to enable this option. However,
|
||||
// if you are doing or is intended to do any operation such as perform cleanups
|
||||
// after the manager stops then its usage might be unsafe.
|
||||
// LeaderElectionReleaseOnCancel: true,
|
||||
})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "unable to create manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&fluxplunger.FluxPlunger{
|
||||
Client: mgr.GetClient(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "FluxPlunger")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// +kubebuilder:scaffold:builder
|
||||
|
||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up health check")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up ready check")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
setupLog.Info("starting manager")
|
||||
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
|
||||
setupLog.Error(err, "problem running manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
333
internal/controller/fluxplunger/flux_plunger.go
Normal file
333
internal/controller/fluxplunger/flux_plunger.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package fluxplunger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
)
|
||||
|
||||
const (
|
||||
annotationLastProcessedVersion = "flux-plunger.cozystack.io/last-processed-version"
|
||||
errorMessageNoDeployedReleases = "has no deployed releases"
|
||||
fieldManager = "flux-client-side-apply"
|
||||
)
|
||||
|
||||
// FluxPlunger watches HelmRelease resources and fixes "has no deployed releases" errors
|
||||
type FluxPlunger struct {
|
||||
client.Client
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;delete
|
||||
|
||||
// Reconcile handles HelmRelease resources with "has no deployed releases" error
|
||||
func (r *FluxPlunger) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Get the HelmRelease
|
||||
hr := &helmv2.HelmRelease{}
|
||||
if err := r.Get(ctx, req.NamespacedName, hr); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
// Check if HelmRelease is suspended
|
||||
if hr.Spec.Suspend {
|
||||
logger.Info("HelmRelease is suspended, checking if we need to unsuspend")
|
||||
|
||||
// Get the list of Helm release secrets
|
||||
secrets, err := r.listHelmReleaseSecrets(ctx, hr.Namespace, hr.Name)
|
||||
if err != nil {
|
||||
logger.Error(err, "Failed to list Helm release secrets")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// If no secrets, treat latest version as 0
|
||||
latestVersion := 0
|
||||
if len(secrets) > 0 {
|
||||
latestSecret := getLatestSecret(secrets)
|
||||
latestVersion = extractVersionNumber(latestSecret.Name)
|
||||
} else {
|
||||
logger.Info("No Helm release secrets found while suspended, treating as version 0")
|
||||
}
|
||||
|
||||
// Check if version is previous to just processed (latestVersion+1 == processedVersion)
|
||||
// This is the ONLY condition when we unsuspend
|
||||
shouldUnsuspend := false
|
||||
if hr.Annotations != nil {
|
||||
if processedVersionStr, exists := hr.Annotations[annotationLastProcessedVersion]; exists {
|
||||
processedVersion, err := strconv.Atoi(processedVersionStr)
|
||||
if err == nil && latestVersion+1 == processedVersion {
|
||||
shouldUnsuspend = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shouldUnsuspend {
|
||||
// Unsuspend the HelmRelease
|
||||
logger.Info("Secret was already deleted in previous run, removing suspend", "latest", latestVersion, "processed", latestVersion+1)
|
||||
if err := r.unsuspendHelmRelease(ctx, hr); err != nil {
|
||||
logger.Info("Could not unsuspend HelmRelease, will retry on next reconcile", "error", err.Error())
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// If not previous to processed, skip all actions
|
||||
logger.Info("HelmRelease is suspended by external process, skipping", "latest", latestVersion)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Check if HelmRelease has the specific error
|
||||
if !hasNoDeployedReleasesError(hr) {
|
||||
logger.V(1).Info("HelmRelease does not have 'has no deployed releases' error, skipping")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
logger.Info("Detected HelmRelease with 'has no deployed releases' error")
|
||||
|
||||
// Get the list of Helm release secrets
|
||||
secrets, err := r.listHelmReleaseSecrets(ctx, hr.Namespace, hr.Name)
|
||||
if err != nil {
|
||||
logger.Error(err, "Failed to list Helm release secrets")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if len(secrets) == 0 {
|
||||
logger.Info("No Helm release secrets found, skipping")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Find the latest version
|
||||
latestSecret := getLatestSecret(secrets)
|
||||
latestVersion := extractVersionNumber(latestSecret.Name)
|
||||
|
||||
logger.Info("Found latest Helm release version", "version", latestVersion, "secret", latestSecret.Name)
|
||||
|
||||
// Check if we just processed the next version (current + 1 == processed)
|
||||
if hr.Annotations != nil {
|
||||
if processedVersionStr, exists := hr.Annotations[annotationLastProcessedVersion]; exists {
|
||||
processedVersion, err := strconv.Atoi(processedVersionStr)
|
||||
if err == nil {
|
||||
if latestVersion+1 == processedVersion {
|
||||
logger.Info("Already processed, secret was deleted previously", "latest", latestVersion, "processed", processedVersion)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
} else {
|
||||
// Failed to parse annotation, treat as if annotation doesn't exist
|
||||
logger.Info("Failed to parse annotation, will process", "annotation", processedVersionStr, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suspend the HelmRelease
|
||||
logger.Info("Suspending HelmRelease")
|
||||
if err := r.suspendHelmRelease(ctx, hr); err != nil {
|
||||
// Optimistic lock conflicts are normal - FluxCD also updates HelmRelease
|
||||
// Don't return error, just log and let controller-runtime requeue on next update
|
||||
logger.Info("Could not suspend HelmRelease, will retry on next reconcile", "error", err.Error())
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Delete the latest secret
|
||||
logger.Info("Deleting latest Helm release secret", "secret", latestSecret.Name)
|
||||
if err := r.Delete(ctx, &latestSecret); err != nil {
|
||||
logger.Error(err, "Failed to delete Helm release secret")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Update annotation with processed version
|
||||
logger.Info("Updating annotation with processed version", "version", latestVersion)
|
||||
if err := r.updateProcessedVersionAnnotation(ctx, hr, latestVersion); err != nil {
|
||||
logger.Info("Could not update annotation, will retry on next reconcile", "error", err.Error())
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Unsuspend the HelmRelease
|
||||
logger.Info("Unsuspending HelmRelease")
|
||||
if err := r.unsuspendHelmRelease(ctx, hr); err != nil {
|
||||
logger.Info("Could not unsuspend HelmRelease, will retry on next reconcile", "error", err.Error())
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
logger.Info("Successfully processed HelmRelease", "version", latestVersion)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// hasNoDeployedReleasesError checks if the HelmRelease has the specific error
|
||||
func hasNoDeployedReleasesError(hr *helmv2.HelmRelease) bool {
|
||||
for _, condition := range hr.Status.Conditions {
|
||||
if condition.Type == "Ready" && condition.Status == metav1.ConditionFalse {
|
||||
if strings.Contains(condition.Message, errorMessageNoDeployedReleases) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// listHelmReleaseSecrets lists all Helm release secrets for a specific release
|
||||
func (r *FluxPlunger) listHelmReleaseSecrets(ctx context.Context, namespace, releaseName string) ([]corev1.Secret, error) {
|
||||
secretList := &corev1.SecretList{}
|
||||
listOpts := []client.ListOption{
|
||||
client.InNamespace(namespace),
|
||||
client.MatchingLabels{
|
||||
"name": releaseName,
|
||||
"owner": "helm",
|
||||
},
|
||||
}
|
||||
|
||||
if err := r.List(ctx, secretList, listOpts...); err != nil {
|
||||
return nil, fmt.Errorf("failed to list secrets: %w", err)
|
||||
}
|
||||
|
||||
// Filter only helm.sh/release.v1 secrets
|
||||
filtered := []corev1.Secret{}
|
||||
for _, secret := range secretList.Items {
|
||||
if secret.Type == "helm.sh/release.v1" {
|
||||
filtered = append(filtered, secret)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// getLatestSecret returns the secret with the highest version number
|
||||
func getLatestSecret(secrets []corev1.Secret) corev1.Secret {
|
||||
if len(secrets) == 1 {
|
||||
return secrets[0]
|
||||
}
|
||||
|
||||
sort.Slice(secrets, func(i, j int) bool {
|
||||
vi := extractVersionNumber(secrets[i].Name)
|
||||
vj := extractVersionNumber(secrets[j].Name)
|
||||
return vi > vj
|
||||
})
|
||||
|
||||
return secrets[0]
|
||||
}
|
||||
|
||||
// extractVersionFromSecretName extracts version string from secret name
|
||||
// e.g., "sh.helm.release.v1.cozystack-resource-definitions.v10" -> "v10"
|
||||
func extractVersionFromSecretName(secretName string) string {
|
||||
parts := strings.Split(secretName, ".")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractVersionNumber extracts numeric version from secret name
|
||||
// e.g., "sh.helm.release.v1.cozystack-resource-definitions.v10" -> 10
|
||||
func extractVersionNumber(secretName string) int {
|
||||
version := extractVersionFromSecretName(secretName)
|
||||
// Remove 'v' prefix if present
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
num, err := strconv.Atoi(version)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
// suspendHelmRelease sets suspend to true on the HelmRelease
|
||||
func (r *FluxPlunger) suspendHelmRelease(ctx context.Context, hr *helmv2.HelmRelease) error {
|
||||
// Re-fetch the HelmRelease to get the latest state
|
||||
key := types.NamespacedName{Namespace: hr.Namespace, Name: hr.Name}
|
||||
latestHR := &helmv2.HelmRelease{}
|
||||
if err := r.Get(ctx, key, latestHR); err != nil {
|
||||
return fmt.Errorf("failed to get latest HelmRelease: %w", err)
|
||||
}
|
||||
|
||||
// If already suspended, nothing to do
|
||||
if latestHR.Spec.Suspend {
|
||||
return nil
|
||||
}
|
||||
|
||||
patch := client.MergeFromWithOptions(latestHR.DeepCopy(), client.MergeFromWithOptimisticLock{})
|
||||
latestHR.Spec.Suspend = true
|
||||
|
||||
return r.Patch(ctx, latestHR, patch, client.FieldOwner(fieldManager))
|
||||
}
|
||||
|
||||
// unsuspendHelmRelease sets suspend to false on the HelmRelease
|
||||
func (r *FluxPlunger) unsuspendHelmRelease(ctx context.Context, hr *helmv2.HelmRelease) error {
|
||||
// Re-fetch the HelmRelease to get the latest state
|
||||
key := types.NamespacedName{Namespace: hr.Namespace, Name: hr.Name}
|
||||
latestHR := &helmv2.HelmRelease{}
|
||||
if err := r.Get(ctx, key, latestHR); err != nil {
|
||||
return fmt.Errorf("failed to get latest HelmRelease: %w", err)
|
||||
}
|
||||
|
||||
// If already unsuspended, nothing to do
|
||||
if !latestHR.Spec.Suspend {
|
||||
return nil
|
||||
}
|
||||
|
||||
patch := client.MergeFromWithOptions(latestHR.DeepCopy(), client.MergeFromWithOptimisticLock{})
|
||||
latestHR.Spec.Suspend = false
|
||||
|
||||
return r.Patch(ctx, latestHR, patch, client.FieldOwner(fieldManager))
|
||||
}
|
||||
|
||||
// updateProcessedVersionAnnotation updates the annotation with the processed version
|
||||
func (r *FluxPlunger) updateProcessedVersionAnnotation(ctx context.Context, hr *helmv2.HelmRelease, version int) error {
|
||||
// Re-fetch the HelmRelease to get the latest state
|
||||
key := types.NamespacedName{Namespace: hr.Namespace, Name: hr.Name}
|
||||
latestHR := &helmv2.HelmRelease{}
|
||||
if err := r.Get(ctx, key, latestHR); err != nil {
|
||||
return fmt.Errorf("failed to get latest HelmRelease: %w", err)
|
||||
}
|
||||
|
||||
patch := client.MergeFromWithOptions(latestHR.DeepCopy(), client.MergeFromWithOptimisticLock{})
|
||||
|
||||
if latestHR.Annotations == nil {
|
||||
latestHR.Annotations = make(map[string]string)
|
||||
}
|
||||
latestHR.Annotations[annotationLastProcessedVersion] = strconv.Itoa(version)
|
||||
|
||||
return r.Patch(ctx, latestHR, patch, client.FieldOwner(fieldManager))
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager
|
||||
func (r *FluxPlunger) SetupWithManager(mgr ctrl.Manager) error {
|
||||
// Watch HelmReleases that either:
|
||||
// 1. Have the specific error, OR
|
||||
// 2. Are suspended with our annotation (to handle crash recovery)
|
||||
pred := predicate.NewPredicateFuncs(func(obj client.Object) bool {
|
||||
hr, ok := obj.(*helmv2.HelmRelease)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Always process if has error
|
||||
if hasNoDeployedReleasesError(hr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also process suspended HelmReleases with our annotation (crash recovery)
|
||||
if hr.Spec.Suspend && hr.Annotations != nil {
|
||||
if _, exists := hr.Annotations[annotationLastProcessedVersion]; exists {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
Named("fluxplunger").
|
||||
For(&helmv2.HelmRelease{}).
|
||||
WithEventFilter(pred).
|
||||
Complete(r)
|
||||
}
|
||||
22
packages/core/platform/sources/flux-plunger.yaml
Normal file
22
packages/core/platform/sources/flux-plunger.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
apiVersion: cozystack.io/v1alpha1
|
||||
kind: PackageSource
|
||||
metadata:
|
||||
name: cozystack.flux-plunger
|
||||
spec:
|
||||
sourceRef:
|
||||
kind: OCIRepository
|
||||
name: cozystack-packages
|
||||
namespace: cozy-system
|
||||
path: /
|
||||
variants:
|
||||
- name: default
|
||||
dependsOn:
|
||||
- cozystack.cert-manager
|
||||
- cozystack.cozystack-engine
|
||||
components:
|
||||
- name: flux-plunger
|
||||
path: system/flux-plunger
|
||||
install:
|
||||
namespace: cozy-fluxcd
|
||||
releaseName: flux-plunger
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
|
||||
# Common Packages
|
||||
{{include "cozystack.platform.package.default" (list "cozystack.cert-manager" $) }}
|
||||
{{include "cozystack.platform.package.default" (list "cozystack.flux-plunger" $) }}
|
||||
{{include "cozystack.platform.package.default" (list "cozystack.victoria-metrics-operator" $) }}
|
||||
{{- $tenantComponents := dict -}}
|
||||
{{- $tenantClusterValues := dict "_cluster" (dict "oidc-enabled" (ternary "true" "false" .Values.authentication.oidc.enabled)) -}}
|
||||
|
|
|
|||
6
packages/system/flux-plunger/Chart.yaml
Normal file
6
packages/system/flux-plunger/Chart.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: v2
|
||||
name: cozy-flux-plunger
|
||||
description: Controller that automatically fixes HelmRelease resources with "has no deployed releases" error
|
||||
type: application
|
||||
version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process
|
||||
appVersion: "1.0.0"
|
||||
19
packages/system/flux-plunger/Makefile
Normal file
19
packages/system/flux-plunger/Makefile
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export NAME=flux-plunger
|
||||
export NAMESPACE=cozy-fluxcd
|
||||
|
||||
include ../../../scripts/common-envs.mk
|
||||
include ../../../scripts/package.mk
|
||||
|
||||
image:
|
||||
docker buildx build -f images/flux-plunger/Dockerfile ../../../ \
|
||||
--provenance false \
|
||||
--tag $(REGISTRY)/flux-plunger:$(call settag,$(TAG)) \
|
||||
--cache-from type=registry,ref=$(REGISTRY)/flux-plunger:latest \
|
||||
--cache-to type=inline \
|
||||
--metadata-file images/flux-plunger.json \
|
||||
--push=$(PUSH) \
|
||||
--label "org.opencontainers.image.source=https://github.com/cozystack/cozystack" \
|
||||
--load=$(LOAD)
|
||||
IMAGE="$(REGISTRY)/flux-plunger:$(call settag,$(TAG))@$$(yq e '."containerimage.digest"' images/flux-plunger.json -o json -r)" \
|
||||
yq -i '.image = strenv(IMAGE)' values.yaml
|
||||
rm -f images/flux-plunger.json
|
||||
23
packages/system/flux-plunger/images/flux-plunger/Dockerfile
Normal file
23
packages/system/flux-plunger/images/flux-plunger/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go mod download
|
||||
|
||||
COPY pkg pkg/
|
||||
COPY cmd cmd/
|
||||
COPY internal internal/
|
||||
COPY api api/
|
||||
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o /flux-plunger cmd/flux-plunger/main.go
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /flux-plunger /flux-plunger
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
ENTRYPOINT ["/flux-plunger"]
|
||||
55
packages/system/flux-plunger/templates/deployment.yaml
Normal file
55
packages/system/flux-plunger/templates/deployment.yaml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: flux-plunger
|
||||
labels:
|
||||
app.kubernetes.io/name: flux-plunger
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: flux-plunger
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: flux-plunger
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
serviceAccountName: flux-plunger
|
||||
containers:
|
||||
- name: flux-plunger
|
||||
image: "{{ .Values.image }}"
|
||||
args:
|
||||
{{- if .Values.debug }}
|
||||
- --zap-log-level=debug
|
||||
{{- else }}
|
||||
- --zap-log-level=info
|
||||
{{- end }}
|
||||
- --metrics-bind-address=:8080
|
||||
- --metrics-secure=false
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 8080
|
||||
- name: health
|
||||
containerPort: 8081
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: health
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: health
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 128Mi
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 64Mi
|
||||
37
packages/system/flux-plunger/templates/rbac.yaml
Normal file
37
packages/system/flux-plunger/templates/rbac.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: flux-plunger
|
||||
rules:
|
||||
- apiGroups:
|
||||
- helm.toolkit.fluxcd.io
|
||||
resources:
|
||||
- helmreleases
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
- update
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- secrets
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- delete
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: flux-plunger
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: flux-plunger
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: flux-plunger
|
||||
namespace: {{ .Release.Namespace }}
|
||||
18
packages/system/flux-plunger/templates/service.yaml
Normal file
18
packages/system/flux-plunger/templates/service.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: flux-plunger
|
||||
labels:
|
||||
app.kubernetes.io/name: flux-plunger
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: flux-plunger
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
ports:
|
||||
- name: metrics
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
- name: health
|
||||
port: 8081
|
||||
targetPort: 8081
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
apiVersion: v1
|
||||
automountServiceAccountToken: true
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: flux-plunger
|
||||
labels:
|
||||
app.kubernetes.io/name: flux-plunger
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
1
packages/system/flux-plunger/values.yaml
Normal file
1
packages/system/flux-plunger/values.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
image: "ghcr.io/cozystack/cozystack/flux-plunger:latest@sha256:6a6ec938973e6583c5e1573f7d25beddc1852a6699a089116b2407e29a564a72"
|
||||
Loading…
Add table
Add a link
Reference in a new issue