go-latestver/internal/fetcher/helm.go
Knut Ahlers cbb31d6f02
Add helm fetcher
Signed-off-by: Knut Ahlers <knut@ahlers.me>
2024-03-08 12:34:14 +01:00

164 lines
4.9 KiB
Go

package fetcher
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go-latestver/internal/helpers"
"github.com/Luzifer/go_helpers/v2/fieldcollection"
"github.com/pkg/errors"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/repo"
"sigs.k8s.io/yaml"
)
/*
* @module helm
* @module_desc Fetches the index file of a Helm Repo and yields the latest Helm-Chart version
*/
type (
// HELMFetcher implements the fetcher interface to retrieve a version from a Helm Repo
HELMFetcher struct{}
)
func init() { registerFetcher("helm", func() Fetcher { return &HELMFetcher{} }) }
// FetchVersion retrieves the latest version for the catalog entry
func (h HELMFetcher) FetchVersion(ctx context.Context, attrs *fieldcollection.FieldCollection) (string, time.Time, error) {
vers, err := h.getChartVersionsFromRepo(ctx, attrs.MustString("repo", nil), attrs.MustString("chart", nil))
if err != nil {
return "", time.Time{}, fmt.Errorf("fetching chart versions: %w", err)
}
if vers == nil {
return "", time.Time{}, fmt.Errorf("chart not found in repo")
}
return vers[0].Version, vers[0].Created, nil
}
// Links retrieves a collection of links for the fetcher
func (HELMFetcher) Links(_ *fieldcollection.FieldCollection) []database.CatalogLink { return nil }
// Validate validates the configuration given to the fetcher
func (HELMFetcher) Validate(attrs *fieldcollection.FieldCollection) error {
// @attr repo required string "" URL of the repo (i.e. "https://grafana.github.io/helm-charts")
if v, err := attrs.String("repo"); err != nil || v == "" {
return errors.New("repo is expected to be non-empty string")
}
// @attr chart required string "" Chart to fetch the version of (i.e. "grafana")
if v, err := attrs.String("chart"); err != nil || v == "" {
return errors.New("chart is expected to be non-empty string")
}
return nil
}
func (h HELMFetcher) getChartVersionsFromRepo(ctx context.Context, repoURL, chartName string) (repo.ChartVersions, error) {
if !strings.HasSuffix(repoURL, "/index.yaml") {
repoURL = strings.Join([]string{
strings.TrimRight(repoURL, "/"),
"index.yaml",
}, "/")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, repoURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer func() { helpers.LogIfErr(resp.Body.Close(), "closing response body after read") }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
}
buf := new(bytes.Buffer)
if _, err = io.Copy(buf, resp.Body); err != nil {
return nil, fmt.Errorf("reading index content: %w", err)
}
index, err := h.loadIndex(buf.Bytes(), repoURL)
if err != nil {
return nil, fmt.Errorf("parsing index file: %w", err)
}
return index.Entries[chartName], nil
}
/*
* Load functions taken from Helm v3 library as they are only defined
* internally and not exposed
*
* https://github.com/helm/helm/blob/v3.14.2/pkg/repo/index.go#L341-L394
*/
// loadIndex loads an index file and does minimal validity checking.
//
// The source parameter is only used for logging.
// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails.
func (h HELMFetcher) loadIndex(data []byte, source string) (*repo.IndexFile, error) {
i := &repo.IndexFile{}
if len(data) == 0 {
return i, repo.ErrEmptyIndexYaml
}
if err := h.jsonOrYamlUnmarshal(data, i); err != nil {
return i, err
}
for name, cvs := range i.Entries {
for idx := len(cvs) - 1; idx >= 0; idx-- {
if cvs[idx] == nil {
log.Printf("skipping loading invalid entry for chart %q from %s: empty entry", name, source)
continue
}
// When metadata section missing, initialize with no data
if cvs[idx].Metadata == nil {
cvs[idx].Metadata = &chart.Metadata{}
}
if cvs[idx].APIVersion == "" {
cvs[idx].APIVersion = chart.APIVersionV1
}
if err := cvs[idx].Validate(); err != nil {
log.Printf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err)
cvs = append(cvs[:idx], cvs[idx+1:]...)
}
}
}
i.SortEntries()
if i.APIVersion == "" {
return i, repo.ErrNoAPIVersion
}
return i, nil
}
// jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML
// into the provided interface.
//
// It automatically detects whether the data is in JSON or YAML format by
// checking its validity as JSON. If the data is valid JSON, it will use the
// `encoding/json` package to unmarshal it. Otherwise, it will use the
// `sigs.k8s.io/yaml` package to unmarshal the YAML data.
func (HELMFetcher) jsonOrYamlUnmarshal(b []byte, i interface{}) error {
if json.Valid(b) {
return json.Unmarshal(b, i) //nolint:wrapcheck // Fine at this point
}
return yaml.UnmarshalStrict(b, i) //nolint:wrapcheck // Fine at this point
}