Skip to content
This repository has been archived by the owner on Aug 6, 2021. It is now read-only.

forced SHA pinning #59

Merged
merged 5 commits into from
Jan 21, 2021
Merged
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
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ inputs:
pattern: /.*aws.*/
range: >= 2.0.0, <3.0.0
required: false
sha_pinning:
description: 'Replace images with pinned shasums'
required: false
default: false
runs:
using: "composite"
steps:
Expand All @@ -57,3 +61,4 @@ runs:
INPUT_TOKEN: ${{ inputs.token }}
INPUT_LOG_LEVEL: ${{ inputs.log_level }}
INPUT_IGNORE: ${{ inputs.ignore }}
INPUT_SHA_PINNING: ${{ inputs.sha_pinning }}
54 changes: 43 additions & 11 deletions docker/applyupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package docker

import (
"context"
"io"
"fmt"
"io/ioutil"
"os"
"regexp"
"strings"

"github.com/moby/buildkit/frontend/dockerfile/command"
Expand All @@ -13,7 +14,18 @@ import (
"golang.org/x/mod/semver"
)

func (u *Updater) ApplyUpdate(_ context.Context, update updater.Update) error {
func (u *Updater) ApplyUpdate(ctx context.Context, update updater.Update) error {
var nextVersion string
if u.pinImageSha {
pinned, err := u.pinner.Pin(ctx, fmt.Sprintf("%s:%s", update.Path, update.Next))
if err != nil {
return fmt.Errorf("pinning image: %w", err)
}
nextVersion = pinned
} else {
nextVersion = update.Next
}

return WalkDockerfiles(u.root, u.pathFilter, func(path string, parsed *parser.Result) error {
vars := NewInterpolation(parsed)

Expand All @@ -28,8 +40,31 @@ func (u *Updater) ApplyUpdate(_ context.Context, update updater.Update) error {
if dep == nil || dep.Path != update.Path {
continue
}
// Ignore FROM statements with a variable:
if !strings.Contains(instruction.Original, "$") {
oldnew = append(oldnew, instruction.Original, strings.ReplaceAll(instruction.Original, update.Previous, update.Next))
re := regexp.MustCompile(fmt.Sprintf(`%s[:@][^\s]*`, regexp.QuoteMeta(update.Path)))
var replacement string
if u.pinImageSha {
replacement = fmt.Sprintf("%s@%s", update.Path, nextVersion)
} else {
replacement = fmt.Sprintf("%s:%s", update.Path, nextVersion)
}
newInstruction := re.ReplaceAllString(instruction.Original, replacement)

oldVersion := fmt.Sprintf("%s:%s", update.Path, update.Previous)
newVersion := fmt.Sprintf("%s:%s", update.Path, update.Next)
var commentFound bool
for _, comment := range instruction.PrevComment {
if strings.Contains(comment, oldVersion) {
comment = fmt.Sprintf("# %s", comment)
oldnew = append(oldnew, comment, re.ReplaceAllString(comment, newVersion))
commentFound = true
}
}
if u.pinImageSha && !commentFound {
newInstruction = fmt.Sprintf("# %s\n%s", newVersion, newInstruction)
}
oldnew = append(oldnew, instruction.Original, newInstruction)
}
case command.Arg:
if seenFrom {
Expand All @@ -43,7 +78,7 @@ func (u *Updater) ApplyUpdate(_ context.Context, update updater.Update) error {

if varSplit[1] == update.Previous {
// Variable is exact version, direct replace
oldnew = append(oldnew, instruction.Original, strings.ReplaceAll(instruction.Original, update.Previous, update.Next))
oldnew = append(oldnew, instruction.Original, strings.ReplaceAll(instruction.Original, update.Previous, nextVersion))
continue
}

Expand All @@ -67,20 +102,17 @@ func (u *Updater) ApplyUpdate(_ context.Context, update updater.Update) error {
}

// Read file into memory:
f, err := os.OpenFile(path, os.O_RDWR, 0640)
if err != nil {
return err
}
defer f.Close()
b, err := ioutil.ReadAll(f)
b, err := ioutil.ReadFile(path)
if err != nil {
return err
}

// Rewrite contents through replacer:
if _, err := f.Seek(0, io.SeekStart); err != nil {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
}
defer f.Close()
if _, err := strings.NewReplacer(oldnew...).WriteString(f, string(b)); err != nil {
return err
}
Expand Down
42 changes: 39 additions & 3 deletions docker/applyupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,31 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/thepwagner/action-update-docker/docker"
"github.com/thepwagner/action-update/updater"
"github.com/thepwagner/action-update/updatertest"
)

var (
alpine3120 = updater.Update{Path: "alpine", Previous: "3.11.0", Next: "3.12.0"}
redis608 = updater.Update{Path: "redis", Previous: "6.0.0-alpine", Next: "6.0.8-alpine"}
//alpine3120Pinned = updater.Update{Path: "alpine", Previous: "sha256:7c92a2c6bbcb6b6beff92d0a940779769c2477b807c202954c537e2e0deb9bed", Next: "3.12.0"}
redis608 = updater.Update{Path: "redis", Previous: "6.0.0-alpine", Next: "6.0.8-alpine"}
)

func TestUpdater_ApplyUpdate_Simple(t *testing.T) {
dockerfile := applyUpdateToFixture(t, "simple", alpine3120)
assert.Contains(t, dockerfile, "alpine:3.12.0")
assert.NotContains(t, dockerfile, "# alpine:3.12.0\n")
assert.NotContains(t, dockerfile, "alpine:3.11.0")
}

func TestUpdater_ApplyUpdate_Simple_Pinned(t *testing.T) {
dockerfile := applyUpdateToFixture(t, "simple", alpine3120, withShaPinning("sha256:pinned")...)
assert.Contains(t, dockerfile, "alpine@sha256:pinned")
assert.Contains(t, dockerfile, "# alpine:3.12.0\n")
assert.NotContains(t, dockerfile, "FROM alpine:3.12.0")
assert.NotContains(t, dockerfile, "alpine:3.11.0")
}

Expand All @@ -43,10 +55,34 @@ func TestUpdater_ApplyUpdate_Comments(t *testing.T) {
assert.Contains(t, dockerfile, "# check out this whitespace\n\n\n# intentional trailing spaces \n")
}

func applyUpdateToFixture(t *testing.T, fixture string, update updater.Update) string {
tempDir := updatertest.ApplyUpdateToFixture(t, fixture, &testFactory{}, update)
func TestUpdater_ApplyUpdate_Pinned(t *testing.T) {
dockerfile := applyUpdateToFixture(t, "pinned", alpine3120)
assert.Contains(t, dockerfile, "FROM alpine:3.12.0")
assert.Contains(t, dockerfile, "# alpine:3.12.0\n")
assert.NotContains(t, dockerfile, "alpine:3.11.0")
}
func TestUpdater_ApplyUpdate_Pinned_Pinned(t *testing.T) {
dockerfile := applyUpdateToFixture(t, "pinned", alpine3120, withShaPinning("sha256:pinned")...)
assert.Contains(t, dockerfile, "alpine@sha256:pinned")
assert.Contains(t, dockerfile, "# alpine:3.12.0\n")
assert.NotContains(t, dockerfile, "FROM alpine:3.12.0")
assert.NotContains(t, dockerfile, "alpine:3.11.0")
}

func applyUpdateToFixture(t *testing.T, fixture string, update updater.Update, opts ...docker.UpdaterOpt) string {
tempDir := updatertest.ApplyUpdateToFixture(t, fixture, updaterFactory(opts...), update)
b, err := ioutil.ReadFile(filepath.Join(tempDir, "Dockerfile"))
require.NoError(t, err)
dockerfile := string(b)
t.Log(dockerfile)
return dockerfile
}

func withShaPinning(pinned string) []docker.UpdaterOpt {
mockPinner := &mockImagePinner{}
mockPinner.On("Pin", mock.Anything, mock.Anything).Return(pinned, nil)
return []docker.UpdaterOpt{
docker.WithShaPinning(true),
docker.WithImagePinner(mockPinner),
}
}
43 changes: 31 additions & 12 deletions docker/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package docker
import (
"context"
"fmt"
"regexp"
"sort"
"strings"

Expand All @@ -14,8 +15,15 @@ import (
func (u *Updater) Check(ctx context.Context, dependency updater.Dependency, filter func(string) bool) (*updater.Update, error) {
previous := semverIsh(dependency.Version)
if previous == "" {
logrus.WithFields(logrus.Fields{"path": dependency.Path, "version": dependency.Version}).Debug("ignoring non-semver dependency")
return nil, nil
if !sha256Ish(dependency.Version) {
logrus.WithFields(logrus.Fields{"path": dependency.Path, "version": dependency.Version}).Debug("ignoring non-semver dependency")
return nil, nil
}
tag, err := u.pinner.Unpin(ctx, dependency.Path, dependency.Version)
if err != nil {
return nil, fmt.Errorf("unpinning %q: %w", dependency.Path, err)
}
previous = tag
}
suffix := semver.Prerelease(previous)

Expand Down Expand Up @@ -50,16 +58,7 @@ func (u *Updater) Check(ctx context.Context, dependency updater.Dependency, filt
return nil, nil
}

sort.Slice(versions, func(i, j int) bool {
// Prefer strict semver ordering:
if c := semver.Compare(versions[i], versions[j]); c > 0 {
return true
} else if c < 0 {
return false
}
// Failing that, prefer the most specific version:
return strings.Count(versions[i], ".") > strings.Count(versions[j], ".")
})
versions = semverSort(versions)
latest := versions[0]
if semver.Compare(previous, latest) >= 0 {
return nil, nil
Expand All @@ -72,6 +71,20 @@ func (u *Updater) Check(ctx context.Context, dependency updater.Dependency, filt
}, nil
}

func semverSort(versions []string) []string {
sort.Slice(versions, func(i, j int) bool {
// Prefer strict semver ordering:
if c := semver.Compare(semverIsh(versions[i]), semverIsh(versions[j])); c > 0 {
return true
} else if c < 0 {
return false
}
// Failing that, prefer the most specific version:
return strings.Count(versions[i], ".") > strings.Count(versions[j], ".")
})
return versions
}

func semverIsh(s string) string {
if semver.IsValid(s) {
return s
Expand All @@ -82,3 +95,9 @@ func semverIsh(s string) string {
}
return ""
}

var sha256VersionRE = regexp.MustCompile("sha256:[a-f0-9]{64}")

func sha256Ish(s string) bool {
return sha256VersionRE.MatchString(s)
}
8 changes: 8 additions & 0 deletions docker/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ func TestUpdater_CheckAuth(t *testing.T) {
assert.Equal(t, "3.12.0", u.Next)
}

func TestUpdater_CheckAuthPinned(t *testing.T) {
t.Skip("early integration test")
var privateImage = updater.Dependency{Path: "ghcr.io/thepwagner/alpine", Version: "sha256:d371657a4f661a854ff050898003f4cb6c7f36d968a943c1d5cde0952bd93c80"}
u := updatertest.CheckInFixture(t, "pinned", updaterFactory(), privateImage, nil)
assert.NotNil(t, u)
assert.Equal(t, "3.12.0", u.Next)
}

func TestUpdater_Check(t *testing.T) {
cases := map[string]struct {
dep updater.Dependency
Expand Down
5 changes: 5 additions & 0 deletions docker/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package docker
import (
"context"
"fmt"
"regexp"
"strings"

"github.com/moby/buildkit/frontend/dockerfile/command"
Expand Down Expand Up @@ -46,6 +47,8 @@ func extractImages(parsed *parser.Result) ([]updater.Dependency, error) {
return deps, nil
}

var sha256RE = regexp.MustCompile("[a-f0-9]{64}")

func parseDependency(vars *Interpolation, image string) *updater.Dependency {
imageSplit := strings.SplitN(image, ":", 2)
if len(imageSplit) == 1 {
Expand All @@ -62,6 +65,8 @@ func parseDependency(vars *Interpolation, image string) *updater.Dependency {
} else if semverIsh(imageSplit[1]) != "" {
// Image tag is valid semver:
return &updater.Dependency{Path: imageSplit[0], Version: imageSplit[1]}
} else if strings.HasSuffix(imageSplit[0], "@sha256") && sha256RE.MatchString(imageSplit[1]) {
return &updater.Dependency{Path: imageSplit[0][:len(imageSplit[0])-7], Version: fmt.Sprintf("sha256:%s", imageSplit[1])}
}
return nil
}
3 changes: 3 additions & 0 deletions docker/dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ func TestUpdater_Dependencies(t *testing.T) {
{Path: "redis", Version: "6.0.0-alpine"},
{Path: "alpine", Version: "3.11.0"},
},
"pinned": {
{Path: "alpine", Version: "sha256:7c92a2c6bbcb6b6beff92d0a940779769c2477b807c202954c537e2e0deb9bed"},
},
}
updatertest.DependenciesFixtures(t, updaterFactory(), cases)
}
3 changes: 2 additions & 1 deletion docker/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import (

type Environment struct {
updateaction.Environment
ShaPinning bool `env:"INPUT_SHA_PINNING" envDefault:"false"`
}

func (c *Environment) NewUpdater(root string) updater.Updater {
u := NewUpdater(root)
u := NewUpdater(root, WithShaPinning(c.ShaPinning))
u.pathFilter = c.Ignored
return u
}
56 changes: 56 additions & 0 deletions docker/mockimagepinner_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading