Skip to content

Commit

Permalink
bake: implement composable attributes for attestations
Browse files Browse the repository at this point in the history
Signed-off-by: Jonathan A. Sternberg <[email protected]>
  • Loading branch information
jsternberg committed Dec 18, 2024
1 parent 3771fe2 commit 4f81bcb
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 44 deletions.
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

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

0 comments on commit 4f81bcb

Please sign in to comment.