Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions environment_tags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package api

import (
"context"
"encoding/json"
"fmt"
"net/http"

"gopkg.in/nullstone-io/go-api-client.v0/response"
"gopkg.in/nullstone-io/go-api-client.v0/types"
)

// UpdateEnvironmentTagsInput is a per-key patch of an environment's tags. It
// deliberately avoids whole-map replacement, which would force every caller into
// a read-modify-write and make concurrent single-key writes clobber each other.
type UpdateEnvironmentTagsInput struct {
// Tags applies a per-key patch: a key mapped to a value sets/updates it,
// a key mapped to nil clears it, and any key not present is left untouched.
// Note that setting a key to the empty string is distinct from clearing it —
// the key remains present with an empty value.
Tags map[string]*string `json:"tags"`
}

// ApplyTo merges the patch onto an existing tag map and returns the result. The
// input map is never mutated.
func (i UpdateEnvironmentTagsInput) ApplyTo(existing map[string]string) map[string]string {
result := make(map[string]string, len(existing)+len(i.Tags))
for k, v := range existing {
result[k] = v
}
for k, v := range i.Tags {
if v == nil {
delete(result, k)
continue
}
result[k] = *v
}
return result
}

func (s Environments) envTagsPath(stackId, envId int64) string {
return fmt.Sprintf("orgs/%s/stacks/%d/envs/%d/tags", s.Client.Config.OrgName, stackId, envId)
}

// UpdateTags - PATCH /orgs/:orgName/stacks/:stack_id/envs/:id/tags
// Applies a per-key patch to the environment's tags and returns the updated environment.
// This is a dedicated route rather than a field on Update so that tag writes are
// atomic server-side and can be authorized separately from the rest of the env.
func (s Environments) UpdateTags(ctx context.Context, stackId, envId int64, input UpdateEnvironmentTagsInput) (*types.Environment, error) {
rawPayload, _ := json.Marshal(input)
res, err := s.Client.Do(ctx, http.MethodPatch, s.envTagsPath(stackId, envId), nil, nil, json.RawMessage(rawPayload))
if err != nil {
return nil, err
}
return response.ReadJsonPtr[types.Environment](res)
}
81 changes: 81 additions & 0 deletions environment_tags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package api

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestUpdateEnvironmentTagsInput_ApplyTo(t *testing.T) {
strPtr := func(s string) *string { return &s }

tests := []struct {
name string
existing map[string]string
input UpdateEnvironmentTagsInput
want map[string]string
}{
{
name: "sets a new key",
existing: map[string]string{"tier": "gold"},
input: UpdateEnvironmentTagsInput{Tags: map[string]*string{"claim": strPtr("brad")}},
want: map[string]string{"tier": "gold", "claim": "brad"},
},
{
name: "updates an existing key and leaves others untouched",
existing: map[string]string{"tier": "gold", "claim": "brad"},
input: UpdateEnvironmentTagsInput{Tags: map[string]*string{"claim": strPtr("alex")}},
want: map[string]string{"tier": "gold", "claim": "alex"},
},
{
name: "nil value clears only that key",
existing: map[string]string{"tier": "gold", "claim": "brad"},
input: UpdateEnvironmentTagsInput{Tags: map[string]*string{"claim": nil}},
want: map[string]string{"tier": "gold"},
},
{
name: "empty string is distinct from clearing",
existing: map[string]string{"claim": "brad"},
input: UpdateEnvironmentTagsInput{Tags: map[string]*string{"claim": strPtr("")}},
want: map[string]string{"claim": ""},
},
{
name: "clearing a key that does not exist is a no-op",
existing: map[string]string{"tier": "gold"},
input: UpdateEnvironmentTagsInput{Tags: map[string]*string{"claim": nil}},
want: map[string]string{"tier": "gold"},
},
{
name: "empty patch leaves everything untouched",
existing: map[string]string{"tier": "gold"},
input: UpdateEnvironmentTagsInput{},
want: map[string]string{"tier": "gold"},
},
{
name: "applies to nil existing tags",
existing: nil,
input: UpdateEnvironmentTagsInput{Tags: map[string]*string{"claim": strPtr("brad")}},
want: map[string]string{"claim": "brad"},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := test.input.ApplyTo(test.existing)
assert.Equal(t, test.want, got)
})
}
}

