Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support building from a scratch image #1350

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ jobs:
echo platform is ${PLATFORM}
# Build and run the ko binary, which should be runnable.
docker run $(go run ./ build ./ --platform=${PLATFORM} --preserve-import-paths) version
# Build and run the ko binary with the scratch base image, which should be runnable.
docker run $(KO_DEFAULTBASEIMAGE=scratch go run ./ build ./ --platform=${PLATFORM} --preserve-import-paths) version
# Build and run the test/ binary, which should log "Hello there" served from KO_DATA_PATH
testimg=$(go run ./ build ./test --platform=${PLATFORM} --preserve-import-paths)
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 79 additions & 5 deletions pkg/build/gobuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,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)
Expand Down Expand Up @@ -640,7 +641,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()
Expand Down Expand Up @@ -791,7 +792,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)
}
Expand Down Expand Up @@ -860,7 +861,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) {
Expand Down Expand Up @@ -1068,7 +1069,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) {
Expand Down Expand Up @@ -1114,7 +1115,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 {
Expand Down Expand Up @@ -1454,6 +1455,79 @@ 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)
}
})
}

func TestDebugger(t *testing.T) {
base, err := random.Image(1024, 3)
if err != nil {
Expand Down
62 changes: 62 additions & 0 deletions pkg/build/scratch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2024 ko Build Authors 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

Check failure on line 28 in pkg/build/scratch.go

View workflow job for this annotation

GitHub Actions / lint

Consider pre-allocating `manifests` (prealloc)
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
}
74 changes: 74 additions & 0 deletions pkg/build/scratch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2024 ko Build Authors 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()

Check failure on line 55 in pkg/build/scratch_test.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to err (ineffassign)
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)
}
}

}

Check failure on line 74 in pkg/build/scratch_test.go

View workflow job for this annotation

GitHub Actions / lint

unnecessary trailing newline (whitespace)
10 changes: 10 additions & 0 deletions pkg/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,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)
Expand All @@ -108,6 +109,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
}

var result build.Result

// For ko.local, look in the daemon.
Expand Down
Loading