portainer/pkg/libhelm/sdk/search_repo.go

407 lines
12 KiB
Go

package sdk
import (
"net/url"
"os"
"path/filepath"
"sync"
"time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
)
var (
errRequiredSearchOptions = errors.New("repo is required")
errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
)
type RepoIndex struct {
APIVersion string `json:"apiVersion"`
Entries map[string][]ChartInfo `json:"entries"`
Generated string `json:"generated"`
}
type RepoIndexCache struct {
Index *repo.IndexFile
Timestamp time.Time
}
var (
indexCache = make(map[string]RepoIndexCache)
cacheMutex sync.RWMutex
cacheDuration = 60 * time.Minute
)
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Missing required search repo options")
return nil, err
}
log.Debug().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Msg("Searching repository")
// Parse and validate the repository URL
repoURL, err := parseRepoURL(searchRepoOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Invalid repository URL")
return nil, err
}
// Check cache first
if searchRepoOpts.UseCache {
cacheMutex.RLock()
if cached, exists := indexCache[repoURL.String()]; exists {
if time.Since(cached.Timestamp) < cacheDuration {
cacheMutex.RUnlock()
return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart)
}
}
cacheMutex.RUnlock()
}
// Set up Helm CLI environment
repoSettings := cli.New()
// Ensure all required Helm directories exist
if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to ensure Helm directories exist")
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
}
repoName, err := getRepoNameFromURL(repoURL.String())
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get hostname from URL")
return nil, err
}
// Download the index file and update repository configuration
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo_url", repoURL.String()).
Err(err).
Msg("Failed to download repository index")
return nil, err
}
// Load and parse the index file
log.Debug().
Str("context", "HelmClient").
Str("index_path", indexPath).
Msg("Loading index file")
indexFile, err := loadIndexFile(indexPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("index_path", indexPath).
Err(err).
Msg("Failed to load index file")
return nil, err
}
// Update cache and remove old entries
cacheMutex.Lock()
indexCache[searchRepoOpts.Repo] = RepoIndexCache{
Index: indexFile,
Timestamp: time.Now(),
}
for key, index := range indexCache {
if time.Since(index.Timestamp) > cacheDuration {
delete(indexCache, key)
}
}
cacheMutex.Unlock()
return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
}
// validateSearchRepoOptions validates the required search repository options.
func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
if opts.Repo == "" {
return errRequiredSearchOptions
}
return nil
}
// parseRepoURL parses and validates the repository URL.
func parseRepoURL(repoURL string) (*url.URL, error) {
parsedURL, err := url.ParseRequestURI(repoURL)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
}
return parsedURL, nil
}
// downloadRepoIndex downloads the index.yaml file from the repository and updates
// the repository configuration.
func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Str("repo_name", repoName).
Msg("Creating chart repository object")
// Create chart repository object
rep, err := repo.NewChartRepository(
&repo.Entry{
Name: repoName,
URL: repoURLString,
},
getter.All(repoSettings),
)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to create chart repository object")
return "", errInvalidRepoURL
}
// Load repository configuration file
f, err := repo.LoadFile(repoSettings.RepositoryConfig)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_config", repoSettings.RepositoryConfig).
Err(err).
Msg("Failed to load repo config")
return "", errors.Wrap(err, "failed to load repo config")
}
// Download the index file
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Msg("Downloading index file")
indexPath, err := rep.DownloadIndexFile()
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to download index file")
return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURLString)
}
// Update repository configuration
c := repo.Entry{
Name: repoName,
URL: repoURLString,
}
f.Update(&c)
// Write updated configuration
repoFile := repoSettings.RepositoryConfig
if err := f.WriteFile(repoFile, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_repo_index").
Str("repo_file", repoSettings.RepositoryConfig).
Err(err).
Msg("Failed to write repository configuration")
return "", errors.Wrap(err, "failed to write repository configuration")
}
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("index_path", indexPath).
Msg("Successfully downloaded index file")
return indexPath, nil
}
// loadIndexFile loads the index file from the given path.
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
indexFile, err := repo.LoadIndexFile(indexPath)
if err != nil {
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
}
return indexFile, nil
}
// convertIndexToResponse converts the Helm index file to our response format.
func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIndex, error) {
result := RepoIndex{
APIVersion: indexFile.APIVersion,
Entries: make(map[string][]ChartInfo),
Generated: indexFile.Generated.String(),
}
// Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries {
if chartName == "" || name == chartName {
result.Entries[name] = convertChartsToChartInfo(charts)
}
}
return result, nil
}
// convertChartsToChartInfo converts Helm chart entries to ChartInfo objects.
func convertChartsToChartInfo(charts []*repo.ChartVersion) []ChartInfo {
chartInfos := make([]ChartInfo, len(charts))
for i, chart := range charts {
chartInfos[i] = ChartInfo{
Name: chart.Name,
Version: chart.Version,
AppVersion: chart.AppVersion,
Description: chart.Description,
Deprecated: chart.Deprecated,
Created: chart.Created.String(),
Digest: chart.Digest,
Home: chart.Home,
Sources: chart.Sources,
URLs: chart.URLs,
Icon: chart.Icon,
Annotations: chart.Annotations,
}
}
return chartInfos
}
// ChartInfo represents a Helm chart in the repository index
type ChartInfo struct {
Name string `json:"name"`
Version string `json:"version"`
AppVersion string `json:"appVersion"`
Description string `json:"description"`
Deprecated bool `json:"deprecated"`
Created string `json:"created"`
Digest string `json:"digest"`
Home string `json:"home"`
Sources []string `json:"sources"`
URLs []string `json:"urls"`
Icon string `json:"icon,omitempty"`
Annotations any `json:"annotations,omitempty"`
}
// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Ensuring Helm directories exist")
// List of directories to ensure exist
directories := []string{
filepath.Dir(settings.RepositoryConfig), // Repository config directory
settings.RepositoryCache, // Repository cache directory
filepath.Dir(settings.RegistryConfig), // Registry config directory
settings.PluginsDirectory, // Plugins directory
}
// Create each directory if it doesn't exist
for _, dir := range directories {
if dir == "" {
continue // Skip empty paths
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
}
}
// Ensure registry config file exists
if settings.RegistryConfig != "" {
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
// Create the directory if it doesn't exist
dir := filepath.Dir(settings.RegistryConfig)
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
// Create an empty registry config file
if _, err := os.Create(settings.RegistryConfig); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RegistryConfig).
Err(err).
Msg("Failed to create registry config file")
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
}
}
}
// Ensure repository config file exists
if settings.RepositoryConfig != "" {
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
// Create an empty repository config file with default yaml structure
f := repo.NewFile()
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RepositoryConfig).
Err(err).
Msg("Failed to create repository config file")
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
}
}
}
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Successfully ensured all Helm directories exist")
return nil
}
func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) {
// Convert the index file to our response format
result, err := convertIndexToResponse(indexFile, chartName)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to convert index to response format")
return nil, errors.Wrap(err, "failed to convert index to response format")
}
log.Debug().
Str("context", "HelmClient").
Str("repo", chartName).
Int("entries_count", len(indexFile.Entries)).
Msg("Successfully searched repository")
return json.Marshal(result)
}