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..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 { @@ -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 @@ -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 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) +} 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"` +}