Skip to content

Commit

Permalink
cli: add revision/tag support to get and open (#275)
Browse files Browse the repository at this point in the history
These changes add support to the CLI for getting or opening
environments at specific revisions or tags using the familiar
`name:revision-or-tag` syntax.
  • Loading branch information
pgavlin authored Mar 28, 2024
1 parent 14fe569 commit 57acb56
Show file tree
Hide file tree
Showing 19 changed files with 573 additions and 84 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG_PENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@



- Add support for getting or opening environments at specific revisions/tags.
[#275](https://github.com/pulumi/esc/pull/275)

### Bug Fixes

133 changes: 109 additions & 24 deletions cmd/esc/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,12 +238,22 @@ func (e *testEnvironments) LoadEnvironment(ctx context.Context, envName string)
if !ok {
return nil, nil, errors.New("not found")
}
return env.yaml, rot128{}, nil
return env.latest().yaml, rot128{}, nil
}

type testEnvironmentRevision struct {
number int
yaml []byte
tag string
}

type testEnvironment struct {
yaml []byte
tag string
revisions []*testEnvironmentRevision
tags map[string]int
}

func (env *testEnvironment) latest() *testEnvironmentRevision {
return env.revisions[len(env.revisions)-1]
}

type testPulumiClient struct {
Expand Down Expand Up @@ -327,6 +337,34 @@ func mapDiags(diags syntax.Diagnostics) []client.EnvironmentDiagnostic {
return out
}

func (c *testPulumiClient) getEnvironment(orgName, envName, revisionOrTag string) (*testEnvironment, *testEnvironmentRevision, error) {
name := path.Join(orgName, envName)

env, ok := c.environments[name]
if !ok {
return nil, nil, errors.New("not found")
}

var revision int
if revisionOrTag == "" || revisionOrTag == "latest" {
revision = len(env.revisions) - 1
} else if revisionOrTag[0] >= '0' && revisionOrTag[0] <= '9' {
rev, err := strconv.ParseInt(revisionOrTag, 10, 0)
if err != nil || rev < 0 || rev >= int64(len(env.revisions)) {
return nil, nil, errors.New("not found")
}
revision = int(rev)
} else {
rev, ok := env.tags[revisionOrTag]
if !ok {
return nil, nil, errors.New("not found")
}
revision = rev
}

return env, env.revisions[revision], nil
}

func (c *testPulumiClient) checkEnvironment(ctx context.Context, orgName, envName string, yaml []byte) (*esc.Environment, []client.EnvironmentDiagnostic, error) {
environment, diags, err := eval.LoadYAMLBytes(envName, yaml)
if err != nil {
Expand Down Expand Up @@ -438,15 +476,23 @@ func (c *testPulumiClient) CreateEnvironment(ctx context.Context, orgName, envNa
if _, ok := c.environments[name]; ok {
return errors.New("already exists")
}
c.environments[name] = &testEnvironment{}
c.environments[name] = &testEnvironment{
revisions: []*testEnvironmentRevision{{}},
tags: map[string]int{"latest": 0},
}
return nil
}

func (c *testPulumiClient) GetEnvironment(ctx context.Context, orgName, envName string, showSecrets bool) ([]byte, string, error) {
name := path.Join(orgName, envName)
env, ok := c.environments[name]
if !ok {
return nil, "", errors.New("not found")
func (c *testPulumiClient) GetEnvironment(
ctx context.Context,
orgName string,
envName string,
revisionOrTag string,
showSecrets bool,
) ([]byte, string, error) {
_, env, err := c.getEnvironment(orgName, envName, revisionOrTag)
if err != nil {
return nil, "", err
}

yaml := env.yaml
Expand All @@ -468,12 +514,11 @@ func (c *testPulumiClient) UpdateEnvironment(
yaml []byte,
tag string,
) ([]client.EnvironmentDiagnostic, error) {
name := path.Join(orgName, envName)
env, ok := c.environments[name]
if !ok {
return nil, errors.New("not found")
env, latest, err := c.getEnvironment(orgName, envName, "")
if err != nil {
return nil, err
}
if tag != "" && tag != env.tag {
if tag != "" && tag != latest.tag {
return nil, errors.New("tag mismatch")
}

Expand All @@ -487,8 +532,13 @@ func (c *testPulumiClient) UpdateEnvironment(
return nil, err
}

env.yaml = yaml
env.tag = base64.StdEncoding.EncodeToString(h.Sum(nil))
revisionNumber := len(env.revisions)
env.revisions = append(env.revisions, &testEnvironmentRevision{
number: revisionNumber,
yaml: yaml,
tag: base64.StdEncoding.EncodeToString(h.Sum(nil)),
})
env.tags["latest"] = revisionNumber
}

return diags, err
Expand All @@ -507,12 +557,12 @@ func (c *testPulumiClient) OpenEnvironment(
ctx context.Context,
orgName string,
envName string,
revisionOrTag string,
duration time.Duration,
) (string, []client.EnvironmentDiagnostic, error) {
name := path.Join(orgName, envName)
env, ok := c.environments[name]
if !ok {
return "", nil, errors.New("not found")
_, env, err := c.getEnvironment(orgName, envName, revisionOrTag)
if err != nil {
return "", nil, err
}

return c.openEnvironment(ctx, orgName, envName, env.yaml)
Expand Down Expand Up @@ -671,6 +721,15 @@ type cliTestcaseProcess struct {
Commands map[string]string `yaml:"commands,omitempty"`
}

type cliTestcaseRevision struct {
YAML yaml.Node `yaml:"yaml"`
Tag string `yaml:"tag,omitempty"`
}

type cliTestcaseRevisions struct {
Revisions []cliTestcaseRevision `yaml:"revisions,omitempty"`
}

type cliTestcaseYAML struct {
Parent string `yaml:"parent,omitempty"`

Expand Down Expand Up @@ -722,12 +781,38 @@ func loadTestcase(path string) (*cliTestcaseYAML, *cliTestcase, error) {

environments := map[string]*testEnvironment{}
for k, env := range testcase.Environments {
bytes, err := yaml.Marshal(env)
if err != nil {
return nil, nil, err
var revisions cliTestcaseRevisions
if err := env.Decode(&revisions); err != nil || len(revisions.Revisions) == 0 {
revisions = cliTestcaseRevisions{Revisions: []cliTestcaseRevision{{YAML: env}}}
}

envRevisions := []*testEnvironmentRevision{{}}
tags := map[string]int{}
for _, rev := range revisions.Revisions {
bytes, err := yaml.Marshal(rev.YAML)
if err != nil {
return nil, nil, err
}

revisionNumber := len(envRevisions)
envRevisions = append(envRevisions, &testEnvironmentRevision{
number: revisionNumber,
yaml: bytes,
})

if rev.Tag != "" {
if _, ok := tags[rev.Tag]; ok || rev.Tag == "latest" {
return nil, nil, fmt.Errorf("duplicate tag %q", rev.Tag)
}
tags[rev.Tag] = revisionNumber
}
}
tags["latest"] = len(envRevisions) - 1

environments[k] = &testEnvironment{yaml: bytes}
environments[k] = &testEnvironment{
revisions: envRevisions,
tags: tags,
}
}

creds := workspace.Credentials{
Expand Down
29 changes: 29 additions & 0 deletions cmd/esc/cli/client/apitype.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package client
import (
"fmt"
"strings"
"time"

"github.com/pulumi/esc"
)
Expand Down Expand Up @@ -58,6 +59,34 @@ func diagsErrorString(envDiags []EnvironmentDiagnostic) string {
return diags.String()
}

type EnvironmentRevision struct {
Number int `json:"number"`
Created time.Time `json:"created"`
CreatorLogin string `json:"creatorLogin"`
CreatorName string `json:"creatorName"`
}

type CreateEnvironmentRevisionTagRequest struct {
Revision *int `json:"revision,omitempty"`
}

type UpdateEnvironmentRevisionTagRequest struct {
Revision *int `json:"revision,omitempty"`
}

type EnvironmentRevisionTag struct {
Name string `json:"name"`
Revision int `json:"revision"`
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
EditorLogin string `json:"editorLogin"`
EditorName string `json:"editorName"`
}

type ListEnvironmentRevisionTagsResponse struct {
Tags []EnvironmentRevisionTag `json:"tags"`
NextToken string `json:"nextToken"`
}
type OrgEnvironment struct {
Organization string `json:"organization,omitempty"`
Name string `json:"name,omitempty"`
Expand Down
54 changes: 49 additions & 5 deletions cmd/esc/cli/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ type Client interface {
//
// The etag returned by GetEnvironment can be passed to UpdateEnvironment in order to avoid RMW issues
// when editing envirionments.
GetEnvironment(ctx context.Context, orgName, envName string, decrypt bool) (yaml []byte, etag string, err error)
GetEnvironment(
ctx context.Context,
orgName string,
envName string,
revisionOrTag string,
decrypt bool,
) (yaml []byte, etag string, err error)

// UpdateEnvironment updates the YAML for the environment envName in org orgName.
//
Expand Down Expand Up @@ -98,6 +104,7 @@ type Client interface {
ctx context.Context,
orgName string,
envName string,
revisionOrTag string,
duration time.Duration,
) (string, []EnvironmentDiagnostic, error)

Expand Down Expand Up @@ -255,6 +262,28 @@ func (pc *client) GetPulumiAccountDetails(ctx context.Context) (string, []string
return pc.apiUser, pc.apiOrgs, pc.tokenInfo, nil
}

// resolveEnvironmentPath resolves an environment and revision or tag to its API path.
//
// If revisionOrTag begins with a digit, it is treated as a revision number. Otherwise, it is trated as a tag. If
// no revision or tag is present, the "latest" tag is used.
func (pc *client) resolveEnvironmentPath(ctx context.Context, orgName, envName, revisionOrTag string) (string, error) {
if revisionOrTag == "" {
return fmt.Sprintf("/api/preview/environments/%v/%v", orgName, envName), nil
}

if revisionOrTag[0] >= '0' && revisionOrTag[0] <= '9' {
return fmt.Sprintf("/api/preview/environments/%v/%v/revisions/%v", orgName, envName, revisionOrTag), nil
}

path := fmt.Sprintf("/api/preview/environments/%v/%v/tags/%v", orgName, envName, revisionOrTag)

var resp EnvironmentRevisionTag
if err := pc.restCall(ctx, http.MethodGet, path, nil, nil, &resp); err != nil {
return "", fmt.Errorf("resolving tag %q: %w", revisionOrTag, err)
}
return fmt.Sprintf("/api/preview/environments/%v/%v/revisions/%v", orgName, envName, resp.Revision), nil
}

func (pc *client) ListEnvironments(
ctx context.Context,
orgName string,
Expand All @@ -281,8 +310,17 @@ func (pc *client) CreateEnvironment(ctx context.Context, orgName, envName string
return pc.restCall(ctx, http.MethodPost, path, nil, nil, nil)
}

func (pc *client) GetEnvironment(ctx context.Context, orgName, envName string, decrypt bool) ([]byte, string, error) {
path := fmt.Sprintf("/api/preview/environments/%v/%v", orgName, envName)
func (pc *client) GetEnvironment(
ctx context.Context,
orgName string,
envName string,
revisionOrTag string,
decrypt bool,
) ([]byte, string, error) {
path, err := pc.resolveEnvironmentPath(ctx, orgName, envName, revisionOrTag)
if err != nil {
return nil, "", err
}
if decrypt {
path += "/decrypt"
}
Expand Down Expand Up @@ -336,8 +374,15 @@ func (pc *client) OpenEnvironment(
ctx context.Context,
orgName string,
envName string,
revisionOrTag string,
duration time.Duration,
) (string, []EnvironmentDiagnostic, error) {
path, err := pc.resolveEnvironmentPath(ctx, orgName, envName, revisionOrTag)
if err != nil {
return "", nil, err
}
path += "/open"

queryObj := struct {
Duration string `url:"duration"`
}{
Expand All @@ -348,8 +393,7 @@ func (pc *client) OpenEnvironment(
ID string `json:"id"`
}
var errResp EnvironmentErrorResponse
path := fmt.Sprintf("/api/preview/environments/%v/%v/open", orgName, envName)
err := pc.restCallWithOptions(ctx, http.MethodPost, path, queryObj, nil, &resp, httpCallOptions{
err = pc.restCallWithOptions(ctx, http.MethodPost, path, queryObj, nil, &resp, httpCallOptions{
ErrorResponse: &errResp,
})
if err != nil {
Expand Down
Loading

0 comments on commit 57acb56

Please sign in to comment.