func TestUpdateEnvironmentTagsInput_ApplyTo_DoesNotMutateExisting(t *testing.T) {
strPtr := func(s string) *string { return &s }
existing := map[string]string{"tier": "gold", "claim": "brad"}

input := UpdateEnvironmentTagsInput{Tags: map[string]*string{
"claim": nil,
"env": strPtr("preview"),
}}
_ = input.ApplyTo(existing)

assert.Equal(t, map[string]string{"tier": "gold", "claim": "brad"}, existing)
}
87 changes: 87 additions & 0 deletions environments.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -60,6 +61,7 @@ func (s Environments) GlobalList(ctx context.Context, envTypes []types.Environme
}

// List - GET /orgs/:orgName/stacks/:stackId/envs
// Returns active environments only.
func (s Environments) List(ctx context.Context, stackId int64) ([]*types.Environment, error) {
res, err := s.Client.Do(ctx, http.MethodGet, s.basePath(stackId), nil, nil, nil)
if err != nil {
Expand All @@ -75,6 +77,71 @@ func (s Environments) List(ctx context.Context, stackId int64) ([]*types.Environ
return envs, nil
}

// FindEnvironmentsInput narrows Find. Filters AND together; a zero value matches every
// active environment in the stack, which is what List returns.
type FindEnvironmentsInput struct {
// Types matches any one of these environment types (OR).
Types []types.EnvironmentType
// Status selects active (the default) or archived environments.
Status types.EnvStatus
// IsProd matches on the prod flag; nil matches either.
IsProd *bool
// Search matches the name case-insensitively: as a whole-name pattern when it contains
// * (any run of characters) or ? (one character), otherwise as a substring.
Search string
// Tags requires every key: a non-empty value must equal the environment's tag; an empty
// value matches environments where the key is absent or present as "" — the way to find
// environments nobody has tagged yet.
Tags map[string]string
}

func (input FindEnvironmentsInput) query() url.Values {
q := url.Values{}
if len(input.Types) > 0 {
envTypeStrings := make([]string, 0, len(input.Types))
for _, envType := range input.Types {
envTypeStrings = append(envTypeStrings, string(envType))
}
q.Set("type", strings.Join(envTypeStrings, ","))
}
if input.Status != "" {
q.Set("status", string(input.Status))
}
if input.IsProd != nil {
q.Set("is_prod", strconv.FormatBool(*input.IsProd))
}
if input.Search != "" {
q.Set("search", input.Search)
}
// sorted so the same filters always produce the same URL
keys := make([]string, 0, len(input.Tags))
for key := range input.Tags {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
q.Add("tag", key+"="+input.Tags[key])
}
return q
}

// Find - GET /orgs/:orgName/stacks/:stackId/envs?type=&status=&is_prod=&search=&tag=KEY=VALUE
// Filtering happens server-side; List is Find with no filters.
func (s Environments) Find(ctx context.Context, stackId int64, input FindEnvironmentsInput) ([]*types.Environment, error) {
res, err := s.Client.Do(ctx, http.MethodGet, s.basePath(stackId), input.query(), nil, nil)
if err != nil {
return nil, err
}

var envs []*types.Environment
if err := response.ReadJson(res, &envs); response.IsNotFoundError(err) {
return nil, nil
} else if err != nil {
return nil, err
}
return envs, nil
}

// Get - GET /orgs/:orgName/stacks/:stack_id/envs/:id
func (s Environments) Get(ctx context.Context, stackId, envId int64, includeArchived bool) (*types.Environment, error) {
q := url.Values{
Expand Down Expand Up @@ -105,11 +172,31 @@ func (s Environments) Create(ctx context.Context, stackId int64, env *types.Envi
return response.ReadJsonPtr[types.Environment](res)
}

// UpdateEnvironmentMetadataInput is a partial update of an environment's
// descriptive metadata. Every field is a pointer so a caller can update a single
// field without clearing the others.
type UpdateEnvironmentMetadataInput struct {
// Description updates the environment description: nil leaves it untouched,
// an empty string clears it, any other value sets it.
Description *string `json:"description,omitempty"`
}

// ApplyTo merges the provided fields onto existing metadata, leaving untouched
// any field whose pointer is nil.
func (i UpdateEnvironmentMetadataInput) ApplyTo(existing types.EnvironmentMetadata) types.EnvironmentMetadata {
if i.Description != nil {
existing.Description = *i.Description
}
return existing
}

type UpdateEnvironmentInput struct {
Name *string `json:"name,omitempty"`
IsProd *bool `json:"isProd,omitempty"`
PipelineOrder *int `json:"pipelineOrder,omitempty"`
ProviderConfig *types.ProviderConfig `json:"providerConfig,omitempty"`
// Metadata is a partial update; omitting it leaves the stored metadata unchanged.
Metadata *UpdateEnvironmentMetadataInput `json:"metadata,omitempty"`
}

// Update - PUT/PATCH /orgs/:orgName/stacks/:stack_id/envs/:id
Expand Down
31 changes: 31 additions & 0 deletions environments_find_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package api

import (
"testing"

"github.com/stretchr/testify/assert"
"gopkg.in/nullstone-io/go-api-client.v0/types"
)

func TestFindEnvironmentsInput_query(t *testing.T) {
t.Run("zero value sends no params", func(t *testing.T) {
assert.Empty(t, FindEnvironmentsInput{}.query().Encode())
})

t.Run("encodes every filter, tags sorted and repeated", func(t *testing.T) {
isProd := false
q := FindEnvironmentsInput{
Types: []types.EnvironmentType{types.EnvTypePreview, types.EnvTypePipeline},
Status: types.EnvStatusArchived,
IsProd: &isProd,
Search: "pr-*",
Tags: map[string]string{"tier": "gold", "claim": ""},
}.query()

assert.Equal(t, "PreviewEnv,PipelineEnv", q.Get("type"))
assert.Equal(t, "archived", q.Get("status"))
assert.Equal(t, "false", q.Get("is_prod"))
assert.Equal(t, "pr-*", q.Get("search"))
assert.Equal(t, []string{"claim=", "tier=gold"}, q["tag"], "empty value is sent as KEY= so the API can tell it apart from an absent filter")
})
}
17 changes: 17 additions & 0 deletions preview_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -78,3 +79,19 @@ func (p PreviewApps) FindByStackName(ctx context.Context, stackName string, inpu

return response.ReadJsonVal[[]types.PreviewApp](res)
}

// Replace - PUT /orgs/{orgName}/stacks/{stackId}/envs/{envId}/preview_apps
// This has replace semantics: the env's preview app set becomes exactly previewApps,
// and any app not in the list is removed from the env. In a preview env "enabled"
// means "present in this set", so adding or removing an app is a membership change,
// not a field write. Callers wanting to change one app must List first and send the
// full mutated list back.
func (p PreviewApps) Replace(ctx context.Context, stackId, envId int64, previewApps []types.PreviewApp) ([]types.PreviewApp, error) {
rawPayload, _ := json.Marshal(previewApps)
res, err := p.Client.Do(ctx, http.MethodPut, p.basePath(stackId, envId), nil, nil, json.RawMessage(rawPayload))
if err != nil {
return nil, err
}

return response.ReadJsonVal[[]types.PreviewApp](res)
}
7 changes: 7 additions & 0 deletions types/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ type Environment struct {
Status EnvStatus `json:"status"`
IsProd bool `json:"isProd"`
LatestActivityAt time.Time `json:"latestActivityAt"`

// Metadata is platform-defined descriptive metadata (see EnvironmentMetadata).
Metadata EnvironmentMetadata `json:"metadata"`
// Tags is an open, user-defined keyspace for labelling environments. Unlike
// Metadata, callers may set any key; tags are what environment queries filter on.
// A key present with an empty value is distinct from an absent key.
Tags map[string]string `json:"tags"`
}

type EnvironmentWithStack struct {
Expand Down
14 changes: 14 additions & 0 deletions types/environment_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package types

// EnvironmentMetadata is an extensible container for platform-defined
// descriptive metadata about an environment. Description is its first member;
// future metadata (owner, lifecycle policy, …) lands here too, without a
// migration — the whole struct is persisted as a single jsonb column.
//
// It is deliberately a *closed* set of fields, mirroring WorkspaceMetadata.
// Open-ended, user-defined keys belong in Environment.Tags instead.
type EnvironmentMetadata struct {
// Description is free-form prose describing what the environment is for.
// Empty = no description.
Description string `json:"description,omitempty"`
}