From 02bc02103f703a827c67c6c818f8c8c7e16395d7 Mon Sep 17 00:00:00 2001 From: Christopher Hlubek Date: Tue, 21 May 2024 18:47:08 +0200 Subject: [PATCH] Implemented first version --- .envrc | 7 + .github/workflows/release.yml | 42 +++++ .github/workflows/test.yml | 60 ++++++ .gitignore | 20 ++ .goreleaser.yml | 69 +++++++ Dockerfile | 4 + LICENSE | 21 +++ README.md | 13 ++ cmd/main.go | 333 ++++++++++++++++++++++++++++++++++ devbox.json | 14 ++ devbox.lock | 53 ++++++ go.mod | 44 +++++ go.sum | 128 +++++++++++++ yaml/patcher.go | 287 +++++++++++++++++++++++++++++ yaml/patcher_test.go | 242 ++++++++++++++++++++++++ 15 files changed, 1337 insertions(+) create mode 100644 .envrc create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/main.go create mode 100644 devbox.json create mode 100644 devbox.lock create mode 100644 go.mod create mode 100644 go.sum create mode 100644 yaml/patcher.go create mode 100644 yaml/patcher_test.go diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..84fc8e5 --- /dev/null +++ b/.envrc @@ -0,0 +1,7 @@ +# Automatically sets up your devbox environment whenever you cd into this +# directory via our direnv integration: + +eval "$(devbox generate direnv --print-envrc)" + +# check out https://www.jetpack.io/devbox/docs/ide_configuration/direnv/ +# for more details diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..53ff3a2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - + name: Fetch all tags + run: git fetch --force --tags + - name: Docker Login + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.22.x + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb63073 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,60 @@ +on: + push: + tags: + - v* + branches: + - main + pull_request: + +name: run tests +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@v3 + with: + go-version: 1.22.x + - name: Checkout code + uses: actions/checkout@v3 + - name: Run linters + uses: golangci/golangci-lint-action@v3 + + test: + strategy: + matrix: + go-version: [ '1.22.x' ] + os: [ 'ubuntu-latest', 'macos-latest' ] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + if: success() + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v3 + - name: Run tests + run: go test -v ./... + +# coverage: +# runs-on: ubuntu-latest +# steps: +# - name: Install Go +# if: success() +# uses: actions/setup-go@v2 +# with: +# go-version: 1.15.x +# - name: Checkout code +# uses: actions/checkout@v2 +# - name: Calc coverage +# run: | +# go test -v -covermode=count -coverprofile=coverage.out ./... +# - name: Convert coverage.out to coverage.lcov +# uses: jandelgado/gcov2lcov-action@v1.0.6 +# - name: Coveralls +# uses: coverallsapp/github-action@v1.1.2 +# with: +# github-token: ${{ secrets.github_token }} +# path-to-lcov: coverage.lcov +# \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cf3855 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# devbox +.devbox + +/tmp \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..924be33 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,69 @@ +before: + hooks: + - go mod tidy +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + main: ./cmd +archives: + - name_template: >- + {{ .ProjectName }}_ + {{ .Version }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} +dockers: + - image_templates: + - ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }}-amd64 + dockerfile: Dockerfile + use: buildx + build_flag_templates: + - --platform=linux/amd64 + - --label=org.opencontainers.image.title={{ .ProjectName }} + - --label=org.opencontainers.image.description={{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/networkteam/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/networkteam/{{ .ProjectName }} + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=MIT + - image_templates: + - ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }}-arm64v8 + goarch: arm64 + dockerfile: Dockerfile + use: buildx + build_flag_templates: + - --platform=linux/arm64/v8 + - --label=org.opencontainers.image.title={{ .ProjectName }} + - --label=org.opencontainers.image.description={{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/networkteam/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/networkteam/{{ .ProjectName }} + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=MIT +docker_manifests: + - name_template: ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }} + image_templates: + - ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }}-amd64 + - ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }}-arm64v8 + - name_template: ghcr.io/networkteam/{{ .ProjectName }}:latest + image_templates: + - ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }}-amd64 + - ghcr.io/networkteam/{{ .ProjectName }}:{{ .Version }}-arm64v8 +checksum: + name_template: "checksums.txt" +snapshot: + name_template: "{{ incpatch .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7e39748 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3.19 +ENTRYPOINT ["/stacker"] +STOPSIGNAL SIGINT +COPY stacker /stacker diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a158d70 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 networkteam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e748d71 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Stacker + +A tool to rebase container (Docker) images instead of rebuilding them. + +It is based around the idea of OCI annotations for base images to just change the layers of the base image if a newer version is available. + +## Acknowledgements + +This tool heavily uses https://github.com/google/go-containerregistry/tree/main/cmd/crane for the image manipulation. + +## License + +[MIT](./LICENSE) diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..7e01e9f --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/crane" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/hashicorp/go-multierror" + "github.com/networkteam/slogutils" + specsv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/urfave/cli/v2" + + "github.com/networkteam/stacker/yaml" +) + +type ctxKey int + +const ( + ctxKeyConfig ctxKey = iota +) + +func main() { + app := cli.NewApp() + app.Name = "stacker" + app.Usage = "Automatic rebasing of images using OCI base image annotations" + app.Flags = []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Enable verbose logging", + }, + &cli.BoolFlag{ + Name: "super-verbose", + Aliases: []string{"vv"}, + Usage: "Enable super verbose logging", + }, + } + app.ArgsUsage = "[directory]" + app.Before = func(c *cli.Context) error { + level := slog.LevelInfo + if c.Bool("verbose") { + level = slog.LevelDebug + } else if c.Bool("super-verbose") { + level = slogutils.LevelTrace + } + + slog.SetDefault(slog.New( + slogutils.NewCLIHandler(os.Stderr, &slogutils.CLIHandlerOptions{ + Level: level, + }), + )) + + return nil + } + app.Description = "Recurses through the given directory to find YAML files with a rebase annotation and rebases the image onto the newest base image." + app.Action = func(c *cli.Context) error { + if c.NArg() != 1 { + return fmt.Errorf("expected exactly one directory argument") + } + + directory := c.Args().First() + + var rebaseErr error + + // Find YAML files in directory + err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + if filepath.Ext(path) != ".yaml" && filepath.Ext(path) != ".yml" { + return nil + } + + // Get path relative to directory + relPath, err := filepath.Rel(directory, path) + + ctx := slogutils.WithLogger(c.Context, slog.With("file", relPath)) + + err = processRebaseAnnotations(ctx, path) + if err != nil { + rebaseErr = multierror.Append(rebaseErr, fmt.Errorf("processing %s: %w", relPath, err)) + } + + return nil + }) + if err != nil { + return multierror.Append(rebaseErr, fmt.Errorf("walking directory: %w", err)) + } + + return rebaseErr + } + + err := app.Run(os.Args) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func processRebaseAnnotations(ctx context.Context, path string) error { + logger := slogutils.FromContext(ctx) + + logger.Log(ctx, slogutils.LevelTrace, "Checking for annotations in file") + + f, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer f.Close() + + patcher, err := yaml.NewPatcher(f) + if err != nil { + return fmt.Errorf("opening YAML: %w", err) + } + + annotations, err := patcher.FindRebaseAnnotations() + if err != nil { + return fmt.Errorf("finding rebase annotations: %w", err) + } + + // Process annotations + + var rebaseErr error + var didRebaseAny bool + + for _, annotation := range annotations { + newDigest, didRebase, err := processRebaseAnnotation(ctx, annotation) + if err != nil { + rebaseErr = multierror.Append(rebaseErr, fmt.Errorf("rebasing image %s:%s: %w", annotation.Name, annotation.TagWithoutDigest(), err)) + } + if !didRebase { + logger.Debug("No-op rebase", "image", annotation.Name, "tag", annotation.TagWithoutDigest()) + continue + } + + logger.Info("Rebased image", "image", annotation.Name, "tag", annotation.TagWithoutDigest(), "newDigest", newDigest) + didRebaseAny = true + + annotation.TagNode.SetString(annotation.TagWithoutDigest() + "@" + newDigest) + } + + if didRebaseAny { + // Write back to file by calling Encode from patcher to file + + err = f.Truncate(0) + if err != nil { + return fmt.Errorf("truncating file: %w", err) + } + _, err = f.Seek(0, 0) + if err != nil { + return fmt.Errorf("seeking to beginning of file: %w", err) + } + + err = patcher.Encode(f) + if err != nil { + return fmt.Errorf("encoding YAML back to file: %w", err) + } + + logger.Info("Wrote back updated YAML to file") + } + + return rebaseErr +} + +func processRebaseAnnotation(ctx context.Context, annotation yaml.RebaseAnnotation) (string, bool, error) { + logger := slogutils.FromContext(ctx).With("image", annotation.Name, "tag", annotation.TagWithoutDigest()) + + logger.Debug("Rebasing") + + var oldBase, newBase, rebased string + + orig := fmt.Sprintf("%s:%s", annotation.Name, annotation.TagWithoutDigest()) + // For now the target is always the same image and tag + rebased = orig + + r, err := name.ParseReference(rebased) + if err != nil { + return "", false, fmt.Errorf("parsing rebased reference: %w", err) + } + + desc, err := crane.Head(orig) + if err != nil { + return "", false, fmt.Errorf("checking: %w", err) + } + + if desc.MediaType.IsIndex() { + return "", false, errors.New("rebasing an index is not yet supported") + } + + // This is from `crane rebase` + + origImg, err := crane.Pull(orig) + if err != nil { + return "", false, fmt.Errorf("pulling image: %w", err) + } + origMf, err := origImg.Manifest() + if err != nil { + return "", false, fmt.Errorf("getting manifest: %w", err) + } + anns := origMf.Annotations + newBase = anns[specsv1.AnnotationBaseImageName] + if newBase == "" { + return "", false, errors.New("could not determine new base image from annotations") + } + newBaseRef, err := name.ParseReference(newBase) + if err != nil { + return "", false, fmt.Errorf("parsing new base reference: %w", err) + } + oldBaseDigest := anns[specsv1.AnnotationBaseImageDigest] + oldBase = newBaseRef.Context().Digest(oldBaseDigest).String() + if oldBase == "" { + return "", false, errors.New("could not determine old base image by digest from annotations") + } + + rebasedImg, err := rebaseImage(ctx, origImg, oldBase, newBase) + if err != nil { + return "", false, fmt.Errorf("rebasing image: %w", err) + } + + rebasedDigest, err := rebasedImg.Digest() + if err != nil { + return "", false, fmt.Errorf("digesting new image: %w", err) + } + origDigest, err := origImg.Digest() + if err != nil { + return "", false, fmt.Errorf("digesting old image: %w", err) + } + + // Check if the image was rebased or we had a no-op rebase + if rebasedDigest == origDigest { + return rebasedDigest.String(), false, nil + } + + if _, ok := r.(name.Digest); ok { + rebased = r.Context().Digest(rebasedDigest.String()).String() + } + + logger.Debug("Pushing rebased image as", "rebased", rebased) + err = crane.Push(rebasedImg, rebased) + if err != nil { + return "", false, fmt.Errorf("pushing %s: %v", rebased, err) + } + + return rebasedDigest.String(), true, nil +} + +// rebaseImage parses the references and uses them to perform a rebase on the +// original image. +// +// If oldBase or newBase are "", rebaseImage attempts to derive them using +// annotations in the original image. If those annotations are not found, +// rebaseImage returns an error. +// +// If rebasing is successful, base image annotations are set on the resulting +// image to facilitate implicit rebasing next time. +func rebaseImage(ctx context.Context, orig v1.Image, oldBase, newBase string, opt ...crane.Option) (v1.Image, error) { + logger := slogutils.FromContext(ctx) + + m, err := orig.Manifest() + if err != nil { + return nil, err + } + if newBase == "" && m.Annotations != nil { + newBase = m.Annotations[specsv1.AnnotationBaseImageName] + if newBase != "" { + logger.Log(ctx, slogutils.LevelTrace, "Detected new base from annotation", "annotationName", specsv1.AnnotationBaseImageName, "newBase", newBase) + } + } + if newBase == "" { + return nil, fmt.Errorf("either new base or %q annotation is required", specsv1.AnnotationBaseImageName) + } + newBaseImg, err := crane.Pull(newBase, opt...) + if err != nil { + return nil, err + } + + if oldBase == "" && m.Annotations != nil { + oldBase = m.Annotations[specsv1.AnnotationBaseImageDigest] + if oldBase != "" { + newBaseRef, err := name.ParseReference(newBase) + if err != nil { + return nil, fmt.Errorf("parsing new base reference: %w", err) + } + + oldBase = newBaseRef.Context().Digest(oldBase).String() + logger.Log(ctx, slogutils.LevelTrace, "Detected old base from annotation", "annotationName", specsv1.AnnotationBaseImageDigest, "oldBase", oldBase) + } + } + if oldBase == "" { + return nil, fmt.Errorf("either old base or %q annotation is required", specsv1.AnnotationBaseImageDigest) + } + + oldBaseImg, err := crane.Pull(oldBase, opt...) + if err != nil { + return nil, fmt.Errorf("pulling old base image: %w", err) + } + + // NB: if newBase is an index, we need to grab the index's digest to + // annotate the resulting image, even though we pull the + // platform-specific image to rebase. + // crane.Digest will pull a platform-specific image, so use crane.Head + // here instead. + newBaseDesc, err := crane.Head(newBase, opt...) + if err != nil { + return nil, fmt.Errorf("getting new base image digest: %w", err) + } + newBaseDigest := newBaseDesc.Digest.String() + + rebased, err := mutate.Rebase(orig, oldBaseImg, newBaseImg) + if err != nil { + return nil, fmt.Errorf("rebasing: %w", err) + } + + // Update base image annotations for the new image manifest. + logger.Log(ctx, slogutils.LevelTrace, "Setting annotation", "annotationName", specsv1.AnnotationBaseImageDigest, "annotationValue", newBaseDigest) + logger.Log(ctx, slogutils.LevelTrace, "Setting annotation", "annotationName", specsv1.AnnotationBaseImageName, "annotationValue", newBase) + return mutate.Annotations(rebased, map[string]string{ + specsv1.AnnotationBaseImageDigest: newBaseDigest, + specsv1.AnnotationBaseImageName: newBase, + }).(v1.Image), nil +} diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..b74d55a --- /dev/null +++ b/devbox.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.10.6/.schema/devbox.schema.json", + "packages": ["go@1.22"], + "shell": { + "init_hook": [ + "echo 'Welcome to devbox!' > /dev/null" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..04f508e --- /dev/null +++ b/devbox.lock @@ -0,0 +1,53 @@ +{ + "lockfile_version": "1", + "packages": { + "go@1.22": { + "last_modified": "2024-05-12T16:19:40Z", + "resolved": "github:NixOS/nixpkgs/3281bec7174f679eabf584591e75979a258d8c40#go", + "source": "devbox-search", + "version": "1.22.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/460vdyz0ghxh8n5ibq3fgc3s63is68cd-go-1.22.2", + "default": true + } + ], + "store_path": "/nix/store/460vdyz0ghxh8n5ibq3fgc3s63is68cd-go-1.22.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/c732580an83by9405c5j2fmn04hp6ry6-go-1.22.2", + "default": true + } + ], + "store_path": "/nix/store/c732580an83by9405c5j2fmn04hp6ry6-go-1.22.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/9cim6f30wrzdbiaw8wa45kvffns73dgz-go-1.22.2", + "default": true + } + ], + "store_path": "/nix/store/9cim6f30wrzdbiaw8wa45kvffns73dgz-go-1.22.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/6bvndddvxaypc42x6x4ari20gv3vfdgd-go-1.22.2", + "default": true + } + ], + "store_path": "/nix/store/6bvndddvxaypc42x6x4ari20gv3vfdgd-go-1.22.2" + } + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2861ee3 --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module github.com/networkteam/stacker + +go 1.22 + +require ( + github.com/google/go-containerregistry v0.19.1 + github.com/hashicorp/go-multierror v1.1.1 + github.com/networkteam/slogutils v0.1.0 + github.com/stretchr/testify v1.8.4 + github.com/urfave/cli/v2 v2.11.1 + github.com/vmware-labs/yaml-jsonpath v0.3.2 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/cli v24.0.0+incompatible // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect + github.com/docker/docker v24.0.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.7.0 // indirect + github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/klauspost/compress v1.16.5 // indirect + github.com/kr/pretty v0.2.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sergi/go-diff v1.3.1 // indirect + github.com/sirupsen/logrus v1.9.1 // indirect + github.com/vbatts/tar-split v0.11.3 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/sync v0.2.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.13.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eb40b8e --- /dev/null +++ b/go.sum @@ -0,0 +1,128 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= +github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v24.0.0+incompatible h1:0+1VshNwBQzQAx9lOl+OYCTCEAD8fKs/qeXMx3O0wqM= +github.com/docker/cli v24.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.0+incompatible h1:z4bf8HvONXX9Tde5lGBMQ7yCJgNahmJumdrStZAbeY4= +github.com/docker/docker v24.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= +github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 h1:aRd8M7HJVZOqn/vhOzrGcQH0lNAMkqMn+pXUYkatmcA= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.19.1 h1:yMQ62Al6/V0Z7CqIrrS1iYoA5/oQCm88DeNujc7C1KY= +github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/networkteam/slogutils v0.1.0 h1:yBtZMekVBFa8MBjK1O9pJaoOg2JnFtjIiRityP0DhsY= +github.com/networkteam/slogutils v0.1.0/go.mod h1:ihaCGGJ8bYqXcZ4Y8/P3wddHz7A+5/aBWlQXwtR9YZI= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2 h1:uqH7bpe+ERSiDa34FDOF7RikN6RzXgduUF8yarlZp94= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0Ys52cJydRwBkb8= +github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ= +github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= +github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE= +github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo= +github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck= +github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= +gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= diff --git a/yaml/patcher.go b/yaml/patcher.go new file mode 100644 index 0000000..15459f2 --- /dev/null +++ b/yaml/patcher.go @@ -0,0 +1,287 @@ +package yaml + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" + goyaml "gopkg.in/yaml.v3" +) + +type Patcher struct { + node *goyaml.Node +} + +func NewPatcher(r io.Reader) (*Patcher, error) { + dec := goyaml.NewDecoder(r) + var node goyaml.Node + if err := dec.Decode(&node); err != nil { + return nil, err + } + + return &Patcher{ + node: &node, + }, nil +} + +func (p *Patcher) SetField(path string, value any, createKeys bool) error { + parsedPath, err := yamlpath.NewPath(path) + if err != nil { + return fmt.Errorf("parsing path: %w", err) + } + + matchedNodes, err := parsedPath.Find(p.node) + if err != nil { + return fmt.Errorf("finding value node: %w", err) + } + + var valueNode *goyaml.Node + + if len(matchedNodes) == 0 { + if createKeys { + pathParts := strings.Split(path, ".") + // Note: we do not support JSONPath expressions in the path if createKeys is executed! + valueNode, err = recurseNodeByPath(p.node, pathParts, true) + if err != nil { + return fmt.Errorf("creating path: %w", err) + } + } else { + return errors.New("no nodes matched path") + } + } else if len(matchedNodes) > 1 { + return errors.New("multiple nodes matched path") + } else { + valueNode = matchedNodes[0] + } + + if valueNode.Kind != goyaml.ScalarNode { + return fmt.Errorf("expected scalar node, got %s (at %d:%d)", kindToStr(valueNode.Kind), valueNode.Line, valueNode.Column) + } + + newNode := new(goyaml.Node) + newNode.Kind = goyaml.ScalarNode + err = newNode.Encode(value) + if err != nil { + return fmt.Errorf("encoding value: %w", err) + } + + valueNode.Value = newNode.Value + valueNode.Tag = newNode.Tag + + return nil +} + +func recurseNodeByPath(node *goyaml.Node, path []string, createKeys bool) (valueNode *goyaml.Node, err error) { + if node.Kind == goyaml.DocumentNode { + return handleDocumentNode(node, path, createKeys) + } + + if len(path) == 0 { + return handleScalarNode(node) + } + + if node.Kind == goyaml.MappingNode { + return handleMappingNode(node, path, createKeys) + } + + return nil, fmt.Errorf("unexpected node of kind %s (at %d:%d)", kindToStr(node.Kind), node.Line, node.Column) +} + +func handleDocumentNode(node *goyaml.Node, path []string, createKeys bool) (*goyaml.Node, error) { + if len(node.Content) != 1 { + return nil, fmt.Errorf("expected exactly one node in document, got %d (at %d:%d)", len(node.Content), node.Line, node.Column) + } + + // Special case for empty documents + if createKeys && node.Content[0].Kind == goyaml.ScalarNode && node.Content[0].Tag == "!!null" { + // The document is empty, so we need to create a mapping node + node.Content[0] = &goyaml.Node{ + Kind: goyaml.MappingNode, + } + } + + return recurseNodeByPath(node.Content[0], path, createKeys) +} + +func handleScalarNode(node *goyaml.Node) (*goyaml.Node, error) { + if node.Kind != goyaml.ScalarNode { + return nil, fmt.Errorf("expected scalar node, got %s (at %d:%d)", kindToStr(node.Kind), node.Line, node.Column) + } + + return node, nil +} + +func handleMappingNode(node *goyaml.Node, path []string, createKeys bool) (*goyaml.Node, error) { + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i].Value + if key == path[0] { + return recurseNodeByPath(node.Content[i+1], path[1:], createKeys) + } + } + + // We didn't find the key, so we need to create it + if createKeys { + keyNode := &goyaml.Node{ + Kind: goyaml.ScalarNode, + Value: path[0], + } + // Create a mapping node if the path is longer than 1 + if len(path) > 1 { + mappingNode := &goyaml.Node{ + Kind: goyaml.MappingNode, + } + node.Content = append(node.Content, keyNode, mappingNode) + return recurseNodeByPath(mappingNode, path[1:], createKeys) + } + + // Otherwise, create a scalar node + scalarNode := &goyaml.Node{ + Kind: goyaml.ScalarNode, + } + node.Content = append(node.Content, keyNode, scalarNode) + return scalarNode, nil + } + + return node, fmt.Errorf("key %q not found (at %d:%d)", path[0], node.Line, node.Column) +} + +func kindToStr(kind goyaml.Kind) string { + switch kind { + case goyaml.DocumentNode: + return "DocumentNode" + case goyaml.SequenceNode: + return "SequenceNode" + case goyaml.MappingNode: + return "MappingNode" + case goyaml.ScalarNode: + return "ScalarNode" + case goyaml.AliasNode: + return "AliasNode" + default: + return fmt.Sprintf("unknown kind: %d", kind) + } +} + +func (p *Patcher) Encode(w io.Writer) error { + enc := goyaml.NewEncoder(w) + enc.SetIndent(2) + return enc.Encode(p.node) +} + +type RebaseAnnotation struct { + Identifier string + Name string + Tag string + + NameNode *goyaml.Node + TagNode *goyaml.Node +} + +func (a RebaseAnnotation) TagWithoutDigest() string { + tag := a.Tag + + idx := strings.IndexByte(tag, '@') + if idx >= 0 { + tag = tag[:idx] + } + + return tag +} + +type commentAnnotation map[string]string + +func (p *Patcher) FindRebaseAnnotations() ([]RebaseAnnotation, error) { + var annotations map[string]*RebaseAnnotation + var visitErr error + + p.visitMappingScalarNodes(p.node, func(node *goyaml.Node) { + comment := node.LineComment + if comment == "" { + return + } + + // Strip leading comment character and whitespace + comment = strings.TrimLeft(comment, "#") + comment = strings.TrimSpace(comment) + + if !strings.HasPrefix(comment, "{") { + return + } + + // Try to parse JSON + var comAnt commentAnnotation + err := json.Unmarshal([]byte(comment), &comAnt) + if err != nil { + visitErr = multierror.Append(visitErr, fmt.Errorf("parsing JSON from annotation in line %d: %w", node.Line, err)) + return + } + + if _, exists := comAnt["$rebase"]; !exists { + slog.Debug("Ignoring annotation", "annotation", comment, "line", node.Line) + return + } + + rebaseValue := comAnt["$rebase"] + rebaseIdentifierAndPart := strings.Split(rebaseValue, ":") + if len(rebaseIdentifierAndPart) != 2 { + visitErr = multierror.Append(visitErr, fmt.Errorf("invalid value %q in $rebase annotation of line %d", rebaseValue, node.Line)) + return + } + + identifier := rebaseIdentifierAndPart[0] + part := rebaseIdentifierAndPart[1] + + if annotations == nil { + annotations = make(map[string]*RebaseAnnotation) + } + + annotation := annotations[identifier] + if annotation == nil { + annotation = &RebaseAnnotation{ + Identifier: identifier, + } + annotations[identifier] = annotation + } + + switch part { + case "name": + annotation.Name = node.Value + annotation.NameNode = node + case "tag": + annotation.Tag = node.Value + annotation.TagNode = node + default: + visitErr = multierror.Append(visitErr, fmt.Errorf("invalid part %q in $rebase annotation of line %d, expected \"name\" or \"tag\"", part, node.Line)) + return + } + }) + + var result []RebaseAnnotation + for _, annotation := range annotations { + result = append(result, *annotation) + } + + return result, visitErr +} + +func (p *Patcher) visitMappingScalarNodes(node *goyaml.Node, f func(node *goyaml.Node)) { + if node.Kind == goyaml.DocumentNode { + p.visitMappingScalarNodes(node.Content[0], f) + } + + if node.Kind == goyaml.MappingNode { + for i := 0; i < len(node.Content); i += 2 { + value := node.Content[i+1] + p.visitMappingScalarNodes(value, f) + } + } + + if node.Kind == goyaml.ScalarNode { + f(node) + } +} diff --git a/yaml/patcher_test.go b/yaml/patcher_test.go new file mode 100644 index 0000000..5723860 --- /dev/null +++ b/yaml/patcher_test.go @@ -0,0 +1,242 @@ +package yaml_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/networkteam/stacker/yaml" +) + +func TestPatcher(t *testing.T) { + tests := []struct { + name string + inputYAML string + fieldPath string + value any + createKeys bool + expectedYAML string + expectErr bool + }{ + { + name: "valid yaml with nested key and comment as annotation", + inputYAML: ` +foo: bar +spec: + image: + # some special comment + tag: 0.1.0 +`, + fieldPath: "spec.image.tag", + value: "0.2.0", + expectedYAML: `foo: bar +spec: + image: + # some special comment + tag: 0.2.0 +`, + }, + { + name: "valid yaml with nested key and comment as annotation on same line", + inputYAML: ` +foo: bar +spec: + image: + tag: 0.1.0 # {"$imagepolicy": "foo:bar:tag"} +`, + fieldPath: "spec.image.tag", + value: "0.2.0", + expectedYAML: `foo: bar +spec: + image: + tag: 0.2.0 # {"$imagepolicy": "foo:bar:tag"} +`, + }, + { + name: "yaml with non-leaf key", + inputYAML: ` +spec: + image: + tag: + name: Foo +`, + fieldPath: "spec.image.tag", + value: "0.2.0", + expectErr: true, + }, + { + name: "yaml without key", + inputYAML: `spec:\n`, + fieldPath: "spec.image.tag", + value: "0.2.0", + expectErr: true, + }, + { + name: "yaml without key and create keys", + inputYAML: `spec: + image: + name: my/image`, + fieldPath: "spec.image.tag", + value: "0.2.0", + createKeys: true, + expectedYAML: `spec: + image: + name: my/image + tag: 0.2.0 +`, + }, + { + name: "yaml with other key and create keys", + inputYAML: `foo: bar`, + fieldPath: "spec.image.tag", + value: "0.2.0", + createKeys: true, + expectedYAML: `foo: bar +spec: + image: + tag: 0.2.0 +`, + }, + { + name: "empty yaml and create keys", + inputYAML: `---`, + fieldPath: "spec.image.tag", + value: "0.2.0", + createKeys: true, + expectedYAML: `spec: + image: + tag: 0.2.0 +`, + }, + { + name: "yaml with array index key", + inputYAML: `spec: + template: + spec: + containers: + - name: test + image: test.example.com:latest +`, + fieldPath: "spec.template.spec.containers[0].image", + value: "test.example.com:0.1.0", + expectedYAML: `spec: + template: + spec: + containers: + - name: test + image: test.example.com:0.1.0 +`, + }, + { + name: "yaml with filter by name", + inputYAML: `spec: + template: + spec: + containers: + - env: + - name: FOO + value: '1' + - name: BAR + value: '2' +`, + fieldPath: "spec.template.spec.containers[0].env[?(@.name=='BAR')].value", + value: "3", + expectedYAML: `spec: + template: + spec: + containers: + - env: + - name: FOO + value: '1' + - name: BAR + value: '3' +`, + }, + // Test various conversion of an existing value + { + name: "setting multi-line strings", + inputYAML: ` +foo: bar +`, + fieldPath: "foo", + value: "A longer string\nwith a newline", + expectedYAML: `foo: |- + A longer string + with a newline +`, + }, + { + name: "setting unquoted string to quoted", + inputYAML: ` +foo: bar +`, + fieldPath: "foo", + value: "!better quote this", + expectedYAML: `foo: '!better quote this' +`, + }, + { + name: "setting string in single quote is escaped correctly", + inputYAML: ` +foo: 'double quote this' +`, + fieldPath: "foo", + value: "single's quote", + expectedYAML: `foo: 'single''s quote' +`, + }, + { + name: "setting string to bool", + inputYAML: ` +foo: bar +`, + fieldPath: "foo", + value: true, + expectedYAML: `foo: true +`, + }, + { + name: "setting string to int", + inputYAML: ` +foo: bar +`, + fieldPath: "foo", + value: 42, + expectedYAML: `foo: 42 +`, + }, + { + name: "setting string to null", + inputYAML: ` +foo: bar +`, + fieldPath: "foo", + value: nil, + expectedYAML: `foo: null +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patcher, err := yaml.NewPatcher(strings.NewReader(tt.inputYAML)) + require.NoError(t, err) + + err = patcher.SetField(tt.fieldPath, tt.value, tt.createKeys) + if tt.expectErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + var sb strings.Builder + err = patcher.Encode(&sb) + require.NoError(t, err) + + assert.Equal(t, tt.expectedYAML, sb.String()) + }) + } +}