automatic image updates from public repos with selected tags feature added
This commit is contained in:
220
internal/controller/imageupdate_controller.go
Normal file
220
internal/controller/imageupdate_controller.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// internal/controller/imageupdate_controller.go
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/tools/record"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
configv1alpha1 "git.vendetti.ru/andy/operator/api/v1alpha1"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/crane"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultRestartAnnotation = "andy.vendetti.ru/restartedAt"
|
||||
)
|
||||
|
||||
// ImageUpdateReconciler reconciles Deployment objects to check for image updates.
|
||||
type ImageUpdateReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Recorder record.EventRecorder
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;update;patch
|
||||
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups=operator.andy.vendetti.ru,resources=nodetainterconfigs,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
|
||||
|
||||
func (r *ImageUpdateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
log := log.FromContext(ctx).WithValues("deployment", req.NamespacedName)
|
||||
|
||||
var config configv1alpha1.NodeTainterConfig
|
||||
configKey := types.NamespacedName{Name: GlobalTaintConfigName}
|
||||
if err := r.Get(ctx, configKey, &config); err != nil {
|
||||
if !errors.IsNotFound(err) {
|
||||
log.Error(err, "Failed to get NodeTainterConfig for image updates", "configName", GlobalTaintConfigName)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
log.V(1).Info("Global NodeTainterConfig not found, image update checks skipped.", "configName", GlobalTaintConfigName)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if config.Spec.ImageUpdatePolicy == nil || !config.Spec.ImageUpdatePolicy.Enabled {
|
||||
log.V(1).Info("Image update policy is disabled in NodeTainterConfig.")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
policy := config.Spec.ImageUpdatePolicy
|
||||
if len(policy.MonitoredTags) == 0 {
|
||||
log.V(1).Info("No monitored tags configured in ImageUpdatePolicy.")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
var deployment appsv1.Deployment
|
||||
if err := r.Get(ctx, req.NamespacedName, &deployment); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
log.Info("Deployment not found. Ignoring.")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
log.Error(err, "Failed to get Deployment")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
log.V(1).Info("Checking deployment for image updates")
|
||||
needsRestart := false
|
||||
restartAnnotation := policy.RestartAnnotation
|
||||
if restartAnnotation == "" {
|
||||
restartAnnotation = DefaultRestartAnnotation
|
||||
}
|
||||
|
||||
podList, err := r.findPodsForDeployment(ctx, &deployment)
|
||||
if err != nil {
|
||||
log.Error(err, "Failed to list pods for deployment")
|
||||
return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil
|
||||
}
|
||||
|
||||
for _, container := range deployment.Spec.Template.Spec.Containers {
|
||||
containerLog := log.WithValues("container", container.Name, "image", container.Image)
|
||||
|
||||
imageName, imageTag, isMonitored := r.parseImageAndCheckTag(container.Image, policy.MonitoredTags)
|
||||
if !isMonitored {
|
||||
containerLog.V(1).Info("Image tag is not monitored, skipping.")
|
||||
continue
|
||||
}
|
||||
|
||||
currentDigest, err := r.findCurrentImageDigest(container.Name, podList)
|
||||
if err != nil {
|
||||
containerLog.Error(err, "Could not determine current image digest from running pods")
|
||||
continue
|
||||
}
|
||||
if currentDigest == "" {
|
||||
containerLog.V(1).Info("No running pods found with imageID for this container, skipping check.")
|
||||
continue
|
||||
}
|
||||
containerLog = containerLog.WithValues("currentDigest", currentDigest)
|
||||
|
||||
latestDigest, err := crane.Digest(imageName + ":" + imageTag)
|
||||
if err != nil {
|
||||
containerLog.Error(err, "Failed to get latest digest from registry", "image", imageName+":"+imageTag)
|
||||
r.Recorder.Eventf(&deployment, corev1.EventTypeWarning, "RegistryError", "Failed to fetch digest for %s:%s: %v", imageName, imageTag, err)
|
||||
continue
|
||||
}
|
||||
containerLog = containerLog.WithValues("latestDigest", latestDigest)
|
||||
|
||||
if currentDigest != latestDigest {
|
||||
containerLog.Info("Image update detected!", "image", imageName+":"+imageTag)
|
||||
r.Recorder.Eventf(&deployment, corev1.EventTypeNormal, "UpdateAvailable", "New digest %s detected for image %s:%s (current: %s)", latestDigest, imageName, imageTag, currentDigest)
|
||||
needsRestart = true
|
||||
break
|
||||
} else {
|
||||
containerLog.V(1).Info("Image is up-to-date.")
|
||||
}
|
||||
}
|
||||
|
||||
if needsRestart {
|
||||
deploymentCopy := deployment.DeepCopy()
|
||||
if deploymentCopy.Spec.Template.Annotations == nil {
|
||||
deploymentCopy.Spec.Template.Annotations = make(map[string]string)
|
||||
}
|
||||
restartValue := time.Now().Format(time.RFC3339)
|
||||
deploymentCopy.Spec.Template.Annotations[restartAnnotation] = restartValue
|
||||
|
||||
log.Info("Triggering deployment restart due to image update", "annotationKey", restartAnnotation, "annotationValue", restartValue)
|
||||
if err := r.Patch(ctx, deploymentCopy, client.MergeFrom(&deployment)); err != nil {
|
||||
log.Error(err, "Failed to patch Deployment to trigger restart")
|
||||
r.Recorder.Eventf(&deployment, corev1.EventTypeWarning, "UpdateFailed", "Failed to trigger restart: %v", err)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
log.Info("Deployment patched successfully to trigger restart.")
|
||||
r.Recorder.Eventf(&deployment, corev1.EventTypeNormal, "RestartTriggered", "Triggered restart due to updated image")
|
||||
}
|
||||
|
||||
checkInterval, err := time.ParseDuration(policy.CheckInterval)
|
||||
if err != nil {
|
||||
log.Error(err, "Failed to parse CheckInterval from config, using default 1h", "configuredInterval", policy.CheckInterval)
|
||||
checkInterval = time.Hour // Fallback
|
||||
}
|
||||
|
||||
log.V(1).Info("Requeuing deployment for next check", "after", checkInterval.String())
|
||||
return ctrl.Result{RequeueAfter: checkInterval}, nil
|
||||
}
|
||||
|
||||
func (r *ImageUpdateReconciler) parseImageAndCheckTag(image string, monitoredTags []string) (imgName, imgTag string, monitored bool) {
|
||||
ref, err := name.ParseReference(image, name.WeakValidation)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
imgName = ref.Context().Name()
|
||||
imgTag = ref.Identifier()
|
||||
|
||||
if strings.HasPrefix(imgTag, "sha256:") {
|
||||
return imgName, imgTag, false
|
||||
}
|
||||
|
||||
for _, monitoredKeyword := range monitoredTags {
|
||||
if strings.Contains(imgTag, monitoredKeyword) {
|
||||
return imgName, imgTag, true
|
||||
}
|
||||
}
|
||||
|
||||
return imgName, imgTag, false
|
||||
}
|
||||
|
||||
func (r *ImageUpdateReconciler) findPodsForDeployment(ctx context.Context, deployment *appsv1.Deployment) (*corev1.PodList, error) {
|
||||
selector, err := metav1.LabelSelectorAsSelector(deployment.Spec.Selector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert deployment selector: %w", err)
|
||||
}
|
||||
|
||||
podList := &corev1.PodList{}
|
||||
err = r.List(ctx, podList, client.InNamespace(deployment.Namespace), client.MatchingLabelsSelector{Selector: selector})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
return podList, nil
|
||||
}
|
||||
|
||||
func (r *ImageUpdateReconciler) findCurrentImageDigest(containerName string, podList *corev1.PodList) (string, error) {
|
||||
for _, pod := range podList.Items {
|
||||
if pod.Status.Phase != corev1.PodRunning && pod.Status.Phase != corev1.PodPending {
|
||||
continue
|
||||
}
|
||||
for _, cs := range pod.Status.ContainerStatuses {
|
||||
if cs.Name == containerName && cs.ImageID != "" {
|
||||
parts := strings.SplitN(cs.ImageID, "@", 2)
|
||||
if len(parts) == 2 && strings.HasPrefix(parts[1], "sha256:") {
|
||||
return parts[1], nil
|
||||
}
|
||||
if strings.HasPrefix(cs.ImageID, "sha256:") {
|
||||
return cs.ImageID, nil
|
||||
}
|
||||
return "", fmt.Errorf("unrecognized imageID format in pod %s: %s", pod.Name, cs.ImageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (r *ImageUpdateReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
r.Recorder = mgr.GetEventRecorderFor("imageupdate-controller")
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&appsv1.Deployment{}).
|
||||
Complete(r)
|
||||
}
|
Reference in New Issue
Block a user