kustomize-controller/controllers/kustomization_controller.go

655 lines
20 KiB
Go

/*
Copyright 2020 The Flux CD contributors.
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"
"os"
"os/exec"
"path"
"path/filepath"
"sigs.k8s.io/yaml"
"strings"
"time"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
kustypes "sigs.k8s.io/kustomize/api/types"
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1alpha1"
"github.com/fluxcd/kustomize-controller/internal/alert"
"github.com/fluxcd/kustomize-controller/internal/lockedfile"
sourcev1 "github.com/fluxcd/source-controller/api/v1alpha1"
)
// KustomizationReconciler reconciles a Kustomization object
type KustomizationReconciler struct {
client.Client
Log logr.Logger
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=kustomize.fluxcd.io,resources=kustomizations,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=kustomize.fluxcd.io,resources=kustomizations/status,verbs=get;update;patch
func (r *KustomizationReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()
syncStart := time.Now()
var kustomization kustomizev1.Kustomization
if err := r.Get(ctx, req.NamespacedName, &kustomization); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
log := r.Log.WithValues(strings.ToLower(kustomization.Kind), req.NamespacedName)
kustomization = kustomizev1.KustomizationProgressing(kustomization)
if err := r.Status().Update(ctx, &kustomization); err != nil {
log.Error(err, "unable to update Kustomization status")
return ctrl.Result{Requeue: true}, err
}
if kustomization.Spec.Suspend {
msg := "Kustomization is suspended, skipping execution"
kustomization = kustomizev1.KustomizationNotReady(kustomization, kustomizev1.SuspendedReason, msg)
if err := r.Status().Update(ctx, &kustomization); err != nil {
log.Error(err, "unable to update Kustomization status")
return ctrl.Result{Requeue: true}, err
}
log.Info(msg)
return ctrl.Result{}, nil
}
var source sourcev1.Source
// get artifact source from Git repository
if kustomization.Spec.SourceRef.Kind == "GitRepository" {
var repository sourcev1.GitRepository
repositoryName := types.NamespacedName{
Namespace: kustomization.GetNamespace(),
Name: kustomization.Spec.SourceRef.Name,
}
err := r.Client.Get(ctx, repositoryName, &repository)
if err != nil {
log.Error(err, "GitRepository not found", "gitrepository", repositoryName)
return ctrl.Result{Requeue: true}, err
}
source = &repository
}
if source == nil {
err := fmt.Errorf("source `%s` kind '%s' not supported",
kustomization.Spec.SourceRef.Name, kustomization.Spec.SourceRef.Kind)
return ctrl.Result{}, err
}
// check source readiness
if source.GetArtifact() == nil {
msg := "Source is not ready"
kustomization = kustomizev1.KustomizationNotReady(kustomization, kustomizev1.ArtifactFailedReason, msg)
if err := r.Status().Update(ctx, &kustomization); err != nil {
log.Error(err, "unable to update Kustomization status")
return ctrl.Result{Requeue: true}, err
}
log.Info(msg)
return ctrl.Result{}, nil
}
// check dependencies
if len(kustomization.Spec.DependsOn) > 0 {
if err := r.checkDependencies(kustomization); err != nil {
kustomization = kustomizev1.KustomizationNotReady(kustomization, kustomizev1.DependencyNotReadyReason, err.Error())
if err := r.Status().Update(ctx, &kustomization); err != nil {
log.Error(err, "unable to update Kustomization status")
return ctrl.Result{Requeue: true}, err
}
// we can't rely on exponential backoff because it will prolong the execution too much,
// instead we requeue every half a minute.
requeueAfter := 30 * time.Second
msg := fmt.Sprintf("Dependencies do not meet ready condition, retrying in %s", requeueAfter.String())
log.Error(err, msg)
r.alert(kustomization, msg, "info")
return ctrl.Result{RequeueAfter: requeueAfter}, nil
}
log.Info("All dependencies area ready, proceeding with apply")
}
// try sync
syncedKustomization, err := r.sync(*kustomization.DeepCopy(), source)
if err != nil {
log.Error(err, "Kustomization apply failed", "revision", source.GetArtifact().Revision)
r.alert(kustomization, err.Error(), "error")
}
// update status
if err := r.Status().Update(ctx, &syncedKustomization); err != nil {
log.Error(err, "unable to update Kustomization status after sync")
return ctrl.Result{Requeue: true}, err
}
// log sync duration
log.Info(fmt.Sprintf("Kustomization sync finished in %s, next run in %s",
time.Now().Sub(syncStart).String(),
kustomization.Spec.Interval.Duration.String(),
))
// requeue kustomization
return ctrl.Result{RequeueAfter: kustomization.Spec.Interval.Duration}, nil
}
func (r *KustomizationReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&kustomizev1.Kustomization{}).
WithEventFilter(KustomizationGarbageCollectPredicate{Log: r.Log}).
WithEventFilter(KustomizationSyncAtPredicate{}).
WithOptions(controller.Options{MaxConcurrentReconciles: 4}).
Complete(r)
}
func (r *KustomizationReconciler) sync(
kustomization kustomizev1.Kustomization,
source sourcev1.Source) (kustomizev1.Kustomization, error) {
// acquire lock
unlock, err := r.lock(fmt.Sprintf("%s-%s", kustomization.GetName(), kustomization.GetNamespace()))
if err != nil {
err = fmt.Errorf("tmp dir error: %w", err)
return kustomizev1.KustomizationNotReady(kustomization, sourcev1.StorageOperationFailedReason, err.Error()), err
}
defer unlock()
// create tmp dir
tmpDir, err := ioutil.TempDir("", kustomization.Name)
if err != nil {
err = fmt.Errorf("tmp dir error: %w", err)
return kustomizev1.KustomizationNotReady(kustomization, sourcev1.StorageOperationFailedReason, err.Error()), err
}
defer os.RemoveAll(tmpDir)
// download artifact and extract files
err = r.download(kustomization, source.GetArtifact().URL, tmpDir)
if err != nil {
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.ArtifactFailedReason,
"artifact acquisition failed",
), err
}
dirPath := path.Join(tmpDir, kustomization.Spec.Path)
// check build path exists
if _, err := os.Stat(dirPath); err != nil {
err = fmt.Errorf("kustomization path not found: %w", err)
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.ArtifactFailedReason,
err.Error(),
), err
}
// generate kustomization.yaml
err = r.generate(kustomization, source.GetArtifact().Revision, dirPath)
if err != nil {
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.BuildFailedReason,
"kustomize create failed",
), err
}
// kustomize build
snapshot, err := r.build(kustomization, source.GetArtifact().Revision, dirPath)
if err != nil {
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.BuildFailedReason,
"kustomize build failed",
), err
}
// dry-run apply
err = r.validate(kustomization, dirPath)
if err != nil {
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.ValidationFailedReason,
fmt.Sprintf("%s-side validation failed", kustomization.Spec.Validation),
), err
}
// apply
err = r.apply(kustomization, dirPath)
if err != nil {
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.ApplyFailedReason,
"apply failed",
), err
}
// prune
err = r.prune(kustomization, snapshot)
if err != nil {
return kustomizev1.KustomizationNotReady(
kustomization,
kustomizev1.PruneFailedReason,
err.Error(),
), err
}
// health assessment
err = r.checkHealth(kustomization)
if err != nil {
return kustomizev1.KustomizationNotReadySnapshot(
kustomization,
snapshot,
kustomizev1.HealthCheckFailedReason,
"health check failed",
), err
}
return kustomizev1.KustomizationReady(
kustomization,
snapshot,
source.GetArtifact().Revision,
kustomizev1.ApplySucceedReason,
"kustomization was successfully applied",
), nil
}
func (r *KustomizationReconciler) download(kustomization kustomizev1.Kustomization, url string, tmpDir string) error {
timeout := kustomization.GetTimeout() + (time.Second * 1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := fmt.Sprintf("cd %s && curl -sL %s -o artifact.tar.gz && tar -xzf artifact.tar.gz --strip-components=1 -C .",
tmpDir, url)
command := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := command.CombinedOutput()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return err
}
return fmt.Errorf("artifact `%s` download failed: %s", url, string(output))
}
return nil
}
func (r *KustomizationReconciler) generate(kustomization kustomizev1.Kustomization, revision, dirPath string) error {
kfile := filepath.Join(dirPath, kustomizationFileName)
if _, err := os.Stat(kfile); err != nil {
timeout := kustomization.GetTimeout() + (time.Second * 1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := fmt.Sprintf("cd %s && kustomize create --autodetect --recursive", dirPath)
command := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := command.CombinedOutput()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return err
}
return fmt.Errorf("kustomize create failed: %s", string(output))
}
}
if err := r.generateLabelTransformer(kustomization, revision, dirPath); err != nil {
return err
}
data, err := ioutil.ReadFile(kfile)
if err != nil {
return err
}
kus := kustypes.Kustomization{
TypeMeta: kustypes.TypeMeta{
APIVersion: kustypes.KustomizationVersion,
Kind: kustypes.KustomizationKind,
},
}
if err := yaml.Unmarshal(data, &kus); err != nil {
return err
}
if len(kus.Transformers) == 0 {
kus.Transformers = []string{transformerFileName}
} else {
var exists bool
for _, transformer := range kus.Transformers {
if transformer == transformerFileName {
exists = true
break
}
}
if !exists {
kus.Transformers = append(kus.Transformers, transformerFileName)
}
}
kd, err := yaml.Marshal(kus)
if err != nil {
return err
}
return ioutil.WriteFile(kfile, kd, os.ModePerm)
}
func (r *KustomizationReconciler) generateLabelTransformer(kustomization kustomizev1.Kustomization, revision, dirPath string) error {
var lt = struct {
ApiVersion string `json:"apiVersion" yaml:"apiVersion"`
Kind string `json:"kind" yaml:"kind"`
Metadata struct {
Name string `json:"name" yaml:"name"`
} `json:"metadata" yaml:"metadata"`
Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
FieldSpecs []kustypes.FieldSpec `json:"fieldSpecs,omitempty" yaml:"fieldSpecs,omitempty"`
}{
ApiVersion: "builtin",
Kind: "LabelTransformer",
Metadata: struct {
Name string `json:"name" yaml:"name"`
}{
Name: kustomization.GetName(),
},
Labels: gcLabels(kustomization.GetName(), kustomization.GetNamespace(), revision),
FieldSpecs: []kustypes.FieldSpec{
{Path: "metadata/labels", CreateIfNotPresent: true},
},
}
data, err := yaml.Marshal(lt)
if err != nil {
return err
}
labelsFile := filepath.Join(dirPath, transformerFileName)
if err := ioutil.WriteFile(labelsFile, data, os.ModePerm); err != nil {
return err
}
return nil
}
func (r *KustomizationReconciler) build(kustomization kustomizev1.Kustomization, revision, dirPath string) (*kustomizev1.Snapshot, error) {
timeout := kustomization.GetTimeout() + (time.Second * 1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := fmt.Sprintf("cd %s && kustomize build . > %s.yaml",
dirPath, kustomization.GetUID())
command := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := command.CombinedOutput()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, fmt.Errorf("kustomize build failed: %s", string(output))
}
manifestsFile := filepath.Join(dirPath, fmt.Sprintf("%s.yaml", kustomization.GetUID()))
data, err := ioutil.ReadFile(manifestsFile)
if err != nil {
return nil, err
}
return kustomizev1.NewSnapshot(data, revision)
}
func (r *KustomizationReconciler) validate(kustomization kustomizev1.Kustomization, dirPath string) error {
if kustomization.Spec.Validation == "" {
return nil
}
timeout := kustomization.GetTimeout() + (time.Second * 1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := fmt.Sprintf("cd %s && kubectl apply -f %s.yaml --timeout=%s --dry-run=%s",
dirPath, kustomization.GetUID(), kustomization.GetTimeout().String(), kustomization.Spec.Validation)
command := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := command.CombinedOutput()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("validation timeout: %w", err)
}
return fmt.Errorf("validation failed: %s", string(output))
}
return nil
}
func (r *KustomizationReconciler) apply(kustomization kustomizev1.Kustomization, dirPath string) error {
start := time.Now()
timeout := kustomization.GetTimeout() + (time.Second * 1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := fmt.Sprintf("cd %s && kubectl apply -f %s.yaml --timeout=%s",
dirPath, kustomization.GetUID(), kustomization.Spec.Interval.Duration.String())
// impersonate SA
if sa := kustomization.Spec.ServiceAccount; sa != nil {
cmd = fmt.Sprintf("%s --as system:serviceaccount:%s:%s", cmd, sa.Namespace, sa.Name)
}
command := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := command.CombinedOutput()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("apply timeout: %w", err)
}
return fmt.Errorf("apply failed: %s", string(output))
}
resources := r.parseApplyOutput(output)
r.Log.WithValues(
strings.ToLower(kustomization.Kind),
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
).Info(
fmt.Sprintf("Kustomization applied in %s",
time.Now().Sub(start).String()),
"output", resources,
)
var diff bool
for _, action := range resources {
if action != "unchanged" {
diff = true
break
}
}
if diff {
r.alert(kustomization, string(output), "info")
}
return nil
}
func (r *KustomizationReconciler) prune(kustomization kustomizev1.Kustomization, snapshot *kustomizev1.Snapshot) error {
if kustomization.Status.Snapshot == nil || snapshot == nil {
return nil
}
if kustomization.Status.Snapshot.Revision == snapshot.Revision {
return nil
}
if !prune(kustomization.GetTimeout(),
kustomization.GetName(),
kustomization.GetNamespace(),
kustomization.Status.Snapshot,
r.Log,
) {
return fmt.Errorf("pruning failed")
}
return nil
}
func (r *KustomizationReconciler) checkHealth(kustomization kustomizev1.Kustomization) error {
timeout := kustomization.GetTimeout() + (time.Second * 1)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var alerts string
for _, check := range kustomization.Spec.HealthChecks {
cmd := fmt.Sprintf("kubectl -n %s rollout status %s %s --timeout=%s",
check.Namespace, check.Kind, check.Name, kustomization.GetTimeout())
command := exec.CommandContext(ctx, "/bin/sh", "-c", cmd)
output, err := command.CombinedOutput()
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("health check timeout for %s '%s/%s': %w",
check.Kind, check.Namespace, check.Name, err)
}
return fmt.Errorf("health check failed for %s '%s/%s': %s",
check.Kind, check.Namespace, check.Name, string(output))
} else {
msg := fmt.Sprintf("Health check passed for %s '%s/%s'",
check.Kind, check.Namespace, check.Name)
r.Log.WithValues(
strings.ToLower(kustomization.Kind),
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
).Info(msg)
alerts += msg + "\n"
}
}
if alerts != "" {
r.alert(kustomization, alerts, "info")
}
return nil
}
func (r *KustomizationReconciler) lock(name string) (unlock func(), err error) {
lockFile := path.Join(os.TempDir(), name+".lock")
mutex := lockedfile.MutexAt(lockFile)
return mutex.Lock()
}
func (r *KustomizationReconciler) parseApplyOutput(in []byte) map[string]string {
result := make(map[string]string)
input := strings.Split(string(in), "\n")
if len(input) == 0 {
return result
}
var parts []string
for _, str := range input {
if str != "" {
parts = append(parts, str)
}
}
for _, str := range parts {
kv := strings.Split(str, " ")
if len(kv) > 1 {
result[kv[0]] = kv[1]
}
}
return result
}
func (r *KustomizationReconciler) checkDependencies(kustomization kustomizev1.Kustomization) error {
for _, dep := range kustomization.Spec.DependsOn {
depName := types.NamespacedName{
Namespace: kustomization.GetNamespace(),
Name: dep,
}
var k kustomizev1.Kustomization
err := r.Get(context.Background(), depName, &k)
if err != nil {
return fmt.Errorf("unable to get '%s' dependency: %w", depName, err)
}
if len(k.Status.Conditions) == 0 {
return fmt.Errorf("dependency '%s' is not ready", depName)
}
for _, condition := range k.Status.Conditions {
if condition.Type == kustomizev1.ReadyCondition && condition.Status != corev1.ConditionTrue {
return fmt.Errorf("dependency '%s' is not ready", depName)
}
}
}
return nil
}
func (r *KustomizationReconciler) getProfiles(kustomization kustomizev1.Kustomization) ([]kustomizev1.Profile, error) {
list := make([]kustomizev1.Profile, 0)
var profiles kustomizev1.ProfileList
err := r.List(context.TODO(), &profiles, client.InNamespace(kustomization.GetNamespace()))
if err != nil {
return list, err
}
// filter profiles that match this kustomization taking into account '*' wildcard
for _, profile := range profiles.Items {
for _, name := range profile.Spec.Kustomizations {
if name == kustomization.GetName() || name == "*" {
list = append(list, profile)
break
}
}
}
return list, nil
}
func (r *KustomizationReconciler) alert(kustomization kustomizev1.Kustomization, msg string, verbosity string) {
profiles, err := r.getProfiles(kustomization)
if err != nil {
r.Log.WithValues(
strings.ToLower(kustomization.Kind),
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
).Error(err, "unable to list profiles")
return
}
for _, profile := range profiles {
if settings := profile.Spec.Alert; settings != nil {
provider, err := alert.NewProvider(settings.Type, settings.Address, settings.Username, settings.Channel)
if err != nil {
r.Log.WithValues(
strings.ToLower(kustomization.Kind),
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
).Error(err, "unable to configure alert provider")
continue
}
if settings.Verbosity == verbosity || verbosity == "error" {
err = provider.Post(kustomization.GetName(), kustomization.GetNamespace(), msg, verbosity)
if err != nil {
r.Log.WithValues(
strings.ToLower(kustomization.Kind),
fmt.Sprintf("%s/%s", kustomization.GetNamespace(), kustomization.GetName()),
).Error(err, "unable to send alert")
}
}
}
}
}
var (
kustomizationFileName = "kustomization.yaml"
transformerFileName = "kustomization-gc-labels.yaml"
)