Skip to content

Commit 7ae5575

Browse files
committed
feat(vsa): introduce stable VSA schema and switch to minimal payload
Full rule results in VSAs made them large and brittle. This change introduces a compact, reproducible schema that stores only evaluation inputs and status metadata. - Define a stable Predicate struct for VSAs capturing: resolved ECP (pinned refs), image refs, timestamp, status, verifier, and summary counts. - Drop successes/violations/warnings from the payload. - Update VSA generation, snapshot handling, and tests to use the new predicate and write path. Results in smaller, more stable VSA artifacts that record evaluation inputs and summary counts rather than full rule results.
1 parent 8442a8c commit 7ae5575

File tree

11 files changed

+947
-401
lines changed

11 files changed

+947
-401
lines changed

cmd/validate/image.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
6969
outputFile string
7070
policy policy.Policy
7171
policyConfiguration string
72+
policySource string
7273
publicKey string
7374
rekorURL string
7475
snapshot string
@@ -221,6 +222,9 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
221222
data.expansion = exp
222223
}
223224

225+
// Store policy source before resolution
226+
data.policySource = data.policyConfiguration
227+
224228
policyConfiguration, err := validate_utils.GetPolicyConfig(ctx, data.policyConfiguration)
225229
if err != nil {
226230
allErrors = errors.Join(allErrors, err)
@@ -500,7 +504,7 @@ func validateImageCmd(validate imageValidationFunc) *cobra.Command {
500504
}
501505

502506
// Create VSA service
503-
vsaService := vsa.NewServiceWithFS(signer, utils.FS(cmd.Context()))
507+
vsaService := vsa.NewServiceWithFS(signer, utils.FS(cmd.Context()), data.policySource, data.policy)
504508

505509
// Define helper functions for getting git URL and digest
506510
getGitURL := func(comp applicationsnapshot.Component) string {

internal/applicationsnapshot/report.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ func (r *Report) toFormat(format string) (data []byte, err error) {
224224
}
225225

226226
func (r *Report) toVSA() ([]byte, error) {
227-
generator := NewSnapshotVSAGenerator(*r)
227+
generator := NewSnapshotPredicateGenerator(*r)
228228
predicate, err := generator.GeneratePredicate(context.Background())
229229
if err != nil {
230230
return []byte{}, err

internal/applicationsnapshot/vsa.go

Lines changed: 167 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,69 @@ import (
2222
"fmt"
2323
"os"
2424
"path/filepath"
25+
"time"
2526

27+
ecc "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1"
2628
log "github.com/sirupsen/logrus"
2729
"github.com/spf13/afero"
2830
)
2931

30-
// SnapshotVSAWriter handles writing application snapshot VSA predicates to files
31-
type SnapshotVSAWriter struct {
32+
// SnapshotComponentDetail represents detailed information about a component in the snapshot summary
33+
type SnapshotComponentDetail struct {
34+
Name string `json:"name"`
35+
ContainerImage string `json:"containerImage"`
36+
Success bool `json:"success"`
37+
Violations int `json:"violations"`
38+
Warnings int `json:"warnings"`
39+
Successes int `json:"successes"`
40+
}
41+
42+
// SnapshotSummary represents the summary information for a snapshot predicate
43+
type SnapshotSummary struct {
44+
Snapshot string `json:"snapshot"`
45+
Components int `json:"components"`
46+
Success bool `json:"success"`
47+
Key string `json:"key"`
48+
EcVersion string `json:"ec_version"`
49+
ComponentDetails []SnapshotComponentDetail `json:"component_details"`
50+
Violations int `json:"Violations"`
51+
Warnings int `json:"Warnings"`
52+
}
53+
54+
// SnapshotPredicate represents a predicate for an entire application snapshot
55+
type SnapshotPredicate struct {
56+
Policy ecc.EnterpriseContractPolicySpec `json:"policy"`
57+
ImageRefs []string `json:"imageRefs"`
58+
Timestamp string `json:"timestamp"`
59+
Status string `json:"status"`
60+
Verifier string `json:"verifier"`
61+
Summary SnapshotSummary `json:"summary"`
62+
}
63+
64+
// SnapshotPredicateWriter handles writing application snapshot predicates to files
65+
type SnapshotPredicateWriter struct {
3266
FS afero.Fs // defaults to afero.NewOsFs()
33-
TempDirPrefix string // defaults to "snapshot-vsa-"
67+
TempDirPrefix string // defaults to "snapshot-predicate-"
3468
FilePerm os.FileMode // defaults to 0600
3569
}
3670

37-
// NewSnapshotVSAWriter creates a new application snapshot VSA file writer
38-
func NewSnapshotVSAWriter() *SnapshotVSAWriter {
39-
return &SnapshotVSAWriter{
71+
// NewSnapshotPredicateWriter creates a new application snapshot predicate file writer
72+
func NewSnapshotPredicateWriter() *SnapshotPredicateWriter {
73+
return &SnapshotPredicateWriter{
4074
FS: afero.NewOsFs(),
41-
TempDirPrefix: "snapshot-vsa-",
75+
TempDirPrefix: "snapshot-predicate-",
4276
FilePerm: 0o600,
4377
}
4478
}
4579

46-
// WritePredicate writes the Report as a VSA predicate to a file
47-
func (s *SnapshotVSAWriter) WritePredicate(report Report) (string, error) {
80+
// WritePredicate writes the SnapshotPredicate as a JSON file to a temp directory and returns the path
81+
func (s *SnapshotPredicateWriter) WritePredicate(predicate *SnapshotPredicate) (string, error) {
4882
log.Infof("Writing application snapshot VSA")
4983

5084
// Serialize with indent
51-
data, err := json.MarshalIndent(report, "", " ")
85+
data, err := json.MarshalIndent(predicate, "", " ")
5286
if err != nil {
53-
return "", fmt.Errorf("failed to marshal application snapshot VSA: %w", err)
87+
return "", fmt.Errorf("failed to marshal application snapshot VSA predicate: %w", err)
5488
}
5589

5690
// Create temp directory
@@ -59,32 +93,144 @@ func (s *SnapshotVSAWriter) WritePredicate(report Report) (string, error) {
5993
return "", fmt.Errorf("failed to create temp directory: %w", err)
6094
}
6195

62-
// Write to file
96+
// Write to file with same naming convention as old VSA
6397
filename := "application-snapshot-vsa.json"
6498
filepath := filepath.Join(tempDir, filename)
6599
err = afero.WriteFile(s.FS, filepath, data, s.FilePerm)
66100
if err != nil {
67-
return "", fmt.Errorf("failed to write application snapshot VSA to file: %w", err)
101+
return "", fmt.Errorf("failed to write application snapshot VSA predicate to file: %w", err)
68102
}
69103

70104
log.Infof("Application snapshot VSA written to: %s", filepath)
71105
return filepath, nil
72106
}
73107

74-
type SnapshotVSAGenerator struct {
108+
// SnapshotPredicateGenerator generates predicates for application snapshots
109+
type SnapshotPredicateGenerator struct {
75110
Report Report
76111
}
77112

78-
// NewSnapshotVSAGenerator creates a new VSA predicate generator for application snapshots
79-
func NewSnapshotVSAGenerator(report Report) *SnapshotVSAGenerator {
80-
return &SnapshotVSAGenerator{
113+
// NewSnapshotPredicateGenerator creates a new predicate generator for application snapshots
114+
func NewSnapshotPredicateGenerator(report Report) *SnapshotPredicateGenerator {
115+
return &SnapshotPredicateGenerator{
81116
Report: report,
82117
}
83118
}
84119

85-
// GeneratePredicate creates a VSA predicate for the entire application snapshot
86-
func (s *SnapshotVSAGenerator) GeneratePredicate(ctx context.Context) (Report, error) {
87-
log.Infof("Generating application snapshot VSA predicate with %d components", len(s.Report.Components))
120+
// GeneratePredicate creates a predicate for the entire application snapshot
121+
func (s *SnapshotPredicateGenerator) GeneratePredicate(ctx context.Context) (*SnapshotPredicate, error) {
122+
log.Infof("Generating application snapshot EC predicate with %d components", len(s.Report.Components))
123+
124+
// Collect all image references from all components
125+
imageRefs := s.getAllImageRefs()
126+
127+
// Determine overall status
128+
status := "failed"
129+
if s.Report.Success {
130+
status = "passed"
131+
}
132+
133+
// Add detailed component breakdown and calculate totals
134+
components := make([]SnapshotComponentDetail, 0, len(s.Report.Components))
135+
totalViolations := 0
136+
totalWarnings := 0
137+
138+
for _, comp := range s.Report.Components {
139+
compViolations := len(comp.Violations)
140+
compWarnings := len(comp.Warnings)
141+
142+
compDetails := SnapshotComponentDetail{
143+
Name: comp.Name,
144+
ContainerImage: comp.ContainerImage,
145+
Success: comp.Success,
146+
Violations: compViolations,
147+
Warnings: compWarnings,
148+
Successes: len(comp.Successes),
149+
}
150+
components = append(components, compDetails)
151+
152+
// Add to totals
153+
totalViolations += compViolations
154+
totalWarnings += compWarnings
155+
}
156+
157+
// Create summary with component information
158+
summary := SnapshotSummary{
159+
Snapshot: s.Report.Snapshot,
160+
Components: len(s.Report.Components),
161+
Success: s.Report.Success,
162+
Key: s.Report.Key,
163+
EcVersion: s.Report.EcVersion,
164+
ComponentDetails: components,
165+
Violations: totalViolations,
166+
Warnings: totalWarnings,
167+
}
168+
169+
return &SnapshotPredicate{
170+
Policy: s.Report.Policy, // This contains the resolved policy with pinned URLs
171+
ImageRefs: imageRefs,
172+
Timestamp: time.Now().UTC().Format(time.RFC3339),
173+
Status: status,
174+
Verifier: "conforma",
175+
Summary: summary,
176+
}, nil
177+
}
178+
179+
// getAllImageRefs returns all image references from all components including expansion info
180+
func (s *SnapshotPredicateGenerator) getAllImageRefs() []string {
181+
var allImageRefs []string
182+
183+
for _, comp := range s.Report.Components {
184+
// Add the main component image reference
185+
allImageRefs = append(allImageRefs, comp.ContainerImage)
186+
187+
// If we have expansion info, add the index and all architecture-specific images
188+
if s.Report.Expansion != nil {
189+
// Get the normalized index reference
190+
normalizedRef := normalizeIndexRef(comp.ContainerImage, s.Report.Expansion)
191+
192+
// Add the index reference if it's different from the component image
193+
if normalizedRef != comp.ContainerImage {
194+
allImageRefs = append(allImageRefs, normalizedRef)
195+
}
196+
197+
// Get all child images for this index
198+
if children, ok := s.Report.Expansion.GetChildrenByIndex(normalizedRef); ok {
199+
allImageRefs = append(allImageRefs, children...)
200+
}
201+
}
202+
}
203+
204+
// Remove duplicates and return
205+
return removeDuplicateStrings(allImageRefs)
206+
}
207+
208+
// normalizeIndexRef normalizes an image reference to its pinned digest form if it's an index
209+
func normalizeIndexRef(ref string, exp *ExpansionInfo) string {
210+
if exp == nil {
211+
return ref
212+
}
213+
if pinned, ok := exp.GetIndexAlias(ref); ok {
214+
return pinned
215+
}
216+
return ref
217+
}
218+
219+
// removeDuplicateStrings removes duplicate strings from a slice
220+
func removeDuplicateStrings(slice []string) []string {
221+
if len(slice) == 0 {
222+
return []string{}
223+
}
224+
225+
keys := make(map[string]bool)
226+
var result []string
227+
228+
for _, item := range slice {
229+
if !keys[item] {
230+
keys[item] = true
231+
result = append(result, item)
232+
}
233+
}
88234

89-
return s.Report, nil
235+
return result
90236
}

0 commit comments

Comments
 (0)