Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 179 additions & 17 deletions pkg/argo_client/manifests.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,151 @@ func preprocessSources(app *v1alpha1.Application, pullRequest vcs.PullRequest) (
return contentSources, refSources
}

// isExternalHelmChart returns true when a source refers to a Helm chart that is
// fetched directly from a Helm registry (OCI or HTTPS), not from a Git repository.
// Such sources cannot be git-cloned; ArgoCD's repo-server is responsible for
// pulling the chart using its own credentials and OCI support.
func isExternalHelmChart(source v1alpha1.ApplicationSource) bool {
// source.Chart is only set for Helm chart sources (not git-path sources)
if source.Chart == "" {
return false
}
// OCI registries with explicit oci:// scheme
if strings.HasPrefix(source.RepoURL, "oci://") {
return true
}
// HTTPS Helm repos: no .git suffix and no source path means it is a plain
// Helm repository URL (e.g. https://charts.example.io)
if strings.HasPrefix(source.RepoURL, "https://") && !strings.HasSuffix(source.RepoURL, ".git") && source.Path == "" {
return true
}
// OCI registry shorthand without scheme (e.g. "docker.io/envoyproxy",
// "ghcr.io/org/charts"). ArgoCD accepts these for OCI Helm sources.
// If Chart is set and the URL has no scheme and doesn't end in .git,
// it's an OCI/Helm registry reference, not a git repo.
if source.Chart != "" && !strings.Contains(source.RepoURL, "://") && !strings.HasSuffix(source.RepoURL, ".git") {
return true
}
return false
}

// generateManifestForExternalChart handles manifest generation for external Helm chart
// sources (OCI registries and HTTPS Helm repos). Instead of streaming local files via
// GenerateManifestWithFiles, it calls GenerateManifest directly — the repo-server will
// fetch the chart from the registry using its own credentials and OCI/Helm support.
// This avoids needing a local copy of the chart and correctly handles chart dependencies.
func (a *ArgoClient) generateManifestForExternalChart(ctx context.Context, app v1alpha1.Application, source v1alpha1.ApplicationSource, refs []v1alpha1.ApplicationSource) ([]string, error) {
clusterCloser, clusterClient := a.GetClusterClient()
defer pkg.WithErrorLogging(clusterCloser.Close, "failed to close connection")

clusterData, err := clusterClient.Get(ctx, &cluster.ClusterQuery{Name: app.Spec.Destination.Name, Server: app.Spec.Destination.Server})
if err != nil {
getManifestsFailed.WithLabelValues(app.Name).Inc()
return nil, errors.Wrap(err, "failed to get cluster")
}

settingsCloser, settingsClient := a.GetSettingsClient()
defer pkg.WithErrorLogging(settingsCloser.Close, "failed to close connection")

argoSettings, err := settingsClient.Get(ctx, &settings.SettingsQuery{})
if err != nil {
getManifestsFailed.WithLabelValues(app.Name).Inc()
return nil, errors.Wrap(err, "failed to get settings")
}

settingsMgr := argosettings.NewSettingsManager(ctx, a.k8s, a.cfg.ArgoCDNamespace)
argoDB := db.NewDB(a.cfg.ArgoCDNamespace, settingsMgr, a.k8s)

helmRepos, err := argoDB.ListHelmRepositories(ctx)
if err != nil {
return nil, fmt.Errorf("error listing helm repositories: %w", err)
}

proj, err := func() (*v1alpha1.AppProject, error) {
closer, projectClient, err := a.client.NewProjectClient()
if err != nil {
return nil, errors.Wrap(err, "failed to get project client")
}
defer pkg.WithErrorLogging(closer.Close, "failed to close connection")
return projectClient.Get(ctx, &project.ProjectQuery{Name: app.Spec.Project})
}()
if err != nil {
return nil, fmt.Errorf("error getting app project: %w", err)
}

permittedHelmRepos, err := argo.GetPermittedRepos(proj, helmRepos)
if err != nil {
return nil, fmt.Errorf("error getting permitted helm repos: %w", err)
}

helmRepositoryCredentials, err := argoDB.GetAllHelmRepositoryCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("error getting helm repository credentials: %w", err)
}
permittedHelmCredentials, err := argo.GetPermittedReposCredentials(proj, helmRepositoryCredentials)
if err != nil {
return nil, fmt.Errorf("error getting permitted repos credentials: %w", err)
}

enabledSourceTypes, err := settingsMgr.GetEnabledSourceTypes()
if err != nil {
return nil, fmt.Errorf("error getting settings enabled source types: %w", err)
}

helmOptions, err := settingsMgr.GetHelmSettings()
if err != nil {
return nil, fmt.Errorf("error getting helm settings: %w", err)
}

refSources, err := argo.GetRefSources(context.Background(), app.Spec.Sources, app.Spec.Project, argoDB.GetRepository, []string{})

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In generateManifestForExternalChart, ref sources are resolved from app.Spec.Sources using context.Background(), which ignores the refs slice returned by preprocessSources (including PR base->head targetRevision rewrites) and also ignores cancellation/deadlines. This can cause external Helm chart manifest generation to use the wrong ref revisions during PR previews. Use ctx and build the sources list consistently (e.g., prepend source and append the passed-in refs) when calling argo.GetRefSources / populating RefSources.

