From e43f67efdbc1d2e8fd0a70718a0d9191e5a37700 Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Wed, 12 Aug 2026 22:02:15 -0400 Subject: [PATCH 1/3] feat(envs): add Metadata and Tags to Environment Metadata is a closed, platform-defined struct (Description is its first member), mirroring WorkspaceMetadata. Tags is the open user keyspace and sits top-level rather than nested under Metadata, because tag writes are high-frequency per-key and would otherwise be read-modify-writes of the whole metadata blob. UpdateEnvironmentTagsInput applies a per-key patch: a value sets/updates, nil clears, an absent key is untouched. Setting a key to "" is distinct from clearing it. NUL-174 --- environment_tags.go | 56 ++++++++++++++++++++++++ environment_tags_test.go | 81 +++++++++++++++++++++++++++++++++++ environments.go | 20 +++++++++ types/environment.go | 7 +++ types/environment_metadata.go | 14 ++++++ 5 files changed, 178 insertions(+) create mode 100644 environment_tags.go create mode 100644 environment_tags_test.go create mode 100644 types/environment_metadata.go diff --git a/environment_tags.go b/environment_tags.go new file mode 100644 index 0000000..0a8835d --- /dev/null +++ b/environment_tags.go @@ -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) +} diff --git a/environment_tags_test.go b/environment_tags_test.go new file mode 100644 index 0000000..1d9c165 --- /dev/null +++ b/environment_tags_test.go @@ -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) +} diff --git a/environments.go b/environments.go index 8b53870..55f7afc 100644 --- a/environments.go +++ b/environments.go @@ -105,11 +105,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 diff --git a/types/environment.go b/types/environment.go index 7751993..60b1ad1 100644 --- a/types/environment.go +++ b/types/environment.go @@ -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 { diff --git a/types/environment_metadata.go b/types/environment_metadata.go new file mode 100644 index 0000000..47a2f36 --- /dev/null +++ b/types/environment_metadata.go @@ -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"` +} From 872769f2256fcdbe5ec6947726f0816ca710b359 Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Wed, 12 Aug 2026 23:05:55 -0400 Subject: [PATCH 2/3] refactor(envs): take a CreateEnvironmentInput instead of a types.Environment Create accepted a whole types.Environment, so callers were free to set orgName, stackId, contextKey, status and friends -- all of which the API either derives from the path or assigns itself, and all of which were silently dropped. The input struct carries only what create actually accepts, and adds metadata and tags. NUL-174 --- environments.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/environments.go b/environments.go index 55f7afc..aab6ebf 100644 --- a/environments.go +++ b/environments.go @@ -94,9 +94,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 From ed121b97eac3401f53ed0fe2456a0311c8c2c2ac Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Wed, 12 Aug 2026 22:13:18 -0400 Subject: [PATCH 3/3] feat(envs): add PreviewApps.Replace PreviewApps only implemented List, so there was no way to change an env's preview app set from a client. Replace has replace semantics, which the doc comment spells out -- callers must List, mutate, and send the full list back. NUL-175 --- environments.go | 1 + preview_apps.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/environments.go b/environments.go index aab6ebf..b6d07c1 100644 --- a/environments.go +++ b/environments.go @@ -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 { diff --git a/preview_apps.go b/preview_apps.go index 5f06544..fed989d 100644 --- a/preview_apps.go +++ b/preview_apps.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "fmt" "net/http" @@ -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) +}