hack: add oci-pin tool

This tool can generate Go source files and lockfiles for container images.
This commit is contained in:
Malte Poll 2023-04-03 17:39:31 +02:00 committed by Malte Poll
parent 4b9bce9bb7
commit 9d25372e10
14 changed files with 1692 additions and 0 deletions

27
hack/oci-pin/BUILD.bazel Normal file
View File

@ -0,0 +1,27 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "oci-pin_lib",
srcs = [
"codegen.go",
"merge.go",
"oci-pin.go",
"sum.go",
],
importpath = "github.com/edgelesssys/constellation/v2/hack/oci-pin",
visibility = ["//visibility:private"],
deps = [
"//hack/oci-pin/internal/extract",
"//hack/oci-pin/internal/inject",
"//hack/oci-pin/internal/sums",
"//internal/logger",
"@com_github_spf13_cobra//:cobra",
"@org_uber_go_zap//zapcore",
],
)
go_binary(
name = "oci-pin",
embed = [":oci-pin_lib"],
visibility = ["//visibility:public"],
)

176
hack/oci-pin/codegen.go Normal file
View File

@ -0,0 +1,176 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/extract"
"github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/inject"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
)
func newCodegenCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "codegen",
Short: "Generate Go code that pins an OCI image.",
RunE: runCodegen,
}
cmd.Flags().String("oci-path", "", "Path to the OCI image to pin.")
cmd.Flags().String("output", "-", "Output file. If not set, the output is written to stdout.")
cmd.Flags().String("package", "", "Name of the Go package.")
cmd.Flags().String("identifier", "", "Base name of the Go const identifiers.")
cmd.Flags().String("image-registry", "", "Registry where the image is stored.")
cmd.Flags().String("image-prefix", "", "Prefix of the image name. Optional.")
cmd.Flags().String("image-name", "", "Short name of the OCI image to pin.")
cmd.Flags().String("image-tag", "", "Tag of the OCI image to pin. Optional.")
cmd.Flags().String("image-tag-file", "", "Tag file of the OCI image to pin. Optional.")
cmd.MarkFlagsMutuallyExclusive("image-tag", "image-tag-file")
must(cmd.MarkFlagRequired("oci-path"))
must(cmd.MarkFlagRequired("package"))
must(cmd.MarkFlagRequired("identifier"))
must(cmd.MarkFlagRequired("image-registry"))
must(cmd.MarkFlagRequired("image-name"))
return cmd
}
func runCodegen(cmd *cobra.Command, _ []string) error {
flags, err := parseCodegenFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Generating Go code for OCI image %s.", flags.imageName)
ociIndexPath := filepath.Join(flags.ociPath, "index.json")
index, err := os.Open(ociIndexPath)
if err != nil {
return fmt.Errorf("opening OCI index at %q: %w", ociIndexPath, err)
}
defer index.Close()
var out io.Writer
if flags.output == "-" {
out = cmd.OutOrStdout()
} else {
f, err := os.Create(flags.output)
if err != nil {
return fmt.Errorf("creating output file %q: %w", flags.output, err)
}
defer f.Close()
out = f
}
digest, err := extract.Digest(index)
if err != nil {
return err
}
log.Debugf("OCI image digest: %s", digest)
if err := inject.Render(out, inject.PinningValues{
Package: flags.pkg,
Ident: flags.identifier,
Registry: flags.imageRegistry,
Prefix: flags.imagePrefix,
Name: flags.imageName,
Tag: flags.imageTag,
Digest: digest,
}); err != nil {
return fmt.Errorf("rendering Go code: %w", err)
}
log.Debugf("Go code created at %q 🤖", flags.output)
return nil
}
type codegenFlags struct {
ociPath string
output string
pkg string
identifier string
imageRegistry string
imagePrefix string
imageName string
imageTag string
logLevel zapcore.Level
}
func parseCodegenFlags(cmd *cobra.Command) (codegenFlags, error) {
ociPath, err := cmd.Flags().GetString("oci-path")
if err != nil {
return codegenFlags{}, err
}
output, err := cmd.Flags().GetString("output")
if err != nil {
return codegenFlags{}, err
}
pkg, err := cmd.Flags().GetString("package")
if err != nil {
return codegenFlags{}, err
}
identifier, err := cmd.Flags().GetString("identifier")
if err != nil {
return codegenFlags{}, err
}
imageRegistry, err := cmd.Flags().GetString("image-registry")
if err != nil {
return codegenFlags{}, err
}
imagePrefix, err := cmd.Flags().GetString("image-prefix")
if err != nil {
return codegenFlags{}, err
}
imageName, err := cmd.Flags().GetString("image-name")
if err != nil {
return codegenFlags{}, err
}
imageTag, err := cmd.Flags().GetString("image-tag")
if err != nil {
return codegenFlags{}, err
}
imageTagFile, err := cmd.Flags().GetString("image-tag-file")
if err != nil {
return codegenFlags{}, err
}
if imageTagFile != "" {
tag, err := os.ReadFile(imageTagFile)
if err != nil {
return codegenFlags{}, fmt.Errorf("reading image tag file %q: %w", imageTagFile, err)
}
imageTag = strings.TrimSpace(string(tag))
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return codegenFlags{}, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
return codegenFlags{
ociPath: ociPath,
output: output,
pkg: pkg,
identifier: identifier,
imageRegistry: imageRegistry,
imagePrefix: imagePrefix,
imageName: imageName,
imageTag: imageTag,
logLevel: logLevel,
}, nil
}

View File

@ -0,0 +1,19 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//bazel/go:go_test.bzl", "go_test")
go_library(
name = "extract",
srcs = ["extract.go"],
importpath = "github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/extract",
visibility = ["//hack/oci-pin:__subpackages__"],
)
go_test(
name = "extract_test",
srcs = ["extract_test.go"],
embed = [":extract"],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)

View File

@ -0,0 +1,52 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package extract
import (
"encoding/json"
"fmt"
"io"
"regexp"
)
var digestRegexp = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
const (
supportedSchemaVersion = 2
supportedMediaType = "application/vnd.oci.image.index.v1+json"
)
// Digest extracts the digest from an OCI index.
func Digest(index io.Reader) (string, error) {
var oci ociIndex
if err := json.NewDecoder(index).Decode(&oci); err != nil {
return "", fmt.Errorf("decoding oci index: %w", err)
}
if oci.SchemaVersion != supportedSchemaVersion {
return "", fmt.Errorf("unsupported schema version %d", oci.SchemaVersion)
}
if oci.MediaType != supportedMediaType {
return "", fmt.Errorf("unsupported media type %q", oci.MediaType)
}
if len(oci.Manifests) != 1 {
return "", fmt.Errorf("expected 1 manifest, got %d", len(oci.Manifests))
}
digest := oci.Manifests[0].Digest
if matched := digestRegexp.MatchString(digest); !matched {
return "", fmt.Errorf("malformed digest %q", digest)
}
return digest, nil
}
type ociIndex struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Manifests []struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
} `json:"manifests"`
}

