Skip to content

Commit 7ae14c8

Browse files
authored
feat: preserve non-hydrated files during hydration (#24129)
Signed-off-by: nitishfy <[email protected]>
1 parent 8b8d04e commit 7ae14c8

File tree

18 files changed

+625
-74
lines changed

18 files changed

+625
-74
lines changed

assets/swagger.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

commitserver/commit/commit.go

Lines changed: 47 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"os"
88
"time"
99

10+
"github.com/argoproj/argo-cd/v3/controller/hydrator"
11+
1012
log "github.com/sirupsen/logrus"
1113

1214
"github.com/argoproj/argo-cd/v3/commitserver/apiclient"
@@ -31,6 +33,43 @@ func NewService(gitCredsStore git.CredsStore, metricsServer *metrics.Server) *Se
3133
}
3234
}
3335

36+
type hydratorMetadataFile struct {
37+
RepoURL string `json:"repoURL,omitempty"`
38+
DrySHA string `json:"drySha,omitempty"`
39+
Commands []string `json:"commands,omitempty"`
40+
Author string `json:"author,omitempty"`
41+
Date string `json:"date,omitempty"`
42+
// Subject is the subject line of the DRY commit message, i.e. `git show --format=%s`.
43+
Subject string `json:"subject,omitempty"`
44+
// Body is the body of the DRY commit message, excluding the subject line, i.e. `git show --format=%b`.
45+
// Known Argocd- trailers with valid values are removed, but all other trailers are kept.
46+
Body string `json:"body,omitempty"`
47+
References []v1alpha1.RevisionReference `json:"references,omitempty"`
48+
}
49+
50+
// TODO: make this configurable via ConfigMap.
51+
var manifestHydrationReadmeTemplate = `# Manifest Hydration
52+
53+
To hydrate the manifests in this repository, run the following commands:
54+
55+
` + "```shell" + `
56+
git clone {{ .RepoURL }}
57+
# cd into the cloned directory
58+
git checkout {{ .DrySHA }}
59+
{{ range $command := .Commands -}}
60+
{{ $command }}
61+
{{ end -}}` + "```" + `
62+
{{ if .References -}}
63+
64+
## References
65+
66+
{{ range $ref := .References -}}
67+
{{ if $ref.Commit -}}
68+
* [{{ $ref.Commit.SHA | mustRegexFind "[0-9a-f]+" | trunc 7 }}]({{ $ref.Commit.RepoURL }}): {{ $ref.Commit.Subject }} ({{ $ref.Commit.Author }})
69+
{{ end -}}
70+
{{ end -}}
71+
{{ end -}}`
72+
3473
// CommitHydratedManifests handles a commit request. It clones the repository, checks out the sync branch, checks out
3574
// the target branch, clears the repository contents, writes the manifests to the repository, commits the changes, and
3675
// pushes the changes. It returns the hydrated revision SHA and an error if one occurred.
@@ -120,13 +159,17 @@ func (s *Service) handleCommitRequest(logCtx *log.Entry, r *apiclient.CommitHydr
120159

121160
logCtx.Debug("Clearing and preparing paths")
122161
var pathsToClear []string
162+
// range over the paths configured and skip those application
163+
// paths that are referencing to root path
123164
for _, p := range r.Paths {
124-
if p.Path == "" || p.Path == "." {
125-
logCtx.Debug("Using root directory for manifests, no directory removal needed")
126-
} else {
127-
pathsToClear = append(pathsToClear, p.Path)
165+
if hydrator.IsRootPath(p.Path) {
166+
// skip adding paths that are referencing root directory
167+
logCtx.Debugf("Path %s is referencing root directory, ignoring the path", p.Path)
168+
continue
128169
}
170+
pathsToClear = append(pathsToClear, p.Path)
129171
}
172+
130173
if len(pathsToClear) > 0 {
131174
logCtx.Debugf("Clearing paths: %v", pathsToClear)
132175
out, err := gitClient.RemoveContents(pathsToClear)
@@ -221,40 +264,3 @@ func (s *Service) initGitClient(logCtx *log.Entry, r *apiclient.CommitHydratedMa
221264

222265
return gitClient, dirPath, cleanupOrLog, nil
223266
}
224-
225-
type hydratorMetadataFile struct {
226-
RepoURL string `json:"repoURL,omitempty"`
227-
DrySHA string `json:"drySha,omitempty"`
228-
Commands []string `json:"commands,omitempty"`
229-
Author string `json:"author,omitempty"`
230-
Date string `json:"date,omitempty"`
231-
// Subject is the subject line of the DRY commit message, i.e. `git show --format=%s`.
232-
Subject string `json:"subject,omitempty"`
233-
// Body is the body of the DRY commit message, excluding the subject line, i.e. `git show --format=%b`.
234-
// Known Argocd- trailers with valid values are removed, but all other trailers are kept.
235-
Body string `json:"body,omitempty"`
236-
References []v1alpha1.RevisionReference `json:"references,omitempty"`
237-
}
238-
239-
// TODO: make this configurable via ConfigMap.
240-
var manifestHydrationReadmeTemplate = `# Manifest Hydration
241-
242-
To hydrate the manifests in this repository, run the following commands:
243-
244-
` + "```shell" + `
245-
git clone {{ .RepoURL }}
246-
# cd into the cloned directory
247-
git checkout {{ .DrySHA }}
248-
{{ range $command := .Commands -}}
249-
{{ $command }}
250-
{{ end -}}` + "```" + `
251-
{{ if .References -}}
252-
253-
## References
254-
255-
{{ range $ref := .References -}}
256-
{{ if $ref.Commit -}}
257-
* [{{ $ref.Commit.SHA | mustRegexFind "[0-9a-f]+" | trunc 7 }}]({{ $ref.Commit.RepoURL }}): {{ $ref.Commit.Subject }} ({{ $ref.Commit.Author }})
258-
{{ end -}}
259-
{{ end -}}
260-
{{ end -}}`

controller/hydrator/hydrator.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"path/filepath"
78
"sync"
89
"time"
910

@@ -155,6 +156,12 @@ func (h *Hydrator) ProcessHydrationQueueItem(hydrationKey types.HydrationQueueKe
155156
})
156157

157158
relevantApps, drySHA, hydratedSHA, err := h.hydrateAppsLatestCommit(logCtx, hydrationKey)
159+
if len(relevantApps) == 0 {
160+
// return early if there are no relevant apps found to hydrate
161+
// otherwise you'll be stuck in hydrating
162+
logCtx.Info("Skipping hydration since there are no relevant apps found to hydrate")
163+
return
164+
}
158165
if drySHA != "" {
159166
logCtx = logCtx.WithField("drySHA", drySHA)
160167
}
@@ -245,6 +252,12 @@ func (h *Hydrator) getRelevantAppsAndProjectsForHydration(logCtx *log.Entry, hyd
245252
continue
246253
}
247254

255+
path := app.Spec.SourceHydrator.SyncSource.Path
256+
// ensure that the path is always set to a path that doesn't resolve to the root of the repo
257+
if IsRootPath(path) {
258+
return nil, nil, fmt.Errorf("app %q has path %q which resolves to repository root", app.QualifiedName(), path)
259+
}
260+
248261
var proj *appv1.AppProject
249262
// We can't short-circuit this even if we have seen this project before, because we need to verify that this
250263
// particular app is allowed to use this project. That logic is in GetProcessableAppProj.
@@ -262,10 +275,10 @@ func (h *Hydrator) getRelevantAppsAndProjectsForHydration(logCtx *log.Entry, hyd
262275

263276
// TODO: test the dupe detection
264277
// TODO: normalize the path to avoid "path/.." from being treated as different from "."
265-
if _, ok := uniquePaths[app.Spec.SourceHydrator.SyncSource.Path]; ok {
278+
if _, ok := uniquePaths[path]; ok {
266279
return nil, nil, fmt.Errorf("multiple app hydrators use the same destination: %v", app.Spec.SourceHydrator.SyncSource.Path)
267280
}
268-
uniquePaths[app.Spec.SourceHydrator.SyncSource.Path] = true
281+
uniquePaths[path] = true
269282

270283
relevantApps = append(relevantApps, &app)
271284
}
@@ -282,6 +295,21 @@ func (h *Hydrator) hydrate(logCtx *log.Entry, apps []*appv1.Application, project
282295
syncBranch := apps[0].Spec.SourceHydrator.SyncSource.TargetBranch
283296
targetBranch := apps[0].Spec.GetHydrateToSource().TargetRevision
284297

298+
// Disallow hydrating to the repository root.
299+
// Hydrating to root would overwrite or delete files at the top level of the repo,
300+
// which can break other applications or shared configuration.
301+
// Every hydrated app must write into a subdirectory instead.
302+
303+
for _, app := range apps {
304+
destPath := app.Spec.SourceHydrator.SyncSource.Path
305+
if IsRootPath(destPath) {
306+
return "", "", fmt.Errorf(
307+
"app %q is configured to hydrate to the repository root (branch %q, path %q) which is not allowed",
308+
app.QualifiedName(), targetBranch, destPath,
309+
)
310+
}
311+
}
312+
285313
// Get a static SHA revision from the first app so that all apps are hydrated from the same revision.
286314
targetRevision, pathDetails, err := h.getManifests(context.Background(), apps[0], "", projects[apps[0].Spec.Project])
287315
if err != nil {
@@ -468,3 +496,9 @@ func getTemplatedCommitMessage(repoURL, revision, commitMessageTemplate string,
468496
}
469497
return templatedCommitMsg, nil
470498
}
499+
500+
// IsRootPath returns whether the path references a root path
501+
func IsRootPath(path string) bool {
502+
clean := filepath.Clean(path)
503+
return clean == "" || clean == "." || clean == string(filepath.Separator)
504+
}

controller/hydrator/hydrator_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package hydrator
22

33
import (
4+
"path/filepath"
45
"testing"
56
"time"
67

@@ -251,3 +252,72 @@ Co-authored-by: test [email protected]
251252
})
252253
}
253254
}
255+
256+
func Test_getRelevantAppsForHydration_RootPathSkipped(t *testing.T) {
257+
t.Parallel()
258+
259+
d := mocks.NewDependencies(t)
260+
// create an app that has a SyncSource.Path set to root
261+
d.On("GetProcessableApps").Return(&v1alpha1.ApplicationList{
262+
Items: []v1alpha1.Application{
263+
{
264+
Spec: v1alpha1.ApplicationSpec{
265+
Project: "project",
266+
SourceHydrator: &v1alpha1.SourceHydrator{
267+
DrySource: v1alpha1.DrySource{
268+
RepoURL: "https://example.com/repo",
269+
TargetRevision: "main",
270+
Path: ".", // root path
271+
},
272+
SyncSource: v1alpha1.SyncSource{
273+
TargetBranch: "main",
274+
Path: ".", // root path
275+
},
276+
},
277+
},
278+
},
279+
},
280+
}, nil)
281+
282+
d.On("GetProcessableAppProj", mock.Anything).Return(&v1alpha1.AppProject{
283+
Spec: v1alpha1.AppProjectSpec{
284+
SourceRepos: []string{"https://example.com/*"},
285+
},
286+
}, nil).Maybe()
287+
288+
hydrator := &Hydrator{dependencies: d}
289+
290+
hydrationKey := types.HydrationQueueKey{
291+
SourceRepoURL: "https://example.com/repo",
292+
SourceTargetRevision: "main",
293+
DestinationBranch: "main",
294+
}
295+
296+
logCtx := log.WithField("test", "RootPathSkipped")
297+
relevantApps, proj, err := hydrator.getRelevantAppsAndProjectsForHydration(logCtx, hydrationKey)
298+
require.Error(t, err)
299+
assert.Empty(t, relevantApps, "Expected no apps to be returned because SyncSource.Path resolves to root")
300+
assert.Nil(t, proj)
301+
}
302+
303+
func TestIsRootPath(t *testing.T) {
304+
tests := []struct {
305+
name string
306+
path string
307+
expected bool
308+
}{
309+
{"empty string", "", true},
310+
{"dot path", ".", true},
311+
{"slash", string(filepath.Separator), true},
312+
{"nested path", "app", false},
313+
{"nested path with slash", "app/", false},
314+
{"deep path", "app/config", false},
315+
{"current dir with trailing slash", "./", true},
316+
}
317+
for _, tt := range tests {
318+
t.Run(tt.name, func(t *testing.T) {
319+
result := IsRootPath(tt.path)
320+
require.Equal(t, tt.expected, result)
321+
})
322+
}
323+
}

docs/operator-manual/upgrading/3.1-3.2.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# v3.1 to 3.2
22

3+
## Breaking Changes
4+
5+
### Hydration paths must now be non-root
6+
7+
Source hydration now requires that every application specify a non-root path.
8+
Using the repository root (for example, "" or ".") is no longer supported. This change ensures
9+
that hydration outputs are isolated to a dedicated subdirectory and prevents accidental overwrites
10+
or deletions of important files stored at the root, such as CI pipelines, documentation, or configuration files.
11+
12+
Previously, it was possible for hydration to write manifests directly into the repository root. While convenient, this had two major drawbacks:
13+
14+
1. Hydration would wipe and replace files at the root on every run, which risked deleting important files such as CI/CD workflows, project-level READMEs, or other configuration.
15+
2. It made it harder to clearly separate hydrated application outputs from unrelated repository content.
16+
17+
To identify affected applications, review your Application manifests and look for `.spec.sourceHydrator.syncSource.path` values that are empty, missing,
18+
`"."`, or otherwise point to the repository root. These applications must be updated to use a subdirectory path, such as `apps/guestbook`.
19+
20+
After migration, check your repository root for any stale hydration output from earlier versions.
21+
Common leftovers include files such as `manifest.yaml` or `README.md`. These will not be cleaned up
22+
automatically and should be deleted manually if no longer needed.
23+
324
## Argo CD Now Respects Kustomize Version in `.argocd-source.yaml`
425

526
Argo CD provides a way to [override Application `spec.source` values](../../user-guide/parameters.md#store-overrides-in-git)

docs/user-guide/source-hydrator.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ spec:
108108
In this example, the hydrated manifests will be pushed to the `environments/dev` branch of the `argocd-example-apps`
109109
repository.
110110

111+
When using source hydration, the `syncSource.path` field is required and must always point to a non-root
112+
directory in the repository. Setting the path to the repository root (for eg. `"."` or `""`) is not
113+
supported. This ensures that hydration is always scoped to a dedicated subdirectory, which avoids unintentionally overwriting or removing files that may exist in the repository root.
114+
115+
During each hydration run, Argo CD cleans the application's configured path before writing out newly generated manifests. This guarantees that old or stale files from previous hydration do not linger in the output directory. However, the repository root is never cleaned, so files such as CI/CD configuration, README files, or other root-level assets remain untouched.
116+
117+
It is important to note that hydration only cleans the currently configured application path. If an application’s path changes, the old directory is not removed automatically. Likewise, if an application is deleted, its output path remains in the repository and must be cleaned up manually by the repository owner if desired. This design is intentional: it prevents accidental deletion of files when applications are restructured or removed, and it protects critical files like CI pipelines that may coexist in the repository.
118+
111119
!!! important "Project-Scoped Repositories"
112120

113121
Repository Secrets may contain a `project` field, making the secret only usable by Applications in that project.
@@ -372,4 +380,3 @@ to configure branch protection rules on the destination repository.
372380
Argo CD-specific metadata (such as `argocd.argoproj.io/tracking-id`) is
373381
not written to Git during hydration. These annotations are added dynamically
374382
during application sync and comparison.
375-
!!!

0 commit comments

Comments
 (0)