github.com/webmeshproj/webmesh-cni@v0.0.27/internal/types/install.go (about)

     1  /*
     2  Copyright 2023 Avi Zimmerman <avi.zimmerman@gmail.com>.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package types
    18  
    19  import (
    20  	"bytes"
    21  	"encoding/json"
    22  	"flag"
    23  	"fmt"
    24  	"io"
    25  	"log"
    26  	"os"
    27  	"path/filepath"
    28  	"runtime"
    29  	"strings"
    30  
    31  	"github.com/mitchellh/mapstructure"
    32  	"k8s.io/client-go/rest"
    33  	"k8s.io/client-go/tools/clientcmd"
    34  	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
    35  	ctrl "sigs.k8s.io/controller-runtime"
    36  )
    37  
    38  // InstallOptions are the options for the install component.
    39  type InstallOptions struct {
    40  	// Kubeconfig is the kubeconfig to use for the plugin.
    41  	Kubeconfig string `json:"kubeconfig" mapstructure:"kubeconfig"`
    42  	// SourceBinary is the path to the source binary.
    43  	SourceBinary string `json:"sourceBinary" mapstructure:"sourceBinary"`
    44  	// BinaryDestBin is the destination directory for the CNI binaries.
    45  	BinaryDestBin string `json:"binaryDestBin" mapstructure:"binaryDestBin"`
    46  	// ConfDestDir is the destination directory for the CNI configuration.
    47  	ConfDestDir string `json:"confDestDir" mapstructure:"confDestDir"`
    48  	// ConfDestName is the name of the CNI configuration file.
    49  	ConfDestName string `json:"confDestName" mapstructure:"confDestName"`
    50  	// HostLocalNetDir is the directory containing host-local IPAM allocations.
    51  	// We release these when we start for the first time.
    52  	HostLocalNetDir string `json:"hostLocalNetDir" mapstructure:"hostLocalNetDir"`
    53  	// NetConfTemplate is the template for the CNI configuration.
    54  	NetConfTemplate string `json:"netConfTemplate" mapstructure:"netConfTemplate"`
    55  	// NodeName is the name of the node we are running on.
    56  	NodeName string `json:"nodeName" mapstructure:"nodeName"`
    57  	// Namespace is the namespace to use for the plugin.
    58  	Namespace string `json:"namespace" mapstructure:"namespace"`
    59  	// DryRun is whether or not to run in dry run mode.
    60  	DryRun bool `json:"dryRun" mapstructure:"dryRun"`
    61  }
    62  
    63  // String returns a string representation of the install options.
    64  func (i *InstallOptions) String() string {
    65  	mapstruct := map[string]any{}
    66  	err := mapstructure.Decode(i, &mapstruct)
    67  	if err != nil {
    68  		return fmt.Sprintf("error decoding install options: %s", err.Error())
    69  	}
    70  	delete(mapstruct, "netConfTemplate")
    71  	confTempl := map[string]any{}
    72  	err = json.Unmarshal([]byte(i.NetConfTemplate), &confTempl)
    73  	if err == nil {
    74  		mapstruct["netConfTemplate"] = confTempl
    75  	} else {
    76  		mapstruct["netConfTemplate"] = "error parsing netconf template: " + err.Error()
    77  	}
    78  	out, _ := json.MarshalIndent(mapstruct, "", "  ")
    79  	return string(out)
    80  }
    81  
    82  // BindFlags binds the install options to the given flag set.
    83  func (i *InstallOptions) BindFlags(fs *flag.FlagSet) {
    84  	fs.BoolVar(&i.DryRun, "dry-run", i.DryRun, "whether or not to run in dry run mode")
    85  	fs.StringVar(&i.SourceBinary, "source-binary", i.SourceBinary, "path to the source binary (default: current executable)")
    86  	fs.StringVar(&i.BinaryDestBin, "binary-dest-bin", i.BinaryDestBin, "destination directory for the CNI binaries")
    87  	fs.StringVar(&i.ConfDestDir, "conf-dest-dir", i.ConfDestDir, "destination directory for the CNI configuration")
    88  	fs.StringVar(&i.ConfDestName, "conf-dest-name", i.ConfDestName, "name of the CNI configuration file")
    89  	fs.StringVar(&i.HostLocalNetDir, "host-local-net-dir", i.HostLocalNetDir, "directory containing host-local IPAM allocations to clear, leave this empty to disable")
    90  	fs.StringVar(&i.NodeName, "node-name", i.NodeName, "name of the node we are running on")
    91  	fs.StringVar(&i.Namespace, "namespace", i.Namespace, "namespace to use for the plugin")
    92  	fs.Func("netconf-template", "template file for the CNI configuration", func(fname string) error {
    93  		data, err := os.ReadFile(fname)
    94  		if err != nil {
    95  			return fmt.Errorf("read file: %w", err)
    96  		}
    97  		i.NetConfTemplate = string(data)
    98  		return nil
    99  	})
   100  }
   101  
   102  // getExecutable is the function for retrieving the current executable.
   103  // This is overridden in tests.
   104  var getExecutable = os.Executable
   105  
   106  // LoadInstallOptionsFromEnv loads the install options from the environment.
   107  func LoadInstallOptionsFromEnv() *InstallOptions {
   108  	var opts InstallOptions
   109  	opts.Kubeconfig = envOrDefault(KubeconfigEnvVar, "")
   110  	opts.NodeName = envOrDefault(NodeNameEnvVar, "")
   111  	opts.HostLocalNetDir = envOrDefault(CNINetDirEnvVar, os.Getenv(CNINetDirEnvVar))
   112  	opts.Namespace = envOrDefault(PodNamespaceEnvVar, DefaultNamespace)
   113  	opts.BinaryDestBin = envOrDefault(DestBinEnvVar, DefaultDestBin)
   114  	opts.ConfDestDir = envOrDefault(DestConfEnvVar, DefaultDestConfDir)
   115  	opts.ConfDestName = envOrDefault(DestConfFileNameEnvVar, DefaultDestConfFilename)
   116  	opts.NetConfTemplate = os.Getenv(NetConfTemplateEnvVar)
   117  	opts.SourceBinary, _ = getExecutable()
   118  	if dryrun, ok := os.LookupEnv(DryRunEnvVar); ok {
   119  		opts.DryRun = dryrun == "true" || dryrun == "1"
   120  	}
   121  	return &opts
   122  }
   123  
   124  func envOrDefault(env, def string) string {
   125  	if val, ok := os.LookupEnv(env); ok {
   126  		return val
   127  	}
   128  	return def
   129  }
   130  
   131  func (i *InstallOptions) Default() {
   132  	if i.SourceBinary == "" {
   133  		i.SourceBinary, _ = getExecutable()
   134  	}
   135  	if i.BinaryDestBin == "" {
   136  		i.BinaryDestBin = DefaultDestBin
   137  	}
   138  	if i.ConfDestDir == "" {
   139  		i.ConfDestDir = DefaultDestConfDir
   140  	}
   141  	if i.ConfDestName == "" {
   142  		i.ConfDestName = DefaultDestConfFilename
   143  	}
   144  	if i.Namespace == "" {
   145  		i.Namespace, _ = GetInClusterNamespace()
   146  	}
   147  }
   148  
   149  func (i *InstallOptions) Validate() error {
   150  	i.Default()
   151  	if i.SourceBinary == "" {
   152  		return fmt.Errorf("source binary not set")
   153  	}
   154  	if i.BinaryDestBin == "" {
   155  		return fmt.Errorf("binary destination directory not set")
   156  	}
   157  	if i.ConfDestDir == "" {
   158  		return fmt.Errorf("configuration destination directory not set")
   159  	}
   160  	if i.ConfDestName == "" {
   161  		return fmt.Errorf("configuration destination name not set")
   162  	}
   163  	if i.NetConfTemplate == "" {
   164  		return fmt.Errorf("CNI configuration template not set")
   165  	}
   166  	if i.NodeName == "" {
   167  		return fmt.Errorf("node name not set")
   168  	}
   169  	if i.Namespace == "" {
   170  		return fmt.Errorf("%s not set and unable to get in-cluster namespace", PodNamespaceEnvVar)
   171  	}
   172  	err := json.Unmarshal([]byte(i.NetConfTemplate), &struct{}{})
   173  	if err != nil {
   174  		return fmt.Errorf("CNI configuration template is not proper JSON: %w", err)
   175  	}
   176  	return nil
   177  }
   178  
   179  // getInstallRestConfig is the function for retrieving the REST config during installation.
   180  // This is overridden in tests.
   181  var getInstallRestConfig = ctrl.GetConfig
   182  
   183  // RunInstall is an alias for running all install steps.
   184  func (i *InstallOptions) RunInstall() error {
   185  	var apicfg *rest.Config
   186  	var err error
   187  	if i.Kubeconfig == "" {
   188  		log.Println("no kubeconfig provided, trying to auto-detect")
   189  		apicfg, err = getInstallRestConfig()
   190  		if err != nil {
   191  			log.Println("error getting kubeconfig:", err)
   192  			return err
   193  		}
   194  	} else {
   195  		log.Println("using kubeconfig provided at", i.Kubeconfig)
   196  		apicfg, err = clientcmd.BuildConfigFromKubeconfigGetter("", func() (*clientcmdapi.Config, error) {
   197  			return clientcmd.LoadFromFile(i.Kubeconfig)
   198  		})
   199  		if err != nil {
   200  			log.Println("error getting kubeconfig:", err)
   201  			return err
   202  		}
   203  	}
   204  	// Clear any local host IPAM allocations that already exist.
   205  	if i.HostLocalNetDir != "" {
   206  		log.Println("clearing host-local IPAM allocations from", i.HostLocalNetDir)
   207  		if !i.DryRun {
   208  			err = i.ClearHostLocalIPAMAllocations()
   209  			if err != nil {
   210  				log.Println("error clearing host-local IPAM allocations:", err)
   211  				return err
   212  			}
   213  		}
   214  	}
   215  	pluginBin := filepath.Join(i.BinaryDestBin, PluginBinaryName)
   216  	log.Println("installing plugin binary to -> ", pluginBin)
   217  	if !i.DryRun {
   218  		err = i.InstallPlugin(pluginBin)
   219  		if err != nil {
   220  			log.Println("error installing plugin:", err)
   221  			return err
   222  		}
   223  	}
   224  	kubeconfigPath := filepath.Join(i.ConfDestDir, PluginKubeconfigName)
   225  	log.Println("installing kubeconfig to destination -> ", kubeconfigPath)
   226  	if !i.DryRun {
   227  		err = i.InstallKubeconfig(kubeconfigPath)
   228  		if err != nil {
   229  			log.Println("error writing kubeconfig:", err)
   230  			return err
   231  		}
   232  	}
   233  	log.Println("rendering CNI configuration")
   234  	netConf := i.RenderNetConf(apicfg.Host, strings.TrimPrefix(kubeconfigPath, "/host"))
   235  	log.Println("effective CNI configuration ->\n", netConf)
   236  	confPath := filepath.Join(i.ConfDestDir, i.ConfDestName)
   237  	log.Println("installing CNI configuration to destination -> ", confPath)
   238  	if !i.DryRun {
   239  		err = i.InstallNetConf(confPath, netConf)
   240  		if err != nil {
   241  			log.Println("error writing netconf:", err)
   242  			return err
   243  		}
   244  	}
   245  	return nil
   246  }
   247  
   248  // ClearHostLocalIPAMAllocations removes any host-local CNI plugins from the CNI configuration.
   249  func (i *InstallOptions) ClearHostLocalIPAMAllocations() error {
   250  	dir, err := os.ReadDir(i.HostLocalNetDir)
   251  	if err != nil {
   252  		if os.IsNotExist(err) {
   253  			return nil
   254  		}
   255  		return fmt.Errorf("error reading host-local CNI directory: %w", err)
   256  	}
   257  	for _, file := range dir {
   258  		// Skip parent directory.
   259  		if file.Name() == filepath.Base(i.HostLocalNetDir) {
   260  			continue
   261  		}
   262  		err = os.RemoveAll(filepath.Join(i.HostLocalNetDir, file.Name()))
   263  		if err != nil {
   264  			return fmt.Errorf("error removing host-local CNI plugin: %w", err)
   265  		}
   266  	}
   267  	return nil
   268  }
   269  
   270  // InstallPlugin installs the plugin.
   271  func (i *InstallOptions) InstallPlugin(dest string) error {
   272  	if err := installPluginBinary(i.SourceBinary, dest); err != nil {
   273  		log.Printf("error installing binary to %s: %v", dest, err)
   274  		return err
   275  	}
   276  	return nil
   277  }
   278  
   279  // InstallNetConf installs the CNI configuration.
   280  func (i *InstallOptions) InstallNetConf(path string, config string) error {
   281  	if err := os.WriteFile(path, []byte(config), 0644); err != nil {
   282  		log.Println("error writing CNI configuration:", err)
   283  		return err
   284  	}
   285  	return nil
   286  }
   287  
   288  // RenderNetConf renders the CNI configuration.
   289  func (i *InstallOptions) RenderNetConf(apiEndpoint string, kubeconfig string) string {
   290  	conf := i.NetConfTemplate
   291  	conf = strings.Replace(conf, NodeNameReplaceStr, i.NodeName, -1)
   292  	conf = strings.Replace(conf, PodNamespaceReplaceStr, i.Namespace, -1)
   293  	conf = strings.Replace(conf, APIEndpointReplaceStr, apiEndpoint, -1)
   294  	conf = strings.Replace(conf, KubeconfigFilepathReplaceStr, kubeconfig, -1)
   295  	return conf
   296  }
   297  
   298  // InstallKubeconfig writes the kubeconfig file for the plugin.
   299  func (i *InstallOptions) InstallKubeconfig(kubeconfigPath string) error {
   300  	kubeconfig, err := i.GetKubeconfig()
   301  	if err != nil {
   302  		return fmt.Errorf("error getting kubeconfig: %w", err)
   303  	}
   304  	if err := clientcmd.WriteToFile(kubeconfig, kubeconfigPath); err != nil {
   305  		log.Println("error writing kubeconfig:", err)
   306  		return err
   307  	}
   308  	return nil
   309  }
   310  
   311  // GetKubeconfig tries to build a kubeconfig from the current in cluster
   312  // configuration.
   313  func (i *InstallOptions) GetKubeconfig() (clientcmdapi.Config, error) {
   314  	// If our cert data is empty, convert it to the contents of the cert file.
   315  	cfg, err := getInstallRestConfig()
   316  	if err != nil {
   317  		return clientcmdapi.Config{}, fmt.Errorf("error getting config: %w", err)
   318  	}
   319  	return KubeconfigFromRestConfig(cfg, i.Namespace)
   320  }
   321  
   322  // KubeconfigFromRestConfig returns a kubeconfig from the given rest config.
   323  // It reads in any files and encodes them as base64 in the final configuration.
   324  // GetKubeconfig tries to build a kubeconfig from the current in cluster
   325  // configuration.
   326  func KubeconfigFromRestConfig(cfg *rest.Config, namespace string) (clientcmdapi.Config, error) {
   327  	if cfg.CAFile != "" {
   328  		caData, err := os.ReadFile(cfg.CAFile)
   329  		if err != nil {
   330  			return clientcmdapi.Config{}, fmt.Errorf("error reading certificate authority data: %w", err)
   331  		}
   332  		cfg.CAData = caData
   333  	}
   334  	// If our bearer token is a file, convert it to the contents of the file.
   335  	if cfg.BearerTokenFile != "" {
   336  		token, err := os.ReadFile(cfg.BearerTokenFile)
   337  		if err != nil {
   338  			return clientcmdapi.Config{}, fmt.Errorf("error reading bearer token: %w", err)
   339  		}
   340  		cfg.BearerToken = string(token)
   341  	}
   342  	// If our client certificate is a file, convert it to the contents of the file.
   343  	if cfg.CertFile != "" {
   344  		cert, err := os.ReadFile(cfg.CertFile)
   345  		if err != nil {
   346  			log.Println("error reading client certificate:", err)
   347  			return clientcmdapi.Config{}, fmt.Errorf("error reading client certificate: %w", err)
   348  		}
   349  		cfg.CertData = cert
   350  	}
   351  	// Same for any key
   352  	if cfg.KeyFile != "" {
   353  		key, err := os.ReadFile(cfg.KeyFile)
   354  		if err != nil {
   355  			log.Println("error reading client key:", err)
   356  			return clientcmdapi.Config{}, fmt.Errorf("error reading client key: %w", err)
   357  		}
   358  		cfg.KeyData = key
   359  	}
   360  	return clientcmdapi.Config{
   361  		Kind:       "Config",
   362  		APIVersion: "v1",
   363  		Clusters: map[string]*clientcmdapi.Cluster{
   364  			KubeconfigContextName: {
   365  				Server:                   cfg.Host,
   366  				TLSServerName:            cfg.ServerName,
   367  				InsecureSkipTLSVerify:    cfg.Insecure,
   368  				CertificateAuthorityData: cfg.CAData,
   369  			},
   370  		},
   371  		AuthInfos: map[string]*clientcmdapi.AuthInfo{
   372  			KubeconfigContextName: {
   373  				ClientCertificateData: cfg.CertData,
   374  				ClientKeyData:         cfg.KeyData,
   375  				Token:                 cfg.BearerToken,
   376  				Impersonate:           cfg.Impersonate.UserName,
   377  				ImpersonateGroups:     cfg.Impersonate.Groups,
   378  			},
   379  		},
   380  		Contexts: map[string]*clientcmdapi.Context{
   381  			KubeconfigContextName: {
   382  				Cluster:   KubeconfigContextName,
   383  				AuthInfo:  KubeconfigContextName,
   384  				Namespace: namespace,
   385  			},
   386  		},
   387  		CurrentContext: KubeconfigContextName,
   388  	}, nil
   389  }
   390  
   391  var setSuidBit = setSuidBitToFile
   392  
   393  // installPluginBinary copies the binary to the destination directory.
   394  func installPluginBinary(src, dest string) error {
   395  	f, err := os.Open(src)
   396  	if err != nil {
   397  		return fmt.Errorf("error opening binary: %w", err)
   398  	}
   399  	defer f.Close()
   400  	// Create the destination directory if it doesn't exist.
   401  	if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
   402  		return fmt.Errorf("error creating destination directory: %w", err)
   403  	}
   404  	// Create the destination file.
   405  	out, err := os.Create(dest)
   406  	if err != nil {
   407  		return fmt.Errorf("error creating destination file: %w", err)
   408  	}
   409  	// Copy the binary to the destination file.
   410  	if _, err := io.Copy(out, f); err != nil {
   411  		return fmt.Errorf("error copying binary: %w", err)
   412  	}
   413  	err = out.Close()
   414  	if err != nil {
   415  		return fmt.Errorf("error closing destination file: %w", err)
   416  	}
   417  	// Make the destination file executable.
   418  	if err := os.Chmod(dest, 0755); err != nil {
   419  		return fmt.Errorf("error making destination file executable: %w", err)
   420  	}
   421  	return setSuidBit(dest)
   422  }
   423  
   424  func setSuidBitToFile(file string) error {
   425  	if runtime.GOOS == "windows" {
   426  		// chmod doesn't work on windows
   427  		log.Println("chmod doesn't work on windows, skipping setSuidBit()")
   428  		return nil
   429  	}
   430  	fi, err := os.Stat(file)
   431  	if err != nil {
   432  		return fmt.Errorf("failed to stat file: %s", err)
   433  	}
   434  	err = os.Chmod(file, fi.Mode()|os.FileMode(uint32(8388608)))
   435  	if err != nil {
   436  		return fmt.Errorf("failed to chmod file: %s", err)
   437  	}
   438  	return nil
   439  }
   440  
   441  // inClusterNamespacePath is the path to the namespace file in the pod.
   442  // Declared as a variable for testing.
   443  var inClusterNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
   444  
   445  // GetInClusterNamespace returns the namespace of the pod we are running in.
   446  func GetInClusterNamespace() (string, error) {
   447  	// Load the namespace file and return its content
   448  	namespace, err := os.ReadFile(inClusterNamespacePath)
   449  	if err != nil {
   450  		if os.IsNotExist(err) {
   451  			return "", fmt.Errorf("namespace file does not exist, not running in-cluster")
   452  		}
   453  		return "", fmt.Errorf("error reading namespace file: %w", err)
   454  	}
   455  	return string(bytes.TrimSpace(namespace)), nil
   456  }