Skip to content
Open
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
211 changes: 211 additions & 0 deletions baker/bake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
Copyright 2025 The cert-manager 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 baker

import (
"context"
"fmt"
"maps"
"slices"
"strings"

"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
)

type BakeReference struct {
Repository string
Tag string
Digest string
}

func ParseBakeReference(value string) (bakeInput BakeReference) {
// extract digest from value
if digestRef, err := name.NewDigest(value); err == nil {
bakeInput.Repository = digestRef.Context().String()
bakeInput.Digest = digestRef.DigestStr()
}

// extract tag from value
if tagRef, err := name.NewTag(value); err == nil {
bakeInput.Repository = tagRef.Context().String()
bakeInput.Tag = tagRef.TagStr()
}

return bakeInput
}

func (br BakeReference) Reference() name.Reference {
repo, _ := name.NewRepository(br.Repository)
if br.Digest != "" {
return repo.Digest(br.Digest)
}
return repo.Tag(br.Tag)
}

func (br BakeReference) String() string {
var builder strings.Builder
_, _ = builder.WriteString(br.Repository)
if br.Tag != "" {
_, _ = builder.WriteString(":")
_, _ = builder.WriteString(br.Tag)
}
if br.Digest != "" {
_, _ = builder.WriteString("@")
_, _ = builder.WriteString(br.Digest)
}
return builder.String()
}

type BakeInput = BakeReference

func (bi BakeInput) Find(ctx context.Context) (BakeOutput, error) {
desc, err := remote.Head(bi.Reference(), remote.WithContext(ctx))
if err != nil {
return BakeReference{}, fmt.Errorf("failed to pull %s", bi)
}

return BakeReference{
Repository: bi.Repository,
Digest: desc.Digest.String(),
Tag: bi.Tag,
}, nil
}

type BakeOutput = BakeReference

func Extract(ctx context.Context, inputPath string) (map[string]BakeInput, error) {
results := map[string]BakeInput{}

values, err := readValuesYAML(inputPath)
if err != nil {
return nil, err
}

if _, err := allNestedStringValues(values, nil, func(path []string, value string) (string, error) {
if path[len(path)-1] != "_defaultReference" {
return value, nil
}

bakeInput := ParseBakeReference(value)
if bakeInput == (BakeInput{}) {
return "", fmt.Errorf("invalid _defaultReference value: %q", value)
}

results[strings.Join(path, ".")] = bakeInput

return value, nil
}); err != nil {
return nil, err
}

return results, nil
}

type BakeAction struct {
In BakeInput `json:"in"`
Out BakeOutput `json:"out"`
}

func Bake(ctx context.Context, inputPath string, outputPath string, valuesPaths []string) (map[string]BakeAction, error) {
results := map[string]BakeAction{}
return results, modifyValuesYAML(inputPath, outputPath, func(values map[string]any) (map[string]any, error) {
replacedValuePaths := map[string]struct{}{}
newValues, err := allNestedStringValues(values, nil, func(path []string, value string) (string, error) {
if path[len(path)-1] != "_defaultReference" {
return value, nil
}

bakeInput := ParseBakeReference(value)
if bakeInput == (BakeInput{}) {
return "", fmt.Errorf("invalid _defaultReference value: %q", value)
}

bakeOutput, err := bakeInput.Find(ctx)
if err != nil {
return "", err
}

pathString := strings.Join(path, ".")
replacedValuePaths[pathString] = struct{}{}
results[pathString] = BakeAction{
In: bakeInput,
Out: bakeOutput,
}

return bakeOutput.String(), nil
})
if err != nil {
return nil, err
}

if len(replacedValuePaths) > len(valuesPaths) {
return nil, fmt.Errorf("too many value paths were replaced: %v", slices.Collect(maps.Keys(replacedValuePaths)))
}
for _, valuesPath := range valuesPaths {
if _, ok := replacedValuePaths[valuesPath]; !ok {
return nil, fmt.Errorf("path was not replaced: %s", valuesPath)
}
}

return newValues.(map[string]any), nil
})
}

func allNestedStringValues(object any, path []string, fn func(path []string, value string) (string, error)) (any, error) {
switch t := object.(type) {
case map[string]any:
for key, value := range t {
keyPath := append(path, key)
if stringValue, ok := value.(string); ok {
newValue, err := fn(slices.Clone(keyPath), stringValue)
if err != nil {
return nil, err
}
t[key] = newValue
} else {
newValue, err := allNestedStringValues(value, keyPath, fn)
if err != nil {
return nil, err
}
t[key] = newValue
}
}
case map[string]string:
for key, stringValue := range t {
keyPath := append(path, key)
newValue, err := fn(slices.Clone(keyPath), stringValue)
if err != nil {
return nil, err
}
t[key] = newValue
}
case []any:
for i, value := range t {
path = append(path, fmt.Sprintf("%d", i))
newValue, err := allNestedStringValues(value, path, fn)
if err != nil {
return nil, err
}
t[i] = newValue
}
default:
// ignore object
}

return object, nil
}
157 changes: 157 additions & 0 deletions baker/modify_values.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
Copyright 2025 The cert-manager 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 baker

