Skip to content

Commit

Permalink
Update fetcher to update distroless images (#931)
Browse files Browse the repository at this point in the history
  • Loading branch information
pkwarren authored Nov 30, 2023
1 parent 3350f05 commit bd58cdf
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 93 deletions.
113 changes: 20 additions & 93 deletions internal/cmd/fetcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"go.uber.org/multierr"
"golang.org/x/mod/semver"

"github.com/bufbuild/plugins/internal/docker"
"github.com/bufbuild/plugins/internal/fetchclient"
"github.com/bufbuild/plugins/internal/source"
)
Expand Down Expand Up @@ -144,7 +145,11 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) {
defer func() {
log.Printf("finished running in: %.2fs\n", time.Since(now).Seconds())
}()
latestBaseImageVersions, err := getLatestBaseImageVersions(root)
baseImageDir, err := docker.FindBaseImageDir(root)
if err != nil {
return nil, err
}
latestBaseImageVersions, err := docker.LoadLatestBaseImages(baseImageDir)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -200,89 +205,6 @@ func run(ctx context.Context, root string) ([]createdPlugin, error) {
return created, nil
}

func getLatestBaseImageVersions(basedir string) (_ map[string]string, retErr error) {
// Walk up from plugins dir to find .github dir
rootDir := basedir
var githubDir string
for {
githubDir = filepath.Join(rootDir, ".github")
if st, err := os.Stat(githubDir); err == nil && st.IsDir() {
break
}
newRootDir := filepath.Dir(filepath.Dir(githubDir))
if newRootDir == rootDir {
return nil, fmt.Errorf("failed to find .github directory from %s", basedir)
}
rootDir = newRootDir
}
dockerDir := filepath.Join(githubDir, "docker")
d, err := os.Open(dockerDir)
if err != nil {
return nil, err
}
defer func() {
retErr = errors.Join(retErr, d.Close())
}()
entries, err := d.ReadDir(-1)
if err != nil {
return nil, err
}
latestVersions := make(map[string]string, len(entries))
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "Dockerfile.") {
continue
}
imageName, version, err := parseDockerfileBaseImageNameVersion(filepath.Join(dockerDir, entry.Name()))
if err != nil {
return nil, err
}
latestVersions[imageName] = version
}
return latestVersions, nil
}

func parseDockerfileBaseImageNameVersion(dockerfile string) (_ string, _ string, retErr error) {
f, err := os.Open(dockerfile)
if err != nil {
return "", "", nil
}
defer func() {
retErr = errors.Join(retErr, f.Close())
}()
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if !strings.EqualFold(fields[0], "from") {
continue
}
var image string
for i := 1; i < len(fields); i++ {
if strings.HasPrefix(fields[i], "--") {
// Ignore --platform and other args
continue
}
image = fields[i]
break
}
if image == "" {
return "", "", fmt.Errorf("missing image in FROM: %q", line)
}
imageName, version, found := strings.Cut(image, ":")
if !found {
return "", "", fmt.Errorf("invalid FROM line: %q", line)
}
return imageName, version, nil
}
if err := s.Err(); err != nil {
return "", "", err
}
return "", "", fmt.Errorf("failed to detect base image in %s", dockerfile)
}