View File

@ -0,0 +1,116 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package extract
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDigest(t *testing.T) {
testCases := map[string]struct {
ociIndex string
wantDigest string
wantErr bool
}{
"valid OCI index": {
ociIndex: validOCIIndex,
wantDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
wantErr: false,
},
"wrong version": {
ociIndex: `{
"schemaVersion": 1,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
}
]
}`,
wantErr: true,
},
"wrong media type": {
ociIndex: `{
"schemaVersion": 2,
"mediaType": "application/something-else",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
}
]
}`,
wantErr: true,
},
"incorrect manifest length": {
ociIndex: `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": []
}`,
wantErr: true,
},
"incorrect manifest digest format": {
ociIndex: `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "foo:bar",
"size": 0
}
]
}`,
wantErr: true,
},
"malformed json": {
ociIndex: `}`,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
digest, err := Digest(bytes.NewBufferString(tc.ociIndex))
if tc.wantErr {
require.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantDigest, digest)
})
}
}
const (
// This is a valid OCI index.
// It has a schema version of 2, a media type of
// "application/vnd.oci.image.index.v1+json", and a single manifest.
// The manifest has a media type of
// "application/vnd.oci.image.manifest.v1+json", and a digest.
// The digest is a valid SHA256 hash.
validOCIIndex = `{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
}
]
}`
)

View File

@ -0,0 +1,19 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//bazel/go:go_test.bzl", "go_test")
go_library(
name = "inject",
srcs = ["inject.go"],
importpath = "github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/inject",
visibility = ["//hack/oci-pin:__subpackages__"],
)
go_test(
name = "inject_test",
srcs = ["inject_test.go"],
embed = [":inject"],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)

