diff --git a/cmd/flux/trace.go b/cmd/flux/trace.go new file mode 100644 index 00000000..eee3da03 --- /dev/null +++ b/cmd/flux/trace.go @@ -0,0 +1,435 @@ +/* +Copyright 2021 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 main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "text/template" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/fluxcd/flux2/internal/utils" + helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" + fluxmeta "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" +) + +var traceCmd = &cobra.Command{ + Use: "trace [name]", + Short: "trace an in-cluster object throughout the GitOps delivery pipeline", + Long: `The trace command shows how an object is managed by Flux, +from which source and revision it comes, and what's the latest reconciliation status.'`, + Example: ` # Trace a Kubernetes Deployment + flux trace my-app --kind=deployment --api-version=apps/v1 --namespace=apps`, + RunE: traceCmdRun, +} + +type traceFlags struct { + apiVersion string + kind string +} + +var traceArgs = traceFlags{} + +func init() { + traceCmd.Flags().StringVar(&traceArgs.kind, "kind", "", + "the Kubernetes object kind, e.g. Deployment'") + traceCmd.Flags().StringVar(&traceArgs.apiVersion, "api-version", "", + "the Kubernetes object API version, e.g. 'apps/v1'") + rootCmd.AddCommand(traceCmd) +} + +func traceCmdRun(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("object name is required") + } + name := args[0] + + if traceArgs.kind == "" { + return fmt.Errorf("object kind is required (--kind)") + } + + if traceArgs.apiVersion == "" { + return fmt.Errorf("object apiVersion is required (--api-version)") + } + + ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + + kubeClient, err := utils.KubeClient(rootArgs.kubeconfig, rootArgs.kubecontext) + if err != nil { + return err + } + + gv, err := schema.ParseGroupVersion(traceArgs.apiVersion) + if err != nil { + return fmt.Errorf("invaild apiVersion: %w", err) + } + + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: traceArgs.kind, + }) + + objName := types.NamespacedName{ + Namespace: rootArgs.namespace, + Name: name, + } + + err = kubeClient.Get(ctx, objName, obj) + if err != nil { + return fmt.Errorf("failed to find object: %w", err) + } + + if ks, ok := isManagedByFlux(obj, kustomizev1.GroupVersion.Group); ok { + report, err := traceKustomization(ctx, kubeClient, ks, obj) + if err != nil { + return err + } + fmt.Println(report) + return nil + } + + if hr, ok := isManagedByFlux(obj, helmv2.GroupVersion.Group); ok { + report, err := traceHelm(ctx, kubeClient, hr, obj) + if err != nil { + return err + } + fmt.Println(report) + return nil + } + + return fmt.Errorf("object not managed by Flux") +} + +func traceKustomization(ctx context.Context, kubeClient client.Client, ksName types.NamespacedName, obj *unstructured.Unstructured) (string, error) { + ks := &kustomizev1.Kustomization{} + ksReady := &metav1.Condition{} + err := kubeClient.Get(ctx, ksName, ks) + if err != nil { + return "", fmt.Errorf("failed to find kustomization: %w", err) + } + ksReady = meta.FindStatusCondition(ks.Status.Conditions, fluxmeta.ReadyCondition) + + var ksRepository *sourcev1.GitRepository + var ksRepositoryReady *metav1.Condition + if ks.Spec.SourceRef.Kind == sourcev1.GitRepositoryKind { + ksRepository = &sourcev1.GitRepository{} + sourceNamespace := ks.Namespace + if ks.Spec.SourceRef.Namespace != "" { + sourceNamespace = ks.Spec.SourceRef.Namespace + } + err = kubeClient.Get(ctx, types.NamespacedName{ + Namespace: sourceNamespace, + Name: ks.Spec.SourceRef.Name, + }, ksRepository) + if err != nil { + return "", fmt.Errorf("failed to find GitRepository: %w", err) + } + ksRepositoryReady = meta.FindStatusCondition(ksRepository.Status.Conditions, fluxmeta.ReadyCondition) + } + + var traceTmpl = ` +Object: {{.ObjectName}} +{{- if .ObjectNamespace }} +Namespace: {{.ObjectNamespace}} +{{- end }} +Status: Managed by Flux +{{- if .Kustomization }} +--- +Kustomization: {{.Kustomization.Name}} +Namespace: {{.Kustomization.Namespace}} +{{- if .Kustomization.Spec.TargetNamespace }} +Target: {{.Kustomization.Spec.TargetNamespace}} +{{- end }} +Path: {{.Kustomization.Spec.Path}} +Revision: {{.Kustomization.Status.LastAppliedRevision}} +{{- if .KustomizationReady }} +Status: Last reconciled at {{.KustomizationReady.LastTransitionTime}} +Message: {{.KustomizationReady.Message}} +{{- else }} +Status: Unknown +{{- end }} +{{- end }} +{{- if .GitRepository }} +--- +GitRepository: {{.GitRepository.Name}} +Namespace: {{.GitRepository.Namespace}} +URL: {{.GitRepository.Spec.URL}} +{{- if .GitRepository.Spec.Reference.Branch }} +Branch: {{.GitRepository.Spec.Reference.Branch}} +{{- end }} +{{- if .GitRepository.Spec.Reference.Tag }} +Tag: {{.GitRepository.Spec.Reference.Tag}} +{{- else if .GitRepository.Spec.Reference.SemVer }} +Tag: {{.GitRepository.Spec.Reference.SemVer}} +{{- else if .GitRepository.Status.Artifact }} +Revision: {{.GitRepository.Status.Artifact.Revision}} +{{- end }} +{{- if .GitRepositoryReady }} +Status: Last reconciled at {{.GitRepositoryReady.LastTransitionTime}} +Message: {{.GitRepositoryReady.Message}} +{{- else }} +Status: Unknown +{{- end }} +{{- end }} +` + + traceResult := struct { + ObjectName string + ObjectNamespace string + Kustomization *kustomizev1.Kustomization + KustomizationReady *metav1.Condition + GitRepository *sourcev1.GitRepository + GitRepositoryReady *metav1.Condition + }{ + ObjectName: obj.GetKind() + "/" + obj.GetName(), + ObjectNamespace: obj.GetNamespace(), + Kustomization: ks, + KustomizationReady: ksReady, + GitRepository: ksRepository, + GitRepositoryReady: ksRepositoryReady, + } + + t, err := template.New("tmpl").Parse(traceTmpl) + if err != nil { + return "", err + } + + var data bytes.Buffer + writer := bufio.NewWriter(&data) + if err := t.Execute(writer, traceResult); err != nil { + return "", err + } + + if err := writer.Flush(); err != nil { + return "", err + } + + return data.String(), nil +} + +func traceHelm(ctx context.Context, kubeClient client.Client, hrName types.NamespacedName, obj *unstructured.Unstructured) (string, error) { + hr := &helmv2.HelmRelease{} + hrReady := &metav1.Condition{} + err := kubeClient.Get(ctx, hrName, hr) + if err != nil { + return "", fmt.Errorf("failed to find HelmRelease: %w", err) + } + hrReady = meta.FindStatusCondition(hr.Status.Conditions, fluxmeta.ReadyCondition) + + var hrChart *sourcev1.HelmChart + var hrChartReady *metav1.Condition + if chart := hr.Status.HelmChart; chart != "" { + hrChart = &sourcev1.HelmChart{} + err = kubeClient.Get(ctx, utils.ParseNamespacedName(chart), hrChart) + if err != nil { + return "", fmt.Errorf("failed to find HelmChart: %w", err) + } + hrChartReady = meta.FindStatusCondition(hrChart.Status.Conditions, fluxmeta.ReadyCondition) + } + + var hrGitRepository *sourcev1.GitRepository + var hrGitRepositoryReady *metav1.Condition + if hr.Spec.Chart.Spec.SourceRef.Kind == sourcev1.GitRepositoryKind { + hrGitRepository = &sourcev1.GitRepository{} + sourceNamespace := hr.Namespace + if hr.Spec.Chart.Spec.SourceRef.Namespace != "" { + sourceNamespace = hr.Spec.Chart.Spec.SourceRef.Namespace + } + err = kubeClient.Get(ctx, types.NamespacedName{ + Namespace: sourceNamespace, + Name: hr.Spec.Chart.Spec.SourceRef.Name, + }, hrGitRepository) + if err != nil { + return "", fmt.Errorf("failed to find GitRepository: %w", err) + } + hrGitRepositoryReady = meta.FindStatusCondition(hrGitRepository.Status.Conditions, fluxmeta.ReadyCondition) + } + + var hrHelmRepository *sourcev1.HelmRepository + var hrHelmRepositoryReady *metav1.Condition + if hr.Spec.Chart.Spec.SourceRef.Kind == sourcev1.HelmRepositoryKind { + hrHelmRepository = &sourcev1.HelmRepository{} + sourceNamespace := hr.Namespace + if hr.Spec.Chart.Spec.SourceRef.Namespace != "" { + sourceNamespace = hr.Spec.Chart.Spec.SourceRef.Namespace + } + err = kubeClient.Get(ctx, types.NamespacedName{ + Namespace: sourceNamespace, + Name: hr.Spec.Chart.Spec.SourceRef.Name, + }, hrHelmRepository) + if err != nil { + return "", fmt.Errorf("failed to find HelmRepository: %w", err) + } + hrHelmRepositoryReady = meta.FindStatusCondition(hrHelmRepository.Status.Conditions, fluxmeta.ReadyCondition) + } + + var traceTmpl = ` +Object: {{.ObjectName}} +{{- if .ObjectNamespace }} +Namespace: {{.ObjectNamespace}} +{{- end }} +Status: Managed by Flux +{{- if .HelmRelease }} +--- +HelmRelease: {{.HelmRelease.Name}} +Namespace: {{.HelmRelease.Namespace}} +{{- if .HelmRelease.Spec.TargetNamespace }} +Target: {{.HelmRelease.Spec.TargetNamespace}} +{{- end }} +Revision: {{.HelmRelease.Status.LastAppliedRevision}} +{{- if .HelmReleaseReady }} +Status: Last reconciled at {{.HelmReleaseReady.LastTransitionTime}} +Message: {{.HelmReleaseReady.Message}} +{{- else }} +Status: Unknown +{{- end }} +{{- end }} +{{- if .HelmChart }} +--- +HelmChart: {{.HelmChart.Name}} +Namespace: {{.HelmChart.Namespace}} +Chart: {{.HelmChart.Spec.Chart}} +Version: {{.HelmChart.Spec.Version}} +{{- if .HelmChart.Status.Artifact }} +Revision: {{.HelmChart.Status.Artifact.Revision}} +{{- end }} +{{- if .HelmChartReady }} +Status: Last reconciled at {{.HelmChartReady.LastTransitionTime}} +Message: {{.HelmChartReady.Message}} +{{- else }} +Status: Unknown +{{- end }} +{{- end }} +{{- if .HelmRepository }} +--- +HelmRepository: {{.HelmRepository.Name}} +Namespace: {{.HelmRepository.Namespace}} +URL: {{.HelmRepository.Spec.URL}} +{{- if .HelmRepository.Status.Artifact }} +Revision: {{.HelmRepository.Status.Artifact.Revision}} +{{- end }} +{{- if .HelmRepositoryReady }} +Status: Last reconciled at {{.HelmRepositoryReady.LastTransitionTime}} +Message: {{.HelmRepositoryReady.Message}} +{{- else }} +Status: Unknown +{{- end }} +{{- end }} +{{- if .GitRepository }} +--- +GitRepository: {{.GitRepository.Name}} +Namespace: {{.GitRepository.Namespace}} +URL: {{.GitRepository.Spec.URL}} +{{- if .GitRepository.Spec.Reference.Branch }} +Branch: {{.GitRepository.Spec.Reference.Branch}} +{{- end }} +{{- if .GitRepository.Spec.Reference.Tag }} +Tag: {{.GitRepository.Spec.Reference.Tag}} +{{- end }} +{{- if .GitRepository.Spec.Reference.Tag }} +Tag: {{.GitRepository.Spec.Reference.Tag}} +{{- end }} +{{- if .GitRepository.Spec.Reference.SemVer }} +Tag: {{.GitRepository.Spec.Reference.SemVer}} +{{- end }} +{{- if .GitRepository.Status.Artifact }} +Revision: {{.GitRepository.Status.Artifact.Revision}} +{{- end }} +{{- if .GitRepositoryReady }} +Status: Last reconciled at {{.GitRepositoryReady.LastTransitionTime}} +Message: {{.GitRepositoryReady.Message}} +{{- else }} +Status: Unknown +{{- end }} +{{- end }} +` + + traceResult := struct { + ObjectName string + ObjectNamespace string + HelmRelease *helmv2.HelmRelease + HelmReleaseReady *metav1.Condition + HelmChart *sourcev1.HelmChart + HelmChartReady *metav1.Condition + GitRepository *sourcev1.GitRepository + GitRepositoryReady *metav1.Condition + HelmRepository *sourcev1.HelmRepository + HelmRepositoryReady *metav1.Condition + }{ + ObjectName: obj.GetKind() + "/" + obj.GetName(), + ObjectNamespace: obj.GetNamespace(), + HelmRelease: hr, + HelmReleaseReady: hrReady, + HelmChart: hrChart, + HelmChartReady: hrChartReady, + GitRepository: hrGitRepository, + GitRepositoryReady: hrGitRepositoryReady, + HelmRepository: hrHelmRepository, + HelmRepositoryReady: hrHelmRepositoryReady, + } + + t, err := template.New("tmpl").Parse(traceTmpl) + if err != nil { + return "", err + } + + var data bytes.Buffer + writer := bufio.NewWriter(&data) + if err := t.Execute(writer, traceResult); err != nil { + return "", err + } + + if err := writer.Flush(); err != nil { + return "", err + } + + return data.String(), nil +} + +func isManagedByFlux(obj *unstructured.Unstructured, group string) (types.NamespacedName, bool) { + nameKey := fmt.Sprintf("%s/name", group) + namespaceKey := fmt.Sprintf("%s/namespace", group) + namespacedName := types.NamespacedName{} + + for k, v := range obj.GetLabels() { + if k == nameKey { + namespacedName.Name = v + } + if k == namespaceKey { + namespacedName.Namespace = v + } + } + + if namespacedName.Name == "" { + return namespacedName, false + } + return namespacedName, true +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index f606f4eb..958f861f 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -17,20 +17,12 @@ limitations under the License. package utils import ( - "bufio" "bytes" "context" "fmt" + "github.com/olekukonko/tablewriter" "io" "io/ioutil" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "text/template" - - "github.com/olekukonko/tablewriter" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -38,11 +30,17 @@ import ( 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" + "os" + "os/exec" + "path/filepath" + "runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" + "strings" helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" imageautov1 "github.com/fluxcd/image-automation-controller/api/v1beta1" @@ -109,36 +107,6 @@ func ExecKubectlCommand(ctx context.Context, mode ExecMode, kubeConfigPath strin return "", nil } -func ExecTemplate(obj interface{}, tmpl, filename string) error { - t, err := template.New("tmpl").Parse(tmpl) - if err != nil { - return err - } - - var data bytes.Buffer - writer := bufio.NewWriter(&data) - if err := t.Execute(writer, obj); err != nil { - return err - } - - if err := writer.Flush(); err != nil { - return err - } - - file, err := os.Create(filename) - if err != nil { - return err - } - defer file.Close() - - _, err = io.WriteString(file, data.String()) - if err != nil { - return err - } - - return file.Sync() -} - func KubeConfig(kubeConfigPath string, kubeContext string) (*rest.Config, error) { configFiles := SplitKubeConfigPath(kubeConfigPath) configOverrides := clientcmd.ConfigOverrides{} @@ -225,6 +193,21 @@ func ContainsEqualFoldItemString(s []string, e string) (string, bool) { return "", false } +// ParseNamespacedName extracts the NamespacedName of a resource +// based on the '/' 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 '/' format func ParseObjectKindName(input string) (kind, name string) {