diff --git a/docs/configuration.md b/docs/configuration.md index c669eda165..06cf0b67ff 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,10 @@ baseImageOverrides: github.com/my-user/my-repo/cmd/app: registry.example.com/base/for/app github.com/my-user/my-repo/cmd/foo: registry.example.com/base/for/foo ``` +#### Scratch Base Image + +If the base image name `scratch` is used, `ko` will construct an empty base image with support for the platforms +specified. In this mode it is not possible to specify `all` platforms. ### Overriding Go build settings diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go index c77a122bad..a335b1488e 100644 --- a/pkg/build/gobuild_test.go +++ b/pkg/build/gobuild_test.go @@ -137,6 +137,7 @@ func TestGoBuildQualifyImport(t *testing.T) { } var baseRef = name.MustParseReference("all.your/base") +var scratchBaseRef = name.MustParseReference("scratch") func TestGoBuildIsSupportedRef(t *testing.T) { base, err := random.Image(1024, 3) @@ -514,7 +515,7 @@ func TestGoBuildNoKoData(t *testing.T) { }) } -func validateImage(t *testing.T, img oci.SignedImage, baseLayers int64, creationTime v1.Time, checkAnnotations bool, expectSBOM bool) { +func validateImage(t *testing.T, img oci.SignedImage, baseLayers int64, base name.Reference, creationTime v1.Time, checkAnnotations bool, expectSBOM bool) { t.Helper() ls, err := img.Layers() @@ -665,7 +666,7 @@ func validateImage(t *testing.T, img oci.SignedImage, baseLayers int64, creation if _, found := mf.Annotations[specsv1.AnnotationBaseImageDigest]; !found { t.Errorf("image annotations did not contain base image digest") } - want := baseRef.Name() + want := base.Name() if got := mf.Annotations[specsv1.AnnotationBaseImageName]; got != want { t.Errorf("base image ref; got %q, want %q", got, want) } @@ -734,7 +735,7 @@ func TestGoBuild(t *testing.T) { t.Fatalf("Build() not a SignedImage: %T", result) } - validateImage(t, img, baseLayers, creationTime, true, true) + validateImage(t, img, baseLayers, baseRef, creationTime, true, true) // Check that rebuilding the image again results in the same image digest. t.Run("check determinism", func(t *testing.T) { @@ -848,7 +849,7 @@ func TestGoBuildWithoutSBOM(t *testing.T) { t.Fatalf("Build() not a SignedImage: %T", result) } - validateImage(t, img, baseLayers, creationTime, true, false) + validateImage(t, img, baseLayers, baseRef, creationTime, true, false) } func TestGoBuildIndex(t *testing.T) { @@ -894,7 +895,7 @@ func TestGoBuildIndex(t *testing.T) { if err != nil { t.Fatalf("idx.Image(%s) = %v", desc.Digest, err) } - validateImage(t, img, baseLayers, creationTime, false, true) + validateImage(t, img, baseLayers, baseRef, creationTime, false, true) } if want, got := images, int64(len(im.Manifests)); want != got { @@ -1233,3 +1234,76 @@ func TestGoBuildConsistentMediaTypes(t *testing.T) { }) } } + +func TestGoBuildScratch(t *testing.T) { + importpath := "github.com/google/ko" + + creationTime := v1.Time{Time: time.Unix(5000, 0)} + ng, err := NewGo( + context.Background(), + "", + WithCreationTime(creationTime), + WithBaseImages(func(context.Context, string) (name.Reference, Result, error) { + img, err := ScratchImage([]string{"linux/s390x"}) + return scratchBaseRef, img, err + }), + withBuilder(writeTempFile), + withSBOMber(fauxSBOM), + WithLabel("foo", "bar"), + WithLabel("hello", "world"), + WithPlatforms("all"), + ) + if err != nil { + t.Fatalf("NewGo() = %v", err) + } + + result, err := ng.Build(context.Background(), StrictScheme+filepath.Join(importpath, "test")) + if err != nil { + t.Fatalf("Build() = %v", err) + } + + img, ok := result.(oci.SignedImage) + if !ok { + t.Fatalf("Build() not a SignedImage: %T", result) + } + + baseLayers := int64(0) // scratch image has no layers + validateImage(t, img, baseLayers, scratchBaseRef, creationTime, true, true) + + // Check that rebuilding the image again results in the same image digest. + t.Run("check determinism", func(t *testing.T) { + result2, err := ng.Build(context.Background(), StrictScheme+filepath.Join(importpath, "test")) + if err != nil { + t.Fatalf("Build() = %v", err) + } + + d1, err := result.Digest() + if err != nil { + t.Fatalf("Digest() = %v", err) + } + d2, err := result2.Digest() + if err != nil { + t.Fatalf("Digest() = %v", err) + } + + if d1 != d2 { + t.Errorf("Digest mismatch: %s != %s", d1, d2) + } + }) + + t.Run("check labels", func(t *testing.T) { + cfg, err := img.ConfigFile() + if err != nil { + t.Fatalf("ConfigFile() = %v", err) + } + + want := map[string]string{ + "foo": "bar", + "hello": "world", + } + got := cfg.Config.Labels + if d := cmp.Diff(got, want); d != "" { + t.Fatalf("Labels diff (-got,+want): %s", d) + } + }) +} diff --git a/pkg/build/scratch.go b/pkg/build/scratch.go new file mode 100644 index 0000000000..c4d271023f --- /dev/null +++ b/pkg/build/scratch.go @@ -0,0 +1,62 @@ +// Copyright 2023 Google LLC All Rights Reserved. +// +// 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 build + +import ( + "fmt" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +// ScratchImage returns a scratch image manifest with scratch images for each of the specified platforms +func ScratchImage(platforms []string) (Result, error) { + var manifests []mutate.IndexAddendum + for _, pf := range platforms { + if pf == "all" { + return nil, fmt.Errorf("'all' is not supported for building a scratch image, the platform list must be provided") + } + p, err := v1.ParsePlatform(pf) + if err != nil { + return nil, err + } + img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{ + RootFS: v1.RootFS{ + // Some clients check this. + Type: "layers", + }, + Architecture: p.Architecture, + OS: p.OS, + Variant: p.Variant, + OSVersion: p.OSVersion, + OSFeatures: p.OSFeatures, + }, + ) + if err != nil { + return nil, fmt.Errorf("setting config file on empty image, %w", err) + } + manifests = append(manifests, mutate.IndexAddendum{ + Add: img, + Descriptor: v1.Descriptor{ + Platform: p, + }, + }) + } + idx := mutate.IndexMediaType(empty.Index, types.OCIImageIndex) + idx = mutate.AppendManifests(idx, manifests...) + return idx, nil +} diff --git a/pkg/build/scratch_test.go b/pkg/build/scratch_test.go new file mode 100644 index 0000000000..2ba41846ac --- /dev/null +++ b/pkg/build/scratch_test.go @@ -0,0 +1,74 @@ +// Copyright 2023 Google LLC All Rights Reserved. +// +// 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 build + +import ( + "testing" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/types" +) + +func TestScratchImage(t *testing.T) { + img, err := ScratchImage([]string{"linux/s390x", "plan9/386"}) + if err != nil { + t.Fatalf("expected to create the image, %s", err) + } + mt, err := img.MediaType() + if err != nil { + t.Fatalf("expected to get a mediatype, %s", err) + } + expMT := types.OCIImageIndex + if mt != expMT { + t.Errorf("expected media type = %s, got %s", expMT, mt) + } + + imgIdx, ok := img.(v1.ImageIndex) + if !ok { + t.Fatalf("expected to have an image index") + } + + mf, err := imgIdx.IndexManifest() + if mt != expMT { + t.Errorf("expected a manifest, got %s", err) + } + if len(mf.Manifests) != 2 { + t.Fatalf("expected two manifests, got %d", len(mf.Manifests)) + } + for _, m := range mf.Manifests { + img, err := imgIdx.Image(m.Digest) + if err != nil { + t.Fatalf("expected no error when getting image for digest %s, got %s", m.Digest, err) + } + ls, err := img.Layers() + if len(ls) != 0 { + t.Errorf("expected no layers, found %d", len(ls)) + } + + switch m.Platform.OS { + case "linux": + if m.Platform.Architecture != "s390x" { + t.Errorf("expected arch = s390x, got %s", m.Platform.Architecture) + } + case "plan9": + if m.Platform.Architecture != "386" { + t.Errorf("expected arch = 386, got %s", m.Platform.Architecture) + } + default: + t.Errorf("unexpected OS %s", m.Platform.OS) + } + } + +} diff --git a/pkg/commands/config.go b/pkg/commands/config.go index 1575a53438..26c0710db4 100644 --- a/pkg/commands/config.go +++ b/pkg/commands/config.go @@ -103,6 +103,7 @@ func getBaseImage(bo *options.BuildOptions) build.GetBase { if !ok || baseImage == "" { baseImage = bo.BaseImage } + var nameOpts []name.Option if bo.InsecureRegistry { nameOpts = append(nameOpts, name.Insecure) @@ -112,6 +113,15 @@ func getBaseImage(bo *options.BuildOptions) build.GetBase { return nil, nil, fmt.Errorf("parsing base image (%q): %w", baseImage, err) } + if baseImage == "scratch" { + log.Printf("Using base %s for %s", ref, s) + si, err := build.ScratchImage(bo.Platforms) + if err != nil { + return nil, nil, fmt.Errorf("constructing scratch image: %w", err) + } + return ref, si, nil + } + result, err := cache.get(ctx, ref, fetch) if err != nil { return ref, result, err