View File

@ -0,0 +1,130 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
// inject renders Go source files with injected pinning values.
package inject
import (
"errors"
"io"
"regexp"
"text/template"
)
// Render renders the source code to inject the pinned values.
func Render(out io.Writer, vals PinningValues) error {
if err := validate(vals); err != nil {
return err
}
return sourceTpl.Execute(out, vals)
}
// validate validates the values to inject.
func validate(vals PinningValues) error {
if vals.Package == "" {
return errors.New("package is empty")
}
if vals.Ident == "" {
return errors.New("identifier is empty")
}
if vals.Registry == "" {
return errors.New("registry is empty")
}
if vals.Name == "" {
return errors.New("name is empty")
}
if vals.Digest == "" {
return errors.New("digest is empty")
}
if matched := packageNameRegexp.MatchString(vals.Package); !matched {
return errors.New("package is not valid")
}
if matched := identRegexp.MatchString(vals.Ident); !matched {
return errors.New("identifier is not valid")
}
if matched := registryRegexp.MatchString(vals.Registry); !matched {
return errors.New("registry is not valid")
}
if matched := prefixRegexp.MatchString(vals.Prefix); !matched {
return errors.New("prefix is not valid")
}
if matched := nameRegexp.MatchString(vals.Name); !matched {
return errors.New("name is not valid")
}
if matched := tagRegexp.MatchString(vals.Tag); vals.Tag != "" && !matched {
return errors.New("tag is not valid")
}
if matched := digestRegexp.MatchString(vals.Digest); !matched {
return errors.New("digest is not valid")
}
return nil
}
// PinningValues contains the values to inject into the generated source code.
type PinningValues struct {
// Package is the name of the package to generate.
Package string
// Ident is the base identifier of the generated constants.
Ident string
// Registry string
Registry string
// Prefix is the prefix of the container image name.
Prefix string
// Name is the name of the container image.
Name string
// Tag is the (optional) tag of the container image.
Tag string
// Digest is the digest of the container image.
Digest string
}
var (
sourceTpl = template.Must(template.New("goSource").Parse(goSourceTpl))
// packageNameRegexp is the regular expression for a valid package name.
packageNameRegexp = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// identRegexp is the regular expression for a valid identifier.
identRegexp = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// registryRegexp is the regular expression for a valid registry.
registryRegexp = regexp.MustCompile(`^[^\s/"]+$`)
// prefixRegexp is the regular expression for a valid prefix.
prefixRegexp = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9-_/]*)?$`)
// nameRegexp is the regular expression for a valid name.
nameRegexp = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]*$`)
// tagRegexp is the regular expression for a valid tag.
tagRegexp = regexp.MustCompile(`[\w][\w.-]{0,127}`)
// digestRegexp is the regular expression for a valid digest.
digestRegexp = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
)
const goSourceTpl = `package {{.Package}}
// Code generated by oci-pin. DO NOT EDIT.
const (
// {{.Ident}}Registry is the {{.Name}} container image registry.
{{.Ident}}Registry = "{{.Registry}}"
{{if .Prefix }} // {{.Ident}}Prefix is the {{.Name}} container image prefix.
{{.Ident}}Prefix = "{{.Prefix}}"
{{end}} // {{.Ident}}Name is the {{.Name}} container image short name part.
{{.Ident}}Name = "{{.Name}}"
{{if .Tag }} // {{.Ident}}Tag is the tag for the {{.Name}} container image.
{{.Ident}}Tag = "{{.Tag}}"
{{end}} // {{.Ident}}Digest is the digest for the {{.Name}} container image.
{{.Ident}}Digest = "{{.Digest}}"
)
`

View File

