diff --git a/README.md b/README.md index f3dc798..a28134d 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,26 @@ The `/resources` endpoint wraps the `http.RoundTripper` of the Helm client with - **Endpoint:** `/resources` - **Method:** `GET` -- **Query Parameters:** - - `compositionUID` (string): The unique identifier of the composition. - - `compositionNamespace` (string): The namespace where the composition is located. - - `compositionDefinitionUID` (string): The unique identifier of the composition definition. - - `compositionDefinitionNamespace` (string): The namespace where the composition definition is located. -- **Response:** A JSON object containing the resources involved in the Helm chart template. +- **Query Parameters (required):** + - `compositionName` (string): The name of the Composition resource. + - `compositionNamespace` (string): The namespace of the Composition resource. + - `compositionDefinitionName` (string): The name of the CompositionDefinition resource. + - `compositionDefinitionNamespace` (string): The namespace of the CompositionDefinition resource. + - `compositionVersion` (string): The API version of the Composition (e.g. `v1alpha1`). + - `compositionResource` (string): The plural resource name for Compositions (e.g. `compositions`). + +- **Query Parameters (optional):** + - `compositionGroup` (string): Composition group (default: `composition.krateo.io`). + - `compositionDefinitionGroup` (string): CompositionDefinition group (default: `core.krateo.io`). + - `compositionDefinitionVersion` (string): CompositionDefinition version (default: `v1alpha1`). + - `compositionDefinitionResource` (string): CompositionDefinition resource name (default: `compositiondefinitions`). + +- **Response:** JSON array of resources touched by the Helm chart template. ##### Example Request ```sh -curl "http://localhost:8081/resources?compositionUID=example-uid&compositionNamespace=default&compositionDefinitionUID=example-def-uid&compositionDefinitionNamespace=default" +curl "http://localhost:8081/resources?compositionName=my-composition&compositionNamespace=default&compositionDefinitionName=my-cd&compositionDefinitionNamespace=default&compositionVersion=v1alpha1&compositionResource=compositions" ``` ### Swagger Documentation @@ -47,3 +56,8 @@ Chart Inspector provides Swagger documentation for its API. You can access it at http://localhost:8081/swagger/ ``` +# Environment variables +Some environment variables affect the behavior of Chart Inspector and the components used in tests. + +- `DEBUG`: If set (e.g. DEBUG=true) enables debug output used in tests and local runs. Default is false. +- `HELM_CHART_CACHE_DIR`:Directory where downloaded charts are temporarily stored. If not set, /tmp/helmchart-cache is used. The cache is used by getter.Get (getter.go) to avoid repeated downloads. \ No newline at end of file diff --git a/internal/handlers/resources/get/resources.go b/internal/handlers/resources/get/resources.go index 06ef498..3fc9f09 100644 --- a/internal/handlers/resources/get/resources.go +++ b/internal/handlers/resources/get/resources.go @@ -11,6 +11,7 @@ import ( "github.com/krateoplatformops/chart-inspector/internal/getter" "github.com/krateoplatformops/chart-inspector/internal/handlers" + "github.com/krateoplatformops/chart-inspector/internal/handlers/resources" "github.com/krateoplatformops/chart-inspector/internal/helmclient" "github.com/krateoplatformops/chart-inspector/internal/helmclient/tools" "github.com/krateoplatformops/chart-inspector/internal/helper" @@ -57,6 +58,14 @@ var _ http.Handler = (*handler)(nil) // @Success 200 {object} []Resource // @Router /resources [get] func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + h.Log.Error("panic in ServeHTTP", + slog.Any("panic", rec)) + response.InternalError(w, fmt.Errorf("internal server error")) + } + }() + compositionName := r.URL.Query().Get("compositionName") compositionNamespace := r.URL.Query().Get("compositionNamespace") compositionDefinitionName := r.URL.Query().Get("compositionDefinitionName") @@ -209,12 +218,17 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Getting the resources - resources := tracer.GetResources() + resLi := tracer.GetResources() + + // assicurarsi di rispondere sempre con un array JSON invece di null/vuoto + if resLi == nil { + resLi = []resources.Resource{} + } // write the response in JSON format w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) - err = enc.Encode(resources) + err = enc.Encode(resLi) if err != nil { h.Log.Error("unable to marshal resources", slog.Any("err", err), diff --git a/internal/helm/getter/getter.go b/internal/helm/getter/getter.go index 78e86c4..6c796d5 100644 --- a/internal/helm/getter/getter.go +++ b/internal/helm/getter/getter.go @@ -1,11 +1,14 @@ package getter import ( - "bytes" + "crypto/sha256" "crypto/tls" + "encoding/hex" "fmt" "io" "net/http" + "os" + "path/filepath" "time" "github.com/krateoplatformops/unstructured-runtime/pkg/logging" @@ -25,61 +28,119 @@ type GetOptions struct { // Getter is an interface to support GET to the specified URI. type Getter interface { // Get file content by url string - Get(opts GetOptions) ([]byte, string, error) + Get(opts GetOptions) (io.ReadCloser, string, error) } -func Get(opts GetOptions) ([]byte, string, error) { - if isOCI(opts.URI) { - g, err := newOCIGetter() - if err != nil { - return nil, "", err +func Get(opts GetOptions) (io.ReadCloser, string, error) { + // Simple disk cache: env HELM_CHART_CACHE_DIR or /tmp/helmchart-cache + cacheDir := func() string { + if v := os.Getenv("HELM_CHART_CACHE_DIR"); v != "" { + return v } - return g.Get(opts) + return "/tmp/helmchart-cache" + }() + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + // non-blocking: log and continue to fetch from network } - if isTGZ(opts.URI) { - g := &tgzGetter{} - return g.Get(opts) + // cache key = sha256(uri|version|repo) + h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s", opts.URI, opts.Version, opts.Repo))) + cacheFile := filepath.Join(cacheDir, hex.EncodeToString(h[:])+".tgz") + + // if cached file exists, open and return it (caller must Close) + if fi, err := os.Stat(cacheFile); err == nil && fi.Mode().IsRegular() && fi.Size() > 0 { + f, err := os.Open(cacheFile) + if err == nil { + return f, cacheFile, nil + } + // if error opening, fallthrough to refetch } - if isHTTP(opts.URI) { + // fallback: call the appropriate getter and stream to cache file + var ( + rc io.ReadCloser + uri string + err error + ) + + // delegate to specific getters + if isOCI(opts.URI) { + g, errNew := newOCIGetter() + if errNew != nil { + return nil, "", errNew + } + rc, uri, err = g.Get(opts) + } else if isTGZ(opts.URI) { + g := &tgzGetter{} + rc, uri, err = g.Get(opts) + } else if isHTTP(opts.URI) { g := &repoGetter{} - return g.Get(opts) + rc, uri, err = g.Get(opts) + } else { + return nil, "", fmt.Errorf("no handler found for url: %s", opts.URI) + } + if err != nil { + return nil, "", err } + // ensure rc is closed on error / after copy + defer func() { + // if we return success we'll re-open cached file and return that handle instead + }() - return nil, "", fmt.Errorf("no handler found for url: %s", opts.URI) + // write stream -> tmp file in cache dir + tmpf, err := os.CreateTemp(cacheDir, "chart-*.tmp") + if err != nil { + rc.Close() + return nil, "", err + } + _, err = io.Copy(tmpf, rc) + // free original stream + _ = rc.Close() + // close tmp + if cerr := tmpf.Close(); cerr != nil && err == nil { + err = cerr + } + if err != nil { + os.Remove(tmpf.Name()) + return nil, "", err + } + + // atomic move to final cache path + if err := os.Rename(tmpf.Name(), cacheFile); err != nil { + // if rename fails, try to remove tmp and return file directly + os.Remove(tmpf.Name()) + return nil, "", err + } + + // open cached file for reading and return it + f, err := os.Open(cacheFile) + if err != nil { + return nil, "", err + } + return f, uri, nil } -func fetch(opts GetOptions) ([]byte, error) { +func fetchStream(opts GetOptions) (io.ReadCloser, error) { req, err := http.NewRequest(http.MethodGet, opts.URI, nil) if err != nil { return nil, err } - // Host on URL (returned from url.Parse) contains the port if present. - // This check ensures credentials are not passed between different - // services on different ports. if opts.PassCredentialsAll { if opts.Username != "" && opts.Password != "" { req.SetBasicAuth(opts.Username, opts.Password) } } - - // out, err := httputil.DumpRequest(req, true) - // fmt.Println(string(out)) - resp, err := newHTTPClient(opts).Do(req) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch %s : %s", opts.URI, resp.Status) + resp.Body.Close() + return nil, fmt.Errorf("failed to fetch %s: %s", opts.URI, resp.Status) } - - buf := bytes.NewBuffer(nil) - _, err = io.Copy(buf, resp.Body) - return buf.Bytes(), err + // return the body stream directly; caller is responsible for closing it + return resp.Body, nil } func newHTTPClient(opts GetOptions) *http.Client { diff --git a/internal/helm/getter/getter_test.go b/internal/helm/getter/getter_test.go index 35300d6..d147d80 100644 --- a/internal/helm/getter/getter_test.go +++ b/internal/helm/getter/getter_test.go @@ -1,11 +1,15 @@ -//go:build unit -// +build unit - package getter import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -17,13 +21,13 @@ func TestGet(t *testing.T) { opts GetOptions wantErr bool }{ - // { - // name: "OCI URI", - // opts: GetOptions{ - // URI: "oci://example.com/chart", - // }, - // wantErr: false, - // }, + { + name: "OCI URI", + opts: GetOptions{ + URI: "oci://registry-1.docker.io/bitnamicharts/nginx", + }, + wantErr: false, + }, { name: "TGZ URI", opts: GetOptions{ @@ -51,10 +55,18 @@ func TestGet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Delete tmp folder after each test + cacheDir := func() string { + if v := os.Getenv("HELM_CHART_CACHE_DIR"); v != "" { + return v + } + return "/tmp/helmchart-cache" + }() _, _, err := Get(tt.opts) if (err != nil) != tt.wantErr { t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr) } + os.RemoveAll(cacheDir) }) } } @@ -69,17 +81,17 @@ func TestTGZGetter(t *testing.T) { assert.NoError(t, err) } -// func TestOCIGetter(t *testing.T) { -// opts := GetOptions{ -// URI: "oci://example.com/chart", -// } +func TestOCIGetter(t *testing.T) { + opts := GetOptions{ + URI: "oci://registry-1.docker.io/bitnamicharts/nginx", + } -// g, err := newOCIGetter() -// assert.NoError(t, err) + g, err := newOCIGetter() + assert.NoError(t, err) -// _, _, err = g.Get(opts) -// assert.NoError(t, err) -// } + _, _, err = g.Get(opts) + assert.NoError(t, err) +} func TestRepoGetter(t *testing.T) { opts := GetOptions{ @@ -104,7 +116,73 @@ func TestFetch(t *testing.T) { URI: server.URL, } - data, err := fetch(opts) + data, err := fetchStream(opts) assert.NoError(t, err) - assert.Equal(t, "test data", string(data)) + b, err := io.ReadAll(data) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, "test data", string(b)) +} + +// ...existing code... + +// Test that Get uses the disk cache and does not re-download when the cache file exists +func TestGet_CacheAvoidsRedownload(t *testing.T) { + // create a temp dir for cache and force the library to use it + tmpdir := t.TempDir() + if err := os.Setenv("HELM_CHART_CACHE_DIR", tmpdir); err != nil { + t.Fatalf("failed to set env: %v", err) + } + defer os.Unsetenv("HELM_CHART_CACHE_DIR") + + var mu sync.Mutex + requests := 0 + + // server returns some data and counts requests + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests++ + mu.Unlock() + w.WriteHeader(http.StatusOK) + w.Write([]byte("chart-data")) + })) + defer server.Close() + + uri := server.URL + "/test.tgz" + opts := GetOptions{URI: uri} + + // First call should hit the server and populate cache + _, _, err := Get(opts) + if err != nil { + t.Fatalf("first Get failed: %v", err) + } + + // check server was called exactly once + mu.Lock() + if requests != 1 { + mu.Unlock() + t.Fatalf("expected 1 request after first Get, got %d", requests) + } + mu.Unlock() + + // compute expected cache file path (same logic as Get()) + h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s", opts.URI, opts.Version, opts.Repo))) + cacheFile := filepath.Join(tmpdir, hex.EncodeToString(h[:])+".tgz") + if _, err := os.Stat(cacheFile); err != nil { + t.Fatalf("expected cache file at %s, stat error: %v", cacheFile, err) + } + + // Second call should read from cache and NOT call server again + _, _, err = Get(opts) + if err != nil { + t.Fatalf("second Get failed: %v", err) + } + + mu.Lock() + if requests != 1 { + mu.Unlock() + t.Fatalf("expected cached access, server should have been called once, got %d", requests) + } + mu.Unlock() } diff --git a/internal/helm/getter/http.go b/internal/helm/getter/http.go index 06f1ca0..122adb4 100644 --- a/internal/helm/getter/http.go +++ b/internal/helm/getter/http.go @@ -2,6 +2,7 @@ package getter import ( "fmt" + "io" "net/url" "strings" @@ -12,12 +13,12 @@ var _ Getter = (*repoGetter)(nil) type repoGetter struct{} -func (g *repoGetter) Get(opts GetOptions) ([]byte, string, error) { +func (g *repoGetter) Get(opts GetOptions) (io.ReadCloser, string, error) { if !isHTTP(opts.URI) { return nil, "", fmt.Errorf("uri '%s' is not a valid Repo ref", opts.URI) } - buf, err := fetch(GetOptions{ + buf, err := fetchStream(GetOptions{ URI: fmt.Sprintf("%s/index.yaml", opts.URI), InsecureSkipVerifyTLS: opts.InsecureSkipVerifyTLS, Username: opts.Username, @@ -27,8 +28,12 @@ func (g *repoGetter) Get(opts GetOptions) ([]byte, string, error) { if err != nil { return nil, "", err } + bufb, err := io.ReadAll(buf) + if err != nil { + return nil, "", err + } - idx, err := repo.Load(buf, opts.URI, opts.Logging) + idx, err := repo.Load(bufb, opts.URI, opts.Logging) if err != nil { return nil, "", err } @@ -61,7 +66,7 @@ func (g *repoGetter) Get(opts GetOptions) ([]byte, string, error) { PassCredentialsAll: opts.PassCredentialsAll, } - dat, err := fetch(newopts) + dat, err := fetchStream(newopts) if err != nil { return nil, "", err } diff --git a/internal/helm/getter/oci.go b/internal/helm/getter/oci.go index 5665291..5b74ebd 100644 --- a/internal/helm/getter/oci.go +++ b/internal/helm/getter/oci.go @@ -1,7 +1,9 @@ package getter import ( + "bytes" "fmt" + "io" "net" "net/http" "net/url" @@ -54,7 +56,7 @@ type ociGetter struct { client *registry.Client } -func (g *ociGetter) Get(opts GetOptions) ([]byte, string, error) { +func (g *ociGetter) Get(opts GetOptions) (io.ReadCloser, string, error) { if !isOCI(opts.URI) { return nil, "", fmt.Errorf("uri '%s' is not a valid OCI ref", opts.URI) } @@ -67,17 +69,20 @@ func (g *ociGetter) Get(opts GetOptions) ([]byte, string, error) { if err != nil { return nil, "", err } + if opts.PassCredentialsAll { - host := strings.Split(ref, "/")[0] - loginopts := []registry.LoginOption{ - registry.LoginOptBasicAuth(opts.Username, opts.Password), - registry.LoginOptInsecure(opts.InsecureSkipVerifyTLS), - } - err := g.client.Login(host, loginopts...) - if err != nil { - return nil, "", fmt.Errorf("failed to login: %w", err) + if opts.Username != "" && opts.Password != "" { + host := strings.Split(ref, "/")[0] + loginopts := []registry.LoginOption{ + registry.LoginOptBasicAuth(opts.Username, opts.Password), + registry.LoginOptInsecure(opts.InsecureSkipVerifyTLS), + } + err := g.client.Login(host, loginopts...) + if err != nil { + return nil, "", fmt.Errorf("failed to login: %w", err) + } + defer g.client.Logout(host) } - defer g.client.Logout(host) } pullOpts := []registry.PullOption{ @@ -90,7 +95,7 @@ func (g *ociGetter) Get(opts GetOptions) ([]byte, string, error) { return nil, "", err } - return result.Chart.Data, opts.URI, nil + return io.NopCloser(bytes.NewReader(result.Chart.Data)), opts.URI, nil } func (g *ociGetter) resolveURI(ref, version string) (*url.URL, error) { diff --git a/internal/helm/getter/tgz.go b/internal/helm/getter/tgz.go index e40c764..1601cdf 100644 --- a/internal/helm/getter/tgz.go +++ b/internal/helm/getter/tgz.go @@ -2,6 +2,7 @@ package getter import ( "fmt" + "io" "strings" ) @@ -9,12 +10,12 @@ var _ Getter = (*tgzGetter)(nil) type tgzGetter struct{} -func (g *tgzGetter) Get(opts GetOptions) ([]byte, string, error) { +func (g *tgzGetter) Get(opts GetOptions) (io.ReadCloser, string, error) { if !isTGZ(opts.URI) { return nil, "", fmt.Errorf("uri '%s' is not a valid .tgz ref", opts.URI) } - dat, err := fetch(opts) + dat, err := fetchStream(opts) if err != nil { return nil, "", err } diff --git a/internal/helmclient/client.go b/internal/helmclient/client.go index 84261bd..3c2506a 100644 --- a/internal/helmclient/client.go +++ b/internal/helmclient/client.go @@ -1045,7 +1045,7 @@ func (c *HelmClient) GetChartV2(spec *ChartInfo) (*chart.Chart, string, error) { return nil, "", fmt.Errorf("failed to get chart %q: %w", spec.Url, err) } - helmChart, err := loader.LoadArchive(bytes.NewReader(bChart)) + helmChart, err := loader.LoadArchive(bChart) if err != nil { return nil, "", err } diff --git a/internal/tracer/tracer.go b/internal/tracer/tracer.go index 296d211..b2f0861 100644 --- a/internal/tracer/tracer.go +++ b/internal/tracer/tracer.go @@ -83,13 +83,5 @@ func (t *Tracer) RoundTrip(req *http.Request) (*http.Response, error) { return resp, err } - //Dump the response to t.OutFile. - _, err = httputil.DumpResponse(resp, req.URL.Query().Get("watch") != "true") - if err != nil { - return nil, err - } - // os.Stderr.Write(b) - // os.Stderr.Write([]byte{'\n'}) - return resp, err }