github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/cmd/juju/caas/add.go (about)

     1  // Copyright 2017 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package caas
     5  
     6  import (
     7  	"bytes"
     8  	"fmt"
     9  	"io"
    10  	"io/ioutil"
    11  	"os"
    12  	"reflect"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/juju/cmd"
    17  	"github.com/juju/errors"
    18  	"github.com/juju/gnuflag"
    19  	"github.com/juju/loggo"
    20  	"github.com/juju/utils/set"
    21  	"golang.org/x/crypto/ssh/terminal"
    22  	"gopkg.in/juju/names.v2"
    23  
    24  	"github.com/juju/juju/api"
    25  	cloudapi "github.com/juju/juju/api/cloud"
    26  	"github.com/juju/juju/api/modelconfig"
    27  	"github.com/juju/juju/apiserver/params"
    28  	"github.com/juju/juju/caas"
    29  	"github.com/juju/juju/caas/kubernetes/clientconfig"
    30  	jujucloud "github.com/juju/juju/cloud"
    31  	jujucmd "github.com/juju/juju/cmd"
    32  	jujucmdcloud "github.com/juju/juju/cmd/juju/cloud"
    33  	"github.com/juju/juju/cmd/juju/common"
    34  	"github.com/juju/juju/cmd/modelcmd"
    35  	"github.com/juju/juju/environs"
    36  	"github.com/juju/juju/environs/config"
    37  	"github.com/juju/juju/jujuclient"
    38  )
    39  
    40  var logger = loggo.GetLogger("juju.cmd.juju.k8s")
    41  
    42  type CloudMetadataStore interface {
    43  	ParseCloudMetadataFile(path string) (map[string]jujucloud.Cloud, error)
    44  	ParseOneCloud(data []byte) (jujucloud.Cloud, error)
    45  	PublicCloudMetadata(searchPaths ...string) (result map[string]jujucloud.Cloud, fallbackUsed bool, _ error)
    46  	PersonalCloudMetadata() (map[string]jujucloud.Cloud, error)
    47  	WritePersonalCloudMetadata(cloudsMap map[string]jujucloud.Cloud) error
    48  }
    49  
    50  // AddCloudAPI - Implemented by cloudapi.Client.
    51  type AddCloudAPI interface {
    52  	AddCloud(jujucloud.Cloud) error
    53  	AddCredential(tag string, credential jujucloud.Credential) error
    54  	Close() error
    55  }
    56  
    57  // BrokerGetter returns caas broker instance.
    58  type BrokerGetter func(cloud jujucloud.Cloud, credential jujucloud.Credential) (k8sBrokerRegionLister, error)
    59  
    60  type k8sBrokerRegionLister interface {
    61  	ListHostCloudRegions() (set.Strings, error)
    62  }
    63  
    64  var usageAddCAASSummary = `
    65  Adds a k8s endpoint and credential to Juju.`[1:]
    66  
    67  var usageAddCAASDetails = `
    68  Creates a user-defined cloud and populate the selected controller with the k8s
    69  cloud details. Speficify non default kubeconfig file location using $KUBECONFIG
    70  environment variable or pipe in file content from stdin.
    71  
    72  The config file can contain definitions for different k8s clusters,
    73  use --cluster-name to pick which one to use.
    74  It's also possible to select a context by name using --context-name.
    75  
    76  When running add-k8s on JAAS and the cloud/region cannot be detected automatically,
    77  use --region <cloudType/region> to specify the host cloud type and region.
    78  
    79  When adding a GKE cluster, you can use the --gke option to interactively be stepped
    80  through the registration process, or you can supply the necessary parameters directly.
    81  
    82  Examples:
    83      juju add-k8s myk8scloud
    84      juju add-k8s --context-name mycontext myk8scloud
    85      juju add-k8s myk8scloud --region <cloudType/region>
    86  
    87      KUBECONFIG=path-to-kubuconfig-file juju add-k8s myk8scloud --cluster-name=my_cluster_name
    88      kubectl config view --raw | juju add-k8s myk8scloud --cluster-name=my_cluster_name
    89  
    90      juju add-k8s --gke myk8scloud
    91      juju add-k8s --gke --project=myproject myk8scloud
    92      juju add-k8s --gke --credential=myaccount --project=myproject myk8scloud
    93      juju add-k8s --gke --credential=myaccount --project=myproject --region=someregion myk8scloud
    94  
    95  See also:
    96      remove-k8s
    97  `
    98  
    99  // AddCAASCommand is the command that allows you to add a caas and credential
   100  type AddCAASCommand struct {
   101  	modelcmd.ControllerCommandBase
   102  
   103  	// caasName is the name of the caas to add.
   104  	caasName string
   105  
   106  	// caasType is the type of CAAS being added.
   107  	caasType string
   108  
   109  	// clusterName is the name of the cluster (k8s) or credential to import.
   110  	clusterName string
   111  
   112  	// contextName is the name of the contex to import.
   113  	contextName string
   114  
   115  	// project is the project id for the cluster.
   116  	project string
   117  
   118  	// credential is the credential to use when accessing the cluster.
   119  	credential string
   120  
   121  	// hostCloudRegion is the cloud region that the nodes of cluster (k8s) are running in.
   122  	// The format is <cloudType/region>.
   123  	hostCloudRegion string
   124  
   125  	// brokerGetter returns caas broker instance.
   126  	brokerGetter BrokerGetter
   127  
   128  	gke        bool
   129  	k8sCluster k8sCluster
   130  
   131  	cloudMetadataStore    CloudMetadataStore
   132  	fileCredentialStore   jujuclient.CredentialStore
   133  	addCloudAPIFunc       func() (AddCloudAPI, error)
   134  	newClientConfigReader func(string) (clientconfig.ClientConfigFunc, error)
   135  
   136  	getAllCloudDetails func() (map[string]*jujucmdcloud.CloudDetails, error)
   137  }
   138  
   139  // NewAddCAASCommand returns a command to add caas information.
   140  func NewAddCAASCommand(cloudMetadataStore CloudMetadataStore) cmd.Command {
   141  	cmd := &AddCAASCommand{
   142  		cloudMetadataStore:  cloudMetadataStore,
   143  		fileCredentialStore: jujuclient.NewFileCredentialStore(),
   144  		newClientConfigReader: func(caasType string) (clientconfig.ClientConfigFunc, error) {
   145  			return clientconfig.NewClientConfigReader(caasType)
   146  		},
   147  	}
   148  	cmd.addCloudAPIFunc = func() (AddCloudAPI, error) {
   149  		root, err := cmd.NewAPIRoot()
   150  		if err != nil {
   151  			return nil, errors.Trace(err)
   152  		}
   153  		return cloudapi.NewClient(root), nil
   154  	}
   155  
   156  	cmd.brokerGetter = newK8sBrokerGetter(cmd.NewAPIRoot)
   157  	cmd.getAllCloudDetails = jujucmdcloud.GetAllCloudDetails
   158  	return modelcmd.WrapController(cmd)
   159  }
   160  
   161  // Info returns help information about the command.
   162  func (c *AddCAASCommand) Info() *cmd.Info {
   163  	return jujucmd.Info(&cmd.Info{
   164  		Name:    "add-k8s",
   165  		Args:    "<k8s name>",
   166  		Purpose: usageAddCAASSummary,
   167  		Doc:     usageAddCAASDetails,
   168  	})
   169  }
   170  
   171  // SetFlags initializes the flags supported by the command.
   172  func (c *AddCAASCommand) SetFlags(f *gnuflag.FlagSet) {
   173  	c.CommandBase.SetFlags(f)
   174  	f.StringVar(&c.clusterName, "cluster-name", "", "Specify the k8s cluster to import")
   175  	f.StringVar(&c.contextName, "context-name", "", "Specify the k8s context to import")
   176  	f.StringVar(&c.hostCloudRegion, "region", "", "kubernetes cluster cloud and/or region")
   177  	f.StringVar(&c.project, "project", "", "project to which the cluster belongs")
   178  	f.StringVar(&c.credential, "credential", "", "the credential to use when accessing the cluster")
   179  	f.BoolVar(&c.gke, "gke", false, "used when adding a GKE cluster")
   180  }
   181  
   182  // Init populates the command with the args from the command line.
   183  func (c *AddCAASCommand) Init(args []string) (err error) {
   184  	if len(args) == 0 {
   185  		return errors.Errorf("missing k8s name.")
   186  	}
   187  	c.caasType = "kubernetes"
   188  	c.caasName = args[0]
   189  
   190  	if c.contextName != "" && c.clusterName != "" {
   191  		return errors.New("only specify one of cluster-name or context-name, not both")
   192  	}
   193  	if c.gke {
   194  		if c.contextName != "" {
   195  			return errors.New("do not specify context name when adding a GKE cluster")
   196  		}
   197  	} else {
   198  		if c.project != "" {
   199  			return errors.New("do not specify project unless adding a GKE cluster")
   200  		}
   201  		if c.credential != "" {
   202  			return errors.New("do not specify credential unless adding a GKE cluster")
   203  		}
   204  	}
   205  
   206  	return cmd.CheckEmpty(args[1:])
   207  }
   208  
   209  // getStdinPipe returns nil if the context's stdin is not a pipe.
   210  func getStdinPipe(ctx *cmd.Context) (io.Reader, error) {
   211  	if stdIn, ok := ctx.Stdin.(*os.File); ok && !terminal.IsTerminal(int(stdIn.Fd())) {
   212  		// stdIn from pipe but not terminal
   213  		stat, err := stdIn.Stat()
   214  		if err != nil {
   215  			return nil, err
   216  		}
   217  		content, err := ioutil.ReadAll(stdIn)
   218  		if err != nil {
   219  			return nil, err
   220  		}
   221  		if (stat.Mode()&os.ModeCharDevice) == 0 && len(content) > 0 {
   222  			// workaround to get piped stdIn size because stat.Size() always == 0
   223  			return bytes.NewReader(content), nil
   224  		}
   225  	}
   226  	return nil, nil
   227  }
   228  
   229  func (c *AddCAASCommand) newCloudCredentialFromKubeConfig(reader io.Reader, contextName, clusterName string) (jujucloud.Cloud, jujucloud.Credential, clientconfig.Context, error) {
   230  	var credential jujucloud.Credential
   231  	var context clientconfig.Context
   232  	newCloud := jujucloud.Cloud{
   233  		Name:            c.caasName,
   234  		Type:            c.caasType,
   235  		HostCloudRegion: c.hostCloudRegion,
   236  	}
   237  	clientConfigFunc, err := c.newClientConfigReader(c.caasType)
   238  	if err != nil {
   239  		return newCloud, credential, context, errors.Trace(err)
   240  	}
   241  	caasConfig, err := clientConfigFunc(reader, contextName, clusterName, clientconfig.EnsureK8sCredential)
   242  	if err != nil {
   243  		return newCloud, credential, context, errors.Trace(err)
   244  	}
   245  	logger.Debugf("caasConfig: %+v", caasConfig)
   246  
   247  	if len(caasConfig.Contexts) == 0 {
   248  		return newCloud, credential, context, errors.Errorf("No k8s cluster definitions found in config")
   249  	}
   250  
   251  	context = caasConfig.Contexts[reflect.ValueOf(caasConfig.Contexts).MapKeys()[0].Interface().(string)]
   252  
   253  	credential = caasConfig.Credentials[context.CredentialName]
   254  	newCloud.AuthTypes = []jujucloud.AuthType{credential.AuthType()}
   255  	currentCloud := caasConfig.Clouds[context.CloudName]
   256  	newCloud.Endpoint = currentCloud.Endpoint
   257  
   258  	cloudCAData, ok := currentCloud.Attributes["CAData"].(string)
   259  	if !ok {
   260  		return newCloud, credential, context, errors.Errorf("CAData attribute should be a string")
   261  	}
   262  	newCloud.CACertificates = []string{cloudCAData}
   263  	return newCloud, credential, context, nil
   264  }
   265  
   266  func (c *AddCAASCommand) getConfigReader(ctx *cmd.Context) (io.Reader, string, error) {
   267  	if !c.gke {
   268  		rdr, err := getStdinPipe(ctx)
   269  		return rdr, c.clusterName, err
   270  	}
   271  	p := &clusterParams{
   272  		name:       c.clusterName,
   273  		region:     c.hostCloudRegion,
   274  		project:    c.project,
   275  		credential: c.credential,
   276  	}
   277  	// TODO - add support for AKS etc
   278  	cluster := c.k8sCluster
   279  	if cluster == nil {
   280  		cluster = newGKECluster()
   281  	}
   282  
   283  	// If any items are missing, prompt for them.
   284  	if p.name == "" || p.project == "" || p.region == "" {
   285  		var err error
   286  		p, err = cluster.interactiveParams(ctx, p)
   287  		if err != nil {
   288  			return nil, "", errors.Trace(err)
   289  		}
   290  	}
   291  	c.clusterName = p.name
   292  	c.hostCloudRegion = cluster.cloud() + "/" + p.region
   293  	return cluster.getKubeConfig(p)
   294  }
   295  
   296  // Run is defined on the Command interface.
   297  func (c *AddCAASCommand) Run(ctx *cmd.Context) error {
   298  	if err := c.verifyName(c.caasName); err != nil {
   299  		return errors.Trace(err)
   300  	}
   301  	rdr, clusterName, err := c.getConfigReader(ctx)
   302  	if err != nil {
   303  		return errors.Trace(err)
   304  	}
   305  	if closer, ok := rdr.(io.Closer); ok {
   306  		defer closer.Close()
   307  	}
   308  	newCloud, credential, context, err := c.newCloudCredentialFromKubeConfig(rdr, c.contextName, clusterName)
   309  	if err != nil {
   310  		return errors.Trace(err)
   311  	}
   312  
   313  	cloudClient, err := c.addCloudAPIFunc()
   314  	if err != nil {
   315  		return errors.Trace(err)
   316  	}
   317  	defer cloudClient.Close()
   318  
   319  	if err := c.addCloudToControllerWithRegion(cloudClient, newCloud); err != nil {
   320  		if !params.IsCodeCloudRegionRequired(err) {
   321  			return errors.Trace(err)
   322  		}
   323  		// try to fetch cloud and region then retry.
   324  		cloudRegion, err := c.getClusterRegion(ctx, newCloud, credential)
   325  		errMsg := `
   326  	JAAS requires cloud and region information. But it's
   327  	not possible to fetch cluster region in this case,
   328  	please use --region to specify the cloud/region manually.
   329  	`[1:]
   330  		if err != nil {
   331  			return errors.Annotate(err, errMsg)
   332  		}
   333  		if cloudRegion == "" {
   334  			return errors.NewNotValid(nil, errMsg)
   335  		}
   336  		newCloud.HostCloudRegion = cloudRegion
   337  		if err := c.addCloudToControllerWithRegion(cloudClient, newCloud); err != nil {
   338  			return errors.Trace(err)
   339  		}
   340  	}
   341  
   342  	if err := addCloudToLocal(c.cloudMetadataStore, newCloud); err != nil {
   343  		return errors.Trace(err)
   344  	}
   345  
   346  	if err := c.addCredentialToLocal(c.caasName, credential, context.CredentialName); err != nil {
   347  		return errors.Trace(err)
   348  	}
   349  
   350  	if err := c.addCredentialToController(cloudClient, credential, context.CredentialName); err != nil {
   351  		return errors.Trace(err)
   352  	}
   353  	fmt.Fprintf(ctx.Stdout, "k8s substrate %q added as cloud %q\n", clusterName, c.caasName)
   354  
   355  	return nil
   356  }
   357  
   358  func (c *AddCAASCommand) addCloudToControllerWithRegion(apiClient AddCloudAPI, newCloud jujucloud.Cloud) (err error) {
   359  	if newCloud.HostCloudRegion != "" {
   360  		hostCloudRegion, err := c.validateCloudRegion(newCloud.HostCloudRegion)
   361  		if err != nil {
   362  			return errors.Trace(err)
   363  		}
   364  		newCloud.HostCloudRegion = hostCloudRegion
   365  	}
   366  	if err := addCloudToController(apiClient, newCloud); err != nil {
   367  		return errors.Trace(err)
   368  	}
   369  	return nil
   370  }
   371  
   372  func newK8sBrokerGetter(rootAPIGetter func() (api.Connection, error)) BrokerGetter {
   373  	return func(cloud jujucloud.Cloud, credential jujucloud.Credential) (k8sBrokerRegionLister, error) {
   374  		conn, err := rootAPIGetter()
   375  		if err != nil {
   376  			return nil, errors.Trace(err)
   377  		}
   378  		modelAPI := modelconfig.NewClient(conn)
   379  		defer modelAPI.Close()
   380  
   381  		// Use the controller model config for constructing the Juju k8s client.
   382  		attrs, err := modelAPI.ModelGet()
   383  		if err != nil {
   384  			return nil, errors.Trace(err)
   385  		}
   386  		cfg, err := config.New(config.NoDefaults, attrs)
   387  		if err != nil {
   388  			return nil, errors.Trace(err)
   389  		}
   390  
   391  		cloudSpec, err := environs.MakeCloudSpec(cloud, "", &credential)
   392  		if err != nil {
   393  			return nil, errors.Trace(err)
   394  		}
   395  		return caas.New(environs.OpenParams{Cloud: cloudSpec, Config: cfg})
   396  	}
   397  }
   398  
   399  func parseCloudRegion(cloudRegion string) (string, string, error) {
   400  	fields := strings.SplitN(cloudRegion, "/", 2)
   401  	if len(fields) != 2 || fields[0] == "" || fields[1] == "" {
   402  		return "", "", errors.NotValidf("cloud region %s", cloudRegion)
   403  	}
   404  	return fields[0], fields[1], nil
   405  }
   406  
   407  func (c *AddCAASCommand) validateCloudRegion(cloudRegion string) (_ string, err error) {
   408  	defer errors.DeferredAnnotatef(&err, "validating cloud region %q", cloudRegion)
   409  
   410  	cloudNameOrType, region, err := parseCloudRegion(cloudRegion)
   411  	if err != nil {
   412  		return "", errors.Annotate(err, "parsing cloud region")
   413  	}
   414  
   415  	clouds, err := c.getAllCloudDetails()
   416  	if err != nil {
   417  		return "", errors.Annotate(err, "listing cloud regions")
   418  	}
   419  	for name, details := range clouds {
   420  		// User may have specified cloud name or type so match on both.
   421  		if name == cloudNameOrType || details.CloudType == cloudNameOrType {
   422  			for k := range details.RegionsMap {
   423  				if k == region {
   424  					logger.Debugf("cloud region %q is valid", cloudRegion)
   425  					return details.CloudType + "/" + region, nil
   426  				}
   427  			}
   428  		}
   429  	}
   430  	return "", errors.NotValidf("cloud region %s", cloudRegion)
   431  }
   432  
   433  func (c *AddCAASCommand) getClusterRegion(
   434  	ctx *cmd.Context,
   435  	cloud jujucloud.Cloud,
   436  	credential jujucloud.Credential,
   437  ) (string, error) {
   438  	broker, err := c.brokerGetter(cloud, credential)
   439  	if err != nil {
   440  		return "", errors.Trace(err)
   441  	}
   442  
   443  	interrupted := make(chan os.Signal, 1)
   444  	defer close(interrupted)
   445  	ctx.InterruptNotify(interrupted)
   446  	defer ctx.StopInterruptNotify(interrupted)
   447  
   448  	result := make(chan string, 1)
   449  	errChan := make(chan error, 1)
   450  	go func() {
   451  		cloudRegions, err := broker.ListHostCloudRegions()
   452  		if err != nil {
   453  			errChan <- err
   454  		}
   455  		if cloudRegions == nil || cloudRegions.Size() == 0 {
   456  			result <- ""
   457  		} else {
   458  			// we currently assume it's always a single region cluster.
   459  			result <- cloudRegions.SortedValues()[0]
   460  		}
   461  	}()
   462  
   463  	timeout := 30 * time.Second
   464  	defer fmt.Fprintln(ctx.Stdout, "")
   465  	for {
   466  		select {
   467  		case <-time.After(1 * time.Second):
   468  			fmt.Fprintf(ctx.Stdout, ".")
   469  		case <-interrupted:
   470  			ctx.Infof("ctrl+c detected, aborting...")
   471  			return "", nil
   472  		case <-time.After(timeout):
   473  			return "", errors.Timeoutf("timeout after %v", timeout)
   474  		case err := <-errChan:
   475  			return "", err
   476  		case cloudRegion := <-result:
   477  			return cloudRegion, nil
   478  		}
   479  	}
   480  }
   481  
   482  func (c *AddCAASCommand) verifyName(name string) error {
   483  	public, _, err := c.cloudMetadataStore.PublicCloudMetadata()
   484  	if err != nil {
   485  		return err
   486  	}
   487  	msg, err := nameExists(name, public)
   488  	if err != nil {
   489  		return errors.Trace(err)
   490  	}
   491  	if msg != "" {
   492  		return errors.Errorf(msg)
   493  	}
   494  	return nil
   495  }
   496  
   497  // nameExists returns either an empty string if the name does not exist, or a
   498  // non-empty string with an error message if it does exist.
   499  func nameExists(name string, public map[string]jujucloud.Cloud) (string, error) {
   500  	if _, ok := public[name]; ok {
   501  		return fmt.Sprintf("%q is the name of a public cloud", name), nil
   502  	}
   503  	builtin, err := common.BuiltInClouds()
   504  	if err != nil {
   505  		return "", errors.Trace(err)
   506  	}
   507  	if _, ok := builtin[name]; ok {
   508  		return fmt.Sprintf("%q is the name of a built-in cloud", name), nil
   509  	}
   510  	return "", nil
   511  }
   512  
   513  func addCloudToLocal(cloudMetadataStore CloudMetadataStore, newCloud jujucloud.Cloud) error {
   514  	personalClouds, err := cloudMetadataStore.PersonalCloudMetadata()
   515  	if err != nil {
   516  		return err
   517  	}
   518  	if personalClouds == nil {
   519  		personalClouds = make(map[string]jujucloud.Cloud)
   520  	}
   521  	personalClouds[newCloud.Name] = newCloud
   522  	return cloudMetadataStore.WritePersonalCloudMetadata(personalClouds)
   523  }
   524  
   525  func addCloudToController(apiClient AddCloudAPI, newCloud jujucloud.Cloud) error {
   526  	err := apiClient.AddCloud(newCloud)
   527  	if err != nil {
   528  		return errors.Trace(err)
   529  	}
   530  	return nil
   531  }
   532  
   533  func (c *AddCAASCommand) addCredentialToLocal(cloudName string, newCredential jujucloud.Credential, credentialName string) error {
   534  	newCredentials := &jujucloud.CloudCredential{
   535  		AuthCredentials: make(map[string]jujucloud.Credential),
   536  	}
   537  	newCredentials.AuthCredentials[credentialName] = newCredential
   538  	err := c.fileCredentialStore.UpdateCredential(cloudName, *newCredentials)
   539  	if err != nil {
   540  		return errors.Trace(err)
   541  	}
   542  	return nil
   543  }
   544  
   545  func (c *AddCAASCommand) addCredentialToController(apiClient AddCloudAPI, newCredential jujucloud.Credential, credentialName string) error {
   546  	currentAccountDetails, err := c.CurrentAccountDetails()
   547  	if err != nil {
   548  		return errors.Trace(err)
   549  	}
   550  
   551  	cloudCredTag := names.NewCloudCredentialTag(fmt.Sprintf("%s/%s/%s",
   552  		c.caasName, currentAccountDetails.User, credentialName))
   553  
   554  	if err := apiClient.AddCredential(cloudCredTag.String(), newCredential); err != nil {
   555  		return errors.Trace(err)
   556  	}
   557  	return nil
   558  }