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
5 changes: 5 additions & 0 deletions app/cli/cmd/attestation_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ func displayMaterialInfo(status *action.AttestationStatusMaterial, policyEvaluat
mt.AppendRow(table.Row{"Type", status.Type})
mt.AppendRow(table.Row{"Required", hBool(status.Required)})

if status.Group != "" {
mt.AppendRow(table.Row{"Group", status.Group})
mt.AppendRow(table.Row{"Rule", "at least one of the group required"})
}

if status.IsOutput {
mt.AppendRow(table.Row{"Is output", "Yes"})
}
Expand Down
134 changes: 94 additions & 40 deletions app/cli/cmd/attestation_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,63 +190,117 @@ func materialsTable(status *action.AttestationStatusResult, w io.Writer, full bo
return nil
}

// Sort materials by name for consistent output
slices.SortFunc(status.Materials, func(a, b action.AttestationStatusMaterial) int {
return strings.Compare(a.Name, b.Name)
})
// Partition materials into standalone (ungrouped) ones and choke groups.
// Grouped materials are rendered together under a group header so it is
// clear they form an "at least one of" set rather than independent materials.
var ungrouped []action.AttestationStatusMaterial
groupedBy := make(map[string][]action.AttestationStatusMaterial)
var groupOrder []string
for _, m := range status.Materials {
if m.Group == "" {
ungrouped = append(ungrouped, m)
continue
}
if _, ok := groupedBy[m.Group]; !ok {
groupOrder = append(groupOrder, m.Group)
}
groupedBy[m.Group] = append(groupedBy[m.Group], m)
}

byName := func(a, b action.AttestationStatusMaterial) int { return strings.Compare(a.Name, b.Name) }
slices.SortFunc(ungrouped, byName)
slices.Sort(groupOrder)

mt := output.NewTableWriterWithWriter(w)
mt.SetTitle("Materials")

for _, m := range status.Materials {
mt.AppendRow(table.Row{"Name", m.Name})
mt.AppendRow(table.Row{"Type", m.Type})
mt.AppendRow(table.Row{"Set", hBool(m.Set)})
mt.AppendRow(table.Row{"Required", hBool(m.Required)})
if m.IsOutput {
mt.AppendRow(table.Row{"Is output", "Yes"})
}
if m.SkipUpload {
mt.AppendRow(table.Row{"Skip upload", "Yes"})
}
for _, m := range ungrouped {
appendMaterialRows(mt, m, status, full, false)
mt.AppendSeparator()
}

if full {
if m.Value != "" {
v := m.Value
if m.Tag != "" {
v = fmt.Sprintf("%s:%s", v, m.Tag)
}
mt.AppendRow(table.Row{"Value", wrap.String(v, 100)})
}
for _, g := range groupOrder {
members := groupedBy[g]
slices.SortFunc(members, byName)

if m.Hash != "" {
mt.AppendRow(table.Row{"Digest", m.Hash})
// A choke group is satisfied as soon as one of its members is set.
satisfied := false
for _, m := range members {
if m.Set {
satisfied = true
break
}
}

if len(m.Annotations) > 0 {
mt.AppendRow(table.Row{"Annotations", "------"})
for _, a := range m.Annotations {
value := a.Value
if value == "" {
value = NotSet
}
mt.AppendRow(table.Row{"Group", g})
mt.AppendRow(table.Row{"Rule", fmt.Sprintf("at least one of %d required", len(members))})
mt.AppendRow(table.Row{"Satisfied", hBool(satisfied)})
mt.AppendSeparator()

for _, m := range members {
appendMaterialRows(mt, m, status, full, true)
mt.AppendSeparator()
}
}

mt.Render()

mt.AppendRow(table.Row{"", fmt.Sprintf("%s: %s", a.Name, value)})
return nil
}

// appendMaterialRows renders a single material as a block of rows. When the
// material belongs to a choke group, its name is indented under the group
// header and the per-material "Required" row is omitted (the group header
// carries the "at least one of" requirement instead).
func appendMaterialRows(mt table.Writer, m action.AttestationStatusMaterial, status *action.AttestationStatusResult, full, grouped bool) {
name := m.Name
if grouped {
name = "↳ " + name
}
mt.AppendRow(table.Row{"Name", name})
mt.AppendRow(table.Row{"Type", m.Type})
mt.AppendRow(table.Row{"Set", hBool(m.Set)})
if !grouped {
mt.AppendRow(table.Row{"Required", hBool(m.Required)})
}
if m.IsOutput {
mt.AppendRow(table.Row{"Is output", "Yes"})
}
if m.SkipUpload {
mt.AppendRow(table.Row{"Skip upload", "Yes"})
}

if full {
if m.Value != "" {
v := m.Value
if m.Tag != "" {
v = fmt.Sprintf("%s:%s", v, m.Tag)
}
mt.AppendRow(table.Row{"Value", wrap.String(v, 100)})
}

evs := status.PolicyEvaluations[m.Name]
if len(evs) > 0 {
mt.AppendRow(table.Row{"Policies", "------"})
policiesTable(evs, mt, flagDebug)
if m.Hash != "" {
mt.AppendRow(table.Row{"Digest", m.Hash})
}
}

mt.AppendSeparator()
if len(m.Annotations) > 0 {
mt.AppendRow(table.Row{"Annotations", "------"})
for _, a := range m.Annotations {
value := a.Value
if value == "" {
value = NotSet
}

mt.AppendRow(table.Row{"", fmt.Sprintf("%s: %s", a.Name, value)})
}
}
mt.Render()

return nil
evs := status.PolicyEvaluations[m.Name]
if len(evs) > 0 {
mt.AppendRow(table.Row{"Policies", "------"})
policiesTable(evs, mt, flagDebug)
}
}

func hBool(b bool) string {
Expand Down
8 changes: 7 additions & 1 deletion app/cli/pkg/action/attestation_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ type AttestationStatusWorkflowMeta struct {
type AttestationStatusMaterial struct {
*Material
Set, IsOutput, Required, SkipUpload bool
// Group is the choke group this material belongs to. Materials sharing a
// non-empty group form an "at least one of" set.
Group string `json:"group,omitempty"`
}

func NewAttestationStatus(cfg *AttestationStatusOpts) (*AttestationStatus, error) {
Expand Down Expand Up @@ -225,12 +228,15 @@ func populateMaterials(craftingState *v1.CraftingState, res *AttestationStatusRe
func populateContractMaterials(inputSchemaMaterials []*pbc.CraftingSchema_Material, attsMaterial map[string]*v1.Attestation_Material, res *AttestationStatusResult, visitedMaterials map[string]struct{}) error {
for _, m := range inputSchemaMaterials {
// This one need to be crafter manually because it might not be in the attestation yet
// Grouped materials are enforced at the group level ("at least one of"),
// so they are not individually required.
materialResult := &AttestationStatusMaterial{
Material: &Material{
Name: m.Name, Type: m.Type.String(),
Annotations: pbAnnotationsToAction(m.Annotations),
},
IsOutput: m.Output, Required: !m.Optional, SkipUpload: m.SkipUpload,
IsOutput: m.Output, Required: !m.Optional && m.Group == "", SkipUpload: m.SkipUpload,
Group: m.Group,
}

if cm, found := attsMaterial[m.Name]; found {
Expand Down
41 changes: 40 additions & 1 deletion app/cli/pkg/action/attestation_status_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024-2025 The Chainloop Authors.
// Copyright 2024-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -115,3 +115,42 @@ func TestPopulateContractMaterials(t *testing.T) {
})
}
}

func TestPopulateContractMaterialsGroups(t *testing.T) {
state := &v1.CraftingState{
Schema: &v1.CraftingState_InputSchema{
InputSchema: &craftingpb.CraftingSchema{
SchemaVersion: "v1",
Materials: []*craftingpb.CraftingSchema_Material{
{Type: craftingpb.CraftingSchema_Material_ARTIFACT, Name: "required-one"},
{Type: craftingpb.CraftingSchema_Material_ARTIFACT, Name: "optional-one", Optional: true},
{Type: craftingpb.CraftingSchema_Material_SBOM_CYCLONEDX_JSON, Name: "sbom-cyclonedx", Group: "sbom"},
{Type: craftingpb.CraftingSchema_Material_SBOM_SPDX_JSON, Name: "sbom-spdx", Group: "sbom"},
},
},
},
}

res := &AttestationStatusResult{}
err := populateMaterials(state, res)
assert.NoError(t, err)

byName := make(map[string]AttestationStatusMaterial, len(res.Materials))
for _, m := range res.Materials {
byName[m.Name] = m
}

// Ungrouped required material: Required, no group
assert.True(t, byName["required-one"].Required)
assert.Empty(t, byName["required-one"].Group)

// Ungrouped optional material: not required, no group
assert.False(t, byName["optional-one"].Required)
assert.Empty(t, byName["optional-one"].Group)

// Grouped materials: carry the group and are NOT individually required
assert.Equal(t, "sbom", byName["sbom-cyclonedx"].Group)
assert.False(t, byName["sbom-cyclonedx"].Required)
assert.Equal(t, "sbom", byName["sbom-spdx"].Group)
assert.False(t, byName["sbom-spdx"].Required)
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 17 additions & 5 deletions app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading