custom-targets/util/applysetters/apply_setters.go (228 lines of code) (raw):
/*
Copyright 2023 The Skaffold 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 applysetters is an adaptation for Skaffold's applysetters package
// to apply kpt-style param transformations for a yaml config file with the
// parameters provided as key value pairs.
package applysetters
import (
"fmt"
"path"
"regexp"
"strings"
"sigs.k8s.io/kustomize/kyaml/errors"
"sigs.k8s.io/kustomize/kyaml/kio"
"sigs.k8s.io/kustomize/kyaml/kio/kioutil"
"sigs.k8s.io/kustomize/kyaml/yaml"
)
const SetterCommentIdentifier = "# from-param: "
var _ kio.Filter = &ApplySetters{}
// ApplySetters applies the setter values to the resource fields which are tagged
// by the setter reference comments
type ApplySetters struct {
// Setters holds the user provided values for all the setters
Setters []Setter
// Results are the results of applying setter values
Results []*Result
// filePath file path of resource
filePath string
}
type Setter struct {
// Name is the name of the setter
Name string
// Value is the input value for setter
Value string
}
// Result holds result of search and replace operation
type Result struct {
// FilePath is the file path of the matching field
FilePath string
// FieldPath is field path of the matching field
FieldPath string
// Value of the matching field
Value string
}
// Filter implements Set as a yaml.Filter
func (as *ApplySetters) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error) {
for i := range nodes {
filePath, _, err := kioutil.GetFileAnnotations(nodes[i])
if err != nil {
return nodes, err
}
as.filePath = filePath
err = accept(as, nodes[i])
if err != nil {
return nil, errors.Wrap(err)
}
}
return nodes, nil
}
/*
visitMapping takes input mapping node, and performs following steps
checks if the key node of the input mapping node has line comment with SetterCommentIdentifier
checks if the value node is of sequence node type
if yes to both, resolves the setter value for the setter name in the line comment
replaces the existing sequence node with the new values provided by user
e.g. for input of Mapping node
environments: # from-param: ${env}
- dev
- stage
For input ApplySetters [name: env, value: "[stage, prod]"], qthe yaml node is transformed to
environments: # from-param: ${env}
- stage
- prod
*/
func (as *ApplySetters) visitMapping(object *yaml.RNode, path string) error {
return object.VisitFields(func(node *yaml.MapNode) error {
if node == nil || node.Key.IsNil() || node.Value.IsNil() {
// don't do IsNilOrEmpty check as empty sequences are allowed
return nil
}
// the aim of this method is to apply-setter for sequence nodes
if node.Value.YNode().Kind != yaml.SequenceNode {
// return if it is not a sequence node
return nil
}
lineComment := node.Key.YNode().LineComment
if node.Value.YNode().Style == yaml.FlowStyle {
// if node is FlowStyle e.g. env: [foo, bar] # from-param: ${env}
// the setter comment will be on value node
lineComment = node.Value.YNode().LineComment
}
setterPattern := extractSetterPattern(lineComment)
if setterPattern == "" {
// the node is not tagged with setter pattern
return nil
}
if !shouldSet(setterPattern, as.Setters) {
// this means there is no intent from user to modify this setter tagged resources
return nil
}
// since this setter pattern is found on sequence node, make sure that it is
// not interpolation of setters, it should be simple setter e.g. ${environments}
if !validArraySetterPattern(setterPattern) {
return errors.Errorf("invalid setter pattern for array node: %q", setterPattern)
}
// get the setter value for the setter name in the comment
sv := setterValue(as.Setters, setterPattern)
// add the key to the field path
fieldPath := strings.TrimPrefix(fmt.Sprintf("%s.%s", path, node.Key.YNode().Value), ".")
if sv == "" {
node.Value.YNode().Content = []*yaml.Node{}
// empty sequence must be FlowStyle e.g. env: [] # from-param: ${env}
node.Value.YNode().Style = yaml.FlowStyle
// setter pattern comment must be on value node
node.Value.YNode().LineComment = lineComment
node.Key.YNode().LineComment = ""
as.Results = append(as.Results, &Result{
FilePath: as.filePath,
FieldPath: fieldPath,
Value: sv,
})
return nil
}
// parse the setter value as yaml node
rn, err := yaml.Parse(sv)
if err != nil {
return errors.Errorf("input to array setter must be an array of values, but found %q", sv)
}
// the setter value must parse as sequence node
if rn.YNode().Kind != yaml.SequenceNode {
return errors.Errorf("input to array setter must be an array of values, but found %q", sv)
}
node.Value.YNode().Content = rn.YNode().Content
node.Key.YNode().LineComment = lineComment
// non-empty sequences should be standardized to FoldedStyle
// env: # from-param: ${env}
// - foo
// - bar
node.Value.YNode().Style = yaml.FoldedStyle
as.Results = append(as.Results, &Result{
FilePath: as.filePath,
FieldPath: fieldPath,
Value: sv,
})
return nil
})
}
/*
visitScalar accepts the input scalar node and performs following steps,
checks if the line comment of input scalar node has prefix SetterCommentIdentifier
resolves the setter values for the setter name in the comment
replaces the existing value of the scalar node with the new value
e.g.for input of scalar node 'nginx:1.7.1 # from-param: ${image}:${tag}' in the yaml node
apiVersion: v1
...
image: nginx:1.7.1 # from-param: ${image}:${tag}
and for input ApplySetters [[name: image, value: ubuntu], [name: tag, value: 1.8.0]]
The yaml node is transformed to
apiVersion: v1
...
image: ubuntu:1.8.0 # from-param: ${image}:${tag}
*/
func (as *ApplySetters) visitScalar(object *yaml.RNode, path string) error {
if object.IsNil() {
return nil
}
if object.YNode().Kind != yaml.ScalarNode {
// return if it is not a scalar node
return nil
}
// perform a direct set of the field if it matches
setterPattern := extractSetterPattern(object.YNode().LineComment)
if setterPattern == "" {
// the node is not tagged with setter pattern
return nil
}
curPattern := setterPattern
if !shouldSet(setterPattern, as.Setters) {
// this means there is no intent from user to modify this setter tagged resources
return nil
}
// replace the setter names in comment pattern with provided values
for _, setter := range as.Setters {
setterPattern = strings.ReplaceAll(
setterPattern,
fmt.Sprintf("${%s}", setter.Name),
fmt.Sprintf("%v", setter.Value),
)
}
// replace the remaining setter names in comment pattern with values derived from current
// field value, these values are not provided by user
currentSetterValues := currentSetterValues(curPattern, object.YNode().Value)
for setterName, setterValue := range currentSetterValues {
setterPattern = strings.ReplaceAll(
setterPattern,
fmt.Sprintf("${%s}", setterName),
fmt.Sprintf("%v", setterValue),
)
}
// check if there are unresolved setters and throw error
urs := unresolvedSetters(setterPattern)
if len(urs) > 0 {
return errors.Errorf("values for setters %v must be provided", urs)
}
object.YNode().Value = setterPattern
if setterPattern == "" {
object.YNode().Style = yaml.DoubleQuotedStyle
}
object.YNode().Tag = yaml.NodeTagEmpty
as.Results = append(as.Results, &Result{
FilePath: as.filePath,
FieldPath: strings.TrimPrefix(path, "."),
Value: object.YNode().Value,
})
return nil
}
// shouldSet takes the setter pattern comment and setter values map and returns true
// iff at least one of the setter names in the pattern match with the setter names
// in input setterValues map
func shouldSet(pattern string, setters []Setter) bool {
for _, s := range setters {
if strings.Contains(pattern, fmt.Sprintf("${%s}", s.Name)) {
return true
}
}
return false
}
// currentSetterValues takes pattern and value and returns setter names to values
// derived using pattern matching
// e.g. pattern = my-app-layer.${stage}.${domain}.${tld}, value = my-app-layer.dev.example.com
// returns {"stage":"dev", "domain":"example", "tld":"com"}
func currentSetterValues(pattern, value string) map[string]string {
res := make(map[string]string)
// get all setter names enclosed in ${}
// e.g. value: my-app-layer.dev.example.com
// pattern: my-app-layer.${stage}.${domain}.${tld}
// urs: [${stage}, ${domain}, ${tld}]
urs := unresolvedSetters(pattern)
// and escape pattern
pattern = regexp.QuoteMeta(pattern)
// escaped pattern: my-app-layer\.\$\{stage\}\.\$\{domain\}\.\$\{tld\}
for _, setterName := range urs {
// escape setter name
// we need to escape the setterName as well to replace it in the escaped pattern string later
setterName = regexp.QuoteMeta(setterName)
pattern = strings.ReplaceAll(
pattern,
setterName,
`(?P<x>.*)`) // x is just a place holder, it could be any alphanumeric string
}
// pattern: my-app-layer\.(?P<x>.*)\.(?P<x>.*)\.(?P<x>.*)
r, err := regexp.Compile(pattern)
if err != nil {
// just return empty map if values can't be derived from pattern
return res
}
setterValues := r.FindStringSubmatch(value)
if len(setterValues) == 0 {
return res
}
// setterValues: [ "my-app-layer.dev.example.com", "dev", "example", "com"]
setterValues = setterValues[1:]
// setterValues: [ "dev", "example", "com"]
if len(urs) != len(setterValues) {
// just return empty map if values can't be derived
return res
}
for i := range setterValues {
if setterValues[i] == "" {
// if any of the value is unresolved return empty map
// and expect users to provide all values
return make(map[string]string)
}
res[clean(urs[i])] = setterValues[i]
}
return res
}
// setterValue returns the value for the setter
func setterValue(setters []Setter, setterName string) string {
for _, setter := range setters {
if setter.Name == clean(setterName) {
return setter.Value
}
}
return ""
}
// extractSetterPattern extracts the setter pattern from the line comment of the
// yaml RNode. If the the line comment doesn't contain SetterCommentIdentifier
// prefix, then it returns empty string
func extractSetterPattern(lineComment string) string {
if !strings.HasPrefix(lineComment, SetterCommentIdentifier) {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(lineComment, SetterCommentIdentifier))
}
// validArraySetterPattern returns true if the array setter pattern is valid
// pattern must not interpolation of setters, it should be simple setter e.g. ${environments}
func validArraySetterPattern(pattern string) bool {
return len(unresolvedSetters(pattern)) == 1 &&
strings.HasPrefix(pattern, "${") &&
strings.HasSuffix(pattern, "}")
}
// unresolvedSetters returns the list of values enclosed in ${} present within given
// pattern e.g. pattern = foo-${image}:${tag}-bar return ["${image}", "${tag}"]
func unresolvedSetters(pattern string) []string {
re := regexp.MustCompile(`\$\{([^}]*)}`)
return re.FindAllString(pattern, -1)
}
// clean extracts value enclosed in ${}
func clean(input string) string {
input = strings.TrimSpace(input)
return strings.TrimSuffix(strings.TrimPrefix(input, "${"), "}")
}
// ApplyParams sets the value of a kpt-style param in the input file with the values
// from the 'params' map.
func ApplyParams(filePath string, params map[string]string) error {
s := &ApplySetters{}
addSetters(params, s)
fileName := path.Base(filePath)
baseDir := path.Dir(filePath)
inout := &kio.LocalPackageReadWriter{
PackagePath: baseDir,
NoDeleteFiles: true,
MatchFilesGlob: []string{fileName},
}
return kio.Pipeline{
Inputs: []kio.Reader{inout},
Filters: []kio.Filter{s},
Outputs: []kio.Writer{inout},
}.Execute()
}
// addSetters populates the setter struct with key values provided in params
func addSetters(params map[string]string, fcd *ApplySetters) {
for k, v := range params {
fcd.Setters = append(fcd.Setters, Setter{Name: k, Value: v})
}
}