diff --git a/cmd/flux/main.go b/cmd/flux/main.go index 9295e83e..3ef4abac 100644 --- a/cmd/flux/main.go +++ b/cmd/flux/main.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" _ "k8s.io/client-go/plugin/pkg/client/auth" + "github.com/fluxcd/flux2/internal/utils" "github.com/fluxcd/flux2/pkg/manifestgen/install" ) @@ -126,6 +127,18 @@ func NewRootFlags() rootFlags { return rf } +type rootContext struct { + kubeManager utils.KubeManager +} + +var rootCtx = NewRootContext() + +func NewRootContext() rootContext { + var rc rootContext + rc.kubeManager = utils.DefaultKubeManager() + return rc +} + func main() { log.SetFlags(0) configureKubeconfig() diff --git a/cmd/flux/testdata/trace/deployment.txt b/cmd/flux/testdata/trace/deployment.txt new file mode 100644 index 00000000..04ad9399 --- /dev/null +++ b/cmd/flux/testdata/trace/deployment.txt @@ -0,0 +1,26 @@ + +Object: deployment/podinfo +Namespace: podinfo +Status: Managed by Flux +--- +HelmRelease: podinfo +Namespace: podinfo +Revision: 6.0.0 +Status: Last reconciled at 2021-07-16 15:42:20 +0000 UTC +Message: Release reconciliation succeeded +--- +HelmChart: podinfo-podinfo +Namespace: flux-system +Chart: podinfo +Version: 6.0.0 +Revision: 6.0.0 +Status: Last reconciled at 2021-07-16 15:32:09 +0000 UTC +Message: Fetched revision: 6.0.0 +--- +HelmRepository: podinfo +Namespace: flux-system +URL: https://stefanprodan.github.io/podinfo +Revision: 8411f23d07d3701f0e96e7d9e503b7936d7e1d56 +Status: Last reconciled at 2021-07-11 00:25:46 +0000 UTC +Message: Fetched revision: 8411f23d07d3701f0e96e7d9e503b7936d7e1d56 + diff --git a/cmd/flux/testdata/trace/deployment.yaml b/cmd/flux/testdata/trace/deployment.yaml new file mode 100644 index 00000000..74fc5e46 --- /dev/null +++ b/cmd/flux/testdata/trace/deployment.yaml @@ -0,0 +1,129 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/name: podinfo + helm.toolkit.fluxcd.io/name: podinfo + helm.toolkit.fluxcd.io/namespace: podinfo + name: podinfo + namespace: podinfo +spec: + replicas: 1 + template: + metadata: + labels: + app.kubernetes.io/name: podinfo + spec: +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infrastructure + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: podinfo + namespace: podinfo +spec: + chart: + spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + namespace: flux-system +status: + conditions: + - lastTransitionTime: "2021-07-16T15:42:20Z" + message: Release reconciliation succeeded + reason: ReconciliationSucceeded + status: "True" + type: Ready + helmChart: flux-system/podinfo-podinfo + lastAppliedRevision: 6.0.0 + lastAttemptedRevision: 6.0.0 + lastAttemptedValuesChecksum: c31db75d05b7515eba2eef47bd71038c74b2e531 +--- +apiVersion: source.toolkit.fluxcd.io/v1beta1 +kind: HelmChart +metadata: + name: podinfo-podinfo + namespace: flux-system +spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + version: 6.0.0 +status: + artifact: + checksum: cf13ba96773d9a879cd052c86e73199b3f96c854 + lastUpdateTime: "2021-08-01T04:42:55Z" + revision: 6.0.0 + conditions: + - lastTransitionTime: "2021-07-16T15:32:09Z" + message: 'Fetched revision: 6.0.0' + reason: ChartPullSucceeded + status: "True" + type: Ready + +--- +apiVersion: source.toolkit.fluxcd.io/v1beta1 +kind: HelmRepository +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infrastructure + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: podinfo + namespace: flux-system +spec: + interval: 5m + timeout: 1m0s + url: https://stefanprodan.github.io/podinfo +status: + artifact: + checksum: 8411f23d07d3701f0e96e7d9e503b7936d7e1d56 + lastUpdateTime: "2021-07-11T00:25:46Z" + revision: 8411f23d07d3701f0e96e7d9e503b7936d7e1d56 + conditions: + - lastTransitionTime: "2021-07-11T00:25:46Z" + message: 'Fetched revision: 8411f23d07d3701f0e96e7d9e503b7936d7e1d56' + reason: IndexationSucceed + status: "True" + type: Ready +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1beta1 +kind: Kustomization +metadata: + name: infrastructure + namespace: flux-system +spec: + path: ./infrastructure/ + sourceRef: + kind: GitRepository + name: flux-system + validation: client +status: + conditions: + - lastTransitionTime: "2021-08-01T04:52:56Z" + message: 'Applied revision: main/696f056df216eea4f9401adbee0ff744d4df390f' + reason: ReconciliationSucceeded + status: "True" + type: Ready +--- +apiVersion: source.toolkit.fluxcd.io/v1beta1 +kind: GitRepository +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: flux-system + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: flux-system + namespace: flux-system +spec: + gitImplementation: go-git + ref: + branch: main + secretRef: + name: flux-system + diff --git a/cmd/flux/testdata/trace/helmrelease-missing-git-ref.txt b/cmd/flux/testdata/trace/helmrelease-missing-git-ref.txt new file mode 100644 index 00000000..f43f28cc --- /dev/null +++ b/cmd/flux/testdata/trace/helmrelease-missing-git-ref.txt @@ -0,0 +1,19 @@ + +Object: HelmRelease/podinfo +Namespace: podinfo +Status: Managed by Flux +--- +Kustomization: infrastructure +Namespace: flux-system +Path: ./infrastructure +Revision: main/696f056df216eea4f9401adbee0ff744d4df390f +Status: Last reconciled at 2021-08-01 04:52:56 +0000 UTC +Message: Applied revision: main/696f056df216eea4f9401adbee0ff744d4df390f +--- +GitRepository: flux-system +Namespace: flux-system +URL: ssh://git@github.com/example/repo +Revision: main/696f056df216eea4f9401adbee0ff744d4df390f +Status: Last reconciled at 2021-07-20 00:48:16 +0000 UTC +Message: Fetched revision: main/696f056df216eea4f9401adbee0ff744d4df390f + diff --git a/cmd/flux/testdata/trace/helmrelease-missing-git-ref.yaml b/cmd/flux/testdata/trace/helmrelease-missing-git-ref.yaml new file mode 100644 index 00000000..dee892ae --- /dev/null +++ b/cmd/flux/testdata/trace/helmrelease-missing-git-ref.yaml @@ -0,0 +1,73 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infrastructure + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: podinfo + namespace: podinfo +spec: + chart: + spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + namespace: flux-system +status: + conditions: + - lastTransitionTime: "2021-07-16T15:42:20Z" + message: Release reconciliation succeeded + reason: ReconciliationSucceeded + status: "True" + type: Ready + helmChart: flux-system/podinfo-podinfo + lastAppliedRevision: 6.0.0 + lastAttemptedRevision: 6.0.0 + lastAttemptedValuesChecksum: c31db75d05b7515eba2eef47bd71038c74b2e531 +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1beta1 +kind: Kustomization +metadata: + name: infrastructure + namespace: flux-system +spec: + path: ./infrastructure + sourceRef: + kind: GitRepository + name: flux-system + validation: client +status: + conditions: + - lastTransitionTime: "2021-08-01T04:52:56Z" + message: 'Applied revision: main/696f056df216eea4f9401adbee0ff744d4df390f' + reason: ReconciliationSucceeded + status: "True" + type: Ready + lastAppliedRevision: main/696f056df216eea4f9401adbee0ff744d4df390f +--- +apiVersion: source.toolkit.fluxcd.io/v1beta1 +kind: GitRepository +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: flux-system + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: flux-system + namespace: flux-system +spec: + gitImplementation: go-git + secretRef: + name: flux-system + url: ssh://git@github.com/example/repo +status: + artifact: + lastUpdateTime: "2021-08-01T04:28:42Z" + revision: main/696f056df216eea4f9401adbee0ff744d4df390f + conditions: + - lastTransitionTime: "2021-07-20T00:48:16Z" + message: 'Fetched revision: main/696f056df216eea4f9401adbee0ff744d4df390f' + reason: GitOperationSucceed + status: "True" + type: Ready + diff --git a/cmd/flux/testdata/trace/helmrelease.txt b/cmd/flux/testdata/trace/helmrelease.txt new file mode 100644 index 00000000..ac075ae8 --- /dev/null +++ b/cmd/flux/testdata/trace/helmrelease.txt @@ -0,0 +1,20 @@ + +Object: HelmRelease/podinfo +Namespace: podinfo +Status: Managed by Flux +--- +Kustomization: infrastructure +Namespace: flux-system +Path: ./infrastructure +Revision: main/696f056df216eea4f9401adbee0ff744d4df390f +Status: Last reconciled at 2021-08-01 04:52:56 +0000 UTC +Message: Applied revision: main/696f056df216eea4f9401adbee0ff744d4df390f +--- +GitRepository: flux-system +Namespace: flux-system +URL: ssh://git@github.com/example/repo +Branch: main +Revision: main/696f056df216eea4f9401adbee0ff744d4df390f +Status: Last reconciled at 2021-07-20 00:48:16 +0000 UTC +Message: Fetched revision: main/696f056df216eea4f9401adbee0ff744d4df390f + diff --git a/cmd/flux/testdata/trace/helmrelease.yaml b/cmd/flux/testdata/trace/helmrelease.yaml new file mode 100644 index 00000000..4f0db83d --- /dev/null +++ b/cmd/flux/testdata/trace/helmrelease.yaml @@ -0,0 +1,75 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: infrastructure + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: podinfo + namespace: podinfo +spec: + chart: + spec: + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + namespace: flux-system +status: + conditions: + - lastTransitionTime: "2021-07-16T15:42:20Z" + message: Release reconciliation succeeded + reason: ReconciliationSucceeded + status: "True" + type: Ready + helmChart: flux-system/podinfo-podinfo + lastAppliedRevision: 6.0.0 + lastAttemptedRevision: 6.0.0 + lastAttemptedValuesChecksum: c31db75d05b7515eba2eef47bd71038c74b2e531 +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1beta1 +kind: Kustomization +metadata: + name: infrastructure + namespace: flux-system +spec: + path: ./infrastructure + sourceRef: + kind: GitRepository + name: flux-system + validation: client +status: + conditions: + - lastTransitionTime: "2021-08-01T04:52:56Z" + message: 'Applied revision: main/696f056df216eea4f9401adbee0ff744d4df390f' + reason: ReconciliationSucceeded + status: "True" + type: Ready + lastAppliedRevision: main/696f056df216eea4f9401adbee0ff744d4df390f +--- +apiVersion: source.toolkit.fluxcd.io/v1beta1 +kind: GitRepository +metadata: + labels: + kustomize.toolkit.fluxcd.io/name: flux-system + kustomize.toolkit.fluxcd.io/namespace: flux-system + name: flux-system + namespace: flux-system +spec: + gitImplementation: go-git + ref: + branch: main + secretRef: + name: flux-system + url: ssh://git@github.com/example/repo +status: + artifact: + lastUpdateTime: "2021-08-01T04:28:42Z" + revision: main/696f056df216eea4f9401adbee0ff744d4df390f + conditions: + - lastTransitionTime: "2021-07-20T00:48:16Z" + message: 'Fetched revision: main/696f056df216eea4f9401adbee0ff744d4df390f' + reason: GitOperationSucceed + status: "True" + type: Ready + diff --git a/cmd/flux/testdata/trace/no-args.txt b/cmd/flux/testdata/trace/no-args.txt new file mode 100644 index 00000000..4f71b995 --- /dev/null +++ b/cmd/flux/testdata/trace/no-args.txt @@ -0,0 +1 @@ +object name is required diff --git a/cmd/flux/trace.go b/cmd/flux/trace.go index d7ee022e..c18449f1 100644 --- a/cmd/flux/trace.go +++ b/cmd/flux/trace.go @@ -89,7 +89,7 @@ func traceCmdRun(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) defer cancel() - kubeClient, err := utils.KubeClient(rootArgs.kubeconfig, rootArgs.kubecontext) + kubeClient, err := rootCtx.kubeManager.NewClient(rootArgs.kubeconfig, rootArgs.kubecontext) if err != nil { return err } @@ -121,7 +121,7 @@ func traceCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } - fmt.Println(report) + rootCmd.Print(report) return nil } @@ -130,7 +130,7 @@ func traceCmdRun(cmd *cobra.Command, args []string) error { if err != nil { return err } - fmt.Println(report) + rootCmd.Print(report) return nil } diff --git a/cmd/flux/trace_test.go b/cmd/flux/trace_test.go new file mode 100644 index 00000000..e194be2a --- /dev/null +++ b/cmd/flux/trace_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "io" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/fluxcd/flux2/internal/utils" + "github.com/google/go-cmp/cmp" + shellwords "github.com/mattn/go-shellwords" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func init() { + // Ensure tests print consistent timestamps regardless of timezone + os.Setenv("TZ", "UTC") +} + +func readYamlObjects(objectFile string) ([]client.Object, error) { + obj, err := ioutil.ReadFile(objectFile) + if err != nil { + return nil, err + } + objects := []client.Object{} + reader := k8syaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(obj))) + for { + doc, err := reader.Read() + if err != nil { + if err == io.EOF { + break + } + } + unstructuredObj := &unstructured.Unstructured{} + decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewBuffer(doc), len(doc)) + err = decoder.Decode(unstructuredObj) + if err != nil { + return nil, err + } + objects = append(objects, unstructuredObj) + } + return objects, nil +} + +// A KubeManager that can create objects that are subject to a test. +type fakeKubeManager struct { + fakeClient client.Client +} + +func (m *fakeKubeManager) NewClient(kubeconfig string, kubecontext string) (client.Client, error) { + return m.fakeClient, nil +} + +func (m *fakeKubeManager) CreateObjects(clientObjects []client.Object) error { + for _, obj := range clientObjects { + err := m.fakeClient.Create(context.Background(), obj) + if err != nil { + return err + } + } + return nil +} + +func NewFakeKubeManager() *fakeKubeManager { + c := fakeclient.NewClientBuilder().WithScheme(utils.NewScheme()).Build() + return &fakeKubeManager{ + fakeClient: c, + } +} + +// Run the command and return the captured output. +func executeCommand(cmd string) (string, error) { + args, err := shellwords.Parse(cmd) + if err != nil { + return "", err + } + + buf := new(bytes.Buffer) + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs(args) + + _, err = rootCmd.ExecuteC() + result := buf.String() + + return result, err +} + +// Structure used for each test to load objects into kubernetes, run +// commands and assert on the expected output. +type cmdTestCase struct { + // The command line arguments to test. + args string + // When true, the test expects the command to fail. + wantError bool + // Filename that contains the expected test output. + goldenFile string + // Filename that contains yaml objects to load into Kubernetes + objectFile string +} + +func (cmd *cmdTestCase) runTestCmd(t *testing.T) { + km := NewFakeKubeManager() + rootCtx.kubeManager = km + + if cmd.objectFile != "" { + clientObjects, err := readYamlObjects(cmd.objectFile) + if err != nil { + t.Fatalf("Error loading yaml: '%v'", err) + } + err = km.CreateObjects(clientObjects) + if err != nil { + t.Fatalf("Error creating test objects: '%v'", err) + } + } + + actual, err := executeCommand(cmd.args) + if (err != nil) != cmd.wantError { + t.Fatalf("Expected error='%v', Got: %v", cmd.wantError, err) + } + if err != nil { + actual = err.Error() + } + contents, err := ioutil.ReadFile(cmd.goldenFile) + if err != nil { + t.Fatalf("Error reading golden file: '%s'", err) + } + expected := strings.TrimSuffix(string(contents), "\n") + diff := cmp.Diff(expected, actual) + if diff != "" { + t.Errorf("Mismatch from '%s' (-want +got):\n%s", cmd.goldenFile, diff) + } +} + +func TestTraceNoArgs(t *testing.T) { + cmd := cmdTestCase{ + args: "trace", + wantError: true, + goldenFile: "testdata/trace/no-args.txt", + } + cmd.runTestCmd(t) +} + +func TestTraceDeployment(t *testing.T) { + cmd := cmdTestCase{ + args: "trace podinfo -n podinfo --kind deployment --api-version=apps/v1", + wantError: false, + goldenFile: "testdata/trace/deployment.txt", + objectFile: "testdata/trace/deployment.yaml", + } + cmd.runTestCmd(t) +} + +func TestTraceHelmRelease(t *testing.T) { + cmd := cmdTestCase{ + args: "trace podinfo -n podinfo --kind HelmRelease --api-version=helm.toolkit.fluxcd.io/v2beta1", + wantError: false, + goldenFile: "testdata/trace/helmrelease.txt", + objectFile: "testdata/trace/helmrelease.yaml", + } + cmd.runTestCmd(t) +} + +func TestTraceHelmReleaseMissingGitRef(t *testing.T) { + cmd := cmdTestCase{ + args: "trace podinfo -n podinfo --kind HelmRelease --api-version=helm.toolkit.fluxcd.io/v2beta1", + wantError: false, + goldenFile: "testdata/trace/helmrelease-missing-git-ref.txt", + objectFile: "testdata/trace/helmrelease-missing-git-ref.yaml", + } + cmd.runTestCmd(t) +} diff --git a/go.mod b/go.mod index ed4d8972..e89ce2e3 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,10 @@ require ( github.com/fluxcd/pkg/version v0.0.1 github.com/fluxcd/source-controller/api v0.15.3 github.com/go-git/go-git/v5 v5.4.2 + github.com/google/go-cmp v0.5.5 github.com/google/go-containerregistry v0.2.0 github.com/manifoldco/promptui v0.7.0 + github.com/mattn/go-shellwords v1.0.12 github.com/olekukonko/tablewriter v0.0.4 github.com/spf13/cobra v1.1.3 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index ba5342d4..a8695f81 100644 --- a/go.sum +++ b/go.sum @@ -537,6 +537,8 @@ github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY= diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 50b903fa..b6e1ec90 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -131,12 +131,39 @@ func KubeConfig(kubeConfigPath string, kubeContext string) (*rest.Config, error) return cfg, nil } -func KubeClient(kubeConfigPath string, kubeContext string) (client.Client, error) { +// 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) @@ -149,15 +176,13 @@ func KubeClient(kubeConfigPath string, kubeContext string) (client.Client, error _ = notificationv1.AddToScheme(scheme) _ = imagereflectv1.AddToScheme(scheme) _ = imageautov1.AddToScheme(scheme) + return scheme +} - 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 +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