Suggested change
refSources, err := argo.GetRefSources(context.Background(), app.Spec.Sources, app.Spec.Project, argoDB.GetRepository, []string{})
sources := append([]v1alpha1.ApplicationSource{source}, app.Spec.Sources...)
refSources, err := argo.GetRefSources(ctx, sources, app.Spec.Project, argoDB.GetRepository, refs)

Copilot uses AI. Check for mistakes.
if err != nil {
return nil, fmt.Errorf("failed to get ref sources: %w", err)
}

// Resolve the repository object so repo-server has credentials for the chart registry.
repo, err := argoDB.GetRepository(ctx, source.RepoURL, app.Spec.Project)
if err != nil {
log.Warn().Err(err).Str("repoURL", source.RepoURL).Msg("could not resolve repository credentials; proceeding without them")
repo = &v1alpha1.Repository{Repo: source.RepoURL}
}

q := repoapiclient.ManifestRequest{
Repo: repo,
Revision: source.TargetRevision,
AppLabelKey: argoSettings.AppLabelKey,
AppName: app.Name,
Namespace: app.Spec.Destination.Namespace,
ApplicationSource: &source,
Repos: permittedHelmRepos,
KustomizeOptions: argoSettings.KustomizeOptions,
KubeVersion: clusterData.Info.ServerVersion,
ApiVersions: clusterData.Info.APIVersions,
HelmRepoCreds: permittedHelmCredentials,
HelmOptions: helmOptions,
TrackingMethod: argoSettings.TrackingMethod,
EnabledSourceTypes: enabledSourceTypes,
ProjectName: proj.Name,
ProjectSourceRepos: proj.Spec.SourceRepos,
HasMultipleSources: app.Spec.HasMultipleSources(),
RefSources: refSources,
}

repoClient, conn, err := a.createRepoServerClient()
if err != nil {
return nil, errors.Wrap(err, "error creating repo client")
}
defer pkg.WithErrorLogging(conn.Close, "failed to close connection")

log.Debug().Caller().Str("app", app.Name).Msg("calling GenerateManifest for external Helm chart")
resp, err := repoClient.GenerateManifest(ctx, &q)
if err != nil {
getManifestsFailed.WithLabelValues(app.Name).Inc()
return nil, fmt.Errorf("failed to generate manifests for external Helm chart %s: %w", app.Name, err)
}
getManifestsSuccess.WithLabelValues(app.Name).Inc()
Comment on lines +241 to +244

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateManifestForExternalChart increments getManifestsSuccess/getManifestsFailed internally, but GetManifests() already increments getManifestsSuccess on overall success and relies on errors for failures. This will make success/failure metrics inconsistent (and likely double-count successes) specifically for external Helm chart sources. Consider emitting metrics in only one place (preferably GetManifests) and keep label values consistent (name vs app.Name).

Suggested change
getManifestsFailed.WithLabelValues(app.Name).Inc()
return nil, fmt.Errorf("failed to generate manifests for external Helm chart %s: %w", app.Name, err)
}
getManifestsSuccess.WithLabelValues(app.Name).Inc()
return nil, fmt.Errorf("failed to generate manifests for external Helm chart %s: %w", app.Name, err)
}

Copilot uses AI. Check for mistakes.
return resp.Manifests, nil
}

