pulumi/pkg/backend/httpstate/client/client.go
2019-08-23 09:09:54 +02:00

679 lines
23 KiB
Go

// Copyright 2016-2018, 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 client
import (
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"path"
"time"
"github.com/pulumi/pulumi/pkg/resource/plugin"
"github.com/blang/semver"
"github.com/pkg/errors"
"github.com/pulumi/pulumi/pkg/apitype"
"github.com/pulumi/pulumi/pkg/diag"
"github.com/pulumi/pulumi/pkg/diag/colors"
"github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pulumi/pulumi/pkg/util/validation"
"github.com/pulumi/pulumi/pkg/workspace"
)
// Client provides a slim wrapper around the Pulumi HTTP/REST API.
type Client struct {
apiURL string
apiToken apiAccessToken
apiUser string
diag diag.Sink
}
// NewClient creates a new Pulumi API client with the given URL and API token.
func NewClient(apiURL, apiToken string, d diag.Sink) *Client {
return &Client{
apiURL: apiURL,
apiToken: apiAccessToken(apiToken),
diag: d,
}
}
// URL returns the URL of the API endpoint this client interacts with
func (pc *Client) URL() string {
return pc.apiURL
}
// restCall makes a REST-style request to the Pulumi API using the given method, path, query object, and request
// object. If a response object is provided, the server's response is deserialized into that object.
func (pc *Client) restCall(ctx context.Context, method, path string, queryObj, reqObj, respObj interface{}) error {
return pulumiRESTCall(ctx, pc.diag, pc.apiURL, method, path, queryObj, reqObj, respObj, pc.apiToken, httpCallOptions{})
}
// restCall makes a REST-style request to the Pulumi API using the given method, path, query object, and request
// object. If a response object is provided, the server's response is deserialized into that object.
func (pc *Client) restCallWithOptions(ctx context.Context, method, path string, queryObj, reqObj,
respObj interface{}, opts httpCallOptions) error {
return pulumiRESTCall(ctx, pc.diag, pc.apiURL, method, path, queryObj, reqObj, respObj, pc.apiToken, opts)
}
// updateRESTCall makes a REST-style request to the Pulumi API using the given method, path, query object, and request
// object. The call is authorized with the indicated update token. If a response object is provided, the server's
// response is deserialized into that object.
func (pc *Client) updateRESTCall(ctx context.Context, method, path string, queryObj, reqObj, respObj interface{},
token updateAccessToken, httpOptions httpCallOptions) error {
return pulumiRESTCall(ctx, pc.diag, pc.apiURL, method, path, queryObj, reqObj, respObj, token, httpOptions)
}
// getProjectPath returns the API path for the given owner and the given project name joined with path separators
// and appended to the stack root.
func getProjectPath(owner string, projectName string) string {
return fmt.Sprintf("/api/stacks/%s/%s", owner, projectName)
}
// getStackPath returns the API path to for the given stack with the given components joined with path separators
// and appended to the stack root.
func getStackPath(stack StackIdentifier, components ...string) string {
prefix := fmt.Sprintf("/api/stacks/%s/%s/%s", stack.Owner, stack.Project, stack.Stack)
return path.Join(append([]string{prefix}, components...)...)
}
// publishPolicyPackPath returns the API path to for the given organization with the given
// components joined with path separators and appended to the organization root.
func publishPolicyPackPath(orgName string) string {
return fmt.Sprintf("/api/orgs/%s/policypacks", orgName)
}
// appyPolicyPackPath returns the path for an API call to the Pulumi service to apply a PolicyPack
// to a Pulumi organization.
func applyPolicyPackPath(orgName, policyPackName string, version int) string {
return fmt.Sprintf(
"/api/orgs/%s/policypacks/%s/versions/%d/apply", orgName, policyPackName, version)
}
// publishPolicyPackPublishComplete returns the path for an API call to signal to the Pulumi service
// that a PolicyPack to a Pulumi organization.
func publishPolicyPackPublishComplete(orgName, policyPackName string, version int) string {
return fmt.Sprintf(
"/api/orgs/%s/policypacks/%s/versions/%d/complete", orgName, policyPackName, version)
}
// getUpdatePath returns the API path to for the given stack with the given components joined with path separators
// and appended to the update root.
func getUpdatePath(update UpdateIdentifier, components ...string) string {
components = append([]string{string(apitype.UpdateUpdate), update.UpdateID}, components...)
return getStackPath(update.StackIdentifier, components...)
}
// GetPulumiAccountName returns the user implied by the API token associated with this client.
func (pc *Client) GetPulumiAccountName(ctx context.Context) (string, error) {
if pc.apiUser == "" {
resp := struct {
GitHubLogin string `json:"githubLogin"`
}{}
if err := pc.restCall(ctx, "GET", "/api/user", nil, nil, &resp); err != nil {
return "", err
}
if resp.GitHubLogin == "" {
return "", errors.New("unexpected response from server")
}
pc.apiUser = resp.GitHubLogin
}
return pc.apiUser, nil
}
// GetCLIVersionInfo asks the service for information about versions of the CLI (the newest version as well as the
// oldest version before the CLI should warn about an upgrade).
func (pc *Client) GetCLIVersionInfo(ctx context.Context) (semver.Version, semver.Version, error) {
var versionInfo apitype.CLIVersionResponse
if err := pc.restCall(ctx, "GET", "/api/cli/version", nil, nil, &versionInfo); err != nil {
return semver.Version{}, semver.Version{}, err
}
latestSem, err := semver.ParseTolerant(versionInfo.LatestVersion)
if err != nil {
return semver.Version{}, semver.Version{}, err
}
oldestSem, err := semver.ParseTolerant(versionInfo.OldestWithoutWarning)
if err != nil {
return semver.Version{}, semver.Version{}, err
}
return latestSem, oldestSem, nil
}
// ListStacksFilter describes optional filters when listing stacks.
type ListStacksFilter struct {
Project *string
Organization *string
TagName *string
TagValue *string
}
// ListStacks lists all stacks the current user has access to, optionally filtered by project.
func (pc *Client) ListStacks(
ctx context.Context, filter ListStacksFilter) ([]apitype.StackSummary, error) {
queryFilter := struct {
Project *string `url:"project,omitempty"`
Organization *string `url:"organization,omitempty"`
TagName *string `url:"tagName,omitempty"`
TagValue *string `url:"tagValue,omitempty"`
}{
Project: filter.Project,
Organization: filter.Organization,
TagName: filter.TagName,
TagValue: filter.TagValue,
}
var resp apitype.ListStacksResponse
if err := pc.restCall(ctx, "GET", "/api/user/stacks", queryFilter, nil, &resp); err != nil {
return nil, err
}
return resp.Stacks, nil
}
var (
// ErrNoPreviousDeployment is returned when there isn't a previous deployment.
ErrNoPreviousDeployment = errors.New("no previous deployment")
)
// GetLatestConfiguration returns the configuration for the latest deployment of a given stack.
func (pc *Client) GetLatestConfiguration(ctx context.Context, stackID StackIdentifier) (config.Map, error) {
latest := struct {
Info apitype.UpdateInfo `json:"info,allowEmpty"`
}{}
if err := pc.restCall(ctx, "GET", getStackPath(stackID, "updates", "latest"), nil, nil, &latest); err != nil {
if restErr, ok := err.(*apitype.ErrorResponse); ok {
if restErr.Code == http.StatusNotFound {
return nil, ErrNoPreviousDeployment
}
}
return nil, err
}
cfg := make(config.Map)
for k, v := range latest.Info.Config {
newKey, err := config.ParseKey(k)
if err != nil {
return nil, err
}
if v.Secret {
cfg[newKey] = config.NewSecureValue(v.String)
} else {
cfg[newKey] = config.NewValue(v.String)
}
}
return cfg, nil
}
// DoesProjectExist returns true if a project with the given name exists, or false otherwise.
func (pc *Client) DoesProjectExist(ctx context.Context, owner string, projectName string) (bool, error) {
if err := pc.restCall(ctx, "HEAD", getProjectPath(owner, projectName), nil, nil, nil); err != nil {
// If this was a 404, return false - project not found.
if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == http.StatusNotFound {
return false, nil
}
return false, err
}
return true, nil
}
// GetStack retrieves the stack with the given name.
func (pc *Client) GetStack(ctx context.Context, stackID StackIdentifier) (apitype.Stack, error) {
var stack apitype.Stack
if err := pc.restCall(ctx, "GET", getStackPath(stackID), nil, nil, &stack); err != nil {
return apitype.Stack{}, err
}
return stack, nil
}
// CreateStack creates a stack with the given cloud and stack name in the scope of the indicated project.
func (pc *Client) CreateStack(
ctx context.Context, stackID StackIdentifier, tags map[apitype.StackTagName]string) (apitype.Stack, error) {
// Validate names and tags.
if err := validation.ValidateStackProperties(stackID.Stack, tags); err != nil {
return apitype.Stack{}, errors.Wrap(err, "validating stack properties")
}
stack := apitype.Stack{
StackName: tokens.QName(stackID.Stack),
ProjectName: stackID.Project,
OrgName: stackID.Owner,
Tags: tags,
}
createStackReq := apitype.CreateStackRequest{
StackName: stackID.Stack,
Tags: tags,
}
var createStackResp apitype.CreateStackResponse
endpoint := fmt.Sprintf("/api/stacks/%s/%s", stackID.Owner, stackID.Project)
if err := pc.restCall(
ctx, "POST", endpoint, nil, &createStackReq, &createStackResp); err != nil {
return apitype.Stack{}, err
}
return stack, nil
}
// DeleteStack deletes the indicated stack. If force is true, the stack is deleted even if it contains resources.
func (pc *Client) DeleteStack(ctx context.Context, stack StackIdentifier, force bool) (bool, error) {
path := getStackPath(stack)
queryObj := struct {
Force bool `url:"force"`
}{
Force: force,
}
err := pc.restCall(ctx, "DELETE", path, queryObj, nil, nil)
return isStackHasResourcesError(err), err
}
func isStackHasResourcesError(err error) bool {
if err == nil {
return false
}
errRsp, ok := err.(*apitype.ErrorResponse)
if !ok {
return false
}
return errRsp.Code == 400 && errRsp.Message == "Bad Request: Stack still contains resources."
}
// EncryptValue encrypts a plaintext value in the context of the indicated stack.
func (pc *Client) EncryptValue(ctx context.Context, stack StackIdentifier, plaintext []byte) ([]byte, error) {
req := apitype.EncryptValueRequest{Plaintext: plaintext}
var resp apitype.EncryptValueResponse
if err := pc.restCall(ctx, "POST", getStackPath(stack, "encrypt"), nil, &req, &resp); err != nil {
return nil, err
}
return resp.Ciphertext, nil
}
// DecryptValue decrypts a ciphertext value in the context of the indicated stack.
func (pc *Client) DecryptValue(ctx context.Context, stack StackIdentifier, ciphertext []byte) ([]byte, error) {
req := apitype.DecryptValueRequest{Ciphertext: ciphertext}
var resp apitype.DecryptValueResponse
if err := pc.restCall(ctx, "POST", getStackPath(stack, "decrypt"), nil, &req, &resp); err != nil {
return nil, err
}
return resp.Plaintext, nil
}
// GetStackUpdates returns all updates to the indicated stack.
func (pc *Client) GetStackUpdates(ctx context.Context, stack StackIdentifier) ([]apitype.UpdateInfo, error) {
var response apitype.GetHistoryResponse
if err := pc.restCall(ctx, "GET", getStackPath(stack, "updates"), nil, nil, &response); err != nil {
return nil, err
}
return response.Updates, nil
}
// ExportStackDeployment exports the indicated stack's deployment as a raw JSON message.
func (pc *Client) ExportStackDeployment(ctx context.Context,
stack StackIdentifier) (apitype.UntypedDeployment, error) {
var resp apitype.ExportStackResponse
if err := pc.restCall(ctx, "GET", getStackPath(stack, "export"), nil, nil, &resp); err != nil {
return apitype.UntypedDeployment{}, err
}
return apitype.UntypedDeployment(resp), nil
}
// ImportStackDeployment imports a new deployment into the indicated stack.
func (pc *Client) ImportStackDeployment(ctx context.Context, stack StackIdentifier,
deployment *apitype.UntypedDeployment) (UpdateIdentifier, error) {
var resp apitype.ImportStackResponse
if err := pc.restCall(ctx, "POST", getStackPath(stack, "import"), nil, deployment, &resp); err != nil {
return UpdateIdentifier{}, err
}
return UpdateIdentifier{
StackIdentifier: stack,
UpdateKind: apitype.UpdateUpdate,
UpdateID: resp.UpdateID,
}, nil
}
// CreateUpdate creates a new update for the indicated stack with the given kind and assorted options. If the update
// requires that the Pulumi program is uploaded, the provided getContents callback will be invoked to fetch the
// contents of the Pulumi program.
func (pc *Client) CreateUpdate(
ctx context.Context, kind apitype.UpdateKind, stack StackIdentifier, proj *workspace.Project,
cfg config.Map, m apitype.UpdateMetadata, opts engine.UpdateOptions,
dryRun bool) (UpdateIdentifier, []apitype.RequiredPolicy, error) {
// First create the update program request.
wireConfig := make(map[string]apitype.ConfigValue)
for k, cv := range cfg {
v, err := cv.Value(config.NopDecrypter)
contract.AssertNoError(err)
wireConfig[k.String()] = apitype.ConfigValue{
String: v,
Secret: cv.Secure(),
}
}
description := ""
if proj.Description != nil {
description = *proj.Description
}
updateRequest := apitype.UpdateProgramRequest{
Name: string(proj.Name),
Runtime: proj.Runtime.Name(),
Main: proj.Main,
Description: description,
Config: wireConfig,
Options: apitype.UpdateOptions{
LocalPolicyPackPaths: opts.LocalPolicyPackPaths,
Color: colors.Raw, // force raw colorization, we handle colorization in the CLI
DryRun: dryRun,
Parallel: opts.Parallel,
ShowConfig: false, // This is a legacy option now, the engine will always emit config information
ShowReplacementSteps: false, // This is a legacy option now, the engine will always emit this information
ShowSames: false, // This is a legacy option now, the engine will always emit this information
},
Metadata: m,
}
// Create the initial update object.
var endpoint string
switch kind {
case apitype.UpdateUpdate:
endpoint = "update"
case apitype.PreviewUpdate:
endpoint = "preview"
case apitype.RefreshUpdate:
endpoint = "refresh"
case apitype.DestroyUpdate:
endpoint = "destroy"
default:
contract.Failf("Unknown kind: %s", kind)
}
path := getStackPath(stack, endpoint)
var updateResponse apitype.UpdateProgramResponse
if err := pc.restCall(ctx, "POST", path, nil, &updateRequest, &updateResponse); err != nil {
return UpdateIdentifier{}, []apitype.RequiredPolicy{}, err
}
return UpdateIdentifier{
StackIdentifier: stack,
UpdateKind: kind,
UpdateID: updateResponse.UpdateID,
}, updateResponse.RequiredPolicies, nil
}
func (pc *Client) RenameStack(ctx context.Context, stack StackIdentifier, newName string) error {
req := apitype.StackRenameRequest{
NewName: newName,
}
var resp apitype.ImportStackResponse
return pc.restCall(ctx, "POST", getStackPath(stack, "rename"), nil, &req, &resp)
}
// StartUpdate starts the indicated update. It returns the new version of the update's target stack and the token used
// to authenticate operations on the update if any. Replaces the stack's tags with the updated set.
func (pc *Client) StartUpdate(ctx context.Context, update UpdateIdentifier,
tags map[apitype.StackTagName]string) (int, string, error) {
// Validate names and tags.
if err := validation.ValidateStackProperties(update.StackIdentifier.Stack, tags); err != nil {
return 0, "", errors.Wrap(err, "validating stack properties")
}
req := apitype.StartUpdateRequest{
Tags: tags,
}
var resp apitype.StartUpdateResponse
if err := pc.restCall(ctx, "POST", getUpdatePath(update), nil, req, &resp); err != nil {
return 0, "", err
}
return resp.Version, resp.Token, nil
}
// PublishPolicyPack publishes a `PolicyPack` to the Pulumi service.
func (pc *Client) PublishPolicyPack(ctx context.Context, orgName string,
analyzerInfo plugin.AnalyzerInfo, dirArchive io.Reader) error {
//
// Step 1: Send POST containing policy metadata to service. This begins process of creating
// publishing the PolicyPack.
//
req := apitype.CreatePolicyPackRequest{
Name: analyzerInfo.Name,
DisplayName: analyzerInfo.DisplayName,
Policies: analyzerInfo.Policies,
}
fmt.Printf("Publishing as %s\n", analyzerInfo.Name)
var resp apitype.CreatePolicyPackResponse
err := pc.restCall(ctx, "POST", publishPolicyPackPath(orgName), nil, req, &resp)
if err != nil {
return errors.Wrapf(err, "HTTP POST to publish policy pack failed")
}
fmt.Printf("Published as version %d\n", resp.Version)
//
// Step 2: Upload the compressed PolicyPack directory to the presigned S3 URL. The PolicyPack is
// now published.
//
putS3Req, err := http.NewRequest(http.MethodPut, resp.UploadURI, dirArchive)
if err != nil {
return errors.Wrapf(err, "Failed to upload compressed PolicyPack")
}
_, err = http.DefaultClient.Do(putS3Req)
if err != nil {
return errors.Wrapf(err, "Failed to upload compressed PolicyPack")
}
//
// Step 3: Signal to the service that the PolicyPack publish operation is complete.
//
err = pc.restCall(ctx, "POST",
publishPolicyPackPublishComplete(orgName, analyzerInfo.Name, resp.Version), nil, nil, nil)
if err != nil {
return errors.Wrapf(err, "HTTP POST to signal completion of the publish operation failed")
}
return nil
}
// ApplyPolicyPack applies a `PolicyPack` to the Pulumi organization.
func (pc *Client) ApplyPolicyPack(ctx context.Context, orgName string, policyPackName string,
version int) error {
req := apitype.ApplyPolicyPackRequest{Name: policyPackName, Version: version}
err := pc.restCall(
ctx, "POST", applyPolicyPackPath(orgName, policyPackName, version), nil, req, nil)
if err != nil {
return errors.Wrapf(err, "HTTP POST to apply policy pack failed")
}
return nil
}
// DownloadPolicyPack applies a `PolicyPack` to the Pulumi organization.
func (pc *Client) DownloadPolicyPack(ctx context.Context, url string) ([]byte, error) {
fmt.Println("Downloading policy pack")
getS3Req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, errors.Wrapf(err, "Failed to download compressed PolicyPack")
}
resp, err := http.DefaultClient.Do(getS3Req)
if err != nil {
return nil, errors.Wrapf(err, "Failed to download compressed PolicyPack")
}
defer resp.Body.Close()
tarball, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrapf(err, "Failed to download compressed PolicyPack")
}
return tarball, nil
}
// GetUpdateEvents returns all events, taking an optional continuation token from a previous call.
func (pc *Client) GetUpdateEvents(ctx context.Context, update UpdateIdentifier,
continuationToken *string) (apitype.UpdateResults, error) {
path := getUpdatePath(update)
if continuationToken != nil {
path += fmt.Sprintf("?continuationToken=%s", *continuationToken)
}
var results apitype.UpdateResults
if err := pc.restCall(ctx, "GET", path, nil, nil, &results); err != nil {
return apitype.UpdateResults{}, err
}
return results, nil
}
// RenewUpdateLease renews the indicated update lease for the given duration.
func (pc *Client) RenewUpdateLease(ctx context.Context, update UpdateIdentifier, token string,
duration time.Duration) (string, error) {
req := apitype.RenewUpdateLeaseRequest{
Token: token,
Duration: int(duration / time.Second),
}
var resp apitype.RenewUpdateLeaseResponse
// While renewing a lease uses POST, it is safe to send multiple requests (consider that we do this multiple times
// during a long running update). Since we would fail our update operation if we can't renew our lease, we'll retry
// these POST operations.
if err := pc.restCallWithOptions(ctx, "POST", getUpdatePath(update, "renew_lease"), nil,
req, &resp, httpCallOptions{RetryAllMethods: true}); err != nil {
return "", err
}
return resp.Token, nil
}
// InvalidateUpdateCheckpoint invalidates the checkpoint for the indicated update.
func (pc *Client) InvalidateUpdateCheckpoint(ctx context.Context, update UpdateIdentifier, token string) error {
req := apitype.PatchUpdateCheckpointRequest{
IsInvalid: true,
}
// It is safe to retry this PATCH operation, because it is logically idempotent.
return pc.updateRESTCall(ctx, "PATCH", getUpdatePath(update, "checkpoint"), nil, req, nil,
updateAccessToken(token), httpCallOptions{RetryAllMethods: true})
}
// PatchUpdateCheckpoint patches the checkpoint for the indicated update with the given contents.
func (pc *Client) PatchUpdateCheckpoint(ctx context.Context, update UpdateIdentifier, deployment *apitype.DeploymentV3,
token string) error {
rawDeployment, err := json.Marshal(deployment)
if err != nil {
return err
}
req := apitype.PatchUpdateCheckpointRequest{
Version: 3,
Deployment: rawDeployment,
}
// It is safe to retry this PATCH operation, because it is logically idempotent, since we send the entire
// deployment instead of a set of changes to apply.
return pc.updateRESTCall(ctx, "PATCH", getUpdatePath(update, "checkpoint"), nil, req, nil,
updateAccessToken(token), httpCallOptions{RetryAllMethods: true, GzipCompress: true})
}
// CancelUpdate cancels the indicated update.
func (pc *Client) CancelUpdate(ctx context.Context, update UpdateIdentifier) error {
// It is safe to retry this PATCH operation, because it is logically idempotent.
return pc.restCallWithOptions(ctx, "POST", getUpdatePath(update, "cancel"), nil, nil, nil,
httpCallOptions{RetryAllMethods: true})
}
// CompleteUpdate completes the indicated update with the given status.
func (pc *Client) CompleteUpdate(ctx context.Context, update UpdateIdentifier, status apitype.UpdateStatus,
token string) error {
req := apitype.CompleteUpdateRequest{
Status: status,
}
// It is safe to retry this PATCH operation, because it is logically idempotent.
return pc.updateRESTCall(ctx, "POST", getUpdatePath(update, "complete"), nil, req, nil,
updateAccessToken(token), httpCallOptions{RetryAllMethods: true})
}
// RecordEngineEvents posts a batch of engine events to the Pulumi service.
func (pc *Client) RecordEngineEvents(
ctx context.Context, update UpdateIdentifier, batch apitype.EngineEventBatch, token string) error {
callOpts := httpCallOptions{
GzipCompress: true,
RetryAllMethods: true,
}
return pc.updateRESTCall(
ctx, "POST", getUpdatePath(update, "events/batch"),
nil, batch, nil,
updateAccessToken(token), callOpts)
}
// UpdateStackTags updates the stacks's tags, replacing all existing tags.
func (pc *Client) UpdateStackTags(
ctx context.Context, stack StackIdentifier, tags map[apitype.StackTagName]string) error {
// Validate stack tags.
if err := validation.ValidateStackTags(tags); err != nil {
return err
}
return pc.restCall(ctx, "PATCH", getStackPath(stack, "tags"), nil, tags, nil)
}