Skip to content

Commit 30008c4

Browse files
authored
feat: Add Wildcards support in ApplicationSet Source Namespaces (argoproj-labs#1988)
* Add support Wildcards in ApplicationSet Source Namespaces Signed-off-by: nmirasch <neus.miras@gmail.com> * Removed doc mention to Enable ApplicationSets in all namespaces Signed-off-by: nmirasch <neus.miras@gmail.com> * Add sorting to ensure deterministic namespace ordering Signed-off-by: nmirasch <neus.miras@gmail.com> * added reg expression references and upstream documentation Signed-off-by: nmirasch <neus.miras@gmail.com> --------- Signed-off-by: nmirasch <neus.miras@gmail.com>
1 parent 062bc25 commit 30008c4

File tree

5 files changed

+465
-47
lines changed

5 files changed

+465
-47
lines changed

controllers/argocd/applicationset.go

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"errors"
2020
"fmt"
2121
"reflect"
22+
"sort"
2223
"strings"
2324

2425
appsv1 "k8s.io/api/apps/v1"
@@ -32,6 +33,8 @@ import (
3233
"sigs.k8s.io/controller-runtime/pkg/client"
3334
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3435

36+
"github.com/argoproj/argo-cd/v3/util/glob"
37+
3538
argoproj "github.com/argoproj-labs/argocd-operator/api/v1beta1"
3639
"github.com/argoproj-labs/argocd-operator/common"
3740
"github.com/argoproj-labs/argocd-operator/controllers/argoutil"
@@ -70,27 +73,31 @@ func (r *ReconcileArgoCD) getArgoApplicationSetCommand(cr *argoproj.ArgoCD) []st
7073
if argoutil.IsNamespaceClusterConfigNamespace(cr.Namespace) {
7174

7275
// appset source namespaces should be subset of apps source namespaces
73-
appsetsSourceNamespaces := []string{}
74-
appsNamespaces, err := r.getSourceNamespaces(cr)
75-
if err == nil {
76-
for _, ns := range cr.Spec.ApplicationSet.SourceNamespaces {
77-
if contains(appsNamespaces, ns) {
78-
appsetsSourceNamespaces = append(appsetsSourceNamespaces, ns)
79-
} else {
80-
log.V(1).Info(fmt.Sprintf("Apps in target sourceNamespace %s is not enabled, thus skipping the namespace in deployment command.", ns))
76+
appsetsSourceNamespacesExpanded, err := r.getApplicationSetSourceNamespaces(cr)
77+
if err != nil {
78+
log.Error(err, "failed to getting ApplicationSet source namespaces")
79+
} else {
80+
appsNamespaces, err := r.getSourceNamespaces(cr)
81+
if err == nil {
82+
appsetsSourceNamespaces := []string{}
83+
for _, ns := range appsetsSourceNamespacesExpanded {
84+
if contains(appsNamespaces, ns) {
85+
appsetsSourceNamespaces = append(appsetsSourceNamespaces, ns)
86+
} else {
87+
log.V(1).Info(fmt.Sprintf("Apps in target sourceNamespace %s is not enabled, thus skipping the namespace in deployment command.", ns))
88+
}
89+
}
90+
if len(appsetsSourceNamespaces) > 0 {
91+
cmd = append(cmd, "--applicationset-namespaces", fmt.Sprint(strings.Join(appsetsSourceNamespaces, ",")))
8192
}
82-
}
83-
}
84-
85-
if len(appsetsSourceNamespaces) > 0 {
86-
cmd = append(cmd, "--applicationset-namespaces", fmt.Sprint(strings.Join(appsetsSourceNamespaces, ",")))
87-
}
8893

89-
// appset in any ns is enabled and no scmProviders allow list is specified,
90-
// disables scm & PR generators to prevent potential security issues
91-
// https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Appset-Any-Namespace/#scm-providers-secrets-consideration
92-
if len(appsetsSourceNamespaces) > 0 && (len(cr.Spec.ApplicationSet.SCMProviders) <= 0) {
93-
cmd = append(cmd, "--enable-scm-providers=false")
94+
// appset in any ns is enabled and no scmProviders allow list is specified,
95+
// disables scm & PR generators to prevent potential security issues
96+
// https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Appset-Any-Namespace/#scm-providers-secrets-consideration
97+
if len(appsetsSourceNamespaces) > 0 && (len(cr.Spec.ApplicationSet.SCMProviders) <= 0) {
98+
cmd = append(cmd, "--enable-scm-providers=false")
99+
}
100+
}
94101
}
95102
}
96103

@@ -653,14 +660,20 @@ func (r *ReconcileArgoCD) reconcileApplicationSetSourceNamespacesResources(cr *a
653660
}
654661

655662
// create resources for each appset source namespace
656-
for _, sourceNamespace := range cr.Spec.ApplicationSet.SourceNamespaces {
663+
appsetsSourceNamespacesExpanded, err := r.getApplicationSetSourceNamespaces(cr)
664+
if err != nil {
665+
return fmt.Errorf("failed getting ApplicationSet source namespaces: %w", err)
666+
}
657667

658-
// source ns should be part of app-in-any-ns
659-
appsNamespaces, err := r.getSourceNamespaces(cr)
660-
if err != nil {
661-
reconciliationErrors = append(reconciliationErrors, err)
662-
continue
663-
}
668+
// source ns should be part of app-in-any-ns
669+
appsNamespaces, err := r.getSourceNamespaces(cr)
670+
if err != nil {
671+
return fmt.Errorf("failed to get apps source namespaces: %w", err)
672+
}
673+
674+
// create resources for each appset source namespace (after wildcard expansion)
675+
for _, sourceNamespace := range appsetsSourceNamespacesExpanded {
676+
// Only process namespaces that are also in apps source namespaces
664677
if !contains(appsNamespaces, sourceNamespace) {
665678
log.Info(fmt.Sprintf("skipping reconciliation of resources for sourceNamespace %s as Apps in target sourceNamespace is not enabled", sourceNamespace))
666679
continue
@@ -986,17 +999,16 @@ func (r *ReconcileArgoCD) removeUnmanagedApplicationSetSourceNamespaceResources(
986999

9871000
if cr.Spec.ApplicationSet != nil && cr.GetDeletionTimestamp() == nil {
9881001

989-
// namespace is valid if it's mentioend in cr.Spec.ApplicationSet.SourceNamespaces AND cr.Spec.SourceNamespaces
990-
//
1002+
// namespace is valid if it matches any pattern in cr.Spec.ApplicationSet.SourceNamespaces AND is in cr.Spec.SourceNamespaces
9911003
appsNamespaces, err := r.getSourceNamespaces(cr)
9921004
if err != nil {
9931005
return err
9941006
}
995-
for _, namespace := range cr.Spec.ApplicationSet.SourceNamespaces {
1007+
// Check if the namespace matches any of the ApplicationSet source namespace patterns
1008+
if glob.MatchStringInList(cr.Spec.ApplicationSet.SourceNamespaces, appsetsInAnyNamespaceLabelledNS, glob.REGEXP) {
9961009
// appset ns should be part of apps ns
997-
if namespace == appsetsInAnyNamespaceLabelledNS && contains(appsNamespaces, namespace) {
1010+
if contains(appsNamespaces, appsetsInAnyNamespaceLabelledNS) {
9981011
managedNamespace = true
999-
break
10001012
}
10011013
}
10021014
}
@@ -1170,10 +1182,28 @@ func (r *ReconcileArgoCD) reconcileSourceNamespaceRoleBinding(roleBinding v1.Rol
11701182
return nil
11711183
}
11721184

1173-
// getApplicationSetSourceNamespaces return list of namespaces from .spec.ApplicationSet.SourceNamespaces
1174-
func (r *ReconcileArgoCD) getApplicationSetSourceNamespaces(cr *argoproj.ArgoCD) []string {
1175-
if cr.Spec.ApplicationSet != nil {
1176-
return cr.Spec.ApplicationSet.SourceNamespaces
1185+
// getApplicationSetSourceNamespaces returns the list of actual namespaces that match the patterns
1186+
// specified in .spec.ApplicationSet.SourceNamespaces. It supports wildcard patterns (e.g., team-*).
1187+
func (r *ReconcileArgoCD) getApplicationSetSourceNamespaces(cr *argoproj.ArgoCD) ([]string, error) {
1188+
if cr.Spec.ApplicationSet == nil {
1189+
return []string(nil), nil
1190+
}
1191+
1192+
sourceNamespaces := []string{}
1193+
namespaces := &corev1.NamespaceList{}
1194+
1195+
if err := r.List(context.TODO(), namespaces, &client.ListOptions{}); err != nil {
1196+
return nil, err
1197+
}
1198+
1199+
// Intentional: use REGEXP so .spec.applicationSet.sourceNamespaces can contain either
1200+
// glob-like wildcards or full regular expressions. We expand to concrete namespaces here,
1201+
// and pass the final list to the controller via --applicationset-namespaces.
1202+
for _, namespace := range namespaces.Items {
1203+
if glob.MatchStringInList(cr.Spec.ApplicationSet.SourceNamespaces, namespace.Name, glob.REGEXP) {
1204+
sourceNamespaces = append(sourceNamespaces, namespace.Name)
1205+
}
11771206
}
1178-
return []string(nil)
1207+
sort.Strings(sourceNamespaces)
1208+
return sourceNamespaces, nil
11791209
}

controllers/argocd/applicationset_test.go

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ func TestReconcileApplicationSet_Deployments_Command(t *testing.T) {
557557
},
558558
SourceNamespaces: []string{"foo", "bar"},
559559
},
560-
expectedCmd: []string{"--applicationset-namespaces", "foo,bar", "--enable-scm-providers=false"},
560+
expectedCmd: []string{"--applicationset-namespaces", "bar,foo", "--enable-scm-providers=false"},
561561
},
562562
{
563563
name: "with SCM provider list",
@@ -1258,34 +1258,123 @@ func TestArgoCDApplicationSet_getApplicationSetSourceNamespaces(t *testing.T) {
12581258
tests := []struct {
12591259
name string
12601260
appSetField *argoproj.ArgoCDApplicationSet
1261+
namespaces []client.Object
12611262
expected []string
12621263
}{
12631264
{
12641265
name: "Appsets not enabled",
12651266
appSetField: nil,
1267+
namespaces: []client.Object{},
12661268
expected: []string(nil),
12671269
},
12681270
{
12691271
name: "No appset source namespaces",
12701272
appSetField: &argoproj.ArgoCDApplicationSet{
12711273
Enabled: boolPtr(true),
12721274
},
1273-
expected: []string(nil),
1275+
namespaces: []client.Object{},
1276+
expected: []string(nil),
12741277
},
12751278
{
1276-
name: "Appset source namespaces",
1279+
name: "Appset source namespaces - exact match",
12771280
appSetField: &argoproj.ArgoCDApplicationSet{
12781281
SourceNamespaces: []string{"foo", "bar"},
12791282
},
1283+
namespaces: []client.Object{
1284+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "foo"}},
1285+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "bar"}},
1286+
},
12801287
expected: []string{"foo", "bar"},
12811288
},
1289+
{
1290+
name: "Appset source namespaces with wildcard glob pattern",
1291+
appSetField: &argoproj.ArgoCDApplicationSet{
1292+
SourceNamespaces: []string{"team-*"},
1293+
},
1294+
namespaces: []client.Object{
1295+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-1"}},
1296+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-2"}},
1297+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-frontend"}},
1298+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-backend"}},
1299+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "other-ns"}},
1300+
},
1301+
expected: []string{"team-1", "team-2", "team-backend", "team-frontend"},
1302+
},
1303+
{
1304+
name: "Appset source namespaces with regex pattern - anchored",
1305+
appSetField: &argoproj.ArgoCDApplicationSet{
1306+
SourceNamespaces: []string{"/^team-(1|2)$/"},
1307+
},
1308+
namespaces: []client.Object{
1309+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-1"}},
1310+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-2"}},
1311+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-frontend"}},
1312+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-10"}},
1313+
},
1314+
expected: []string{"team-1", "team-2"},
1315+
},
1316+
{
1317+
name: "Appset source namespaces with regex pattern - unanchored",
1318+
appSetField: &argoproj.ArgoCDApplicationSet{
1319+
SourceNamespaces: []string{"/team-.*/"},
1320+
},
1321+
namespaces: []client.Object{
1322+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-1"}},
1323+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-2"}},
1324+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-frontend"}},
1325+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "other-ns"}},
1326+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "not-team"}},
1327+
},
1328+
expected: []string{"team-1", "team-2", "team-frontend"},
1329+
},
1330+
{
1331+
name: "Appset source namespaces with regex pattern - character class",
1332+
appSetField: &argoproj.ArgoCDApplicationSet{
1333+
SourceNamespaces: []string{"/^team-[0-9]+$/"},
1334+
},
1335+
namespaces: []client.Object{
1336+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-1"}},
1337+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-2"}},
1338+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-10"}},
1339+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-frontend"}},
1340+
},
1341+
expected: []string{"team-1", "team-10", "team-2"},
1342+
},
1343+
{
1344+
name: "Appset source namespaces with multiple patterns",
1345+
appSetField: &argoproj.ArgoCDApplicationSet{
1346+
SourceNamespaces: []string{"team-*", "app-*"},
1347+
},
1348+
namespaces: []client.Object{
1349+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-1"}},
1350+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-2"}},
1351+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app-frontend"}},
1352+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app-backend"}},
1353+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "other-ns"}},
1354+
},
1355+
expected: []string{"app-backend", "app-frontend", "team-1", "team-2"},
1356+
},
1357+
{
1358+
name: "Appset source namespaces with regex pattern - starts with",
1359+
appSetField: &argoproj.ArgoCDApplicationSet{
1360+
SourceNamespaces: []string{"/^prod-.*/"},
1361+
},
1362+
namespaces: []client.Object{
1363+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "prod-frontend"}},
1364+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "prod-backend"}},
1365+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "dev-frontend"}},
1366+
&v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "staging-prod"}},
1367+
},
1368+
expected: []string{"prod-backend", "prod-frontend"},
1369+
},
12821370
}
12831371

