github.com/myhau/pulumi/pkg/v3@v3.70.2-0.20221116134521-f2775972e587/backend/httpstate/backend.go (about) 1 // Copyright 2016-2022, Pulumi Corporation. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package httpstate 16 17 import ( 18 "context" 19 cryptorand "crypto/rand" 20 "encoding/hex" 21 "errors" 22 "fmt" 23 "io" 24 "net" 25 "net/http" 26 "net/url" 27 "os" 28 "path" 29 "regexp" 30 "strconv" 31 "strings" 32 "time" 33 34 opentracing "github.com/opentracing/opentracing-go" 35 36 "github.com/pulumi/pulumi/pkg/v3/backend" 37 "github.com/pulumi/pulumi/pkg/v3/backend/display" 38 "github.com/pulumi/pulumi/pkg/v3/backend/filestate" 39 "github.com/pulumi/pulumi/pkg/v3/backend/httpstate/client" 40 "github.com/pulumi/pulumi/pkg/v3/engine" 41 "github.com/pulumi/pulumi/pkg/v3/operations" 42 "github.com/pulumi/pulumi/pkg/v3/resource/deploy" 43 "github.com/pulumi/pulumi/pkg/v3/secrets" 44 "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" 45 "github.com/pulumi/pulumi/sdk/v3/go/common/diag" 46 "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" 47 sdkDisplay "github.com/pulumi/pulumi/sdk/v3/go/common/display" 48 "github.com/pulumi/pulumi/sdk/v3/go/common/resource" 49 "github.com/pulumi/pulumi/sdk/v3/go/common/resource/config" 50 "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" 51 "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" 52 "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" 53 "github.com/pulumi/pulumi/sdk/v3/go/common/util/logging" 54 "github.com/pulumi/pulumi/sdk/v3/go/common/util/result" 55 "github.com/pulumi/pulumi/sdk/v3/go/common/util/retry" 56 "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" 57 "github.com/skratchdot/open-golang/open" 58 ) 59 60 const ( 61 // defaultAPIEnvVar can be set to override the default cloud chosen, if `--cloud` is not present. 62 defaultURLEnvVar = "PULUMI_API" 63 // AccessTokenEnvVar is the environment variable used to bypass a prompt on login. 64 AccessTokenEnvVar = "PULUMI_ACCESS_TOKEN" 65 ) 66 67 // Name validation rules enforced by the Pulumi Service. 68 var ( 69 stackOwnerRegexp = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9-_]{1,38}[a-zA-Z0-9]$") 70 stackNameAndProjectRegexp = regexp.MustCompile("^[A-Za-z0-9_.-]{1,100}$") 71 ) 72 73 // DefaultURL returns the default cloud URL. This may be overridden using the PULUMI_API environment 74 // variable. If no override is found, and we are authenticated with a cloud, choose that. Otherwise, 75 // we will default to the https://api.pulumi.com/ endpoint. 76 func DefaultURL() string { 77 return ValueOrDefaultURL("") 78 } 79 80 // ValueOrDefaultURL returns the value if specified, or the default cloud URL otherwise. 81 func ValueOrDefaultURL(cloudURL string) string { 82 // If we have a cloud URL, just return it. 83 if cloudURL != "" { 84 return strings.TrimSuffix(cloudURL, "/") 85 } 86 87 // Otherwise, respect the PULUMI_API override. 88 if cloudURL := os.Getenv(defaultURLEnvVar); cloudURL != "" { 89 return cloudURL 90 } 91 92 // If that didn't work, see if we have a current cloud, and use that. Note we need to be careful 93 // to ignore the local cloud. 94 if creds, err := workspace.GetStoredCredentials(); err == nil { 95 if creds.Current != "" && !filestate.IsFileStateBackendURL(creds.Current) { 96 return creds.Current 97 } 98 } 99 100 // If none of those led to a cloud URL, simply return the default. 101 return PulumiCloudURL 102 } 103 104 // Backend extends the base backend interface with specific information about cloud backends. 105 type Backend interface { 106 backend.Backend 107 108 CloudURL() string 109 110 StackConsoleURL(stackRef backend.StackReference) (string, error) 111 Client() *client.Client 112 113 RunDeployment(ctx context.Context, stackRef backend.StackReference, req apitype.CreateDeploymentRequest, 114 opts display.Options) error 115 } 116 117 type cloudBackend struct { 118 d diag.Sink 119 url string 120 client *client.Client 121 currentProject *workspace.Project 122 } 123 124 // Assert we implement the backend.Backend and backend.SpecificDeploymentExporter interfaces. 125 var _ backend.SpecificDeploymentExporter = &cloudBackend{} 126 127 // New creates a new Pulumi backend for the given cloud API URL and token. 128 func New(d diag.Sink, cloudURL string) (Backend, error) { 129 cloudURL = ValueOrDefaultURL(cloudURL) 130 account, err := workspace.GetAccount(cloudURL) 131 if err != nil { 132 return nil, fmt.Errorf("getting stored credentials: %w", err) 133 } 134 apiToken := account.AccessToken 135 136 // When stringifying backend references, we take the current project (if present) into account. 137 currentProject, err := workspace.DetectProject() 138 if err != nil { 139 currentProject = nil 140 } 141 142 return &cloudBackend{ 143 d: d, 144 url: cloudURL, 145 client: client.NewClient(cloudURL, apiToken, d), 146 currentProject: currentProject, 147 }, nil 148 } 149 150 // loginWithBrowser uses a web-browser to log into the cloud and returns the cloud backend for it. 151 func loginWithBrowser(ctx context.Context, d diag.Sink, cloudURL string, opts display.Options) (Backend, error) { 152 // Locally, we generate a nonce and spin up a web server listening on a random port on localhost. We then open a 153 // browser to a special endpoint on the Pulumi.com console, passing the generated nonce as well as the port of the 154 // webserver we launched. This endpoint does the OAuth flow and when it completes, redirects to localhost passing 155 // the nonce and the pulumi access token we created as part of the OAuth flow. If the nonces match, we set the 156 // access token that was passed to us and the redirect to a special welcome page on Pulumi.com 157 158 loginURL := cloudConsoleURL(cloudURL, "cli-login") 159 finalWelcomeURL := cloudConsoleURL(cloudURL, "welcome", "cli") 160 161 if loginURL == "" || finalWelcomeURL == "" { 162 return nil, errors.New("could not determine login url") 163 } 164 165 // Listen on localhost, have the kernel pick a random port for us 166 c := make(chan string) 167 l, err := net.Listen("tcp", "127.0.0.1:") 168 if err != nil { 169 return nil, fmt.Errorf("could not start listener: %w", err) 170 } 171 172 // Extract the port 173 _, port, err := net.SplitHostPort(l.Addr().String()) 174 if err != nil { 175 return nil, fmt.Errorf("could not determine port: %w", err) 176 } 177 178 // Generate a nonce we'll send with the request. 179 nonceBytes := make([]byte, 32) 180 _, err = cryptorand.Read(nonceBytes) 181 contract.AssertNoErrorf(err, "could not get random bytes") 182 nonce := hex.EncodeToString(nonceBytes) 183 184 u, err := url.Parse(loginURL) 185 contract.AssertNoError(err) 186 187 // Generate a description to associate with the access token we'll generate, for display on the Account Settings 188 // page. 189 var tokenDescription string 190 if host, hostErr := os.Hostname(); hostErr == nil { 191 tokenDescription = fmt.Sprintf("Generated by pulumi login on %s at %s", host, time.Now().Format(time.RFC822)) 192 } else { 193 tokenDescription = fmt.Sprintf("Generated by pulumi login at %s", time.Now().Format(time.RFC822)) 194 } 195 196 // Pass our state around as query parameters on the URL we'll open the user's preferred browser to 197 q := u.Query() 198 q.Add("cliSessionPort", port) 199 q.Add("cliSessionNonce", nonce) 200 q.Add("cliSessionDescription", tokenDescription) 201 u.RawQuery = q.Encode() 202 203 // Start the webserver to listen to handle the response 204 go serveBrowserLoginServer(l, nonce, finalWelcomeURL, c) 205 206 // Launch the web browser and navigate to the login URL. 207 if openErr := open.Run(u.String()); openErr != nil { 208 fmt.Printf("We couldn't launch your web browser for some reason. Please visit:\n\n%s\n\n"+ 209 "to finish the login process.", u) 210 } else { 211 fmt.Println("We've launched your web browser to complete the login process.") 212 } 213 214 fmt.Println("\nWaiting for login to complete...") 215 216 accessToken := <-c 217 218 username, organizations, err := client.NewClient(cloudURL, accessToken, d).GetPulumiAccountDetails(ctx) 219 if err != nil { 220 return nil, err 221 } 222 223 // Save the token and return the backend 224 account := workspace.Account{ 225 AccessToken: accessToken, 226 Username: username, 227 Organizations: organizations, 228 LastValidatedAt: time.Now(), 229 } 230 if err = workspace.StoreAccount(cloudURL, account, true); err != nil { 231 return nil, err 232 } 233 234 // Welcome the user since this was an interactive login. 235 WelcomeUser(opts) 236 237 return New(d, cloudURL) 238 } 239 240 // LoginManager provides a slim wrapper around functions related to backend logins. 241 type LoginManager interface { 242 // Current returns the current cloud backend if one is already logged in. 243 Current(ctx context.Context, d diag.Sink, cloudURL string) (Backend, error) 244 245 // Login logs into the target cloud URL and returns the cloud backend for it. 246 Login(ctx context.Context, d diag.Sink, cloudURL string, opts display.Options) (Backend, error) 247 } 248 249 // NewLoginManager returns a LoginManager for handling backend logins. 250 func NewLoginManager() LoginManager { 251 return newLoginManager() 252 } 253 254 // newLoginManager creates a new LoginManager for handling logins. It is a variable instead of a regular 255 // function so it can be set to a different implementation at runtime, if necessary. 256 var newLoginManager = func() LoginManager { 257 return defaultLoginManager{} 258 } 259 260 type defaultLoginManager struct{} 261 262 // Current returns the current cloud backend if one is already logged in. 263 func (m defaultLoginManager) Current(ctx context.Context, d diag.Sink, cloudURL string) (Backend, error) { 264 cloudURL = ValueOrDefaultURL(cloudURL) 265 266 // If we have a saved access token, and it is valid, use it. 267 existingAccount, err := workspace.GetAccount(cloudURL) 268 if err == nil && existingAccount.AccessToken != "" { 269 // If the account was last verified less than an hour ago, assume the token is valid. 270 valid, username, organizations := true, existingAccount.Username, existingAccount.Organizations 271 if username == "" || existingAccount.LastValidatedAt.Add(1*time.Hour).Before(time.Now()) { 272 valid, username, organizations, err = IsValidAccessToken(ctx, cloudURL, existingAccount.AccessToken) 273 if err != nil { 274 return nil, err 275 } 276 existingAccount.LastValidatedAt = time.Now() 277 } 278 279 if valid { 280 // Save the token. While it hasn't changed this will update the current cloud we are logged into, as well. 281 existingAccount.Username = username 282 existingAccount.Organizations = organizations 283 if err = workspace.StoreAccount(cloudURL, existingAccount, true); err != nil { 284 return nil, err 285 } 286 287 return New(d, cloudURL) 288 } 289 } 290 291 // We intentionally don't accept command-line args for the user's access token. Having it in 292 // .bash_history is not great, and specifying it via flag isn't of much use. 293 accessToken := os.Getenv(AccessTokenEnvVar) 294 295 if accessToken == "" { 296 // No access token available, this isn't an error per-se but we don't have a backend 297 return nil, nil 298 } 299 300 // If there's already a token from the environment, use it. 301 _, err = fmt.Fprintf(os.Stderr, "Logging in using access token from %s\n", AccessTokenEnvVar) 302 contract.IgnoreError(err) 303 304 // Try and use the credentials to see if they are valid. 305 valid, username, organizations, err := IsValidAccessToken(ctx, cloudURL, accessToken) 306 if err != nil { 307 return nil, err 308 } else if !valid { 309 return nil, fmt.Errorf("invalid access token") 310 } 311 312 // Save them. 313 account := workspace.Account{ 314 AccessToken: accessToken, 315 Username: username, 316 Organizations: organizations, 317 LastValidatedAt: time.Now(), 318 } 319 if err = workspace.StoreAccount(cloudURL, account, true); err != nil { 320 return nil, err 321 } 322 323 return New(d, cloudURL) 324 } 325 326 // Login logs into the target cloud URL and returns the cloud backend for it. 327 func (m defaultLoginManager) Login( 328 ctx context.Context, d diag.Sink, cloudURL string, opts display.Options) (Backend, error) { 329 330 current, err := m.Current(ctx, d, cloudURL) 331 if err != nil { 332 return nil, err 333 } 334 if current != nil { 335 return current, nil 336 } 337 338 cloudURL = ValueOrDefaultURL(cloudURL) 339 var accessToken string 340 accountLink := cloudConsoleURL(cloudURL, "account", "tokens") 341 342 if !cmdutil.Interactive() { 343 // If interactive mode isn't enabled, the only way to specify a token is through the environment variable. 344 // Fail the attempt to login. 345 return nil, fmt.Errorf("%s must be set for login during non-interactive CLI sessions", AccessTokenEnvVar) 346 } 347 348 // If no access token is available from the environment, and we are interactive, prompt and offer to 349 // open a browser to make it easy to generate and use a fresh token. 350 line1 := "Manage your Pulumi stacks by logging in." 351 line1len := len(line1) 352 line1 = colors.Highlight(line1, "Pulumi stacks", colors.Underline+colors.Bold) 353 fmt.Printf(opts.Color.Colorize(line1) + "\n") 354 maxlen := line1len 355 356 line2 := "Run `pulumi login --help` for alternative login options." 357 line2len := len(line2) 358 fmt.Printf(opts.Color.Colorize(line2) + "\n") 359 if line2len > maxlen { 360 maxlen = line2len 361 } 362 363 // In the case where we could not construct a link to the pulumi console based on the API server's hostname, 364 // don't offer magic log-in or text about where to find your access token. 365 if accountLink == "" { 366 for { 367 if accessToken, err = cmdutil.ReadConsoleNoEcho("Enter your access token"); err != nil { 368 return nil, err 369 } 370 if accessToken != "" { 371 break 372 } 373 } 374 } else { 375 line3 := fmt.Sprintf("Enter your access token from %s", accountLink) 376 line3len := len(line3) 377 line3 = colors.Highlight(line3, "access token", colors.BrightCyan+colors.Bold) 378 line3 = colors.Highlight(line3, accountLink, colors.BrightBlue+colors.Underline+colors.Bold) 379 fmt.Printf(opts.Color.Colorize(line3) + "\n") 380 if line3len > maxlen { 381 maxlen = line3len 382 } 383 384 line4 := " or hit <ENTER> to log in using your browser" 385 var padding string 386 if pad := maxlen - len(line4); pad > 0 { 387 padding = strings.Repeat(" ", pad) 388 } 389 line4 = colors.Highlight(line4, "<ENTER>", colors.BrightCyan+colors.Bold) 390 391 if accessToken, err = cmdutil.ReadConsoleNoEcho(opts.Color.Colorize(line4) + padding); err != nil { 392 return nil, err 393 } 394 395 if accessToken == "" { 396 return loginWithBrowser(ctx, d, cloudURL, opts) 397 } 398 399 // Welcome the user since this was an interactive login. 400 WelcomeUser(opts) 401 } 402 403 // Try and use the credentials to see if they are valid. 404 valid, username, organizations, err := IsValidAccessToken(ctx, cloudURL, accessToken) 405 if err != nil { 406 return nil, err 407 } else if !valid { 408 return nil, fmt.Errorf("invalid access token") 409 } 410 411 // Save them. 412 account := workspace.Account{ 413 AccessToken: accessToken, 414 Username: username, 415 Organizations: organizations, 416 LastValidatedAt: time.Now(), 417 } 418 if err = workspace.StoreAccount(cloudURL, account, true); err != nil { 419 return nil, err 420 } 421 422 return New(d, cloudURL) 423 } 424 425 // WelcomeUser prints a Welcome to Pulumi message. 426 func WelcomeUser(opts display.Options) { 427 fmt.Printf(` 428 429 %s 430 431 Pulumi helps you create, deploy, and manage infrastructure on any cloud using 432 your favorite language. You can get started today with Pulumi at: 433 434 https://www.pulumi.com/docs/get-started/ 435 436 %s Resources you create with Pulumi are given unique names (a randomly 437 generated suffix) by default. To learn more about auto-naming or customizing resource 438 names see https://www.pulumi.com/docs/intro/concepts/resources/#autonaming. 439 440 441 `, 442 opts.Color.Colorize(colors.SpecHeadline+"Welcome to Pulumi!"+colors.Reset), 443 opts.Color.Colorize(colors.SpecSubHeadline+"Tip of the day:"+colors.Reset)) 444 } 445 446 func (b *cloudBackend) StackConsoleURL(stackRef backend.StackReference) (string, error) { 447 stackID, err := b.getCloudStackIdentifier(stackRef) 448 if err != nil { 449 return "", err 450 } 451 452 path := b.cloudConsoleStackPath(stackID) 453 454 url := b.CloudConsoleURL(path) 455 if url == "" { 456 return "", errors.New("could not determine cloud console URL") 457 } 458 return url, nil 459 } 460 461 func (b *cloudBackend) Name() string { 462 if b.url == PulumiCloudURL { 463 return "pulumi.com" 464 } 465 466 return b.url 467 } 468 469 func (b *cloudBackend) URL() string { 470 user, _, err := b.CurrentUser() 471 if err != nil { 472 return cloudConsoleURL(b.url) 473 } 474 return cloudConsoleURL(b.url, user) 475 } 476 477 func (b *cloudBackend) CurrentUser() (string, []string, error) { 478 return b.currentUser(context.Background()) 479 } 480 481 func (b *cloudBackend) currentUser(ctx context.Context) (string, []string, error) { 482 account, err := workspace.GetAccount(b.CloudURL()) 483 if err != nil { 484 return "", nil, err 485 } 486 if account.Username != "" { 487 logging.V(1).Infof("found username for access token") 488 return account.Username, account.Organizations, nil 489 } 490 logging.V(1).Infof("no username for access token") 491 name, orgs, err := b.client.GetPulumiAccountDetails(ctx) 492 return name, orgs, err 493 } 494 495 func (b *cloudBackend) CloudURL() string { return b.url } 496 497 func (b *cloudBackend) parsePolicyPackReference(s string) (backend.PolicyPackReference, error) { 498 split := strings.Split(s, "/") 499 var orgName string 500 var policyPackName string 501 502 switch len(split) { 503 case 2: 504 orgName = split[0] 505 policyPackName = split[1] 506 default: 507 return nil, fmt.Errorf("could not parse policy pack name '%s'; must be of the form "+ 508 "<org-name>/<policy-pack-name>", s) 509 } 510 511 if orgName == "" { 512 currentUser, _, userErr := b.CurrentUser() 513 if userErr != nil { 514 return nil, userErr 515 } 516 orgName = currentUser 517 } 518 519 return newCloudBackendPolicyPackReference(b.CloudConsoleURL(), orgName, tokens.QName(policyPackName)), nil 520 } 521 522 func (b *cloudBackend) GetPolicyPack(ctx context.Context, policyPack string, 523 d diag.Sink) (backend.PolicyPack, error) { 524 525 policyPackRef, err := b.parsePolicyPackReference(policyPack) 526 if err != nil { 527 return nil, err 528 } 529 530 account, err := workspace.GetAccount(b.CloudURL()) 531 if err != nil { 532 return nil, err 533 } 534 apiToken := account.AccessToken 535 536 return &cloudPolicyPack{ 537 ref: newCloudBackendPolicyPackReference(b.CloudConsoleURL(), 538 policyPackRef.OrgName(), policyPackRef.Name()), 539 b: b, 540 cl: client.NewClient(b.CloudURL(), apiToken, d)}, nil 541 } 542 543 func (b *cloudBackend) ListPolicyGroups(ctx context.Context, orgName string, inContToken backend.ContinuationToken) ( 544 apitype.ListPolicyGroupsResponse, backend.ContinuationToken, error) { 545 return b.client.ListPolicyGroups(ctx, orgName, inContToken) 546 } 547 548 func (b *cloudBackend) ListPolicyPacks(ctx context.Context, orgName string, inContToken backend.ContinuationToken) ( 549 apitype.ListPolicyPacksResponse, backend.ContinuationToken, error) { 550 return b.client.ListPolicyPacks(ctx, orgName, inContToken) 551 } 552 553 func (b *cloudBackend) SupportsTags() bool { 554 return true 555 } 556 557 func (b *cloudBackend) SupportsOrganizations() bool { 558 return true 559 } 560 561 // qualifiedStackReference describes a qualified stack on the Pulumi Service. The Owner or Project 562 // may be "" if unspecified, e.g. "pulumi/production" specifies the Owner and Name, but not the 563 // Project. We infer the missing data and try to make things work as best we can in ParseStackReference. 564 type qualifiedStackReference struct { 565 Owner string 566 Project string 567 Name string 568 } 569 570 // parseStackName parses the stack name into a potentially qualifiedStackReference. Any omitted 571 // portions will be left as "". For example: 572 // 573 // "alpha" - will just set the Name, but ignore Owner and Project. 574 // "alpha/beta" - will set the Owner and Name, but not Project. 575 // "alpha/beta/gamma" - will set Owner, Name, and Project. 576 func (b *cloudBackend) parseStackName(s string) (qualifiedStackReference, error) { 577 var q qualifiedStackReference 578 579 split := strings.Split(s, "/") 580 switch len(split) { 581 case 1: 582 q.Name = split[0] 583 case 2: 584 q.Owner = split[0] 585 q.Name = split[1] 586 case 3: 587 q.Owner = split[0] 588 q.Project = split[1] 589 q.Name = split[2] 590 default: 591 return qualifiedStackReference{}, fmt.Errorf("could not parse stack name '%s'", s) 592 } 593 594 return q, nil 595 } 596 597 func (b *cloudBackend) ParseStackReference(s string) (backend.StackReference, error) { 598 // Parse the input as a qualified stack name. 599 qualifiedName, err := b.parseStackName(s) 600 if err != nil { 601 return nil, err 602 } 603 604 // If the provided stack name didn't include the Owner or Project, infer them from the 605 // local environment. 606 if qualifiedName.Owner == "" { 607 // if the qualifiedName doesn't include an owner then let's check to see if there is a default org which *will* 608 // be the stack owner. If there is no defaultOrg, then we revert to checking the CurrentUser 609 defaultOrg, err := workspace.GetBackendConfigDefaultOrg() 610 if err != nil { 611 return nil, err 612 } 613 614 if defaultOrg != "" { 615 qualifiedName.Owner = defaultOrg 616 } else { 617 currentUser, _, userErr := b.CurrentUser() 618 if userErr != nil { 619 return nil, userErr 620 } 621 qualifiedName.Owner = currentUser 622 } 623 } 624 625 if qualifiedName.Project == "" { 626 currentProject, projectErr := workspace.DetectProject() 627 if projectErr != nil { 628 return nil, fmt.Errorf("If you're using the --stack flag, "+ 629 "pass the fully qualified name (org/project/stack): %w", projectErr) 630 } 631 632 qualifiedName.Project = currentProject.Name.String() 633 } 634 635 if !tokens.IsName(qualifiedName.Name) { 636 return nil, errors.New("stack names may only contain alphanumeric, hyphens, underscores, and periods") 637 } 638 639 return cloudBackendReference{ 640 owner: qualifiedName.Owner, 641 project: qualifiedName.Project, 642 name: tokens.Name(qualifiedName.Name), 643 b: b, 644 }, nil 645 } 646 647 func (b *cloudBackend) ValidateStackName(s string) error { 648 qualifiedName, err := b.parseStackName(s) 649 if err != nil { 650 return err 651 } 652 653 // The Pulumi Service enforces specific naming restrictions for organizations, 654 // projects, and stacks. Though ignore any values that need to be inferred later. 655 if qualifiedName.Owner != "" { 656 if err := validateOwnerName(qualifiedName.Owner); err != nil { 657 return err 658 } 659 } 660 661 if qualifiedName.Project != "" { 662 if err := validateProjectName(qualifiedName.Project); err != nil { 663 return err 664 } 665 } 666 667 return validateStackName(qualifiedName.Name) 668 } 669 670 // validateOwnerName checks if a stack owner name is valid. An "owner" is simply the namespace 671 // a stack may exist within, which for the Pulumi Service is the user account or organization. 672 func validateOwnerName(s string) error { 673 if !stackOwnerRegexp.MatchString(s) { 674 return errors.New("invalid stack owner") 675 } 676 return nil 677 } 678 679 // validateStackName checks if a stack name is valid, returning a user-suitable error if needed. 680 func validateStackName(s string) error { 681 if len(s) > 100 { 682 return errors.New("stack names must be less than 100 characters") 683 } 684 if !stackNameAndProjectRegexp.MatchString(s) { 685 return errors.New("stack names may only contain alphanumeric, hyphens, underscores, and periods") 686 } 687 return nil 688 } 689 690 // validateProjectName checks if a project name is valid, returning a user-suitable error if needed. 691 // 692 // NOTE: Be careful when requiring a project name be valid. The Pulumi.yaml file may contain 693 // an invalid project name like "r@bid^W0MBAT!!", but we try to err on the side of flexibility by 694 // implicitly "cleaning" the project name before we send it to the Pulumi Service. So when we go 695 // to make HTTP requests, we use a more palitable name like "r_bid_W0MBAT__". 696 // 697 // The projects canonical name will be the sanitized "r_bid_W0MBAT__" form, but we do not require the 698 // Pulumi.yaml file be updated. 699 // 700 // So we should only call validateProject name when creating _new_ stacks or creating _new_ projects. 701 // We should not require that project names be valid when reading what is in the current workspace. 702 func validateProjectName(s string) error { 703 if len(s) > 100 { 704 return errors.New("project names must be less than 100 characters") 705 } 706 if !stackNameAndProjectRegexp.MatchString(s) { 707 return errors.New("project names may only contain alphanumeric, hyphens, underscores, and periods") 708 } 709 return nil 710 } 711 712 // CloudConsoleURL returns a link to the cloud console with the given path elements. If a console link cannot be 713 // created, we return the empty string instead (this can happen if the endpoint isn't a recognized pattern). 714 func (b *cloudBackend) CloudConsoleURL(paths ...string) string { 715 return cloudConsoleURL(b.CloudURL(), paths...) 716 } 717 718 // serveBrowserLoginServer hosts the server that completes the browser based login flow. 719 func serveBrowserLoginServer(l net.Listener, expectedNonce string, destinationURL string, c chan<- string) { 720 handler := func(res http.ResponseWriter, req *http.Request) { 721 tok := req.URL.Query().Get("accessToken") 722 nonce := req.URL.Query().Get("nonce") 723 724 if tok == "" || nonce != expectedNonce { 725 res.WriteHeader(400) 726 return 727 } 728 729 http.Redirect(res, req, destinationURL, http.StatusTemporaryRedirect) 730 c <- tok 731 } 732 733 mux := &http.ServeMux{} 734 mux.HandleFunc("/", handler) 735 contract.IgnoreError(http.Serve(l, mux)) // nolint gosec 736 } 737 738 // CloudConsoleStackPath returns the stack path components for getting to a stack in the cloud console. This path 739 // must, of course, be combined with the actual console base URL by way of the CloudConsoleURL function above. 740 func (b *cloudBackend) cloudConsoleStackPath(stackID client.StackIdentifier) string { 741 return path.Join(stackID.Owner, stackID.Project, stackID.Stack) 742 } 743 744 // Logout logs out of the target cloud URL. 745 func (b *cloudBackend) Logout() error { 746 return workspace.DeleteAccount(b.CloudURL()) 747 } 748 749 // LogoutAll logs out of all accounts 750 func (b *cloudBackend) LogoutAll() error { 751 return workspace.DeleteAllAccounts() 752 } 753 754 // DoesProjectExist returns true if a project with the given name exists in this backend, or false otherwise. 755 func (b *cloudBackend) DoesProjectExist(ctx context.Context, projectName string) (bool, error) { 756 owner, _, err := b.currentUser(ctx) 757 if err != nil { 758 return false, err 759 } 760 761 return b.client.DoesProjectExist(ctx, owner, projectName) 762 } 763 764 func (b *cloudBackend) GetStack(ctx context.Context, stackRef backend.StackReference) (backend.Stack, error) { 765 stackID, err := b.getCloudStackIdentifier(stackRef) 766 if err != nil { 767 return nil, err 768 } 769 770 stack, err := b.client.GetStack(ctx, stackID) 771 if err != nil { 772 // If this was a 404, return nil, nil as per this method's contract. 773 if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == http.StatusNotFound { 774 return nil, nil 775 } 776 return nil, err 777 } 778 779 return newStack(stack, b), nil 780 } 781 782 // Confirm the specified stack's project doesn't contradict the Pulumi.yaml of the current project. 783 // if the CWD is not in a Pulumi project, 784 // 785 // does not contradict 786 // 787 // if the project name in Pulumi.yaml is "foo". 788 // 789 // a stack with a name of foo/bar/foo should not work. 790 func currentProjectContradictsWorkspace(stack client.StackIdentifier) bool { 791 projPath, err := workspace.DetectProjectPath() 792 if err != nil { 793 return false 794 } 795 796 if projPath == "" { 797 return false 798 } 799 800 proj, err := workspace.LoadProject(projPath) 801 if err != nil { 802 return false 803 } 804 805 return proj.Name.String() != stack.Project 806 } 807 808 func (b *cloudBackend) CreateStack( 809 ctx context.Context, stackRef backend.StackReference, _ interface{} /* No custom options for httpstate backend. */) ( 810 backend.Stack, error) { 811 stackID, err := b.getCloudStackIdentifier(stackRef) 812 if err != nil { 813 return nil, err 814 } 815 816 if currentProjectContradictsWorkspace(stackID) { 817 return nil, fmt.Errorf("provided project name %q doesn't match Pulumi.yaml", stackID.Project) 818 } 819 820 tags, err := backend.GetEnvironmentTagsForCurrentStack() 821 if err != nil { 822 return nil, fmt.Errorf("error determining initial tags: %w", err) 823 } 824 825 apistack, err := b.client.CreateStack(ctx, stackID, tags) 826 if err != nil { 827 // Wire through well-known error types. 828 if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == http.StatusConflict { 829 // A 409 error response is returned when per-stack organizations are over their limit, 830 // so we need to look at the message to differentiate. 831 if strings.Contains(errResp.Message, "already exists") { 832 return nil, &backend.StackAlreadyExistsError{StackName: stackID.String()} 833 } 834 if strings.Contains(errResp.Message, "you are using") { 835 return nil, &backend.OverStackLimitError{Message: errResp.Message} 836 } 837 } 838 return nil, err 839 } 840 841 stack := newStack(apistack, b) 842 fmt.Printf("Created stack '%s'\n", stack.Ref()) 843 844 return stack, nil 845 } 846 847 func (b *cloudBackend) ListStacks( 848 ctx context.Context, filter backend.ListStacksFilter, inContToken backend.ContinuationToken) ( 849 []backend.StackSummary, backend.ContinuationToken, error) { 850 // Sanitize the project name as needed, so when communicating with the Pulumi Service we 851 // always use the name the service expects. (So that a similar, but not technically valid 852 // name may be put in Pulumi.yaml without causing problems.) 853 if filter.Project != nil { 854 cleanedProj := cleanProjectName(*filter.Project) 855 filter.Project = &cleanedProj 856 } 857 858 // Duplicate type to avoid circular dependency. 859 clientFilter := client.ListStacksFilter{ 860 Organization: filter.Organization, 861 Project: filter.Project, 862 TagName: filter.TagName, 863 TagValue: filter.TagValue, 864 } 865 866 apiSummaries, outContToken, err := b.client.ListStacks(ctx, clientFilter, inContToken) 867 if err != nil { 868 return nil, nil, err 869 } 870 871 // Convert []apitype.StackSummary into []backend.StackSummary. 872 var backendSummaries []backend.StackSummary 873 for _, apiSummary := range apiSummaries { 874 backendSummary := cloudStackSummary{ 875 summary: apiSummary, 876 b: b, 877 } 878 backendSummaries = append(backendSummaries, backendSummary) 879 } 880 881 return backendSummaries, outContToken, nil 882 } 883 884 func (b *cloudBackend) RemoveStack(ctx context.Context, stack backend.Stack, force bool) (bool, error) { 885 stackID, err := b.getCloudStackIdentifier(stack.Ref()) 886 if err != nil { 887 return false, err 888 } 889 890 return b.client.DeleteStack(ctx, stackID, force) 891 } 892 893 func (b *cloudBackend) RenameStack(ctx context.Context, stack backend.Stack, 894 newName tokens.QName) (backend.StackReference, error) { 895 stackID, err := b.getCloudStackIdentifier(stack.Ref()) 896 if err != nil { 897 return nil, err 898 } 899 900 // Support a qualified stack name, which would also rename the stack's project too. 901 // e.g. if you want to change the project name on the Pulumi Console to reflect a 902 // new value in Pulumi.yaml. 903 newRef, err := b.ParseStackReference(string(newName)) 904 if err != nil { 905 return nil, err 906 } 907 newIdentity, err := b.getCloudStackIdentifier(newRef) 908 if err != nil { 909 return nil, err 910 } 911 912 if stackID.Owner != newIdentity.Owner { 913 errMsg := fmt.Sprintf( 914 "New stack owner, %s, does not match existing owner, %s.\n\n", 915 stackID.Owner, newIdentity.Owner) 916 917 // Re-parse the name using the parseStackName function to avoid the logic in ParseStackReference 918 // that auto-populates the owner property with the currently logged in account. We actually want to 919 // give a different error message if the raw stack name itself didn't include an owner part. 920 parsedName, err := b.parseStackName(string(newName)) 921 contract.IgnoreError(err) 922 if parsedName.Owner == "" { 923 errMsg += fmt.Sprintf( 924 " Did you forget to include the owner name? If yes, rerun the command as follows:\n\n"+ 925 " $ pulumi stack rename %s/%s\n\n", 926 stackID.Owner, newName) 927 } 928 929 errMsgSuffix := "." 930 if consoleURL, err := b.StackConsoleURL(stack.Ref()); err == nil { 931 errMsgSuffix = ":\n\n " + consoleURL + "/settings/options" 932 } 933 errMsg += " You cannot transfer stack ownership via a rename. If you wish to transfer ownership\n" + 934 " of a stack to another organization, you can do so in the Pulumi Console by going to the\n" + 935 " \"Settings\" page of the stack and then clicking the \"Transfer Stack\" button" 936 937 return nil, errors.New(errMsg + errMsgSuffix) 938 } 939 940 if err = b.client.RenameStack(ctx, stackID, newIdentity); err != nil { 941 return nil, err 942 } 943 return newRef, nil 944 } 945 946 func (b *cloudBackend) Preview(ctx context.Context, stack backend.Stack, 947 op backend.UpdateOperation) (*deploy.Plan, sdkDisplay.ResourceChanges, result.Result) { 948 // We can skip PreviewtThenPromptThenExecute, and just go straight to Execute. 949 opts := backend.ApplierOptions{ 950 DryRun: true, 951 ShowLink: true, 952 } 953 return b.apply( 954 ctx, apitype.PreviewUpdate, stack, op, opts, nil /*events*/) 955 } 956 957 func (b *cloudBackend) Update(ctx context.Context, stack backend.Stack, 958 op backend.UpdateOperation) (sdkDisplay.ResourceChanges, result.Result) { 959 return backend.PreviewThenPromptThenExecute(ctx, apitype.UpdateUpdate, stack, op, b.apply) 960 } 961 962 func (b *cloudBackend) Import(ctx context.Context, stack backend.Stack, 963 op backend.UpdateOperation, imports []deploy.Import) (sdkDisplay.ResourceChanges, result.Result) { 964 op.Imports = imports 965 return backend.PreviewThenPromptThenExecute(ctx, apitype.ResourceImportUpdate, stack, op, b.apply) 966 } 967 968 func (b *cloudBackend) Refresh(ctx context.Context, stack backend.Stack, 969 op backend.UpdateOperation) (sdkDisplay.ResourceChanges, result.Result) { 970 return backend.PreviewThenPromptThenExecute(ctx, apitype.RefreshUpdate, stack, op, b.apply) 971 } 972 973 func (b *cloudBackend) Destroy(ctx context.Context, stack backend.Stack, 974 op backend.UpdateOperation) (sdkDisplay.ResourceChanges, result.Result) { 975 return backend.PreviewThenPromptThenExecute(ctx, apitype.DestroyUpdate, stack, op, b.apply) 976 } 977 978 func (b *cloudBackend) Watch(ctx context.Context, stack backend.Stack, 979 op backend.UpdateOperation, paths []string) result.Result { 980 return backend.Watch(ctx, b, stack, op, b.apply, paths) 981 } 982 983 func (b *cloudBackend) Query(ctx context.Context, op backend.QueryOperation) result.Result { 984 return b.query(ctx, op, nil /*events*/) 985 } 986 987 func (b *cloudBackend) createAndStartUpdate( 988 ctx context.Context, action apitype.UpdateKind, stack backend.Stack, 989 op *backend.UpdateOperation, dryRun bool) (client.UpdateIdentifier, int, string, error) { 990 991 stackRef := stack.Ref() 992 993 stackID, err := b.getCloudStackIdentifier(stackRef) 994 if err != nil { 995 return client.UpdateIdentifier{}, 0, "", err 996 } 997 if currentProjectContradictsWorkspace(stackID) { 998 return client.UpdateIdentifier{}, 0, "", fmt.Errorf( 999 "provided project name %q doesn't match Pulumi.yaml", stackID.Project) 1000 } 1001 metadata := apitype.UpdateMetadata{ 1002 Message: op.M.Message, 1003 Environment: op.M.Environment, 1004 } 1005 update, reqdPolicies, err := b.client.CreateUpdate( 1006 ctx, action, stackID, op.Proj, op.StackConfiguration.Config, metadata, op.Opts.Engine, dryRun) 1007 if err != nil { 1008 return client.UpdateIdentifier{}, 0, "", err 1009 } 1010 1011 // 1012 // TODO[pulumi-service#3745]: Move this to the plugin-gathering routine when we have a dedicated 1013 // service API when for getting a list of the required policies to run. 1014 // 1015 // For now, this list is given to us when we start an update; yet, the list of analyzers to boot 1016 // is given to us by CLI flag, and passed to the step generator (which lazily instantiates the 1017 // plugins) via `op.Opts.Engine.Analyzers`. Since the "start update" API request is sent well 1018 // after this field is populated, we instead populate the `RequiredPlugins` field here. 1019 // 1020 // Once this API is implemented, we can safely move these lines to the plugin-gathering code, 1021 // which is much closer to being the "correct" place for this stuff. 1022 // 1023 for _, policy := range reqdPolicies { 1024 op.Opts.Engine.RequiredPolicies = append( 1025 op.Opts.Engine.RequiredPolicies, newCloudRequiredPolicy(b.client, policy, update.Owner)) 1026 } 1027 1028 // Start the update. We use this opportunity to pass new tags to the service, to pick up any 1029 // metadata changes. 1030 tags, err := backend.GetMergedStackTags(ctx, stack) 1031 if err != nil { 1032 return client.UpdateIdentifier{}, 0, "", fmt.Errorf("getting stack tags: %w", err) 1033 } 1034 version, token, err := b.client.StartUpdate(ctx, update, tags) 1035 if err != nil { 1036 if err, ok := err.(*apitype.ErrorResponse); ok && err.Code == 409 { 1037 conflict := backend.ConflictingUpdateError{Err: err} 1038 return client.UpdateIdentifier{}, 0, "", conflict 1039 } 1040 return client.UpdateIdentifier{}, 0, "", err 1041 } 1042 // Any non-preview update will be considered part of the stack's update history. 1043 if action != apitype.PreviewUpdate { 1044 logging.V(7).Infof("Stack %s being updated to version %d", stackRef, version) 1045 } 1046 1047 return update, version, token, nil 1048 } 1049 1050 // apply actually performs the provided type of update on a stack hosted in the Pulumi Cloud. 1051 func (b *cloudBackend) apply( 1052 ctx context.Context, kind apitype.UpdateKind, stack backend.Stack, 1053 op backend.UpdateOperation, opts backend.ApplierOptions, 1054 events chan<- engine.Event) (*deploy.Plan, sdkDisplay.ResourceChanges, result.Result) { 1055 1056 actionLabel := backend.ActionLabel(kind, opts.DryRun) 1057 1058 if !(op.Opts.Display.JSONDisplay || op.Opts.Display.Type == display.DisplayWatch) { 1059 // Print a banner so it's clear this is going to the cloud. 1060 fmt.Printf(op.Opts.Display.Color.Colorize( 1061 colors.SpecHeadline+"%s (%s)"+colors.Reset+"\n\n"), actionLabel, stack.Ref()) 1062 } 1063 1064 // Create an update object to persist results. 1065 update, version, token, err := 1066 b.createAndStartUpdate(ctx, kind, stack, &op, opts.DryRun) 1067 if err != nil { 1068 return nil, nil, result.FromError(err) 1069 } 1070 1071 if !op.Opts.Display.SuppressPermalink && opts.ShowLink && !op.Opts.Display.JSONDisplay { 1072 // Print a URL at the beginning of the update pointing to the Pulumi Service. 1073 b.printLink(op, opts, update, version) 1074 } 1075 1076 return b.runEngineAction(ctx, kind, stack.Ref(), op, update, token, events, opts.DryRun) 1077 } 1078 1079 // printLink prints a link to the update in the Pulumi Service. 1080 func (b *cloudBackend) printLink( 1081 op backend.UpdateOperation, opts backend.ApplierOptions, 1082 update client.UpdateIdentifier, version int) { 1083 var link string 1084 base := b.cloudConsoleStackPath(update.StackIdentifier) 1085 if !opts.DryRun { 1086 link = b.CloudConsoleURL(base, "updates", strconv.Itoa(version)) 1087 } else { 1088 link = b.CloudConsoleURL(base, "previews", update.UpdateID) 1089 } 1090 if link != "" { 1091 fmt.Printf(op.Opts.Display.Color.Colorize( 1092 colors.SpecHeadline+"View Live: "+ 1093 colors.Underline+colors.BrightBlue+"%s"+colors.Reset+"\n\n"), link) 1094 } 1095 } 1096 1097 // query executes a query program against the resource outputs of a stack hosted in the Pulumi 1098 // Cloud. 1099 func (b *cloudBackend) query(ctx context.Context, op backend.QueryOperation, 1100 callerEventsOpt chan<- engine.Event) result.Result { 1101 1102 return backend.RunQuery(ctx, b, op, callerEventsOpt, b.newQuery) 1103 } 1104 1105 func (b *cloudBackend) runEngineAction( 1106 ctx context.Context, kind apitype.UpdateKind, stackRef backend.StackReference, 1107 op backend.UpdateOperation, update client.UpdateIdentifier, token string, 1108 callerEventsOpt chan<- engine.Event, dryRun bool) (*deploy.Plan, sdkDisplay.ResourceChanges, result.Result) { 1109 1110 contract.Assertf(token != "", "persisted actions require a token") 1111 u, err := b.newUpdate(ctx, stackRef, op, update, token) 1112 if err != nil { 1113 return nil, nil, result.FromError(err) 1114 } 1115 1116 // displayEvents renders the event to the console and Pulumi service. The processor for the 1117 // will signal all events have been proceed when a value is written to the displayDone channel. 1118 displayEvents := make(chan engine.Event) 1119 displayDone := make(chan bool) 1120 go u.RecordAndDisplayEvents( 1121 backend.ActionLabel(kind, dryRun), kind, stackRef, op, 1122 displayEvents, displayDone, op.Opts.Display, dryRun) 1123 1124 // The engineEvents channel receives all events from the engine, which we then forward onto other 1125 // channels for actual processing. (displayEvents and callerEventsOpt.) 1126 engineEvents := make(chan engine.Event) 1127 eventsDone := make(chan bool) 1128 go func() { 1129 for e := range engineEvents { 1130 displayEvents <- e 1131 if callerEventsOpt != nil { 1132 callerEventsOpt <- e 1133 } 1134 } 1135 1136 close(eventsDone) 1137 }() 1138 1139 // The backend.SnapshotManager and backend.SnapshotPersister will keep track of any changes to 1140 // the Snapshot (checkpoint file) in the HTTP backend. We will reuse the snapshot's secrets manager when possible 1141 // to ensure that secrets are not re-encrypted on each update. 1142 sm := op.SecretsManager 1143 if secrets.AreCompatible(sm, u.GetTarget().Snapshot.SecretsManager) { 1144 sm = u.GetTarget().Snapshot.SecretsManager 1145 } 1146 persister := b.newSnapshotPersister(ctx, u.update, u.tokenSource, sm) 1147 snapshotManager := backend.NewSnapshotManager(persister, u.GetTarget().Snapshot) 1148 1149 // Depending on the action, kick off the relevant engine activity. Note that we don't immediately check and 1150 // return error conditions, because we will do so below after waiting for the display channels to close. 1151 cancellationScope := op.Scopes.NewScope(engineEvents, dryRun) 1152 engineCtx := &engine.Context{ 1153 Cancel: cancellationScope.Context(), 1154 Events: engineEvents, 1155 SnapshotManager: snapshotManager, 1156 BackendClient: httpstateBackendClient{backend: b}, 1157 } 1158 if parentSpan := opentracing.SpanFromContext(ctx); parentSpan != nil { 1159 engineCtx.ParentSpan = parentSpan.Context() 1160 } 1161 1162 var plan *deploy.Plan 1163 var changes sdkDisplay.ResourceChanges 1164 var res result.Result 1165 switch kind { 1166 case apitype.PreviewUpdate: 1167 plan, changes, res = engine.Update(u, engineCtx, op.Opts.Engine, true) 1168 case apitype.UpdateUpdate: 1169 _, changes, res = engine.Update(u, engineCtx, op.Opts.Engine, dryRun) 1170 case apitype.ResourceImportUpdate: 1171 _, changes, res = engine.Import(u, engineCtx, op.Opts.Engine, op.Imports, dryRun) 1172 case apitype.RefreshUpdate: 1173 _, changes, res = engine.Refresh(u, engineCtx, op.Opts.Engine, dryRun) 1174 case apitype.DestroyUpdate: 1175 _, changes, res = engine.Destroy(u, engineCtx, op.Opts.Engine, dryRun) 1176 default: 1177 contract.Failf("Unrecognized update kind: %s", kind) 1178 } 1179 1180 // Wait for dependent channels to finish processing engineEvents before closing. 1181 <-displayDone 1182 cancellationScope.Close() // Don't take any cancellations anymore, we're shutting down. 1183 close(engineEvents) 1184 contract.IgnoreClose(snapshotManager) 1185 1186 // Make sure that the goroutine writing to displayEvents and callerEventsOpt 1187 // has exited before proceeding 1188 <-eventsDone 1189 close(displayEvents) 1190 1191 // Mark the update as complete. 1192 status := apitype.UpdateStatusSucceeded 1193 if res != nil { 1194 status = apitype.UpdateStatusFailed 1195 } 1196 completeErr := u.Complete(status) 1197 if completeErr != nil { 1198 res = result.Merge(res, result.FromError(fmt.Errorf("failed to complete update: %w", completeErr))) 1199 } 1200 1201 return plan, changes, res 1202 } 1203 1204 func (b *cloudBackend) CancelCurrentUpdate(ctx context.Context, stackRef backend.StackReference) error { 1205 stackID, err := b.getCloudStackIdentifier(stackRef) 1206 if err != nil { 1207 return err 1208 } 1209 stack, err := b.client.GetStack(ctx, stackID) 1210 if err != nil { 1211 return err 1212 } 1213 1214 if stack.ActiveUpdate == "" { 1215 return fmt.Errorf("stack %v has never been updated", stackRef) 1216 } 1217 1218 // Compute the update identifier and attempt to cancel the update. 1219 // 1220 // NOTE: the update kind is not relevant; the same endpoint will work for updates of all kinds. 1221 updateID := client.UpdateIdentifier{ 1222 StackIdentifier: stackID, 1223 UpdateKind: apitype.UpdateUpdate, 1224 UpdateID: stack.ActiveUpdate, 1225 } 1226 return b.client.CancelUpdate(ctx, updateID) 1227 } 1228 1229 func (b *cloudBackend) GetHistory( 1230 ctx context.Context, 1231 stackRef backend.StackReference, 1232 pageSize int, 1233 page int) ([]backend.UpdateInfo, error) { 1234 stack, err := b.getCloudStackIdentifier(stackRef) 1235 if err != nil { 1236 return nil, err 1237 } 1238 1239 updates, err := b.client.GetStackUpdates(ctx, stack, pageSize, page) 1240 if err != nil { 1241 return nil, fmt.Errorf("failed to get stack updates: %w", err) 1242 } 1243 1244 // Convert apitype.UpdateInfo objects to the backend type. 1245 var beUpdates []backend.UpdateInfo 1246 for _, update := range updates { 1247 // Convert types from the apitype package into their internal counterparts. 1248 cfg, err := convertConfig(update.Config) 1249 if err != nil { 1250 return nil, fmt.Errorf("converting configuration: %w", err) 1251 } 1252 1253 beUpdates = append(beUpdates, backend.UpdateInfo{ 1254 Version: update.Version, 1255 Kind: update.Kind, 1256 Message: update.Message, 1257 Environment: update.Environment, 1258 Config: cfg, 1259 Result: backend.UpdateResult(update.Result), 1260 StartTime: update.StartTime, 1261 EndTime: update.EndTime, 1262 ResourceChanges: convertResourceChanges(update.ResourceChanges), 1263 }) 1264 } 1265 1266 return beUpdates, nil 1267 } 1268 1269 func (b *cloudBackend) GetLatestConfiguration(ctx context.Context, 1270 stack backend.Stack) (config.Map, error) { 1271 1272 stackID, err := b.getCloudStackIdentifier(stack.Ref()) 1273 if err != nil { 1274 return nil, err 1275 } 1276 1277 cfg, err := b.client.GetLatestConfiguration(ctx, stackID) 1278 switch { 1279 case err == client.ErrNoPreviousDeployment: 1280 return nil, backend.ErrNoPreviousDeployment 1281 case err != nil: 1282 return nil, err 1283 default: 1284 return cfg, nil 1285 } 1286 } 1287 1288 // convertResourceChanges converts the apitype version of sdkDisplay.ResourceChanges into the internal version. 1289 func convertResourceChanges(changes map[apitype.OpType]int) sdkDisplay.ResourceChanges { 1290 b := make(sdkDisplay.ResourceChanges) 1291 for k, v := range changes { 1292 b[sdkDisplay.StepOp(k)] = v 1293 } 1294 return b 1295 } 1296 1297 // convertResourceChanges converts the apitype version of config.Map into the internal version. 1298 func convertConfig(apiConfig map[string]apitype.ConfigValue) (config.Map, error) { 1299 c := make(config.Map) 1300 for rawK, rawV := range apiConfig { 1301 k, err := config.ParseKey(rawK) 1302 if err != nil { 1303 return nil, err 1304 } 1305 if rawV.Object { 1306 if rawV.Secret { 1307 c[k] = config.NewSecureObjectValue(rawV.String) 1308 } else { 1309 c[k] = config.NewObjectValue(rawV.String) 1310 } 1311 } else { 1312 if rawV.Secret { 1313 c[k] = config.NewSecureValue(rawV.String) 1314 } else { 1315 c[k] = config.NewValue(rawV.String) 1316 } 1317 } 1318 } 1319 return c, nil 1320 } 1321 1322 func (b *cloudBackend) GetLogs(ctx context.Context, stack backend.Stack, cfg backend.StackConfiguration, 1323 logQuery operations.LogQuery) ([]operations.LogEntry, error) { 1324 1325 target, targetErr := b.getTarget(ctx, stack.Ref(), cfg.Config, cfg.Decrypter) 1326 if targetErr != nil { 1327 return nil, targetErr 1328 } 1329 return filestate.GetLogsForTarget(target, logQuery) 1330 } 1331 1332 // ExportDeployment exports a deployment _from_ the backend service. 1333 // This will return the stack state that was being stored on the backend service. 1334 func (b *cloudBackend) ExportDeployment(ctx context.Context, 1335 stack backend.Stack) (*apitype.UntypedDeployment, error) { 1336 return b.exportDeployment(ctx, stack.Ref(), nil /* latest */) 1337 } 1338 1339 func (b *cloudBackend) ExportDeploymentForVersion( 1340 ctx context.Context, stack backend.Stack, version string) (*apitype.UntypedDeployment, error) { 1341 // The Pulumi Console defines versions as a positive integer. Parse the provided version string and 1342 // ensure it is valid. 1343 // 1344 // The first stack update version is 1, and monotonically increasing from there. 1345 versionNumber, err := strconv.Atoi(version) 1346 if err != nil || versionNumber <= 0 { 1347 return nil, fmt.Errorf( 1348 "%q is not a valid stack version. It should be a positive integer", 1349 version) 1350 } 1351 1352 return b.exportDeployment(ctx, stack.Ref(), &versionNumber) 1353 } 1354 1355 // exportDeployment exports the checkpoint file for a stack, optionally getting a previous version. 1356 func (b *cloudBackend) exportDeployment( 1357 ctx context.Context, stackRef backend.StackReference, version *int) (*apitype.UntypedDeployment, error) { 1358 stack, err := b.getCloudStackIdentifier(stackRef) 1359 if err != nil { 1360 return nil, err 1361 } 1362 1363 deployment, err := b.client.ExportStackDeployment(ctx, stack, version) 1364 if err != nil { 1365 return nil, err 1366 } 1367 1368 return &deployment, nil 1369 } 1370 1371 // ImportDeployment imports a deployment _into_ the backend. At the end of this operation, 1372 // the deployment provided will be the current state stored on the backend service. 1373 func (b *cloudBackend) ImportDeployment(ctx context.Context, stack backend.Stack, 1374 deployment *apitype.UntypedDeployment) error { 1375 1376 stackID, err := b.getCloudStackIdentifier(stack.Ref()) 1377 if err != nil { 1378 return err 1379 } 1380 1381 update, err := b.client.ImportStackDeployment(ctx, stackID, deployment) 1382 if err != nil { 1383 return err 1384 } 1385 1386 // Wait for the import to complete, which also polls and renders event output to STDOUT. 1387 status, err := b.waitForUpdate( 1388 ctx, backend.ActionLabel(apitype.StackImportUpdate, false /*dryRun*/), update, 1389 display.Options{Color: colors.Always}) 1390 if err != nil { 1391 return fmt.Errorf("waiting for import: %w", err) 1392 } else if status != apitype.StatusSucceeded { 1393 return fmt.Errorf("import unsuccessful: status %v", status) 1394 } 1395 return nil 1396 } 1397 1398 var ( 1399 projectNameCleanRegexp = regexp.MustCompile("[^a-zA-Z0-9-_.]") 1400 ) 1401 1402 // cleanProjectName replaces undesirable characters in project names with hyphens. At some point, these restrictions 1403 // will be further enforced by the service, but for now we need to ensure that if we are making a rest call, we 1404 // do this cleaning on our end. 1405 func cleanProjectName(projectName string) string { 1406 return projectNameCleanRegexp.ReplaceAllString(projectName, "-") 1407 } 1408 1409 // getCloudStackIdentifier converts a backend.StackReference to a client.StackIdentifier for the same logical stack 1410 func (b *cloudBackend) getCloudStackIdentifier(stackRef backend.StackReference) (client.StackIdentifier, error) { 1411 cloudBackendStackRef, ok := stackRef.(cloudBackendReference) 1412 if !ok { 1413 return client.StackIdentifier{}, errors.New("bad stack reference type") 1414 } 1415 1416 return client.StackIdentifier{ 1417 Owner: cloudBackendStackRef.owner, 1418 Project: cleanProjectName(cloudBackendStackRef.project), 1419 Stack: string(cloudBackendStackRef.name), 1420 }, nil 1421 } 1422 1423 // Client returns a client object that may be used to interact with this backend. 1424 func (b *cloudBackend) Client() *client.Client { 1425 return b.client 1426 } 1427 1428 type DisplayEventType string 1429 1430 const ( 1431 UpdateEvent DisplayEventType = "UpdateEvent" 1432 ShutdownEvent DisplayEventType = "Shutdown" 1433 ) 1434 1435 type displayEvent struct { 1436 Kind DisplayEventType 1437 Payload interface{} 1438 } 1439 1440 // waitForUpdate waits for the current update of a Pulumi program to reach a terminal state. Returns the 1441 // final state. "path" is the URL endpoint to poll for updates. 1442 func (b *cloudBackend) waitForUpdate(ctx context.Context, actionLabel string, update client.UpdateIdentifier, 1443 displayOpts display.Options) (apitype.UpdateStatus, error) { 1444 1445 events, done := make(chan displayEvent), make(chan bool) 1446 defer func() { 1447 events <- displayEvent{Kind: ShutdownEvent, Payload: nil} 1448 <-done 1449 close(events) 1450 close(done) 1451 }() 1452 go displayEvents(strings.ToLower(actionLabel), events, done, displayOpts) 1453 1454 // The UpdateEvents API returns a continuation token to only get events after the previous call. 1455 var continuationToken *string 1456 for { 1457 // Query for the latest update results, including log entries so we can provide active status updates. 1458 _, results, err := retry.Until(context.Background(), retry.Acceptor{ 1459 Accept: func(try int, nextRetryTime time.Duration) (bool, interface{}, error) { 1460 return b.tryNextUpdate(ctx, update, continuationToken, try, nextRetryTime) 1461 }, 1462 }) 1463 if err != nil { 1464 return apitype.StatusFailed, err 1465 } 1466 1467 // We got a result, print it out. 1468 updateResults := results.(apitype.UpdateResults) 1469 for _, event := range updateResults.Events { 1470 events <- displayEvent{Kind: UpdateEvent, Payload: event} 1471 } 1472 1473 continuationToken = updateResults.ContinuationToken 1474 // A nil continuation token means there are no more events to read and the update has finished. 1475 if continuationToken == nil { 1476 return updateResults.Status, nil 1477 } 1478 } 1479 } 1480 1481 func displayEvents(action string, events <-chan displayEvent, done chan<- bool, opts display.Options) { 1482 prefix := fmt.Sprintf("%s%s...", cmdutil.EmojiOr("✨ ", "@ "), action) 1483 spinner, ticker := cmdutil.NewSpinnerAndTicker(prefix, nil, opts.Color, 8 /*timesPerSecond*/) 1484 1485 defer func() { 1486 spinner.Reset() 1487 ticker.Stop() 1488 done <- true 1489 }() 1490 1491 for { 1492 select { 1493 case <-ticker.C: 1494 spinner.Tick() 1495 case event := <-events: 1496 if event.Kind == ShutdownEvent { 1497 return 1498 } 1499 1500 // Pluck out the string. 1501 payload := event.Payload.(apitype.UpdateEvent) 1502 if raw, ok := payload.Fields["text"]; ok && raw != nil { 1503 if text, ok := raw.(string); ok { 1504 text = opts.Color.Colorize(text) 1505 1506 // Choose the stream to write to (by default stdout). 1507 var stream io.Writer 1508 if payload.Kind == apitype.StderrEvent { 1509 stream = os.Stderr 1510 } else { 1511 stream = os.Stdout 1512 } 1513 1514 if text != "" { 1515 spinner.Reset() 1516 fmt.Fprint(stream, text) 1517 } 1518 } 1519 } 1520 } 1521 } 1522 } 1523 1524 // tryNextUpdate tries to get the next update for a Pulumi program. This may time or error out, which results in a 1525 // false returned in the first return value. If a non-nil error is returned, this operation should fail. 1526 func (b *cloudBackend) tryNextUpdate(ctx context.Context, update client.UpdateIdentifier, continuationToken *string, 1527 try int, nextRetryTime time.Duration) (bool, interface{}, error) { 1528 1529 // If there is no error, we're done. 1530 results, err := b.client.GetUpdateEvents(ctx, update, continuationToken) 1531 if err == nil { 1532 return true, results, nil 1533 } 1534 1535 // There are three kinds of errors we might see: 1536 // 1) Expected HTTP errors (like timeouts); silently retry. 1537 // 2) Unexpected HTTP errors (like Unauthorized, etc); exit with an error. 1538 // 3) Anything else; this could be any number of things, including transient errors (flaky network). 1539 // In this case, we warn the user and keep retrying; they can ^C if it's not transient. 1540 warn := true 1541 if errResp, ok := err.(*apitype.ErrorResponse); ok { 1542 if errResp.Code == 504 { 1543 // If our request to the Pulumi Service returned a 504 (Gateway Timeout), ignore it and keep 1544 // continuing. The sole exception is if we've done this 10 times. At that point, we will have 1545 // been waiting for many seconds, and want to let the user know something might be wrong. 1546 if try < 10 { 1547 warn = false 1548 } 1549 logging.V(3).Infof("Expected %s HTTP %d error after %d retries (retrying): %v", 1550 b.CloudURL(), errResp.Code, try, err) 1551 } else { 1552 // Otherwise, we will issue an error. 1553 logging.V(3).Infof("Unexpected %s HTTP %d error after %d retries (erroring): %v", 1554 b.CloudURL(), errResp.Code, try, err) 1555 return false, nil, err 1556 } 1557 } else { 1558 logging.V(3).Infof("Unexpected %s error after %d retries (retrying): %v", b.CloudURL(), try, err) 1559 } 1560 1561 // Issue a warning if appropriate. 1562 if warn { 1563 b.d.Warningf(diag.Message("" /*urn*/, "error querying update status: %v"), err) 1564 b.d.Warningf(diag.Message("" /*urn*/, "retrying in %vs... ^C to stop (this will not cancel the update)"), 1565 nextRetryTime.Seconds()) 1566 } 1567 1568 return false, nil, nil 1569 } 1570 1571 // IsValidAccessToken tries to use the provided Pulumi access token and returns if it is accepted 1572 // or not. Returns error on any unexpected error. 1573 func IsValidAccessToken(ctx context.Context, cloudURL, accessToken string) (bool, string, []string, error) { 1574 // Make a request to get the authenticated user. If it returns a successful response, 1575 // we know the access token is legit. We also parse the response as JSON and confirm 1576 // it has a githubLogin field that is non-empty (like the Pulumi Service would return). 1577 username, organizations, err := client.NewClient(cloudURL, accessToken, cmdutil.Diag()).GetPulumiAccountDetails(ctx) 1578 if err != nil { 1579 if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == 401 { 1580 return false, "", nil, nil 1581 } 1582 return false, "", nil, fmt.Errorf("getting user info from %v: %w", cloudURL, err) 1583 } 1584 1585 return true, username, organizations, nil 1586 } 1587 1588 // UpdateStackTags updates the stacks's tags, replacing all existing tags. 1589 func (b *cloudBackend) UpdateStackTags(ctx context.Context, 1590 stack backend.Stack, tags map[apitype.StackTagName]string) error { 1591 1592 stackID, err := b.getCloudStackIdentifier(stack.Ref()) 1593 if err != nil { 1594 return err 1595 } 1596 1597 return b.client.UpdateStackTags(ctx, stackID, tags) 1598 } 1599 1600 const pulumiOperationHeader = "Pulumi operation" 1601 1602 func (b *cloudBackend) RunDeployment(ctx context.Context, stackRef backend.StackReference, 1603 req apitype.CreateDeploymentRequest, opts display.Options) error { 1604 1605 stackID, err := b.getCloudStackIdentifier(stackRef) 1606 if err != nil { 1607 return err 1608 } 1609 1610 resp, err := b.client.CreateDeployment(ctx, stackID, req) 1611 if err != nil { 1612 return err 1613 } 1614 id := resp.ID 1615 1616 fmt.Printf(opts.Color.Colorize(colors.SpecHeadline + "Preparing deployment..." + colors.Reset + "\n\n")) 1617 1618 if !opts.SuppressPermalink && !opts.JSONDisplay && resp.ConsoleURL != "" { 1619 fmt.Printf(opts.Color.Colorize( 1620 colors.SpecHeadline+"View Live: "+ 1621 colors.Underline+colors.BrightBlue+"%s"+colors.Reset+"\n"), resp.ConsoleURL) 1622 } 1623 1624 token := "" 1625 for { 1626 logs, err := b.client.GetDeploymentLogs(ctx, stackID, id, token) 1627 if err != nil { 1628 return err 1629 } 1630 1631 for _, l := range logs.Lines { 1632 if l.Header != "" { 1633 fmt.Printf(opts.Color.Colorize( 1634 "\n" + colors.SpecHeadline + l.Header + ":" + colors.Reset + "\n")) 1635 1636 // If we see it's a Pulumi operation, rather than outputting the deployment logs, 1637 // find the associated update and show the normal rendering of the operation's events. 1638 if l.Header == pulumiOperationHeader { 1639 fmt.Println() 1640 return b.showDeploymentEvents(ctx, stackID, apitype.UpdateKind(req.Operation.Operation), id, opts) 1641 } 1642 } else { 1643 fmt.Print(l.Line) 1644 } 1645 } 1646 1647 // If there are no more logs for the deployment and the deployment has finished or we're not following, 1648 // then we're done. 1649 if logs.NextToken == "" { 1650 break 1651 } 1652 1653 // Otherwise, update the token, sleep, and loop around. 1654 if logs.NextToken == token { 1655 time.Sleep(500 * time.Millisecond) 1656 } 1657 token = logs.NextToken 1658 } 1659 1660 return nil 1661 } 1662 1663 func (b *cloudBackend) showDeploymentEvents(ctx context.Context, stackID client.StackIdentifier, 1664 kind apitype.UpdateKind, deploymentID string, opts display.Options) error { 1665 1666 getUpdateID := func() (string, error) { 1667 for tries := 0; tries < 10; tries++ { 1668 updates, err := b.client.GetDeploymentUpdates(ctx, stackID, deploymentID) 1669 if err != nil { 1670 return "", err 1671 } 1672 if len(updates) > 0 { 1673 return updates[0].UpdateID, nil 1674 } 1675 1676 time.Sleep(500 * time.Millisecond) 1677 } 1678 return "", fmt.Errorf("could not find update associated with deployment %s", deploymentID) 1679 } 1680 1681 updateID, err := getUpdateID() 1682 if err != nil { 1683 return err 1684 } 1685 1686 dryRun := kind == apitype.PreviewUpdate 1687 update := client.UpdateIdentifier{ 1688 StackIdentifier: stackID, 1689 UpdateKind: kind, 1690 UpdateID: updateID, 1691 } 1692 1693 events := make(chan engine.Event) // Note: unbuffered, but we assume it won't matter in practice. 1694 done := make(chan bool) 1695 1696 // Timings do not display correctly when rendering remote events, so suppress showing them. 1697 opts.SuppressTimings = true 1698 1699 go display.ShowEvents( 1700 backend.ActionLabel(kind, dryRun), kind, tokens.Name(stackID.Stack), tokens.PackageName(stackID.Project), 1701 events, done, opts, dryRun) 1702 1703 // The UpdateEvents API returns a continuation token to only get events after the previous call. 1704 var continuationToken *string 1705 var lastEvent engine.Event 1706 for { 1707 resp, err := b.client.GetUpdateEngineEvents(ctx, update, continuationToken) 1708 if err != nil { 1709 return err 1710 } 1711 for _, jsonEvent := range resp.Events { 1712 event, err := display.ConvertJSONEvent(jsonEvent) 1713 if err != nil { 1714 return err 1715 } 1716 lastEvent = event 1717 events <- event 1718 } 1719 1720 continuationToken = resp.ContinuationToken 1721 // A nil continuation token means there are no more events to read and the update has finished. 1722 if continuationToken == nil { 1723 // If the event stream does not terminate with a cancel event, synthesize one here. 1724 if lastEvent.Type != engine.CancelEvent { 1725 events <- engine.NewEvent(engine.CancelEvent, nil) 1726 } 1727 1728 close(events) 1729 <-done 1730 return nil 1731 } 1732 1733 time.Sleep(500 * time.Millisecond) 1734 } 1735 } 1736 1737 type httpstateBackendClient struct { 1738 backend Backend 1739 } 1740 1741 func (c httpstateBackendClient) GetStackOutputs(ctx context.Context, name string) (resource.PropertyMap, error) { 1742 // When using the cloud backend, require that stack references are fully qualified so they 1743 // look like "<org>/<project>/<stack>" 1744 if strings.Count(name, "/") != 2 { 1745 return nil, fmt.Errorf("a stack reference's name should be of the form " + 1746 "'<organization>/<project>/<stack>'. See https://pulumi.io/help/stack-reference for more information.") 1747 } 1748 1749 return backend.NewBackendClient(c.backend).GetStackOutputs(ctx, name) 1750 } 1751 1752 func (c httpstateBackendClient) GetStackResourceOutputs( 1753 ctx context.Context, name string) (resource.PropertyMap, error) { 1754 return backend.NewBackendClient(c.backend).GetStackResourceOutputs(ctx, name) 1755 }