@ -0,0 +1,201 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package inject
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRender(t *testing.T) {
testCases := map[string]struct {
modifier func(vals *PinningValues)
wantTemplate string
wantErr bool
}{
"valid": {
wantTemplate: `package foo
// Code generated by oci-pin. DO NOT EDIT.
const (
// barServiceRegistry is the bar-service container image registry.
barServiceRegistry = "registry.example.com"
// barServicePrefix is the bar-service container image prefix.
barServicePrefix = "staging"
// barServiceName is the bar-service container image short name part.
barServiceName = "bar-service"
// barServiceTag is the tag for the bar-service container image.
barServiceTag = "v1.2.3"
// barServiceDigest is the digest for the bar-service container image.
barServiceDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
`,
wantErr: false,
},
"valid without prefix": {
modifier: func(vals *PinningValues) {
vals.Prefix = ""
},
wantTemplate: `package foo
// Code generated by oci-pin. DO NOT EDIT.
const (
// barServiceRegistry is the bar-service container image registry.
barServiceRegistry = "registry.example.com"
// barServiceName is the bar-service container image short name part.
barServiceName = "bar-service"
// barServiceTag is the tag for the bar-service container image.
barServiceTag = "v1.2.3"
// barServiceDigest is the digest for the bar-service container image.
barServiceDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
`,
wantErr: false,
},
"valid without tag": {
modifier: func(vals *PinningValues) {
vals.Tag = ""
},
wantTemplate: `package foo
// Code generated by oci-pin. DO NOT EDIT.
const (
// barServiceRegistry is the bar-service container image registry.
barServiceRegistry = "registry.example.com"
// barServicePrefix is the bar-service container image prefix.
barServicePrefix = "staging"
// barServiceName is the bar-service container image short name part.
barServiceName = "bar-service"
// barServiceDigest is the digest for the bar-service container image.
barServiceDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)
`,
wantErr: false,
},
"missing package": {
modifier: func(vals *PinningValues) {
vals.Package = ""
},
wantErr: true,
},
"missing ident": {
modifier: func(vals *PinningValues) {
vals.Ident = ""
},
wantErr: true,
},
"missing registry": {
modifier: func(vals *PinningValues) {
vals.Registry = ""
},
wantErr: true,
},
"missing name": {
modifier: func(vals *PinningValues) {
vals.Name = ""
},
wantErr: true,
},
"missing digest": {
modifier: func(vals *PinningValues) {
vals.Digest = ""
},
wantErr: true,
},
"malformed package": {
modifier: func(vals *PinningValues) {
vals.Package = "foo bar"
},
wantErr: true,
},
"malformed ident": {
modifier: func(vals *PinningValues) {
vals.Ident = "foo bar"
},
wantErr: true,
},
"malformed registry": {
modifier: func(vals *PinningValues) {
vals.Registry = "\n"
},
wantErr: true,
},
"malformed prefix": {
modifier: func(vals *PinningValues) {
vals.Prefix = "\n"
},
wantErr: true,
},
"malformed name": {
modifier: func(vals *PinningValues) {
vals.Name = "foo bar"
},
wantErr: true,
},
"malformed tag": {
modifier: func(vals *PinningValues) {
vals.Tag = `"`
},
wantErr: true,
},
"malformed digest": {
modifier: func(vals *PinningValues) {
vals.Digest = "foo bar"
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
vals := defaultPinningValues()
if tc.modifier != nil {
tc.modifier(vals)
}
var buf bytes.Buffer
err := Render(&buf, *vals)
if tc.wantErr {
require.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantTemplate, buf.String())
})
}
}
func defaultPinningValues() *PinningValues {
return &PinningValues{
Package: "foo",
Ident: "barService",
Registry: "registry.example.com",
Prefix: "staging",
Name: "bar-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
}
}

View File

@ -0,0 +1,19 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("//bazel/go:go_test.bzl", "go_test")
go_library(
name = "sums",
srcs = ["sums.go"],
importpath = "github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/sums",
visibility = ["//hack/oci-pin:__subpackages__"],
)
go_test(
name = "sums_test",
srcs = ["sums_test.go"],
embed = [":sums"],
deps = [
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//require",
],
)

View File

