Skip to content

Commit a0d3ad5

Browse files
authored
feat: Add support for JUnit XML material type (#135)
Signed-off-by: Daniel Liszka <[email protected]>
1 parent d20db08 commit a0d3ad5

File tree

10 files changed

+304
-11
lines changed

10 files changed

+304
-11
lines changed

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

Lines changed: 7 additions & 1 deletion
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: 14 additions & 10 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
@@ -54,6 +54,7 @@ message CraftingSchema {
5454
ARTIFACT = 3;
5555
SBOM_CYCLONEDX_JSON = 4;
5656
SBOM_SPDX_JSON = 5;
57+
JUNIT_XML = 6;
5758
// SARIF = 5;
5859
}
5960
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ require (
3838
github.com/in-toto/in-toto-golang v0.8.0
3939
github.com/jackc/pgx/v4 v4.18.1
4040
github.com/jedib0t/go-pretty/v6 v6.4.6
41+
github.com/joshdk/go-junit v1.0.0
4142
github.com/lib/pq v1.10.7
4243
github.com/moby/moby v23.0.1+incompatible
4344
github.com/opencontainers/image-spec v1.1.0-rc2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,8 @@ github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqx
744744
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
745745
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
746746
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
747+
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
748+
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
747749
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
748750
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
749751
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//
2+
// Copyright 2023 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+
"context"
20+
"fmt"
21+
"io"
22+
"os"
23+
24+
"encoding/xml"
25+
26+
api "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1"
27+
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
28+
"github.com/chainloop-dev/chainloop/internal/casclient"
29+
junit "github.com/joshdk/go-junit"
30+
"github.com/rs/zerolog"
31+
)
32+
33+
type JUnitXMLCrafter struct {
34+
*crafterCommon
35+
uploader casclient.Uploader
36+
}
37+
38+
func NewJUnitXMLCrafter(schema *schemaapi.CraftingSchema_Material, uploader casclient.Uploader, l *zerolog.Logger) (*JUnitXMLCrafter, error) {
39+
if schema.Type != schemaapi.CraftingSchema_Material_JUNIT_XML {
40+
return nil, fmt.Errorf("material type is not JUnit XML")
41+
}
42+
craftCommon := &crafterCommon{logger: l, input: schema}
43+
return &JUnitXMLCrafter{uploader: uploader, crafterCommon: craftCommon}, nil
44+
}
45+
46+
func (i *JUnitXMLCrafter) Craft(ctx context.Context, filePath string) (*api.Attestation_Material, error) {
47+
if err := i.validate(filePath); err != nil {
48+
return nil, err
49+
}
50+
51+
return uploadAndCraft(ctx, i.input, i.uploader, filePath)
52+
}
53+
54+
func (i *JUnitXMLCrafter) validate(filePath string) error {
55+
f, err := os.Open(filePath)
56+
if err != nil {
57+
return fmt.Errorf("can't open the file: %w", err)
58+
}
59+
defer f.Close()
60+
61+
bytes, err := io.ReadAll(f)
62+
if err != nil {
63+
return fmt.Errorf("can't read the file: %w", err)
64+
}
65+
66+
if err := xml.Unmarshal(bytes, &junit.Suite{}); err != nil {
67+
return fmt.Errorf("invalid JUnit XML file: %w", ErrInvalidMaterialType)
68+
}
69+
70+
_, err = junit.IngestReader(f)
71+
if err != nil {
72+
i.logger.Debug().Err(err).Msgf("error decoding file: %s", filePath)
73+
return fmt.Errorf("invalid JUnit XML file: %w", ErrInvalidMaterialType)
74+
}
75+
76+
return nil
77+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//
2+
// Copyright 2023 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+
"time"
22+
23+
attestationApi "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1"
24+
contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
25+
"github.com/chainloop-dev/chainloop/internal/attestation/crafter/materials"
26+
"github.com/chainloop-dev/chainloop/internal/casclient"
27+
mUploader "github.com/chainloop-dev/chainloop/internal/casclient/mocks"
28+
"github.com/rs/zerolog"
29+
"github.com/stretchr/testify/assert"
30+
"github.com/stretchr/testify/require"
31+
)
32+
33+
func TestNewJUnitXMLCrafter(t *testing.T) {
34+
testCases := []struct {
35+
name string
36+
input *contractAPI.CraftingSchema_Material
37+
wantErr bool
38+
}{
39+
{
40+
name: "happy path",
41+
input: &contractAPI.CraftingSchema_Material{
42+
Type: contractAPI.CraftingSchema_Material_JUNIT_XML,
43+
},
44+
},
45+
{
46+
name: "wrong type",
47+
input: &contractAPI.CraftingSchema_Material{
48+
Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE,
49+
},
50+
wantErr: true,
51+
},
52+
}
53+
54+
for _, tc := range testCases {
55+
t.Run(tc.name, func(t *testing.T) {
56+
_, err := materials.NewJUnitXMLCrafter(tc.input, nil, nil)
57+
if tc.wantErr {
58+
assert.Error(t, err)
59+
return
60+
}
61+
62+
assert.NoError(t, err)
63+
})
64+
}
65+
}
66+
func TestJUnitXMLCraft(t *testing.T) {
67+
testCases := []struct {
68+
name string
69+
filePath string
70+
wantErr string
71+
}{
72+
{
73+
name: "invalid path",
74+
filePath: "./testdata/non-existing.json",
75+
wantErr: "no such file or directory",
76+
},
77+
{
78+
name: "invalid artifact type",
79+
filePath: "./testdata/simple.txt",
80+
wantErr: "unexpected material type",
81+
},
82+
{
83+
name: "invalid artifact type",
84+
filePath: "./testdata/junit-invalid.xml",
85+
wantErr: "unexpected material type",
86+
},
87+
{
88+
name: "valid artifact type",
89+
filePath: "./testdata/junit.xml",
90+
},
91+
}
92+
93+
assert := assert.New(t)
94+
schema := &contractAPI.CraftingSchema_Material{
95+
Name: "test",
96+
Type: contractAPI.CraftingSchema_Material_JUNIT_XML,
97+
}
98+
l := zerolog.Nop()
99+
for _, tc := range testCases {
100+
t.Run(tc.name, func(t *testing.T) {
101+
// Mock uploader
102+
uploader := mUploader.NewUploader(t)
103+
if tc.wantErr == "" {
104+
uploader.On("UploadFile", context.TODO(), tc.filePath).
105+
Return(&casclient.UpDownStatus{
106+
Digest: "deadbeef",
107+
Filename: "test.xml",
108+
}, nil)
109+
}
110+
111+
crafter, err := materials.NewJUnitXMLCrafter(schema, uploader, &l)
112+
require.NoError(t, err)
113+
114+
got, err := crafter.Craft(context.TODO(), tc.filePath)
115+
if tc.wantErr != "" {
116+
assert.ErrorContains(err, tc.wantErr)
117+
return
118+
}
119+
120+
require.NoError(t, err)
121+
assert.Equal(contractAPI.CraftingSchema_Material_JUNIT_XML.String(), got.MaterialType.String())
122+
assert.WithinDuration(time.Now(), got.AddedAt.AsTime(), 5*time.Second)
123+
124+
// The result includes the digest reference
125+
assert.Equal(got.GetArtifact(), &attestationApi.Attestation_Material_Artifact{
126+
Id: "test", Digest: "deadbeef", Name: "test.xml",
127+
})
128+
})
129+
}
130+
}

internal/attestation/crafter/materials/materials.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ package materials
1818
import (
1919
"context"
2020
"fmt"
21+
"time"
2122

2223
api "github.com/chainloop-dev/chainloop/app/cli/api/attestation/v1"
2324
schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1"
2425
"github.com/chainloop-dev/chainloop/internal/casclient"
2526
"github.com/rs/zerolog"
27+
"google.golang.org/protobuf/types/known/timestamppb"
2628
)
2729

2830
// ErrInvalidMaterialType is returned when the provided material type
@@ -34,6 +36,30 @@ type crafterCommon struct {
3436
input *schemaapi.CraftingSchema_Material
3537
}
3638

39+
// uploadAndCraft uploads the artifact to CAS and crafts the material
40+
// this function is used by all the uploadable artifacts crafters (SBOMs, JUnit, and more in the future)
41+
func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Material, uploader casclient.Uploader, artifactPath string) (*api.Attestation_Material, error) {
42+
result, err := uploader.UploadFile(ctx, artifactPath)
43+
if err != nil {
44+
return nil, fmt.Errorf("uploading material: %w", err)
45+
}
46+
47+
res := &api.Attestation_Material{
48+
AddedAt: timestamppb.New(time.Now()),
49+
MaterialType: input.Type,
50+
M: &api.Attestation_Material_Artifact_{
51+
Artifact: &api.Attestation_Material_Artifact{
52+
Id: input.Name,
53+
Name: result.Filename,
54+
Digest: result.Digest,
55+
IsSubject: input.Output,
56+
},
57+
},
58+
}
59+
60+
return res, nil
61+
}
62+
3763
type Craftable interface {
3864
Craft(ctx context.Context, value string) (*api.Attestation_Material, error)
3965
}
@@ -53,6 +79,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia
5379
crafter, err = NewCyclonedxJSONCrafter(materialSchema, uploader, logger)
5480
case schemaapi.CraftingSchema_Material_SBOM_SPDX_JSON:
5581
crafter, err = NewSPDXJSONCrafter(materialSchema, uploader, logger)
82+
case schemaapi.CraftingSchema_Material_JUNIT_XML:
83+
crafter, err = NewJUnitXMLCrafter(materialSchema, uploader, logger)
5684
default:
5785
return nil, fmt.Errorf("material of type %q not supported yet", materialSchema.Type)
5886
}

0 commit comments

Comments
 (0)