From 4f81bcb5c8feea2e463561dc5639b9aa8a696c40 Mon Sep 17 00:00:00 2001 From: "Jonathan A. Sternberg" Date: Mon, 9 Dec 2024 16:27:02 -0600 Subject: [PATCH] bake: implement composable attributes for attestations Signed-off-by: Jonathan A. Sternberg --- bake/bake.go | 47 +++----- bake/bake_test.go | 4 +- bake/hcl_test.go | 6 + util/buildflags/attests.go | 206 +++++++++++++++++++++++++++++++-- util/buildflags/attests_cty.go | 97 ++++++++++++++++ 5 files changed, 316 insertions(+), 44 deletions(-) create mode 100644 util/buildflags/attests_cty.go diff --git a/bake/bake.go b/bake/bake.go index 3905d69e20f..f7b232868a5 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -699,7 +699,7 @@ 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"` @@ -707,8 +707,8 @@ type Target struct { 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"` @@ -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. @@ -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() @@ -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) @@ -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 { @@ -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 { @@ -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 diff --git a/bake/bake_test.go b/bake/bake_test.go index efa75502943..3840570e539 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -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{}) @@ -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{}) diff --git a/bake/hcl_test.go b/bake/hcl_test.go index 8c1944882b5..fd58b4460a9 100644 --- a/bake/hcl_test.go +++ b/bake/hcl_test.go @@ -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", @@ -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)) diff --git a/util/buildflags/attests.go b/util/buildflags/attests.go index daef2b96538..c1a73e91fe8 100644 --- a/util/buildflags/attests.go +++ b/util/buildflags/attests.go @@ -1,7 +1,9 @@ package buildflags import ( + "encoding/json" "fmt" + "maps" "strconv" "strings" @@ -10,6 +12,167 @@ import ( "github.com/tonistiigi/go-csvvalue" ) +type Attests []*Attest + +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 "" @@ -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 } @@ -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 +} diff --git a/util/buildflags/attests_cty.go b/util/buildflags/attests_cty.go new file mode 100644 index 00000000000..0575d1f92ea --- /dev/null +++ b/util/buildflags/attests_cty.go @@ -0,0 +1,97 @@ +package buildflags + +import ( + "strconv" + "sync" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" +) + +var attestType = sync.OnceValue(func() cty.Type { + return cty.Map(cty.String) +}) + +func (e *Attests) FromCtyValue(in cty.Value, p cty.Path) error { + got := in.Type() + if got.IsTupleType() || got.IsListType() { + return e.fromCtyValue(in, p) + } + + want := cty.List(attestType()) + return p.NewErrorf("%s", convert.MismatchMessage(got, want)) +} + +func (e *Attests) fromCtyValue(in cty.Value, p cty.Path) error { + *e = make([]*Attest, 0, in.LengthInt()) + for elem := in.ElementIterator(); elem.Next(); { + _, value := elem.Element() + + entry := &Attest{} + if err := entry.FromCtyValue(value, p); err != nil { + return err + } + *e = append(*e, entry) + } + return nil +} + +func (e Attests) ToCtyValue() cty.Value { + if len(e) == 0 { + return cty.ListValEmpty(attestType()) + } + + vals := make([]cty.Value, len(e)) + for i, entry := range e { + vals[i] = entry.ToCtyValue() + } + return cty.ListVal(vals) +} + +func (e *Attest) FromCtyValue(in cty.Value, p cty.Path) error { + if in.Type() == cty.String { + if err := e.UnmarshalText([]byte(in.AsString())); err != nil { + return p.NewError(err) + } + return nil + } + + conv, err := convert.Convert(in, cty.Map(cty.String)) + if err != nil { + return err + } + + e.Attrs = map[string]string{} + for it := conv.ElementIterator(); it.Next(); { + k, v := it.Element() + switch key := k.AsString(); key { + case "type": + e.Type = v.AsString() + case "disabled": + b, err := strconv.ParseBool(v.AsString()) + if err != nil { + return err + } + e.Disabled = b + default: + e.Attrs[key] = v.AsString() + } + } + return nil +} + +func (e *Attest) ToCtyValue() cty.Value { + if e == nil { + return cty.NullVal(cty.Map(cty.String)) + } + + vals := make(map[string]cty.Value, len(e.Attrs)+2) + for k, v := range e.Attrs { + vals[k] = cty.StringVal(v) + } + vals["type"] = cty.StringVal(e.Type) + if e.Disabled { + vals["disabled"] = cty.StringVal("true") + } + return cty.MapVal(vals) +}