Skip to content

Commit 881a179

Browse files
authored
feat(hydrator): hydrateTo and syncSource as a different repository (#22719)
1 parent 706e469 commit 881a179

31 files changed

+2941
-565
lines changed

USERS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ Currently, the following organizations are **officially** using Argo CD:
420420
1. [WeMaintain](https://www.wemaintain.com/)
421421
1. [WeMo Scooter](https://www.wemoscooter.com/)
422422
1. [Whitehat Berlin](https://whitehat.berlin) by Guido Maria Serra +Fenaroli
423+
1. [WhizUs](https://whizus.com)
423424
1. [Witick](https://witick.io/)
424425
1. [Wolffun Game](https://www.wolffungame.com/)
425426
1. [WooliesX](https://wooliesx.com.au/)

assets/swagger.json

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/util/app.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ type AppOptions struct {
9898
drySourcePath string
9999
syncSourceBranch string
100100
syncSourcePath string
101+
syncSourceRepo string
101102
hydrateToBranch string
103+
hydrateToRepo string
104+
hydrateToPath string
102105
}
103106

104107
func AddAppFlags(command *cobra.Command, opts *AppOptions) {
@@ -112,7 +115,10 @@ func AddAppFlags(command *cobra.Command, opts *AppOptions) {
112115
command.Flags().StringVar(&opts.drySourcePath, "dry-source-path", "", "Path in repository to the app directory for the dry source")
113116
command.Flags().StringVar(&opts.syncSourceBranch, "sync-source-branch", "", "The branch from which the app will sync")
114117
command.Flags().StringVar(&opts.syncSourcePath, "sync-source-path", "", "The path in the repository from which the app will sync")
118+
command.Flags().StringVar(&opts.syncSourceRepo, "sync-source-repo", "", "The repository URL from which the app will sync (defaults to dry-source-repo if not set)")
115119
command.Flags().StringVar(&opts.hydrateToBranch, "hydrate-to-branch", "", "The branch to hydrate the app to")
120+
command.Flags().StringVar(&opts.hydrateToRepo, "hydrate-to-repo", "", "The repository URL to hydrate the app to (defaults to sync-source-repo or dry-source-repo if not set)")
121+
command.Flags().StringVar(&opts.hydrateToPath, "hydrate-to-path", "", "The path in the repository to hydrate the app to (defaults to sync-source-path if not set)")
116122
command.Flags().IntVar(&opts.revisionHistoryLimit, "revision-history-limit", argoappv1.RevisionHistoryLimit, "How many items to keep in revision history")
117123
command.Flags().StringVar(&opts.destServer, "dest-server", "", "K8s cluster URL (e.g. https://kubernetes.default.svc)")
118124
command.Flags().StringVar(&opts.destName, "dest-name", "", "K8s cluster Name (e.g. minikube)")
@@ -829,13 +835,31 @@ func constructSourceHydrator(h *argoappv1.SourceHydrator, appOpts AppOptions, fl
829835
case "sync-source-path":
830836
ensureNotNil(appOpts.syncSourcePath != "")
831837
h.SyncSource.Path = appOpts.syncSourcePath
838+
case "sync-source-repo":
839+
ensureNotNil(appOpts.syncSourceRepo != "")
840+
h.SyncSource.RepoURL = appOpts.syncSourceRepo
832841
case "hydrate-to-branch":
833842
ensureNotNil(appOpts.hydrateToBranch != "")
834843
if appOpts.hydrateToBranch == "" {
835844
h.HydrateTo = nil
836845
} else {
837-
h.HydrateTo = &argoappv1.HydrateTo{TargetBranch: appOpts.hydrateToBranch}
846+
if h.HydrateTo == nil {
847+
h.HydrateTo = &argoappv1.HydrateTo{}
848+
}
849+
h.HydrateTo.TargetBranch = appOpts.hydrateToBranch
850+
}
851+
case "hydrate-to-repo":
852+
ensureNotNil(appOpts.hydrateToRepo != "")
853+
if h.HydrateTo == nil {
854+
h.HydrateTo = &argoappv1.HydrateTo{}
855+
}
856+
h.HydrateTo.RepoURL = appOpts.hydrateToRepo
857+
case "hydrate-to-path":
858+
ensureNotNil(appOpts.hydrateToPath != "")
859+
if h.HydrateTo == nil {
860+
h.HydrateTo = &argoappv1.HydrateTo{}
838861
}
862+
h.HydrateTo.Path = appOpts.hydrateToPath
839863
}
840864
})
841865
return h, hasHydratorFlag

controller/hydrator/hydrator.go

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,12 @@ func (h *Hydrator) ProcessAppHydrateQueueItem(origApp *appv1.Application) {
132132
}
133133

134134
func getHydrationQueueKey(app *appv1.Application) types.HydrationQueueKey {
135+
hydrateToSource := app.Spec.GetHydrateToSource()
135136
key := types.HydrationQueueKey{
136137
SourceRepoURL: git.NormalizeGitURLAllowInvalid(app.Spec.SourceHydrator.DrySource.RepoURL),
137138
SourceTargetRevision: app.Spec.SourceHydrator.DrySource.TargetRevision,
138-
DestinationBranch: app.Spec.GetHydrateToSource().TargetRevision,
139+
DestinationRepoURL: git.NormalizeGitURLAllowInvalid(hydrateToSource.RepoURL),
140+
DestinationBranch: hydrateToSource.TargetRevision,
139141
}
140142
return key
141143
}
@@ -148,6 +150,7 @@ func (h *Hydrator) ProcessHydrationQueueItem(hydrationKey types.HydrationQueueKe
148150
logCtx := log.WithFields(log.Fields{
149151
"sourceRepoURL": hydrationKey.SourceRepoURL,
150152
"sourceTargetRevision": hydrationKey.SourceTargetRevision,
153+
"destinationRepoURL": hydrationKey.DestinationRepoURL,
151154
"destinationBranch": hydrationKey.DestinationBranch,
152155
})
153156

@@ -319,20 +322,30 @@ func (h *Hydrator) validateApplications(apps []*appv1.Application) (map[string]*
319322
// Hydrating to root would overwrite or delete files at the top level of the repo,
320323
// which can break other applications or shared configuration.
321324
// Every hydrated app must write into a subdirectory instead.
322-
destPath := app.Spec.SourceHydrator.SyncSource.Path
325+
hydrateToSource := app.Spec.GetHydrateToSource()
326+
destPath := hydrateToSource.Path
323327
if IsRootPath(destPath) {
324-
errors[app.QualifiedName()] = fmt.Errorf("app is configured to hydrate to the repository root (branch %q, path %q) which is not allowed", app.Spec.GetHydrateToSource().TargetRevision, destPath)
328+
errors[app.QualifiedName()] = fmt.Errorf("app is configured to hydrate to the repository root (branch %q, path %q) which is not allowed", hydrateToSource.TargetRevision, destPath)
329+
continue
330+
}
331+
332+
// Validate that the destination repo is permitted in the project
333+
destRepoPermitted := proj.IsSourcePermitted(hydrateToSource)
334+
if !destRepoPermitted {
335+
errors[app.QualifiedName()] = fmt.Errorf("destination repo %s is not permitted in project '%s'", hydrateToSource.RepoURL, proj.Name)
325336
continue
326337
}
327338

328339
// TODO: test the dupe detection
329340
// TODO: normalize the path to avoid "path/.." from being treated as different from "."
330-
if appName, ok := uniquePaths[destPath]; ok {
331-
errors[app.QualifiedName()] = fmt.Errorf("app %s hydrator use the same destination: %v", appName, app.Spec.SourceHydrator.SyncSource.Path)
332-
errors[appName] = fmt.Errorf("app %s hydrator use the same destination: %v", app.QualifiedName(), app.Spec.SourceHydrator.SyncSource.Path)
341+
// Use a combination of repo URL and path for uniqueness since apps can hydrate to different repos
342+
destKey := fmt.Sprintf("%s:%s", hydrateToSource.RepoURL, destPath)
343+
if appName, ok := uniquePaths[destKey]; ok {
344+
errors[app.QualifiedName()] = fmt.Errorf("app %s hydrator use the same destination: %v", appName, destKey)
345+
errors[appName] = fmt.Errorf("app %s hydrator use the same destination: %v", app.QualifiedName(), destKey)
333346
continue
334347
}
335-
uniquePaths[destPath] = app.QualifiedName()
348+
uniquePaths[destKey] = app.QualifiedName()
336349
}
337350

338351
// If there are any errors, return nil for projects to avoid possible partial processing.
@@ -350,14 +363,15 @@ func (h *Hydrator) hydrate(logCtx *log.Entry, apps []*appv1.Application, project
350363
}
351364

352365
// These values are the same for all apps being hydrated together, so just get them from the first app.
353-
repoURL := apps[0].Spec.GetHydrateToSource().RepoURL
366+
destinationRepoURL := apps[0].Spec.GetHydrateToSource().RepoURL
354367
targetBranch := apps[0].Spec.GetHydrateToSource().TargetRevision
355368
// FIXME: As a convenience, the commit server will create the syncBranch if it does not exist. If the
356369
// targetBranch does not exist, it will create it based on the syncBranch. On the next line, we take
357370
// the `syncBranch` from the first app and assume that they're all configured the same. Instead, if any
358371
// app has a different syncBranch, we should send the commit server an empty string and allow it to
359372
// create the targetBranch as an orphan since we can't reliable determine a reasonable base.
360373
syncBranch := apps[0].Spec.SourceHydrator.SyncSource.TargetBranch
374+
drySourceRepoURL := apps[0].Spec.SourceHydrator.DrySource.RepoURL
361375

362376
// Get a static SHA revision from the first app so that all apps are hydrated from the same revision.
363377
targetRevision, pathDetails, err := h.getManifests(context.Background(), apps[0], "", projects[apps[0].Spec.Project])
@@ -398,20 +412,20 @@ func (h *Hydrator) hydrate(logCtx *log.Entry, apps []*appv1.Application, project
398412
}
399413
}
400414

401-
// Get the commit metadata for the target revision.
402-
revisionMetadata, err := h.getRevisionMetadata(context.Background(), repoURL, project, targetRevision)
415+
// Get the commit metadata for the target revision from the dry source repo.
416+
revisionMetadata, err := h.getRevisionMetadata(context.Background(), drySourceRepoURL, project, targetRevision)
403417
if err != nil {
404418
return targetRevision, "", errors, fmt.Errorf("failed to get revision metadata for %q: %w", targetRevision, err)
405419
}
406420

407-
repo, err := h.dependencies.GetWriteCredentials(context.Background(), repoURL, project)
421+
repo, err := h.dependencies.GetWriteCredentials(context.Background(), destinationRepoURL, project)
408422
if err != nil {
409423
return targetRevision, "", errors, fmt.Errorf("failed to get hydrator credentials: %w", err)
410424
}
411425
if repo == nil {
412426
// Try without credentials.
413427
repo = &appv1.Repository{
414-
Repo: repoURL,
428+
Repo: destinationRepoURL,
415429
}
416430
logCtx.Warn("no credentials found for repo, continuing without credentials")
417431
}
@@ -420,7 +434,7 @@ func (h *Hydrator) hydrate(logCtx *log.Entry, apps []*appv1.Application, project
420434
if err != nil {
421435
return targetRevision, "", errors, fmt.Errorf("failed to get hydrated commit message template: %w", err)
422436
}
423-
commitMessage, errMsg := getTemplatedCommitMessage(repoURL, targetRevision, commitMessageTemplate, revisionMetadata)
437+
commitMessage, errMsg := getTemplatedCommitMessage(drySourceRepoURL, targetRevision, commitMessageTemplate, revisionMetadata)
424438
if errMsg != nil {
425439
return targetRevision, "", errors, fmt.Errorf("failed to get hydrator commit templated message: %w", errMsg)
426440
}
@@ -477,8 +491,14 @@ func (h *Hydrator) getManifests(ctx context.Context, app *appv1.Application, tar
477491
manifestDetails[i] = &commitclient.HydratedManifestDetails{ManifestJSON: string(objJSON)}
478492
}
479493

494+
// Use the hydrateTo path if set, otherwise use the syncSource path
495+
destPath := app.Spec.SourceHydrator.SyncSource.Path
496+
if app.Spec.SourceHydrator.HydrateTo != nil && app.Spec.SourceHydrator.HydrateTo.Path != "" {
497+
destPath = app.Spec.SourceHydrator.HydrateTo.Path
498+
}
499+
480500
return resp.Revision, &commitclient.PathDetails{
481-
Path: app.Spec.SourceHydrator.SyncSource.Path,
501+
Path: destPath,
482502
Manifests: manifestDetails,
483503
Commands: resp.Commands,
484504
}, nil

controller/hydrator/hydrator_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ func Test_getAppsForHydrationKey_RepoURLNormalization(t *testing.T) {
167167
hydrationKey := types.HydrationQueueKey{
168168
SourceRepoURL: "https://example.com/repo",
169169
SourceTargetRevision: "main",
170+
DestinationRepoURL: "https://example.com/repo",
170171
DestinationBranch: "main",
171172
}
172173

@@ -1094,3 +1095,93 @@ func TestHydrator_getManifests_GetRepoObjsError(t *testing.T) {
10941095
assert.Empty(t, rev)
10951096
assert.Nil(t, pathDetails)
10961097
}
1098+
1099+
func TestHydrator_getManifests_UsesHydrateToPath(t *testing.T) {
1100+
t.Parallel()
1101+
d := mocks.NewDependencies(t)
1102+
h := &Hydrator{dependencies: d}
1103+
app := newTestApp("test-app")
1104+
app.Spec.SourceHydrator.HydrateTo.Path = "hydrated-path"
1105+
proj := newTestProject()
1106+
1107+
cm := kube.MustToUnstructured(&corev1.ConfigMap{
1108+
ObjectMeta: metav1.ObjectMeta{
1109+
Name: "test",
1110+
},
1111+
})
1112+
1113+
d.EXPECT().GetRepoObjs(mock.Anything, app, app.Spec.SourceHydrator.GetDrySource(), "sha123", proj).Return([]*unstructured.Unstructured{cm}, &repoclient.ManifestResponse{
1114+
Revision: "sha123",
1115+
Commands: []string{"cmd1"},
1116+
}, nil)
1117+
1118+
rev, pathDetails, err := h.getManifests(t.Context(), app, "sha123", proj)
1119+
require.NoError(t, err)
1120+
assert.Equal(t, "sha123", rev)
1121+
assert.Equal(t, "hydrated-path", pathDetails.Path)
1122+
}
1123+
1124+
func TestGetHydrateToSource_DifferentRepo(t *testing.T) {
1125+
spec := &v1alpha1.ApplicationSpec{
1126+
SourceHydrator: &v1alpha1.SourceHydrator{
1127+
DrySource: v1alpha1.DrySource{
1128+
RepoURL: "https://example.com/dry-repo",
1129+
TargetRevision: "main",
1130+
Path: "base",
1131+
},
1132+
SyncSource: v1alpha1.SyncSource{
1133+
TargetBranch: "hydrated",
1134+
Path: "app",
1135+
},
1136+
HydrateTo: &v1alpha1.HydrateTo{
1137+
RepoURL: "https://example.com/hydrated-repo",
1138+
TargetBranch: "staging",
1139+
Path: "hydrated-app",
1140+
},
1141+
},
1142+
}
1143+
1144+
source := spec.GetHydrateToSource()
1145+
assert.Equal(t, "https://example.com/hydrated-repo", source.RepoURL)
1146+
assert.Equal(t, "hydrated-app", source.Path)
1147+
assert.Equal(t, "staging", source.TargetRevision)
1148+
}
1149+
1150+
func TestGetSyncSource_DifferentRepo(t *testing.T) {
1151+
hydrator := &v1alpha1.SourceHydrator{
1152+
DrySource: v1alpha1.DrySource{
1153+
RepoURL: "https://example.com/dry-repo",
1154+
TargetRevision: "main",
1155+
Path: "base",
1156+
},
1157+
SyncSource: v1alpha1.SyncSource{
1158+
RepoURL: "https://example.com/sync-repo",
1159+
TargetBranch: "hydrated",
1160+
Path: "app",
1161+
},
1162+
}
1163+
1164+
source := hydrator.GetSyncSource()
1165+
assert.Equal(t, "https://example.com/sync-repo", source.RepoURL)
1166+
assert.Equal(t, "app", source.Path)
1167+
assert.Equal(t, "hydrated", source.TargetRevision)
1168+
}
1169+
1170+
func TestGetSyncSource_FallsBackToDrySourceRepo(t *testing.T) {
1171+
hydrator := &v1alpha1.SourceHydrator{
1172+
DrySource: v1alpha1.DrySource{
1173+
RepoURL: "https://example.com/dry-repo",
1174+
TargetRevision: "main",
1175+
Path: "base",
1176+
},
1177+
SyncSource: v1alpha1.SyncSource{
1178+
TargetBranch: "hydrated",
1179+
Path: "app",
1180+
},
1181+
}
1182+
1183+
source := hydrator.GetSyncSource()
1184+
assert.Equal(t, "https://example.com/dry-repo", source.RepoURL)
1185+
assert.Equal(t, "app", source.Path)
1186+
assert.Equal(t, "hydrated", source.TargetRevision)
1187+
}

controller/hydrator/types/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ type HydrationQueueKey struct {
88
SourceRepoURL string
99
SourceTargetRevision string
1010
DestinationBranch string
11+
// DestinationRepoURL must be normalized with git.NormalizeGitURL to ensure proper deduplication when apps hydrate
12+
// to different repositories.
13+
DestinationRepoURL string
1114
}

docs/user-guide/commands/argocd_admin_app_generate-spec.md

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user-guide/commands/argocd_app_add-source.md

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)