12841372
for _, test := range tests {
12851373
t.Run(test.name, func(t *testing.T) {
12861374

12871375
a := makeTestArgoCD()
12881376
resObjs := []client.Object{a}
1377+
resObjs = append(resObjs, test.namespaces...)
12891378
subresObjs := []client.Object{a}
12901379
runtimeObjs := []runtime.Object{}
12911380
sch := makeTestReconcilerScheme(argoproj.AddToScheme)
@@ -1297,8 +1386,21 @@ func TestArgoCDApplicationSet_getApplicationSetSourceNamespaces(t *testing.T) {
12971386

12981387
a.Spec.ApplicationSet = test.appSetField
12991388

1300-
actual := r.getApplicationSetSourceNamespaces(a)
1301-
assert.Equal(t, test.expected, actual)
1389+
actual, err := r.getApplicationSetSourceNamespaces(a)
1390+
assert.NoError(t, err)
1391+
1392+
if actual == nil {
1393+
actual = []string{}
1394+
}
1395+
expected := test.expected
1396+
if expected == nil {
1397+
expected = []string{}
1398+
}
1399+
1400+
sort.Strings(actual)
1401+
sort.Strings(expected)
1402+
1403+
assert.Equal(t, expected, actual)
13021404
})
13031405
}
13041406
}