@ -0,0 +1,227 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
// sums creates and combines sha256sums files.
package sums
import (
"bufio"
"errors"
"fmt"
"io"
"path"
"regexp"
"sort"
"strings"
)
var sumRegexp = regexp.MustCompile(`^[a-f0-9]{64}$`)
const (
sumLength = 64
minLineLength = sumLength + 2 // 64 hex + 2 space
notFound = -1 // index not found
)
// Create creates a sha256sums file from a list of digests.
// The digests going in are expected to be in the oci format "sha256:hex".
func Create(refs []PinnedImageReference, out io.Writer) error {
coalescedRefs, err := coalesceRefs(refs)
if err != nil {
return err
}
for _, ref := range coalescedRefs {
if _, err := fmt.Fprintf(out, "%s %s\n", ref.Sum(), ref.Reference()); err != nil {
return err
}
}
return nil
}
// Merge merges multiple sha256sums files into one.
func Merge(refs [][]PinnedImageReference, out io.Writer) error {
mergedRefs := make([]PinnedImageReference, 0)
for _, refs := range refs {
mergedRefs = append(mergedRefs, refs...)
}
return Create(mergedRefs, out)
}
// Parse parses a sha256sums file.
func Parse(in io.Reader) ([]PinnedImageReference, error) {
scanner := newLineScanner(in)
return parseLines(scanner)
}
// PinnedImageReference contains the components of a pinned image reference.
type PinnedImageReference struct {
Registry string
Prefix string
Name string
Tag string
Digest string
}
// Reference returns the string representation of the reference (without the digest).
func (r PinnedImageReference) Reference() string {
var base string
if r.Prefix == "" {
base = path.Join(r.Registry, r.Name)
} else {
base = path.Join(r.Registry, r.Prefix, r.Name)
}
var tag string
if r.Tag != "" {
tag = ":" + r.Tag
}
return base + tag
}
// Sum returns the string representation of the digest.
// The digest is expected to be in the oci format "sha256:hex".
// The resulting Sum is only the hex part.
func (r PinnedImageReference) Sum() string {
return r.Digest[len("sha256:"):]
}
// coalesceRefs coalesces the image references.
// It sorts the references and removes duplicates.
// If conflicting digests are found, an error is returned.
func coalesceRefs(refs []PinnedImageReference) ([]PinnedImageReference, error) {
sortRefs(refs)
uniqueRefs := make([]PinnedImageReference, 0, len(refs))
var prev PinnedImageReference
for _, ref := range refs {
equal, err := compareRefs(prev, ref)
if err != nil {
return nil, err
}
if !equal {
uniqueRefs = append(uniqueRefs, ref)
prev = ref
}
}
return uniqueRefs, nil
}
func sortRefs(refs []PinnedImageReference) {
sort.Slice(refs, func(i, j int) bool {
if refs[i].Registry != refs[j].Registry {
return refs[i].Registry < refs[j].Registry
}
if refs[i].Prefix != refs[j].Prefix {
return refs[i].Prefix < refs[j].Prefix
}
if refs[i].Name != refs[j].Name {
return refs[i].Name < refs[j].Name
}
if refs[i].Tag != refs[j].Tag {
return refs[i].Tag < refs[j].Tag
}
return refs[i].Digest < refs[j].Digest
})
}
// compareRefs compares two references.
// references are equal if they have the same registry, prefix, name, tag and digest.
// If refs only differ in digest, they are inconsistent and an error is returned.
func compareRefs(a, b PinnedImageReference) (equal bool, err error) {
if a.Registry != b.Registry {
return false, nil
}
if a.Prefix != b.Prefix {
return false, nil
}
if a.Name != b.Name {
return false, nil
}
if a.Tag != b.Tag {
return false, nil
}
if a.Digest != b.Digest {
return false, errors.New("conflicting digests")
}
return true, nil
}
func parseLines(scanner scanner) ([]PinnedImageReference, error) {
var refs []PinnedImageReference
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
ref, err := parseLine(line)
if err != nil {
return nil, err
}
refs = append(refs, ref)
}
return refs, nil
}
// parseLine parses a line from a sha256sums file.
// The line is expected to be in the format "hex reference".
func parseLine(line string) (PinnedImageReference, error) {
parts := strings.Split(line, " ")
if len(parts) != 2 {
return PinnedImageReference{}, fmt.Errorf("line does not have exactly 2 parts separated by two spaces: %q", line)
}
return refFromSumAndRef(parts[0], parts[1])
}
func refFromSumAndRef(sum, ref string) (PinnedImageReference, error) {
if !sumRegexp.MatchString(sum) {
return PinnedImageReference{}, fmt.Errorf("invalid sum: %q", sum)
}
// last colon is separator between name and tag
var base, tag string
tagSep := strings.LastIndexByte(ref, ':')
if tagSep == notFound {
base = ref
} else {
base = ref[:tagSep]
tag = ref[tagSep+1:]
}
// first slash is separator between registry and full name
registrySep := strings.IndexByte(base, '/')
if registrySep == notFound {
return PinnedImageReference{}, fmt.Errorf("invalid reference: missing registry %q", ref)
}
registry := base[:registrySep]
fullName := base[registrySep+1:]
// last slash is separator between prefix and short name
var prefix, name string
nameSep := strings.LastIndexByte(fullName, '/')
if nameSep == notFound {
name = fullName
} else {
prefix = fullName[:nameSep]
name = fullName[nameSep+1:]
}
return PinnedImageReference{
Registry: registry,
Prefix: prefix,
Name: name,
Tag: tag,
Digest: "sha256:" + sum,
}, nil
}
func newLineScanner(r io.Reader) scanner {
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
return scanner
}
type scanner interface {
Scan() bool
Text() string
}

