436 lines
14 KiB
Go
436 lines
14 KiB
Go
/*
|
|
Copyright 2020 The Flux authors
|
|
|
|
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 controllers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"math"
|
|
"os"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
gogit "github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
"github.com/go-logr/logr"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
kuberecorder "k8s.io/client-go/tools/record"
|
|
"k8s.io/client-go/tools/reference"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/handler"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
"sigs.k8s.io/controller-runtime/pkg/source"
|
|
|
|
imagev1_reflect "github.com/fluxcd/image-reflector-controller/api/v1alpha1"
|
|
"github.com/fluxcd/pkg/apis/meta"
|
|
"github.com/fluxcd/pkg/runtime/events"
|
|
"github.com/fluxcd/pkg/runtime/metrics"
|
|
"github.com/fluxcd/pkg/runtime/predicates"
|
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
|
|
"github.com/fluxcd/source-controller/pkg/git"
|
|
|
|
imagev1 "github.com/fluxcd/image-automation-controller/api/v1alpha1"
|
|
"github.com/fluxcd/image-automation-controller/pkg/update"
|
|
)
|
|
|
|
const defaultInterval = 2 * time.Minute
|
|
|
|
// log level for debug info
|
|
const debug = 1
|
|
const originRemote = "origin"
|
|
|
|
const defaultMessageTemplate = `Update from image update automation`
|
|
|
|
const repoRefKey = ".spec.gitRepository"
|
|
const imagePolicyKey = ".spec.update.imagePolicy"
|
|
|
|
// ImageUpdateAutomationReconciler reconciles a ImageUpdateAutomation object
|
|
type ImageUpdateAutomationReconciler struct {
|
|
client.Client
|
|
Log logr.Logger
|
|
Scheme *runtime.Scheme
|
|
EventRecorder kuberecorder.EventRecorder
|
|
ExternalEventRecorder *events.Recorder
|
|
MetricsRecorder *metrics.Recorder
|
|
}
|
|
|
|
// +kubebuilder:rbac:groups=image.toolkit.fluxcd.io,resources=imageupdateautomations,verbs=get;list;watch;create;update;patch;delete
|
|
// +kubebuilder:rbac:groups=image.toolkit.fluxcd.io,resources=imageupdateautomations/status,verbs=get;update;patch
|
|
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=gitrepositories,verbs=get;list;watch
|
|
|
|
func (r *ImageUpdateAutomationReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
|
|
ctx := context.Background()
|
|
log := r.Log.WithValues("imageupdateautomation", req.NamespacedName)
|
|
now := time.Now()
|
|
|
|
var auto imagev1.ImageUpdateAutomation
|
|
if err := r.Get(ctx, req.NamespacedName, &auto); err != nil {
|
|
return ctrl.Result{}, client.IgnoreNotFound(err)
|
|
}
|
|
|
|
if auto.Spec.Suspend {
|
|
log.Info("ImageUpdateAutomation is suspended, skipping automation run")
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
// Record readiness metric when exiting; if there's any points at
|
|
// which the readiness is updated _without also exiting_, they
|
|
// should also record the readiness.
|
|
defer r.recordReadinessMetric(&auto)
|
|
// Record reconciliation duration when exiting
|
|
if r.MetricsRecorder != nil {
|
|
objRef, err := reference.GetReference(r.Scheme, &auto)
|
|
if err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
defer r.MetricsRecorder.RecordDuration(*objRef, now)
|
|
}
|
|
|
|
// whatever else happens, we've now "seen" the reconcile
|
|
// annotation if it's there
|
|
if token, ok := meta.ReconcileAnnotationValue(auto.GetAnnotations()); ok {
|
|
auto.Status.SetLastHandledReconcileRequest(token)
|
|
if err := r.Status().Update(ctx, &auto); err != nil {
|
|
return ctrl.Result{Requeue: true}, err
|
|
}
|
|
}
|
|
|
|
// failWithError is a helper for bailing on the reconciliation.
|
|
failWithError := func(err error) (ctrl.Result, error) {
|
|
r.event(auto, events.EventSeverityError, err.Error())
|
|
imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, meta.ReconciliationFailedReason, err.Error())
|
|
if err := r.Status().Update(ctx, &auto); err != nil {
|
|
log.Error(err, "failed to reconcile")
|
|
}
|
|
return ctrl.Result{Requeue: true}, err
|
|
}
|
|
|
|
// get the git repository object so it can be checked out
|
|
var origin sourcev1.GitRepository
|
|
originName := types.NamespacedName{
|
|
Name: auto.Spec.Checkout.GitRepositoryRef.Name,
|
|
Namespace: auto.GetNamespace(),
|
|
}
|
|
if err := r.Get(ctx, originName, &origin); err != nil {
|
|
if client.IgnoreNotFound(err) == nil {
|
|
imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, imagev1.GitNotAvailableReason, "referenced git repository is missing")
|
|
log.Error(err, "referenced git repository does not exist")
|
|
if err := r.Status().Update(ctx, &auto); err != nil {
|
|
return ctrl.Result{Requeue: true}, err
|
|
}
|
|
return ctrl.Result{}, nil // and assume we'll hear about it when it arrives
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
log.V(debug).Info("found git repository", "gitrepository", originName)
|
|
|
|
tmp, err := ioutil.TempDir("", fmt.Sprintf("%s-%s", originName.Namespace, originName.Name))
|
|
if err != nil {
|
|
return failWithError(err)
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
// FIXME use context with deadline for at least the following ops
|
|
|
|
access, err := r.getRepoAccess(ctx, &origin)
|
|
if err != nil {
|
|
return failWithError(err)
|
|
}
|
|
|
|
var repo *gogit.Repository
|
|
if repo, err = cloneInto(ctx, access, auto.Spec.Checkout.Branch, tmp); err != nil {
|
|
return failWithError(err)
|
|
}
|
|
|
|
log.V(debug).Info("cloned git repository", "gitrepository", originName, "branch", auto.Spec.Checkout.Branch, "working", tmp)
|
|
|
|
updateStrat := auto.Spec.Update
|
|
switch {
|
|
case updateStrat.Setters != nil:
|
|
// For setters we first want to compile a list of _all_ the
|
|
// policies in the same namespace (maybe in the future this
|
|
// could be filtered by the automation object).
|
|
var policies imagev1_reflect.ImagePolicyList
|
|
if err := r.List(ctx, &policies, &client.ListOptions{Namespace: req.NamespacedName.Namespace}); err != nil {
|
|
return failWithError(err)
|
|
}
|
|
|
|
if err := updateAccordingToSetters(ctx, tmp, policies.Items); err != nil {
|
|
return failWithError(err)
|
|
}
|
|
default:
|
|
log.Info("no update strategy given in the spec")
|
|
// no sense rescheduling until this resource changes
|
|
r.event(auto, events.EventSeverityInfo, "no update strategy in spec, failing trivially")
|
|
imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionFalse, imagev1.NoStrategyReason, "no update strategy is given for object")
|
|
err := r.Status().Update(ctx, &auto)
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
log.V(debug).Info("ran updates to working dir", "working", tmp)
|
|
|
|
var statusMessage string
|
|
|
|
if rev, err := commitAllAndPush(ctx, repo, access, &auto.Spec.Commit); err != nil {
|
|
if err == errNoChanges {
|
|
r.event(auto, events.EventSeverityInfo, "no updates made")
|
|
log.V(debug).Info("no changes made in working directory; no commit")
|
|
statusMessage = "no updates made"
|
|
} else {
|
|
return failWithError(err)
|
|
}
|
|
} else {
|
|
r.event(auto, events.EventSeverityInfo, "committed and pushed change "+rev)
|
|
log.Info("pushed commit to origin", "revision", rev)
|
|
statusMessage = "committed and pushed " + rev
|
|
}
|
|
|
|
// Getting to here is a successful run.
|
|
auto.Status.LastAutomationRunTime = &metav1.Time{Time: now}
|
|
imagev1.SetImageUpdateAutomationReadiness(&auto, metav1.ConditionTrue, meta.ReconciliationSucceededReason, statusMessage)
|
|
if err = r.Status().Update(ctx, &auto); err != nil {
|
|
return ctrl.Result{Requeue: true}, err
|
|
}
|
|
|
|
// We're either in this method because something changed, or this
|
|
// object got requeued. Either way, once successful, we don't need
|
|
// to see the object again until Interval has passed, or something
|
|
// changes again.
|
|
|
|
interval := intervalOrDefault(&auto)
|
|
return ctrl.Result{RequeueAfter: interval}, nil
|
|
}
|
|
|
|
func (r *ImageUpdateAutomationReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|
ctx := context.Background()
|
|
// Index the git repository object that each I-U-A refers to
|
|
if err := mgr.GetFieldIndexer().IndexField(ctx, &imagev1.ImageUpdateAutomation{}, repoRefKey, func(obj runtime.Object) []string {
|
|
updater := obj.(*imagev1.ImageUpdateAutomation)
|
|
ref := updater.Spec.Checkout.GitRepositoryRef
|
|
return []string{ref.Name}
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
For(&imagev1.ImageUpdateAutomation{}).
|
|
WithEventFilter(predicates.ChangePredicate{}).
|
|
Watches(&source.Kind{Type: &sourcev1.GitRepository{}},
|
|
&handler.EnqueueRequestsFromMapFunc{
|
|
ToRequests: handler.ToRequestsFunc(r.automationsForGitRepo),
|
|
}).
|
|
Complete(r)
|
|
}
|
|
|
|
// intervalOrDefault gives the interval specified, or if missing, the default
|
|
func intervalOrDefault(auto *imagev1.ImageUpdateAutomation) time.Duration {
|
|
if auto.Spec.RunInterval != nil {
|
|
return auto.Spec.RunInterval.Duration
|
|
}
|
|
return defaultInterval
|
|
}
|
|
|
|
// durationSinceLastRun calculates how long it's been since the last
|
|
// time the automation ran (which you can then use to find how long to
|
|
// wait until the next run).
|
|
func durationSinceLastRun(auto *imagev1.ImageUpdateAutomation, now time.Time) time.Duration {
|
|
last := auto.Status.LastAutomationRunTime
|
|
if last == nil {
|
|
return time.Duration(math.MaxInt64) // a fairly long time
|
|
}
|
|
return now.Sub(last.Time)
|
|
}
|
|
|
|
// automationsForGitRepo fetches all the automations that refer to a
|
|
// particular source.GitRepository object.
|
|
func (r *ImageUpdateAutomationReconciler) automationsForGitRepo(obj handler.MapObject) []reconcile.Request {
|
|
ctx := context.Background()
|
|
var autoList imagev1.ImageUpdateAutomationList
|
|
if err := r.List(ctx, &autoList, client.InNamespace(obj.Meta.GetNamespace()), client.MatchingFields{repoRefKey: obj.Meta.GetName()}); err != nil {
|
|
r.Log.Error(err, "failed to list ImageUpdateAutomations for GitRepository", "name", types.NamespacedName{
|
|
Name: obj.Meta.GetName(),
|
|
Namespace: obj.Meta.GetNamespace(),
|
|
})
|
|
return nil
|
|
}
|
|
reqs := make([]reconcile.Request, len(autoList.Items), len(autoList.Items))
|
|
for i := range autoList.Items {
|
|
reqs[i].NamespacedName.Name = autoList.Items[i].GetName()
|
|
reqs[i].NamespacedName.Namespace = autoList.Items[i].GetNamespace()
|
|
}
|
|
return reqs
|
|
}
|
|
|
|
// --- git ops
|
|
|
|
type repoAccess struct {
|
|
auth transport.AuthMethod
|
|
url string
|
|
}
|
|
|
|
func (r *ImageUpdateAutomationReconciler) getRepoAccess(ctx context.Context, repository *sourcev1.GitRepository) (repoAccess, error) {
|
|
var access repoAccess
|
|
access.url = repository.Spec.URL
|
|
authStrat := git.AuthSecretStrategyForURL(access.url)
|
|
|
|
if repository.Spec.SecretRef != nil && authStrat != nil {
|
|
name := types.NamespacedName{
|
|
Namespace: repository.GetNamespace(),
|
|
Name: repository.Spec.SecretRef.Name,
|
|
}
|
|
|
|
var secret corev1.Secret
|
|
err := r.Client.Get(ctx, name, &secret)
|
|
if err != nil {
|
|
err = fmt.Errorf("auth secret error: %w", err)
|
|
return access, err
|
|
}
|
|
|
|
access.auth, err = authStrat.Method(secret)
|
|
if err != nil {
|
|
err = fmt.Errorf("auth error: %w", err)
|
|
return access, err
|
|
}
|
|
}
|
|
return access, nil
|
|
}
|
|
|
|
func cloneInto(ctx context.Context, access repoAccess, branch, path string) (*gogit.Repository, error) {
|
|
checkoutStrat := git.CheckoutStrategyForRef(&sourcev1.GitRepositoryRef{
|
|
Branch: branch,
|
|
})
|
|
_, _, err := checkoutStrat.Checkout(ctx, path, access.url, access.auth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return gogit.PlainOpen(path)
|
|
}
|
|
|
|
var errNoChanges error = errors.New("no changes made to working directory")
|
|
|
|
func commitAllAndPush(ctx context.Context, repo *gogit.Repository, access repoAccess, commit *imagev1.CommitSpec) (string, error) {
|
|
working, err := repo.Worktree()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
status, err := working.Status()
|
|
if err != nil {
|
|
return "", err
|
|
} else if status.IsClean() {
|
|
return "", errNoChanges
|
|
}
|
|
|
|
msgTmpl := commit.MessageTemplate
|
|
if msgTmpl == "" {
|
|
msgTmpl = defaultMessageTemplate
|
|
}
|
|
tmpl, err := template.New("commit message").Parse(msgTmpl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
buf := &strings.Builder{}
|
|
if err := tmpl.Execute(buf, "no data! yet"); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var rev plumbing.Hash
|
|
if rev, err = working.Commit(buf.String(), &gogit.CommitOptions{
|
|
All: true,
|
|
Author: &object.Signature{
|
|
Name: commit.AuthorName,
|
|
Email: commit.AuthorEmail,
|
|
When: time.Now(),
|
|
},
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return rev.String(), repo.PushContext(ctx, &gogit.PushOptions{
|
|
Auth: access.auth,
|
|
})
|
|
}
|
|
|
|
// --- events, metrics
|
|
|
|
func (r *ImageUpdateAutomationReconciler) event(auto imagev1.ImageUpdateAutomation, severity, msg string) {
|
|
if r.EventRecorder != nil {
|
|
r.EventRecorder.Event(&auto, "Normal", severity, msg)
|
|
}
|
|
if r.ExternalEventRecorder != nil {
|
|
objRef, err := reference.GetReference(r.Scheme, &auto)
|
|
if err != nil {
|
|
r.Log.WithValues(
|
|
"request",
|
|
fmt.Sprintf("%s/%s", auto.GetNamespace(), auto.GetName()),
|
|
).Error(err, "unable to send event")
|
|
return
|
|
}
|
|
|
|
if err := r.ExternalEventRecorder.Eventf(*objRef, nil, severity, severity, msg); err != nil {
|
|
r.Log.WithValues(
|
|
"request",
|
|
fmt.Sprintf("%s/%s", auto.GetNamespace(), auto.GetName()),
|
|
).Error(err, "unable to send event")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *ImageUpdateAutomationReconciler) recordReadinessMetric(auto *imagev1.ImageUpdateAutomation) {
|
|
if r.MetricsRecorder == nil {
|
|
return
|
|
}
|
|
|
|
objRef, err := reference.GetReference(r.Scheme, auto)
|
|
if err != nil {
|
|
r.Log.WithValues(
|
|
strings.ToLower(auto.Kind),
|
|
fmt.Sprintf("%s/%s", auto.GetNamespace(), auto.GetName()),
|
|
).Error(err, "unable to record readiness metric")
|
|
return
|
|
}
|
|
if rc := apimeta.FindStatusCondition(auto.Status.Conditions, meta.ReadyCondition); rc != nil {
|
|
r.MetricsRecorder.RecordCondition(*objRef, *rc, !auto.DeletionTimestamp.IsZero())
|
|
} else {
|
|
r.MetricsRecorder.RecordCondition(*objRef, metav1.Condition{
|
|
Type: meta.ReadyCondition,
|
|
Status: metav1.ConditionUnknown,
|
|
}, !auto.DeletionTimestamp.IsZero())
|
|
}
|
|
}
|
|
|
|
// --- updates
|
|
|
|
// updateAccordingToSetters updates files under the root by treating
|
|
// the given image policies as kyaml setters.
|
|
func updateAccordingToSetters(ctx context.Context, path string, policies []imagev1_reflect.ImagePolicy) error {
|
|
return update.UpdateWithSetters(path, path, policies)
|
|
}
|