diff --git a/attest/manifest/manifest.go b/attest/manifest/manifest.go index f3ebf7d..97bd86a 100644 --- a/attest/manifest/manifest.go +++ b/attest/manifest/manifest.go @@ -4,10 +4,11 @@ import ( "cmp" "github.com/errordeveloper/tape/attest/types" + attestTypes "github.com/errordeveloper/tape/attest/types" ) const ( - ManifestDirPredicateType = "docker.com/tape/ManifestDir/v0.1" + ManifestDirPredicateType = "docker.com/tape/ManifestDir/v0.2" ) var ( @@ -15,7 +16,7 @@ var ( ) type DirContents struct { - types.GenericStatement[SourceDirectory] + types.GenericStatement[SourceDirectoryContents] } type SourceDirectory struct { @@ -24,14 +25,16 @@ type SourceDirectory struct { VCSEntries *types.PathCheckSummaryCollection `json:"vcsEntries"` } +type SourceDirectoryContents struct { + SourceDirectory `json:"containedInDirectory"` +} + func MakeDirContentsStatement(dir string, entries *types.PathCheckSummaryCollection) types.Statement { return &DirContents{ - types.MakeStatement[SourceDirectory]( + types.MakeStatement[SourceDirectoryContents]( ManifestDirPredicateType, - struct { - SourceDirectory `json:"containedInDirectory"` - }{ - SourceDirectory{ + SourceDirectoryContents{ + SourceDirectory: SourceDirectory{ Path: dir, VCSEntries: entries, }, @@ -41,6 +44,14 @@ func MakeDirContentsStatement(dir string, entries *types.PathCheckSummaryCollect } } +func MakeDirContentsStatementFrom(statement types.Statement) DirContents { + dirContents := DirContents{ + GenericStatement: attestTypes.GenericStatement[SourceDirectoryContents]{}, + } + dirContents.ConvertFrom(statement) + return dirContents +} + func (a SourceDirectory) Compare(b SourceDirectory) types.Cmp { if cmp := cmp.Compare(a.Path, b.Path); cmp != 0 { return &cmp @@ -54,3 +65,7 @@ func (a SourceDirectory) Compare(b SourceDirectory) types.Cmp { cmp := a.VCSEntries.Compare(*b.VCSEntries) return &cmp } + +func (a SourceDirectoryContents) Compare(b SourceDirectoryContents) types.Cmp { + return a.SourceDirectory.Compare(b.SourceDirectory) +} diff --git a/attest/registry_test.go b/attest/registry_test.go index 2b6b0dc..72554fe 100644 --- a/attest/registry_test.go +++ b/attest/registry_test.go @@ -48,9 +48,9 @@ func makeRegistryTest(tc testdata.TestCase) func(t *testing.T) { loader := loader.NewRecursiveManifestDirectoryLoader(tc.Directory) g.Expect(loader.Load()).To(Succeed()) - pathChecker, attreg, err := DetectVCS(tc.Directory) + repoDetected, attreg, err := DetectVCS(tc.Directory) g.Expect(err).NotTo(HaveOccurred()) - g.Expect(pathChecker).ToNot(BeNil()) + g.Expect(repoDetected).To(BeTrue()) g.Expect(attreg).ToNot(BeNil()) scanner := imagescanner.NewDefaultImageScanner() diff --git a/attest/types/types.go b/attest/types/types.go index 680db54..bbfa5c1 100644 --- a/attest/types/types.go +++ b/attest/types/types.go @@ -119,6 +119,30 @@ func Export(s ExportableStatement) toto.Statement { } } +func FilterByPredicateType(t string, s Statements) Statements { + results := Statements{} + for i := range s { + if s[i].GetType() == t { + results = append(results, s[i]) + } + } + return results +} + +type StamentConverter[T any] struct { + Statement +} + +func (s *GenericStatement[T]) ConvertFrom(statement Statement) error { + predicate, ok := statement.GetPredicate().(ComparablePredicate[T]) + if !ok { + return fmt.Errorf("cannot convert statement with predicte of type %T into %T", statement.GetPredicate(), GenericStatement[T]{}) + } + + *s = MakeStatement[T](statement.GetType(), predicate, statement.GetSubject()...) + return nil +} + func (s Statements) Export() []toto.Statement { statements := make([]toto.Statement, len(s)) for i := range s { @@ -368,8 +392,9 @@ func comparePathCheckSummaries(a, b PathCheckSummary) int { return cmp.Compare(a.Common().Path, b.Common().Path) } -func (p Predicate[T]) GetType() string { return p.Type } -func (p Predicate[T]) GetPredicate() any { return p.ComparablePredicate } +func (p Predicate[T]) GetType() string { return p.Type } +func (p Predicate[T]) GetPredicate() any { return p.ComparablePredicate } +func (p Predicate[T]) GetUnderlyingPredicate() T { return p.ComparablePredicate.(T) } func (p Predicate[T]) Compare(b any) Cmp { if b, ok := b.(Predicate[T]); ok { diff --git a/attest/vcs/git/git.go b/attest/vcs/git/git.go index 95d1a38..3b31517 100644 --- a/attest/vcs/git/git.go +++ b/attest/vcs/git/git.go @@ -23,9 +23,6 @@ const ( DefaultPrimaryRemoteName = "origin" ) -// TODO: need a way to detect multiple repos, for now PathChecker is only meant -// to be used for the manifest dir iteself, and assume there is no nested repos - func NewPathChecker(path string, digest digest.SHA256) types.PathChecker { return &PathChecker{ path: path, @@ -47,16 +44,35 @@ type ( } GitSummary struct { - ObjectHash *string `json:"objectHash,omitempty"` - Remotes map[string][]string `json:"remotes,omitempty"` - Reference GitReference `json:"reference,omitempty"` + Object GitObject `json:"object,omitempty"` + Remotes map[string][]string `json:"remotes,omitempty"` + Reference GitReference `json:"reference,omitempty"` + } + + GitObject struct { + TreeHash string `json:"treeHash,omitempty"` + CommitHash string `json:"commitHash,omitempty"` + } + + Signature struct { + PGP []byte `json:"pgp"` + Validated bool `json:"validated"` + } + + GitTag struct { + Name string `json:"name"` + Hash string `json:"hash,omitempty"` + Target string `json:"target,omitempty"` + Signature *Signature `json:"signature,omitempty"` } GitReference struct { - Name string `json:"name,omitempty"` - Hash string `json:"hash,omitempty"` - Type string `json:"type,omitempty"` - Target string `json:"target,omitempty"` + Name string `json:"name,omitempty"` + Hash string `json:"hash,omitempty"` + Type string `json:"type,omitempty"` + Target string `json:"target,omitempty"` + Tags []GitTag `json:"tags,omitempty"` + Signature *Signature `json:"signature,omitempty"` } ) @@ -142,19 +158,101 @@ func (c *PathChecker) MakeSummary() (types.PathCheckSummary, error) { Git: &git, } - // TODO: determine position of local branch against remote - // TODO: introduce notion of primary remote branch to determine the possition of the working branch - // TODO: determine if a tag is used - // TODO: also check if local tag in sync wirth remote tag - // TODO: provide info on singed tags/commits + head, err := c.cache.repo.Head() + if err != nil { + return nil, err + } + + ref := GitReference{ + Name: head.Name().String(), + Hash: head.Hash().String(), + Type: head.Type().String(), + Target: head.Target().String(), + } + obj := &GitObject{} if summary.Unmodified { - git.ObjectHash = new(string) - *git.ObjectHash = c.cache.obj.ID().String() + obj.TreeHash = c.cache.obj.ID().String() } else if c.IsBlob() { // there is currently no easy way to obtain a hash for a subtree - git.ObjectHash = new(string) - *git.ObjectHash = c.cache.blobHash + obj.TreeHash = c.cache.blobHash + } + + headCommit, err := c.cache.repo.CommitObject(head.Hash()) + if err != nil { + return nil, err + } + if headCommit.PGPSignature != "" { + ref.Signature = &Signature{ + PGP: []byte(headCommit.PGPSignature), + Validated: false, + } + } + + if summary.Unmodified { + commitIter := object.NewCommitPathIterFromIter( + func(path string) bool { + switch { + case c.IsTree(): + return strings.HasPrefix(c.cache.repoPath, path) + case c.IsBlob(): + return c.cache.repoPath == path + default: + return false + } + }, + object.NewCommitIterCTime(headCommit, nil, nil), + true, + ) + defer commitIter.Close() + // only need first commit, avoid looping over all commits with ForEach + commit, err := commitIter.Next() + if err == nil { + obj.CommitHash = commit.Hash.String() + } else if err != io.EOF { + return nil, err + } + } + + tags, err := c.cache.repo.Tags() + if err != nil { + return nil, err + } + + if err := tags.ForEach(func(t *plumbing.Reference) error { + target, err := c.cache.repo.ResolveRevision(plumbing.Revision(t.Name())) + if err != nil { + return err + } + if *target != head.Hash() { + // doesn't point to HEAD + return nil + } + + tag := GitTag{ + Name: t.Name().Short(), + Hash: t.Hash().String(), + Target: target.String(), + } + + if tag.Target != tag.Hash { + // annotated tags have own object hash, while has of a leightweight tag is the same as target + tagObject, err := c.cache.repo.TagObject(t.Hash()) + if err != nil { + return err + } + if tagObject.PGPSignature != "" { + tag.Signature = &Signature{ + PGP: []byte(tagObject.PGPSignature), + Validated: false, + } + } + } + + ref.Tags = append(ref.Tags, tag) + return nil + }); err != nil { + return nil, err } remotes, err := c.cache.repo.Remotes() @@ -189,17 +287,8 @@ func (c *PathChecker) MakeSummary() (types.PathCheckSummary, error) { git.Remotes[remoteConfig.Name] = remoteConfig.URLs } - head, err := c.cache.repo.Head() - if err != nil { - return nil, err - } - - git.Reference = GitReference{ - Name: head.Name().String(), - Hash: head.Hash().String(), - Type: head.Type().String(), - Target: head.Target().String(), - } + git.Reference = ref + git.Object = *obj return summary, nil } @@ -288,6 +377,10 @@ func (c *PathChecker) Check() (bool, bool, error) { } unmodified, _, err := isBlobUnmodified(worktree, &f.Blob, filepath.Join(repoPath, f.Name)) if err != nil { + if f.Mode == filemode.Symlink { + // TODO: should at least log a warning for broken symlink + return nil + } return err } if !unmodified { @@ -419,6 +512,9 @@ func findByPath(repo *gogit.Repository, path string) (object.Object, error) { if err != nil { return nil, err } + if path == "." { + return tree, nil + } treeEntry, err := tree.FindEntry(path) switch err { case nil: @@ -438,12 +534,12 @@ func findByPath(repo *gogit.Repository, path string) (object.Object, error) { } func detectRepo(path string) (*gogit.Repository, bool) { + if repo, err := gogit.PlainOpen(path); err == nil { + return repo, true + } dir := filepath.Dir(path) if dir == path { // reached root return nil, false } - if repo, err := gogit.PlainOpen(dir); err == nil { - return repo, true - } return detectRepo(dir) } diff --git a/attest/vcs_test.go b/attest/vcs_test.go new file mode 100644 index 0000000..88ce6c5 --- /dev/null +++ b/attest/vcs_test.go @@ -0,0 +1,258 @@ +package attest_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/google/go-containerregistry/pkg/name" + + . "github.com/onsi/gomega" + + . "github.com/errordeveloper/tape/attest" + "github.com/errordeveloper/tape/attest/vcs/git" + "github.com/errordeveloper/tape/manifest/imagescanner" + "github.com/errordeveloper/tape/manifest/loader" + "github.com/errordeveloper/tape/oci" +) + +type vcsTestCase struct { + URL, CheckoutTag, CheckoutHash, Branch string + LoadPath string + ExpectManifests, ExpectImageTags, ExpectRawTags []string +} + +func (tc vcsTestCase) Name() string { + rev := tc.CheckoutTag + if rev == "" { + rev = tc.CheckoutHash + } + return fmt.Sprintf("%s@%s", tc.URL, rev) +} + +func TestVCS(t *testing.T) { + testCases := []vcsTestCase{ + // { + // URL: "https://github.com/stefanprodan/podinfo", + // CheckoutTag: "6.7.0", // => 0b1481aa8ed0a6c34af84f779824a74200d5c1d6 + // LoadPath: "kustomize", + // ExpectManifests: []string{"kustomization.yaml", "deployment.yaml", "hpa.yaml", "service.yaml"}, + // ExpectImageTags: []string{"6.7.0"}, + // ExpectRawTags: []string{"6.7.0"}, + // }, + // { + // URL: "https://github.com/stefanprodan/podinfo", + // CheckoutHash: "0b1481aa8ed0a6c34af84f779824a74200d5c1d6", // => 6.7.0 + // Branch: "master", + // LoadPath: "kustomize", + // ExpectManifests: []string{"kustomization.yaml", "deployment.yaml", "hpa.yaml", "service.yaml"}, + // ExpectImageTags: []string{"6.7.0"}, + // ExpectRawTags: []string{"6.7.0"}, + // }, + { + URL: "https://github.com/errordeveloper/tape-git-testing", + CheckoutHash: "3cad1d255c1d83b5e523de64d34758609498d81b", + Branch: "main", + LoadPath: "", + ExpectManifests: []string{"kustomization.yaml", "deployment.yaml", "hpa.yaml", "service.yaml"}, + ExpectImageTags: nil, + ExpectRawTags: nil, + }, + { + URL: "https://github.com/errordeveloper/tape-git-testing", + CheckoutTag: "0.0.1", + LoadPath: "", + ExpectManifests: []string{"podinfo/kustomization.yaml", "podinfo/deployment.yaml", "podinfo/hpa.yaml", "podinfo/service.yaml"}, + ExpectImageTags: []string{"v0.0.1"}, + ExpectRawTags: []string{"0.0.1", "v0.0.1", "podinfo/v6.6.3"}, + }, + { + URL: "https://github.com/errordeveloper/tape-git-testing", + CheckoutTag: "v0.0.2", + LoadPath: "podinfo", + ExpectManifests: []string{"kustomization.yaml", "deployment.yaml", "hpa.yaml", "service.yaml"}, + ExpectImageTags: []string{"v6.7.0"}, + ExpectRawTags: []string{"0.0.2", "v0.0.2", "podinfo/v6.7.0"}, + }, + { + URL: "https://github.com/errordeveloper/tape-git-testing", + CheckoutHash: "9eeeed9f4ff44812ca23dba1bd0af9f509686d21", // => v0.0.1 + LoadPath: "podinfo", + ExpectManifests: []string{"kustomization.yaml", "deployment.yaml", "hpa.yaml", "service.yaml"}, + ExpectImageTags: []string{"v6.6.3"}, + ExpectRawTags: []string{"0.0.1", "v0.0.1", "podinfo/v6.6.3"}, + }, + } + + repos := &repos{} + repos.init() + defer repos.cleanup() + + for i := range testCases { + tc := testCases[i] + t.Run(tc.Name(), makeVCSTest(repos, tc)) + } +} + +func makeVCSTest(repos *repos, tc vcsTestCase) func(t *testing.T) { + return func(t *testing.T) { + g := NewWithT(t) + + ctx := context.Background() + checkoutPath, err := repos.clone(ctx, tc) + g.Expect(err).NotTo(HaveOccurred()) + + loadPath := filepath.Join(checkoutPath, tc.LoadPath) + loader := loader.NewRecursiveManifestDirectoryLoader(loadPath) + g.Expect(loader.Load()).To(Succeed()) + + repoDetected, attreg, err := DetectVCS(loadPath) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(repoDetected).To(BeTrue()) + g.Expect(attreg).ToNot(BeNil()) + + scanner := imagescanner.NewDefaultImageScanner() + scanner.WithProvinanceAttestor(attreg) + + if tc.ExpectManifests != nil { + g.Expect(loader.Paths()).To(HaveLen(len(tc.ExpectManifests))) + for _, manifest := range tc.ExpectManifests { + g.Expect(loader.ContainsRelPath(manifest)).To(BeTrue()) + } + } + + g.Expect(scanner.Scan(loader.RelPaths())).To(Succeed()) + + collection, err := attreg.MakePathCheckSummarySummaryCollection() + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(collection).ToNot(BeNil()) + g.Expect(collection.Providers).To(ConsistOf("git")) + g.Expect(collection.EntryGroups).To(HaveLen(1)) + g.Expect(collection.EntryGroups[0]).To(HaveLen(5)) + + vcsSummary := attreg.BaseDirSummary() + g.Expect(vcsSummary).ToNot(BeNil()) + summaryJSON, err := json.Marshal(vcsSummary.Full()) + g.Expect(err).NotTo(HaveOccurred()) + t.Logf("VCS info for %q: %s", tc.LoadPath, summaryJSON) + + g.Expect(attreg.AssociateCoreStatements()).To(Succeed()) + + statements := attreg.GetStatements() + g.Expect(statements).To(HaveLen(1)) + g.Expect(statements[0].GetSubject()).To(HaveLen(4)) + + // TODO: validate schema + + groupSummary, ok := vcsSummary.Full().(*git.Summary) + g.Expect(ok).To(BeTrue()) + ref := groupSummary.Git.Reference + g.Expect(ref.Tags).To(HaveLen(len(tc.ExpectRawTags))) + imageTagNames := make([]string, len(ref.Tags)) + for i, tag := range ref.Tags { + imageTagNames[i] = tag.Name + } + g.Expect(imageTagNames).To(ConsistOf(tc.ExpectRawTags)) + + image, err := name.NewRepository("podinfo") + g.Expect(err).NotTo(HaveOccurred()) + + semVerTags := oci.SemVerTagsFromAttestations(ctx, image.Tag("test.123456"), statements...) + g.Expect(semVerTags).To(HaveLen(len(tc.ExpectImageTags))) + semVerTagNames := make([]string, len(semVerTags)) + for i, tag := range semVerTags { + semVerTagNames[i] = tag.TagStr() + } + g.Expect(semVerTagNames).To(ConsistOf(tc.ExpectImageTags)) + } +} + +type repos struct { + workDir string + tempDir string + cache map[string]string +} + +func (r *repos) init() error { + workDir, err := os.Getwd() + if err != nil { + return err + } + r.workDir = workDir + tempDir, err := os.MkdirTemp("", ".vcs-test-*") + if err != nil { + return err + } + r.tempDir = tempDir + r.cache = map[string]string{} + return nil +} + +func (r *repos) cleanup() error { + if r.tempDir == "" { + return nil + } + return os.RemoveAll(r.tempDir) +} + +func (r *repos) mktemp() (string, error) { + return os.MkdirTemp(r.tempDir, "repo-*") +} + +func (r *repos) mirror(ctx context.Context, tc vcsTestCase) (string, error) { + if _, ok := r.cache[tc.URL]; !ok { + mirrorDir, err := r.mktemp() + if err != nil { + return "", err + } + _, err = gogit.PlainCloneContext(ctx, mirrorDir, true, &gogit.CloneOptions{Mirror: true, URL: tc.URL}) + if err != nil { + return "", err + } + r.cache[tc.URL] = mirrorDir + } + return r.cache[tc.URL], nil +} + +func (r *repos) clone(ctx context.Context, tc vcsTestCase) (string, error) { + mirrorDir, err := r.mirror(ctx, tc) + if err != nil { + return "", err + } + checkoutDir, err := r.mktemp() + if err != nil { + return "", err + } + + opts := &gogit.CloneOptions{URL: mirrorDir} + if tc.CheckoutTag != "" { + opts.ReferenceName = plumbing.NewTagReferenceName(tc.CheckoutTag) + } else if tc.Branch != "" { + opts.ReferenceName = plumbing.NewBranchReferenceName(tc.Branch) + } + + repo, err := gogit.PlainCloneContext(ctx, checkoutDir, false, opts) + if err != nil { + return "", fmt.Errorf("failed to clone: %w", err) + } + + if tc.CheckoutHash != "" { + workTree, err := repo.Worktree() + if err != nil { + return "", err + } + opts := &gogit.CheckoutOptions{ + Hash: plumbing.NewHash(tc.CheckoutHash), + } + + if err := workTree.Checkout(opts); err != nil { + return "", err + } + } + return filepath.Rel(r.workDir, checkoutDir) +} diff --git a/manifest/packager/packager.go b/manifest/packager/packager.go index c047985..bb519e9 100644 --- a/manifest/packager/packager.go +++ b/manifest/packager/packager.go @@ -10,7 +10,7 @@ import ( type Packager interface { //Pull(string) error - Push(context.Context, string) (string, error) + Push(context.Context, string) (*oci.PackageRefs, error) } type DefaultPackager struct { @@ -32,7 +32,7 @@ func NewDefaultPackager(client *oci.Client, destinationRef string, sourceEpochTi } } -func (r *DefaultPackager) Push(ctx context.Context, dir string) (string, error) { +func (r *DefaultPackager) Push(ctx context.Context, dir string) (*oci.PackageRefs, error) { return r.Client.PushArtefact(ctx, r.destinationRef, dir, r.sourceEpochTimestamp, r.sourceAttestations...) } diff --git a/manifest/packager/packager_test.go b/manifest/packager/packager_test.go index 4147b9b..737ec6b 100644 --- a/manifest/packager/packager_test.go +++ b/manifest/packager/packager_test.go @@ -43,9 +43,9 @@ func makePackagerTest(tc testdata.TestCase) func(t *testing.T) { loader := loader.NewRecursiveManifestDirectoryLoader(tc.Directory) g.Expect(loader.Load()).To(Succeed()) - pathChecker, attreg, err := attest.DetectVCS(tc.Directory) + repoDetected, attreg, err := attest.DetectVCS(tc.Directory) g.Expect(err).NotTo(HaveOccurred()) - g.Expect(pathChecker).ToNot(BeNil()) + g.Expect(repoDetected).To(BeTrue()) g.Expect(attreg).ToNot(BeNil()) scanner := imagescanner.NewDefaultImageScanner() diff --git a/manifest/testdata/testdata.go b/manifest/testdata/testdata.go index 857e852..e4810c2 100644 --- a/manifest/testdata/testdata.go +++ b/manifest/testdata/testdata.go @@ -24,6 +24,7 @@ func (tcs TestCases) Run(t *testing.T, pathToRootDir string, doTest func(tc Test t.Run(tcs[i].Description, doTest(tcs[i])) } } + func (tcs TestCases) makeRelativeTo(dir string) { for i := range tcs { tcs[i].Directory = filepath.Join(dir, tcs[i].Directory) diff --git a/oci/artefact.go b/oci/artefact.go index 15eadf7..d3a5917 100644 --- a/oci/artefact.go +++ b/oci/artefact.go @@ -11,8 +11,11 @@ import ( "maps" "os" "path/filepath" + "strings" "time" + "golang.org/x/mod/semver" + ociclient "github.com/fluxcd/pkg/oci" "github.com/go-git/go-git/v5/utils/ioutil" "github.com/google/go-containerregistry/pkg/compression" @@ -23,7 +26,9 @@ import ( "github.com/google/go-containerregistry/pkg/v1/tarball" typesv1 "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/errordeveloper/tape/attest/manifest" attestTypes "github.com/errordeveloper/tape/attest/types" + "github.com/errordeveloper/tape/attest/vcs/git" manifestTypes "github.com/errordeveloper/tape/manifest/types" ) @@ -53,6 +58,13 @@ type ArtefactInfo struct { Digest string } +type PackageRefs struct { + Digest string + Primary string + Short string + SemVer []string +} + func (c *Client) Fetch(ctx context.Context, ref string, mediaTypes ...MediaType) ([]*ArtefactInfo, error) { imageIndex, indexManifest, image, err := c.GetIndexOrImage(ctx, ref) if err != nil { @@ -216,10 +228,10 @@ func (c *Client) getImage(ctx context.Context, imageIndex ImageIndex, digest Has } // based on https://github.com/fluxcd/pkg/blob/2a323d771e17af02dee2ccbbb9b445b78ab048e5/oci/client/push.go -func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir string, timestamp *time.Time, sourceAttestations ...attestTypes.Statement) (string, error) { +func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir string, timestamp *time.Time, sourceAttestations ...attestTypes.Statement) (*PackageRefs, error) { tmpDir, err := os.MkdirTemp("", "bpt-oci-artefact-*") if err != nil { - return "", err + return nil, err } defer os.RemoveAll(tmpDir) @@ -227,7 +239,7 @@ func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir str outputFile, err := os.OpenFile(tmpFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, regularFileMode) if err != nil { - return "", err + return nil, err } defer outputFile.Close() @@ -236,21 +248,22 @@ func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir str output := io.MultiWriter(outputFile, c.hash) if err := c.BuildArtefact(tmpFile, sourceDir, output); err != nil { - return "", err + return nil, err } attestLayer, err := c.BuildAttestations(sourceAttestations) if err != nil { - return "", fmt.Errorf("failed to serialise attestations: %w", err) + return nil, fmt.Errorf("failed to serialise attestations: %w", err) } repo, err := name.NewRepository(destinationRef) if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) + return nil, fmt.Errorf("invalid URL: %w", err) } hash := hex.EncodeToString(c.hash.Sum(nil)) tag := repo.Tag(manifestTypes.ConfigImageTagPrefix + hash) - tagAlias := tag.Context().Tag(manifestTypes.ConfigImageTagPrefix + hash[:7]) + shortTag := tag.Context().Tag(manifestTypes.ConfigImageTagPrefix + hash[:7]) + semVerTags := SemVerTagsFromAttestations(ctx, tag, sourceAttestations...) if timestamp == nil { timestamp = new(time.Time) @@ -287,12 +300,12 @@ func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir str tarball.WithCompressedCaching, ) if err != nil { - return "", fmt.Errorf("creating artefact content layer failed: %w", err) + return nil, fmt.Errorf("creating artefact content layer failed: %w", err) } config, err = mutate.Append(config, mutate.Addendum{Layer: configLayer}) if err != nil { - return "", fmt.Errorf("appeding content to artifact failed: %w", err) + return nil, fmt.Errorf("appeding content to artifact failed: %w", err) } index = mutate.AppendManifests(index, @@ -307,7 +320,7 @@ func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir str summary, err := (attestTypes.Statements)(sourceAttestations).MarshalSummaryAnnotation() if err != nil { - return "", err + return nil, err } attestAnnotations[AttestationsSummaryAnnotation] = summary @@ -321,7 +334,7 @@ func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir str attest, err = mutate.Append(attest, mutate.Addendum{Layer: attestLayer}) if err != nil { - return "", fmt.Errorf("appeding attestations to artifact failed: %w", err) + return nil, fmt.Errorf("appeding attestations to artifact failed: %w", err) } index = mutate.AppendManifests(index, @@ -334,18 +347,99 @@ func (c *Client) PushArtefact(ctx context.Context, destinationRef, sourceDir str digest, err := index.Digest() if err != nil { - return "", fmt.Errorf("parsing index digest failed: %w", err) + return nil, fmt.Errorf("parsing index digest failed: %w", err) } if err := remote.WriteIndex(tag, index, c.remoteWithContext(ctx)...); err != nil { - return "", fmt.Errorf("pushing index failed: %w", err) + return nil, fmt.Errorf("pushing index failed: %w", err) + } + + refs := &PackageRefs{ + Digest: digest.String(), + Primary: tag.String(), + Short: shortTag.String(), + SemVer: make([]string, len(semVerTags)), } - if err := remote.Tag(tagAlias, index, c.remoteWithContext(ctx)...); err != nil { - return "", fmt.Errorf("adding alias tagging failed: %w", err) + for i, tagAlias := range append(semVerTags, shortTag) { + if err := remote.Tag(tagAlias, index, c.remoteWithContext(ctx)...); err != nil { + return nil, fmt.Errorf("adding alias tagging failed: %w", err) + } + if i < len(semVerTags) { + refs.SemVer[i] = tagAlias.String() + "@" + digest.String() + } + } + return refs, nil +} + +func (p *PackageRefs) String() string { return p.Short + "@" + p.Digest } + +func SemVerTagsFromAttestations(ctx context.Context, tag name.Tag, sourceAttestations ...attestTypes.Statement) []name.Tag { + statements := attestTypes.FilterByPredicateType(manifest.ManifestDirPredicateType, sourceAttestations) + if len(statements) != 1 { + return []name.Tag{} + } + + entries := manifest.MakeDirContentsStatementFrom(statements[0]).GetUnderlyingPredicate().VCSEntries + if len(entries.EntryGroups) != 1 && len(entries.Providers) != 1 || + entries.Providers[0] != git.ProviderName { + return []name.Tag{} + } + if len(entries.EntryGroups[0]) == 0 { + return []name.Tag{} } - return tagAlias.String() + "@" + digest.String(), err + // TODO: try to use generics for this? + groupSummary, ok := entries.EntryGroups[0][0].Full().(*git.Summary) + if !ok { + return []name.Tag{} + } + ref := groupSummary.Git.Reference + numTags := len(ref.Tags) + if numTags == 0 { + return []name.Tag{} + } + tags := newTagtagSet(numTags) + scopedTags := newTagtagSet(numTags) + for i := range ref.Tags { + t := ref.Tags[i].Name + // this is accounts only for a simple case where tape is pointed at a dir + // and a tags have prefix that matches it exactly, it won't work for cases + // where tape is pointed at a subdir a parent of which has a scoped tag + if strings.HasPrefix(t, groupSummary.Path+"/") { + scopedTags.add(strings.TrimPrefix(t, groupSummary.Path+"/"), tag) + continue + } + tags.add(t, tag) + } + if len(scopedTags.list) > 0 { + return scopedTags.list + } + return tags.list +} + +type tagSet struct { + set map[string]struct{} + list []name.Tag +} + +func newTagtagSet(c int) *tagSet { + return &tagSet{ + set: make(map[string]struct{}, c), + list: make([]name.Tag, 0, c), + } +} + +func (s *tagSet) add(t string, image name.Tag) { + if !strings.HasPrefix(t, "v") { + t = "v" + t + } + if _, ok := s.set[t]; !ok { + if semver.IsValid(t) { + s.list = append(s.list, image.Context().Tag(t)) + s.set[t] = struct{}{} + } + } } func makeDescriptorWithPlatform() Descriptor { diff --git a/tape/app/package.go b/tape/app/package.go index 0710216..66b25f0 100644 --- a/tape/app/package.go +++ b/tape/app/package.go @@ -182,11 +182,16 @@ func (c *TapePackageCommand) Execute(args []string) error { path, sourceEpochTimestamp := loader.MostRecentlyModified() c.tape.log.Debugf("using source epoch timestamp %s from most recently modified manifest file %q", sourceEpochTimestamp, path) packager := packager.NewDefaultPackager(client, c.OutputImage, &sourceEpochTimestamp, attreg.GetStatements()...) - packageRef, err := packager.Push(ctx, images.Dir()) + packageRefs, err := packager.Push(ctx, images.Dir()) if err != nil { return fmt.Errorf("failed to create package: %w", err) } - c.tape.log.Infof("created package %q", packageRef) + c.tape.log.Infof("created package %q", packageRefs.String()) + // c.tape.log.Infof("primary reference %q", packageRefs.Primary) + + if len(packageRefs.SemVer) > 0 { + c.tape.log.Infof("additional semver tags from VCS: %s", strings.Join(packageRefs.SemVer, ", ")) + } return nil }