Skip to content

Commit

Permalink
v1: Rewrite OCI storage to use new interface (#842)
Browse files Browse the repository at this point in the history
* v1: Rewrite OCI storage to use new interface.

* oci: Refactor storer structs to take in variadic options.

Removes the need to take in a config object.

* oci/options.go: add missing license header.
  • Loading branch information
wlynch authored Jul 6, 2023
1 parent 383ca62 commit 2eb21be
Show file tree
Hide file tree
Showing 7 changed files with 509 additions and 64 deletions.
166 changes: 166 additions & 0 deletions docs/v1-proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# v1 client changes proposal - storage refactoring

With v1 approaching, I want to take a moment to look at changes we want to make
to the existing client libraries to better set us up for long term maintenance.

We already know that we want to reduce the external library surface of chains.
But to do this, we need to define better interfaces between components that we
expect external clients to use.

Today, I think a lot of the codebase's complexity has come from a few places:

1. Storage libraries

Each storage type needs different pieces of data - i.e. Grafeas and OCI need
to distinguish image signatures and attestation formats, and some clients
need the original object to extract out information like GVKs, names,
namespaces, etc. This has led to a organic growth of the chains libraries to
pass the different types of data around, and a lot of typecasting and other
generic object tricks.

Looking at the storage interfaces, I think this data roughly boils down to:

- The original Tekton object
- The formatted data object
- The signed payload + signature (with optional cert information)

Unlike when chains first started, we now have another useful tool available
to us: generics. I think we can use this to create clearer interfaces.

2. Dependence on the config package

tkn depends on the chains server config, but it probably shouldn't. We should
aim to have better ways to initialize clients for others to use.

Good news, I don't think we're far off, but we should make some changes

## Signables

At it's core, Chains is basically an ETL pipeline. We Extract artifacts from run
objects, Transform and sign them, then Load them into storage.

```go
type Signable[T any] interface {
Extract(ctx context.Context, obj objects.TektonObject) []T{}
}
```

## Payloaders

I think payloaders are mostly in a good place, though we can introduce generics
to start creating stricter type relationships between Signables and Payloaders.

```go
type Payloader[Input any, Output BinaryMarshaler] interface {
CreatePayload(ctx context.Context, in Input) (Output, error)
}
```

tl;dr: Some type comes in, some type comes out.

[BinaryMarshaler comes from the encoding package](https://pkg.go.dev/encoding#BinaryMarshaler),
but basically all we're aiming for here is to make sure we can get a []byte for
signing. For existing payload types, this may mean we need to wrap external
types for this functionality.

## Signers

Signers are mostly in a good spot, though we should probably just embrace []byte
instead of typecasting between string for cert details.

```go
type Signer interface {
signature.SignerVerifier
Cert() []byte
Chain() []byte
}
```

## Storers

Now that we have all the other pieces defined, we can now have stricter typing
for storing:

```go
type Storer[Input any, Output any] interface {
Store(ctx context.Context, req *StoreRequest) (*StoreResponse, error)
}

type StoreRequest[Input any, Output any] struct {
Object objects.TektonObject
Artifact Input
Payload Output
Bundle *signing.Bundle
}

type StoreResponse struct {
// Some identifier for what we uploaded to reference later?
ID string
}

type Bundle struct {
Content []byte
Signature []byte
Cert []byte
Chain []byte
}
```

While the StoreRequest struct may not be necessary, it has a nice RPC-like
quality in that it will make it easier to add/remove fields in the future.

## Attestors

To put it all together, we can add a new type: Attestor. This is effectively
just a wrapper type around all of the other interfaces that binds the generic
types together. Because things are strictly typed, we'll know at compile
what clients are compatible with each other.

**TBD if we expose this at all** - it may remain an internal implementation
detail of chains. This is what we will generate from the Chains server config.

```go
type Attestor[Input, Output] struct {
payloader Payloader[Input, Output]
signer Signer
storer Storer[Input, Output]
}
```

What this looks like in practice:

OCI Simple Signing:

```go
attestor := &Attestor[name.Digest, simple.SimpleContainerImage]{
payloader: NewSimpleSigningPayloader(),
signer: x509Signer,
storer: NewSimpleOCIStorage(),
}
```

SLSA:

```go
attestor := &Attestor[TektonObject, *intoto.Statement]{
payloader: NewSLSAPayloader(),
signer: fulcio,
storer: NewGCSStorage(),
}
```

Grafeas:

```go
attestor := &Attestor[TektonObject, *Occurrence]{
payloader: NewGrafeasPayloader(),
signer: kmsSigner,
storer: NewGrafeasClient(),
}
```

## Final thoughts

If all goes well, this should have 0 impact on typical consumer usage of
chains - these should all be internal refactors with no change in behavior. If
our e2e start failing, we've done something wrong.
16 changes: 15 additions & 1 deletion pkg/chains/signing/iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ limitations under the License.

package signing

import "github.com/sigstore/sigstore/pkg/signature"
import (
"github.com/sigstore/sigstore/pkg/signature"
)

type Signer interface {
signature.SignerVerifier
Expand All @@ -28,3 +30,15 @@ const (
)

var AllSigners = []string{TypeX509, TypeKMS}

// Bundle represents the output of a signing operation.
type Bundle struct {
// Content is the raw content that was signed.
Content []byte
// Signature is the content signature.
Signature []byte
// Cert is an optional PEM encoded x509 certificate, if one was used for signing.
Cert []byte
// Cert is an optional PEM encoded x509 certificate chain, if one was used for signing.
Chain []byte
}
44 changes: 44 additions & 0 deletions pkg/chains/storage/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2023 The Tekton Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"context"

"github.com/tektoncd/chains/pkg/chains/objects"
"github.com/tektoncd/chains/pkg/chains/signing"
)

// StoreRequest contains the information needed to store a signature/attestation object.
type StoreRequest[Input any, Output any] struct {
// Object is the original Tekton object as received by the controller.
Object objects.TektonObject
// Artifact is the artifact that was extracted from the Object (e.g. build config, image, etc.)
Artifact Input
// Payload is the formatted payload that was generated for the Artifact (e.g. simplesigning, in-toto attestation)
Payload Output
// Bundle contains the signing output details.
Bundle *signing.Bundle
}

// StoreResponse contains metadata for the result of the store operation.
type StoreResponse struct {
// currently empty, but may contain data in the future.
// present to allow for backwards compatible changes to the Storer interface in the future.
}

type Storer[Input, Output any] interface {
Store(context.Context, *StoreRequest[Input, Output]) (*StoreResponse, error)
}
88 changes: 88 additions & 0 deletions pkg/chains/storage/oci/attestation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright 2023 The Tekton Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package oci

import (
"context"

"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/in-toto/in-toto-golang/in_toto"
"github.com/pkg/errors"
"github.com/sigstore/cosign/v2/pkg/oci/mutate"
ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote"
"github.com/sigstore/cosign/v2/pkg/oci/static"
"github.com/sigstore/cosign/v2/pkg/types"
"github.com/tektoncd/chains/pkg/chains/storage/api"
"knative.dev/pkg/logging"
)

var (
_ api.Storer[name.Digest, in_toto.Statement] = &AttestationStorer{}
)

// AttestationStorer stores in-toto Attestation payloads in OCI registries.
type AttestationStorer struct {
// repo configures the repo where data should be stored.
// If empty, the repo is inferred from the Artifact.
repo *name.Repository
// remoteOpts are additional remote options (i.e. auth) to use for client operations.
remoteOpts []remote.Option
}

func NewAttestationStorer(opts ...AttestationStorerOption) (*AttestationStorer, error) {
s := &AttestationStorer{}
for _, o := range opts {
if err := o.applyAttestationStorer(s); err != nil {
return nil, err
}
}
return s, nil
}

func (s *AttestationStorer) Store(ctx context.Context, req *api.StoreRequest[name.Digest, in_toto.Statement]) (*api.StoreResponse, error) {
logger := logging.FromContext(ctx)

repo := req.Artifact.Repository
if s.repo != nil {
repo = *s.repo
}
se, err := ociremote.SignedEntity(req.Artifact, ociremote.WithRemoteOptions(s.remoteOpts...))
if err != nil {
return nil, errors.Wrap(err, "getting signed image")
}

// Create the new attestation for this entity.
attOpts := []static.Option{static.WithLayerMediaType(types.DssePayloadType)}
if req.Bundle.Cert != nil {
attOpts = append(attOpts, static.WithCertChain(req.Bundle.Cert, req.Bundle.Chain))
}
att, err := static.NewAttestation(req.Bundle.Signature, attOpts...)
if err != nil {
return nil, err
}
newImage, err := mutate.AttachAttestationToEntity(se, att)
if err != nil {
return nil, err
}

// Publish the signatures associated with this entity
if err := ociremote.WriteAttestations(repo, newImage, ociremote.WithRemoteOptions(s.remoteOpts...)); err != nil {
return nil, err
}
logger.Infof("Successfully uploaded attestation for %s", req.Artifact.String())

return &api.StoreResponse{}, nil
}
Loading

0 comments on commit 2eb21be

Please sign in to comment.