import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
"strings"

"gopkg.in/yaml.v3"
Copy link
Member

@maelvls maelvls Sep 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could replace gopkg.in/yaml.v3 with goccy/go-yaml package, which supports keeping comments and empty newlines, e.g.,

✦ $ ycat /dev/stdin <<EOF
foo: bar

# A comment
bar:foo

# Some extraneous newlines that should be kept as-is



# end of file
EOF
 1 | foo: bar
 2 |
 3 | # A comment
 4 | bar:foo
 5 |
 6 | # Some extraneous newlines that should be kept as-is
 7 |
 8 |
 9 |
10 | # end of file
11 |

Note that we will need to decode YAML into an AST instead of decoding it to a Go struct (otherwise, we will lose the comments and newlines and such). Goccy probably has helper funcs to modify the AST, then we can encode it back to YAML.

)

// inplaceReadValuesYAML reads the provided chart tar file and returns the values
func readValuesYAML(inputPath string) (map[string]any, error) {
var result map[string]any
return result, modifyValuesYAML(inputPath, "", func(m map[string]any) (map[string]any, error) {
result = m
return m, nil
})
}

type modFunction func(map[string]any) (map[string]any, error)

func modifyValuesYAML(inFilePath string, outFilePath string, modFn modFunction) error {
inReader, err := os.Open(inFilePath)
if err != nil {
return err
}
defer inReader.Close()

outWriter := io.Discard
if outFilePath != "" {
outFile, err := os.Create(outFilePath)
if err != nil {
return err
}
defer outFile.Close()

outWriter = outFile
}

if strings.HasSuffix(inFilePath, ".tgz") {
if err := modifyTarStreamValuesYAML(inReader, outWriter, modFn); err != nil {
return err
}
} else {
if err := modifyStreamValuesYAML(inReader, outWriter, modFn); err != nil {
return err
}
}

return nil
}

func modifyTarStreamValuesYAML(in io.Reader, out io.Writer, modFn modFunction) error {
inFileDecompressed, err := gzip.NewReader(in)
if err != nil {
return err
}
defer inFileDecompressed.Close()
tr := tar.NewReader(inFileDecompressed)

outFileCompressed, err := gzip.NewWriterLevel(out, gzip.BestCompression)
if err != nil {
return err
}
// see https://github.com/helm/helm/blob/b25a51b291b930ef05ed0f4a9ea7cce073c10f63/pkg/chart/v2/util/save.go#L35C28-L35C67
// seems like an easter egg, but keep it for "backwards compatibility" :)
outFileCompressed.Extra = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=")
outFileCompressed.Comment = "Helm"
defer outFileCompressed.Close()
tw := tar.NewWriter(outFileCompressed)
defer tw.Close()

for {
hdr, err := tr.Next()
if err == io.EOF {
break // End of archive
}
if err != nil {
return err
}

const maxValuesYAMLSize = 2 * 1024 * 1024 // 2MB
limitedReader := &io.LimitedReader{
R: tr,
N: maxValuesYAMLSize,
}

if strings.HasSuffix(hdr.Name, "/values.yaml") {
var modifiedContent bytes.Buffer
if err := modifyStreamValuesYAML(limitedReader, &modifiedContent, modFn); err != nil {
return err
}

// Update header size
hdr.Size = int64(modifiedContent.Len())

// Write updated header and content
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(modifiedContent.Bytes()); err != nil {
return err
}
} else {
// Stream other files unchanged
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := io.Copy(tw, limitedReader); err != nil {
return err
}
}

if limitedReader.N <= 0 {
return fmt.Errorf("values.yaml is larger than %v bytes", maxValuesYAMLSize)
}
}

return nil
}

func modifyStreamValuesYAML(in io.Reader, out io.Writer, modFn modFunction) error {
// Parse YAML
var data map[string]any
if err := yaml.NewDecoder(in).Decode(&data); err != nil {
return err
}

// Modify YAML
data, err := modFn(data)
if err != nil {
return err
}

// Marshal back to YAML
return yaml.NewEncoder(out).Encode(data)
}
Loading