Skip to content

Commit 0d4acdc

Browse files
committed
feat(vsa): generate digest-based VSAs for index and arch images
Switch VSA generation from name-based to digest-based grouping. Image index expansion now records relationships between image indexes and their child manifests, storing this mapping in the report. VSAs for an index image include validation results for the index and all child images, while VSAs for a child image contain only its own results.
1 parent 589f6e3 commit 0d4acdc

11 files changed

Lines changed: 203 additions & 146 deletions

File tree

cmd/validate/__snapshots__/image_test.snap

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
[Test_ValidateImageCommandYAMLPolicyFile/spec - 1]
32
{
43
"components": [
@@ -36,7 +35,6 @@
3635
"success": true
3736
}
3837
---
39-
4038
[Test_ValidateImageCommandYAMLPolicyFile/ecp - 1]
4139
{
4240
"components": [

cmd/validate/image.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,16 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
7070
rekorURL string
7171
snapshot string
7272
spec *app.SnapshotSpec
73-
strict bool
74-
images string
75-
noColor bool
76-
forceColor bool
77-
workers int
78-
vsaEnabled bool
79-
vsaSigningKey string
80-
vsaUpload []string
73+
// Only used to pass the expansion info to the report. Not a cli flag.
74+
expansion *applicationsnapshot.ExpansionInfo
75+
strict bool
76+
images string
77+
noColor bool
78+
forceColor bool
79+
workers int
80+
vsaEnabled bool
81+
vsaSigningKey string
82+
vsaUpload []string
8183
}{
8284
strict: true,
8385
workers: 5,
@@ -200,7 +202,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
200202
cmd.SetContext(ctx)
201203
}
202204

203-
if s, err := applicationsnapshot.DetermineInputSpec(ctx, applicationsnapshot.Input{
205+
if s, exp, err := applicationsnapshot.DetermineInputSpec(ctx, applicationsnapshot.Input{
204206
File: data.filePath,
205207
JSON: data.input,
206208
Image: data.imageRef,
@@ -210,6 +212,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
210212
allErrors = errors.Join(allErrors, err)
211213
} else {
212214
data.spec = s
215+
data.expansion = exp
213216
}
214217

215218
policyConfiguration, err := validate_utils.GetPolicyConfig(ctx, data.policyConfiguration)
@@ -450,7 +453,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
450453
data.output = append(data.output, fmt.Sprintf("%s=%s", applicationsnapshot.JSON, data.outputFile))
451454
}
452455

453-
report, err := applicationsnapshot.NewReport(data.snapshot, components, data.policy, manyPolicyInput, showSuccesses)
456+
report, err := applicationsnapshot.NewReport(data.snapshot, components, data.policy, manyPolicyInput, showSuccesses, data.expansion)
454457
if err != nil {
455458
return err
456459
}

cmd/validate/image_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ func Test_determineInputSpec(t *testing.T) {
323323
ctx := oci.WithClient(context.Background(), &client)
324324
for _, c := range cases {
325325
t.Run(c.name, func(t *testing.T) {
326-
s, err := applicationsnapshot.DetermineInputSpec(ctx, applicationsnapshot.Input{
326+
s, _, err := applicationsnapshot.DetermineInputSpec(ctx, applicationsnapshot.Input{
327327
File: c.arguments.filePath,
328328
JSON: c.arguments.input,
329329
Image: c.arguments.imageRef,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright The Conforma Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
package applicationsnapshot
18+
19+
// ExpansionInfo tracks the relationships between image indexes and their child manifests
20+
// that are created when expanding multi-arch images.
21+
type ExpansionInfo struct {
22+
// ChildrenByIndex maps an image index digest to the list of child manifest digests
23+
ChildrenByIndex map[string][]string `json:"childrenByIndex,omitempty"`
24+
// ParentByChild maps a child manifest digest to its parent index digest
25+
ParentByChild map[string]string `json:"parentByChild,omitempty"`
26+
// IndexAliases maps image references to their pinned digest form
27+
IndexAliases map[string]string `json:"indexAliases,omitempty"`
28+
}
29+
30+
// NewExpansionInfo creates a new ExpansionInfo instance
31+
func NewExpansionInfo() *ExpansionInfo {
32+
return &ExpansionInfo{
33+
ChildrenByIndex: make(map[string][]string),
34+
ParentByChild: make(map[string]string),
35+
IndexAliases: make(map[string]string),
36+
}
37+
}

internal/applicationsnapshot/input.go

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type Input struct {
5454

5555
type snapshot struct {
5656
app.SnapshotSpec
57+
Expansion *ExpansionInfo
5758
}
5859

5960
func (s *snapshot) merge(snap app.SnapshotSpec) {
@@ -89,7 +90,7 @@ func (s *snapshot) merge(snap app.SnapshotSpec) {
8990
}
9091
}
9192

92-
func DetermineInputSpec(ctx context.Context, input Input) (*app.SnapshotSpec, error) {
93+
func DetermineInputSpec(ctx context.Context, input Input) (*app.SnapshotSpec, *ExpansionInfo, error) {
9394
var snapshot snapshot
9495
provided := false
9596

@@ -106,7 +107,7 @@ func DetermineInputSpec(ctx context.Context, input Input) (*app.SnapshotSpec, er
106107

107108
file, err := readSnapshotSource(content)
108109
if err != nil {
109-
return nil, err
110+
return nil, nil, err
110111
}
111112
snapshot.merge(file)
112113
provided = true
@@ -117,11 +118,11 @@ func DetermineInputSpec(ctx context.Context, input Input) (*app.SnapshotSpec, er
117118
fs := utils.FS(ctx)
118119
content, err := afero.ReadFile(fs, input.File)
119120
if err != nil {
120-
return nil, err
121+
return nil, nil, err
121122
}
122123
file, err := readSnapshotSource(content)
123124
if err != nil {
124-
return nil, err
125+
return nil, nil, err
125126
}
126127
snapshot.merge(file)
127128
provided = true
@@ -131,7 +132,7 @@ func DetermineInputSpec(ctx context.Context, input Input) (*app.SnapshotSpec, er
131132
if input.JSON != "" {
132133
json, err := readSnapshotSource([]byte(input.JSON))
133134
if err != nil {
134-
return nil, err
135+
return nil, nil, err
135136
}
136137
snapshot.merge(json)
137138
provided = true
@@ -156,25 +157,29 @@ func DetermineInputSpec(ctx context.Context, input Input) (*app.SnapshotSpec, er
156157
client, err := kubernetes.NewClient(ctx)
157158
if err != nil {
158159
log.Debugf("Unable to initialize Kubernetes Client: %v", err)
159-
return nil, err
160+
return nil, nil, err
160161
}
161162

162163
cluster, err := client.FetchSnapshot(ctx, input.Snapshot)
163164
if err != nil {
164165
log.Debugf("Unable to fetch snapshot %s from Kubernetes cluster: %v", input.Snapshot, err)
165-
return nil, err
166+
return nil, nil, err
166167
}
167168
snapshot.merge(cluster.Spec)
168169
provided = true
169170
}
170171

171172
if !provided {
172173
log.Debug("No application snapshot available")
173-
return nil, errors.New("neither Snapshot nor image reference provided to validate")
174+
return nil, nil, errors.New("neither Snapshot nor image reference provided to validate")
174175
}
175-
expandImageIndex(ctx, &snapshot.SnapshotSpec)
176+
exp := expandImageIndex(ctx, &snapshot.SnapshotSpec)
176177

177-
return &snapshot.SnapshotSpec, nil
178+
// Store expansion info in the snapshot for later use
179+
// This will be used when building the Report
180+
snapshot.Expansion = exp
181+
182+
return &snapshot.SnapshotSpec, exp, nil
178183
}
179184

180185
func readSnapshotSource(input []byte) (app.SnapshotSpec, error) {
@@ -192,7 +197,7 @@ func readSnapshotSource(input []byte) (app.SnapshotSpec, error) {
192197
// For an image index, remove the original component and replace it with an expanded component with all its image manifests
193198
// Do not raise an error if the image is inaccessible, it will be handled as a violation when evaluated against the policy
194199
// This is to retain the original behavior of the `ec validate` command.
195-
func imageIndexWorker(client oci.Client, component app.SnapshotComponent, componentChan chan<- []app.SnapshotComponent, errorsChan chan<- error) {
200+
func imageIndexWorker(client oci.Client, component app.SnapshotComponent, componentChan chan<- []app.SnapshotComponent, errorsChan chan<- error, exp *ExpansionInfo) {
196201
var components []app.SnapshotComponent
197202
components = append(components, component)
198203
// to avoid adding to componentsChan before each return
@@ -228,6 +233,10 @@ func imageIndexWorker(client oci.Client, component app.SnapshotComponent, compon
228233
return
229234
}
230235

236+
// Track expansion metadata
237+
idxPinned := fmt.Sprintf("%s@%s", ref.Context().Name(), desc.Digest)
238+
exp.IndexAliases[ref.Name()] = idxPinned
239+
231240
// Add the platform-specific image references (Image Manifests) to the list of components so
232241
// each is validated as well as the multi-platform image reference (Image Index).
233242
for i, manifest := range indexManifest.Manifests {
@@ -241,15 +250,21 @@ func imageIndexWorker(client oci.Client, component app.SnapshotComponent, compon
241250
archComponent.Name = fmt.Sprintf("%s-%s-%s", component.Name, manifest.Digest, arch)
242251
archComponent.ContainerImage = fmt.Sprintf("%s@%s", ref.Context().Name(), manifest.Digest)
243252
components = append(components, archComponent)
253+
254+
// Track parent-child relationships
255+
childPinned := archComponent.ContainerImage
256+
exp.ChildrenByIndex[idxPinned] = append(exp.ChildrenByIndex[idxPinned], childPinned)
257+
exp.ParentByChild[childPinned] = idxPinned
244258
}
245259
}
246260

247-
func expandImageIndex(ctx context.Context, snap *app.SnapshotSpec) {
261+
func expandImageIndex(ctx context.Context, snap *app.SnapshotSpec) *ExpansionInfo {
248262
if trace.IsEnabled() {
249263
region := trace.StartRegion(ctx, "ec:expand-image-index")
250264
defer region.End()
251265
}
252266

267+
exp := NewExpansionInfo()
253268
client := oci.NewClient(ctx)
254269

255270
componentChan := make(chan []app.SnapshotComponent, len(snap.Components))
@@ -259,7 +274,7 @@ func expandImageIndex(ctx context.Context, snap *app.SnapshotSpec) {
259274
for _, component := range snap.Components {
260275
// fetch manifests concurrently
261276
g.Go(func() error {
262-
imageIndexWorker(client, component, componentChan, errorsChan)
277+
imageIndexWorker(client, component, componentChan, errorsChan, exp)
263278
return nil
264279
})
265280
}
@@ -289,6 +304,8 @@ func expandImageIndex(ctx context.Context, snap *app.SnapshotSpec) {
289304
log.Warnf("Encountered error while checking for Image Index: %v", allErrors)
290305
}
291306
log.Debugf("Snap component after expanding the image index is %v", snap.Components)
307+
308+
return exp
292309
}
293310

294311
func imageWorkers() int {

internal/applicationsnapshot/input_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ func Test_DetermineInputSpec(t *testing.T) {
177177
panic(err)
178178
}
179179
}
180-
got, err := DetermineInputSpec(ctx, tc.input)
180+
got, _, err := DetermineInputSpec(ctx, tc.input)
181181
// expect an error so check for nil
182182
if tc.want != nil {
183183
assert.NoError(t, err)

internal/applicationsnapshot/report.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type Report struct {
6161
EffectiveTime time.Time `json:"effective-time"`
6262
PolicyInput [][]byte `json:"-"`
6363
ShowSuccesses bool `json:"-"`
64+
Expansion *ExpansionInfo `json:"-"`
6465
}
6566

6667
type summary struct {
@@ -126,7 +127,7 @@ var OutputFormats = []string{
126127

127128
// WriteReport returns a new instance of Report representing the state of
128129
// components from the snapshot.
129-
func NewReport(snapshot string, components []Component, policy policy.Policy, policyInput [][]byte, showSuccesses bool) (Report, error) {
130+
func NewReport(snapshot string, components []Component, policy policy.Policy, policyInput [][]byte, showSuccesses bool, expansion *ExpansionInfo) (Report, error) {
130131
success := true
131132

132133
// Set the report success, remains true if all components are successful
@@ -157,6 +158,7 @@ func NewReport(snapshot string, components []Component, policy policy.Policy, po
157158
PolicyInput: policyInput,
158159
EffectiveTime: policy.EffectiveTime().UTC(),
159160
ShowSuccesses: showSuccesses,
161+
Expansion: expansion,
160162
}, nil
161163
}
162164

internal/applicationsnapshot/report_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func Test_ReportJson(t *testing.T) {
5252

5353
ctx := context.Background()
5454
testPolicy := createTestPolicy(t, ctx)
55-
report, err := NewReport("snappy", components, testPolicy, nil, true)
55+
report, err := NewReport("snappy", components, testPolicy, nil, true, nil)
5656
assert.NoError(t, err)
5757

5858
testEffectiveTime := testPolicy.EffectiveTime().UTC().Format(time.RFC3339Nano)
@@ -110,7 +110,7 @@ func Test_ReportYaml(t *testing.T) {
110110

111111
ctx := context.Background()
112112
testPolicy := createTestPolicy(t, ctx)
113-
report, err := NewReport("snappy", components, testPolicy, nil, true)
113+
report, err := NewReport("snappy", components, testPolicy, nil, true, nil)
114114
assert.NoError(t, err)
115115

116116
testEffectiveTime := testPolicy.EffectiveTime().UTC().Format(time.RFC3339Nano)
@@ -257,7 +257,7 @@ func Test_GenerateMarkdownSummary(t *testing.T) {
257257
for _, c := range cases {
258258
t.Run(c.name, func(t *testing.T) {
259259
ctx := context.Background()
260-
report, err := NewReport(c.snapshot, c.components, createTestPolicy(t, ctx), nil, true)
260+
report, err := NewReport(c.snapshot, c.components, createTestPolicy(t, ctx), nil, true, nil)
261261
assert.NoError(t, err)
262262
report.created = time.Unix(0, 0).UTC()
263263

@@ -504,7 +504,7 @@ func Test_ReportSummary(t *testing.T) {
504504
for _, tc := range tests {
505505
t.Run(fmt.Sprintf("NewReport=%s", tc.name), func(t *testing.T) {
506506
ctx := context.Background()
507-
report, err := NewReport(tc.snapshot, []Component{tc.input}, createTestPolicy(t, ctx), nil, true)
507+
report, err := NewReport(tc.snapshot, []Component{tc.input}, createTestPolicy(t, ctx), nil, true, nil)
508508
assert.NoError(t, err)
509509
assert.Equal(t, tc.want, report.toSummary())
510510
})
@@ -641,7 +641,7 @@ func Test_ReportAppstudio(t *testing.T) {
641641
assert.NoError(t, err)
642642

643643
ctx := context.Background()
644-
report, err := NewReport(c.snapshot, c.components, createTestPolicy(t, ctx), nil, true)
644+
report, err := NewReport(c.snapshot, c.components, createTestPolicy(t, ctx), nil, true, nil)
645645
assert.NoError(t, err)
646646
assert.False(t, report.created.IsZero())
647647
assert.Equal(t, c.success, report.Success)
@@ -789,7 +789,7 @@ func Test_ReportHACBS(t *testing.T) {
789789
assert.NoError(t, err)
790790

791791
ctx := context.Background()
792-
report, err := NewReport(c.snapshot, c.components, createTestPolicy(t, ctx), nil, true)
792+
report, err := NewReport(c.snapshot, c.components, createTestPolicy(t, ctx), nil, true, nil)
793793
assert.NoError(t, err)
794794
assert.False(t, report.created.IsZero())
795795
assert.Equal(t, c.success, report.Success)
@@ -821,7 +821,7 @@ func Test_ReportPolicyInput(t *testing.T) {
821821
}
822822

823823
ctx := context.Background()
824-
report, err := NewReport("snapshot", nil, createTestPolicy(t, ctx), policyInput, true)
824+
report, err := NewReport("snapshot", nil, createTestPolicy(t, ctx), policyInput, true, nil)
825825
require.NoError(t, err)
826826

827827
p := format.NewTargetParser(JSON, format.Options{}, defaultWriter, fs)

internal/input/report_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ func Test_ReportSummary(t *testing.T) {
233233
t.Run(fmt.Sprintf("NewReport=%s", tc.name), func(t *testing.T) {
234234
ctx := context.Background()
235235
report, err := NewReport(tc.input, createTestPolicy(t, ctx), nil)
236-
// report, err := NewReport(tc.snapshot, []Component{tc.input}, createTestPolicy(t, ctx), nil)
236+
// report, err := NewReport(tc.snapshot, []Component{tc.input}, createTestPolicy(t, ctx), nil, nil)
237237
assert.NoError(t, err)
238238
fmt.Println("\n\nExpected:\n", tc.want, "\n\nActual:\n", report.toSummary())
239239
assert.Equal(t, tc.want, report.toSummary())

0 commit comments

Comments
 (0)