Compare commits

...

8 Commits

Author SHA1 Message Date
argo-cd-cherry-pick-bot[bot]
ea71adbae5 chore(deps): bump google.golang.org/grpc from 1.79.2 to 1.79.3 (cherry-pick #26886 for 3.4) (#26952)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Blake Pettersson <blake.pettersson@gmail.com>
2026-03-22 15:46:39 +02:00
argo-cd-cherry-pick-bot[bot]
5ed403cf60 fix(server): fix find container logic for terminal (cherry-pick #26858 for 3.4) (#26933)
Signed-off-by: linghaoSu <linghao.su@daocloud.io>
Co-authored-by: Linghao Su <linghao.su@daocloud.io>
2026-03-20 13:00:53 +01:00
github-actions[bot]
9044c6c0ff Bump version to 3.4.0-rc2 on release-3.4 branch (#26927)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: jannfis <3942683+jannfis@users.noreply.github.com>
2026-03-19 19:00:49 -04:00
argo-cd-cherry-pick-bot[bot]
3157fb15a4 feat(helm): support wildcard glob patterns for valueFiles (cherry-pick #26768 for 3.4) (#26919)
Signed-off-by: nitishfy <justnitish06@gmail.com>
Co-authored-by: Nitish Kumar <justnitish06@gmail.com>
2026-03-19 15:48:06 -04:00
argo-cd-cherry-pick-bot[bot]
e70034a44b fix(ci): add .gitkeep to images dir (cherry-pick #26892 for 3.4) (#26912)
Signed-off-by: Blake Pettersson <blake.pettersson@gmail.com>
Co-authored-by: Blake Pettersson <blake.pettersson@gmail.com>
2026-03-19 15:36:36 +02:00
argo-cd-cherry-pick-bot[bot]
5deef68eaf fix(ui): include _-prefixed dirs in embedded assets (cherry-pick #26589 for 3.4) (#26909)
Signed-off-by: choejwoo <jaewoo45@gmail.com>
Co-authored-by: Jaewoo Choi <jaewoo45@gmail.com>
2026-03-19 15:35:50 +02:00
argo-cd-cherry-pick-bot[bot]
21e13a621e fix(UI): show RollingSync step clearly when labels match no step (cherry-pick #26877 for 3.4) (#26882)
Signed-off-by: Atif Ali <atali@redhat.com>
Co-authored-by: Atif Ali <atali@redhat.com>
2026-03-17 21:41:53 -04:00
argo-cd-cherry-pick-bot[bot]
226178c1a5 fix: stack overflow when processing circular ownerrefs in resource graph (#26783) (cherry-pick #26790 for 3.4) (#26878)
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Signed-off-by: Jonathan Ogilvie <679297+jcogilvie@users.noreply.github.com>
Co-authored-by: Jonathan Ogilvie <679297+jcogilvie@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-17 22:55:48 +01:00
37 changed files with 1418 additions and 140 deletions

View File

@@ -1 +1 @@
3.4.0-rc1
3.4.0-rc2

View File

@@ -90,6 +90,241 @@ source:
ignoreMissingValueFiles: true
```
## Glob Patterns in Value Files
Glob patterns can be used in `valueFiles` entries to match multiple files at once. This is useful
when the set of environment-specific override files is not known in advance, or when you want to
pick up new files automatically without updating the Application spec.
```bash
# Single quotes prevent the shell from expanding the glob before Argo CD receives it
argocd app set helm-guestbook --values 'envs/*.yaml'
```
In the declarative syntax:
```yaml
source:
helm:
valueFiles:
- envs/*.yaml
```
### Supported pattern syntax
Glob expansion uses the [doublestar](https://github.com/bmatcuk/doublestar) library.
| Pattern | Description |
|---------|-------------|
| `*` | Matches any sequence of non-separator characters within a single directory level |
| `?` | Matches any single non-separator character |
| `[abc]` | Matches one of the characters listed inside the brackets |
| `[a-z]` | Matches any character in the given range |
| `**` | Matches any sequence of characters including `/` (recursive across directory levels) |
### How files are passed to Helm
Each matched file is passed to `helm template` as a separate `--values <path>` flag, in the same
order they appear after expansion. This is identical to listing each file individually in
`valueFiles`. Argo CD does the expansion before invoking Helm.
Matched files are expanded **in-place** within the `valueFiles` list and sorted in **lexical
(alphabetical) order**. Because Helm gives higher precedence to later `--values` flags, lexical
order determines which file wins when the same key appears in multiple files.
```
envs/
a.yaml # sets foo: a-value
b.yaml # sets foo: b-value
```
```yaml
# envs/*.yaml expands to: envs/a.yaml, envs/b.yaml (lexical order)
# b.yaml is last → foo = "b-value"
source:
helm:
valueFiles:
- envs/*.yaml
```
When you have multiple entries in `valueFiles`, the relative order between entries is preserved.
Glob expansion only reorders files within a single pattern:
```yaml
valueFiles:
- base.yaml # passed first
- overrides/*.yaml # expanded in lexical order, passed after base.yaml
- final.yaml # passed last, highest precedence
```
### Recursive matching with `**`
Use `**` to match files at any depth below a directory:
```yaml
# envs/**/*.yaml processes each directory's own files before descending into subdirectories,
# with directories and files sorted alphabetically at each level.
#
# envs/a.yaml ← 'a' (flat file in envs/)
# envs/z.yaml ← 'z' (flat file in envs/, processed before descending)
# envs/nested/c.yaml ← inside envs/nested/, processed after envs/ flat files
#
# nested/c.yaml is last → foo = "nested-value"
source:
helm:
valueFiles:
- envs/**/*.yaml
```
> [!NOTE]
> `**` matches zero or more path segments, so `envs/**/*.yaml` also matches files directly
> inside `envs/` (not just subdirectories). doublestar traverses directories in lexical order
> and processes each directory's own files (alphabetically) before descending into its
> subdirectories. This means `envs/z.yaml` always comes before `envs/nested/c.yaml`, even
> though `'n' < 'z'` alphabetically. To make ordering fully explicit and predictable,
> use numeric prefixes (see [Naming conventions](#naming-conventions)).
### Using environment variables in glob patterns
[Build environment variables](./build-environment.md) are substituted **before** the glob is
evaluated, so you can construct patterns dynamically:
```yaml
source:
helm:
valueFiles:
- envs/$ARGOCD_APP_NAME/*.yaml
```
This lets a single Application template expand to the right set of files per app name.
### Glob patterns with multiple sources
Glob patterns work with [value files from an external repository](./multiple_sources.md#helm-value-files-from-external-git-repository).
The `$ref` variable is resolved first to the external repo's root, and the rest of the pattern is
evaluated within that repo's directory tree:
```yaml
sources:
- repoURL: https://git.example.com/my-configs.git
ref: configs
- repoURL: https://git.example.com/my-chart.git
path: chart
helm:
valueFiles:
- $configs/envs/*.yaml # matches files in the 'my-configs' repo under envs/
```
### Naming conventions
Because files are sorted lexically, the sort order controls merge precedence. A common pattern is
to use a numeric prefix to make the intended order explicit:
```
values/
00-defaults.yaml
10-region.yaml
20-env.yaml
30-override.yaml
```
```yaml
valueFiles:
- values/*.yaml
# expands to: 00-defaults.yaml, 10-region.yaml, 20-env.yaml, 30-override.yaml
# 30-override.yaml has the highest precedence
```
Without a prefix, pure alphabetical ordering applies. Be careful with names that sort
unexpectedly, for example `values-10.yaml` sorts before `values-9.yaml` because `"1"` < `"9"`
lexically.
### Constraints and limitations
**Path boundary**: Glob patterns cannot match files outside the repository root, even with
patterns like `../../secrets/*.yaml`. Argo CD resolves the pattern's base path against the
repository root before expanding it, and any match that would escape the root is rejected.
**Symlinks**: Argo CD follows symlinks when checking the path boundary. A symlink that lives
inside the repository but points to a target outside the repository root is rejected, even though
the symlink's own path is within the repo. This check applies to every file produced by glob
expansion, including multi-hop symlink chains. Symlinks that resolve to a target still inside the
repository are allowed.
**Absolute paths**: A path starting with `/` is treated as relative to the **repository root**,
not the filesystem root. The pattern `/configs/*.yaml` matches files in the `configs/` directory
at the top of the repository.
**Remote URLs are not glob-expanded**: Entries that are remote URLs (e.g.
`https://raw.githubusercontent.com/.../values.yaml`) are passed to Helm as-is. Glob characters
in a URL have no special meaning and will cause the URL to fail if the literal characters are not
part of the URL.
**Shell quoting on the CLI**: Shells expand glob patterns before passing arguments to programs.
Always quote patterns to prevent unintended shell expansion:
```bash
# Correct: single quotes pass the literal pattern to Argo CD
argocd app set myapp --values 'envs/*.yaml'
# Incorrect: the shell expands *.yaml against the current directory first
argocd app set myapp --values envs/*.yaml
```
### Deduplication
Each file is included only once, but **explicit entries take priority over glob matches** when
determining position. If a file appears both in a glob pattern and as an explicit entry, the glob
skips it and the explicit entry places it at its declared position.
```yaml
valueFiles:
- envs/*.yaml # expands to base.yaml, prod.yaml — but prod.yaml is listed explicitly below,
# so the glob skips it: only base.yaml is added here
- envs/prod.yaml # placed here at the end, giving it highest Helm precedence
```
This means you can use a glob to pick up all files in a directory and then pin a specific file to
the end (highest precedence) by listing it explicitly after the glob.
If the same file (same absolute path) is matched by two glob patterns, it is included at the
position of the first match. Subsequent glob matches for that exact path are silently dropped.
Files with the same name but at different paths are treated as distinct files and are always included.
```yaml
valueFiles:
- envs/*.yaml # matches envs/base.yaml, envs/prod.yaml
- envs/**/*.yaml # envs/prod.yaml already matched above and is skipped;
# envs/nested/prod.yaml is a different path and is still included
```
### No-match behavior
If a glob pattern matches no files, Argo CD saves the Application spec (the spec is not invalid and
the files may be added to the repository later) and surfaces a `ComparisonError` condition on the
Application:
```
values file glob "nonexistent/*.yaml" matched no files
```
The app will remain in a degraded state until the pattern matches at least one file or the pattern
is removed. No spec update is required once the files are added to the repository.
To silently skip a pattern that matches no files instead of raising an error, combine the glob with
`ignoreMissingValueFiles`:
```yaml
source:
helm:
valueFiles:
- envs/*.yaml
ignoreMissingValueFiles: true
```
This is useful for implementing a default/override pattern where override files may not exist in
every environment.
## Values
Argo CD supports the equivalent of a values file directly in the Application manifest using the `source.helm.valuesObject` key.

View File

@@ -92,6 +92,15 @@ const (
RespectRbacStrict
)
// callState tracks whether action() has been called on a resource during hierarchy iteration.
type callState int
const (
notCalled callState = iota // action() has not been called yet
inProgress // action() is currently being processed (in call stack)
completed // action() has been called and processing is complete
)
type apiMeta struct {
namespaced bool
// watchCancel stops the watch of all resources for this API. This gets called when the cache is invalidated or when
@@ -1186,8 +1195,11 @@ func (c *clusterCache) IterateHierarchyV2(keys []kube.ResourceKey, action func(r
c.lock.RLock()
defer c.lock.RUnlock()
// Track visited resources to avoid cycles
visited := make(map[kube.ResourceKey]int)
// Track whether action() has been called on each resource (notCalled/inProgress/completed).
// This is shared across processNamespaceHierarchy and processCrossNamespaceChildren.
// Note: This is distinct from 'crossNSTraversed' in processCrossNamespaceChildren, which tracks
// whether we've traversed a cluster-scoped key's cross-namespace children.
actionCallState := make(map[kube.ResourceKey]callState)
// Group keys by namespace for efficient processing
keysPerNamespace := make(map[string][]kube.ResourceKey)
@@ -1203,12 +1215,18 @@ func (c *clusterCache) IterateHierarchyV2(keys []kube.ResourceKey, action func(r
for namespace, namespaceKeys := range keysPerNamespace {
nsNodes := c.nsIndex[namespace]
graph := buildGraph(nsNodes)
c.processNamespaceHierarchy(namespaceKeys, nsNodes, graph, visited, action)
c.processNamespaceHierarchy(namespaceKeys, nsNodes, graph, actionCallState, action)
}
// Process pre-computed cross-namespace children
if clusterKeys, ok := keysPerNamespace[""]; ok {
c.processCrossNamespaceChildren(clusterKeys, visited, action)
// Track which cluster-scoped keys have had their cross-namespace children traversed.
// This is distinct from 'actionCallState' - a resource may have had action() called
// (i.e., its actionCallState is in the completed state) but not yet had its cross-namespace
// children traversed. This prevents infinite recursion when resources have circular
// ownerReferences.
crossNSTraversed := make(map[kube.ResourceKey]bool)
c.processCrossNamespaceChildren(clusterKeys, actionCallState, crossNSTraversed, action)
}
}
@@ -1216,12 +1234,21 @@ func (c *clusterCache) IterateHierarchyV2(keys []kube.ResourceKey, action func(r
// This enables traversing from cluster-scoped parents to their namespaced children across namespace boundaries.
// It also handles multi-level hierarchies where cluster-scoped resources own other cluster-scoped resources
// that in turn own namespaced resources (e.g., Provider -> ProviderRevision -> Deployment in Crossplane).
// The crossNSTraversed map tracks which keys have already been processed to prevent infinite recursion
// from circular ownerReferences (e.g., a resource that owns itself).
func (c *clusterCache) processCrossNamespaceChildren(
clusterScopedKeys []kube.ResourceKey,
visited map[kube.ResourceKey]int,
actionCallState map[kube.ResourceKey]callState,
crossNSTraversed map[kube.ResourceKey]bool,
action func(resource *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool,
) {
for _, clusterKey := range clusterScopedKeys {
// Skip if already processed (cycle detection)
if crossNSTraversed[clusterKey] {
continue
}
crossNSTraversed[clusterKey] = true
// Get cluster-scoped resource to access its UID
clusterResource := c.resources[clusterKey]
if clusterResource == nil {
@@ -1236,16 +1263,17 @@ func (c *clusterCache) processCrossNamespaceChildren(
continue
}
alreadyVisited := visited[childKey] != 0
alreadyProcessed := actionCallState[childKey] != notCalled
// If child is cluster-scoped and was already visited by processNamespaceHierarchy,
// If child is cluster-scoped and action() was already called by processNamespaceHierarchy,
// we still need to recursively check for its cross-namespace children.
// This handles multi-level hierarchies like: ClusterScoped -> ClusterScoped -> Namespaced
// (e.g., Crossplane's Provider -> ProviderRevision -> Deployment)
if alreadyVisited {
if alreadyProcessed {
if childKey.Namespace == "" {
// Recursively process cross-namespace children of this cluster-scoped child
c.processCrossNamespaceChildren([]kube.ResourceKey{childKey}, visited, action)
// The crossNSTraversed map prevents infinite recursion on circular ownerReferences
c.processCrossNamespaceChildren([]kube.ResourceKey{childKey}, actionCallState, crossNSTraversed, action)
}
continue
}
@@ -1258,16 +1286,16 @@ func (c *clusterCache) processCrossNamespaceChildren(
// Process this child
if action(child, nsNodes) {
visited[childKey] = 1
actionCallState[childKey] = inProgress
// Recursively process descendants using index-based traversal
c.iterateChildrenUsingIndex(child, nsNodes, visited, action)
c.iterateChildrenUsingIndex(child, nsNodes, actionCallState, action)
// If this child is also cluster-scoped, recursively process its cross-namespace children
if childKey.Namespace == "" {
c.processCrossNamespaceChildren([]kube.ResourceKey{childKey}, visited, action)
c.processCrossNamespaceChildren([]kube.ResourceKey{childKey}, actionCallState, crossNSTraversed, action)
}
visited[childKey] = 2
actionCallState[childKey] = completed
}
}
}
@@ -1278,14 +1306,14 @@ func (c *clusterCache) processCrossNamespaceChildren(
func (c *clusterCache) iterateChildrenUsingIndex(
parent *Resource,
nsNodes map[kube.ResourceKey]*Resource,
visited map[kube.ResourceKey]int,
actionCallState map[kube.ResourceKey]callState,
action func(resource *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool,
) {
// Look up direct children of this parent using the index
childKeys := c.parentUIDToChildren[parent.Ref.UID]
for _, childKey := range childKeys {
if visited[childKey] != 0 {
continue // Already visited or in progress
if actionCallState[childKey] != notCalled {
continue // action() already called or in progress
}
child := c.resources[childKey]
@@ -1300,10 +1328,10 @@ func (c *clusterCache) iterateChildrenUsingIndex(
}
if action(child, nsNodes) {
visited[childKey] = 1
actionCallState[childKey] = inProgress
// Recursively process this child's descendants
c.iterateChildrenUsingIndex(child, nsNodes, visited, action)
visited[childKey] = 2
c.iterateChildrenUsingIndex(child, nsNodes, actionCallState, action)
actionCallState[childKey] = completed
}
}
}
@@ -1313,22 +1341,19 @@ func (c *clusterCache) processNamespaceHierarchy(
namespaceKeys []kube.ResourceKey,
nsNodes map[kube.ResourceKey]*Resource,
graph map[kube.ResourceKey]map[types.UID]*Resource,
visited map[kube.ResourceKey]int,
actionCallState map[kube.ResourceKey]callState,
action func(resource *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool,
) {
for _, key := range namespaceKeys {
visited[key] = 0
}
for _, key := range namespaceKeys {
res := c.resources[key]
if visited[key] == 2 || !action(res, nsNodes) {
if actionCallState[key] == completed || !action(res, nsNodes) {
continue
}
visited[key] = 1
actionCallState[key] = inProgress
if _, ok := graph[key]; ok {
for _, child := range graph[key] {
if visited[child.ResourceKey()] == 0 && action(child, nsNodes) {
child.iterateChildrenV2(graph, nsNodes, visited, func(err error, child *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool {
if actionCallState[child.ResourceKey()] == notCalled && action(child, nsNodes) {
child.iterateChildrenV2(graph, nsNodes, actionCallState, func(err error, child *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool {
if err != nil {
c.log.V(2).Info(err.Error())
return false
@@ -1338,7 +1363,7 @@ func (c *clusterCache) processNamespaceHierarchy(
}
}
}
visited[key] = 2
actionCallState[key] = completed
}
}

View File

@@ -2189,3 +2189,112 @@ func TestIterateHierarchyV2_NoDuplicatesCrossNamespace(t *testing.T) {
assert.Equal(t, 1, visitCount["namespaced-child"], "namespaced child should be visited once")
assert.Equal(t, 1, visitCount["cluster-child"], "cluster child should be visited once")
}
func TestIterateHierarchyV2_CircularOwnerReference_NoStackOverflow(t *testing.T) {
// Test that self-referencing resources (circular ownerReferences) don't cause stack overflow.
// This reproduces the bug reported in https://github.com/argoproj/argo-cd/issues/26783
// where a resource with an ownerReference pointing to itself caused infinite recursion.
// Create a cluster-scoped resource that owns itself (self-referencing)
selfReferencingResource := &corev1.Namespace{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Namespace",
},
ObjectMeta: metav1.ObjectMeta{
Name: "self-referencing",
UID: "self-ref-uid",
ResourceVersion: "1",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: "v1",
Kind: "Namespace",
Name: "self-referencing",
UID: "self-ref-uid", // Points to itself
}},
},
}
cluster := newCluster(t, selfReferencingResource).WithAPIResources([]kube.APIResourceInfo{{
GroupKind: schema.GroupKind{Group: "", Kind: "Namespace"},
GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"},
Meta: metav1.APIResource{Namespaced: false},
}})
err := cluster.EnsureSynced()
require.NoError(t, err)
visitCount := 0
// This should complete without stack overflow
cluster.IterateHierarchyV2(
[]kube.ResourceKey{kube.GetResourceKey(mustToUnstructured(selfReferencingResource))},
func(resource *Resource, _ map[kube.ResourceKey]*Resource) bool {
visitCount++
return true
},
)
// The self-referencing resource should be visited exactly once
assert.Equal(t, 1, visitCount, "self-referencing resource should be visited exactly once")
}
func TestIterateHierarchyV2_CircularOwnerChain_NoStackOverflow(t *testing.T) {
// Test that circular ownership chains (A -> B -> A) don't cause stack overflow.
// This is a more complex case where two resources own each other.
resourceA := &corev1.Namespace{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Namespace",
},
ObjectMeta: metav1.ObjectMeta{
Name: "resource-a",
UID: "uid-a",
ResourceVersion: "1",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: "v1",
Kind: "Namespace",
Name: "resource-b",
UID: "uid-b", // A is owned by B
}},
},
}
resourceB := &corev1.Namespace{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Namespace",
},
ObjectMeta: metav1.ObjectMeta{
Name: "resource-b",
UID: "uid-b",
ResourceVersion: "1",
OwnerReferences: []metav1.OwnerReference{{
APIVersion: "v1",
Kind: "Namespace",
Name: "resource-a",
UID: "uid-a", // B is owned by A
}},
},
}
cluster := newCluster(t, resourceA, resourceB).WithAPIResources([]kube.APIResourceInfo{{
GroupKind: schema.GroupKind{Group: "", Kind: "Namespace"},
GroupVersionResource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"},
Meta: metav1.APIResource{Namespaced: false},
}})
err := cluster.EnsureSynced()
require.NoError(t, err)
visitCount := make(map[string]int)
// This should complete without stack overflow
cluster.IterateHierarchyV2(
[]kube.ResourceKey{kube.GetResourceKey(mustToUnstructured(resourceA))},
func(resource *Resource, _ map[kube.ResourceKey]*Resource) bool {
visitCount[resource.Ref.Name]++
return true
},
)
// Each resource in the circular chain should be visited exactly once
assert.Equal(t, 1, visitCount["resource-a"], "resource-a should be visited exactly once")
assert.Equal(t, 1, visitCount["resource-b"], "resource-b should be visited exactly once")
}

View File

@@ -76,16 +76,16 @@ func (r *Resource) toOwnerRef() metav1.OwnerReference {
}
// iterateChildrenV2 is a depth-first traversal of the graph of resources starting from the current resource.
func (r *Resource) iterateChildrenV2(graph map[kube.ResourceKey]map[types.UID]*Resource, ns map[kube.ResourceKey]*Resource, visited map[kube.ResourceKey]int, action func(err error, child *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool) {
func (r *Resource) iterateChildrenV2(graph map[kube.ResourceKey]map[types.UID]*Resource, ns map[kube.ResourceKey]*Resource, actionCallState map[kube.ResourceKey]callState, action func(err error, child *Resource, namespaceResources map[kube.ResourceKey]*Resource) bool) {
key := r.ResourceKey()
if visited[key] == 2 {
if actionCallState[key] == completed {
return
}
// this indicates that we've started processing this node's children
visited[key] = 1
actionCallState[key] = inProgress
defer func() {
// this indicates that we've finished processing this node's children
visited[key] = 2
actionCallState[key] = completed
}()
children, ok := graph[key]
if !ok || children == nil {
@@ -94,13 +94,13 @@ func (r *Resource) iterateChildrenV2(graph map[kube.ResourceKey]map[types.UID]*R
for _, child := range children {
childKey := child.ResourceKey()
// For cross-namespace relationships, child might not be in ns, so use it directly from graph
switch visited[childKey] {
case 1:
switch actionCallState[childKey] {
case inProgress:
// Since we encountered a node that we're currently processing, we know we have a circular dependency.
_ = action(fmt.Errorf("circular dependency detected. %s is child and parent of %s", childKey.String(), key.String()), child, ns)
case 0:
case notCalled:
if action(nil, child, ns) {
child.iterateChildrenV2(graph, ns, visited, action)
child.iterateChildrenV2(graph, ns, actionCallState, action)
}
}
}

2
go.mod
View File

@@ -102,7 +102,7 @@ require (
golang.org/x/term v0.41.0
golang.org/x/time v0.15.0
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57
google.golang.org/grpc v1.79.2
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1

4
go.sum
View File

@@ -1404,8 +1404,8 @@ google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=

View File

@@ -12,4 +12,4 @@ resources:
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v3.4.0-rc1
newTag: v3.4.0-rc2

View File

@@ -5,7 +5,7 @@ kind: Kustomization
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v3.4.0-rc1
newTag: v3.4.0-rc2
resources:
- ./application-controller
- ./dex

View File

@@ -31332,7 +31332,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -31473,7 +31473,7 @@ spec:
key: log.format.timestamp
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -31601,7 +31601,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -31910,7 +31910,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -31963,7 +31963,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -32366,7 +32366,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -31300,7 +31300,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -31429,7 +31429,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -31738,7 +31738,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -31791,7 +31791,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -32194,7 +32194,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -12,4 +12,4 @@ resources:
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v3.4.0-rc1
newTag: v3.4.0-rc2

View File

@@ -12,7 +12,7 @@ patches:
images:
- name: quay.io/argoproj/argocd
newName: quay.io/argoproj/argocd
newTag: v3.4.0-rc1
newTag: v3.4.0-rc2
resources:
- ../../base/application-controller
- ../../base/applicationset-controller

View File

@@ -32758,7 +32758,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -32899,7 +32899,7 @@ spec:
key: log.format.timestamp
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -33057,7 +33057,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -33159,7 +33159,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -33283,7 +33283,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -33618,7 +33618,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -33671,7 +33671,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -34100,7 +34100,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -34532,7 +34532,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -32728,7 +32728,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -32887,7 +32887,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -32989,7 +32989,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -33113,7 +33113,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -33448,7 +33448,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -33501,7 +33501,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -33930,7 +33930,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -34362,7 +34362,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -2005,7 +2005,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -2146,7 +2146,7 @@ spec:
key: log.format.timestamp
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -2304,7 +2304,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -2406,7 +2406,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -2530,7 +2530,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -2865,7 +2865,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -2918,7 +2918,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -3347,7 +3347,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -3779,7 +3779,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -1975,7 +1975,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -2134,7 +2134,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -2236,7 +2236,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -2360,7 +2360,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -2695,7 +2695,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -2748,7 +2748,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -3177,7 +3177,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -3609,7 +3609,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -31776,7 +31776,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -31917,7 +31917,7 @@ spec:
key: log.format.timestamp
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -32075,7 +32075,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -32177,7 +32177,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -32279,7 +32279,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -32588,7 +32588,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -32641,7 +32641,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -33068,7 +33068,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -33500,7 +33500,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

16
manifests/install.yaml generated
View File

@@ -31744,7 +31744,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -31903,7 +31903,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -32005,7 +32005,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -32107,7 +32107,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -32416,7 +32416,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -32469,7 +32469,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -32896,7 +32896,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -33328,7 +33328,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -1023,7 +1023,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -1164,7 +1164,7 @@ spec:
key: log.format.timestamp
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -1322,7 +1322,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -1424,7 +1424,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -1526,7 +1526,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -1835,7 +1835,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -1888,7 +1888,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -2315,7 +2315,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -2747,7 +2747,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -991,7 +991,7 @@ spec:
key: applicationsetcontroller.status.max.resources.count
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-applicationset-controller
ports:
@@ -1150,7 +1150,7 @@ spec:
- -n
- /usr/local/bin/argocd
- /shared/argocd-dex
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: copyutil
securityContext:
@@ -1252,7 +1252,7 @@ spec:
key: notificationscontroller.repo.server.plaintext
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
tcpSocket:
@@ -1354,7 +1354,7 @@ spec:
- argocd
- admin
- redis-initial-password
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: IfNotPresent
name: secret-init
securityContext:
@@ -1663,7 +1663,7 @@ spec:
value: /helm-working-dir
- name: HELM_DATA_HOME
value: /helm-working-dir
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
failureThreshold: 3
@@ -1716,7 +1716,7 @@ spec:
command:
- sh
- -c
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
name: copyutil
securityContext:
allowPrivilegeEscalation: false
@@ -2143,7 +2143,7 @@ spec:
key: server.sync.replace.allowed
name: argocd-cmd-params-cm
optional: true
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
livenessProbe:
httpGet:
@@ -2575,7 +2575,7 @@ spec:
optional: true
- name: KUBECACHEDIR
value: /tmp/kubecache
image: quay.io/argoproj/argocd:v3.4.0-rc1
image: quay.io/argoproj/argocd:v3.4.0-rc2
imagePullPolicy: Always
name: argocd-application-controller
ports:

View File

@@ -18,6 +18,7 @@ import (
"time"
"github.com/TomOnTime/utfutil"
"github.com/bmatcuk/doublestar/v4"
imagev1 "github.com/opencontainers/image-spec/specs-go/v1"
"sigs.k8s.io/yaml"
@@ -656,6 +657,13 @@ func (s *Service) GenerateManifest(ctx context.Context, q *apiclient.ManifestReq
return nil, err
}
}
// Convert typed errors to gRPC status codes so callers can use status.Code()
// rather than string matching.
var globNoMatch *GlobNoMatchError
if errors.As(err, &globNoMatch) {
return nil, status.Error(codes.NotFound, err.Error())
}
return res, err
}
@@ -1376,19 +1384,55 @@ func getResolvedValueFiles(
gitRepoPaths utilio.TempPaths,
ignoreMissingValueFiles bool,
) ([]pathutil.ResolvedFilePath, error) {
// Pre-collect resolved paths for all explicit (non-glob) entries. This allows glob
// expansion to skip files that also appear explicitly, so the explicit entry controls
// the final position. For example, with ["*.yaml", "c.yaml"], c.yaml is excluded from
// the glob expansion and placed at the end where it was explicitly listed.
explicitPaths := make(map[pathutil.ResolvedFilePath]struct{})
for _, rawValueFile := range rawValueFiles {
referencedSource := getReferencedSource(rawValueFile, refSources)
var resolved pathutil.ResolvedFilePath
var err error
if referencedSource != nil {
resolved, err = getResolvedRefValueFile(rawValueFile, env, allowedValueFilesSchemas, referencedSource.Repo.Repo, gitRepoPaths)
} else {
resolved, _, err = pathutil.ResolveValueFilePathOrUrl(appPath, repoRoot, env.Envsubst(rawValueFile), allowedValueFilesSchemas)
}
if err != nil {
continue // resolution errors will be surfaced in the main loop below
}
if !isGlobPath(string(resolved)) {
explicitPaths[resolved] = struct{}{}
}
}
var resolvedValueFiles []pathutil.ResolvedFilePath
seen := make(map[pathutil.ResolvedFilePath]struct{})
appendUnique := func(p pathutil.ResolvedFilePath) {
if _, ok := seen[p]; !ok {
seen[p] = struct{}{}
resolvedValueFiles = append(resolvedValueFiles, p)
}
}
for _, rawValueFile := range rawValueFiles {
isRemote := false
var resolvedPath pathutil.ResolvedFilePath
var err error
referencedSource := getReferencedSource(rawValueFile, refSources)
// effectiveRoot is the repository root used for the symlink boundary check
// on glob matches. For ref-source paths this is the external repo's checkout
// directory; for local paths it is the main repo root.
effectiveRoot := repoRoot
if referencedSource != nil {
// If the $-prefixed path appears to reference another source, do env substitution _after_ resolving that source.
resolvedPath, err = getResolvedRefValueFile(rawValueFile, env, allowedValueFilesSchemas, referencedSource.Repo.Repo, gitRepoPaths)
if err != nil {
return nil, fmt.Errorf("error resolving value file path: %w", err)
}
if refRepoPath := gitRepoPaths.GetPathIfExists(git.NormalizeGitURL(referencedSource.Repo.Repo)); refRepoPath != "" {
effectiveRoot = refRepoPath
}
} else {
// This will resolve val to an absolute path (or a URL)
resolvedPath, isRemote, err = pathutil.ResolveValueFilePathOrUrl(appPath, repoRoot, env.Envsubst(rawValueFile), allowedValueFilesSchemas)
@@ -1397,6 +1441,38 @@ func getResolvedValueFiles(
}
}
// If the resolved path contains a glob pattern, expand it to all matching files.
// doublestar.FilepathGlob is used (consistent with AppSet generators) because it supports
// ** for recursive matching in addition to all standard glob patterns (*,?,[).
// Matches are returned in lexical order, which determines helm's merge precedence
// (later files override earlier ones). Glob patterns are only expanded for local files;
// remote value file URLs (e.g. https://...) are passed through as-is.
// If the glob matches no files and ignoreMissingValueFiles is true, skip it silently.
// Otherwise, return an error — consistent with how missing non-glob value files are handled.
if !isRemote && isGlobPath(string(resolvedPath)) {
matches, err := doublestar.FilepathGlob(string(resolvedPath))
if err != nil {
return nil, fmt.Errorf("error expanding glob pattern %q: %w", rawValueFile, err)
}
if len(matches) == 0 {
if ignoreMissingValueFiles {
log.Debugf(" %s values file glob matched no files", rawValueFile)
continue
}
return nil, &GlobNoMatchError{Pattern: rawValueFile}
}
if err := verifyGlobMatchesWithinRoot(matches, effectiveRoot); err != nil {
return nil, fmt.Errorf("glob pattern %q: %w", rawValueFile, err)
}
for _, match := range matches {
// Skip files that are also listed explicitly - they will be placed
// at their explicit position rather than the glob's position.
if _, isExplicit := explicitPaths[pathutil.ResolvedFilePath(match)]; !isExplicit {
appendUnique(pathutil.ResolvedFilePath(match))
}
}
continue
}
if !isRemote {
_, err = os.Stat(string(resolvedPath))
if os.IsNotExist(err) {
@@ -1407,8 +1483,9 @@ func getResolvedValueFiles(
}
}
resolvedValueFiles = append(resolvedValueFiles, resolvedPath)
appendUnique(resolvedPath)
}
log.Infof("resolved value files: %v", resolvedValueFiles)
return resolvedValueFiles, nil
}
@@ -1478,6 +1555,61 @@ func getRepoCredential(repoCredentials []*v1alpha1.RepoCreds, repoURL string) *v
return nil
}
// GlobNoMatchError is returned when a glob pattern in valueFiles matches no files.
// It is a runtime condition (the files may be added later), not a spec error.
type GlobNoMatchError struct {
Pattern string
}
func (e *GlobNoMatchError) Error() string {
return fmt.Sprintf("values file glob %q matched no files", e.Pattern)
}
// isGlobPath reports whether path contains any glob metacharacters
// supported by doublestar: *, ?, or [. The ** pattern is covered by *.
func isGlobPath(path string) bool {
return strings.ContainsAny(path, "*?[")
}
// verifyGlobMatchesWithinRoot resolves symlinks for each glob match and verifies
// that the resolved target is within effectiveRoot. It protects against symlinks
// inside the repository that point to targets outside it.
//
// doublestar.FilepathGlob uses os.Lstat, so it returns the path of the symlink
// itself (which lives inside the repo) rather than the symlink target. If the
// target is outside the repo, Helm would still follow the link and read the
// external file. This function catches that case before the paths reach Helm.
//
// Both effectiveRoot and each match are canonicalized via filepath.EvalSymlinks
// so the prefix comparison is correct on systems where the working directory is
// itself under a symlink chain (e.g. /var -> /private/var on macOS).
func verifyGlobMatchesWithinRoot(matches []string, effectiveRoot string) error {
absRoot, err := filepath.Abs(effectiveRoot)
if err != nil {
return fmt.Errorf("error resolving repo root: %w", err)
}
canonicalRoot, err := filepath.EvalSymlinks(absRoot)
if err != nil {
return fmt.Errorf("error resolving symlinks in repo root: %w", err)
}
requiredRootPath := canonicalRoot
if !strings.HasSuffix(requiredRootPath, string(os.PathSeparator)) {
requiredRootPath += string(os.PathSeparator)
}
for _, match := range matches {
realMatch, err := filepath.EvalSymlinks(match)
if err != nil {
return fmt.Errorf("error resolving symlink for glob match %q: %w", match, err)
}
// Allow the match to resolve exactly to the root (realMatch+sep == requiredRootPath)
// or to any path beneath it (HasPrefix).
if realMatch+string(os.PathSeparator) != requiredRootPath && !strings.HasPrefix(realMatch, requiredRootPath) {
return fmt.Errorf("glob match %q resolved to outside repository root", match)
}
}
return nil
}
type (
GenerateManifestOpt func(*generateManifestOpt)
generateManifestOpt struct {

View File

@@ -3895,6 +3895,567 @@ func Test_getResolvedValueFiles(t *testing.T) {
}
}
func Test_getResolvedValueFiles_glob(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
paths := utilio.NewRandomizedTempPaths(tempDir)
paths.Add(git.NormalizeGitURL("https://github.com/org/repo1"), path.Join(tempDir, "repo1"))
// main-repo files
require.NoError(t, os.MkdirAll(path.Join(tempDir, "main-repo", "prod", "nested"), 0o755))
require.NoError(t, os.MkdirAll(path.Join(tempDir, "main-repo", "staging"), 0o755))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "a.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "b.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "staging", "e.yaml"), []byte{}, 0o644))
// main-repo envs: used to verify depth-order with ** (z.yaml sorts after nested/ alphabetically
// but is still returned before nested/c.yaml because doublestar matches depth-0 files first).
require.NoError(t, os.MkdirAll(path.Join(tempDir, "main-repo", "envs", "nested"), 0o755))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "envs", "a.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "envs", "z.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"), []byte{}, 0o644))
// repo1 files
require.NoError(t, os.MkdirAll(path.Join(tempDir, "repo1", "prod", "nested"), 0o755))
require.NoError(t, os.WriteFile(path.Join(tempDir, "repo1", "prod", "x.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "repo1", "prod", "y.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(path.Join(tempDir, "repo1", "prod", "nested", "z.yaml"), []byte{}, 0o644))
tests := []struct {
name string
rawPath string
env *v1alpha1.Env
refSources map[string]*v1alpha1.RefTarget
expectedPaths []string
ignoreMissingValueFiles bool
expectedErr bool
}{
{
name: "local glob matches multiple files",
rawPath: "prod/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
expectedPaths: []string{
// the order is a.yaml before b.yaml
// since doublestar.FilepathGlob returns lexical order
path.Join(tempDir, "main-repo", "prod", "a.yaml"),
path.Join(tempDir, "main-repo", "prod", "b.yaml"),
},
},
{
name: "local glob matches no files returns error",
rawPath: "dev/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
expectedPaths: nil,
expectedErr: true,
},
{
name: "local glob matches no files with ignoreMissingValueFiles set to true",
rawPath: "dev/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
ignoreMissingValueFiles: true,
expectedPaths: nil,
},
{
name: "referenced glob matches multiple files in external repo",
rawPath: "$ref/prod/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{
"$ref": {
Repo: v1alpha1.Repository{
Repo: "https://github.com/org/repo1",
},
},
},
expectedPaths: []string{
path.Join(tempDir, "repo1", "prod", "x.yaml"),
path.Join(tempDir, "repo1", "prod", "y.yaml"),
},
},
{
name: "ref glob with env var in path",
rawPath: "$ref/$ENV/*.yaml",
env: &v1alpha1.Env{
&v1alpha1.EnvEntry{
Name: "ENV",
Value: "prod",
},
},
refSources: map[string]*v1alpha1.RefTarget{
"$ref": {
Repo: v1alpha1.Repository{
Repo: "https://github.com/org/repo1",
},
},
},
expectedPaths: []string{
path.Join(tempDir, "repo1", "prod", "x.yaml"),
path.Join(tempDir, "repo1", "prod", "y.yaml"),
},
},
{
name: "local glob single match",
rawPath: "prod/a*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
expectedPaths: []string{path.Join(tempDir, "main-repo", "prod", "a.yaml")},
},
{
name: "recursive glob matches files at all depths under a subdirectory",
// ** matches zero or more path segments, so prod/**/*.yaml covers both
// prod/*.yaml (zero intermediate segments) and prod/nested/*.yaml (one segment), etc.
rawPath: "prod/**/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
// lexical order: prod/a.yaml, prod/b.yaml, prod/nested/c.yaml, prod/nested/d.yaml
expectedPaths: []string{
path.Join(tempDir, "main-repo", "prod", "a.yaml"),
path.Join(tempDir, "main-repo", "prod", "b.yaml"),
path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"),
path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"),
},
},
{
name: "recursive glob from repo root matches yaml files across all directories",
rawPath: "**/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
// doublestar traverses directories in lexical order, processing each directory's
// own files before its subdirectories. So the order is:
// envs/ flat files → envs/nested/ files → prod/ flat files → prod/nested/ files → staging/ files
expectedPaths: []string{
path.Join(tempDir, "main-repo", "envs", "a.yaml"),
path.Join(tempDir, "main-repo", "envs", "z.yaml"),
path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"),
path.Join(tempDir, "main-repo", "prod", "a.yaml"),
path.Join(tempDir, "main-repo", "prod", "b.yaml"),
path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"),
path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"),
path.Join(tempDir, "main-repo", "staging", "e.yaml"),
},
},
{
name: "recursive glob anchored to a named subdirectory matches at any depth",
rawPath: "**/nested/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
expectedPaths: []string{
path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"),
path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"),
path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"),
},
},
{
name: "recursive glob with no matches and ignoreMissingValueFiles skips silently",
rawPath: "**/nonexistent/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
ignoreMissingValueFiles: true,
expectedPaths: nil,
},
{
name: "recursive glob with no matches returns error",
rawPath: "**/nonexistent/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
expectedPaths: nil,
expectedErr: true,
},
{
// z.yaml sorts after "nested/" alphabetically by full path, but doublestar processes
// each directory's own files before descending into subdirectories. So for envs/**/*.yaml:
// envs/ flat files (a, z) come before envs/nested/ files (c), giving:
// a.yaml, z.yaml, nested/c.yaml — not a.yaml, nested/c.yaml, z.yaml.
name: "** depth-order: flat files before nested even when flat file sorts after nested/ alphabetically",
rawPath: "envs/**/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{},
expectedPaths: []string{
path.Join(tempDir, "main-repo", "envs", "a.yaml"),
path.Join(tempDir, "main-repo", "envs", "z.yaml"),
path.Join(tempDir, "main-repo", "envs", "nested", "c.yaml"),
},
},
{
name: "recursive glob in external ref repo",
rawPath: "$ref/prod/**/*.yaml",
env: &v1alpha1.Env{},
refSources: map[string]*v1alpha1.RefTarget{
"$ref": {
Repo: v1alpha1.Repository{
Repo: "https://github.com/org/repo1",
},
},
},
expectedPaths: []string{
// doublestar matches zero path segments before recursing into subdirectories,
// so flat files (x, y) come before nested ones (nested/z).
path.Join(tempDir, "repo1", "prod", "x.yaml"),
path.Join(tempDir, "repo1", "prod", "y.yaml"),
path.Join(tempDir, "repo1", "prod", "nested", "z.yaml"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
repoPath := path.Join(tempDir, "main-repo")
resolvedPaths, err := getResolvedValueFiles(repoPath, repoPath, tt.env, []string{}, []string{tt.rawPath}, tt.refSources, paths, tt.ignoreMissingValueFiles)
if tt.expectedErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Len(t, resolvedPaths, len(tt.expectedPaths))
for i, p := range tt.expectedPaths {
assert.Equal(t, p, string(resolvedPaths[i]))
}
})
}
// Deduplication: first occurrence of a resolved path wins. Subsequent references to the
// same file, whether explicit or via glob are silently dropped. This preserves the
// merge-precedence position set by the first mention of each file.
t.Run("glob then explicit: explicit entry placed at end, giving it highest Helm precedence", func(t *testing.T) {
t.Parallel()
repoPath := path.Join(tempDir, "main-repo")
resolvedPaths, err := getResolvedValueFiles(
repoPath, repoPath,
&v1alpha1.Env{}, []string{},
[]string{
"envs/*.yaml", // glob - z.yaml is explicit so skipped; only a.yaml added
"envs/z.yaml", // explicit - placed last, highest precedence
},
map[string]*v1alpha1.RefTarget{}, paths, false,
)
require.NoError(t, err)
require.Len(t, resolvedPaths, 2)
assert.Equal(t, path.Join(tempDir, "main-repo", "envs", "a.yaml"), string(resolvedPaths[0]))
assert.Equal(t, path.Join(tempDir, "main-repo", "envs", "z.yaml"), string(resolvedPaths[1]))
})
t.Run("explicit path before glob: explicit position is kept, glob re-match is dropped", func(t *testing.T) {
t.Parallel()
repoPath := path.Join(tempDir, "main-repo")
resolvedPaths, err := getResolvedValueFiles(
repoPath, repoPath,
&v1alpha1.Env{}, []string{},
[]string{
"prod/a.yaml", // explicit locks in position 0
"prod/*.yaml", // glob - a.yaml already seen, only b.yaml is new
},
map[string]*v1alpha1.RefTarget{}, paths, false,
)
require.NoError(t, err)
require.Len(t, resolvedPaths, 2)
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[0]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[1]))
})
t.Run("glob before explicit path: explicit position wins, glob skips the explicitly listed file", func(t *testing.T) {
t.Parallel()
repoPath := path.Join(tempDir, "main-repo")
resolvedPaths, err := getResolvedValueFiles(
repoPath, repoPath,
&v1alpha1.Env{}, []string{},
[]string{
"prod/*.yaml", // glob - a.yaml is explicit so skipped; only b.yaml added (pos 0)
"prod/a.yaml", // explicit - placed here at pos 1 (highest precedence)
},
map[string]*v1alpha1.RefTarget{}, paths, false,
)
require.NoError(t, err)
require.Len(t, resolvedPaths, 2)
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[0]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[1]))
})
t.Run("two overlapping globs: second glob only adds files not matched by first", func(t *testing.T) {
t.Parallel()
repoPath := path.Join(tempDir, "main-repo")
resolvedPaths, err := getResolvedValueFiles(
repoPath, repoPath,
&v1alpha1.Env{}, []string{},
[]string{
"prod/*.yaml", // adds a.yaml, b.yaml
"prod/**/*.yaml", // a.yaml, b.yaml already seen; adds nested/c.yaml, nested/d.yaml
},
map[string]*v1alpha1.RefTarget{}, paths, false,
)
require.NoError(t, err)
require.Len(t, resolvedPaths, 4)
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[0]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[1]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"), string(resolvedPaths[2]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"), string(resolvedPaths[3]))
})
t.Run("explicit paths take priority: globs skip explicitly listed files, which are placed at their explicit positions", func(t *testing.T) {
t.Parallel()
repoPath := path.Join(tempDir, "main-repo")
resolvedPaths, err := getResolvedValueFiles(
repoPath, repoPath,
&v1alpha1.Env{}, []string{},
[]string{
"prod/a.yaml", // explicit - pos 0
"prod/*.yaml", // a.yaml and b.yaml are both explicit, skipped entirely
"prod/b.yaml", // explicit - pos 1
"prod/**/*.yaml", // a.yaml, b.yaml, nested/c.yaml all explicit and skipped; nested/d.yaml added - pos 2
"prod/nested/c.yaml", // explicit - pos 3
},
map[string]*v1alpha1.RefTarget{}, paths, false,
)
require.NoError(t, err)
require.Len(t, resolvedPaths, 4)
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "a.yaml"), string(resolvedPaths[0]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "b.yaml"), string(resolvedPaths[1]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "d.yaml"), string(resolvedPaths[2]))
assert.Equal(t, path.Join(tempDir, "main-repo", "prod", "nested", "c.yaml"), string(resolvedPaths[3]))
})
}
func Test_verifyGlobMatchesWithinRoot(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
repoDir := filepath.Join(tempDir, "repo")
outsideDir := filepath.Join(tempDir, "outside")
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "values", "sub"), 0o755))
require.NoError(t, os.MkdirAll(outsideDir, 0o755))
// Files used as symlink targets
inRepoFile := filepath.Join(repoDir, "values", "real.yaml")
outsideFile := filepath.Join(outsideDir, "secret.yaml")
require.NoError(t, os.WriteFile(inRepoFile, []byte{}, 0o644))
require.NoError(t, os.WriteFile(outsideFile, []byte("password: hunter2"), 0o644))
// Symlink inside repo → file inside repo (safe)
inRepoLink := filepath.Join(repoDir, "values", "inrepo-link.yaml")
require.NoError(t, os.Symlink(inRepoFile, inRepoLink))
// Symlink inside repo → file outside repo (escape)
escapeLink := filepath.Join(repoDir, "values", "escape-link.yaml")
require.NoError(t, os.Symlink(outsideFile, escapeLink))
// Two-hop symlink: inside repo → another symlink (still inside) → file inside repo
hop1 := filepath.Join(repoDir, "values", "hop1.yaml")
require.NoError(t, os.Symlink(inRepoLink, hop1)) // hop1 → inRepoLink → real.yaml
// Two-hop symlink: inside repo → another symlink (inside repo) → file outside repo
hop2 := filepath.Join(repoDir, "values", "hop2.yaml")
require.NoError(t, os.Symlink(escapeLink, hop2)) // hop2 → escape-link → secret.yaml
tests := []struct {
name string
matches []string
expectErr bool
errContains string
}{
{
name: "regular file inside root passes",
matches: []string{inRepoFile},
},
{
name: "symlink inside root pointing to file inside root passes",
matches: []string{inRepoLink},
},
{
name: "two-hop chain that stays within root passes",
matches: []string{hop1},
},
{
name: "symlink pointing directly outside root is rejected",
matches: []string{escapeLink},
expectErr: true,
errContains: "resolved to outside repository root",
},
{
name: "two-hop chain that escapes root is rejected",
matches: []string{hop2},
expectErr: true,
errContains: "resolved to outside repository root",
},
{
name: "multiple matches all inside root pass",
matches: []string{inRepoFile, inRepoLink, hop1},
},
{
name: "one bad match in a list fails the whole call",
matches: []string{inRepoFile, escapeLink},
expectErr: true,
errContains: "resolved to outside repository root",
},
{
name: "empty matches list is a no-op",
matches: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := verifyGlobMatchesWithinRoot(tt.matches, repoDir)
if tt.expectErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errContains)
} else {
require.NoError(t, err)
}
})
}
}
// Test_getResolvedValueFiles_glob_symlink_escape is an integration-level check
// that verifyGlobMatchesWithinRoot is wired into glob expansion correctly: a
// symlink inside the repo pointing outside must cause getResolvedValueFiles to
// return an error rather than silently including the external file.
func Test_getResolvedValueFiles_glob_symlink_escape(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
paths := utilio.NewRandomizedTempPaths(tempDir)
repoDir := filepath.Join(tempDir, "repo")
outsideDir := filepath.Join(tempDir, "outside")
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "values"), 0o755))
require.NoError(t, os.MkdirAll(outsideDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "values", "base.yaml"), []byte{}, 0o644))
require.NoError(t, os.WriteFile(filepath.Join(outsideDir, "secret.yaml"), []byte("password: hunter2"), 0o644))
require.NoError(t, os.Symlink(filepath.Join(outsideDir, "secret.yaml"), filepath.Join(repoDir, "values", "escape.yaml")))
_, err := getResolvedValueFiles(repoDir, repoDir, &v1alpha1.Env{}, []string{}, []string{"values/*.yaml"}, map[string]*v1alpha1.RefTarget{}, paths, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "resolved to outside repository root")
}
func Test_isGlobPath(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{
path: "prod/*.yaml",
expected: true,
},
{
path: "prod/?.yaml",
expected: true,
},
{
path: "prod[ab].yaml",
expected: true,
},
{
path: "prod/**/*.yaml",
expected: true,
},
{
path: "prod/values.yaml",
},
{
path: "values.yaml",
},
{
path: "",
},
{
path: "/absolute/path/to/*.yaml",
expected: true,
},
{
path: "/absolute/path/to/values.yaml",
},
{
path: "*",
expected: true,
},
{
path: "?",
expected: true,
},
{
path: "[",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.expected, isGlobPath(tt.path))
})
}
}
func Test_getReferencedSource(t *testing.T) {
t.Parallel()
refTarget := &v1alpha1.RefTarget{
Repo: v1alpha1.Repository{
Repo: "https://github.com/org/repo1",
},
}
tests := []struct {
name string
rawValueFile string
refSources map[string]*v1alpha1.RefTarget
expected *v1alpha1.RefTarget
}{
{
name: "ref with file path found in map",
rawValueFile: "$ref/values.yaml",
refSources: map[string]*v1alpha1.RefTarget{
"$ref": refTarget,
},
expected: refTarget,
},
{
name: "ref with file path not in map",
rawValueFile: "$ref/values.yaml",
refSources: map[string]*v1alpha1.RefTarget{},
expected: nil,
},
{
name: "bare ref without file path found in map",
rawValueFile: "$ref",
refSources: map[string]*v1alpha1.RefTarget{
"$ref": refTarget,
},
expected: refTarget,
},
{
name: "empty string returns nil",
rawValueFile: "",
refSources: map[string]*v1alpha1.RefTarget{
"$ref": refTarget,
},
expected: nil,
},
{
name: "no $ prefix returns nil",
rawValueFile: "values.yaml",
refSources: map[string]*v1alpha1.RefTarget{
"$ref": refTarget,
},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := getReferencedSource(tt.rawValueFile, tt.refSources)
assert.Equal(t, tt.expected, result)
})
}
}
func TestErrorGetGitDirectories(t *testing.T) {
// test not using the cache
root := "./testdata/git-files-dirs"

View File

@@ -210,21 +210,9 @@ func (s *terminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if pod.Status.Phase != corev1.PodRunning {
http.Error(w, "Pod not running", http.StatusBadRequest)
return
}
var findContainer bool
for _, c := range pod.Spec.Containers {
if container == c.Name {
findContainer = true
break
}
}
if !findContainer {
fieldLog.Warn("terminal container not found")
http.Error(w, "Cannot find container", http.StatusBadRequest)
if !containerRunning(pod, container) {
fieldLog.Warn("terminal container not running")
http.Error(w, "container find running", http.StatusBadRequest)
return
}
@@ -273,6 +261,20 @@ func podExists(treeNodes []appv1.ResourceNode, podName, namespace string) bool {
return false
}
func containerRunning(pod *corev1.Pod, containerName string) bool {
return containerStatusRunning(pod.Status.ContainerStatuses, containerName) ||
containerStatusRunning(pod.Status.InitContainerStatuses, containerName)
}
func containerStatusRunning(statuses []corev1.ContainerStatus, containerName string) bool {
for i := range statuses {
if statuses[i].Name == containerName {
return statuses[i].State.Running != nil
}
}
return false
}
const EndOfTransmission = "\u0004"
// PtyHandler is what remotecommand expects from a pty

View File

@@ -5,9 +5,12 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/argoproj/argo-cd/gitops-engine/pkg/utils/kube"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
appv1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
"github.com/argoproj/argo-cd/v3/util/argo"
@@ -79,6 +82,115 @@ func TestPodExists(t *testing.T) {
}
}
func TestContainerRunning(t *testing.T) {
for _, tcase := range []struct {
name string
pod *corev1.Pod
containerName string
expectedResult bool
}{
{
name: "empty container",
pod: &corev1.Pod{},
containerName: "",
expectedResult: false,
},
{
name: "container not found",
pod: &corev1.Pod{},
containerName: "not-found",
expectedResult: false,
},
{
name: "container running",
pod: &corev1.Pod{
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "test",
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{
StartedAt: metav1.NewTime(time.Now()),
},
},
},
},
},
},
containerName: "test",
expectedResult: true,
},
{
name: "init container running",
pod: &corev1.Pod{
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "test",
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{
StartedAt: metav1.NewTime(time.Now()),
},
},
},
},
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "test-init",
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{
StartedAt: metav1.NewTime(time.Now()),
},
},
},
},
},
},
containerName: "test-init",
expectedResult: true,
},
{
name: "container not running",
pod: &corev1.Pod{
Status: corev1.PodStatus{
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "test",
State: corev1.ContainerState{
Running: nil,
},
},
},
},
},
containerName: "test",
expectedResult: false,
},
{
name: "init container not running",
pod: &corev1.Pod{
Status: corev1.PodStatus{
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "test-init",
State: corev1.ContainerState{
Running: nil,
},
},
},
},
},
containerName: "test-init",
expectedResult: false,
},
} {
t.Run(tcase.name, func(t *testing.T) {
result := containerRunning(tcase.pod, tcase.containerName)
assert.Equalf(t, tcase.expectedResult, result, "Expected result %v, but got %v", tcase.expectedResult, result)
})
}
}
func TestIsValidPodName(t *testing.T) {
for _, tcase := range []struct {
name string

View File

@@ -156,6 +156,79 @@ func TestHelmIgnoreMissingValueFiles(t *testing.T) {
Expect(ErrorRegex("Error: open .*does-not-exist-values.yaml: no such file or directory", ""))
}
// TestHelmGlobValueFiles verifies that a glob pattern in valueFiles expands to all matching
// files and that they are applied in lexical order (last file wins in helm merging).
// envs/*.yaml expands to envs/a.yaml then envs/b.yaml - b.yaml is last, so foo = "b-value".
func TestHelmGlobValueFiles(t *testing.T) {
fixture.SkipOnEnv(t, "HELM")
ctx := Given(t)
ctx.Path("helm-glob-values").
When().
CreateApp().
AppSet("--values", "envs/*.yaml").
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(HealthIs(health.HealthStatusHealthy)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
And(func(_ *Application) {
val := errors.NewHandler(t).FailOnErr(fixture.Run(".", "kubectl", "-n", ctx.DeploymentNamespace(),
"get", "cm", "my-map", "-o", "jsonpath={.data.foo}")).(string)
assert.Equal(t, "b-value", val)
})
}
// TestHelmRecursiveGlobValueFiles verifies that the ** double-star pattern recursively
// matches files at any depth. envs/**/*.yaml expands (zero-segments first) to:
// envs/a.yaml, envs/b.yaml, envs/nested/c.yaml - c.yaml is last, so foo = "c-value".
func TestHelmRecursiveGlobValueFiles(t *testing.T) {
fixture.SkipOnEnv(t, "HELM")
ctx := Given(t)
ctx.Path("helm-glob-values").
When().
CreateApp().
AppSet("--values", "envs/**/*.yaml").
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(HealthIs(health.HealthStatusHealthy)).
Expect(SyncStatusIs(SyncStatusCodeSynced)).
And(func(_ *Application) {
val := errors.NewHandler(t).FailOnErr(fixture.Run(".", "kubectl", "-n", ctx.DeploymentNamespace(),
"get", "cm", "my-map", "-o", "jsonpath={.data.foo}")).(string)
assert.Equal(t, "c-value", val)
})
}
// TestHelmGlobValueFilesNoMatch verifies that a glob pattern with no matching files
// surfaces as a comparison error on the application.
func TestHelmGlobValueFilesNoMatch(t *testing.T) {
fixture.SkipOnEnv(t, "HELM")
Given(t).
Path("helm-glob-values").
When().
CreateApp().
AppSet("--values", "nonexistent/*.yaml").
Then().
Expect(Condition(ApplicationConditionComparisonError, `values file glob "nonexistent/*.yaml" matched no files`))
}
// TestHelmGlobValueFilesIgnoreMissing verifies that a non-matching glob pattern is
// silently skipped when ignoreMissingValueFiles is set, and the app syncs successfully.
func TestHelmGlobValueFilesIgnoreMissing(t *testing.T) {
fixture.SkipOnEnv(t, "HELM")
Given(t).
Path("helm-glob-values").
When().
CreateApp().
AppSet("--values", "nonexistent/*.yaml", "--ignore-missing-value-files").
Sync().
Then().
Expect(OperationPhaseIs(OperationSucceeded)).
Expect(HealthIs(health.HealthStatusHealthy)).
Expect(SyncStatusIs(SyncStatusCodeSynced))
}
func TestHelmValuesMultipleUnset(t *testing.T) {
Given(t).
Path("helm").

View File

@@ -0,0 +1,3 @@
apiVersion: v2
version: 1.0.0
name: helm-glob-values

View File

@@ -0,0 +1 @@
foo: a-value

View File

@@ -0,0 +1 @@
foo: b-value

View File

@@ -0,0 +1 @@
foo: c-value

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: my-map
data:
foo: {{.Values.foo}}

View File

@@ -0,0 +1 @@
foo: default

View File

View File

@@ -5,4 +5,5 @@ import "embed"
// Embedded contains embedded UI resources
//
//go:embed dist/app
//go:embed all:dist/app/assets/images/resources
var Embedded embed.FS

View File

@@ -8,6 +8,7 @@ import {services} from '../../../shared/services';
import {
ApplicationSyncWindowStatusIcon,
ComparisonStatusIcon,
formatApplicationSetProgressiveSyncStep,
getAppDefaultSource,
getAppDefaultSyncRevisionExtra,
getAppOperationState,
@@ -134,7 +135,7 @@ const ProgressiveSyncStatus = ({application}: {application: models.Application})
<div className='application-status-panel__item-value' style={{color: getProgressiveSyncStatusColor(appResource.status)}}>
{getProgressiveSyncStatusIcon({status: appResource.status})}&nbsp;{appResource.status}
</div>
{appResource?.step && <div className='application-status-panel__item-value'>Wave: {appResource.step}</div>}
{appResource?.step !== undefined && <div className='application-status-panel__item-value'>{formatApplicationSetProgressiveSyncStep(appResource.step)}</div>}
{lastTransitionTime && (
<div className='application-status-panel__item-name' style={{marginBottom: '0.5em'}}>
Last Transition: <br />

View File

@@ -1858,6 +1858,14 @@ export function getAppUrl(app: appModels.AbstractApplication): string {
return `${basePath}/${app.metadata.namespace}/${app.metadata.name}`;
}
/** RollingSync step for display; backend uses -1 when no step matches the app's labels. */
export function formatApplicationSetProgressiveSyncStep(step: string | undefined): string {
if (step === '-1') {
return 'Step: unmatched label';
}
return `Step: ${step ?? ''}`;
}
export const getProgressiveSyncStatusIcon = ({status, isButton}: {status: string; isButton?: boolean}) => {
const getIconProps = () => {
switch (status) {

View File

@@ -910,6 +910,12 @@ func verifyGenerateManifests(
// and not whether it actually contains any manifests.
_, err = repoClient.GenerateManifest(ctx, &req)
if err != nil {
// A glob pattern matching no files is a runtime condition, not a spec error —
// the files may be added later. Skip adding an InvalidSpecError here and let
// the app controller surface it as a ComparisonError during reconciliation.
if status.Code(err) == codes.NotFound && strings.Contains(err.Error(), "matched no files") {
continue
}
errMessage := fmt.Sprintf("Unable to generate manifests in %s: %s", source.Path, err)
conditions = append(conditions, argoappv1.ApplicationCondition{
Type: argoappv1.ApplicationConditionInvalidSpecError,