Skip to content

Commit 76b9a5f

Browse files
committed
feat(materials): Helm Chart material support
Signed-off-by: Javier Rodriguez <javier@chainloop.dev>
1 parent ea0be40 commit 76b9a5f

11 files changed

Lines changed: 295 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ Chainloop supports the collection of the following pieces of evidence types:
130130
- [OpenVEX](https://github.com/openvex)
131131
- [SARIF](https://docs.oasis-open.org/sarif/sarif/v2.1.0/)
132132
- [JUnit](https://www.ibm.com/docs/en/developer-for-zos/14.1?topic=formats-junit-xml-format)
133+
- [Helm Charts](https://helm.sh/docs/topics/charts/)
133134
- Generic Artifact Types
134135
- Key-Value metadata pairs
135136

app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go

Lines changed: 19 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/api/workflowcontract/v1/crafting_schema.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ message CraftingSchema {
8383
// Static analysis output format
8484
// https://github.com/microsoft/sarif-tutorials/blob/main/docs/1-Introduction.md
8585
SARIF = 9;
86+
HELM_CHART = 10;
8687
}
8788
}
8889
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ require (
310310
google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
311311
gopkg.in/inf.v0 v0.9.1 // indirect
312312
gopkg.in/ini.v1 v1.67.0 // indirect
313-
gopkg.in/yaml.v2 v2.4.0 // indirect
313+
gopkg.in/yaml.v2 v2.4.0
314314
gopkg.in/yaml.v3 v3.0.1 // indirect
315315
k8s.io/api v0.28.3 // indirect
316316
k8s.io/apimachinery v0.28.3
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// Copyright 2024 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package materials
17+
18+
import (
19+
"archive/tar"
20+
"compress/gzip"
21+
"context"
22+
"fmt"
23+
"io"
24+
"os"
25+
"strings"
26+
27+
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
28+
api "github.com/chainloop-dev/chainloop/internal/attestation/crafter/api/attestation/v1"
29+
"github.com/chainloop-dev/chainloop/internal/casclient"
30+
"github.com/rs/zerolog"
31+
"gopkg.in/yaml.v2"
32+
)
33+
34+
const (
35+
ChartFileName = "Chart.yaml"
36+
ValuesYamlFileName = "values.yaml"
37+
)
38+
39+
type HelmChartCrafter struct {
40+
backend *casclient.CASBackend
41+
*crafterCommon
42+
}
43+
44+
func NewHelmChartCrafter(materialSchema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend,
45+
l *zerolog.Logger) (*HelmChartCrafter, error) {
46+
if materialSchema.Type != schemaapi.CraftingSchema_Material_HELM_CHART {
47+
return nil, fmt.Errorf("material type is not HELM_CHART format")
48+
}
49+
50+
return &HelmChartCrafter{
51+
backend: backend,
52+
crafterCommon: &crafterCommon{logger: l, input: materialSchema},
53+
}, nil
54+
}
55+
56+
func (c *HelmChartCrafter) Craft(ctx context.Context, filepath string) (*api.Attestation_Material, error) {
57+
// Open the helm chart tar file
58+
f, err := os.Open(filepath)
59+
if err != nil {
60+
return nil, fmt.Errorf("can't open the file: %w", err)
61+
}
62+
defer f.Close()
63+
64+
// Decompress the file if possible
65+
uncompressedStream, err := gzip.NewReader(f)
66+
if err != nil {
67+
return nil, fmt.Errorf("can't uncompress file, unexpected material type: %w", err)
68+
}
69+
70+
// Create a tar reader
71+
tarReader := tar.NewReader(uncompressedStream)
72+
73+
// Flags to track whether required files are found
74+
chartFileValid, chartValuesValid := false, false
75+
76+
// Iterate through the files in the tar archive
77+
for {
78+
header, err := tarReader.Next()
79+
if err == io.EOF {
80+
// Reached the end of tar archive
81+
break
82+
}
83+
if err != nil {
84+
return nil, fmt.Errorf("error reading tar file: %w", err)
85+
}
86+
87+
// Check if the file is a regular file
88+
if header.Typeflag != tar.TypeReg {
89+
continue // Skip if it's not a regular file
90+
}
91+
92+
// Validate Chart.yaml and values.yaml files
93+
if strings.Contains(header.Name, ChartFileName) {
94+
if err := validateYamlFile(tarReader); err != nil {
95+
return nil, fmt.Errorf("invalid Chart.yaml file: %w", err)
96+
}
97+
chartFileValid = true
98+
} else if strings.Contains(header.Name, ValuesYamlFileName) {
99+
if err := validateYamlFile(tarReader); err != nil {
100+
return nil, fmt.Errorf("invalid values.yaml file: %w", err)
101+
}
102+
chartValuesValid = true
103+
}
104+
105+
// Stop iterating if both files are found
106+
if chartValuesValid && chartFileValid {
107+
break
108+
}
109+
}
110+
111+
// If the chart.yaml and values.yaml files are not found, return an error
112+
if !chartFileValid || !chartValuesValid {
113+
return nil, fmt.Errorf("missing required files in the helm chart: Chart.yaml and values.yaml")
114+
}
115+
116+
// Upload and craft the chart
117+
return uploadAndCraft(ctx, c.input, c.backend, filepath, c.logger)
118+
}
119+
120+
// validateYamlFile validates the YAML file just by trying to unmarshal it
121+
func validateYamlFile(r io.Reader) error {
122+
v := make(map[string]interface{})
123+
if err := yaml.NewDecoder(r).Decode(v); err != nil {
124+
return fmt.Errorf("failed to unmarshal YAML file: %w", err)
125+
}
126+
127+
return nil
128+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//
2+
// Copyright 2024 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package materials_test
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
23+
attestationApi "github.com/chainloop-dev/chainloop/internal/attestation/crafter/api/attestation/v1"
24+
"github.com/chainloop-dev/chainloop/internal/attestation/crafter/materials"
25+
"github.com/chainloop-dev/chainloop/internal/casclient"
26+
mUploader "github.com/chainloop-dev/chainloop/internal/casclient/mocks"
27+
"github.com/rs/zerolog"
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
func TestNewHelmChartCrafter(t *testing.T) {
33+
testCases := []struct {
34+
name string
35+
input *contractAPI.CraftingSchema_Material
36+
wantErr bool
37+
}{
38+
{
39+
name: "happy path",
40+
input: &contractAPI.CraftingSchema_Material{
41+
Type: contractAPI.CraftingSchema_Material_HELM_CHART,
42+
},
43+
},
44+
{
45+
name: "wrong type",
46+
input: &contractAPI.CraftingSchema_Material{
47+
Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE,
48+
},
49+
wantErr: true,
50+
},
51+
}
52+
53+
for _, tc := range testCases {
54+
t.Run(tc.name, func(t *testing.T) {
55+
_, err := materials.NewHelmChartCrafter(tc.input, nil, nil)
56+
if tc.wantErr {
57+
assert.Error(t, err)
58+
return
59+
}
60+
61+
assert.NoError(t, err)
62+
})
63+
}
64+
}
65+
66+
func TestHelmChartCraft(t *testing.T) {
67+
testCases := []struct {
68+
name string
69+
filePath string
70+
wantErr string
71+
wantFilename string
72+
wantDigest string
73+
}{
74+
{
75+
name: "invalid path",
76+
filePath: "./testdata/non-existing.json",
77+
wantErr: "no such file or directory",
78+
},
79+
{
80+
name: "missing Chart.yaml file",
81+
filePath: "./testdata/missing-chartyaml.tgz",
82+
wantErr: "missing required files in the helm chart: Chart.yaml and values.yaml",
83+
},
84+
{
85+
name: "missing values.yaml file",
86+
filePath: "./testdata/missing-valuesyaml.tgz",
87+
wantErr: "missing required files in the helm chart: Chart.yaml and values.yaml",
88+
},
89+
{
90+
name: "invalid artifact type",
91+
filePath: "./testdata/simple.txt",
92+
wantErr: "unexpected material type",
93+
},
94+
{
95+
name: "valid artifact type",
96+
filePath: "./testdata/valid-chart.tgz",
97+
wantDigest: "sha256:08a46a850789938ede61d6a53552f48cb8ba74c4e17dcf30c9c50e5783ca6a13",
98+
wantFilename: "valid-chart.tgz",
99+
},
100+
}
101+
102+
assert := assert.New(t)
103+
schema := &contractAPI.CraftingSchema_Material{
104+
Name: "test",
105+
Type: contractAPI.CraftingSchema_Material_HELM_CHART,
106+
}
107+
l := zerolog.Nop()
108+
for _, tc := range testCases {
109+
t.Run(tc.name, func(t *testing.T) {
110+
// Mock uploader
111+
uploader := mUploader.NewUploader(t)
112+
if tc.wantErr == "" {
113+
uploader.On("UploadFile", context.TODO(), tc.filePath).
114+
Return(&casclient.UpDownStatus{}, nil)
115+
}
116+
117+
backend := &casclient.CASBackend{Uploader: uploader}
118+
crafter, err := materials.NewHelmChartCrafter(schema, backend, &l)
119+
require.NoError(t, err)
120+
121+
got, err := crafter.Craft(context.TODO(), tc.filePath)
122+
if tc.wantErr != "" {
123+
assert.ErrorContains(err, tc.wantErr)
124+
return
125+
}
126+
127+
require.NoError(t, err)
128+
assert.Equal(contractAPI.CraftingSchema_Material_HELM_CHART.String(), got.MaterialType.String())
129+
assert.True(got.UploadedToCas)
130+
131+
// The result includes the digest reference
132+
assert.Equal(&attestationApi.Attestation_Material_Artifact{
133+
Id: "test", Digest: tc.wantDigest, Name: tc.wantFilename,
134+
}, got.GetArtifact())
135+
})
136+
}
137+
}

0 commit comments

Comments
 (0)