Skip to content
This repository has been archived by the owner on Oct 12, 2023. It is now read-only.

Prevent ApplicationSet controller from creating invalid Applications, causing 'unable to delete application resource' in Argo CD #136

Merged
merged 1 commit into from
Mar 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 16 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ import (
"github.com/argoproj-labs/applicationset/pkg/utils"

argov1alpha1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/util/db"
argosettings "github.com/argoproj/argo-cd/util/settings"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"

appclientset "github.com/argoproj/argo-cd/pkg/client/clientset/versioned"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
Expand Down Expand Up @@ -124,19 +127,25 @@ func main() {
}

k8s := kubernetes.NewForConfigOrDie(mgr.GetConfig())
argoSettingsMgr := argosettings.NewSettingsManager(context.Background(), k8s, namespace)
appclientset := appclientset.NewForConfigOrDie(mgr.GetConfig())

argoCDDB := db.NewDB(namespace, argoSettingsMgr, k8s)

if err = (&controllers.ApplicationSetReconciler{
Generators: map[string]generators.Generator{
"List": generators.NewListGenerator(),
"Clusters": generators.NewClusterGenerator(mgr.GetClient(), context.Background(), k8s, namespace),
"Git": generators.NewGitGenerator(services.NewArgoCDService(context.Background(), k8s, namespace, argocdRepoServer)),
"Git": generators.NewGitGenerator(services.NewArgoCDService(argoCDDB, argocdRepoServer)),
},
Client: mgr.GetClient(),
Log: ctrl.Log.WithName("controllers").WithName("ApplicationSet"),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("applicationset-controller"),
Renderer: &utils.Render{},
Policy: policyObj,
Client: mgr.GetClient(),
Log: ctrl.Log.WithName("controllers").WithName("ApplicationSet"),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("applicationset-controller"),
Renderer: &utils.Render{},
Policy: policyObj,
ArgoAppClientset: appclientset,
ArgoDB: argoCDDB,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ApplicationSet")
os.Exit(1)
Expand Down
56 changes: 50 additions & 6 deletions pkg/controllers/applicationset_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import (
"github.com/argoproj-labs/applicationset/pkg/generators"
"github.com/argoproj-labs/applicationset/pkg/utils"
argov1alpha1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/util/argo"
"github.com/argoproj/argo-cd/util/db"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand All @@ -43,17 +45,23 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"

argoprojiov1alpha1 "github.com/argoproj-labs/applicationset/api/v1alpha1"
appclientset "github.com/argoproj/argo-cd/pkg/client/clientset/versioned"
argoutil "github.com/argoproj/argo-cd/util/argo"

apierr "k8s.io/apimachinery/pkg/api/errors"

"github.com/imdario/mergo"
)

// ApplicationSetReconciler reconciles a ApplicationSet object
type ApplicationSetReconciler struct {
client.Client
Log logr.Logger
Scheme *runtime.Scheme
Recorder record.EventRecorder
Generators map[string]generators.Generator
Log logr.Logger
Scheme *runtime.Scheme
Recorder record.EventRecorder
Generators map[string]generators.Generator
ArgoDB db.ArgoDB
ArgoAppClientset appclientset.Interface
utils.Policy
utils.Renderer
}
Expand Down Expand Up @@ -81,7 +89,8 @@ func (r *ApplicationSetReconciler) Reconcile(ctx context.Context, req ctrl.Reque
if err != nil {
return ctrl.Result{}, err
}
if hasDuplicates, name := hasDuplicateNames(desiredApplications); hasDuplicates {

if validateError := r.validateGeneratedApplications(ctx, desiredApplications, applicationSetInfo, req.Namespace); validateError != nil {
// The reconciler presumes that any errors that are returned are a signal
// that the resource should attempt to be reconciled again (causing
// Reconcile to be called again, which will return the same error, ad
Expand All @@ -93,7 +102,7 @@ func (r *ApplicationSetReconciler) Reconcile(ctx context.Context, req ctrl.Reque
// will fail. So just log it and return that the resource was
// successfully reconciled (which is true... it was reconciled to an
// error condition).
log.Errorf("ApplicationSet %s contains applications with duplicate name: %s", applicationSetInfo.Name, name)
log.Errorf("%s", validateError.Error())
return ctrl.Result{}, nil
jgwest marked this conversation as resolved.
Show resolved Hide resolved
}

Expand Down Expand Up @@ -124,6 +133,41 @@ func (r *ApplicationSetReconciler) Reconcile(ctx context.Context, req ctrl.Reque
}, nil
}

// validateGeneratedApplications uses the Argo CD validation functions to verify the correctness of the
// generated applications.
func (r *ApplicationSetReconciler) validateGeneratedApplications(ctx context.Context, desiredApplications []argov1alpha1.Application, applicationSetInfo argoprojiov1alpha1.ApplicationSet, namespace string) (err error) {

if hasDuplicates, name := hasDuplicateNames(desiredApplications); hasDuplicates {
return fmt.Errorf("ApplicationSet %s contains applications with duplicate name: %s", applicationSetInfo.Name, name)
}

for _, app := range desiredApplications {

proj, err := r.ArgoAppClientset.ArgoprojV1alpha1().AppProjects(namespace).Get(ctx, app.Spec.GetProject(), metav1.GetOptions{})
if err != nil {
if apierr.IsNotFound(err) {
return fmt.Errorf("application references project %s which does not exist", app.Spec.Project)
}
return err
}

if err := argoutil.ValidateDestination(ctx, &app.Spec.Destination, r.ArgoDB); err != nil {
return fmt.Errorf("application destination spec is invalid: %s", err.Error())
}

conditions, err := argoutil.ValidatePermissions(ctx, &app.Spec, proj, r.ArgoDB)
if err != nil {
return err
}
if len(conditions) > 0 {
return fmt.Errorf("application spec is invalid: %s", argo.FormatAppConditions(conditions))
}

}

return nil
}

// Log a warning if there are unrecognized generators
func checkInvalidGenerators(applicationSetInfo *argoprojiov1alpha1.ApplicationSet) {
hasInvalidGenerators, invalidGenerators := invalidGenerators(applicationSetInfo)
Expand Down
202 changes: 202 additions & 0 deletions pkg/controllers/applicationset_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"

"github.com/argoproj-labs/applicationset/pkg/generators"
"github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
argov1alpha1 "github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
"github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
Expand All @@ -22,6 +24,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

argoprojiov1alpha1 "github.com/argoproj-labs/applicationset/api/v1alpha1"
appclientset "github.com/argoproj/argo-cd/pkg/client/clientset/versioned/fake"
dbmocks "github.com/argoproj/argo-cd/util/db/mocks"
)

type generatorMock struct {
Expand Down Expand Up @@ -1302,3 +1306,201 @@ func TestHasDuplicateNames(t *testing.T) {
assert.Equal(t, c.duplicateName, name)
}
}

func TestValidateGeneratedApplications(t *testing.T) {

scheme := runtime.NewScheme()
err := argoprojiov1alpha1.AddToScheme(scheme)
assert.Nil(t, err)

err = argov1alpha1.AddToScheme(scheme)
assert.Nil(t, err)

client := fake.NewClientBuilder().WithScheme(scheme).Build()

// Valid cluster
myCluster := argov1alpha1.Cluster{
Server: "https://kubernetes.default.svc",
Name: "my-cluster",
}

// Valid project
myProject := &argov1alpha1.AppProject{
ObjectMeta: metav1.ObjectMeta{Name: "default", Namespace: "namespace"},
Spec: argov1alpha1.AppProjectSpec{
SourceRepos: []string{"*"},
Destinations: []argov1alpha1.ApplicationDestination{
{
Namespace: "*",
Server: "*",
},
},
ClusterResourceWhitelist: []metav1.GroupKind{
{
Group: "*",
Kind: "*",
},
},
},
}

// Test a subset of the validations that 'validateGeneratedApplications' performs
for _, cc := range []struct {
name string
apps []argov1alpha1.Application
expectedErrors []string
}{
{
name: "valid app should return true",
apps: []argov1alpha1.Application{
{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{},
Spec: argov1alpha1.ApplicationSpec{
Project: "default",
Source: argov1alpha1.ApplicationSource{
RepoURL: "https://url",
Path: "/",
TargetRevision: "HEAD",
},
Destination: argov1alpha1.ApplicationDestination{
Namespace: "namespace",
Name: "my-cluster",
},
},
},
},
expectedErrors: []string{},
},
{
name: "can't have both name and server defined",
apps: []argov1alpha1.Application{
{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{},
Spec: argov1alpha1.ApplicationSpec{
Project: "default",
Source: argov1alpha1.ApplicationSource{
RepoURL: "https://url",
Path: "/",
TargetRevision: "HEAD",
},
Destination: argov1alpha1.ApplicationDestination{
Namespace: "namespace",
Server: "my-server",
Name: "my-cluster",
},
},
},
},
expectedErrors: []string{"application destination can't have both name and server defined"},
},
{
name: "project mismatch should return error",
apps: []argov1alpha1.Application{
{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{},
Spec: argov1alpha1.ApplicationSpec{
Project: "DOES-NOT-EXIST",
Source: argov1alpha1.ApplicationSource{
RepoURL: "https://url",
Path: "/",
TargetRevision: "HEAD",
},
Destination: argov1alpha1.ApplicationDestination{
Namespace: "namespace",
Name: "my-cluster",
},
},
},
},
expectedErrors: []string{"application references project DOES-NOT-EXIST which does not exist"},
},
{
name: "valid app should return true",
apps: []argov1alpha1.Application{
{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{},
Spec: argov1alpha1.ApplicationSpec{
Project: "default",
Source: argov1alpha1.ApplicationSource{
RepoURL: "https://url",
Path: "/",
TargetRevision: "HEAD",
},
Destination: argov1alpha1.ApplicationDestination{
Namespace: "namespace",
Name: "my-cluster",
},
},
},
},
expectedErrors: []string{},
},
{
name: "cluster should match",
apps: []argov1alpha1.Application{
{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{},
Spec: argov1alpha1.ApplicationSpec{
Project: "default",
Source: argov1alpha1.ApplicationSource{
RepoURL: "https://url",
Path: "/",
TargetRevision: "HEAD",
},
Destination: argov1alpha1.ApplicationDestination{
Namespace: "namespace",
Name: "nonexistent-cluster",
},
},
},
},
expectedErrors: []string{"there are no clusters with this name: nonexistent-cluster"},
},
} {

t.Run(cc.name, func(t *testing.T) {

argoDBMock := dbmocks.ArgoDB{}
argoDBMock.On("GetCluster", mock.Anything, "https://kubernetes.default.svc").Return(&myCluster, nil)
argoDBMock.On("ListClusters", mock.Anything).Return(&v1alpha1.ClusterList{Items: []argov1alpha1.Cluster{
myCluster,
}}, nil)

argoObjs := []runtime.Object{myProject}
for _, app := range cc.apps {
argoObjs = append(argoObjs, &app)
}

r := ApplicationSetReconciler{
Client: client,
Scheme: scheme,
Recorder: record.NewFakeRecorder(1),
Generators: map[string]generators.Generator{},
ArgoDB: &argoDBMock,
ArgoAppClientset: appclientset.NewSimpleClientset(argoObjs...),
}

appSetInfo := argoprojiov1alpha1.ApplicationSet{}

err := r.validateGeneratedApplications(context.TODO(), cc.apps, appSetInfo, "namespace")

if err == nil {
assert.Equal(t, len(cc.expectedErrors), 0, "Expected errors but none were seen")
} else {
// An error was returned: it should be expected
matched := false
for _, expectedErr := range cc.expectedErrors {
foundMatch := strings.Contains(err.Error(), expectedErr)
assert.True(t, foundMatch, "Unble to locate expected error: %s", cc.expectedErrors)
matched = matched || foundMatch
}
assert.True(t, matched, "An unexpected error occurrred: %v", err)
}
})
}
}
7 changes: 2 additions & 5 deletions pkg/services/repo_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import (
"github.com/argoproj/argo-cd/util/db"
"github.com/argoproj/argo-cd/util/git"
"github.com/argoproj/argo-cd/util/io"
"github.com/argoproj/argo-cd/util/settings"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"k8s.io/client-go/kubernetes"
)

// RepositoryDB Is a lean facade for ArgoDB,
Expand All @@ -33,11 +31,10 @@ type Repos interface {
GetFileContent(ctx context.Context, repoURL string, revision string, path string) ([]byte, error)
}

func NewArgoCDService(ctx context.Context, clientset kubernetes.Interface, namespace string, repoServerAddress string) Repos {
settingsMgr := settings.NewSettingsManager(ctx, clientset, namespace)
func NewArgoCDService(db db.ArgoDB, repoServerAddress string) Repos {

return &argoCDService{
repositoriesDB: db.NewDB(namespace, settingsMgr, clientset).(RepositoryDB),
repositoriesDB: db.(RepositoryDB),
repoClientset: apiclient.NewRepoServerClientset(repoServerAddress, 5),
}
}
Expand Down