blob: 13c357bc36c4cb480b8689d4984cb89266bdfd34 [file] [log] [blame]
// Copyright Istio 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 builder
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"strings"
"sync"
"time"
)
import (
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
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/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"istio.io/pkg/log"
)
type BuildSpec struct {
// Name is optional, for logging
Name string
Dests []string
Args []Args
}
type Args struct {
// Name is optional, for logging
Name string
// Image architecture. Required only when multiple images present
Arch string
Env map[string]string
Labels map[string]string
User string
WorkDir string
Entrypoint []string
Cmd []string
// Base image to use
Base string
// Files contains all files, mapping destination path -> source path
Files map[string]string
// FilesBase is the base path for absolute paths in Files
FilesBase string
}
var (
bases = map[string]v1.Image{}
basesMu sync.RWMutex
)
func WarmBase(baseImages ...string) {
basesMu.Lock()
wg := sync.WaitGroup{}
wg.Add(len(baseImages))
resolvedBaseImages := make([]v1.Image, len(baseImages))
go func() {
wg.Wait()
for i, rbi := range resolvedBaseImages {
bases[baseImages[i]] = rbi
}
basesMu.Unlock()
}()
t0 := time.Now()
for i, b := range baseImages {
b, i := b, i
go func() {
defer wg.Done()
ref, err := name.ParseReference(b)
if err != nil {
log.WithLabels("image", b).Warnf("base failed: %v", err)
return
}
bi, err := remote.Image(ref, remote.WithProgress(CreateProgress(fmt.Sprintf("base %v", ref))))
if err != nil {
log.WithLabels("image", b).Warnf("base failed: %v", err)
return
}
log.WithLabels("image", b, "step", time.Since(t0)).Infof("base loaded")
resolvedBaseImages[i] = bi
}()
}
}
func ByteCount(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%dB", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%cB",
float64(b)/float64(div), "kMGTPE"[exp])
}
func Build(b BuildSpec) error {
t0 := time.Now()
lt := t0
trace := func(d ...interface{}) {
log.WithLabels("image", b.Name, "total", time.Since(t0), "step", time.Since(lt)).Infof(d...)
lt = time.Now()
}
if len(b.Dests) == 0 {
return fmt.Errorf("dest required")
}
// Over localhost, compression CPU can be the bottleneck. With remotes, compressing usually saves a lot of time.
compression := gzip.NoCompression
for _, d := range b.Dests {
if !strings.HasPrefix(d, "localhost") {
compression = gzip.BestSpeed
break
}
}
var images []v1.Image
for _, args := range b.Args {
baseImage := empty.Image
if args.Base != "" {
basesMu.RLock()
baseImage = bases[args.Base]
basesMu.RUnlock()
}
if baseImage == nil {
log.Warnf("on demand loading base image %q", args.Base)
ref, err := name.ParseReference(args.Base)
if err != nil {
return err
}
bi, err := remote.Image(ref, remote.WithProgress(CreateProgress(fmt.Sprintf("base %v", ref))))
if err != nil {
return err
}
baseImage = bi
}
trace("create base")
cfgFile, err := baseImage.ConfigFile()
if err != nil {
return err
}
trace("base config")
cfg := cfgFile.Config
for k, v := range args.Env {
cfg.Env = append(cfg.Env, fmt.Sprintf("%v=%v", k, v))
}
if args.User != "" {
cfg.User = args.User
}
if len(args.Entrypoint) > 0 {
cfg.Entrypoint = args.Entrypoint
cfg.Cmd = nil
}
if len(args.Cmd) > 0 {
cfg.Cmd = args.Cmd
cfg.Entrypoint = nil
}
if args.WorkDir != "" {
cfg.WorkingDir = args.WorkDir
}
if len(args.Labels) > 0 && cfg.Labels == nil {
cfg.Labels = map[string]string{}
}
for k, v := range args.Labels {
cfg.Labels[k] = v
}
updated, err := mutate.Config(baseImage, cfg)
if err != nil {
return err
}
trace("config")
// Pre-allocated 100mb
// TODO: cache the size of images, use exactish size
buf := bytes.NewBuffer(make([]byte, 0, 100*1024*1024))
if err := WriteArchiveFromFiles(args.FilesBase, args.Files, buf); err != nil {
return err
}
sz := ByteCount(int64(buf.Len()))
l, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(buf.Bytes())), nil
}, tarball.WithCompressionLevel(compression))
if err != nil {
return err
}
trace("read layer of size %v", sz)
image, err := mutate.AppendLayers(updated, l)
if err != nil {
return err
}
trace("layer")
images = append(images, image)
}
// Write Remote
var artifact remote.Taggable
if len(images) == 1 {
// Single image, just push that
artifact = images[0]
} else {
// Multiple, we need to create an index
var manifest v1.ImageIndex = empty.Index
for idx, i := range images {
img := i
mt, err := img.MediaType()
if err != nil {
return fmt.Errorf("failed to get mediatype: %w", err)
}
h, err := img.Digest()
if err != nil {
return fmt.Errorf("failed to compute digest: %w", err)
}
size, err := img.Size()
if err != nil {
return fmt.Errorf("failed to compute size: %w", err)
}
os, arch, _ := strings.Cut(b.Args[idx].Arch, "/")
manifest = mutate.AppendManifests(manifest, mutate.IndexAddendum{
Add: i,
Descriptor: v1.Descriptor{
MediaType: mt,
Size: size,
Digest: h,
Platform: &v1.Platform{
Architecture: arch,
OS: os,
Variant: "", // TODO?
},
},
})
}
artifact = manifest
}
// MultiWrite takes a Reference -> Taggable, but won't handle writing to multiple Repos. So we keep
// a map of Repository -> MultiWrite args.
remoteTargets := map[name.Repository]map[name.Reference]remote.Taggable{}
for _, dest := range b.Dests {
destRef, err := name.ParseReference(dest)
if err != nil {
return err
}
repo := destRef.Context()
if remoteTargets[repo] == nil {
remoteTargets[repo] = map[name.Reference]remote.Taggable{}
}
remoteTargets[repo][destRef] = artifact
}
for repo, mw := range remoteTargets {
prog := CreateProgress(fmt.Sprintf("upload %v", repo.String()))
if err := remote.MultiWrite(mw, remote.WithProgress(prog), remote.WithAuthFromKeychain(authn.DefaultKeychain)); err != nil {
return err
}
s := repo.String()
if len(mw) == 1 {
for tag := range mw {
s = tag.String()
}
}
trace("upload %v", s)
}
return nil
}
func CreateProgress(name string) chan v1.Update {
updates := make(chan v1.Update, 1000)
go func() {
lastLog := time.Time{}
for u := range updates {
if time.Since(lastLog) < time.Second && !(u.Total == u.Complete) {
// Limit to 1 log per-image per-second, unless it is the final log
continue
}
if u.Total == 0 {
continue
}
lastLog = time.Now()
log.WithLabels("action", name).Infof("progress %s/%s", ByteCount(u.Complete), ByteCount(u.Total))
}
}()
return updates
}
func Experiment() {
registry.New()
}