mirror of https://github.com/pulumi/pulumi.git
2187 lines
72 KiB
Go
2187 lines
72 KiB
Go
// Copyright 2016-2023, Pulumi Corporation.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package httpstate
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
opentracing "github.com/opentracing/opentracing-go"
|
|
"github.com/pkg/browser"
|
|
|
|
esc_client "github.com/pulumi/esc/cmd/esc/cli/client"
|
|
"github.com/pulumi/pulumi/pkg/v3/backend"
|
|
"github.com/pulumi/pulumi/pkg/v3/backend/display"
|
|
"github.com/pulumi/pulumi/pkg/v3/backend/diy"
|
|
"github.com/pulumi/pulumi/pkg/v3/backend/httpstate/client"
|
|
sdkDisplay "github.com/pulumi/pulumi/pkg/v3/display"
|
|
"github.com/pulumi/pulumi/pkg/v3/engine"
|
|
"github.com/pulumi/pulumi/pkg/v3/operations"
|
|
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
|
|
"github.com/pulumi/pulumi/pkg/v3/secrets"
|
|
"github.com/pulumi/pulumi/pkg/v3/util/nosleep"
|
|
pkgWorkspace "github.com/pulumi/pulumi/pkg/v3/workspace"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/diag"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/env"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/result"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/util/retry"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
|
|
)
|
|
|
|
const (
|
|
// defaultAPIEnvVar can be set to override the default cloud chosen, if `--cloud` is not present.
|
|
defaultURLEnvVar = "PULUMI_API"
|
|
// AccessTokenEnvVar is the environment variable used to bypass a prompt on login.
|
|
AccessTokenEnvVar = "PULUMI_ACCESS_TOKEN"
|
|
)
|
|
|
|
type PulumiAILanguage string
|
|
|
|
const (
|
|
PulumiAILanguageTypeScript PulumiAILanguage = "TypeScript"
|
|
PulumiAILanguageJavaScript PulumiAILanguage = "JavaScript"
|
|
PulumiAILanguagePython PulumiAILanguage = "Python"
|
|
PulumiAILanguageGo PulumiAILanguage = "Go"
|
|
PulumiAILanguageCSharp PulumiAILanguage = "C#"
|
|
PulumiAILanguageJava PulumiAILanguage = "Java"
|
|
PulumiAILanguageYAML PulumiAILanguage = "YAML"
|
|
)
|
|
|
|
var pulumiAILanguageMap = map[string]PulumiAILanguage{
|
|
"typescript": PulumiAILanguageTypeScript,
|
|
"javascript": PulumiAILanguageJavaScript,
|
|
"python": PulumiAILanguagePython,
|
|
"go": PulumiAILanguageGo,
|
|
"c#": PulumiAILanguageCSharp,
|
|
"java": PulumiAILanguageJava,
|
|
"yaml": PulumiAILanguageYAML,
|
|
}
|
|
|
|
// All of the languages supported by Pulumi AI.
|
|
var PulumiAILanguageOptions = []PulumiAILanguage{
|
|
PulumiAILanguageTypeScript,
|
|
PulumiAILanguageJavaScript,
|
|
PulumiAILanguagePython,
|
|
PulumiAILanguageGo,
|
|
PulumiAILanguageCSharp,
|
|
PulumiAILanguageJava,
|
|
PulumiAILanguageYAML,
|
|
}
|
|
|
|
// A natural language list of languages supported by Pulumi AI.
|
|
const PulumiAILanguagesClause = "TypeScript, JavaScript, Python, Go, C#, Java, or YAML"
|
|
|
|
func (e *PulumiAILanguage) String() string {
|
|
return string(*e)
|
|
}
|
|
|
|
func (e *PulumiAILanguage) Set(v string) error {
|
|
value, ok := pulumiAILanguageMap[strings.ToLower(v)]
|
|
if !ok {
|
|
return fmt.Errorf("must be one of %s", PulumiAILanguagesClause)
|
|
}
|
|
*e = value
|
|
return nil
|
|
}
|
|
|
|
func (e *PulumiAILanguage) Type() string {
|
|
return "pulumiAILanguage"
|
|
}
|
|
|
|
type AIPromptRequestBody struct {
|
|
Language PulumiAILanguage `json:"language"`
|
|
Instructions string `json:"instructions"`
|
|
ResponseMode string `json:"responseMode"`
|
|
ConversationID string `json:"conversationId"`
|
|
ConnectionID string `json:"connectionId"`
|
|
}
|
|
|
|
// Name validation rules enforced by the Pulumi Service.
|
|
var stackOwnerRegexp = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9-_]{1,38}[a-zA-Z0-9]$")
|
|
|
|
// DefaultURL returns the default cloud URL. This may be overridden using the PULUMI_API environment
|
|
// variable. If no override is found, and we are authenticated with a cloud, choose that. Otherwise,
|
|
// we will default to the https://api.pulumi.com/ endpoint.
|
|
func DefaultURL(ws pkgWorkspace.Context) string {
|
|
return ValueOrDefaultURL(ws, "")
|
|
}
|
|
|
|
// ValueOrDefaultURL returns the value if specified, or the default cloud URL otherwise.
|
|
func ValueOrDefaultURL(ws pkgWorkspace.Context, cloudURL string) string {
|
|
// If we have a cloud URL, just return it.
|
|
if cloudURL != "" {
|
|
return strings.TrimSuffix(cloudURL, "/")
|
|
}
|
|
|
|
// Otherwise, respect the PULUMI_API override.
|
|
if cloudURL := os.Getenv(defaultURLEnvVar); cloudURL != "" {
|
|
return cloudURL
|
|
}
|
|
|
|
// If that didn't work, see if we have a current cloud, and use that. Note we need to be careful
|
|
// to ignore the diy cloud.
|
|
if creds, err := ws.GetStoredCredentials(); err == nil {
|
|
if creds.Current != "" && !diy.IsDIYBackendURL(creds.Current) {
|
|
return creds.Current
|
|
}
|
|
}
|
|
|
|
// If none of those led to a cloud URL, simply return the default.
|
|
return PulumiCloudURL
|
|
}
|
|
|
|
// Backend extends the base backend interface with specific information about cloud backends.
|
|
type Backend interface {
|
|
backend.Backend
|
|
|
|
CloudURL() string
|
|
|
|
StackConsoleURL(stackRef backend.StackReference) (string, error)
|
|
Client() *client.Client
|
|
|
|
RunDeployment(ctx context.Context, stackRef backend.StackReference, req apitype.CreateDeploymentRequest,
|
|
opts display.Options, deploymentInitiator string, streamDeploymentLogs bool) error
|
|
|
|
// Queries the backend for resources based on the given query parameters.
|
|
Search(
|
|
ctx context.Context, orgName string, queryParams *apitype.PulumiQueryRequest,
|
|
) (*apitype.ResourceSearchResponse, error)
|
|
NaturalLanguageSearch(
|
|
ctx context.Context, orgName string, query string,
|
|
) (*apitype.ResourceSearchResponse, error)
|
|
PromptAI(ctx context.Context, requestBody AIPromptRequestBody) (*http.Response, error)
|
|
}
|
|
|
|
type cloudBackend struct {
|
|
d diag.Sink
|
|
url string
|
|
client *client.Client
|
|
escClient esc_client.Client
|
|
capabilities func(context.Context) capabilities
|
|
|
|
// The current project, if any.
|
|
currentProject *workspace.Project
|
|
}
|
|
|
|
// Assert we implement the backend.Backend and backend.SpecificDeploymentExporter interfaces.
|
|
var _ backend.SpecificDeploymentExporter = &cloudBackend{}
|
|
|
|
// New creates a new Pulumi backend for the given cloud API URL and token.
|
|
func New(d diag.Sink, cloudURL string, project *workspace.Project, insecure bool) (Backend, error) {
|
|
cloudURL = ValueOrDefaultURL(pkgWorkspace.Instance, cloudURL)
|
|
account, err := workspace.GetAccount(cloudURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting stored credentials: %w", err)
|
|
}
|
|
apiToken := account.AccessToken
|
|
|
|
apiClient := client.NewClient(cloudURL, apiToken, insecure, d)
|
|
escClient := esc_client.New(client.UserAgent(), cloudURL, apiToken, insecure)
|
|
capabilities := detectCapabilities(d, apiClient)
|
|
|
|
return &cloudBackend{
|
|
d: d,
|
|
url: cloudURL,
|
|
client: apiClient,
|
|
escClient: escClient,
|
|
capabilities: capabilities,
|
|
currentProject: project,
|
|
}, nil
|
|
}
|
|
|
|
// loginWithBrowser uses a web-browser to log into the cloud and returns the cloud backend for it.
|
|
func loginWithBrowser(
|
|
ctx context.Context,
|
|
cloudURL string,
|
|
insecure bool,
|
|
command string,
|
|
welcome func(display.Options),
|
|
current bool,
|
|
opts display.Options,
|
|
) (*workspace.Account, error) {
|
|
// Locally, we generate a nonce and spin up a web server listening on a random port on localhost. We then open a
|
|
// browser to a special endpoint on the Pulumi.com console, passing the generated nonce as well as the port of the
|
|
// webserver we launched. This endpoint does the OAuth flow and when it completes, redirects to localhost passing
|
|
// the nonce and the pulumi access token we created as part of the OAuth flow. If the nonces match, we set the
|
|
// access token that was passed to us and the redirect to a special welcome page on Pulumi.com
|
|
|
|
loginURL := cloudConsoleURL(cloudURL, "cli-login")
|
|
finalWelcomeURL := cloudConsoleURL(cloudURL, "welcome", "cli")
|
|
|
|
if loginURL == "" || finalWelcomeURL == "" {
|
|
return nil, errors.New("could not determine login url")
|
|
}
|
|
|
|
// Listen on localhost, have the kernel pick a random port for us
|
|
c := make(chan string)
|
|
l, err := net.Listen("tcp", "127.0.0.1:")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not start listener: %w", err)
|
|
}
|
|
|
|
// Extract the port
|
|
_, port, err := net.SplitHostPort(l.Addr().String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not determine port: %w", err)
|
|
}
|
|
|
|
// Generate a nonce we'll send with the request.
|
|
nonceBytes := make([]byte, 32)
|
|
_, err = cryptorand.Read(nonceBytes)
|
|
contract.AssertNoErrorf(err, "could not get random bytes")
|
|
nonce := hex.EncodeToString(nonceBytes)
|
|
|
|
u, err := url.Parse(loginURL)
|
|
contract.AssertNoErrorf(err, "error parsing login url: %s", loginURL)
|
|
|
|
// Generate a description to associate with the access token we'll generate, for display on the Account Settings
|
|
// page.
|
|
var tokenDescription string
|
|
if host, hostErr := os.Hostname(); hostErr == nil {
|
|
tokenDescription = fmt.Sprintf("Generated by %s login on %s at %s", command, host, time.Now().Format(time.RFC822))
|
|
} else {
|
|
tokenDescription = fmt.Sprintf("Generated by %s login at %s", command, time.Now().Format(time.RFC822))
|
|
}
|
|
|
|
// Pass our state around as query parameters on the URL we'll open the user's preferred browser to
|
|
q := u.Query()
|
|
q.Add("cliSessionPort", port)
|
|
q.Add("cliSessionNonce", nonce)
|
|
q.Add("cliSessionDescription", tokenDescription)
|
|
if command != "pulumi" {
|
|
q.Add("cliCommand", command)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
// Start the webserver to listen to handle the response
|
|
go serveBrowserLoginServer(l, nonce, finalWelcomeURL, c)
|
|
|
|
// Launch the web browser and navigate to the login URL.
|
|
if openErr := browser.OpenURL(u.String()); openErr != nil {
|
|
fmt.Printf("We couldn't launch your web browser for some reason. Please visit:\n\n%s\n\n"+
|
|
"to finish the login process.", u)
|
|
} else {
|
|
fmt.Println("We've launched your web browser to complete the login process.")
|
|
}
|
|
|
|
fmt.Println("\nWaiting for login to complete...")
|
|
|
|
accessToken := <-c
|
|
|
|
username, organizations, tokenInfo, err := client.NewClient(
|
|
cloudURL, accessToken, insecure, cmdutil.Diag()).GetPulumiAccountDetails(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Save the token and return the backend
|
|
account := workspace.Account{
|
|
AccessToken: accessToken,
|
|
Username: username,
|
|
Organizations: organizations,
|
|
LastValidatedAt: time.Now(),
|
|
Insecure: insecure,
|
|
TokenInformation: tokenInfo,
|
|
}
|
|
if err = workspace.StoreAccount(cloudURL, account, current); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Welcome the user since this was an interactive login.
|
|
if welcome != nil {
|
|
welcome(opts)
|
|
}
|
|
|
|
return &account, nil
|
|
}
|
|
|
|
// LoginManager provides a slim wrapper around functions related to backend logins.
|
|
type LoginManager interface {
|
|
// Current returns the current cloud backend if one is already logged in.
|
|
Current(ctx context.Context, cloudURL string, insecure, setCurrent bool) (*workspace.Account, error)
|
|
|
|
// Login logs into the target cloud URL and returns the cloud backend for it.
|
|
Login(
|
|
ctx context.Context,
|
|
cloudURL string,
|
|
insecure bool,
|
|
command string,
|
|
message string,
|
|
welcome func(display.Options),
|
|
current bool,
|
|
opts display.Options,
|
|
) (*workspace.Account, error)
|
|
}
|
|
|
|
// NewLoginManager returns a LoginManager for handling backend logins.
|
|
func NewLoginManager() LoginManager {
|
|
return newLoginManager()
|
|
}
|
|
|
|
// newLoginManager creates a new LoginManager for handling logins. It is a variable instead of a regular
|
|
// function so it can be set to a different implementation at runtime, if necessary.
|
|
var newLoginManager = func() LoginManager {
|
|
return defaultLoginManager{}
|
|
}
|
|
|
|
type defaultLoginManager struct{}
|
|
|
|
// Current returns the current cloud backend if one is already logged in.
|
|
func (m defaultLoginManager) Current(
|
|
ctx context.Context,
|
|
cloudURL string,
|
|
insecure bool,
|
|
setCurrent bool,
|
|
) (*workspace.Account, error) {
|
|
cloudURL = ValueOrDefaultURL(pkgWorkspace.Instance, cloudURL)
|
|
|
|
// We intentionally don't accept command-line args for the user's access token. Having it in
|
|
// .bash_history is not great, and specifying it via flag isn't of much use.
|
|
accessToken := os.Getenv(AccessTokenEnvVar)
|
|
|
|
// If we have a saved access token, and it is valid, and it
|
|
// either matches PULUMI_ACCESS_TOKEN or PULUMI_ACCESS_TOKEN
|
|
// is not set use it. If PULUMI_ACCESS_TOKEN does not match,
|
|
// we prefer that.
|
|
existingAccount, err := workspace.GetAccount(cloudURL)
|
|
if err == nil && existingAccount.AccessToken != "" &&
|
|
(accessToken == "" || existingAccount.AccessToken == accessToken) {
|
|
// If the account was last verified less than an hour ago, assume the token is valid.
|
|
valid := true
|
|
username := existingAccount.Username
|
|
organizations := existingAccount.Organizations
|
|
tokenInfo := existingAccount.TokenInformation
|
|
if username == "" || existingAccount.LastValidatedAt.Add(1*time.Hour).Before(time.Now()) {
|
|
valid, username, organizations, tokenInfo, err = IsValidAccessToken(
|
|
ctx, cloudURL, insecure, existingAccount.AccessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
existingAccount.LastValidatedAt = time.Now()
|
|
}
|
|
|
|
if valid {
|
|
// Save the token. While it hasn't changed this will update the current cloud we are logged into, as well.
|
|
existingAccount.Username = username
|
|
existingAccount.Organizations = organizations
|
|
existingAccount.TokenInformation = tokenInfo
|
|
existingAccount.Insecure = insecure
|
|
if err = workspace.StoreAccount(cloudURL, existingAccount, setCurrent); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &existingAccount, nil
|
|
}
|
|
}
|
|
|
|
// We either have no token saved, or PULUMI_ACCESS_TOKEN
|
|
// doesn't match what we have saved. Prefer the new
|
|
// PULUMI_ACCESS_TOKEN.
|
|
if accessToken == "" {
|
|
// No access token available, this isn't an error per-se but we don't have a backend
|
|
return nil, nil
|
|
}
|
|
|
|
// If there's already a token from the environment, use it.
|
|
_, err = fmt.Fprintf(os.Stderr, "Logging in using access token from %s\n", AccessTokenEnvVar)
|
|
contract.IgnoreError(err)
|
|
|
|
// Try and use the credentials to see if they are valid.
|
|
valid, username, organizations, tokenInfo, err := IsValidAccessToken(ctx, cloudURL, insecure, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !valid {
|
|
return nil, errors.New("invalid access token")
|
|
}
|
|
|
|
// Save them.
|
|
account := workspace.Account{
|
|
AccessToken: accessToken,
|
|
Username: username,
|
|
Organizations: organizations,
|
|
TokenInformation: tokenInfo,
|
|
LastValidatedAt: time.Now(),
|
|
Insecure: insecure,
|
|
}
|
|
if err = workspace.StoreAccount(cloudURL, account, setCurrent); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &account, nil
|
|
}
|
|
|
|
// Login logs into the target cloud URL and returns the cloud backend for it.
|
|
func (m defaultLoginManager) Login(
|
|
ctx context.Context,
|
|
cloudURL string,
|
|
insecure bool,
|
|
command string,
|
|
message string,
|
|
welcome func(display.Options),
|
|
setCurrent bool,
|
|
opts display.Options,
|
|
) (*workspace.Account, error) {
|
|
current, err := m.Current(ctx, cloudURL, insecure, setCurrent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if current != nil {
|
|
return current, nil
|
|
}
|
|
|
|
cloudURL = ValueOrDefaultURL(pkgWorkspace.Instance, cloudURL)
|
|
var accessToken string
|
|
accountLink := cloudConsoleURL(cloudURL, "account", "tokens")
|
|
|
|
if !cmdutil.Interactive() {
|
|
// If interactive mode isn't enabled, the only way to specify a token is through the environment variable.
|
|
// Fail the attempt to login.
|
|
return nil, fmt.Errorf("%s must be set for login during non-interactive CLI sessions", AccessTokenEnvVar)
|
|
}
|
|
|
|
// If no access token is available from the environment, and we are interactive, prompt and offer to
|
|
// open a browser to make it easy to generate and use a fresh token.
|
|
line1 := "Manage your " + message + " by logging in."
|
|
line1len := len(line1)
|
|
line1 = colors.Highlight(line1, message, colors.Underline+colors.Bold)
|
|
fmt.Println(opts.Color.Colorize(line1))
|
|
maxlen := line1len
|
|
|
|
line2 := fmt.Sprintf("Run `%s login --help` for alternative login options.", command)
|
|
line2len := len(line2)
|
|
fmt.Println(opts.Color.Colorize(line2))
|
|
if line2len > maxlen {
|
|
maxlen = line2len
|
|
}
|
|
|
|
// In the case where we could not construct a link to the pulumi console based on the API server's hostname,
|
|
// don't offer magic log-in or text about where to find your access token.
|
|
if accountLink == "" {
|
|
for {
|
|
if accessToken, err = cmdutil.ReadConsoleNoEcho("Enter your access token"); err != nil {
|
|
return nil, err
|
|
}
|
|
if accessToken != "" {
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
line3 := "Enter your access token from " + accountLink
|
|
line3len := len(line3)
|
|
line3 = colors.Highlight(line3, "access token", colors.BrightCyan+colors.Bold)
|
|
line3 = colors.Highlight(line3, accountLink, colors.BrightBlue+colors.Underline+colors.Bold)
|
|
fmt.Println(opts.Color.Colorize(line3))
|
|
if line3len > maxlen {
|
|
maxlen = line3len
|
|
}
|
|
|
|
line4 := " or hit <ENTER> to log in using your browser"
|
|
var padding string
|
|
if pad := maxlen - len(line4); pad > 0 {
|
|
padding = strings.Repeat(" ", pad)
|
|
}
|
|
line4 = colors.Highlight(line4, "<ENTER>", colors.BrightCyan+colors.Bold)
|
|
|
|
if accessToken, err = cmdutil.ReadConsoleNoEcho(opts.Color.Colorize(line4) + padding); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if accessToken == "" {
|
|
return loginWithBrowser(ctx, cloudURL, insecure, command, welcome, setCurrent, opts)
|
|
}
|
|
|
|
// Welcome the user since this was an interactive login.
|
|
if welcome != nil {
|
|
welcome(opts)
|
|
}
|
|
}
|
|
|
|
// Try and use the credentials to see if they are valid.
|
|
valid, username, organizations, tokenInfo, err := IsValidAccessToken(ctx, cloudURL, insecure, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !valid {
|
|
return nil, errors.New("invalid access token")
|
|
}
|
|
|
|
// Save them.
|
|
account := workspace.Account{
|
|
AccessToken: accessToken,
|
|
Username: username,
|
|
Organizations: organizations,
|
|
TokenInformation: tokenInfo,
|
|
LastValidatedAt: time.Now(),
|
|
Insecure: insecure,
|
|
}
|
|
if err = workspace.StoreAccount(cloudURL, account, setCurrent); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &account, nil
|
|
}
|
|
|
|
// WelcomeUser prints a Welcome to Pulumi message.
|
|
func WelcomeUser(opts display.Options) {
|
|
fmt.Printf(`
|
|
|
|
%s
|
|
|
|
Pulumi helps you create, deploy, and manage infrastructure on any cloud using
|
|
your favorite language. You can get started today with Pulumi at:
|
|
|
|
https://www.pulumi.com/docs/get-started/
|
|
|
|
%s Resources you create with Pulumi are given unique names (a randomly
|
|
generated suffix) by default. To learn more about auto-naming or customizing resource
|
|
names see https://www.pulumi.com/docs/intro/concepts/resources/#autonaming.
|
|
|
|
|
|
`,
|
|
opts.Color.Colorize(colors.SpecHeadline+"Welcome to Pulumi!"+colors.Reset),
|
|
opts.Color.Colorize(colors.SpecSubHeadline+"Tip:"+colors.Reset))
|
|
}
|
|
|
|
func (b *cloudBackend) StackConsoleURL(stackRef backend.StackReference) (string, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
path := b.cloudConsoleStackPath(stackID)
|
|
|
|
url := b.CloudConsoleURL(path)
|
|
if url == "" {
|
|
return "", errors.New("could not determine cloud console URL")
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
func (b *cloudBackend) Name() string {
|
|
if b.url == PulumiCloudURL {
|
|
return "pulumi.com"
|
|
}
|
|
|
|
return b.url
|
|
}
|
|
|
|
func (b *cloudBackend) URL() string {
|
|
user, _, _, err := b.CurrentUser()
|
|
if err != nil {
|
|
return cloudConsoleURL(b.url)
|
|
}
|
|
return cloudConsoleURL(b.url, user)
|
|
}
|
|
|
|
func (b *cloudBackend) SetCurrentProject(project *workspace.Project) {
|
|
b.currentProject = project
|
|
}
|
|
|
|
func (b *cloudBackend) CurrentUser() (string, []string, *workspace.TokenInformation, error) {
|
|
return b.currentUser(context.Background())
|
|
}
|
|
|
|
func (b *cloudBackend) currentUser(ctx context.Context) (string, []string, *workspace.TokenInformation, error) {
|
|
account, err := workspace.GetAccount(b.CloudURL())
|
|
if err != nil {
|
|
return "", nil, nil, err
|
|
}
|
|
if account.Username != "" {
|
|
logging.V(1).Infof("found username for access token")
|
|
return account.Username, account.Organizations, account.TokenInformation, nil
|
|
}
|
|
logging.V(1).Infof("no username for access token")
|
|
return b.client.GetPulumiAccountDetails(ctx)
|
|
}
|
|
|
|
func (b *cloudBackend) CloudURL() string { return b.url }
|
|
|
|
func (b *cloudBackend) parsePolicyPackReference(s string) (backend.PolicyPackReference, error) {
|
|
split := strings.Split(s, "/")
|
|
var orgName string
|
|
var policyPackName string
|
|
|
|
switch len(split) {
|
|
case 2:
|
|
orgName = split[0]
|
|
policyPackName = split[1]
|
|
default:
|
|
return nil, fmt.Errorf("could not parse policy pack name '%s'; must be of the form "+
|
|
"<org-name>/<policy-pack-name>", s)
|
|
}
|
|
|
|
if orgName == "" {
|
|
currentUser, _, _, userErr := b.CurrentUser()
|
|
if userErr != nil {
|
|
return nil, userErr
|
|
}
|
|
orgName = currentUser
|
|
}
|
|
|
|
return newCloudBackendPolicyPackReference(b.CloudConsoleURL(), orgName, tokens.QName(policyPackName)), nil
|
|
}
|
|
|
|
func (b *cloudBackend) GetPolicyPack(ctx context.Context, policyPack string,
|
|
d diag.Sink,
|
|
) (backend.PolicyPack, error) {
|
|
policyPackRef, err := b.parsePolicyPackReference(policyPack)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &cloudPolicyPack{
|
|
ref: newCloudBackendPolicyPackReference(b.CloudConsoleURL(),
|
|
policyPackRef.OrgName(), policyPackRef.Name()),
|
|
b: b,
|
|
cl: b.client,
|
|
}, nil
|
|
}
|
|
|
|
func (b *cloudBackend) ListPolicyGroups(ctx context.Context, orgName string, inContToken backend.ContinuationToken) (
|
|
apitype.ListPolicyGroupsResponse, backend.ContinuationToken, error,
|
|
) {
|
|
return b.client.ListPolicyGroups(ctx, orgName, inContToken)
|
|
}
|
|
|
|
func (b *cloudBackend) ListPolicyPacks(ctx context.Context, orgName string, inContToken backend.ContinuationToken) (
|
|
apitype.ListPolicyPacksResponse, backend.ContinuationToken, error,
|
|
) {
|
|
return b.client.ListPolicyPacks(ctx, orgName, inContToken)
|
|
}
|
|
|
|
func (b *cloudBackend) SupportsTags() bool {
|
|
return true
|
|
}
|
|
|
|
func (b *cloudBackend) SupportsOrganizations() bool {
|
|
return true
|
|
}
|
|
|
|
func (b *cloudBackend) SupportsProgress() bool {
|
|
return true
|
|
}
|
|
|
|
func (b *cloudBackend) SupportsDeployments() bool {
|
|
return true
|
|
}
|
|
|
|
// qualifiedStackReference describes a qualified stack on the Pulumi Service. The Owner or Project
|
|
// may be "" if unspecified, e.g. "pulumi/production" specifies the Owner and Name, but not the
|
|
// Project. We infer the missing data and try to make things work as best we can in ParseStackReference.
|
|
type qualifiedStackReference struct {
|
|
Owner string
|
|
Project string
|
|
Name string
|
|
}
|
|
|
|
// parseStackName parses the stack name into a potentially qualifiedStackReference. Any omitted
|
|
// portions will be left as "". For example:
|
|
//
|
|
// "alpha" - will just set the Name, but ignore Owner and Project.
|
|
// "alpha/beta" - will set the Owner and Name, but not Project.
|
|
// "alpha/beta/gamma" - will set Owner, Name, and Project.
|
|
func (b *cloudBackend) parseStackName(s string) (qualifiedStackReference, error) {
|
|
var q qualifiedStackReference
|
|
|
|
split := strings.Split(s, "/")
|
|
switch len(split) {
|
|
case 1:
|
|
q.Name = split[0]
|
|
case 2:
|
|
q.Owner = split[0]
|
|
q.Name = split[1]
|
|
case 3:
|
|
q.Owner = split[0]
|
|
q.Project = split[1]
|
|
q.Name = split[2]
|
|
default:
|
|
return qualifiedStackReference{}, fmt.Errorf("could not parse stack name '%s'", s)
|
|
}
|
|
|
|
return q, nil
|
|
}
|
|
|
|
func (b *cloudBackend) ParseStackReference(s string) (backend.StackReference, error) {
|
|
// Parse the input as a qualified stack name.
|
|
qualifiedName, err := b.parseStackName(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If the provided stack name didn't include the Owner or Project, infer them from the
|
|
// local environment.
|
|
if qualifiedName.Owner == "" {
|
|
// if the qualifiedName doesn't include an owner then let's check to see if there is a default org which *will*
|
|
// be the stack owner. If there is no defaultOrg, then we revert to checking the CurrentUser
|
|
defaultOrg, err := pkgWorkspace.GetBackendConfigDefaultOrg(b.currentProject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if defaultOrg != "" {
|
|
qualifiedName.Owner = defaultOrg
|
|
} else {
|
|
currentUser, _, _, userErr := b.CurrentUser()
|
|
if userErr != nil {
|
|
return nil, userErr
|
|
}
|
|
qualifiedName.Owner = currentUser
|
|
}
|
|
}
|
|
|
|
if qualifiedName.Project == "" {
|
|
if b.currentProject == nil {
|
|
return nil, errors.New("no current project found, pass the fully qualified stack name (org/project/stack)")
|
|
}
|
|
|
|
qualifiedName.Project = b.currentProject.Name.String()
|
|
}
|
|
|
|
parsedName, err := tokens.ParseStackName(qualifiedName.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cloudBackendReference{
|
|
owner: qualifiedName.Owner,
|
|
project: tokens.Name(qualifiedName.Project),
|
|
name: parsedName,
|
|
b: b,
|
|
}, nil
|
|
}
|
|
|
|
func (b *cloudBackend) ValidateStackName(s string) error {
|
|
qualifiedName, err := b.parseStackName(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The Pulumi Service enforces specific naming restrictions for organizations,
|
|
// projects, and stacks. Though ignore any values that need to be inferred later.
|
|
if qualifiedName.Owner != "" {
|
|
if err := validateOwnerName(qualifiedName.Owner); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if qualifiedName.Project != "" {
|
|
if err := validateProjectName(qualifiedName.Project); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err = tokens.ParseStackName(qualifiedName.Name)
|
|
return err
|
|
}
|
|
|
|
// validateOwnerName checks if a stack owner name is valid. An "owner" is simply the namespace
|
|
// a stack may exist within, which for the Pulumi Service is the user account or organization.
|
|
func validateOwnerName(s string) error {
|
|
if !stackOwnerRegexp.MatchString(s) {
|
|
return errors.New("invalid stack owner")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateProjectName checks if a project name is valid, returning a user-suitable error if needed.
|
|
//
|
|
// NOTE: Be careful when requiring a project name be valid. The Pulumi.yaml file may contain
|
|
// an invalid project name like "r@bid^W0MBAT!!", but we try to err on the side of flexibility by
|
|
// implicitly "cleaning" the project name before we send it to the Pulumi Service. So when we go
|
|
// to make HTTP requests, we use a more palitable name like "r_bid_W0MBAT__".
|
|
//
|
|
// The projects canonical name will be the sanitized "r_bid_W0MBAT__" form, but we do not require the
|
|
// Pulumi.yaml file be updated.
|
|
//
|
|
// So we should only call validateProject name when creating _new_ stacks or creating _new_ projects.
|
|
// We should not require that project names be valid when reading what is in the current workspace.
|
|
func validateProjectName(s string) error {
|
|
return tokens.ValidateProjectName(s)
|
|
}
|
|
|
|
// CloudConsoleURL returns a link to the cloud console with the given path elements. If a console link cannot be
|
|
// created, we return the empty string instead (this can happen if the endpoint isn't a recognized pattern).
|
|
func (b *cloudBackend) CloudConsoleURL(paths ...string) string {
|
|
return cloudConsoleURL(b.CloudURL(), paths...)
|
|
}
|
|
|
|
// serveBrowserLoginServer hosts the server that completes the browser based login flow.
|
|
func serveBrowserLoginServer(l net.Listener, expectedNonce string, destinationURL string, c chan<- string) {
|
|
handler := func(res http.ResponseWriter, req *http.Request) {
|
|
tok := req.URL.Query().Get("accessToken")
|
|
nonce := req.URL.Query().Get("nonce")
|
|
|
|
if tok == "" || nonce != expectedNonce {
|
|
res.WriteHeader(400)
|
|
return
|
|
}
|
|
|
|
http.Redirect(res, req, destinationURL, http.StatusTemporaryRedirect)
|
|
c <- tok
|
|
}
|
|
|
|
mux := &http.ServeMux{}
|
|
mux.HandleFunc("/", handler)
|
|
contract.IgnoreError(http.Serve(l, mux)) //nolint:gosec
|
|
}
|
|
|
|
// CloudConsoleStackPath returns the stack path components for getting to a stack in the cloud console. This path
|
|
// must, of course, be combined with the actual console base URL by way of the CloudConsoleURL function above.
|
|
func (b *cloudBackend) cloudConsoleStackPath(stackID client.StackIdentifier) string {
|
|
return path.Join(stackID.Owner, stackID.Project, stackID.Stack.String())
|
|
}
|
|
|
|
func inferOrg(ctx context.Context,
|
|
getDefaultOrg func() (string, error),
|
|
getUserOrg func() (string, error),
|
|
) (string, error) {
|
|
orgName, err := getDefaultOrg()
|
|
if err != nil || orgName == "" {
|
|
// Fallback to using the current user.
|
|
orgName, err = getUserOrg()
|
|
if err != nil || orgName == "" {
|
|
return "", errors.New("could not determine organization name")
|
|
}
|
|
}
|
|
return orgName, nil
|
|
}
|
|
|
|
// DoesProjectExist returns true if a project with the given name exists in this backend, or false otherwise.
|
|
func (b *cloudBackend) DoesProjectExist(ctx context.Context, orgName string, projectName string) (bool, error) {
|
|
if orgName != "" {
|
|
return b.client.DoesProjectExist(ctx, orgName, projectName)
|
|
}
|
|
|
|
getDefaultOrg := func() (string, error) {
|
|
return pkgWorkspace.GetBackendConfigDefaultOrg(nil)
|
|
}
|
|
getUserOrg := func() (string, error) {
|
|
orgName, _, _, err := b.currentUser(ctx)
|
|
return orgName, err
|
|
}
|
|
orgName, err := inferOrg(ctx, getDefaultOrg, getUserOrg)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return b.client.DoesProjectExist(ctx, orgName, projectName)
|
|
}
|
|
|
|
func (b *cloudBackend) GetStack(ctx context.Context, stackRef backend.StackReference) (backend.Stack, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// GetStack is typically the initial call to a series of calls to the backend. Although logically unrelated,
|
|
// this is a good time to start detecting capabilities so that capability request is not on the critical path.
|
|
go b.capabilities(ctx)
|
|
|
|
stack, err := b.client.GetStack(ctx, stackID)
|
|
if err != nil {
|
|
// If this was a 404, return nil, nil as per this method's contract.
|
|
if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == http.StatusNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return newStack(stack, b), nil
|
|
}
|
|
|
|
// Confirm the specified stack's project doesn't contradict the Pulumi.yaml of the current project.
|
|
// if the CWD is not in a Pulumi project,
|
|
//
|
|
// does not contradict
|
|
//
|
|
// if the project name in Pulumi.yaml is "foo".
|
|
//
|
|
// a stack with a name of foo/bar/foo should not work.
|
|
func currentProjectContradictsWorkspace(project *workspace.Project, stack client.StackIdentifier) bool {
|
|
if project == nil {
|
|
return false
|
|
}
|
|
|
|
return project.Name.String() != stack.Project
|
|
}
|
|
|
|
func (b *cloudBackend) CreateStack(
|
|
ctx context.Context,
|
|
stackRef backend.StackReference,
|
|
root string,
|
|
initialState *apitype.UntypedDeployment,
|
|
opts *backend.CreateStackOptions,
|
|
) (
|
|
backend.Stack, error,
|
|
) {
|
|
if opts == nil {
|
|
opts = &backend.CreateStackOptions{}
|
|
}
|
|
|
|
stackID, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if currentProjectContradictsWorkspace(b.currentProject, stackID) {
|
|
return nil, fmt.Errorf("provided project name %q doesn't match Pulumi.yaml", stackID.Project)
|
|
}
|
|
|
|
// TODO: This should load project config and pass it as the last parameter to GetEnvironmentTagsForCurrentStack.
|
|
tags, err := backend.GetEnvironmentTagsForCurrentStack(root, b.currentProject, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting stack tags: %w", err)
|
|
}
|
|
|
|
apistack, err := b.client.CreateStack(ctx, stackID, tags, opts.Teams, initialState)
|
|
if err != nil {
|
|
// Wire through well-known error types.
|
|
if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == http.StatusConflict {
|
|
// A 409 error response is returned when per-stack organizations are over their limit,
|
|
// so we need to look at the message to differentiate.
|
|
if strings.Contains(errResp.Message, "already exists") {
|
|
return nil, &backend.StackAlreadyExistsError{StackName: stackID.String()}
|
|
}
|
|
if strings.Contains(errResp.Message, "you are using") {
|
|
return nil, &backend.OverStackLimitError{Message: errResp.Message}
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
stack := newStack(apistack, b)
|
|
fmt.Printf("Created stack '%s'\n", stack.Ref())
|
|
|
|
return stack, nil
|
|
}
|
|
|
|
func (b *cloudBackend) ListStacks(
|
|
ctx context.Context, filter backend.ListStacksFilter, inContToken backend.ContinuationToken) (
|
|
[]backend.StackSummary, backend.ContinuationToken, error,
|
|
) {
|
|
// Sanitize the project name as needed, so when communicating with the Pulumi Service we
|
|
// always use the name the service expects. (So that a similar, but not technically valid
|
|
// name may be put in Pulumi.yaml without causing problems.)
|
|
if filter.Project != nil {
|
|
cleanedProj := cleanProjectName(*filter.Project)
|
|
filter.Project = &cleanedProj
|
|
}
|
|
|
|
// Duplicate type to avoid circular dependency.
|
|
clientFilter := client.ListStacksFilter{
|
|
Organization: filter.Organization,
|
|
Project: filter.Project,
|
|
TagName: filter.TagName,
|
|
TagValue: filter.TagValue,
|
|
}
|
|
|
|
apiSummaries, outContToken, err := b.client.ListStacks(ctx, clientFilter, inContToken)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Convert []apitype.StackSummary into []backend.StackSummary.
|
|
backendSummaries := slice.Prealloc[backend.StackSummary](len(apiSummaries))
|
|
for _, apiSummary := range apiSummaries {
|
|
backendSummary := cloudStackSummary{
|
|
summary: apiSummary,
|
|
b: b,
|
|
}
|
|
backendSummaries = append(backendSummaries, backendSummary)
|
|
}
|
|
|
|
return backendSummaries, outContToken, nil
|
|
}
|
|
|
|
func (b *cloudBackend) RemoveStack(ctx context.Context, stack backend.Stack, force bool) (bool, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return b.client.DeleteStack(ctx, stackID, force)
|
|
}
|
|
|
|
func (b *cloudBackend) RenameStack(ctx context.Context, stack backend.Stack,
|
|
newName tokens.QName,
|
|
) (backend.StackReference, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Support a qualified stack name, which would also rename the stack's project too.
|
|
// e.g. if you want to change the project name on the Pulumi Console to reflect a
|
|
// new value in Pulumi.yaml.
|
|
newRef, err := b.ParseStackReference(string(newName))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
newIdentity, err := b.getCloudStackIdentifier(newRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if stackID.Owner != newIdentity.Owner {
|
|
errMsg := fmt.Sprintf(
|
|
"New stack owner, %s, does not match existing owner, %s.\n\n",
|
|
stackID.Owner, newIdentity.Owner)
|
|
|
|
// Re-parse the name using the parseStackName function to avoid the logic in ParseStackReference
|
|
// that auto-populates the owner property with the currently logged in account. We actually want to
|
|
// give a different error message if the raw stack name itself didn't include an owner part.
|
|
parsedName, err := b.parseStackName(string(newName))
|
|
contract.IgnoreError(err)
|
|
if parsedName.Owner == "" {
|
|
errMsg += fmt.Sprintf(
|
|
" Did you forget to include the owner name? If yes, rerun the command as follows:\n\n"+
|
|
" $ pulumi stack rename %s/%s\n\n",
|
|
stackID.Owner, newName)
|
|
}
|
|
|
|
errMsgSuffix := "."
|
|
if consoleURL, err := b.StackConsoleURL(stack.Ref()); err == nil {
|
|
errMsgSuffix = ":\n\n " + consoleURL + "/settings/options"
|
|
}
|
|
errMsg += " You cannot transfer stack ownership via a rename. If you wish to transfer ownership\n" +
|
|
" of a stack to another organization, you can do so in the Pulumi Console by going to the\n" +
|
|
" \"Settings\" page of the stack and then clicking the \"Transfer Stack\" button"
|
|
|
|
return nil, errors.New(errMsg + errMsgSuffix)
|
|
}
|
|
|
|
if err = b.client.RenameStack(ctx, stackID, newIdentity); err != nil {
|
|
return nil, err
|
|
}
|
|
return newRef, nil
|
|
}
|
|
|
|
func (b *cloudBackend) Preview(ctx context.Context, stack backend.Stack,
|
|
op backend.UpdateOperation, events chan<- engine.Event,
|
|
) (*deploy.Plan, sdkDisplay.ResourceChanges, error) {
|
|
// We can skip PreviewThenPromptThenExecute, and just go straight to Execute.
|
|
opts := backend.ApplierOptions{
|
|
DryRun: true,
|
|
ShowLink: true,
|
|
}
|
|
return b.apply(
|
|
ctx, apitype.PreviewUpdate, stack, op, opts, events)
|
|
}
|
|
|
|
func (b *cloudBackend) Update(ctx context.Context, stack backend.Stack,
|
|
op backend.UpdateOperation,
|
|
) (sdkDisplay.ResourceChanges, error) {
|
|
return backend.PreviewThenPromptThenExecute(ctx, apitype.UpdateUpdate, stack, op, b.apply)
|
|
}
|
|
|
|
func (b *cloudBackend) Import(ctx context.Context, stack backend.Stack,
|
|
op backend.UpdateOperation, imports []deploy.Import,
|
|
) (sdkDisplay.ResourceChanges, error) {
|
|
op.Imports = imports
|
|
|
|
if op.Opts.PreviewOnly {
|
|
// We can skip PreviewThenPromptThenExecute, and just go straight to Execute.
|
|
opts := backend.ApplierOptions{
|
|
DryRun: true,
|
|
ShowLink: true,
|
|
}
|
|
|
|
op.Opts.Engine.GeneratePlan = false
|
|
_, changes, err := b.apply(
|
|
ctx, apitype.ResourceImportUpdate, stack, op, opts, nil /*events*/)
|
|
return changes, err
|
|
}
|
|
|
|
return backend.PreviewThenPromptThenExecute(ctx, apitype.ResourceImportUpdate, stack, op, b.apply)
|
|
}
|
|
|
|
func (b *cloudBackend) Refresh(ctx context.Context, stack backend.Stack,
|
|
op backend.UpdateOperation,
|
|
) (sdkDisplay.ResourceChanges, error) {
|
|
if op.Opts.PreviewOnly {
|
|
// We can skip PreviewThenPromptThenExecute, and just go straight to Execute.
|
|
opts := backend.ApplierOptions{
|
|
DryRun: true,
|
|
ShowLink: true,
|
|
}
|
|
|
|
op.Opts.Engine.GeneratePlan = false
|
|
_, changes, err := b.apply(
|
|
ctx, apitype.RefreshUpdate, stack, op, opts, nil /*events*/)
|
|
return changes, err
|
|
}
|
|
return backend.PreviewThenPromptThenExecute(ctx, apitype.RefreshUpdate, stack, op, b.apply)
|
|
}
|
|
|
|
func (b *cloudBackend) Destroy(ctx context.Context, stack backend.Stack,
|
|
op backend.UpdateOperation,
|
|
) (sdkDisplay.ResourceChanges, error) {
|
|
if op.Opts.PreviewOnly {
|
|
// We can skip PreviewThenPromptThenExecute, and just go straight to Execute.
|
|
opts := backend.ApplierOptions{
|
|
DryRun: true,
|
|
ShowLink: true,
|
|
}
|
|
|
|
op.Opts.Engine.GeneratePlan = false
|
|
_, changes, err := b.apply(
|
|
ctx, apitype.DestroyUpdate, stack, op, opts, nil /*events*/)
|
|
return changes, err
|
|
}
|
|
return backend.PreviewThenPromptThenExecute(ctx, apitype.DestroyUpdate, stack, op, b.apply)
|
|
}
|
|
|
|
func (b *cloudBackend) Watch(ctx context.Context, stk backend.Stack,
|
|
op backend.UpdateOperation, paths []string,
|
|
) error {
|
|
return backend.Watch(ctx, b, stk, op, b.apply, paths)
|
|
}
|
|
|
|
func (b *cloudBackend) Query(ctx context.Context, op backend.QueryOperation) error {
|
|
return b.query(ctx, op, nil /*events*/)
|
|
}
|
|
|
|
func (b *cloudBackend) Search(
|
|
ctx context.Context, orgName string, queryParams *apitype.PulumiQueryRequest,
|
|
) (*apitype.ResourceSearchResponse, error) {
|
|
results, err := b.Client().GetSearchQueryResults(ctx, orgName, queryParams, b.CloudConsoleURL())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results.Query = queryParams.Query
|
|
return results, nil
|
|
}
|
|
|
|
func (b *cloudBackend) NaturalLanguageSearch(
|
|
ctx context.Context, orgName string, queryString string,
|
|
) (*apitype.ResourceSearchResponse, error) {
|
|
parsedResults, err := b.Client().GetNaturalLanguageQueryResults(ctx, orgName, queryString)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
requestBody := apitype.PulumiQueryRequest{Query: parsedResults.Query}
|
|
results, err := b.Client().GetSearchQueryResults(ctx, orgName, &requestBody, b.CloudConsoleURL())
|
|
results.Query = parsedResults.Query
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return results, err
|
|
}
|
|
|
|
func (b *cloudBackend) PromptAI(
|
|
ctx context.Context, requestBody AIPromptRequestBody,
|
|
) (*http.Response, error) {
|
|
res, err := b.client.SubmitAIPrompt(ctx, requestBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to submit AI prompt: %s", res.Status)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type updateMetadata struct {
|
|
version int
|
|
leaseToken string
|
|
messages []apitype.Message
|
|
}
|
|
|
|
func (b *cloudBackend) createAndStartUpdate(
|
|
ctx context.Context, action apitype.UpdateKind, stack backend.Stack,
|
|
op *backend.UpdateOperation, dryRun bool,
|
|
) (client.UpdateIdentifier, updateMetadata, error) {
|
|
// Once we start an update we want to keep the machine from sleeping.
|
|
stackRef := stack.Ref()
|
|
|
|
stackID, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return client.UpdateIdentifier{}, updateMetadata{}, err
|
|
}
|
|
if currentProjectContradictsWorkspace(op.Proj, stackID) {
|
|
return client.UpdateIdentifier{}, updateMetadata{}, fmt.Errorf(
|
|
"provided project name %q doesn't match Pulumi.yaml", stackID.Project)
|
|
}
|
|
metadata := apitype.UpdateMetadata{
|
|
Message: op.M.Message,
|
|
Environment: op.M.Environment,
|
|
}
|
|
update, updateDetails, err := b.client.CreateUpdate(
|
|
ctx, action, stackID, op.Proj, op.StackConfiguration.Config, metadata, op.Opts.Engine, dryRun)
|
|
if err != nil {
|
|
return client.UpdateIdentifier{}, updateMetadata{}, err
|
|
}
|
|
|
|
//
|
|
// TODO[pulumi-service#3745]: Move this to the plugin-gathering routine when we have a dedicated
|
|
// service API when for getting a list of the required policies to run.
|
|
//
|
|
// For now, this list is given to us when we start an update; yet, the list of analyzers to boot
|
|
// is given to us by CLI flag, and passed to the step generator (which lazily instantiates the
|
|
// plugins) via `op.Opts.Engine.Analyzers`. Since the "start update" API request is sent well
|
|
// after this field is populated, we instead populate the `RequiredPlugins` field here.
|
|
//
|
|
// Once this API is implemented, we can safely move these lines to the plugin-gathering code,
|
|
// which is much closer to being the "correct" place for this stuff.
|
|
//
|
|
for _, policy := range updateDetails.RequiredPolicies {
|
|
op.Opts.Engine.RequiredPolicies = append(
|
|
op.Opts.Engine.RequiredPolicies, newCloudRequiredPolicy(b.client, policy, update.Owner))
|
|
}
|
|
|
|
// Start the update. We use this opportunity to pass new tags to the service, to pick up any
|
|
// metadata changes.
|
|
tags, err := backend.GetMergedStackTags(ctx, stack, op.Root, op.Proj, op.StackConfiguration.Config)
|
|
if err != nil {
|
|
return client.UpdateIdentifier{}, updateMetadata{}, fmt.Errorf("getting stack tags: %w", err)
|
|
}
|
|
|
|
version, token, err := b.client.StartUpdate(ctx, update, tags)
|
|
if err != nil {
|
|
if err, ok := err.(*apitype.ErrorResponse); ok && err.Code == 409 {
|
|
conflict := backend.ConflictingUpdateError{Err: err}
|
|
return client.UpdateIdentifier{}, updateMetadata{}, conflict
|
|
}
|
|
return client.UpdateIdentifier{}, updateMetadata{}, err
|
|
}
|
|
// Any non-preview update will be considered part of the stack's update history.
|
|
if action != apitype.PreviewUpdate {
|
|
logging.V(7).Infof("Stack %s being updated to version %d", stackRef, version)
|
|
}
|
|
|
|
userName, _, _, err := b.CurrentUser()
|
|
if err != nil {
|
|
userName = "unknown"
|
|
}
|
|
// Check if the user's org (stack's owner) has Copilot enabled. If not, we don't show the link to Copilot.
|
|
isCopilotEnabled := updateDetails.IsCopilotIntegrationEnabled
|
|
copilotEnabledValueString := "is"
|
|
continuationString := ""
|
|
if isCopilotEnabled {
|
|
if env.SuppressCopilotLink.Value() {
|
|
// Copilot is enabled in user's org, but the environment variable to suppress the link to Copilot is set.
|
|
op.Opts.Display.ShowLinkToCopilot = false
|
|
continuationString = " but the environment variable PULUMI_SUPPRESS_COPILOT_LINK" +
|
|
" suppresses the link to Copilot in diagnostics"
|
|
}
|
|
} else {
|
|
op.Opts.Display.ShowLinkToCopilot = false
|
|
copilotEnabledValueString = "is not"
|
|
}
|
|
logging.V(7).Infof("Copilot in org '%s' %s enabled for user '%s'%s",
|
|
stackID.Owner, copilotEnabledValueString, userName, continuationString)
|
|
|
|
return update, updateMetadata{
|
|
version: version,
|
|
leaseToken: token,
|
|
messages: updateDetails.Messages,
|
|
}, nil
|
|
}
|
|
|
|
// apply actually performs the provided type of update on a stack hosted in the Pulumi Cloud.
|
|
func (b *cloudBackend) apply(
|
|
ctx context.Context, kind apitype.UpdateKind, stack backend.Stack,
|
|
op backend.UpdateOperation, opts backend.ApplierOptions,
|
|
events chan<- engine.Event,
|
|
) (*deploy.Plan, sdkDisplay.ResourceChanges, error) {
|
|
resetKeepRunning := nosleep.KeepRunning()
|
|
defer resetKeepRunning()
|
|
|
|
actionLabel := backend.ActionLabel(kind, opts.DryRun)
|
|
|
|
if !(op.Opts.Display.JSONDisplay || op.Opts.Display.Type == display.DisplayWatch) {
|
|
// Print a banner so it's clear this is going to the cloud.
|
|
fmt.Printf(op.Opts.Display.Color.Colorize(
|
|
colors.SpecHeadline+"%s (%s)"+colors.Reset+"\n\n"), actionLabel, stack.Ref())
|
|
}
|
|
|
|
// Create an update object to persist results.
|
|
update, updateMeta, err := b.createAndStartUpdate(ctx, kind, stack, &op, opts.DryRun)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Display messages from the backend if present.
|
|
if len(updateMeta.messages) > 0 {
|
|
for _, msg := range updateMeta.messages {
|
|
m := diag.RawMessage("", msg.Message)
|
|
switch msg.Severity {
|
|
case apitype.MessageSeverityError:
|
|
cmdutil.Diag().Errorf(m)
|
|
case apitype.MessageSeverityWarning:
|
|
cmdutil.Diag().Warningf(m)
|
|
case apitype.MessageSeverityInfo:
|
|
cmdutil.Diag().Infof(m)
|
|
default:
|
|
// Fallback on Info if we don't recognize the severity.
|
|
cmdutil.Diag().Infof(m)
|
|
logging.V(7).Infof("Unknown message severity: %s", msg.Severity)
|
|
}
|
|
}
|
|
fmt.Print("\n")
|
|
}
|
|
|
|
permalink := b.getPermalink(update, updateMeta.version, opts.DryRun)
|
|
return b.runEngineAction(ctx, kind, stack.Ref(), op, update, updateMeta.leaseToken, permalink, events, opts.DryRun)
|
|
}
|
|
|
|
// getPermalink returns a link to the update in the Pulumi Console.
|
|
func (b *cloudBackend) getPermalink(update client.UpdateIdentifier, version int, preview bool) string {
|
|
base := b.cloudConsoleStackPath(update.StackIdentifier)
|
|
if !preview {
|
|
return b.CloudConsoleURL(base, "updates", strconv.Itoa(version))
|
|
}
|
|
return b.CloudConsoleURL(base, "previews", update.UpdateID)
|
|
}
|
|
|
|
// query executes a query program against the resource outputs of a stack hosted in the Pulumi
|
|
// Cloud.
|
|
func (b *cloudBackend) query(ctx context.Context, op backend.QueryOperation,
|
|
callerEventsOpt chan<- engine.Event,
|
|
) error {
|
|
return backend.RunQuery(ctx, b, op, callerEventsOpt, b.newQuery)
|
|
}
|
|
|
|
func (b *cloudBackend) runEngineAction(
|
|
ctx context.Context, kind apitype.UpdateKind, stackRef backend.StackReference,
|
|
op backend.UpdateOperation, update client.UpdateIdentifier, token, permalink string,
|
|
callerEventsOpt chan<- engine.Event, dryRun bool,
|
|
) (*deploy.Plan, sdkDisplay.ResourceChanges, error) {
|
|
contract.Assertf(token != "", "persisted actions require a token")
|
|
u, err := b.newUpdate(ctx, stackRef, op, update, token)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// displayEvents renders the event to the console and Pulumi service. The processor for the
|
|
// will signal all events have been proceed when a value is written to the displayDone channel.
|
|
displayEvents := make(chan engine.Event)
|
|
displayDone := make(chan bool)
|
|
go u.RecordAndDisplayEvents(
|
|
backend.ActionLabel(kind, dryRun), kind, stackRef, op, permalink,
|
|
displayEvents, displayDone, op.Opts.Display, dryRun)
|
|
|
|
// The engineEvents channel receives all events from the engine, which we then forward onto other
|
|
// channels for actual processing. (displayEvents and callerEventsOpt.)
|
|
engineEvents := make(chan engine.Event)
|
|
eventsDone := make(chan bool)
|
|
go func() {
|
|
for e := range engineEvents {
|
|
displayEvents <- e
|
|
if callerEventsOpt != nil {
|
|
callerEventsOpt <- e
|
|
}
|
|
}
|
|
|
|
close(eventsDone)
|
|
}()
|
|
|
|
// We only need a snapshot manager if we're doing an update.
|
|
var snapshotManager *backend.SnapshotManager
|
|
if kind != apitype.PreviewUpdate && !dryRun {
|
|
persister := b.newSnapshotPersister(ctx, u.update, u.tokenSource)
|
|
snapshotManager = backend.NewSnapshotManager(persister, op.SecretsManager, u.GetTarget().Snapshot)
|
|
}
|
|
|
|
// Depending on the action, kick off the relevant engine activity. Note that we don't immediately check and
|
|
// return error conditions, because we will do so below after waiting for the display channels to close.
|
|
cancellationScope := op.Scopes.NewScope(engineEvents, dryRun)
|
|
engineCtx := &engine.Context{
|
|
Cancel: cancellationScope.Context(),
|
|
Events: engineEvents,
|
|
SnapshotManager: snapshotManager,
|
|
BackendClient: httpstateBackendClient{backend: backend.NewBackendClient(b, op.SecretsProvider)},
|
|
}
|
|
if parentSpan := opentracing.SpanFromContext(ctx); parentSpan != nil {
|
|
engineCtx.ParentSpan = parentSpan.Context()
|
|
}
|
|
|
|
var plan *deploy.Plan
|
|
var changes sdkDisplay.ResourceChanges
|
|
var updateErr error
|
|
switch kind {
|
|
case apitype.PreviewUpdate:
|
|
plan, changes, updateErr = engine.Update(u, engineCtx, op.Opts.Engine, true)
|
|
case apitype.UpdateUpdate:
|
|
plan, changes, updateErr = engine.Update(u, engineCtx, op.Opts.Engine, dryRun)
|
|
case apitype.ResourceImportUpdate:
|
|
_, changes, updateErr = engine.Import(u, engineCtx, op.Opts.Engine, op.Imports, dryRun)
|
|
case apitype.RefreshUpdate:
|
|
_, changes, updateErr = engine.Refresh(u, engineCtx, op.Opts.Engine, dryRun)
|
|
case apitype.DestroyUpdate:
|
|
_, changes, updateErr = engine.Destroy(u, engineCtx, op.Opts.Engine, dryRun)
|
|
case apitype.StackImportUpdate, apitype.RenameUpdate:
|
|
contract.Failf("unexpected %s event", kind)
|
|
default:
|
|
contract.Failf("Unrecognized update kind: %s", kind)
|
|
}
|
|
|
|
// Wait for dependent channels to finish processing engineEvents before closing.
|
|
<-displayDone
|
|
cancellationScope.Close() // Don't take any cancellations anymore, we're shutting down.
|
|
close(engineEvents)
|
|
if snapshotManager != nil {
|
|
err = snapshotManager.Close()
|
|
// If the snapshot manager failed to close, we should return that error.
|
|
// Even though all the parts of the operation have potentially succeeded, a
|
|
// snapshotting failure is likely to rear its head on the next
|
|
// operation/invocation (e.g. an invalid snapshot that fails integrity
|
|
// checks, or a failure to write that means the snapshot is incomplete).
|
|
// Reporting now should make debugging and reporting easier.
|
|
if err != nil {
|
|
return plan, changes, fmt.Errorf("writing snapshot: %w", err)
|
|
}
|
|
}
|
|
|
|
// Make sure that the goroutine writing to displayEvents and callerEventsOpt
|
|
// has exited before proceeding
|
|
<-eventsDone
|
|
close(displayEvents)
|
|
|
|
// Mark the update as complete.
|
|
status := apitype.UpdateStatusSucceeded
|
|
if updateErr != nil {
|
|
status = apitype.UpdateStatusFailed
|
|
}
|
|
completeErr := u.Complete(status)
|
|
if completeErr != nil {
|
|
updateErr = result.MergeBails(updateErr, fmt.Errorf("failed to complete update: %w", completeErr))
|
|
}
|
|
|
|
return plan, changes, updateErr
|
|
}
|
|
|
|
func (b *cloudBackend) CancelCurrentUpdate(ctx context.Context, stackRef backend.StackReference) error {
|
|
stackID, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stack, err := b.client.GetStack(ctx, stackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if stack.ActiveUpdate == "" {
|
|
return fmt.Errorf("stack %v has never been updated", stackRef)
|
|
}
|
|
|
|
// Compute the update identifier and attempt to cancel the update.
|
|
//
|
|
// NOTE: the update kind is not relevant; the same endpoint will work for updates of all kinds.
|
|
updateID := client.UpdateIdentifier{
|
|
StackIdentifier: stackID,
|
|
UpdateKind: apitype.UpdateUpdate,
|
|
UpdateID: stack.ActiveUpdate,
|
|
}
|
|
return b.client.CancelUpdate(ctx, updateID)
|
|
}
|
|
|
|
func (b *cloudBackend) GetHistory(
|
|
ctx context.Context,
|
|
stackRef backend.StackReference,
|
|
pageSize int,
|
|
page int,
|
|
) ([]backend.UpdateInfo, error) {
|
|
stack, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updates, err := b.client.GetStackUpdates(ctx, stack, pageSize, page)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get stack updates: %w", err)
|
|
}
|
|
|
|
// Convert apitype.UpdateInfo objects to the backend type.
|
|
beUpdates := slice.Prealloc[backend.UpdateInfo](len(updates))
|
|
for _, update := range updates {
|
|
// Convert types from the apitype package into their internal counterparts.
|
|
cfg, err := convertConfig(update.Config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("converting configuration: %w", err)
|
|
}
|
|
|
|
beUpdates = append(beUpdates, backend.UpdateInfo{
|
|
Version: update.Version,
|
|
Kind: update.Kind,
|
|
Message: update.Message,
|
|
Environment: update.Environment,
|
|
Config: cfg,
|
|
Result: backend.UpdateResult(update.Result),
|
|
StartTime: update.StartTime,
|
|
EndTime: update.EndTime,
|
|
ResourceChanges: convertResourceChanges(update.ResourceChanges),
|
|
})
|
|
}
|
|
|
|
return beUpdates, nil
|
|
}
|
|
|
|
func (b *cloudBackend) GetLatestConfiguration(ctx context.Context,
|
|
stack backend.Stack,
|
|
) (config.Map, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cfg, err := b.client.GetLatestConfiguration(ctx, stackID)
|
|
switch {
|
|
case err == client.ErrNoPreviousDeployment:
|
|
return nil, backend.ErrNoPreviousDeployment
|
|
case err != nil:
|
|
return nil, err
|
|
default:
|
|
return cfg, nil
|
|
}
|
|
}
|
|
|
|
// convertResourceChanges converts the apitype version of sdkDisplay.ResourceChanges into the internal version.
|
|
func convertResourceChanges(changes map[apitype.OpType]int) sdkDisplay.ResourceChanges {
|
|
b := make(sdkDisplay.ResourceChanges)
|
|
for k, v := range changes {
|
|
b[sdkDisplay.StepOp(k)] = v
|
|
}
|
|
return b
|
|
}
|
|
|
|
// convertResourceChanges converts the apitype version of config.Map into the internal version.
|
|
func convertConfig(apiConfig map[string]apitype.ConfigValue) (config.Map, error) {
|
|
c := make(config.Map)
|
|
for rawK, rawV := range apiConfig {
|
|
k, err := config.ParseKey(rawK)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if rawV.Object {
|
|
if rawV.Secret {
|
|
c[k] = config.NewSecureObjectValue(rawV.String)
|
|
} else {
|
|
c[k] = config.NewObjectValue(rawV.String)
|
|
}
|
|
} else {
|
|
if rawV.Secret {
|
|
c[k] = config.NewSecureValue(rawV.String)
|
|
} else {
|
|
c[k] = config.NewValue(rawV.String)
|
|
}
|
|
}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (b *cloudBackend) GetLogs(ctx context.Context,
|
|
secretsProvider secrets.Provider, stack backend.Stack, cfg backend.StackConfiguration,
|
|
logQuery operations.LogQuery,
|
|
) ([]operations.LogEntry, error) {
|
|
target, targetErr := b.getTarget(ctx, secretsProvider, stack.Ref(), cfg.Config, cfg.Decrypter)
|
|
if targetErr != nil {
|
|
return nil, targetErr
|
|
}
|
|
return diy.GetLogsForTarget(target, logQuery)
|
|
}
|
|
|
|
// ExportDeployment exports a deployment _from_ the backend service.
|
|
// This will return the stack state that was being stored on the backend service.
|
|
func (b *cloudBackend) ExportDeployment(ctx context.Context,
|
|
stack backend.Stack,
|
|
) (*apitype.UntypedDeployment, error) {
|
|
return b.exportDeployment(ctx, stack.Ref(), nil /* latest */)
|
|
}
|
|
|
|
func (b *cloudBackend) ExportDeploymentForVersion(
|
|
ctx context.Context, stack backend.Stack, version string,
|
|
) (*apitype.UntypedDeployment, error) {
|
|
// The Pulumi Console defines versions as a positive integer. Parse the provided version string and
|
|
// ensure it is valid.
|
|
//
|
|
// The first stack update version is 1, and monotonically increasing from there.
|
|
versionNumber, err := strconv.Atoi(version)
|
|
if err != nil || versionNumber <= 0 {
|
|
return nil, fmt.Errorf(
|
|
"%q is not a valid stack version. It should be a positive integer",
|
|
version)
|
|
}
|
|
|
|
return b.exportDeployment(ctx, stack.Ref(), &versionNumber)
|
|
}
|
|
|
|
// exportDeployment exports the checkpoint file for a stack, optionally getting a previous version.
|
|
func (b *cloudBackend) exportDeployment(
|
|
ctx context.Context, stackRef backend.StackReference, version *int,
|
|
) (*apitype.UntypedDeployment, error) {
|
|
stack, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
deployment, err := b.client.ExportStackDeployment(ctx, stack, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &deployment, nil
|
|
}
|
|
|
|
// ImportDeployment imports a deployment _into_ the backend. At the end of this operation,
|
|
// the deployment provided will be the current state stored on the backend service.
|
|
func (b *cloudBackend) ImportDeployment(ctx context.Context, stack backend.Stack,
|
|
deployment *apitype.UntypedDeployment,
|
|
) error {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
update, err := b.client.ImportStackDeployment(ctx, stackID, deployment)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Wait for the import to complete, which also polls and renders event output to STDOUT.
|
|
status, err := b.waitForUpdate(
|
|
ctx, backend.ActionLabel(apitype.StackImportUpdate, false /*dryRun*/), update,
|
|
display.Options{Color: colors.Always})
|
|
if err != nil {
|
|
return fmt.Errorf("waiting for import: %w", err)
|
|
} else if status != apitype.StatusSucceeded {
|
|
return fmt.Errorf("import unsuccessful: status %v", status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var projectNameCleanRegexp = regexp.MustCompile("[^a-zA-Z0-9-_.]")
|
|
|
|
// cleanProjectName replaces undesirable characters in project names with hyphens. At some point, these restrictions
|
|
// will be further enforced by the service, but for now we need to ensure that if we are making a rest call, we
|
|
// do this cleaning on our end.
|
|
func cleanProjectName(projectName string) string {
|
|
return projectNameCleanRegexp.ReplaceAllString(projectName, "-")
|
|
}
|
|
|
|
// getCloudStackIdentifier converts a backend.StackReference to a client.StackIdentifier for the same logical stack
|
|
func (b *cloudBackend) getCloudStackIdentifier(stackRef backend.StackReference) (client.StackIdentifier, error) {
|
|
cloudBackendStackRef, ok := stackRef.(cloudBackendReference)
|
|
if !ok {
|
|
return client.StackIdentifier{}, errors.New("bad stack reference type")
|
|
}
|
|
|
|
return client.StackIdentifier{
|
|
Owner: cloudBackendStackRef.owner,
|
|
Project: cleanProjectName(string(cloudBackendStackRef.project)),
|
|
Stack: cloudBackendStackRef.name,
|
|
}, nil
|
|
}
|
|
|
|
// Client returns a client object that may be used to interact with this backend.
|
|
func (b *cloudBackend) Client() *client.Client {
|
|
return b.client
|
|
}
|
|
|
|
type DisplayEventType string
|
|
|
|
const (
|
|
UpdateEvent DisplayEventType = "UpdateEvent"
|
|
ShutdownEvent DisplayEventType = "Shutdown"
|
|
)
|
|
|
|
type displayEvent struct {
|
|
Kind DisplayEventType
|
|
Payload interface{}
|
|
}
|
|
|
|
// waitForUpdate waits for the current update of a Pulumi program to reach a terminal state. Returns the
|
|
// final state. "path" is the URL endpoint to poll for updates.
|
|
func (b *cloudBackend) waitForUpdate(ctx context.Context, actionLabel string, update client.UpdateIdentifier,
|
|
displayOpts display.Options,
|
|
) (apitype.UpdateStatus, error) {
|
|
events, done := make(chan displayEvent), make(chan bool)
|
|
defer func() {
|
|
events <- displayEvent{Kind: ShutdownEvent, Payload: nil}
|
|
<-done
|
|
close(events)
|
|
close(done)
|
|
}()
|
|
go displayEvents(strings.ToLower(actionLabel), events, done, displayOpts)
|
|
|
|
// The UpdateEvents API returns a continuation token to only get events after the previous call.
|
|
var continuationToken *string
|
|
for {
|
|
// Query for the latest update results, including log entries so we can provide active status updates.
|
|
_, results, err := retry.Until(context.Background(), retry.Acceptor{
|
|
Accept: func(try int, nextRetryTime time.Duration) (bool, interface{}, error) {
|
|
return b.tryNextUpdate(ctx, update, continuationToken, try, nextRetryTime)
|
|
},
|
|
})
|
|
if err != nil {
|
|
return apitype.StatusFailed, err
|
|
}
|
|
|
|
// We got a result, print it out.
|
|
updateResults := results.(apitype.UpdateResults)
|
|
for _, event := range updateResults.Events {
|
|
events <- displayEvent{Kind: UpdateEvent, Payload: event}
|
|
}
|
|
|
|
continuationToken = updateResults.ContinuationToken
|
|
// A nil continuation token means there are no more events to read and the update has finished.
|
|
if continuationToken == nil {
|
|
return updateResults.Status, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func displayEvents(action string, events <-chan displayEvent, done chan<- bool, opts display.Options) {
|
|
prefix := fmt.Sprintf("%s%s...", cmdutil.EmojiOr("✨ ", "@ "), action)
|
|
spinner, ticker := cmdutil.NewSpinnerAndTicker(prefix, nil, opts.Color, 8 /*timesPerSecond*/, opts.SuppressProgress)
|
|
|
|
defer func() {
|
|
spinner.Reset()
|
|
ticker.Stop()
|
|
done <- true
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
spinner.Tick()
|
|
case event := <-events:
|
|
if event.Kind == ShutdownEvent {
|
|
return
|
|
}
|
|
|
|
// Pluck out the string.
|
|
payload := event.Payload.(apitype.UpdateEvent)
|
|
if raw, ok := payload.Fields["text"]; ok && raw != nil {
|
|
if text, ok := raw.(string); ok {
|
|
text = opts.Color.Colorize(text)
|
|
|
|
// Choose the stream to write to (by default stdout).
|
|
var stream io.Writer
|
|
if payload.Kind == apitype.StderrEvent {
|
|
stream = os.Stderr
|
|
} else {
|
|
stream = os.Stdout
|
|
}
|
|
|
|
if text != "" {
|
|
spinner.Reset()
|
|
fmt.Fprint(stream, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tryNextUpdate tries to get the next update for a Pulumi program. This may time or error out, which results in a
|
|
// false returned in the first return value. If a non-nil error is returned, this operation should fail.
|
|
func (b *cloudBackend) tryNextUpdate(ctx context.Context, update client.UpdateIdentifier, continuationToken *string,
|
|
try int, nextRetryTime time.Duration,
|
|
) (bool, interface{}, error) {
|
|
// If there is no error, we're done.
|
|
results, err := b.client.GetUpdateEvents(ctx, update, continuationToken)
|
|
if err == nil {
|
|
return true, results, nil
|
|
}
|
|
|
|
// There are three kinds of errors we might see:
|
|
// 1) Expected HTTP errors (like timeouts); silently retry.
|
|
// 2) Unexpected HTTP errors (like Unauthorized, etc); exit with an error.
|
|
// 3) Anything else; this could be any number of things, including transient errors (flaky network).
|
|
// In this case, we warn the user and keep retrying; they can ^C if it's not transient.
|
|
warn := true
|
|
if errResp, ok := err.(*apitype.ErrorResponse); ok {
|
|
if errResp.Code == 504 {
|
|
// If our request to the Pulumi Service returned a 504 (Gateway Timeout), ignore it and keep
|
|
// continuing. The sole exception is if we've done this 10 times. At that point, we will have
|
|
// been waiting for many seconds, and want to let the user know something might be wrong.
|
|
if try < 10 {
|
|
warn = false
|
|
}
|
|
logging.V(3).Infof("Expected %s HTTP %d error after %d retries (retrying): %v",
|
|
b.CloudURL(), errResp.Code, try, err)
|
|
} else {
|
|
// Otherwise, we will issue an error.
|
|
logging.V(3).Infof("Unexpected %s HTTP %d error after %d retries (erroring): %v",
|
|
b.CloudURL(), errResp.Code, try, err)
|
|
return false, nil, err
|
|
}
|
|
} else {
|
|
logging.V(3).Infof("Unexpected %s error after %d retries (retrying): %v", b.CloudURL(), try, err)
|
|
}
|
|
|
|
// Issue a warning if appropriate.
|
|
if warn {
|
|
b.d.Warningf(diag.Message("" /*urn*/, "error querying update status: %v"), err)
|
|
b.d.Warningf(diag.Message("" /*urn*/, "retrying in %vs... ^C to stop (this will not cancel the update)"),
|
|
nextRetryTime.Seconds())
|
|
}
|
|
|
|
return false, nil, nil
|
|
}
|
|
|
|
// IsValidAccessToken tries to use the provided Pulumi access token and returns if it is accepted
|
|
// or not. Returns error on any unexpected error.
|
|
func IsValidAccessToken(ctx context.Context, cloudURL string,
|
|
insecure bool, accessToken string,
|
|
) (bool, string, []string, *workspace.TokenInformation, error) {
|
|
// Make a request to get the authenticated user. If it returns a successful response,
|
|
// we know the access token is legit. We also parse the response as JSON and confirm
|
|
// it has a githubLogin field that is non-empty (like the Pulumi Service would return).
|
|
username, organizations, tokenInfo, err := client.NewClient(cloudURL, accessToken,
|
|
insecure, cmdutil.Diag()).GetPulumiAccountDetails(ctx)
|
|
if err != nil {
|
|
if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == 401 {
|
|
return false, "", nil, nil, nil
|
|
}
|
|
return false, "", nil, nil, fmt.Errorf("getting user info from %v: %w", cloudURL, err)
|
|
}
|
|
|
|
return true, username, organizations, tokenInfo, nil
|
|
}
|
|
|
|
// UpdateStackTags updates the stacks's tags, replacing all existing tags.
|
|
func (b *cloudBackend) UpdateStackTags(ctx context.Context,
|
|
stack backend.Stack, tags map[apitype.StackTagName]string,
|
|
) error {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return b.client.UpdateStackTags(ctx, stackID, tags)
|
|
}
|
|
|
|
func (b *cloudBackend) EncryptStackDeploymentSettingsSecret(ctx context.Context,
|
|
stack backend.Stack, secret string,
|
|
) (*apitype.SecretValue, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return b.client.EncryptStackDeploymentSettingsSecret(ctx, stackID, secret)
|
|
}
|
|
|
|
func (b *cloudBackend) UpdateStackDeploymentSettings(ctx context.Context, stack backend.Stack,
|
|
deployment apitype.DeploymentSettings,
|
|
) error {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return b.client.UpdateStackDeploymentSettings(ctx, stackID, deployment)
|
|
}
|
|
|
|
func (b *cloudBackend) DestroyStackDeploymentSettings(ctx context.Context, stack backend.Stack) error {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return b.client.DestroyStackDeploymentSettings(ctx, stackID)
|
|
}
|
|
|
|
func (b *cloudBackend) GetGHAppIntegration(
|
|
ctx context.Context, stack backend.Stack,
|
|
) (*apitype.GitHubAppIntegration, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return b.client.GetGHAppIntegration(ctx, stackID)
|
|
}
|
|
|
|
func (b *cloudBackend) GetStackDeploymentSettings(ctx context.Context,
|
|
stack backend.Stack,
|
|
) (*apitype.DeploymentSettings, error) {
|
|
stackID, err := b.getCloudStackIdentifier(stack.Ref())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return b.client.GetStackDeploymentSettings(ctx, stackID)
|
|
}
|
|
|
|
func (b *cloudBackend) RunDeployment(ctx context.Context, stackRef backend.StackReference,
|
|
req apitype.CreateDeploymentRequest, opts display.Options, deploymentInitiator string,
|
|
suppressStreamLogs bool,
|
|
) error {
|
|
stackID, err := b.getCloudStackIdentifier(stackRef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := b.client.CreateDeployment(ctx, stackID, req, deploymentInitiator)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := resp.ID
|
|
|
|
fmt.Print(opts.Color.Colorize(colors.SpecHeadline + "Preparing deployment..." + colors.Reset + "\n\n"))
|
|
|
|
if !opts.SuppressPermalink && !opts.JSONDisplay && resp.ConsoleURL != "" {
|
|
fmt.Printf(opts.Color.Colorize(
|
|
colors.SpecHeadline+"View Live: "+
|
|
colors.Underline+colors.BrightBlue+"%s"+colors.Reset+"\n"), resp.ConsoleURL)
|
|
}
|
|
|
|
if suppressStreamLogs {
|
|
return nil
|
|
}
|
|
|
|
token := ""
|
|
for {
|
|
logs, err := b.client.GetDeploymentLogs(ctx, stackID, id, token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, l := range logs.Lines {
|
|
if l.Header != "" {
|
|
fmt.Print(opts.Color.Colorize(
|
|
"\n" + colors.SpecHeadline + l.Header + ":" + colors.Reset + "\n"))
|
|
|
|
// If we see it's a Pulumi operation, rather than outputting the deployment logs,
|
|
// find the associated update and show the normal rendering of the operation's events.
|
|
if l.Header == fmt.Sprintf("pulumi %v", req.Op) {
|
|
fmt.Println()
|
|
return b.showDeploymentEvents(ctx, stackID, apitype.UpdateKind(req.Op), id, opts)
|
|
}
|
|
} else {
|
|
fmt.Print(l.Line)
|
|
}
|
|
}
|
|
|
|
// If there are no more logs for the deployment and the deployment has finished or we're not following,
|
|
// then we're done.
|
|
if logs.NextToken == "" {
|
|
break
|
|
}
|
|
|
|
// Otherwise, update the token, sleep, and loop around.
|
|
if logs.NextToken == token {
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
token = logs.NextToken
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *cloudBackend) showDeploymentEvents(ctx context.Context, stackID client.StackIdentifier,
|
|
kind apitype.UpdateKind, deploymentID string, opts display.Options,
|
|
) error {
|
|
getUpdateID := func() (string, int, error) {
|
|
for tries := 0; tries < 10; tries++ {
|
|
updates, err := b.client.GetDeploymentUpdates(ctx, stackID, deploymentID)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
if len(updates) > 0 {
|
|
return updates[0].UpdateID, updates[0].Version, nil
|
|
}
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
return "", 0, fmt.Errorf("could not find update associated with deployment %s", deploymentID)
|
|
}
|
|
|
|
updateID, version, err := getUpdateID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dryRun := kind == apitype.PreviewUpdate
|
|
update := client.UpdateIdentifier{
|
|
StackIdentifier: stackID,
|
|
UpdateKind: kind,
|
|
UpdateID: updateID,
|
|
}
|
|
|
|
events := make(chan engine.Event) // Note: unbuffered, but we assume it won't matter in practice.
|
|
done := make(chan bool)
|
|
|
|
// Timings do not display correctly when rendering remote events, so suppress showing them.
|
|
opts.SuppressTimings = true
|
|
|
|
permalink := b.getPermalink(update, version, dryRun)
|
|
go display.ShowEvents(
|
|
backend.ActionLabel(kind, dryRun), kind, stackID.Stack, tokens.PackageName(stackID.Project),
|
|
permalink, events, done, opts, dryRun)
|
|
|
|
// The UpdateEvents API returns a continuation token to only get events after the previous call.
|
|
var continuationToken *string
|
|
var lastEvent engine.Event
|
|
for {
|
|
resp, err := b.client.GetUpdateEngineEvents(ctx, update, continuationToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, jsonEvent := range resp.Events {
|
|
event, err := display.ConvertJSONEvent(jsonEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lastEvent = event
|
|
events <- event
|
|
}
|
|
|
|
continuationToken = resp.ContinuationToken
|
|
// A nil continuation token means there are no more events to read and the update has finished.
|
|
if continuationToken == nil {
|
|
// If the event stream does not terminate with a cancel event, synthesize one here.
|
|
if lastEvent.Type != engine.CancelEvent {
|
|
events <- engine.NewCancelEvent()
|
|
}
|
|
|
|
close(events)
|
|
<-done
|
|
return nil
|
|
}
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
type httpstateBackendClient struct {
|
|
backend deploy.BackendClient
|
|
}
|
|
|
|
func (c httpstateBackendClient) GetStackOutputs(ctx context.Context, name string) (resource.PropertyMap, error) {
|
|
// When using the cloud backend, require that stack references are fully qualified so they
|
|
// look like "<org>/<project>/<stack>"
|
|
if strings.Count(name, "/") != 2 {
|
|
return nil, errors.New("a stack reference's name should be of the form '<organization>/<project>/<stack>'. " +
|
|
"See https://www.pulumi.com/docs/using-pulumi/stack-outputs-and-references/#using-stack-references " +
|
|
"for more information.")
|
|
}
|
|
|
|
return c.backend.GetStackOutputs(ctx, name)
|
|
}
|
|
|
|
func (c httpstateBackendClient) GetStackResourceOutputs(
|
|
ctx context.Context, name string,
|
|
) (resource.PropertyMap, error) {
|
|
return c.backend.GetStackResourceOutputs(ctx, name)
|
|
}
|
|
|
|
// Represents feature-detected capabilities of the service the backend is connected to.
|
|
type capabilities struct {
|
|
// If non-nil, indicates that delta checkpoint updates are supported.
|
|
deltaCheckpointUpdates *apitype.DeltaCheckpointUploadsConfigV2
|
|
}
|
|
|
|
// Builds a lazy wrapper around doDetectCapabilities.
|
|
func detectCapabilities(d diag.Sink, client *client.Client) func(ctx context.Context) capabilities {
|
|
var once sync.Once
|
|
var caps capabilities
|
|
done := make(chan struct{})
|
|
get := func(ctx context.Context) capabilities {
|
|
once.Do(func() {
|
|
caps = doDetectCapabilities(ctx, d, client)
|
|
close(done)
|
|
})
|
|
<-done
|
|
return caps
|
|
}
|
|
return get
|
|
}
|
|
|
|
func doDetectCapabilities(ctx context.Context, d diag.Sink, client *client.Client) capabilities {
|
|
resp, err := client.GetCapabilities(ctx)
|
|
if err != nil {
|
|
d.Warningf(diag.Message("" /*urn*/, "failed to get capabilities: %v"), err)
|
|
return capabilities{}
|
|
}
|
|
caps, err := decodeCapabilities(resp.Capabilities)
|
|
if err != nil {
|
|
d.Warningf(diag.Message("" /*urn*/, "failed to decode capabilities: %v"), err)
|
|
return capabilities{}
|
|
}
|
|
|
|
// Allow users to opt out of deltaCheckpointUpdates even if the backend indicates it should be used. This
|
|
// remains necessary while PULUMI_OPTIMIZED_CHECKPOINT_PATCH has higher memory requirements on the client and
|
|
// may cause out-of-memory issues in constrained environments.
|
|
switch strings.ToLower(os.Getenv("PULUMI_OPTIMIZED_CHECKPOINT_PATCH")) {
|
|
case "0", "false":
|
|
caps.deltaCheckpointUpdates = nil
|
|
}
|
|
|
|
return caps
|
|
}
|
|
|
|
func decodeCapabilities(wireLevel []apitype.APICapabilityConfig) (capabilities, error) {
|
|
var parsed capabilities
|
|
for _, entry := range wireLevel {
|
|
switch entry.Capability {
|
|
case apitype.DeltaCheckpointUploads:
|
|
var upcfg apitype.DeltaCheckpointUploadsConfigV1
|
|
if err := json.Unmarshal(entry.Configuration, &upcfg); err != nil {
|
|
msg := "decoding DeltaCheckpointUploadsConfig returned %w"
|
|
return capabilities{}, fmt.Errorf(msg, err)
|
|
}
|
|
parsed.deltaCheckpointUpdates = &apitype.DeltaCheckpointUploadsConfigV2{
|
|
CheckpointCutoffSizeBytes: upcfg.CheckpointCutoffSizeBytes,
|
|
}
|
|
case apitype.DeltaCheckpointUploadsV2:
|
|
if entry.Version == 2 {
|
|
var upcfg apitype.DeltaCheckpointUploadsConfigV2
|
|
if err := json.Unmarshal(entry.Configuration, &upcfg); err != nil {
|
|
msg := "decoding DeltaCheckpointUploadsConfigV2 returned %w"
|
|
return capabilities{}, fmt.Errorf(msg, err)
|
|
}
|
|
parsed.deltaCheckpointUpdates = &upcfg
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func (b *cloudBackend) DefaultSecretManager(*workspace.ProjectStack) (secrets.Manager, error) {
|
|
// The default secrets manager for a cloud-backed stack is a cloud secrets manager, which is inherently
|
|
// stack-specific. Thus at the backend level we return nil, deferring to Stack.DefaultSecretManager when the stack has
|
|
// been created.
|
|
return nil, nil
|
|
}
|