// copyDirectory copies all files from the source directory to the target,
// creating the target directory if it does not exist.
// If the source directory contains subdirectories this function returns an error.
Expand All @@ -291,7 +213,7 @@ func copyDirectory(
target string,
prevVersion string,
newVersion string,
latestBaseImageVersions map[string]string,
latestBaseImages *docker.BaseImages,
) (retErr error) {
entries, err := os.ReadDir(source)
if err != nil {
Expand All @@ -314,7 +236,7 @@ func copyDirectory(
filepath.Join(target, file.Name()),
prevVersion,
newVersion,
latestBaseImageVersions,
latestBaseImages,
); err != nil {
return err
}
Expand All @@ -326,7 +248,7 @@ func createPluginDir(
dir string,
previousVersion string,
newVersion string,
latestBaseImageVersions map[string]string,
latestBaseImages *docker.BaseImages,
) (retErr error) {
if err := os.Mkdir(filepath.Join(dir, newVersion), 0755); err != nil {
return err
Expand All @@ -341,7 +263,7 @@ func createPluginDir(
filepath.Join(dir, newVersion),
previousVersion,
newVersion,
latestBaseImageVersions,
latestBaseImages,
)
}

Expand All @@ -350,7 +272,7 @@ func copyFile(
dest string,
prevVersion string,
newVersion string,
latestBaseImageVersions map[string]string,
latestBaseImages *docker.BaseImages,
) (retErr error) {
srcFile, err := os.Open(src)
if err != nil {
Expand Down Expand Up @@ -380,13 +302,17 @@ func copyFile(
isDockerfile := strings.HasPrefix(filename, "Dockerfile")
prevVersion = strings.TrimPrefix(prevVersion, "v")
newVersion = strings.TrimPrefix(newVersion, "v")
latestBazelVersion := latestBaseImages.ImageVersion(bazelImageName)
if latestBazelVersion == "" {
return fmt.Errorf("failed to find latest version for bazel image %q", bazelImageName)
}
s := bufio.NewScanner(srcFile)
for s.Scan() {
line := strings.ReplaceAll(s.Text(), prevVersion, newVersion)
line = bazelDownloadRegexp.ReplaceAllString(
line,
fmt.Sprintf(`bazelbuild/bazel/releases/download/%[1]s/bazel-%[1]s-linux`,
latestBaseImageVersions[bazelImageName],
latestBazelVersion,
),
)
if isDockerfile && len(line) > 5 && strings.EqualFold(line[0:5], "from ") {
Expand All @@ -401,9 +327,10 @@ func copyFile(
break
}
}
if name, _, found := strings.Cut(image, ":"); found {
if newVersion := latestBaseImageVersions[name]; newVersion != "" {
fields[imageIndex] = name + ":" + newVersion
name, _, _ := strings.Cut(image, ":")
if name != "" {
if newImageNameAndVersion := latestBaseImages.ImageNameAndVersion(name); newImageNameAndVersion != "" {
fields[imageIndex] = newImageNameAndVersion
line = strings.Join(fields, " ")
}
}
Expand Down
170 changes: 170 additions & 0 deletions internal/docker/baseimage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package docker

import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)

var (
distrolessNamePrefix = "gcr.io/distroless/"
)

// BaseImages contains state about the latest versions of base images in the .github/docker directory.
// These are automatically kept up to date by dependabot.
type BaseImages struct {
latestVersions map[string]string
latestDistrolessImageNames map[string]string
}

// ImageNameAndVersion returns the latest image name and version (if tracked in the .github/docker directory).
// For example, passing "debian" will return "debian:bookworm-yyyyMMdd" (where "yyyyMMdd" is the latest image date).
// If the image is not tracked in the .github/docker directory, returns an empty string.
// This is used to automate updating Dockerfile base image versions when fetching new versions of plugins.
func (b *BaseImages) ImageNameAndVersion(imageName string) string {
latestImageName := imageName
if nameWithoutVersions := distrolessImageNameWithoutVersions(imageName); nameWithoutVersions != "" {
latestImageName = b.latestDistrolessImageNames[nameWithoutVersions]
}
latestVersion, ok := b.latestVersions[latestImageName]
if !ok {
return ""
}
return latestImageName + ":" + latestVersion
}

// ImageVersion returns the latest version for the image name (if tracked in the .github/docker directory).
// For example, passing "debian" will return "bookworm-yyyyMMdd" (where "yyyyMMdd" is the latest image date).
// If the image is not tracked in the .github/docker directory, returns an empty string.
func (b *BaseImages) ImageVersion(imageName string) string {
latestImageName := imageName
if nameWithoutVersions := distrolessImageNameWithoutVersions(imageName); nameWithoutVersions != "" {
latestImageName = b.latestDistrolessImageNames[nameWithoutVersions]
}
return b.latestVersions[latestImageName]
}

// FindBaseImageDir looks for the .github/docker folder starting from basedir.
// It continues to search through parent directories till found (or at the root).
func FindBaseImageDir(basedir string) (string, error) {
// Walk up from plugins dir to find .github dir
rootDir, err := filepath.Abs(basedir)
if err != nil {
return "", err
}
var dockerDir string
for {
dockerDir = filepath.Join(rootDir, ".github", "docker")
if st, err := os.Stat(dockerDir); err == nil && st.IsDir() {
break
}
newRootDir := filepath.Dir(rootDir)
if newRootDir == rootDir {
return "", fmt.Errorf("failed to find .github directory from %s", basedir)
}
rootDir = newRootDir
}
return dockerDir, nil
}

// LoadLatestBaseImages returns the latest base image information from images found in the .github/docker directory.
func LoadLatestBaseImages(baseImageDir string) (_ *BaseImages, retErr error) {
d, err := os.Open(baseImageDir)
if err != nil {
return nil, err
}
defer func() {
retErr = errors.Join(retErr, d.Close())
}()
entries, err := d.ReadDir(-1)
if err != nil {
return nil, err
}
latestVersions := make(map[string]string, len(entries))
latestDistrolessImages := make(map[string]string)
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "Dockerfile") {
continue
}
imageName, version, err := parseDockerfileBaseImageNameVersion(filepath.Join(baseImageDir, entry.Name()))
if err != nil {
return nil, err
}
if _, ok := latestVersions[imageName]; ok {
return nil, fmt.Errorf("found duplicate dockerfiles for image %q", imageName)
}
latestVersions[imageName] = version
if imageNameWithoutVersions := distrolessImageNameWithoutVersions(imageName); imageNameWithoutVersions != "" {
if _, ok := latestDistrolessImages[imageNameWithoutVersions]; ok {
return nil, fmt.Errorf("found duplicate distroless dockerfiles for image %q", imageNameWithoutVersions)
}
latestDistrolessImages[imageNameWithoutVersions] = imageName
}
}
return &BaseImages{
latestVersions: latestVersions,
latestDistrolessImageNames: latestDistrolessImages,
}, nil
}

func parseDockerfileBaseImageNameVersion(dockerfile string) (_ string, _ string, retErr error) {
f, err := os.Open(dockerfile)
if err != nil {
return "", "", nil
}
defer func() {
retErr = errors.Join(retErr, f.Close())
}()
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
if !strings.EqualFold(fields[0], "from") {
continue
}
var image string
for i := 1; i < len(fields); i++ {
if strings.HasPrefix(fields[i], "--") {
// Ignore --platform and other args
continue
}
image = fields[i]
break
}
if image == "" {
return "", "", fmt.Errorf("missing image in FROM: %q", line)
}
imageName, version, found := strings.Cut(image, ":")
if !found {
return "", "", fmt.Errorf("invalid FROM line: %q", line)
}
return imageName, version, nil
}
if err := s.Err(); err != nil {
return "", "", err
}
return "", "", fmt.Errorf("failed to detect base image in %s", dockerfile)
}

// distrolessImageNameWithoutVersions returns a distroless image name without version numbers.
// If the passed in image name is a distroless image and contains versions, it returns the name without versions.
// Otherwise, it returns an empty string.
func distrolessImageNameWithoutVersions(nameWithVersions string) string {
if !strings.HasPrefix(nameWithVersions, distrolessNamePrefix) || !strings.ContainsFunc(nameWithVersions, unicode.IsDigit) {
return ""
}
var sb strings.Builder
for _, r := range nameWithVersions {
if !unicode.IsDigit(r) {
sb.WriteRune(r)
}
}
return sb.String()
}
Loading

0 comments on commit bd58cdf

Please sign in to comment.