github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cloudprovider/k3d.go (about)

     1  /*
     2  Copyright (C) 2022-2023 ApeCloud Co., Ltd
     3  
     4  This file is part of KubeBlocks project
     5  
     6  This program is free software: you can redistribute it and/or modify
     7  it under the terms of the GNU Affero General Public License as published by
     8  the Free Software Foundation, either version 3 of the License, or
     9  (at your option) any later version.
    10  
    11  This program is distributed in the hope that it will be useful
    12  but WITHOUT ANY WARRANTY; without even the implied warranty of
    13  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    14  GNU Affero General Public License for more details.
    15  
    16  You should have received a copy of the GNU Affero General Public License
    17  along with this program.  If not, see <http://www.gnu.org/licenses/>.
    18  */
    19  
    20  package cloudprovider
    21  
    22  import (
    23  	"context"
    24  	_ "embed"
    25  	"fmt"
    26  	"io"
    27  	"net"
    28  	"os"
    29  	"strconv"
    30  	"strings"
    31  
    32  	"github.com/docker/go-connections/nat"
    33  	"github.com/k3d-io/k3d/v5/pkg/actions"
    34  	k3dClient "github.com/k3d-io/k3d/v5/pkg/client"
    35  	config "github.com/k3d-io/k3d/v5/pkg/config/v1alpha5"
    36  	l "github.com/k3d-io/k3d/v5/pkg/logger"
    37  	"github.com/k3d-io/k3d/v5/pkg/runtimes"
    38  	k3d "github.com/k3d-io/k3d/v5/pkg/types"
    39  	"github.com/k3d-io/k3d/v5/pkg/types/fixes"
    40  	"github.com/pkg/errors"
    41  	"github.com/sirupsen/logrus"
    42  	"k8s.io/client-go/tools/clientcmd"
    43  	"k8s.io/klog/v2"
    44  
    45  	"github.com/1aal/kubeblocks/pkg/cli/types"
    46  	"github.com/1aal/kubeblocks/pkg/cli/util"
    47  	"github.com/1aal/kubeblocks/version"
    48  )
    49  
    50  var (
    51  	// CliDockerNetwork is docker network for k3d cluster when `kbcli playground`
    52  	// all cluster will be created in this network, so they can communicate with each other
    53  	CliDockerNetwork = "k3d-kbcli-playground"
    54  
    55  	// K3sImage is k3s image repo
    56  	K3sImage = "rancher/k3s:" + version.K3sImageTag
    57  
    58  	// K3dProxyImage is k3d proxy image repo
    59  	K3dProxyImage = "docker.io/apecloud/k3d-proxy:" + version.K3dVersion
    60  
    61  	// K3dFixEnv
    62  	KBEnvFix fixes.K3DFixEnv = "KB_FIX_MOUNTS"
    63  )
    64  
    65  //go:embed assets/k3d-entrypoint-mount.sh
    66  var k3dMountEntrypoint []byte
    67  
    68  // localCloudProvider handles the k3d playground cluster creation and management
    69  type localCloudProvider struct {
    70  	cfg    config.ClusterConfig
    71  	stdout io.Writer
    72  	stderr io.Writer
    73  }
    74  
    75  // localCloudProvider should be an implementation of cloud provider
    76  var _ Interface = &localCloudProvider{}
    77  
    78  func init() {
    79  	if !klog.V(1).Enabled() {
    80  		// set k3d log level to 'warning' to avoid too much info logs
    81  		l.Log().SetLevel(logrus.WarnLevel)
    82  	}
    83  }
    84  
    85  func newLocalCloudProvider(stdout, stderr io.Writer) Interface {
    86  	return &localCloudProvider{
    87  		stdout: stdout,
    88  		stderr: stderr,
    89  	}
    90  }
    91  
    92  func (p *localCloudProvider) Name() string {
    93  	return Local
    94  }
    95  
    96  // CreateK8sCluster creates a local kubernetes cluster using k3d
    97  func (p *localCloudProvider) CreateK8sCluster(clusterInfo *K8sClusterInfo) error {
    98  	var err error
    99  
   100  	if p.cfg, err = buildClusterRunConfig(clusterInfo.ClusterName); err != nil {
   101  		return err
   102  	}
   103  
   104  	if err = setUpK3d(context.Background(), &p.cfg); err != nil {
   105  		return errors.Wrapf(err, "failed to create k3d cluster %s", clusterInfo.ClusterName)
   106  	}
   107  
   108  	return nil
   109  }
   110  
   111  // DeleteK8sCluster removes the k3d cluster
   112  func (p *localCloudProvider) DeleteK8sCluster(clusterInfo *K8sClusterInfo) error {
   113  	var err error
   114  	if clusterInfo == nil {
   115  		clusterInfo, err = p.GetClusterInfo()
   116  		if err != nil {
   117  			return err
   118  		}
   119  	}
   120  	ctx := context.Background()
   121  	clusterName := clusterInfo.ClusterName
   122  	clusters, err := k3dClient.ClusterList(ctx, runtimes.SelectedRuntime)
   123  	if err != nil {
   124  		return errors.Wrap(err, "fail to get k3d cluster list")
   125  	}
   126  
   127  	if len(clusters) == 0 {
   128  		return errors.New("no cluster found")
   129  	}
   130  
   131  	// find cluster that matches the name
   132  	var cluster *k3d.Cluster
   133  	for _, c := range clusters {
   134  		if c.Name == clusterName {
   135  			cluster = c
   136  			break
   137  		}
   138  	}
   139  
   140  	// extra handling to clean up tools nodes
   141  	defer func() {
   142  		if nl, err := k3dClient.NodeList(ctx, runtimes.SelectedRuntime); err == nil {
   143  			toolNode := fmt.Sprintf("k3d-%s-tools", clusterName)
   144  			for _, n := range nl {
   145  				if n.Name == toolNode {
   146  					if err := k3dClient.NodeDelete(ctx, runtimes.SelectedRuntime, n, k3d.NodeDeleteOpts{}); err != nil {
   147  						fmt.Printf("Delete node %s failed.", toolNode)
   148  					}
   149  					break
   150  				}
   151  			}
   152  		}
   153  	}()
   154  
   155  	if cluster == nil {
   156  		return fmt.Errorf("k3d cluster %s does not exist", clusterName)
   157  	}
   158  
   159  	// delete playground cluster
   160  	if err = k3dClient.ClusterDelete(ctx, runtimes.SelectedRuntime, cluster,
   161  		k3d.ClusterDeleteOpts{SkipRegistryCheck: false}); err != nil {
   162  		return errors.Wrapf(err, "failed to delete playground cluster %s", clusterName)
   163  	}
   164  	return nil
   165  }
   166  
   167  func (p *localCloudProvider) GetKubeConfig() (string, error) {
   168  	ctx := context.Background()
   169  	cluster := &k3d.Cluster{Name: types.K3dClusterName}
   170  	kubeConfig, err := k3dClient.KubeconfigGet(ctx, runtimes.SelectedRuntime, cluster)
   171  	if err != nil {
   172  		return "", err
   173  	}
   174  	cfgBytes, err := clientcmd.Write(*kubeConfig)
   175  	if err != nil {
   176  		return "", err
   177  	}
   178  
   179  	var (
   180  		hostToReplace string
   181  		cfgStr        = string(cfgBytes)
   182  	)
   183  
   184  	switch {
   185  	case strings.Contains(cfgStr, "0.0.0.0"):
   186  		hostToReplace = "0.0.0.0"
   187  	case strings.Contains(cfgStr, "host.docker.internal"):
   188  		hostToReplace = "host.docker.internal"
   189  	default:
   190  		return "", errors.Wrap(err, "unrecognized k3d kubeconfig format")
   191  	}
   192  
   193  	// replace host config with loop back address
   194  	return strings.ReplaceAll(cfgStr, hostToReplace, "127.0.0.1"), nil
   195  }
   196  
   197  func (p *localCloudProvider) GetClusterInfo() (*K8sClusterInfo, error) {
   198  	kubeConfig, err := p.GetKubeConfig()
   199  	if err != nil {
   200  		return nil, err
   201  	}
   202  	return &K8sClusterInfo{
   203  		CloudProvider: p.Name(),
   204  		ClusterName:   types.K3dClusterName,
   205  		KubeConfig:    kubeConfig,
   206  		Region:        "",
   207  		KbcliVersion:  version.GetVersion(),
   208  	}, nil
   209  }
   210  
   211  // buildClusterRunConfig returns the run-config for the k3d cluster
   212  func buildClusterRunConfig(clusterName string) (config.ClusterConfig, error) {
   213  	createOpts := buildClusterCreateOpts()
   214  	cluster, err := buildClusterConfig(clusterName, createOpts)
   215  	if err != nil {
   216  		return config.ClusterConfig{}, err
   217  	}
   218  	kubeconfigOpts := buildKubeconfigOptions()
   219  	runConfig := config.ClusterConfig{
   220  		Cluster:           cluster,
   221  		ClusterCreateOpts: createOpts,
   222  		KubeconfigOpts:    kubeconfigOpts,
   223  	}
   224  
   225  	return runConfig, nil
   226  }
   227  
   228  func buildClusterCreateOpts() k3d.ClusterCreateOpts {
   229  	clusterCreateOpts := k3d.ClusterCreateOpts{
   230  		GlobalLabels:        map[string]string{},
   231  		GlobalEnv:           []string{},
   232  		DisableLoadBalancer: false,
   233  	}
   234  
   235  	for k, v := range k3d.DefaultRuntimeLabels {
   236  		clusterCreateOpts.GlobalLabels[k] = v
   237  	}
   238  
   239  	return clusterCreateOpts
   240  }
   241  
   242  func buildClusterConfig(clusterName string, opts k3d.ClusterCreateOpts) (k3d.Cluster, error) {
   243  	var network = k3d.ClusterNetwork{
   244  		Name:     CliDockerNetwork,
   245  		External: false,
   246  	}
   247  
   248  	port, err := findAvailablePort(6444)
   249  	if err != nil {
   250  		panic(err)
   251  	}
   252  
   253  	// build opts to access the Kubernetes API
   254  	kubeAPIOpts := k3d.ExposureOpts{
   255  		PortMapping: nat.PortMapping{
   256  			Port: k3d.DefaultAPIPort,
   257  			Binding: nat.PortBinding{
   258  				HostIP:   k3d.DefaultAPIHost,
   259  				HostPort: port,
   260  			},
   261  		},
   262  		Host: k3d.DefaultAPIHost,
   263  	}
   264  
   265  	// build cluster config
   266  	clusterConfig := k3d.Cluster{
   267  		Name:    clusterName,
   268  		Network: network,
   269  		KubeAPI: &kubeAPIOpts,
   270  	}
   271  
   272  	// build nodes
   273  	var nodes []*k3d.Node
   274  
   275  	// build load balancer node
   276  	clusterConfig.ServerLoadBalancer = buildLoadbalancer(clusterConfig, opts)
   277  	nodes = append(nodes, clusterConfig.ServerLoadBalancer.Node)
   278  
   279  	// build k3d node
   280  	serverNode := k3d.Node{
   281  		Name:       k3dClient.GenerateNodeName(clusterConfig.Name, k3d.ServerRole, 0),
   282  		Role:       k3d.ServerRole,
   283  		Image:      K3sImage,
   284  		ServerOpts: k3d.ServerOpts{},
   285  		Args:       []string{"--disable=metrics-server", "--disable=traefik", "--disable=local-storage"},
   286  	}
   287  
   288  	nodes = append(nodes, &serverNode)
   289  
   290  	clusterConfig.Nodes = nodes
   291  	clusterConfig.ServerLoadBalancer.Config.Ports[fmt.Sprintf("%s.tcp", k3d.DefaultAPIPort)] =
   292  		append(clusterConfig.ServerLoadBalancer.Config.Ports[fmt.Sprintf("%s.tcp", k3d.DefaultAPIPort)], serverNode.Name)
   293  
   294  	// other configurations
   295  	portWithFilter, err := buildPortWithFilters()
   296  	if err != nil {
   297  		return clusterConfig, errors.Wrap(err, "failed to build http ports")
   298  	}
   299  
   300  	err = k3dClient.TransformPorts(context.Background(), runtimes.SelectedRuntime, &clusterConfig, []config.PortWithNodeFilters{portWithFilter})
   301  	if err != nil {
   302  		return clusterConfig, errors.Wrap(err, "failed to transform ports")
   303  	}
   304  
   305  	return clusterConfig, nil
   306  }
   307  
   308  func findAvailablePort(start int) (string, error) {
   309  	for i := start; i < 65535; i++ {
   310  		listener, err := net.Listen("tcp", fmt.Sprintf(":%d", i))
   311  		if err != nil {
   312  			continue
   313  		}
   314  		util.CloseQuietly(listener)
   315  		return strconv.Itoa(i), nil
   316  	}
   317  	return "", errors.New("can not find any available port")
   318  }
   319  
   320  func buildLoadbalancer(cluster k3d.Cluster, opts k3d.ClusterCreateOpts) *k3d.Loadbalancer {
   321  	lb := k3d.NewLoadbalancer()
   322  
   323  	labels := map[string]string{}
   324  	if opts.GlobalLabels == nil && len(opts.GlobalLabels) == 0 {
   325  		labels = opts.GlobalLabels
   326  	}
   327  
   328  	lb.Node.Name = fmt.Sprintf("%s-%s-serverlb", k3d.DefaultObjectNamePrefix, cluster.Name)
   329  	lb.Node.Image = K3dProxyImage
   330  	lb.Node.Ports = nat.PortMap{
   331  		k3d.DefaultAPIPort: []nat.PortBinding{cluster.KubeAPI.Binding},
   332  	}
   333  	lb.Node.Networks = []string{cluster.Network.Name}
   334  	lb.Node.RuntimeLabels = labels
   335  	lb.Node.Restart = true
   336  
   337  	return lb
   338  }
   339  
   340  func buildPortWithFilters() (config.PortWithNodeFilters, error) {
   341  	var port config.PortWithNodeFilters
   342  
   343  	hostPort, err := findAvailablePort(8090)
   344  	if err != nil {
   345  		return port, err
   346  	}
   347  	port.Port = fmt.Sprintf("%s:80", hostPort)
   348  	port.NodeFilters = []string{"loadbalancer"}
   349  
   350  	return port, nil
   351  }
   352  
   353  func buildKubeconfigOptions() config.SimpleConfigOptionsKubeconfig {
   354  	opts := config.SimpleConfigOptionsKubeconfig{
   355  		UpdateDefaultKubeconfig: true,
   356  		SwitchCurrentContext:    true,
   357  	}
   358  	return opts
   359  }
   360  
   361  func setUpK3d(ctx context.Context, cluster *config.ClusterConfig) error {
   362  	// add fix Envs
   363  	if err := os.Setenv(string(KBEnvFix), "1"); err != nil {
   364  		return err
   365  	}
   366  	fixes.FixEnvs = append(fixes.FixEnvs, KBEnvFix)
   367  
   368  	l, err := k3dClient.ClusterList(ctx, runtimes.SelectedRuntime)
   369  	if err != nil {
   370  		return err
   371  	}
   372  
   373  	if cluster == nil {
   374  		return errors.New("failed to create cluster")
   375  	}
   376  
   377  	for _, c := range l {
   378  		if c.Name == cluster.Name {
   379  			if c, err := k3dClient.ClusterGet(ctx, runtimes.SelectedRuntime, c); err == nil {
   380  				klog.V(1).Infof("Detected an existing cluster: %s", c.Name)
   381  				return nil
   382  			}
   383  			break
   384  		}
   385  	}
   386  
   387  	// exec "mount --make-rshared /" to fix csi driver plugins crash
   388  	cluster.ClusterCreateOpts.NodeHooks = append(cluster.ClusterCreateOpts.NodeHooks, k3d.NodeHook{
   389  		Stage: k3d.LifecycleStagePreStart,
   390  		Action: actions.WriteFileAction{
   391  			Runtime:     runtimes.SelectedRuntime,
   392  			Content:     k3dMountEntrypoint,
   393  			Dest:        "/bin/k3d-entrypoint-mount.sh",
   394  			Mode:        0744,
   395  			Description: "Write entrypoint script for mount shared fix",
   396  		},
   397  	})
   398  
   399  	if err := k3dClient.ClusterRun(ctx, runtimes.SelectedRuntime, cluster); err != nil {
   400  		return err
   401  	}
   402  
   403  	return nil
   404  }