Skip to content

Commit

Permalink
feature: cluster configuration backup
Browse files Browse the repository at this point in the history
Signed-off-by: Maxim Vasilenko <maksim.vasilenko@flant.com>
  • Loading branch information
mvasl committed Nov 11, 2024
1 parent 1e22bfd commit ba16cfd
Show file tree
Hide file tree
Showing 15 changed files with 780 additions and 32 deletions.
4 changes: 4 additions & 0 deletions internal/backup/cmd/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/spf13/cobra"
"k8s.io/kubectl/pkg/util/templates"

"github.com/deckhouse/deckhouse-cli/internal/backup/cmd/cluster-config"
"github.com/deckhouse/deckhouse-cli/internal/backup/cmd/etcd"
)

Expand All @@ -35,8 +36,11 @@ func NewCommand() *cobra.Command {
Long: backupLong,
}

addPersistentFlags(backupCmd.PersistentFlags())

backupCmd.AddCommand(
etcd.NewCommand(),
cluster_config.NewCommand(),
)

return backupCmd
Expand Down
163 changes: 163 additions & 0 deletions internal/backup/cmd/cluster-config/cluster_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package cluster_config

import (
"context"
"errors"
"fmt"
"log"
"os"
"reflect"
"runtime"

"github.com/samber/lo"
"github.com/samber/lo/parallel"
"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8sruntime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/kubectl/pkg/util/templates"

"github.com/deckhouse/deckhouse-cli/internal/backup/configs/configmaps"
"github.com/deckhouse/deckhouse-cli/internal/backup/configs/crds"
"github.com/deckhouse/deckhouse-cli/internal/backup/configs/roles"
"github.com/deckhouse/deckhouse-cli/internal/backup/configs/secrets"
"github.com/deckhouse/deckhouse-cli/internal/backup/configs/storageclasses"
"github.com/deckhouse/deckhouse-cli/internal/backup/configs/tarball"
"github.com/deckhouse/deckhouse-cli/internal/backup/configs/whitelist"
"github.com/deckhouse/deckhouse-cli/internal/backup/utilk8s"
)

var clusterConfigLong = templates.LongDesc(`
Take a snapshot of cluster configuration.
This command creates a snapshot various kubernetes resources.
© Flant JSC 2024`)

func NewCommand() *cobra.Command {
etcdCmd := &cobra.Command{
Use: "cluster-config <backup-tarball-path>",
Short: "Take a snapshot of cluster configuration",
Long: clusterConfigLong,
ValidArgs: []string{"backup-tarball-path"},
SilenceErrors: true,
SilenceUsage: true,
RunE: backupConfigs,
}

return etcdCmd
}

type BackupStage struct {
payload BackupFunc
filter tarball.BackupResourcesFilter
}

type BackupFunc func(
restConfig *rest.Config,
kubeCl kubernetes.Interface,
dynamicCl dynamic.Interface,
namespaces []string,
) ([]k8sruntime.Object, error)

func backupConfigs(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("This command requires exactly 1 argument")
}

restConfig, kubeCl, dynamicCl, err := setupK8sClients(cmd)
if err != nil {
return err
}
namespaces, err := getNamespacesFromCluster(kubeCl)
if err != nil {
return err
}

tarFile, err := os.CreateTemp(".", ".*.d8tmp")
if err != nil {
return fmt.Errorf("Failed to create temp file: %v", err)
}
defer func() {
os.Remove(tarFile.Name())
}()
backup := tarball.NewBackup(tarFile)

backupStages := []*BackupStage{
{payload: secrets.BackupSecrets, filter: &whitelist.BakedInFilter{}},
{payload: configmaps.BackupConfigMaps, filter: &whitelist.BakedInFilter{}},
{payload: crds.BackupCustomResources},
{payload: roles.BackupClusterRoles},
{payload: roles.BackupClusterRoleBindings},
{payload: storageclasses.BackupStorageClasses},
}