// generateManifests generates an Application along with all of its files, and sends it to the ArgoCD
// Repository service to be transformed into raw kubernetes manifests. This allows us to take advantage of server
// configuration and credentials.
Expand All @@ -109,6 +254,20 @@ func (a *ArgoClient) generateManifests(ctx context.Context, app v1alpha1.Applica
// 2. there must be one and only one non-ref source
// 3. ref sources that match the pull requests' repo and target branch need to have their target branch swapped to the head branch of the pull request
log.Info().Str("app", app.Name).Msg("generating manifests")

// External Helm chart sources (OCI or HTTPS Helm repos) cannot be git-cloned.
// Instead of streaming files via GenerateManifestWithFiles (which would require
// a local copy of the chart), we delegate directly to GenerateManifest — the
// repo-server will fetch the chart from the registry using its own credentials.
if isExternalHelmChart(source) {
log.Info().
Str("app", app.Name).
Str("repoURL", source.RepoURL).
Str("chart", source.Chart).
Msg("external Helm chart source detected; delegating to repo-server GenerateManifest")
return a.generateManifestForExternalChart(ctx, app, source, refs)
}
Comment on lines +258 to +269

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says external Helm chart sources should be skipped only under certain ArgoCDSendFullRepository settings (warn+return nil when false), but the code now always delegates external charts to repo-server GenerateManifest regardless of that config. Either update the PR description to match current behavior or implement the described conditional handling so runtime behavior is unambiguous.

Copilot uses AI. Check for mistakes.

clusterCloser, clusterClient := a.GetClusterClient()
defer pkg.WithErrorLogging(clusterCloser.Close, "failed to close connection")

Expand All @@ -131,26 +290,29 @@ func (a *ArgoClient) generateManifests(ctx context.Context, app v1alpha1.Applica
settingsMgr := argosettings.NewSettingsManager(ctx, a.k8s, a.cfg.ArgoCDNamespace)
argoDB := db.NewDB(a.cfg.ArgoCDNamespace, settingsMgr, a.k8s)

repoTarget := source.TargetRevision
if pkg.AreSameRepos(source.RepoURL, pullRequest.CloneURL) && areSameTargetRef(source.TargetRevision, pullRequest.BaseRef) {
repoTarget = pullRequest.HeadRef
}
var packageDir string

log.Debug().Caller().Str("app", app.Name).Msg("get repo")
repo, err := getRepo(ctx, source.RepoURL, repoTarget)
if err != nil {
return nil, errors.Wrap(err, "failed to get repo")
}
{
repoTarget := source.TargetRevision
if pkg.AreSameRepos(source.RepoURL, pullRequest.CloneURL) && areSameTargetRef(source.TargetRevision, pullRequest.BaseRef) {
repoTarget = pullRequest.HeadRef
}

var packageDir string
if a.cfg.ArgoCDSendFullRepository {
log.Debug().Caller().Str("app", app.Name).Msg("sending full repository")
packageDir = repo.Directory
} else {
log.Debug().Caller().Str("app", app.Name).Msg("packaging app")
packageDir, err = packageApp(ctx, source, refs, repo, getRepo)
log.Debug().Caller().Str("app", app.Name).Msg("get repo")
repo, err := getRepo(ctx, source.RepoURL, repoTarget)
if err != nil {
return nil, errors.Wrap(err, "failed to package application")
return nil, errors.Wrap(err, "failed to get repo")
}

if a.cfg.ArgoCDSendFullRepository {
log.Debug().Caller().Str("app", app.Name).Msg("sending full repository")
packageDir = repo.Directory
} else {
log.Debug().Caller().Str("app", app.Name).Msg("packaging app")
packageDir, err = packageApp(ctx, source, refs, repo, getRepo)
if err != nil {
return nil, errors.Wrap(err, "failed to package application")
}
}
}

Expand Down
92 changes: 92 additions & 0 deletions pkg/argo_client/manifests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,98 @@ import (
"github.com/zapier/kubechecks/pkg/vcs"
)

func TestIsExternalHelmChart(t *testing.T) {
testcases := map[string]struct {
source v1alpha1.ApplicationSource
expected bool
}{
"oci-chart": {
source: v1alpha1.ApplicationSource{
RepoURL: "oci://registry.example.io/charts",
Chart: "my-chart",
},
expected: true,
},
"https-helm-repo": {
source: v1alpha1.ApplicationSource{
RepoURL: "https://charts.example.io",
Chart: "my-chart",
},
expected: true,
},
"https-git-repo-with-chart-field": {
// .git suffix indicates it is a git repo, not a Helm HTTPS repo
source: v1alpha1.ApplicationSource{
RepoURL: "https://github.com/org/repo.git",
Chart: "my-chart",
},
expected: false,
},
"git-path-source-no-chart": {
// source.Chart is empty — it's a git-path source
source: v1alpha1.ApplicationSource{
RepoURL: "oci://registry.example.io/charts",
Path: "helm/app",
},
expected: false,
},
"plain-git-repo": {
source: v1alpha1.ApplicationSource{
RepoURL: "git@github.com:org/repo.git",
Path: "app/",
},
expected: false,
},
"https-helm-repo-with-path": {
// Has source.Path set — treat as git-path source, not external Helm chart
source: v1alpha1.ApplicationSource{
RepoURL: "https://charts.example.io",
Chart: "my-chart",
Path: "some/path",
},
expected: false,
},
"oci-shorthand-docker-io": {
// OCI registry without scheme (ArgoCD shorthand format)
source: v1alpha1.ApplicationSource{
RepoURL: "docker.io/envoyproxy",
Chart: "gateway-helm",
},
expected: true,
},
"oci-shorthand-ghcr": {
source: v1alpha1.ApplicationSource{
RepoURL: "ghcr.io/org/charts",
Chart: "my-chart",
},
expected: true,
},
"oci-shorthand-no-chart": {
// No Chart field — not an external Helm chart
source: v1alpha1.ApplicationSource{
RepoURL: "docker.io/envoyproxy",
Path: "some/path",
},
expected: false,
},
"oci-shorthand-git-suffix": {
// .git suffix means it's a git repo even without scheme
source: v1alpha1.ApplicationSource{
RepoURL: "github.com/org/repo.git",
Chart: "my-chart",
},
expected: false,
},
}

for name, tc := range testcases {
t.Run(name, func(t *testing.T) {
actual := isExternalHelmChart(tc.source)
assert.Equal(t, tc.expected, actual)
})
}
}

func TestAreSameTargetRef(t *testing.T) {
testcases := map[string]struct {
ref1, ref2 string
Expand Down
Loading