diff --git a/bake/bake.go b/bake/bake.go index 8322edbb3ae..39e50889534 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.Attest `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"` @@ -935,7 +935,11 @@ func (t *Target) AddOverrides(overrides map[string]Override) error { 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 = append(t.Attest, attest...) case "no-cache": noCache, err := strconv.ParseBool(value) if err != nil { @@ -1370,9 +1374,9 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { } } - attests, err := buildflags.ParseAttests(t.Attest) - if err != nil { - return nil, err + attests := make([]*controllerapi.Attest, 0, len(t.Attest)) + for _, attest := range t.Attest { + attests = append(attests, attest.ToPB()) } bo.Attests = controllerapi.CreateAttestations(attests) @@ -1445,21 +1449,15 @@ func removeDupesStr(s []string) []string { return s[:i] } -func removeAttestDupes(s []string) []string { - res := []string{} +func removeAttestDupes(s []*buildflags.Attest) []*buildflags.Attest { + res := []*buildflags.Attest{} m := map[string]int{} - for _, v := range s { - att, err := buildflags.ParseAttest(v) - if err != nil { - res = append(res, v) - continue - } - + for _, att := range s { if i, ok := m[att.Type]; ok { - res[i] = v + res[i] = att } else { m[att.Type] = len(res) - res = append(res, v) + res = append(res, att) } } return res diff --git a/bake/bake_test.go b/bake/bake_test.go index b61e2ecff3e..c18d5e7f51d 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) - 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) - 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/util/buildflags/attests.go b/util/buildflags/attests.go index 600b755e0d9..5a5ebe8de87 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,134 @@ import ( "github.com/tonistiigi/go-csvvalue" ) +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 +151,34 @@ func CanonicalizeAttest(attestType string, in string) string { } func ParseAttests(in []string) ([]*controllerapi.Attest, error) { - 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.UnmarshalJSON([]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 } diff --git a/util/buildflags/cty.go b/util/buildflags/cty.go index d8b6a19a03e..f756ae5dd84 100644 --- a/util/buildflags/cty.go +++ b/util/buildflags/cty.go @@ -2,6 +2,7 @@ package buildflags import ( "encoding" + "strconv" "sync" "github.com/zclconf/go-cty/cty" @@ -9,6 +10,10 @@ import ( "github.com/zclconf/go-cty/cty/gocty" ) +type FromCtyValue interface { + FromCtyValue(in cty.Value, path cty.Path) error +} + func (e *CacheOptionsEntry) FromCtyValue(in cty.Value, p cty.Path) error { conv, err := convert.Convert(in, cty.Map(cty.String)) if err == nil { @@ -153,6 +158,46 @@ func (e *SSH) ToCtyValue() cty.Value { }) } +func (e *Attest) FromCtyValue(in cty.Value, p cty.Path) (err error) { + conv, err := convert.Convert(in, cty.Map(cty.String)) + if err == nil { + 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 + } + return unmarshalTextFallback(in, e, err) +} + +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) +} + func getAndDelete(m map[string]cty.Value, attr string, gv interface{}) error { if v, ok := m[attr]; ok { delete(m, attr)