View File

@ -0,0 +1,337 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package sums
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateParse(t *testing.T) {
testCases := map[string]struct {
refs []PinnedImageReference
wantErr bool
wantOut string
wantRefs []PinnedImageReference
}{
"single image": {
refs: []PinnedImageReference{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service:v1.2.3\n",
},
"no prefix": {
refs: []PinnedImageReference{
{
Registry: "registry.example.com",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/foo-service:v1.2.3\n",
},
"no tag": {
refs: []PinnedImageReference{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service\n",
},
"multiple images": {
refs: []PinnedImageReference{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "bar-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "production",
Name: "baz-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v2.0.0",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "backup.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantRefs: []PinnedImageReference{
{
Registry: "backup.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "production",
Name: "baz-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "bar-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v2.0.0",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 backup.example.com/staging/foo-service:v1.2.3\n" +
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/production/baz-service:v1.2.3\n" +
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/bar-service:v1.2.3\n" +
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service:v1.2.3\n" +
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service:v2.0.0\n",
},
"duplicate images": {
refs: []PinnedImageReference{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantRefs: []PinnedImageReference{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service:v1.2.3\n",
},
"duplicate images with different hashes": {
refs: []PinnedImageReference{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
},
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
},
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name+"_create", func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
var buf bytes.Buffer
err := Create(tc.refs, &buf)
if tc.wantErr {
require.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantOut, buf.String())
})
}
for name, tc := range testCases {
if tc.wantErr {
continue // skip inverse test cases where the forward test case is expected to fail
}
t.Run(name+"_parse", func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
buf := bytes.NewBufferString(tc.wantOut)
gotRefs, err := Parse(buf)
if tc.wantErr {
require.Error(err)
return
}
require.NoError(err)
wantRefs := tc.refs
if len(tc.wantRefs) > 0 {
wantRefs = tc.wantRefs
}
assert.Equal(wantRefs, gotRefs)
})
}
}
func TestMerge(t *testing.T) {
testCases := map[string]struct {
refs [][]PinnedImageReference
wantErr bool
wantOut string
}{
"different images": {
refs: [][]PinnedImageReference{
{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "bar-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/bar-service:v1.2.3\n" + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service:v1.2.3\n",
},
"duplicate images": {
refs: [][]PinnedImageReference{
{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
{
{
Registry: "registry.example.com",
Prefix: "staging",
Name: "foo-service",
Tag: "v1.2.3",
Digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
},
},
wantOut: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 registry.example.com/staging/foo-service:v1.2.3\n",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
var buf bytes.Buffer
err := Merge(tc.refs, &buf)
if tc.wantErr {
require.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantOut, buf.String())
})
}
}
// TestParse has additional test cases that are not covered by TestCreateParse.
func TestParse(t *testing.T) {
testCases := map[string]struct {
in string
wantErr bool
wantRefs []PinnedImageReference
}{
"empty line": {
in: "\n\n",
},
"line too short": {
in: "short",
wantErr: true,
},
"malformed digest": {
in: "malformed-digest-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa registry.example.com/staging/foo-service:v1.2.3",
wantErr: true,
},
"missing registry": {
in: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 foo-service:v1.2.3",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
buf := bytes.NewBufferString(tc.in)
gotRefs, err := Parse(buf)
if tc.wantErr {
require.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantRefs, gotRefs)
})
}
}

122
hack/oci-pin/merge.go Normal file
View File

@ -0,0 +1,122 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"fmt"
"io"
"os"
"github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/sums"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
)
func newMergeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "merge",
Short: "Merge multiple sha256sum files that pin OCI images.",
RunE: runMerge,
}
cmd.Flags().StringArray("input", nil, "Path to existing sha256sum file that should be merged.")
cmd.Flags().String("output", "-", "Output file. If not set, the output is written to stdout.")
must(cmd.MarkFlagRequired("input"))
return cmd
}
func runMerge(cmd *cobra.Command, _ []string) error {
flags, err := parseMergeFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Merging sum file from %q into %q.", flags.inputs, flags.output)
var out io.Writer
if flags.output == "-" {
out = cmd.OutOrStdout()
} else {
f, err := os.Create(flags.output)
if err != nil {
return fmt.Errorf("creating output file %q: %w", flags.output, err)
}
defer f.Close()
out = f
}
unmergedRefs, err := parseInputs(flags.inputs)
if err != nil {
return fmt.Errorf("reading input files: %w", err)
}
if err := sums.Merge(unmergedRefs, out); err != nil {
return fmt.Errorf("creating merged sum file: %w", err)
}
log.Debugf("Sum file created at %q 🤖", flags.output)
return nil
}
func parseInputs(inputs []string) ([][]sums.PinnedImageReference, error) {
var unmergedRefs [][]sums.PinnedImageReference
for _, input := range inputs {
refs, err := parseInput(input)
if err != nil {
return nil, err
}
unmergedRefs = append(unmergedRefs, refs)
}
return unmergedRefs, nil
}
func parseInput(input string) ([]sums.PinnedImageReference, error) {
in, err := os.Open(input)
if err != nil {
return nil, fmt.Errorf("opening sum file at %q: %w", input, err)
}
defer in.Close()
refs, err := sums.Parse(in)
if err != nil {
return nil, fmt.Errorf("parsing sums %q: %w", input, err)
}
return refs, nil
}
type mergeFlags struct {
inputs []string
output string
logLevel zapcore.Level
}
func parseMergeFlags(cmd *cobra.Command) (mergeFlags, error) {
inputs, err := cmd.Flags().GetStringArray("input")
if err != nil {
return mergeFlags{}, err
}
output, err := cmd.Flags().GetString("output")
if err != nil {
return mergeFlags{}, err
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return mergeFlags{}, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
return mergeFlags{
inputs: inputs,
output: output,
logLevel: logLevel,
}, nil
}

85
hack/oci-pin/oci-pin.go Normal file
View File

@ -0,0 +1,85 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
// oci-pin generates Go code and shasum files for OCI images.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"github.com/spf13/cobra"
)
func main() {
if err := execute(); err != nil {
os.Exit(1)
}
}
func execute() error {
rootCmd := newRootCmd()
ctx, cancel := signalContext(context.Background(), os.Interrupt)
defer cancel()
return rootCmd.ExecuteContext(ctx)
}
func newRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Use: "oci-pin",
Short: "Generate pinning artifacts for OCI images.",
Long: "Generate pinning artifacts (Go code, shasum files) for OCI images.",
PersistentPreRun: preRunRoot,
}
rootCmd.SetOut(os.Stdout)
rootCmd.PersistentFlags().Bool("verbose", false, "Enable verbose output.")
rootCmd.AddCommand(newCodegenCmd())
rootCmd.AddCommand(newSumCmd())
rootCmd.AddCommand(newMergeCmd())
return rootCmd
}
// signalContext returns a context that is canceled on the handed signal.
// The signal isn't watched after its first occurrence. Call the cancel
// function to ensure the internal goroutine is stopped and the signal isn't
// watched any longer.
func signalContext(ctx context.Context, sig os.Signal) (context.Context, context.CancelFunc) {
sigCtx, stop := signal.NotifyContext(ctx, sig)
done := make(chan struct{}, 1)
stopDone := make(chan struct{}, 1)
go func() {
defer func() { stopDone <- struct{}{} }()
defer stop()
select {
case <-sigCtx.Done():
fmt.Println(" Signal caught. Press ctrl+c again to terminate the program immediately.")
case <-done:
}
}()
cancelFunc := func() {
done <- struct{}{}
<-stopDone
}
return sigCtx, cancelFunc
}
func preRunRoot(cmd *cobra.Command, _ []string) {
cmd.SilenceUsage = true
}
func must(err error) {
if err != nil {
panic(err)
}
}

162
hack/oci-pin/sum.go Normal file
View File

@ -0,0 +1,162 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/extract"
"github.com/edgelesssys/constellation/v2/hack/oci-pin/internal/sums"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/cobra"
"go.uber.org/zap/zapcore"
)
func newSumCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "sum",
Short: "Generate sha256sum file that pins an OCI image.",
RunE: runSum,
}
cmd.Flags().String("oci-path", "", "Path to the OCI image to pin.")
cmd.Flags().String("output", "-", "Output file. If not set, the output is written to stdout.")
cmd.Flags().String("registry", "", "OCI registry to use.")
cmd.Flags().String("prefix", "", "Prefix of the OCI image to pin.")
cmd.Flags().String("image-name", "", "Short name (suffix) of the OCI image to pin.")
cmd.Flags().String("image-tag", "", "Tag of the OCI image to pin. Optional.")
cmd.Flags().String("image-tag-file", "", "Tag file of the OCI image to pin. Optional.")
cmd.MarkFlagsMutuallyExclusive("image-tag", "image-tag-file")
must(cmd.MarkFlagRequired("registry"))
must(cmd.MarkFlagRequired("oci-path"))
must(cmd.MarkFlagRequired("image-name"))
return cmd
}
func runSum(cmd *cobra.Command, _ []string) error {
flags, err := parseSumFlags(cmd)
if err != nil {
return err
}
log := logger.New(logger.PlainLog, flags.logLevel)
log.Debugf("Parsed flags: %+v", flags)
log.Debugf("Generating sum file for OCI image %s.", flags.imageName)
ociIndexPath := filepath.Join(flags.ociPath, "index.json")
index, err := os.Open(ociIndexPath)
if err != nil {
return fmt.Errorf("opening OCI index at %q: %w", ociIndexPath, err)
}
defer index.Close()
var out io.Writer
if flags.output == "-" {
out = cmd.OutOrStdout()
} else {
f, err := os.Create(flags.output)
if err != nil {
return fmt.Errorf("creating output file %q: %w", flags.output, err)
}
defer f.Close()
out = f
}
digest, err := extract.Digest(index)
if err != nil {
return fmt.Errorf("extracting OCI image digest: %w", err)
}
log.Debugf("OCI image digest: %s", digest)
refs := []sums.PinnedImageReference{
{
Registry: flags.registry,
Prefix: flags.prefix,
Name: flags.imageName,
Tag: flags.imageTag,
Digest: digest,
},
}
if err := sums.Create(refs, out); err != nil {
return fmt.Errorf("creating sum file: %w", err)
}
log.Debugf("Sum file created at %q 🤖", flags.output)
return nil
}
type sumFlags struct {
ociPath string
output string
registry string
prefix string
imageName string
imageTag string
logLevel zapcore.Level
}
func parseSumFlags(cmd *cobra.Command) (sumFlags, error) {
ociPath, err := cmd.Flags().GetString("oci-path")
if err != nil {
return sumFlags{}, err
}
output, err := cmd.Flags().GetString("output")
if err != nil {
return sumFlags{}, err
}
registry, err := cmd.Flags().GetString("registry")
if err != nil {
return sumFlags{}, err
}
prefix, err := cmd.Flags().GetString("prefix")
if err != nil {
return sumFlags{}, err
}
imageName, err := cmd.Flags().GetString("image-name")
if err != nil {
return sumFlags{}, err
}
imageTag, err := cmd.Flags().GetString("image-tag")
if err != nil {
return sumFlags{}, err
}
imageTagFile, err := cmd.Flags().GetString("image-tag-file")
if err != nil {
return sumFlags{}, err
}
if imageTagFile != "" {
tag, err := os.ReadFile(imageTagFile)
if err != nil {
return sumFlags{}, fmt.Errorf("reading image tag file %q: %w", imageTagFile, err)
}
imageTag = strings.TrimSpace(string(tag))
}
verbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return sumFlags{}, err
}
logLevel := zapcore.InfoLevel
if verbose {
logLevel = zapcore.DebugLevel
}
return sumFlags{
ociPath: ociPath,
output: output,
registry: registry,
prefix: prefix,
imageName: imageName,
imageTag: imageTag,
logLevel: logLevel,
}, nil
}