errs := parallel.Map(backupStages, func(stage *BackupStage, _ int) error {
stagePayloadFuncName := runtime.FuncForPC(reflect.ValueOf(stage.payload).Pointer()).Name()

objects, err := stage.payload(restConfig, kubeCl, dynamicCl, namespaces)
if err != nil {
return fmt.Errorf("%s failed: %v", stagePayloadFuncName, err)
}

for _, object := range objects {
if stage.filter != nil && !stage.filter.Matches(object) {
continue
}

if err = backup.PutObject(object); err != nil {
return fmt.Errorf("%s failed: %v", stagePayloadFuncName, err)
}
}

return nil
})
if errors.Join(errs...) != nil {
log.Printf("WARN: Some backup procedures failed, only successfully backed-up resources will be available:\n%v", err)
}

if err = backup.Close(); err != nil {
return fmt.Errorf("close tarball failed: %w", err)
}
if err = tarFile.Sync(); err != nil {
return fmt.Errorf("tarball flush failed: %w", err)
}
if err = tarFile.Close(); err != nil {
return fmt.Errorf("tarball close failed: %w", err)
}

if err = os.Rename(tarFile.Name(), args[0]); err != nil {
return fmt.Errorf("write tarball failed: %w", err)
}

return nil
}

func getNamespacesFromCluster(kubeCl *kubernetes.Clientset) ([]string, error) {
namespaceList, err := kubeCl.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("Failed to list namespaces: %w", err)
}
namespaces := lo.Map(namespaceList.Items, func(ns corev1.Namespace, _ int) string {
return ns.Name
})
return namespaces, nil
}

func setupK8sClients(cmd *cobra.Command) (*rest.Config, *kubernetes.Clientset, *dynamic.DynamicClient, error) {
kubeconfigPath, err := cmd.Flags().GetString("kubeconfig")
if err != nil {
return nil, nil, nil, fmt.Errorf("Failed to setup Kubernetes client: %w", err)
}

restConfig, kubeCl, err := utilk8s.SetupK8sClientSet(kubeconfigPath)
if err != nil {
return nil, nil, nil, fmt.Errorf("Failed to setup Kubernetes client: %w", err)
}

dynamicCl := dynamic.New(kubeCl.RESTClient())
return restConfig, kubeCl, dynamicCl, nil
}
30 changes: 10 additions & 20 deletions internal/backup/cmd/etcd/etcd.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ import (
"k8s.io/client-go/kubernetes"
_ "k8s.io/client-go/plugin/pkg/client/auth"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/remotecommand"
"k8s.io/kubectl/pkg/util/templates"

"github.com/deckhouse/deckhouse-cli/internal/backup/utilk8s"
)

