Skip to content

Commit

Permalink
Add option to push provisioned images
Browse files Browse the repository at this point in the history
Adds three new flags to virter image build

--push automatically pushes images to the container registry indicated by
the target image name. It implements a primitive caching strategy by
comparing the "History" attribute of the remote image (if any) with
the value if the `--build-id` flag. If they match and both existing target
as well as current "image build" invocation use the same base image, actual
provisioning is skipped entirely.

--build-id is the cache key used for the primitive caching exaplained above.
It is the users responsibility to come up with a value that will change
whenever the image should be rebuild.

--no-cache disables the history check mentioned above and will always
rebuild the image before pushing.

This was mostly implemented because:
* we can
* in CI pipelines we might want to rebuild base images only when necessary.
  • Loading branch information
WanzenBug committed Jul 6, 2021
1 parent 7fabeae commit 5e75e93
Show file tree
Hide file tree
Showing 2 changed files with 211 additions and 9 deletions.
192 changes: 183 additions & 9 deletions cmd/image_build.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package cmd

import (
"bytes"
"context"
"fmt"
"net/http"

"github.com/LINBIT/containerapi"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
regv1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/partial"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/rck/unit"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
Expand All @@ -31,6 +38,10 @@ func imageBuildCommand() *cobra.Command {
var consoleDir string
var resetMachineID bool

var push bool
var noCache bool
var buildId string

pullPolicy := PullPolicyIfNotExist

buildCmd := &cobra.Command{
Expand All @@ -55,6 +66,23 @@ func imageBuildCommand() *cobra.Command {
}
defer v.ForceDisconnect()

var existingTargetImage regv1.Image
var existingTargetRef name.Reference
if push {
existingTargetRef, err = name.ParseReference(args[1], name.WithDefaultRegistry(""))
if err != nil {
log.WithError(err).Fatal("failed to parse destination ref")
}

err = remote.CheckPushPermission(existingTargetRef, authn.DefaultKeychain, http.DefaultTransport)
if err != nil {
log.WithError(err).Fatal("not allowed to push")
}

// We deliberately ignore errors here, probably just tells us that the image doesn't exist yet.
existingTargetImage, _ = remote.Image(existingTargetRef, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx))
}

extraAuthorizedKeys, err := extraAuthorizedKeys()
if err != nil {
log.Fatal(err)
Expand All @@ -81,6 +109,41 @@ func imageBuildCommand() *cobra.Command {

p.Wait()

provOpt := virter.ProvisionOption{
FilePath: provisionFile,
Overrides: provisionOverrides,
}

provisionConfig, err := virter.NewProvisionConfig(provOpt)
if err != nil {
log.Fatal(err)
}

if push && buildId == "" {
log.Info("Pushing without providing a build ID. Images will always be rebuilt unless the same build ID is given.")
}

if buildId != "" && existingTargetImage != nil && !noCache {
unchanged, err := provisionStepsUnchanged(baseImage, existingTargetImage, buildId)
if err != nil {
log.WithError(err).Warn("error comparing existing target image, assuming provision steps changed")
} else if unchanged {
log.Info("Image already up-to-date, skipping provision, pulling instead")

p := mpb.NewWithContext(ctx)

_, err := GetLocalImage(ctx, newImageName, args[1], v, PullPolicyAlways, DefaultProgressFormat(p))
if err != nil {
log.Fatal(err)
}

p.Wait()

fmt.Printf("Built %s\n", newImageName)
return
}
}

// ContainerProvider will be set later if needed
tools := virter.ImageBuildTools{
ShellClientBuilder: SSHClientBuilder{},
Expand All @@ -106,15 +169,6 @@ func imageBuildCommand() *cobra.Command {

containerName := "virter-build-" + newImageName

provOpt := virter.ProvisionOption{
FilePath: provisionFile,
Overrides: provisionOverrides,
}

provisionConfig, err := virter.NewProvisionConfig(provOpt)
if err != nil {
log.Fatal(err)
}
if provisionConfig.NeedsContainers() {
containerProvider, err := containerapi.NewProvider(ctx, containerProvider())
if err != nil {
Expand All @@ -138,6 +192,29 @@ func imageBuildCommand() *cobra.Command {
log.Fatalf("Failed to build image: %v", err)
}

if push {
localImg, err := v.FindImage(newImageName, virter.WithProgress(DefaultProgressFormat(p)))
if err != nil {
log.Fatalf("failed to find built image: %v", err)
}

if localImg == nil {
log.Fatal("failed to find built image: not found")
}

imageWithHistory := &historyShimImage{
Image: localImg,
history: []regv1.History{
{Comment: buildId},
},
}

err = remote.Write(existingTargetRef, imageWithHistory, remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithContext(ctx))
if err != nil {
log.Fatalf("failed to push image: %v", err)
}
}

p.Wait()

fmt.Printf("Built %s\n", newImageName)
Expand All @@ -156,6 +233,103 @@ func imageBuildCommand() *cobra.Command {
buildCmd.Flags().StringVarP(&consoleDir, "console", "c", "", "Directory to save the VMs console outputs to")
buildCmd.Flags().BoolVar(&resetMachineID, "reset-machine-id", true, "Whether or not to clear the /etc/machine-id file after provisioning")
buildCmd.Flags().VarP(&pullPolicy, "pull-policy", "", fmt.Sprintf("Whether or not to pull the source image. Valid values: [%s, %s, %s]", PullPolicyAlways, PullPolicyIfNotExist, PullPolicyNever))
buildCmd.Flags().BoolVarP(&push, "push", "", false, "Push the image after building")
buildCmd.Flags().BoolVarP(&noCache, "no-cache", "", false, "Disable caching for the image build")
buildCmd.Flags().StringVarP(&buildId, "build-id", "", "", "Build ID used to determine if an image needs to be rebuild.")

return buildCmd
}

// historyShimImage adds history to an existing image
type historyShimImage struct {
regv1.Image
history []regv1.History
}

func (h *historyShimImage) Size() (int64, error) {
return partial.Size(h)
}

func (h *historyShimImage) ConfigName() (regv1.Hash, error) {
return partial.ConfigName(h)
}

func (h *historyShimImage) ConfigFile() (*regv1.ConfigFile, error) {
original, err := h.Image.ConfigFile()
if err != nil {
return nil, err
}

original.History = h.history
return original, err
}

func (h *historyShimImage) RawConfigFile() ([]byte, error) {
return partial.RawConfigFile(h)
}

func (h *historyShimImage) Digest() (regv1.Hash, error) {
return partial.Digest(h)
}

func (h *historyShimImage) Manifest() (*regv1.Manifest, error) {
original, err := h.Image.Manifest()
if err != nil {
return nil, err
}

raw, err := h.RawConfigFile()
if err != nil {
return nil, err
}

cfgHash, cfgSize, err := regv1.SHA256(bytes.NewReader(raw))
if err != nil {
return nil, err
}

original.Config = regv1.Descriptor{
MediaType: virter.ImageMediaType,
Size: cfgSize,
Digest: cfgHash,
}

return original, nil
}

func (h *historyShimImage) RawManifest() ([]byte, error) {
return partial.RawManifest(h)
}

var _ regv1.Image = &historyShimImage{}

func provisionStepsUnchanged(baseImage *virter.LocalImage, targetImage regv1.Image, expectedHistory string) (bool, error) {
targetCfg, err := targetImage.ConfigFile()
if err != nil {
return false, err
}

if len(targetCfg.History) == 0 {
// No history information, image wasn't provision with (new) virter
return false, nil
}

lastHistoryEntry := targetCfg.History[len(targetCfg.History)-1]
if string(expectedHistory) != lastHistoryEntry.Comment {
return false, nil
}

if len(targetCfg.RootFS.DiffIDs) < 2 {
// There doesn't seem to be a base layer for this image
return false, nil
}

targetBaseImageID := targetCfg.RootFS.DiffIDs[len(targetCfg.RootFS.DiffIDs)-2]

currentBaseImageID, err := baseImage.TopLayer().DiffID()
if err != nil {
return false, err
}

return targetBaseImageID == currentBaseImageID, nil
}
28 changes: 28 additions & 0 deletions doc/provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,31 @@ This is especially useful when combined with a provisioning configuration file t
```shell
$ virter vm exec -p provisioning.toml --set env.foo=bar centos-1 centos-2 centos-3
```

## Caching provision images

You can directly push your provision image to a registry using the `--push` option:

```
$ virter image build ubuntu-focal registry.example.com/my-image:latest --push
```

You can skip rebuilding the same image every time you run `virter image build` by specifying a `--build-id` when using
`--push`:

```
$ virter image build ubuntu-focal registry.example.com/my-image:latest --push --build-id my-latest-build
```

The build ID acts as a cache key: as long the build ID is the same for every `virter image build` command, virter
will re-use the previously provisioned image. If the build ID changes or the current build would use a different
base image virter will re-run the provisioning, even if the build ID remains the same.

For example, if you only want to rerun provisioning if the provisioning config was changed, you can run
```
$ virter image build ubuntu-focal registry.example.com/my-image:latest --push --build-id $(sha256sum provision.toml) -p provision.toml
$ # Alternatively, if you are running in a git repository, you could use:
$ virter image build ubuntu-focal registry.example.com/my-image:latest --push --build-id $(git rev-list -1 HEAD -- provision.toml) -p provision.toml
```

To always rebuild the image, even if using `--build-id`, use the `--no-cache` flag.

0 comments on commit 5e75e93

Please sign in to comment.