github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/cmd/juju/cloud/add.go (about) 1 // Copyright 2016 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package cloud 5 6 import ( 7 "fmt" 8 "io/ioutil" 9 "sort" 10 "strings" 11 12 "github.com/juju/cmd" 13 "github.com/juju/errors" 14 "github.com/juju/gnuflag" 15 "github.com/juju/utils" 16 "github.com/juju/utils/cert" 17 "gopkg.in/juju/names.v2" 18 "gopkg.in/yaml.v2" 19 20 cloudapi "github.com/juju/juju/api/cloud" 21 "github.com/juju/juju/apiserver/params" 22 jujucloud "github.com/juju/juju/cloud" 23 jujucmd "github.com/juju/juju/cmd" 24 "github.com/juju/juju/cmd/juju/common" 25 "github.com/juju/juju/cmd/juju/interact" 26 "github.com/juju/juju/cmd/modelcmd" 27 "github.com/juju/juju/environs" 28 "github.com/juju/juju/environs/context" 29 "github.com/juju/juju/jujuclient" 30 ) 31 32 type CloudMetadataStore interface { 33 ParseCloudMetadataFile(path string) (map[string]jujucloud.Cloud, error) 34 ParseOneCloud(data []byte) (jujucloud.Cloud, error) 35 PublicCloudMetadata(searchPaths ...string) (result map[string]jujucloud.Cloud, fallbackUsed bool, _ error) 36 PersonalCloudMetadata() (map[string]jujucloud.Cloud, error) 37 WritePersonalCloudMetadata(cloudsMap map[string]jujucloud.Cloud) error 38 } 39 40 var usageAddCloudSummary = ` 41 Adds a cloud definition to Juju.`[1:] 42 43 var usageAddCloudDetails = ` 44 Juju needs to know how to connect to clouds. A cloud definition 45 describes a cloud's endpoints and authentication requirements. Each 46 definition is stored and accessed later as <cloud name>. 47 48 If you are accessing a public cloud, running add-cloud unlikely to be 49 necessary. Juju already contains definitions for the public cloud 50 providers it supports. 51 52 add-cloud operates in two modes: 53 54 juju add-cloud 55 juju add-cloud <cloud name> <cloud definition file> 56 57 When invoked without arguments, add-cloud begins an interactive session 58 designed for working with private clouds. The session will enable you 59 to instruct Juju how to connect to your private cloud. 60 61 When <cloud definition file> is provided with <cloud name>, 62 Juju stores that definition its internal cache directly after 63 validating the contents. 64 65 If <cloud name> already exists in Juju's cache, then the `[1:] + "`--replace`" + ` 66 option is required. 67 68 A cloud definition file has the following YAML format: 69 70 clouds: # mandatory 71 mycloud: # <cloud name> argument 72 type: openstack # <cloud type>, see below 73 auth-types: [ userpass ] 74 regions: 75 london: 76 endpoint: https://london.mycloud.com:35574/v3.0/ 77 78 <cloud types> for private clouds: 79 - lxd 80 - maas 81 - manual 82 - openstack 83 - vsphere 84 85 <cloud types> for public clouds: 86 - azure 87 - cloudsigma 88 - ec2 89 - gce 90 - joyent 91 - oci 92 93 If you do not supply a controller name, only the local Juju cache 94 is updated. If you want to update a running controller to upload a 95 new, additional cloud, you can use the --controller or -c option. 96 The cloud details will be uploaded to the controller, along with 97 a credential for the cloud. As with the cloud, the credential needs 98 to have been added to the local Juju cache; add-credential is used to 99 do that. If there's only one credential for the cloud it will be 100 uploaded to the controller. If the cloud has multiple local credentials 101 you can specify which to upload with the --credential option. 102 103 Examples: 104 juju add-cloud 105 juju add-cloud mycloud ~/mycloud.yaml 106 juju add-cloud --replace mycloud ~/mycloud2.yaml 107 juju add-cloud --controller mycontroller mycloud 108 juju add-cloud --controller mycontroller mycloud --credential mycred 109 110 See also: 111 clouds` 112 113 // AddCloudAPI - Implemented by cloudapi.Client. 114 type AddCloudAPI interface { 115 AddCloud(jujucloud.Cloud) error 116 AddCredential(tag string, credential jujucloud.Credential) error 117 Close() error 118 } 119 120 // AddCloudCommand is the command that allows you to add a cloud configuration 121 // for use with juju bootstrap. 122 type AddCloudCommand struct { 123 modelcmd.CommandBase 124 125 // Replace, if true, existing cloud information is overwritten. 126 Replace bool 127 128 // Cloud is the name fo the cloud to add. 129 Cloud string 130 131 // CloudFile is the name of the cloud YAML file. 132 CloudFile string 133 134 // Ping contains the logic for pinging a cloud endpoint to know whether or 135 // not it really has a valid cloud of the same type as the provider. By 136 // default it just calls the correct provider's Ping method. 137 Ping func(p environs.EnvironProvider, endpoint string) error 138 139 // CloudCallCtx contains context to be used for any cloud calls. 140 CloudCallCtx *context.CloudCallContext 141 cloudMetadataStore CloudMetadataStore 142 143 // These attributes are used when adding a cloud to a controller. 144 controllerName string 145 credentialName string 146 store jujuclient.ClientStore 147 addCloudAPIFunc func() (AddCloudAPI, error) 148 } 149 150 // NewAddCloudCommand returns a command to add cloud information. 151 func NewAddCloudCommand(cloudMetadataStore CloudMetadataStore) cmd.Command { 152 cloudCallCtx := context.NewCloudCallContext() 153 c := &AddCloudCommand{ 154 cloudMetadataStore: cloudMetadataStore, 155 CloudCallCtx: cloudCallCtx, 156 // Ping is provider.Ping except in tests where we don't actually want to 157 // require a valid cloud. 158 Ping: func(p environs.EnvironProvider, endpoint string) error { 159 return p.Ping(cloudCallCtx, endpoint) 160 }, 161 store: jujuclient.NewFileClientStore(), 162 } 163 c.addCloudAPIFunc = c.cloudAPI 164 return modelcmd.WrapBase(c) 165 } 166 167 func (c *AddCloudCommand) cloudAPI() (AddCloudAPI, error) { 168 root, err := c.NewAPIRoot(c.store, c.controllerName, "") 169 if err != nil { 170 return nil, errors.Trace(err) 171 } 172 return cloudapi.NewClient(root), nil 173 } 174 175 // Info returns help information about the command. 176 func (c *AddCloudCommand) Info() *cmd.Info { 177 return jujucmd.Info(&cmd.Info{ 178 Name: "add-cloud", 179 Args: "<cloud name> [<cloud definition file>]", 180 Purpose: usageAddCloudSummary, 181 Doc: usageAddCloudDetails, 182 }) 183 } 184 185 // SetFlags initializes the flags supported by the command. 186 func (c *AddCloudCommand) SetFlags(f *gnuflag.FlagSet) { 187 c.CommandBase.SetFlags(f) 188 f.BoolVar(&c.Replace, "replace", false, "Overwrite any existing cloud information for <cloud name>") 189 f.StringVar(&c.CloudFile, "f", "", "The path to a cloud definition file") 190 f.StringVar(&c.credentialName, "credential", "", "Credential to use for new cloud") 191 f.StringVar(&c.controllerName, "c", "", "Controller to operate in") 192 f.StringVar(&c.controllerName, "controller", "", "") 193 } 194 195 // Init populates the command with the args from the command line. 196 func (c *AddCloudCommand) Init(args []string) (err error) { 197 if len(args) > 0 { 198 c.Cloud = args[0] 199 if ok := names.IsValidCloud(c.Cloud); !ok { 200 return errors.NotValidf("cloud name %q", c.Cloud) 201 } 202 } 203 if len(args) > 1 { 204 if c.CloudFile != args[1] && c.CloudFile != "" { 205 return errors.BadRequestf("cannot specify cloud file with option and argument") 206 } 207 c.CloudFile = args[1] 208 } 209 if len(args) > 2 { 210 return cmd.CheckEmpty(args[2:]) 211 } 212 return nil 213 } 214 215 var ambiguousCredentialError = errors.New(` 216 more than one credential is available 217 specify a credential using the --credential argument`[1:], 218 ) 219 220 func (c *AddCloudCommand) findLocalCredential(ctx *cmd.Context, cloud jujucloud.Cloud, credentialName string) (*jujucloud.Credential, string, error) { 221 credential, chosenCredentialName, _, err := modelcmd.GetCredentials(ctx, c.store, modelcmd.GetCredentialsParams{ 222 Cloud: cloud, 223 CredentialName: credentialName, 224 }) 225 if err == nil { 226 return credential, chosenCredentialName, nil 227 } 228 229 switch errors.Cause(err) { 230 case modelcmd.ErrMultipleCredentials: 231 return nil, "", ambiguousCredentialError 232 } 233 return nil, "", errors.Trace(err) 234 } 235 236 func (c *AddCloudCommand) addCredentialToController(ctx *cmd.Context, cloud jujucloud.Cloud, apiClient AddCloudAPI) error { 237 _, err := c.store.ControllerByName(c.controllerName) 238 if err != nil { 239 return errors.Trace(err) 240 } 241 242 currentAccountDetails, err := c.store.AccountDetails(c.controllerName) 243 if err != nil { 244 return errors.Trace(err) 245 } 246 247 cred, credentialName, err := c.findLocalCredential(ctx, cloud, c.credentialName) 248 if err != nil { 249 return errors.Trace(err) 250 } 251 252 cloudCredTag := names.NewCloudCredentialTag(fmt.Sprintf("%s/%s/%s", 253 c.Cloud, currentAccountDetails.User, credentialName)) 254 255 if err := apiClient.AddCredential(cloudCredTag.String(), *cred); err != nil { 256 return errors.Trace(err) 257 } 258 return nil 259 } 260 261 // Run executes the add cloud command, adding a cloud based on a passed-in yaml 262 // file or interactive queries. 263 func (c *AddCloudCommand) Run(ctxt *cmd.Context) error { 264 if c.CloudFile == "" && c.controllerName == "" { 265 return c.runInteractive(ctxt) 266 } 267 268 var newCloud *jujucloud.Cloud 269 if c.CloudFile != "" { 270 var err error 271 newCloud, err = c.readCloudFromFile(ctxt) 272 if err != nil { 273 return errors.Trace(err) 274 } 275 } else { 276 // No cloud file specified so we try and use a named 277 // cloud that already has been added to the local cache. 278 details, err := listCloudDetails() 279 if err != nil { 280 return err 281 } 282 cloudDetails, ok := details.all()[c.Cloud] 283 if !ok { 284 return errors.NotFoundf("cloud %q", c.Cloud) 285 } 286 newCloud = &jujucloud.Cloud{ 287 Name: c.Cloud, 288 Type: cloudDetails.CloudType, 289 Description: cloudDetails.CloudDescription, 290 Endpoint: cloudDetails.Endpoint, 291 IdentityEndpoint: cloudDetails.IdentityEndpoint, 292 StorageEndpoint: cloudDetails.StorageEndpoint, 293 CACertificates: cloudDetails.CACredentials, 294 Config: cloudDetails.Config, 295 RegionConfig: cloudDetails.RegionConfig, 296 } 297 for _, at := range cloudDetails.AuthTypes { 298 newCloud.AuthTypes = append(newCloud.AuthTypes, jujucloud.AuthType(at)) 299 } 300 for name, r := range cloudDetails.RegionsMap { 301 newCloud.Regions = append(newCloud.Regions, jujucloud.Region{ 302 Name: name, 303 Endpoint: r.Endpoint, 304 StorageEndpoint: r.StorageEndpoint, 305 IdentityEndpoint: r.IdentityEndpoint, 306 }) 307 } 308 } 309 if c.controllerName == "" { 310 return addLocalCloud(c.cloudMetadataStore, *newCloud) 311 } 312 313 // A controller has been specified so upload the cloud details 314 // plus a corresponding credential to the controller. 315 api, err := c.addCloudAPIFunc() 316 if err != nil { 317 return err 318 } 319 err = api.AddCloud(*newCloud) 320 if err != nil && params.ErrCode(err) != params.CodeAlreadyExists { 321 return err 322 } 323 // Add a credential for the newly added cloud. 324 return c.addCredentialToController(ctxt, *newCloud, api) 325 } 326 327 func (c *AddCloudCommand) readCloudFromFile(ctxt *cmd.Context) (*jujucloud.Cloud, error) { 328 specifiedClouds, err := c.cloudMetadataStore.ParseCloudMetadataFile(c.CloudFile) 329 if err != nil { 330 return nil, errors.Trace(err) 331 } 332 if specifiedClouds == nil { 333 return nil, errors.New("no personal clouds are defined") 334 } 335 newCloud, ok := specifiedClouds[c.Cloud] 336 if !ok { 337 return nil, errors.Errorf("cloud %q not found in file %q", c.Cloud, c.CloudFile) 338 } 339 340 // first validate cloud input 341 data, err := ioutil.ReadFile(c.CloudFile) 342 if err != nil { 343 return nil, errors.Trace(err) 344 } 345 if err = jujucloud.ValidateCloudSet(data); err != nil { 346 ctxt.Warningf(err.Error()) 347 } 348 349 // validate cloud data 350 provider, err := environs.Provider(newCloud.Type) 351 if err != nil { 352 return nil, errors.Trace(err) 353 } 354 schemas := provider.CredentialSchemas() 355 for _, authType := range newCloud.AuthTypes { 356 if _, defined := schemas[authType]; !defined { 357 return nil, errors.NotSupportedf("auth type %q", authType) 358 } 359 } 360 if err := c.verifyName(c.Cloud); err != nil { 361 return nil, errors.Trace(err) 362 } 363 return &newCloud, nil 364 } 365 366 func (c *AddCloudCommand) runInteractive(ctxt *cmd.Context) error { 367 errout := interact.NewErrWriter(ctxt.Stdout) 368 pollster := interact.New(ctxt.Stdin, ctxt.Stdout, errout) 369 370 cloudType, err := queryCloudType(pollster) 371 if err != nil { 372 return errors.Trace(err) 373 } 374 375 name, err := queryName(c.cloudMetadataStore, c.Cloud, cloudType, pollster) 376 if err != nil { 377 return errors.Trace(err) 378 } 379 380 provider, err := environs.Provider(cloudType) 381 if err != nil { 382 return errors.Trace(err) 383 } 384 385 // At this stage, since we do not have a reference to any model, nor can we get it, 386 // nor do we need to have a model for anything that this command does, 387 // no cloud credential stored server-side can be invalidated. 388 // So, just log an informative message. 389 c.CloudCallCtx.InvalidateCredentialFunc = func(reason string) error { 390 ctxt.Infof("Cloud credential is not accepted by cloud provider: %v", reason) 391 return nil 392 } 393 394 // VerifyURLs will return true if a schema format type jsonschema.FormatURI is used 395 // and the value will Ping(). 396 pollster.VerifyURLs = func(s string) (bool, string, error) { 397 err := c.Ping(provider, s) 398 if err != nil { 399 return false, "Can't validate endpoint: " + err.Error(), nil 400 } 401 return true, "", nil 402 } 403 404 // VerifyCertFile will return true if the schema format type "cert-filename" is used 405 // and the value is readable and a valid cert file. 406 pollster.VerifyCertFile = func(s string) (bool, string, error) { 407 out, err := ioutil.ReadFile(s) 408 if err != nil { 409 return false, "Can't validate CA Certificate file: " + err.Error(), nil 410 } 411 if _, err := cert.ParseCert(string(out)); err != nil { 412 return false, fmt.Sprintf("Can't validate CA Certificate %s: %s", s, err.Error()), nil 413 } 414 return true, "", nil 415 } 416 417 v, err := pollster.QuerySchema(provider.CloudSchema()) 418 if err != nil { 419 return errors.Trace(err) 420 } 421 b, err := yaml.Marshal(v) 422 if err != nil { 423 return errors.Trace(err) 424 } 425 426 filename, alt, err := addCertificate(b) 427 switch { 428 case errors.IsNotFound(err): 429 case err != nil: 430 return errors.Annotate(err, "CA Certificate") 431 default: 432 ctxt.Infof("Successfully read CA Certificate from %s", filename) 433 b = alt 434 } 435 436 newCloud, err := c.cloudMetadataStore.ParseOneCloud(b) 437 if err != nil { 438 return errors.Trace(err) 439 } 440 newCloud.Name = name 441 newCloud.Type = cloudType 442 if err := addLocalCloud(c.cloudMetadataStore, newCloud); err != nil { 443 return errors.Trace(err) 444 } 445 ctxt.Infof("Cloud %q successfully added", name) 446 ctxt.Infof("") 447 ctxt.Infof("You will need to add credentials for this cloud (`juju add-credential %s`)", name) 448 ctxt.Infof("before creating a controller (`juju bootstrap %s`).", name) 449 450 return nil 451 } 452 453 // addCertificate reads the cloud certificate file if available and adds the contents 454 // to the byte slice with the appropriate key. A NotFound error is returned if 455 // a cloud.CertFilenameKey is not contained in the data, or the value is empty, this is 456 // not a fatal error. 457 func addCertificate(data []byte) (string, []byte, error) { 458 vals, err := ensureStringMaps(string(data)) 459 if err != nil { 460 return "", nil, err 461 } 462 name, ok := vals[jujucloud.CertFilenameKey] 463 if !ok { 464 return "", nil, errors.NotFoundf("yaml has no certificate file") 465 } 466 filename := name.(string) 467 if ok && filename != "" { 468 out, err := ioutil.ReadFile(filename) 469 if err != nil { 470 return filename, nil, err 471 } 472 certificate := string(out) 473 if _, err := cert.ParseCert(certificate); err != nil { 474 return filename, nil, errors.Annotate(err, "bad cloud CA certificate") 475 } 476 vals["ca-certificates"] = []string{certificate} 477 478 } else { 479 return filename, nil, errors.NotFoundf("yaml has no certificate file") 480 } 481 alt, err := yaml.Marshal(vals) 482 return filename, alt, err 483 } 484 485 func ensureStringMaps(in string) (map[string]interface{}, error) { 486 userDataMap := make(map[string]interface{}) 487 if err := yaml.Unmarshal([]byte(in), &userDataMap); err != nil { 488 return nil, errors.Annotate(err, "must be valid YAML") 489 } 490 out, err := utils.ConformYAML(userDataMap) 491 if err != nil { 492 return nil, err 493 } 494 return out.(map[string]interface{}), nil 495 } 496 497 func queryName( 498 cloudMetadataStore CloudMetadataStore, 499 cloudName string, 500 cloudType string, 501 pollster *interact.Pollster, 502 ) (string, error) { 503 public, _, err := cloudMetadataStore.PublicCloudMetadata() 504 if err != nil { 505 return "", err 506 } 507 personal, err := cloudMetadataStore.PersonalCloudMetadata() 508 if err != nil { 509 return "", err 510 } 511 512 for { 513 if cloudName == "" { 514 name, err := pollster.Enter(fmt.Sprintf("a name for your %s cloud", cloudType)) 515 if err != nil { 516 return "", errors.Trace(err) 517 } 518 cloudName = name 519 } 520 if _, ok := personal[cloudName]; ok { 521 override, err := pollster.YN(fmt.Sprintf("A cloud named %q already exists. Do you want to replace that definition", cloudName), false) 522 if err != nil { 523 return "", errors.Trace(err) 524 } 525 if override { 526 return cloudName, nil 527 } 528 // else, ask again 529 cloudName = "" 530 continue 531 } 532 msg, err := nameExists(cloudName, public) 533 if err != nil { 534 return "", errors.Trace(err) 535 } 536 if msg == "" { 537 return cloudName, nil 538 } 539 override, err := pollster.YN(msg+", do you want to override that definition", false) 540 if err != nil { 541 return "", errors.Trace(err) 542 } 543 if override { 544 return cloudName, nil 545 } 546 // else, ask again 547 } 548 } 549 550 // addableCloudProviders returns the names of providers supported by add-cloud, 551 // and also the names of those which are not supported. 552 func addableCloudProviders() (providers []string, unsupported []string, _ error) { 553 allproviders := environs.RegisteredProviders() 554 for _, name := range allproviders { 555 provider, err := environs.Provider(name) 556 if err != nil { 557 // should be impossible 558 return nil, nil, errors.Trace(err) 559 } 560 561 if provider.CloudSchema() != nil { 562 providers = append(providers, name) 563 } else { 564 unsupported = append(unsupported, name) 565 } 566 } 567 sort.Strings(providers) 568 return providers, unsupported, nil 569 } 570 571 func queryCloudType(pollster *interact.Pollster) (string, error) { 572 providers, unsupported, err := addableCloudProviders() 573 if err != nil { 574 // should be impossible 575 return "", errors.Trace(err) 576 } 577 supportedCloud := interact.VerifyOptions("cloud type", providers, false) 578 579 cloudVerify := func(s string) (ok bool, errmsg string, err error) { 580 ok, errmsg, err = supportedCloud(s) 581 if err != nil { 582 return false, "", errors.Trace(err) 583 } 584 if ok { 585 return true, "", nil 586 } 587 // Print out a different message if they entered a valid provider that 588 // just isn't something we want people to add (like ec2). 589 for _, name := range unsupported { 590 if strings.ToLower(name) == strings.ToLower(s) { 591 return false, fmt.Sprintf("Cloud type %q not supported for interactive add-cloud.", s), nil 592 } 593 } 594 return false, errmsg, nil 595 } 596 597 return pollster.SelectVerify(interact.List{ 598 Singular: "cloud type", 599 Plural: "cloud types", 600 Options: providers, 601 }, cloudVerify) 602 } 603 604 func (c *AddCloudCommand) verifyName(name string) error { 605 if c.Replace { 606 return nil 607 } 608 public, _, err := c.cloudMetadataStore.PublicCloudMetadata() 609 if err != nil { 610 return err 611 } 612 personal, err := c.cloudMetadataStore.PersonalCloudMetadata() 613 if err != nil { 614 return err 615 } 616 if _, ok := personal[name]; ok { 617 return errors.Errorf("%q already exists; use --replace to replace this existing cloud", name) 618 } 619 msg, err := nameExists(name, public) 620 if err != nil { 621 return errors.Trace(err) 622 } 623 if msg != "" { 624 return errors.Errorf(msg + "; use --replace to override this definition") 625 } 626 return nil 627 } 628 629 // nameExists returns either an empty string if the name does not exist, or a 630 // non-empty string with an error message if it does exist. 631 func nameExists(name string, public map[string]jujucloud.Cloud) (string, error) { 632 if _, ok := public[name]; ok { 633 return fmt.Sprintf("%q is the name of a public cloud", name), nil 634 } 635 builtin, err := common.BuiltInClouds() 636 if err != nil { 637 return "", errors.Trace(err) 638 } 639 if _, ok := builtin[name]; ok { 640 return fmt.Sprintf("%q is the name of a built-in cloud", name), nil 641 } 642 return "", nil 643 } 644 645 func addLocalCloud(cloudMetadataStore CloudMetadataStore, newCloud jujucloud.Cloud) error { 646 personalClouds, err := cloudMetadataStore.PersonalCloudMetadata() 647 if err != nil { 648 return err 649 } 650 if personalClouds == nil { 651 personalClouds = make(map[string]jujucloud.Cloud) 652 } 653 personalClouds[newCloud.Name] = newCloud 654 return cloudMetadataStore.WritePersonalCloudMetadata(personalClouds) 655 }