Skip to content
Open
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)
}
44 changes: 42 additions & 2 deletions environments.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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 Down Expand Up @@ -94,9 +95,28 @@ func (s Environments) Get(ctx context.Context, stackId, envId int64, includeArch
return &env, nil
}

// CreateEnvironmentInput is the payload for creating an environment. It carries only
// the fields the API accepts on create -- orgName and stackId come from the path, and
// everything else on types.Environment is server-assigned, so passing a whole
// types.Environment invited callers to set fields that were silently ignored.
type CreateEnvironmentInput struct {
Name string `json:"name"`
Type types.EnvironmentType `json:"type"`
IsProd bool `json:"isProd,omitempty"`
PipelineOrder *int `json:"pipelineOrder,omitempty"`
ProviderConfig *types.ProviderConfig `json:"providerConfig,omitempty"`
// CreatedBy overrides the authenticated user as the creator. Empty leaves it to the API.
CreatedBy string `json:"createdBy,omitempty"`
// Metadata seeds the environment's descriptive metadata. Omitted leaves it empty.
Metadata *types.EnvironmentMetadata `json:"metadata,omitempty"`
// Tags seeds the environment's tags. Whole-map assignment is safe here because
// there is nothing to clobber yet; after create, tag writes go through UpdateTags.
Tags map[string]string `json:"tags,omitempty"`
}

// Create - POST /orgs/:orgName/stacks/:stack_id/envs
func (s Environments) Create(ctx context.Context, stackId int64, env *types.Environment) (*types.Environment, error) {
rawPayload, _ := json.Marshal(env)
func (s Environments) Create(ctx context.Context, stackId int64, input CreateEnvironmentInput) (*types.Environment, error) {
rawPayload, _ := json.Marshal(input)
res, err := s.Client.Do(ctx, http.MethodPost, s.basePath(stackId), nil, nil, json.RawMessage(rawPayload))
if err != nil {
return nil, err
Expand All @@ -105,11 +125,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
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"

Expand All @@ -26,3 +27,19 @@ func (p PreviewApps) List(ctx context.Context, stackId, envId int64) ([]types.Pr

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"`
}