Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bake: implement composable attributes for attestations #2848

Merged
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
47 changes: 13 additions & 34 deletions bake/bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -699,16 +699,16 @@ type Target struct {
Inherits []string `json:"inherits,omitempty" hcl:"inherits,optional" cty:"inherits"`

Annotations []string `json:"annotations,omitempty" hcl:"annotations,optional" cty:"annotations"`
Attest []string `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"`
Attest buildflags.Attests `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"`
Context *string `json:"context,omitempty" hcl:"context,optional" cty:"context"`
Contexts map[string]string `json:"contexts,omitempty" hcl:"contexts,optional" cty:"contexts"`
Dockerfile *string `json:"dockerfile,omitempty" hcl:"dockerfile,optional" cty:"dockerfile"`
DockerfileInline *string `json:"dockerfile-inline,omitempty" hcl:"dockerfile-inline,optional" cty:"dockerfile-inline"`
Args map[string]*string `json:"args,omitempty" hcl:"args,optional" cty:"args"`
Labels map[string]*string `json:"labels,omitempty" hcl:"labels,optional" cty:"labels"`
Tags []string `json:"tags,omitempty" hcl:"tags,optional" cty:"tags"`
CacheFrom buildflags.CacheOptions `json:"cache-from,omitempty" hcl:"cache-from,optional" cty:"cache-from"`
CacheTo buildflags.CacheOptions `json:"cache-to,omitempty" hcl:"cache-to,optional" cty:"cache-to"`
CacheFrom buildflags.CacheOptions `json:"cache-from,omitempty" hcl:"cache-from,optional" cty:"cache-from"`
CacheTo buildflags.CacheOptions `json:"cache-to,omitempty" hcl:"cache-to,optional" cty:"cache-to"`
Target *string `json:"target,omitempty" hcl:"target,optional" cty:"target"`
Secrets buildflags.Secrets `json:"secret,omitempty" hcl:"secret,optional" cty:"secret"`
SSH buildflags.SSHKeys `json:"ssh,omitempty" hcl:"ssh,optional" cty:"ssh"`
Expand All @@ -718,8 +718,8 @@ type Target struct {
NoCache *bool `json:"no-cache,omitempty" hcl:"no-cache,optional" cty:"no-cache"`
NetworkMode *string `json:"network,omitempty" hcl:"network,optional" cty:"network"`
NoCacheFilter []string `json:"no-cache-filter,omitempty" hcl:"no-cache-filter,optional" cty:"no-cache-filter"`
ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional"`
Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional"`
ShmSize *string `json:"shm-size,omitempty" hcl:"shm-size,optional" cty:"shm-size"`
Ulimits []string `json:"ulimits,omitempty" hcl:"ulimits,optional" cty:"ulimits"`
Call *string `json:"call,omitempty" hcl:"call,optional" cty:"call"`
Entitlements []string `json:"entitlements,omitempty" hcl:"entitlements,optional" cty:"entitlements"`
// IMPORTANT: if you add more fields here, do not forget to update newOverrides/AddOverrides and docs/bake-reference.md.
Expand All @@ -737,7 +737,7 @@ var (

func (t *Target) normalize() {
t.Annotations = removeDupesStr(t.Annotations)
t.Attest = removeAttestDupes(t.Attest)
t.Attest = t.Attest.Normalize()
t.Tags = removeDupesStr(t.Tags)
t.Secrets = t.Secrets.Normalize()
t.SSH = t.SSH.Normalize()
Expand Down Expand Up @@ -811,8 +811,7 @@ func (t *Target) Merge(t2 *Target) {
t.Annotations = append(t.Annotations, t2.Annotations...)
}
if t2.Attest != nil { // merge
t.Attest = append(t.Attest, t2.Attest...)
t.Attest = removeAttestDupes(t.Attest)
t.Attest = t.Attest.Merge(t2.Attest)
}
if t2.Secrets != nil { // merge
t.Secrets = t.Secrets.Merge(t2.Secrets)
Expand Down Expand Up @@ -969,7 +968,11 @@ func (t *Target) AddOverrides(overrides map[string]Override, ent *EntitlementCon
case "annotations":
t.Annotations = append(t.Annotations, o.ArrValue...)
case "attest":
t.Attest = append(t.Attest, o.ArrValue...)
attest, err := parseArrValue[buildflags.Attest](o.ArrValue)
if err != nil {
return errors.Wrap(err, "invalid value for attest")
}
t.Attest = t.Attest.Merge(attest)
case "no-cache":
noCache, err := strconv.ParseBool(value)
if err != nil {
Expand Down Expand Up @@ -1383,11 +1386,7 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) {
}
}

attests, err := buildflags.ParseAttests(t.Attest)
if err != nil {
return nil, err
}
bo.Attests = controllerapi.CreateAttestations(attests)
bo.Attests = controllerapi.CreateAttestations(t.Attest.ToPB())

bo.SourcePolicy, err = build.ReadSourcePolicy()
if err != nil {
Expand Down Expand Up @@ -1430,26 +1429,6 @@ func removeDupesStr(s []string) []string {
return s[:i]
}

func removeAttestDupes(s []string) []string {
res := []string{}
m := map[string]int{}
for _, v := range s {
att, err := buildflags.ParseAttest(v)
if err != nil {
res = append(res, v)
continue
}

if i, ok := m[att.Type]; ok {
res[i] = v
} else {
m[att.Type] = len(res)
res = append(res, v)
}
}
return res
}

func setPushOverride(outputs []*buildflags.ExportEntry, push bool) []*buildflags.ExportEntry {
if !push {
// Disable push for any relevant export types
Expand Down
4 changes: 2 additions & 2 deletions bake/bake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1688,7 +1688,7 @@ func TestAttestDuplicates(t *testing.T) {
ctx := context.TODO()

m, _, err := ReadTargets(ctx, []File{fp}, []string{"default"}, nil, nil, &EntitlementConf{})
require.Equal(t, []string{"type=sbom,foo=bar", "type=provenance,mode=max"}, m["default"].Attest)
require.Equal(t, []string{"type=provenance,mode=max", "type=sbom,foo=bar"}, stringify(m["default"].Attest))
require.NoError(t, err)

opts, err := TargetsToBuildOpt(m, &Input{})
Expand All @@ -1699,7 +1699,7 @@ func TestAttestDuplicates(t *testing.T) {
}, opts["default"].Attests)

m, _, err = ReadTargets(ctx, []File{fp}, []string{"default"}, []string{"*.attest=type=sbom,disabled=true"}, nil, &EntitlementConf{})
require.Equal(t, []string{"type=sbom,disabled=true", "type=provenance,mode=max"}, m["default"].Attest)
require.Equal(t, []string{"type=provenance,mode=max", "type=sbom,disabled=true"}, stringify(m["default"].Attest))
require.NoError(t, err)

opts, err = TargetsToBuildOpt(m, &Input{})
Expand Down
6 changes: 6 additions & 0 deletions bake/hcl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,11 @@ func TestHCLAttrsCustomType(t *testing.T) {
func TestHCLAttrsCapsuleType(t *testing.T) {
dt := []byte(`
target "app" {
attest = [
{ type = "provenance", mode = "max" },
"type=sbom,disabled=true",
]

cache-from = [
{ type = "registry", ref = "user/app:cache" },
"type=local,src=path/to/cache",
Expand Down Expand Up @@ -634,6 +639,7 @@ func TestHCLAttrsCapsuleType(t *testing.T) {
require.NoError(t, err)

require.Equal(t, 1, len(c.Targets))
require.Equal(t, []string{"type=provenance,mode=max", "type=sbom,disabled=true"}, stringify(c.Targets[0].Attest))
require.Equal(t, []string{"type=local,dest=../out", "type=oci,dest=../out.tar"}, stringify(c.Targets[0].Outputs))
require.Equal(t, []string{"type=local,src=path/to/cache", "user/app:cache"}, stringify(c.Targets[0].CacheFrom))
require.Equal(t, []string{"type=local,dest=path/to/cache"}, stringify(c.Targets[0].CacheTo))
Expand Down
206 changes: 198 additions & 8 deletions util/buildflags/attests.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package buildflags

import (
"encoding/json"
"fmt"
"maps"
"strconv"
"strings"

Expand All @@ -10,6 +12,167 @@ import (
"github.com/tonistiigi/go-csvvalue"
)

type Attests []*Attest
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These types with ToPB/String/(Un)Marshal*/FromToCty are good candidates for unit tests. Some part of the helpers can probably be shared. I'm worried about cases where somebody makes a change in one of these functions but forgets to make a matching change in another.


func (a Attests) Merge(other Attests) Attests {
if other == nil {
a.Normalize()
return a
} else if a == nil {
other.Normalize()
return other
}

return append(a, other...).Normalize()
}

func (a Attests) Normalize() Attests {
if len(a) == 0 {
return nil
}
return removeAttestDupes(a)
}

func (a Attests) ToPB() []*controllerapi.Attest {
if len(a) == 0 {
return nil
}

entries := make([]*controllerapi.Attest, len(a))
for i, entry := range a {
entries[i] = entry.ToPB()
}
return entries
}

type Attest struct {
Type string `json:"type"`
Disabled bool `json:"disabled,omitempty"`
Attrs map[string]string `json:"attrs,omitempty"`
}

func (a *Attest) Equal(other *Attest) bool {
if a.Type != other.Type || a.Disabled != other.Disabled {
return false
}
return maps.Equal(a.Attrs, other.Attrs)
}

func (a *Attest) String() string {
var b csvBuilder
if a.Type != "" {
b.Write("type", a.Type)
}
if a.Disabled {
b.Write("disabled", "true")
}
if len(a.Attrs) > 0 {
b.WriteAttributes(a.Attrs)
}
return b.String()
}

func (a *Attest) ToPB() *controllerapi.Attest {
var b csvBuilder
if a.Type != "" {
b.Write("type", a.Type)
}
if a.Disabled {
b.Write("disabled", "true")
}
b.WriteAttributes(a.Attrs)

return &controllerapi.Attest{
Type: a.Type,
Disabled: a.Disabled,
Attrs: b.String(),
}
}

func (a *Attest) MarshalJSON() ([]byte, error) {
m := make(map[string]interface{}, len(a.Attrs)+2)
for k, v := range m {
m[k] = v
}
m["type"] = a.Type
if a.Disabled {
m["disabled"] = true
}
return json.Marshal(m)
}

func (a *Attest) UnmarshalJSON(data []byte) error {
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
return err
}

if typ, ok := m["type"]; ok {
a.Type, ok = typ.(string)
if !ok {
return errors.Errorf("attest type must be a string")
}
delete(m, "type")
}

if disabled, ok := m["disabled"]; ok {
a.Disabled, ok = disabled.(bool)
if !ok {
return errors.Errorf("attest disabled attribute must be a boolean")
}
delete(m, "disabled")
}

attrs := make(map[string]string, len(m))
for k, v := range m {
s, ok := v.(string)
if !ok {
return errors.Errorf("attest attribute %q must be a string", k)
}
attrs[k] = s
}
a.Attrs = attrs
return nil
}

func (a *Attest) UnmarshalText(text []byte) error {
in := string(text)
fields, err := csvvalue.Fields(in, nil)
if err != nil {
return err
}

a.Attrs = map[string]string{}
for _, field := range fields {
key, value, ok := strings.Cut(field, "=")
if !ok {
return errors.Errorf("invalid value %s", field)
}
key = strings.TrimSpace(strings.ToLower(key))

switch key {
case "type":
a.Type = value
case "disabled":
disabled, err := strconv.ParseBool(value)
if err != nil {
return errors.Wrapf(err, "invalid value %s", field)
}
a.Disabled = disabled
default:
a.Attrs[key] = value
}
}
return a.validate()
}

func (a *Attest) validate() error {
if a.Type == "" {
return errors.Errorf("attestation type not specified")
}
return nil
}

func CanonicalizeAttest(attestType string, in string) string {
if in == "" {
return ""
Expand All @@ -21,21 +184,34 @@ func CanonicalizeAttest(attestType string, in string) string {
}

func ParseAttests(in []string) ([]*controllerapi.Attest, error) {
var out []*controllerapi.Attest
found := map[string]struct{}{}
for _, in := range in {
in := in
attest, err := ParseAttest(in)
if err != nil {
var outs []*Attest
for _, s := range in {
var out Attest
if err := out.UnmarshalText([]byte(s)); err != nil {
return nil, err
}
outs = append(outs, &out)
}
return ConvertAttests(outs)
}

// ConvertAttests converts Attestations for the controller API from
// the ones in this package.
//
// Attestations of the same type will cause an error. Some tools,
// like bake, remove the duplicates before calling this function.
func ConvertAttests(in []*Attest) ([]*controllerapi.Attest, error) {
out := make([]*controllerapi.Attest, 0, len(in))

// Check for dupplicate attestations while we convert them
// to the controller API.
found := map[string]struct{}{}
for _, attest := range in {
if _, ok := found[attest.Type]; ok {
return nil, errors.Errorf("duplicate attestation field %s", attest.Type)
}
found[attest.Type] = struct{}{}

out = append(out, attest)
out = append(out, attest.ToPB())
}
return out, nil
}
Expand Down Expand Up @@ -77,3 +253,17 @@ func ParseAttest(in string) (*controllerapi.Attest, error) {

return &attest, nil
}

func removeAttestDupes(s []*Attest) []*Attest {
res := []*Attest{}
m := map[string]int{}
for _, att := range s {
if i, ok := m[att.Type]; ok {
res[i] = att
} else {
m[att.Type] = len(res)
res = append(res, att)
}
}
return res
}
Loading
Loading