controllers/argocd/role.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,14 @@ func (r *ReconcileArgoCD) reconcileRoleForApplicationSourceNamespaces(name strin
261261
}
262262
role.Namespace = namespace.Name
263263
// patch rules if appset in source namespace is allowed
264-
if contains(r.getApplicationSetSourceNamespaces(cr), sourceNamespace) {
264+
appsetSourceNamespaces, err := r.getApplicationSetSourceNamespaces(cr)
265+
if err == nil && contains(appsetSourceNamespaces, sourceNamespace) {
265266
role.Rules = append(role.Rules, policyRuleForServerApplicationSetSourceNamespaces()...)
266267
}
267268

268269
created := false
269270
existingRole := v1.Role{}
270-
err := r.Get(context.TODO(), types.NamespacedName{Name: role.Name, Namespace: namespace.Name}, &existingRole)
271+
err = r.Get(context.TODO(), types.NamespacedName{Name: role.Name, Namespace: namespace.Name}, &existingRole)
271272
if err != nil {
272273
if !errors.IsNotFound(err) {
273274
return fmt.Errorf("failed to reconcile the role for the service account associated with %s : %s", name, err)

docs/usage/appsets-in-any-namespace.md

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,19 @@ To manage the `ApplicationSet` resources in non-control plane namespaces i.e out
1414

1515
## Enable ApplicationSets in a namespace
1616

17-
To enable this feature in a namespace, add the namespace name under `.spec.applicationSet.sourceNamespaces` field in ArgoCD CR.
17+
To enable this feature in a namespace, add the namespace name under `.spec.applicationSet.sourceNamespaces` in the ArgoCD CR.
18+
This field supports both:
19+
- Glob-style wildcards (e.g., `team-*`, `team-frontend`, `app-??`)
20+
- Regular expressions (wrapped in forward slashes, e.g., `/^team-(frontend|backend)$/`, `/^team-.*$/`)
21+
22+
The operator resolves these patterns to actual namespaces at reconcile time and passes the expanded, concrete list to the ApplicationSet controller.
23+
24+
!!! note
25+
Regular expression patterns must be wrapped in forward slashes (`/pattern/`) to be treated as regex. Patterns without slashes are treated as glob patterns. For example:
26+
- `team-*` - glob pattern (matches team-1, team-2, etc.)
27+
- `/^team-[0-9]+$/` - regex pattern (matches team-1, team-2, but not team-frontend)
28+
29+
### Enable ApplicationSets in a specific namespace
1830

1931
For example, following configuration will allow `example` Argo CD instance to create & manage `ApplicationSet` resource in `foo` namespace.
2032
```yaml
@@ -28,10 +40,46 @@ spec:
2840
- foo
2941
```
3042
31-
As of now, wildcards are not supported in `.spec.applicationSet.sourceNamespaces`.
43+
### Enable ApplicationSets in namespaces matching a pattern
44+
45+
You can use wildcard patterns or regular expressions to automatically provision ApplicationSet permissions in all namespaces that match the pattern:
46+
47+
**Using glob patterns:**
48+
```yaml
49+
apiVersion: argoproj.io/v1beta1
50+
kind: ArgoCD
51+
metadata:
52+
name: example
53+
spec:
54+
applicationSet:
55+
sourceNamespaces:
56+
- team-* # glob pattern
57+
```
58+
59+
**Using regular expressions:**
60+
```yaml
61+
apiVersion: argoproj.io/v1beta1
62+
kind: ArgoCD
63+
metadata:
64+
name: example
65+
spec:
66+
applicationSet:
67+
sourceNamespaces:
68+
- /^team-(frontend|backend)$/ # regex pattern (note the /.../ wrapper)
69+
- /^team-[0-9]+$/ # regex: matches team-1, team-2, etc. (numbers only)
70+
```
71+
72+
In the glob pattern example, permissions are granted to namespaces matching `team-*`, such as `team-1`, `team-2`, `team-frontend`, etc. In the regex example, permissions are granted only to namespaces matching the specific regex patterns (e.g., `team-frontend` and `team-backend` for the first pattern, or numeric-only namespaces like `team-1`, `team-2` for the second pattern).
73+
74+
The Operator will automatically create the necessary RBAC permissions in all existing namespaces that match the pattern, and will continue to provision permissions for newly created namespaces that match the pattern.
75+
76+
!!! warning
77+
Exercise caution when using broad wildcard patterns such as `*` or `*-prod`. These patterns can match a large number of namespaces, including system namespaces or sensitive environments, potentially granting unintended access. Always use the most specific pattern that meets your requirements and regularly audit which namespaces match your patterns.
3278

3379
!!! important
3480
Ensure that [Apps in Any Namespace](./apps-in-any-namespace.md) is enabled on target namespace i.e the target namespace name is part of `.spec.sourceNamespaces` field in ArgoCD CR.
81+
82+
For more details about ApplicationSets in Any Namespace, see the upstream [Argo CD documentation](https://argo-cd.readthedocs.io/en/latest/operator-manual/applicationset/Appset-Any-Namespace/).
3583

3684
The Operator creates/modifies below RBAC resources when ApplicationSets in Any Namespace is enabled
3785

0 commit comments

Comments
 (0)