kubeadm: add logic for patching components in util/patches

This commit is contained in:
Lubomir I. Ivanov 2020-06-11 18:26:29 +03:00
parent a8b31556c8
commit 5506049b87
4 changed files with 796 additions and 0 deletions

View File

@ -89,6 +89,7 @@ filegroup(
"//cmd/kubeadm/app/util/kubeconfig:all-srcs",
"//cmd/kubeadm/app/util/kustomize:all-srcs",
"//cmd/kubeadm/app/util/output:all-srcs",
"//cmd/kubeadm/app/util/patches:all-srcs",
"//cmd/kubeadm/app/util/pkiutil:all-srcs",
"//cmd/kubeadm/app/util/pubkeypin:all-srcs",
"//cmd/kubeadm/app/util/runtime:all-srcs",

View File

@ -0,0 +1,40 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = ["patches.go"],
importpath = "k8s.io/kubernetes/cmd/kubeadm/app/util/patches",
visibility = ["//visibility:public"],
deps = [
"//staging/src/k8s.io/apimachinery/pkg/types:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/strategicpatch:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/yaml:go_default_library",
"//vendor/github.com/evanphx/json-patch:go_default_library",
"//vendor/github.com/pkg/errors:go_default_library",
"//vendor/sigs.k8s.io/yaml:go_default_library",
],
)
go_test(
name = "go_default_test",
srcs = ["patches_test.go"],
embed = [":go_default_library"],
deps = [
"//staging/src/k8s.io/api/core/v1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/types:go_default_library",
],
)
filegroup(
name = "package-srcs",
srcs = glob(["**"]),
tags = ["automanaged"],
visibility = ["//visibility:private"],
)
filegroup(
name = "all-srcs",
srcs = [":package-srcs"],
tags = ["automanaged"],
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,342 @@
/*
Copyright 2020 The Kubernetes 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 patches
import (
"bufio"
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
jsonpatch "github.com/evanphx/json-patch"
"github.com/pkg/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/strategicpatch"
utilyaml "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/yaml"
)
// PatchTarget defines a target to be patched, such as a control-plane static Pod.
type PatchTarget struct {
// Name must be the name of a known target. In the case of Kubernetes objects
// this is likely to match the ObjectMeta.Name of a target.
Name string
// StrategicMergePatchObject is only used for strategic merge patches.
// It represents the underlying object type that is patched - e.g. "v1.Pod"
StrategicMergePatchObject interface{}
// Data must contain the bytes that will be patched.
Data []byte
}
// PatchManager defines an object that can apply patches.
type PatchManager struct {
patchSets []*patchSet
knownTargets []string
output io.Writer
}
// patchSet defines a set of patches of a certain type that can patch a PatchTarget.
type patchSet struct {
targetName string
patchType types.PatchType
patches []string
}
// String() is used for unit-testing.
func (ps *patchSet) String() string {
return fmt.Sprintf(
"{%q, %q, %#v}",
ps.targetName,
ps.patchType,
ps.patches,
)
}
var (
pathLock = &sync.RWMutex{}
pathCache = map[string]*PatchManager{}
patchTypes = map[string]types.PatchType{
"json": types.JSONPatchType,
"merge": types.MergePatchType,
"strategic": types.StrategicMergePatchType,
"": types.StrategicMergePatchType, // Default
}
patchTypeList = []string{"json", "merge", "strategic"}
patchTypesJoined = strings.Join(patchTypeList, "|")
knownExtensions = []string{"json", "yaml"}
regExtension = regexp.MustCompile(`.+\.(` + strings.Join(knownExtensions, "|") + `)$`)
)
// GetPatchManagerForPath creates a patch manager that can be used to apply patches to "knownTargets".
// "path" should contain patches that can be used to patch the "knownTargets".
// If "output" is non-nil, messages about actions performed by the manager would go on this io.Writer.
func GetPatchManagerForPath(path string, knownTargets []string, output io.Writer) (*PatchManager, error) {
pathLock.RLock()
if pm, known := pathCache[path]; known {
pathLock.RUnlock()
return pm, nil
}
pathLock.RUnlock()
if output == nil {
output = ioutil.Discard
}
fmt.Fprintf(output, "[patches] Reading patches from path %q\n", path)
// Get the files in the path.
patchSets, patchFiles, ignoredFiles, err := getPatchSetsFromPath(path, knownTargets, output)
if err != nil {
return nil, err
}
if len(patchFiles) > 0 {
fmt.Fprintf(output, "[patches] Found the following patch files: %v\n", patchFiles)
}
if len(ignoredFiles) > 0 {
fmt.Fprintf(output, "[patches] Ignored the following files: %v\n", ignoredFiles)
}
pm := &PatchManager{
patchSets: patchSets,
knownTargets: knownTargets,
output: output,
}
pathLock.Lock()
pathCache[path] = pm
pathLock.Unlock()
return pm, nil
}
// ApplyPatchesToTarget takes a patch target and patches its "Data" using the patches
// stored in the patch manager. The resulted "Data" is always converted to JSON.
func (pm *PatchManager) ApplyPatchesToTarget(patchTarget *PatchTarget) error {
var err error
var patchedData []byte
var found bool
for _, pt := range pm.knownTargets {
if pt == patchTarget.Name {
found = true
break
}
}
if !found {
return errors.Errorf("unknown patch target name %q, must be one of %v", patchTarget.Name, pm.knownTargets)
}
// Always convert the target data to JSON.
patchedData, err = yaml.YAMLToJSON(patchTarget.Data)
if err != nil {
return err
}
// Iterate over the patchSets.
for _, patchSet := range pm.patchSets {
if patchSet.targetName != patchTarget.Name {
continue
}
// Iterate over the patches in the patchSets.
for _, patch := range patchSet.patches {
patchBytes := []byte(patch)
// Patch based on the patch type.
switch patchSet.patchType {
// JSON patch.
case types.JSONPatchType:
var patchObj jsonpatch.Patch
patchObj, err = jsonpatch.DecodePatch(patchBytes)
if err == nil {
patchedData, err = patchObj.Apply(patchedData)
}
// Merge patch.
case types.MergePatchType:
patchedData, err = jsonpatch.MergePatch(patchedData, patchBytes)
// Strategic merge patch.
case types.StrategicMergePatchType:
patchedData, err = strategicpatch.StrategicMergePatch(
patchedData,
patchBytes,
patchTarget.StrategicMergePatchObject,
)
}
if err != nil {
return errors.Wrapf(err, "could not apply the following patch of type %q to target %q:\n%s\n",
patchSet.patchType,
patchTarget.Name,
patch)
}
fmt.Fprintf(pm.output, "[patches] Applied patch of type %q to target %q\n", patchSet.patchType, patchTarget.Name)
}
// Update the data for this patch target.
patchTarget.Data = patchedData
}
return nil
}
// parseFilename validates a file name and retrieves the encoded target name and patch type.
// - On unknown extension or target name it returns a warning
// - On unknown patch type it returns an error
// - On success it returns a target name and patch type
func parseFilename(fileName string, knownTargets []string) (string, types.PatchType, error, error) {
// Return a warning if the extension cannot be matched.
if !regExtension.MatchString(fileName) {
return "", "", errors.Errorf("the file extension must be one of %v", knownExtensions), nil
}
regFileNameSplit := regexp.MustCompile(
fmt.Sprintf(`^(%s)([^.+\n]*)?(\+)?(%s)?`, strings.Join(knownTargets, "|"), patchTypesJoined),
)
// Extract the target name and patch type. The resulting sub-string slice would look like this:
// [full-match, targetName, suffix, +, patchType]
sub := regFileNameSplit.FindStringSubmatch(fileName)
if sub == nil {
return "", "", errors.Errorf("unknown target, must be one of %v", knownTargets), nil
}
targetName := sub[1]
if len(sub[3]) > 0 && len(sub[4]) == 0 {
return "", "", nil, errors.Errorf("unknown or missing patch type after '+', must be one of %v", patchTypeList)
}
patchType := patchTypes[sub[4]]
return targetName, patchType, nil, nil
}
// createPatchSet creates a patchSet object, by splitting the given "data" by "\n---".
func createPatchSet(targetName string, patchType types.PatchType, data string) (*patchSet, error) {
var patches []string
// Split the patches and convert them to JSON.
// Data that is already JSON will not cause an error.
buf := bytes.NewBuffer([]byte(data))
reader := utilyaml.NewYAMLReader(bufio.NewReader(buf))
for {
patch, err := reader.Read()
if err == io.EOF {
break
} else if err != nil {
return nil, errors.Wrapf(err, "could not split patches for data:\n%s\n", data)
}
patch = bytes.TrimSpace(patch)
if len(patch) == 0 {
continue
}
patchJSON, err := yaml.YAMLToJSON(patch)
if err != nil {
return nil, errors.Wrapf(err, "could not convert patch to JSON:\n%s\n", patch)
}
patches = append(patches, string(patchJSON))
}
return &patchSet{
targetName: targetName,
patchType: patchType,
patches: patches,
}, nil
}
// getPatchSetsFromPath walks a path, ignores sub-directories and non-patch files, and
// returns a list of patchFile objects.
func getPatchSetsFromPath(targetPath string, knownTargets []string, output io.Writer) ([]*patchSet, []string, []string, error) {
patchFiles := []string{}
ignoredFiles := []string{}
patchSets := []*patchSet{}
// Check if targetPath is a directory.
info, err := os.Lstat(targetPath)
if err != nil {
goto return_path_error
}
if !info.IsDir() {
err = errors.New("not a directory")
goto return_path_error
}
err = filepath.Walk(targetPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Sub-directories and "." are ignored.
if info.IsDir() {
return nil
}
baseName := info.Name()
// Parse the filename and retrieve the target and patch type
targetName, patchType, warn, err := parseFilename(baseName, knownTargets)
if err != nil {
return err
}
if warn != nil {
fmt.Fprintf(output, "[patches] Ignoring file %q: %v\n", baseName, warn)
ignoredFiles = append(ignoredFiles, baseName)
return nil
}
// Read the patch file.
data, err := ioutil.ReadFile(path)
if err != nil {
return errors.Wrapf(err, "could not read the file %q", path)
}
if len(data) == 0 {
fmt.Fprintf(output, "[patches] Ignoring empty file: %q\n", baseName)
ignoredFiles = append(ignoredFiles, baseName)
return nil
}
// Create a patchSet object.
patchSet, err := createPatchSet(targetName, patchType, string(data))
if err != nil {
return err
}
patchFiles = append(patchFiles, baseName)
patchSets = append(patchSets, patchSet)
return nil
})
return_path_error:
if err != nil {
return nil, nil, nil, errors.Wrapf(err, "could not list patch files for path %q", targetPath)
}
return patchSets, patchFiles, ignoredFiles, nil
}

View File

@ -0,0 +1,413 @@
/*
Copyright 2020 The Kubernetes 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 patches
import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"reflect"
"testing"
"k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)
var testKnownTargets = []string{
"etcd",
"kube-apiserver",
"kube-controller-manager",
"kube-scheduler",
}
const testDirPattern = "patch-files"
func TestParseFilename(t *testing.T) {
tests := []struct {
name string
fileName string
expectedTargetName string
expectedPatchType types.PatchType
expectedWarning bool
expectedError bool
}{
{
name: "valid: known target and patch type",
fileName: "etcd+merge.json",
expectedTargetName: "etcd",
expectedPatchType: types.MergePatchType,
},
{
name: "valid: known target and default patch type",
fileName: "etcd0.yaml",
expectedTargetName: "etcd",
expectedPatchType: types.StrategicMergePatchType,
},
{
name: "valid: known target and custom patch type",
fileName: "etcd0+merge.yaml",
expectedTargetName: "etcd",
expectedPatchType: types.MergePatchType,
},
{
name: "invalid: unknown target",
fileName: "foo.yaml",
expectedWarning: true,
},
{
name: "invalid: unknown extension",
fileName: "etcd.foo",
expectedWarning: true,
},
{
name: "invalid: missing extension",
fileName: "etcd",
expectedWarning: true,
},
{
name: "invalid: unknown patch type",
fileName: "etcd+foo.json",
expectedError: true,
},
{
name: "invalid: missing patch type",
fileName: "etcd+.json",
expectedError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
targetName, patchType, warn, err := parseFilename(tc.fileName, testKnownTargets)
if (err != nil) != tc.expectedError {
t.Errorf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
}
if (warn != nil) != tc.expectedWarning {
t.Errorf("expected warning: %v, got: %v, warning: %v", tc.expectedWarning, warn != nil, warn)
}
if targetName != tc.expectedTargetName {
t.Errorf("expected target name: %v, got: %v", tc.expectedTargetName, targetName)
}
if patchType != tc.expectedPatchType {
t.Errorf("expected patch type: %v, got: %v", tc.expectedPatchType, patchType)
}
})
}
}
func TestCreatePatchSet(t *testing.T) {
tests := []struct {
name string
targetName string
patchType types.PatchType
expectedPatchSet *patchSet
data string
}{
{
name: "valid: YAML patches are separated and converted to JSON",
targetName: "etcd",
patchType: types.StrategicMergePatchType,
data: "foo: bar\n---\nfoo: baz\n",
expectedPatchSet: &patchSet{
targetName: "etcd",
patchType: types.StrategicMergePatchType,
patches: []string{`{"foo":"bar"}`, `{"foo":"baz"}`},
},
},
{
name: "valid: JSON patches are separated",
targetName: "etcd",
patchType: types.StrategicMergePatchType,
data: `{"foo":"bar"}` + "\n---\n" + `{"foo":"baz"}`,
expectedPatchSet: &patchSet{
targetName: "etcd",
patchType: types.StrategicMergePatchType,
patches: []string{`{"foo":"bar"}`, `{"foo":"baz"}`},
},
},
{
name: "valid: empty patches are ignored",
targetName: "etcd",
patchType: types.StrategicMergePatchType,
data: `{"foo":"bar"}` + "\n---\n ---\n" + `{"foo":"baz"}`,
expectedPatchSet: &patchSet{
targetName: "etcd",
patchType: types.StrategicMergePatchType,
patches: []string{`{"foo":"bar"}`, `{"foo":"baz"}`},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ps, _ := createPatchSet(tc.targetName, tc.patchType, tc.data)
if !reflect.DeepEqual(ps, tc.expectedPatchSet) {
t.Fatalf("expected patch set:\n%+v\ngot:\n%+v\n", tc.expectedPatchSet, ps)
}
})
}
}
func TestGetPatchSetsForPathMustBeDirectory(t *testing.T) {
tempFile, err := ioutil.TempFile("", "test-file")
if err != nil {
t.Errorf("error creating temporary file: %v", err)
}
defer os.Remove(tempFile.Name())
_, _, _, err = getPatchSetsFromPath(tempFile.Name(), testKnownTargets, ioutil.Discard)
if err == nil {
t.Fatalf("expected error for non-directory path %q", tempFile.Name())
}
}
func TestGetPatchSetsForPath(t *testing.T) {
const patchData = `{"foo":"bar"}`
tests := []struct {
name string
filesToWrite []string
expectedPatchSets []*patchSet
expectedPatchFiles []string
expectedIgnoredFiles []string
expectedError bool
patchData string
}{
{
name: "valid: patch files are sorted and non-patch files are ignored",
filesToWrite: []string{"kube-scheduler+merge.json", "kube-apiserver+json.yaml", "etcd.yaml", "foo", "bar.json"},
patchData: patchData,
expectedPatchSets: []*patchSet{
{
targetName: "etcd",
patchType: types.StrategicMergePatchType,
patches: []string{patchData},
},
{
targetName: "kube-apiserver",
patchType: types.JSONPatchType,
patches: []string{patchData},
},
{
targetName: "kube-scheduler",
patchType: types.MergePatchType,
patches: []string{patchData},
},
},
expectedPatchFiles: []string{"etcd.yaml", "kube-apiserver+json.yaml", "kube-scheduler+merge.json"},
expectedIgnoredFiles: []string{"bar.json", "foo"},
},
{
name: "valid: empty files are ignored",
patchData: "",
filesToWrite: []string{"kube-scheduler.json"},
expectedPatchFiles: []string{},
expectedIgnoredFiles: []string{"kube-scheduler.json"},
expectedPatchSets: []*patchSet{},
},
{
name: "invalid: bad patch type in filename returns and error",
filesToWrite: []string{"kube-scheduler+foo.json"},
expectedError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tempDir, err := ioutil.TempDir("", testDirPattern)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
for _, file := range tc.filesToWrite {
filePath := filepath.Join(tempDir, file)
err := ioutil.WriteFile(filePath, []byte(tc.patchData), 0644)
if err != nil {
t.Fatalf("could not write temporary file %q", filePath)
}
}
patchSets, patchFiles, ignoredFiles, err := getPatchSetsFromPath(tempDir, testKnownTargets, ioutil.Discard)
if (err != nil) != tc.expectedError {
t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
}
if !reflect.DeepEqual(tc.expectedPatchFiles, patchFiles) {
t.Fatalf("expected patch files:\n%+v\ngot:\n%+v", tc.expectedPatchFiles, patchFiles)
}
if !reflect.DeepEqual(tc.expectedIgnoredFiles, ignoredFiles) {
t.Fatalf("expected ignored files:\n%+v\ngot:\n%+v", tc.expectedIgnoredFiles, ignoredFiles)
}
if !reflect.DeepEqual(tc.expectedPatchSets, patchSets) {
t.Fatalf("expected patch sets:\n%+v\ngot:\n%+v", tc.expectedPatchSets, patchSets)
}
})
}
}
func TestGetPatchManagerForPath(t *testing.T) {
type file struct {
name string
data string
}
tests := []struct {
name string
files []*file
patchTarget *PatchTarget
expectedData []byte
expectedError bool
}{
{
name: "valid: patch a kube-apiserver target using merge patch; json patch is applied first",
patchTarget: &PatchTarget{
Name: "kube-apiserver",
StrategicMergePatchObject: v1.Pod{},
Data: []byte("foo: bar\nbaz: qux\n"),
},
expectedData: []byte(`{"baz":"qux","foo":"patched"}`),
files: []*file{
{
name: "kube-apiserver+merge.yaml",
data: "foo: patched",
},
{
name: "kube-apiserver+json.json",
data: `[{"op": "replace", "path": "/foo", "value": "zzz"}]`,
},
},
},
{
name: "valid: kube-apiserver target is patched with json patch",
patchTarget: &PatchTarget{
Name: "kube-apiserver",
StrategicMergePatchObject: v1.Pod{},
Data: []byte("foo: bar\n"),
},
expectedData: []byte(`{"foo":"zzz"}`),
files: []*file{
{
name: "kube-apiserver+json.json",
data: `[{"op": "replace", "path": "/foo", "value": "zzz"}]`,
},
},
},
{
name: "valid: kube-apiserver target is patched with strategic merge patch",
patchTarget: &PatchTarget{
Name: "kube-apiserver",
StrategicMergePatchObject: v1.Pod{},
Data: []byte("foo: bar\n"),
},
expectedData: []byte(`{"foo":"zzz"}`),
files: []*file{
{
name: "kube-apiserver+strategic.json",
data: `{"foo":"zzz"}`,
},
},
},
{
name: "valid: etcd target is not changed because there are no patches for it",
patchTarget: &PatchTarget{
Name: "etcd",
StrategicMergePatchObject: v1.Pod{},
Data: []byte("foo: bar\n"),
},
expectedData: []byte("foo: bar\n"),
files: []*file{
{
name: "kube-apiserver+merge.yaml",
data: "foo: patched",
},
},
},
{
name: "invalid: cannot patch etcd target due to malformed json patch",
patchTarget: &PatchTarget{
Name: "etcd",
StrategicMergePatchObject: v1.Pod{},
Data: []byte("foo: bar\n"),
},
files: []*file{
{
name: "etcd+json.json",
data: `{"foo":"zzz"}`,
},
},
expectedError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tempDir, err := ioutil.TempDir("", testDirPattern)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
for _, file := range tc.files {
filePath := filepath.Join(tempDir, file.name)
err := ioutil.WriteFile(filePath, []byte(file.data), 0644)
if err != nil {
t.Fatalf("could not write temporary file %q", filePath)
}
}
pm, err := GetPatchManagerForPath(tempDir, testKnownTargets, nil)
if err != nil {
t.Fatal(err)
}
err = pm.ApplyPatchesToTarget(tc.patchTarget)
if (err != nil) != tc.expectedError {
t.Fatalf("expected error: %v, got: %v, error: %v", tc.expectedError, err != nil, err)
}
if err != nil {
return
}
if !bytes.Equal(tc.patchTarget.Data, tc.expectedData) {
t.Fatalf("expected result:\n%s\ngot:\n%s", tc.expectedData, tc.patchTarget.Data)
}
})
}
}
func TestGetPatchManagerForPathCache(t *testing.T) {
tempDir, err := ioutil.TempDir("", testDirPattern)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
pmOld, err := GetPatchManagerForPath(tempDir, testKnownTargets, nil)
if err != nil {
t.Fatal(err)
}
pmNew, err := GetPatchManagerForPath(tempDir, testKnownTargets, nil)
if err != nil {
t.Fatal(err)
}
if pmOld != pmNew {
t.Logf("path %q was not cached, expected pointer: %p, got: %p", tempDir, pmOld, pmNew)
}
}