var etcdLong = templates.LongDesc(`
Expand All @@ -55,7 +56,7 @@ func NewCommand() *cobra.Command {
SilenceErrors: true,
SilenceUsage: true,
PreRunE: func(cmd *cobra.Command, args []string) error {
return validateFlags()
return validateFlags(cmd)
},
RunE: etcd,
}
Expand All @@ -72,19 +73,23 @@ const (
)

var (
kubeconfigPath string
requestedEtcdPodName string

verboseLog bool
)

func etcd(_ *cobra.Command, args []string) error {
func etcd(cmd *cobra.Command, args []string) error {
log.SetFlags(log.LstdFlags)
if len(args) != 1 {
return fmt.Errorf("This command requires exactly 1 argument")
}

config, kubeCl, err := setupK8sClientset(kubeconfigPath)
kubeconfigPath, err := cmd.Flags().GetString("kubeconfig")
if err != nil {
return fmt.Errorf("Failed to setup Kubernetes client: %w", err)
}

config, kubeCl, err := utilk8s.SetupK8sClientSet(kubeconfigPath)
if err != nil {
return fmt.Errorf("Failed to setup Kubernetes client: %w", err)
}
Expand Down Expand Up @@ -234,21 +239,6 @@ func streamCommand(
return nil
}

func setupK8sClientset(kubeconfigPath string) (*rest.Config, *kubernetes.Clientset, error) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, nil).ClientConfig()
if err != nil {
return nil, nil, fmt.Errorf("Reading kubeconfig file: %w", err)
}

kubeCl, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, nil, fmt.Errorf("Constructing Kubernetes clientset: %w", err)
}

return config, kubeCl, nil
}

func findETCDPods(kubeCl kubernetes.Interface) ([]string, error) {
if requestedEtcdPodName != "" {
if err := checkEtcdPodExistsAndReady(kubeCl, requestedEtcdPodName); err != nil {
Expand Down
19 changes: 7 additions & 12 deletions internal/backup/cmd/etcd/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,11 @@ import (
"fmt"
"os"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

func addFlags(flagSet *pflag.FlagSet) {
defaultKubeconfigPath := os.ExpandEnv("$HOME/.kube/config")
if p := os.Getenv("KUBECONFIG"); p != "" {
defaultKubeconfigPath = p
}

flagSet.StringVarP(
&kubeconfigPath,
"kubeconfig", "k",
defaultKubeconfigPath,
"KubeConfig of the cluster. (default is $KUBECONFIG when it is set, $HOME/.kube/config otherwise)",
)
flagSet.StringVarP(
&requestedEtcdPodName,
"etcd-pod", "p",
Expand All @@ -49,7 +39,12 @@ func addFlags(flagSet *pflag.FlagSet) {
)
}

func validateFlags() error {
func validateFlags(cmd *cobra.Command) error {
kubeconfigPath, err := cmd.Flags().GetString("kubeconfig")
if err != nil {
return fmt.Errorf("Failed to setup Kubernetes client: %w", err)
}

stats, err := os.Stat(kubeconfigPath)
if err != nil {
return fmt.Errorf("Invalid --kubeconfig: %w", err)
Expand Down
20 changes: 20 additions & 0 deletions internal/backup/cmd/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package backup

import (
"os"

"github.com/spf13/pflag"
)

func addPersistentFlags(flagSet *pflag.FlagSet) {
defaultKubeconfigPath := os.ExpandEnv("$HOME/.kube/config")
if p := os.Getenv("KUBECONFIG"); p != "" {
defaultKubeconfigPath = p
}

flagSet.StringP(
"kubeconfig", "k",
defaultKubeconfigPath,
"KubeConfig of the cluster. (default is $KUBECONFIG when it is set, $HOME/.kube/config otherwise)",
)
}
47 changes: 47 additions & 0 deletions internal/backup/configs/configmaps/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package configmaps

import (
"context"
"log"
"strings"

"github.com/samber/lo"
"github.com/samber/lo/parallel"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)

func BackupConfigMaps(
_ *rest.Config,
kubeCl kubernetes.Interface,
_ dynamic.Interface,
namespaces []string,
) ([]runtime.Object, error) {
namespaces = lo.Filter(namespaces, func(item string, _ int) bool {
return strings.HasPrefix(item, "d8-") || strings.HasPrefix(item, "kube-")
})

configmaps := parallel.Map(namespaces, func(namespace string, _ int) []runtime.Object {
list, err := kubeCl.CoreV1().ConfigMaps(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
log.Fatalf("Failed to list configmaps from : %v", err)
}

return lo.Map(list.Items, func(item corev1.ConfigMap, _ int) runtime.Object {
// Some shit-for-brains kubernetes/client-go developer decided that it is fun to remove GVK from responses for no reason.
// Have to add it back so that meta.Accessor can do its job
// https://github.com/kubernetes/client-go/issues/1328
item.TypeMeta = metav1.TypeMeta{
Kind: "ConfigMap",
APIVersion: corev1.SchemeGroupVersion.String(),
}
return &item
})
})

return lo.Flatten(configmaps), nil
}
Loading

0 comments on commit ba16cfd

Please sign in to comment.