mirror of
https://github.com/fluxcd/flux2.git
synced 2025-10-28 23:14:48 +08:00
Add tests for flux trace command that fake out the kubernetes client, load objects from a yaml file and create them in the client, and assert on the output of the trace command to an expected golden file. This is a follow up from the suggestions in PR https://github.com/fluxcd/flux2/pull/1626 which suggested that additional testing would be helpful. This test approach is modeled after the helm command tests. This required some changes to the kubernetes client setup to make it possible to use a fake. If we agree this pattern makes sense, it can be applied to other commands. Signed-off-by: Allen Porter <allen@thebends.org>
367 lines
9.5 KiB
Go
367 lines
9.5 KiB
Go
/*
|
|
Copyright 2020 The Flux 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 utils
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/olekukonko/tablewriter"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
networkingv1 "k8s.io/api/networking/v1"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
apiruntime "k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
sigyaml "k8s.io/apimachinery/pkg/util/yaml"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/yaml"
|
|
|
|
helmv2 "github.com/fluxcd/helm-controller/api/v2beta1"
|
|
imageautov1 "github.com/fluxcd/image-automation-controller/api/v1beta1"
|
|
imagereflectv1 "github.com/fluxcd/image-reflector-controller/api/v1beta1"
|
|
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1"
|
|
notificationv1 "github.com/fluxcd/notification-controller/api/v1beta1"
|
|
"github.com/fluxcd/pkg/runtime/dependency"
|
|
"github.com/fluxcd/pkg/version"
|
|
sourcev1 "github.com/fluxcd/source-controller/api/v1beta1"
|
|
|
|
"github.com/fluxcd/flux2/pkg/manifestgen/install"
|
|
)
|
|
|
|
type Utils struct {
|
|
}
|
|
|
|
type ExecMode string
|
|
|
|
const (
|
|
ModeOS ExecMode = "os.stderr|stdout"
|
|
ModeStderrOS ExecMode = "os.stderr"
|
|
ModeCapture ExecMode = "capture.stderr|stdout"
|
|
)
|
|
|
|
func ExecKubectlCommand(ctx context.Context, mode ExecMode, kubeConfigPath string, kubeContext string, args ...string) (string, error) {
|
|
var stdoutBuf, stderrBuf bytes.Buffer
|
|
|
|
if kubeConfigPath != "" && len(filepath.SplitList(kubeConfigPath)) == 1 {
|
|
args = append(args, "--kubeconfig="+kubeConfigPath)
|
|
}
|
|
|
|
if kubeContext != "" {
|
|
args = append(args, "--context="+kubeContext)
|
|
}
|
|
|
|
c := exec.CommandContext(ctx, "kubectl", args...)
|
|
|
|
if mode == ModeStderrOS {
|
|
c.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
|
|
}
|
|
if mode == ModeOS {
|
|
c.Stdout = io.MultiWriter(os.Stdout, &stdoutBuf)
|
|
c.Stderr = io.MultiWriter(os.Stderr, &stderrBuf)
|
|
}
|
|
|
|
if mode == ModeStderrOS || mode == ModeOS {
|
|
if err := c.Run(); err != nil {
|
|
return "", err
|
|
} else {
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
if mode == ModeCapture {
|
|
c.Stdout = &stdoutBuf
|
|
c.Stderr = &stderrBuf
|
|
if err := c.Run(); err != nil {
|
|
return stderrBuf.String(), err
|
|
} else {
|
|
return stdoutBuf.String(), nil
|
|
}
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func KubeConfig(kubeConfigPath string, kubeContext string) (*rest.Config, error) {
|
|
configFiles := SplitKubeConfigPath(kubeConfigPath)
|
|
configOverrides := clientcmd.ConfigOverrides{}
|
|
|
|
if len(kubeContext) > 0 {
|
|
configOverrides.CurrentContext = kubeContext
|
|
}
|
|
|
|
cfg, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
|
&clientcmd.ClientConfigLoadingRules{Precedence: configFiles},
|
|
&configOverrides,
|
|
).ClientConfig()
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kubernetes configuration load failed: %w", err)
|
|
}
|
|
|
|
// avoid throttling request when some Flux CRDs are not registered
|
|
cfg.QPS = 50
|
|
cfg.Burst = 100
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// KubeManger creates a Kubernetes client.Client. This interface exists to
|
|
// facilitate unit testing and provide a fake client.
|
|
type KubeManager interface {
|
|
NewClient(string, string) (client.Client, error)
|
|
}
|
|
|
|
type defaultKubeManager struct{}
|
|
|
|
func DefaultKubeManager() KubeManager {
|
|
var manager defaultKubeManager
|
|
return manager
|
|
}
|
|
|
|
func (m defaultKubeManager) NewClient(kubeConfigPath string, kubeContext string) (client.Client, error) {
|
|
cfg, err := KubeConfig(kubeConfigPath, kubeContext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kubernetes client initialization failed: %w", err)
|
|
}
|
|
|
|
scheme := NewScheme()
|
|
kubeClient, err := client.New(cfg, client.Options{
|
|
Scheme: scheme,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kubernetes client initialization failed: %w", err)
|
|
}
|
|
|
|
return kubeClient, nil
|
|
}
|
|
|
|
// Create the Scheme, methods for serializing and deserializing API objects
|
|
// which can be shared by tests.
|
|
func NewScheme() *apiruntime.Scheme {
|
|
scheme := apiruntime.NewScheme()
|
|
_ = apiextensionsv1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = rbacv1.AddToScheme(scheme)
|
|
_ = appsv1.AddToScheme(scheme)
|
|
_ = networkingv1.AddToScheme(scheme)
|
|
_ = sourcev1.AddToScheme(scheme)
|
|
_ = kustomizev1.AddToScheme(scheme)
|
|
_ = helmv2.AddToScheme(scheme)
|
|
_ = notificationv1.AddToScheme(scheme)
|
|
_ = imagereflectv1.AddToScheme(scheme)
|
|
_ = imageautov1.AddToScheme(scheme)
|
|
return scheme
|
|
}
|
|
|
|
func KubeClient(kubeConfigPath string, kubeContext string) (client.Client, error) {
|
|
m := DefaultKubeManager()
|
|
kubeClient, err := m.NewClient(kubeConfigPath, kubeContext)
|
|
return kubeClient, err
|
|
}
|
|
|
|
// SplitKubeConfigPath splits the given KUBECONFIG path based on the runtime OS
|
|
// target.
|
|
//
|
|
// Ref: https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#the-kubeconfig-environment-variable
|
|
func SplitKubeConfigPath(path string) []string {
|
|
var sep string
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
sep = ";"
|
|
default:
|
|
sep = ":"
|
|
}
|
|
return strings.Split(path, sep)
|
|
}
|
|
|
|
func ContainsItemString(s []string, e string) bool {
|
|
for _, a := range s {
|
|
if a == e {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ContainsEqualFoldItemString(s []string, e string) (string, bool) {
|
|
for _, a := range s {
|
|
if strings.EqualFold(a, e) {
|
|
return a, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// ParseNamespacedName extracts the NamespacedName of a resource
|
|
// based on the '<namespace>/<name>' format
|
|
func ParseNamespacedName(input string) types.NamespacedName {
|
|
parts := strings.Split(input, "/")
|
|
if len(parts) == 2 {
|
|
return types.NamespacedName{
|
|
Namespace: parts[0],
|
|
Name: parts[1],
|
|
}
|
|
}
|
|
return types.NamespacedName{
|
|
Name: input,
|
|
}
|
|
}
|
|
|
|
// ParseObjectKindName extracts the kind and name of a resource
|
|
// based on the '<kind>/<name>' format
|
|
func ParseObjectKindName(input string) (kind, name string) {
|
|
name = input
|
|
parts := strings.Split(input, "/")
|
|
if len(parts) == 2 {
|
|
kind, name = parts[0], parts[1]
|
|
}
|
|
return kind, name
|
|
}
|
|
|
|
// ParseObjectKindNameNamespace extracts the kind, name and namespace of a resource
|
|
// based on the '<kind>/<name>.<namespace>' format
|
|
func ParseObjectKindNameNamespace(input string) (kind, name, namespace string) {
|
|
kind, name = ParseObjectKindName(input)
|
|
|
|
if nn := strings.Split(name, "."); len(nn) > 1 {
|
|
name = strings.Join(nn[:len(nn)-1], ".")
|
|
namespace = nn[len(nn)-1]
|
|
}
|
|
|
|
return kind, name, namespace
|
|
}
|
|
|
|
func MakeDependsOn(deps []string) []dependency.CrossNamespaceDependencyReference {
|
|
refs := []dependency.CrossNamespaceDependencyReference{}
|
|
for _, dep := range deps {
|
|
parts := strings.Split(dep, "/")
|
|
depNamespace := ""
|
|
depName := ""
|
|
if len(parts) > 1 {
|
|
depNamespace = parts[0]
|
|
depName = parts[1]
|
|
} else {
|
|
depName = parts[0]
|
|
}
|
|
refs = append(refs, dependency.CrossNamespaceDependencyReference{
|
|
Namespace: depNamespace,
|
|
Name: depName,
|
|
})
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func PrintTable(writer io.Writer, header []string, rows [][]string) {
|
|
table := tablewriter.NewWriter(writer)
|
|
table.SetHeader(header)
|
|
table.SetAutoWrapText(false)
|
|
table.SetAutoFormatHeaders(true)
|
|
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
|
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
|
table.SetCenterSeparator("")
|
|
table.SetColumnSeparator("")
|
|
table.SetRowSeparator("")
|
|
table.SetHeaderLine(false)
|
|
table.SetBorder(false)
|
|
table.SetTablePadding("\t")
|
|
table.SetNoWhiteSpace(true)
|
|
table.AppendBulk(rows)
|
|
table.Render()
|
|
}
|
|
|
|
func ValidateComponents(components []string) error {
|
|
defaults := install.MakeDefaultOptions()
|
|
bootstrapAllComponents := append(defaults.Components, defaults.ComponentsExtra...)
|
|
for _, component := range components {
|
|
if !ContainsItemString(bootstrapAllComponents, component) {
|
|
return fmt.Errorf("component %s is not available", component)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CompatibleVersion returns if the provided binary version is compatible
|
|
// with the given target version. At present, this is true if the target
|
|
// version is equal to the MINOR range of the binary, or if the binary
|
|
// version is a prerelease.
|
|
func CompatibleVersion(binary, target string) bool {
|
|
binSv, err := version.ParseVersion(binary)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Assume prerelease builds are compatible.
|
|
if binSv.Prerelease() != "" {
|
|
return true
|
|
}
|
|
targetSv, err := version.ParseVersion(target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return binSv.Major() == targetSv.Major() && binSv.Minor() == targetSv.Minor()
|
|
}
|
|
|
|
func ExtractCRDs(inManifestPath, outManifestPath string) error {
|
|
manifests, err := os.ReadFile(inManifestPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
crds := ""
|
|
reader := sigyaml.NewYAMLOrJSONDecoder(bytes.NewReader(manifests), 2048)
|
|
|
|
for {
|
|
var obj unstructured.Unstructured
|
|
err := reader.Decode(&obj)
|
|
if err == io.EOF {
|
|
break
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
if obj.GetKind() == "CustomResourceDefinition" {
|
|
b, err := obj.MarshalJSON()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
y, err := yaml.JSONToYAML(b)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
crds += "---\n" + string(y)
|
|
}
|
|
}
|
|
|
|
if crds == "" {
|
|
return fmt.Errorf("no CRDs found in %s", inManifestPath)
|
|
}
|
|
|
|
return os.WriteFile(outManifestPath, []byte(crds), os.ModePerm)
|
|
}
|