Skip to content
Merged
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
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
18 changes: 16 additions & 2 deletions internal/handlers/resources/get/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand Down
119 changes: 90 additions & 29 deletions internal/helm/getter/getter.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand Down
Loading