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
7 changes: 7 additions & 0 deletions docs/generated/mass_environment_preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ instances:
secrets:
- name: STRIPE_KEY
value: FOO
# Bind connection slots to resources outside this env. `resourceId` is a
# UUID (imported resource) or `<instance>.<field>` (provisioned resource).
remoteReferences:
- resourceId: 161aeb95-e1c5-4f8d-803e-ef82087d7ad4
field: kubernetes_cluster
- resourceId: demo-production-chatdb.hostname
field: database

# listed without overrides — inherit from the fork
imported:
Expand Down
7 changes: 7 additions & 0 deletions docs/helpdocs/environment/preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ instances:
secrets:
- name: STRIPE_KEY
value: FOO
# Bind connection slots to resources outside this env. `resourceId` is a
# UUID (imported resource) or `<instance>.<field>` (provisioned resource).
remoteReferences:
- resourceId: 161aeb95-e1c5-4f8d-803e-ef82087d7ad4
field: kubernetes_cluster
- resourceId: demo-production-chatdb.hostname
field: database

# listed without overrides — inherit from the fork
imported:
Expand Down
50 changes: 41 additions & 9 deletions internal/commands/environment/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ type PreviewConfig struct {
CopySecrets bool `json:"copySecrets,omitempty"`

// CopyRemoteReferences fans copyInstance's `copyRemoteReferences: true`
// across every package during the fork. The SDK does not yet expose a
// per-instance setRemoteReference, so override granularity stops at the
// fork-level macro for now.
// across every package during the fork. Per-instance `remoteReferences`
// overrides in `instances` still apply after this.
CopyRemoteReferences bool `json:"copyRemoteReferences,omitempty"`

// Attributes are key/value labels set on the forked environment. Required
Expand Down Expand Up @@ -74,9 +73,10 @@ type DefaultEntry struct {
// Append `+dev` to pull from the development channel — e.g. `latest+dev` or
// `~2.0+dev`.
type InstanceOverride struct {
Version string `json:"version,omitempty"`
Params map[string]any `json:"params,omitempty"`
Secrets []PreviewSecret `json:"secrets,omitempty"`
Version string `json:"version,omitempty"`
Params map[string]any `json:"params,omitempty"`
Secrets []PreviewSecret `json:"secrets,omitempty"`
RemoteReferences []PreviewRemoteReference `json:"remoteReferences,omitempty"`
}

// PreviewSecret is a single secret override on an instance.
Expand All @@ -85,6 +85,14 @@ type PreviewSecret struct {
Value string `json:"value"`
}

// PreviewRemoteReference binds one of an instance's connection slots to a
// resource outside the preview env. `resourceId` is either a UUID or
// `<instance>.<field>`; `field` names the connection slot on the instance.
type PreviewRemoteReference struct {
ResourceID string `json:"resourceId"`
Field string `json:"field"`
}

// PreviewOptions controls a single invocation of RunPreview.
type PreviewOptions struct {
// ID is the local segment of the preview env identifier (e.g. "pr123").
Expand All @@ -107,6 +115,7 @@ type PreviewAPI interface {
CopyInstance(ctx context.Context, sourceID, destinationID string, input instances.CopyInput) (*types.Instance, error)
UpdateInstance(ctx context.Context, id string, input instances.UpdateInput) (*types.Instance, error)
SetInstanceSecret(ctx context.Context, instanceID, name, value string) error
SetInstanceRemoteReference(ctx context.Context, instanceID, resourceID, field string) error
DeployEnvironment(ctx context.Context, id string) (*types.Environment, error)
}

Expand Down Expand Up @@ -137,6 +146,11 @@ func (s sdkPreviewAPI) SetInstanceSecret(ctx context.Context, instanceID, name,
return err
}

func (s sdkPreviewAPI) SetInstanceRemoteReference(ctx context.Context, instanceID, resourceID, field string) error {
_, err := s.c.Instances.SetRemoteReference(ctx, instanceID, resourceID, field)
return err
}

func (s sdkPreviewAPI) DeployEnvironment(ctx context.Context, id string) (*types.Environment, error) {
return s.c.Environments.Deploy(ctx, id)
}
Expand All @@ -145,7 +159,7 @@ func (s sdkPreviewAPI) DeployEnvironment(ctx context.Context, id string) (*types
//
// 1. Fork the base environment.
// 2. Pin any environment defaults declared in the config.
// 3. Apply per-instance overrides (version, params, secrets).
// 3. Apply per-instance overrides (version, params, secrets, remote references).
// 4. Trigger a deploy of every instance in dependency order.
//
// Every step but (4) is idempotent — re-running the command against the same
Expand Down Expand Up @@ -210,7 +224,7 @@ func RunPreview(ctx context.Context, api PreviewAPI, config *PreviewConfig, opts
// applyInstanceOverride applies the per-instance configuration in `override`
// to the preview env's instance. Order matters: params first (via copyInstance
// from the base env's matching instance, so it deep-merges over the parent's
// values), then version, then secrets.
// values), then version, then secrets, then remote references.
func applyInstanceOverride(ctx context.Context, api PreviewAPI, config *PreviewConfig, instanceID, localID string, override InstanceOverride) error {
if len(override.Params) > 0 {
sourceID := fmt.Sprintf("%s-%s-%s", config.Project, config.BaseEnvironment, localID)
Expand All @@ -234,6 +248,13 @@ func applyInstanceOverride(ctx context.Context, api PreviewAPI, config *PreviewC
}
}

for _, ref := range override.RemoteReferences {
fmt.Printf("🔗 Referencing `%s` as `%s` on `%s`\n", ref.ResourceID, ref.Field, instanceID)
if refErr := api.SetInstanceRemoteReference(ctx, instanceID, ref.ResourceID, ref.Field); refErr != nil {
return fmt.Errorf("set remote reference %s: %w", ref.Field, refErr)
}
}

return nil
}

Expand All @@ -249,14 +270,17 @@ func applyInstanceOverride(ctx context.Context, api PreviewAPI, config *PreviewC
//
// and pick up `GITHUB_PR` from the CI runner. Undefined variables expand to
// empty strings, matching `os.ExpandEnv`'s standard behavior.
//
// Unknown keys are an error so a typo or a key from an older schema fails
// loudly instead of silently converging the wrong environment.
func LoadPreviewConfig(path string) (*PreviewConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read preview config: %w", err)
}
expanded := os.ExpandEnv(string(data))
cfg := &PreviewConfig{}
if unmarshalErr := yaml.Unmarshal([]byte(expanded), cfg); unmarshalErr != nil {
if unmarshalErr := yaml.UnmarshalStrict([]byte(expanded), cfg); unmarshalErr != nil {
return nil, fmt.Errorf("parse preview config: %w", unmarshalErr)
}
return cfg, nil
Expand Down Expand Up @@ -297,6 +321,14 @@ func validatePreviewConfig(config *PreviewConfig) error {
return fmt.Errorf("preview config: instances.%s.secrets[%d]: `name` is required", localID, i)
}
}
for i, ref := range override.RemoteReferences {
if ref.ResourceID == "" {
return fmt.Errorf("preview config: instances.%s.remoteReferences[%d]: `resourceId` is required", localID, i)
}
if ref.Field == "" {
return fmt.Errorf("preview config: instances.%s.remoteReferences[%d]: `field` is required", localID, i)
}
}
}
return nil
}
115 changes: 115 additions & 0 deletions internal/commands/environment/preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -32,6 +33,11 @@ instances:
secrets:
- name: STRIPE_KEY
value: FOO
remoteReferences:
- resourceId: a1b2c3d4-0000-0000-0000-000000000000
field: kubernetes_cluster
- resourceId: demo-production-sharedvpc.vpc
field: vpc

noOverrides:
`
Expand All @@ -52,6 +58,9 @@ type stubPreviewAPI struct {

setSecretCalls []setSecretCall

setRemoteReferenceCalls []setRemoteReferenceCall
setRemoteReferenceErr error

deployed string
deployErr error
deployCallsLen int
Expand All @@ -76,6 +85,10 @@ type setSecretCall struct {
instanceID, name, value string
}

type setRemoteReferenceCall struct {
instanceID, resourceID, field string
}

func (f *stubPreviewAPI) Fork(_ context.Context, parentID string, input environments.ForkInput) (*types.Environment, error) {
f.forkParent = parentID
f.forkInput = input
Expand Down Expand Up @@ -105,6 +118,11 @@ func (f *stubPreviewAPI) SetInstanceSecret(_ context.Context, instanceID, name,
return nil
}

func (f *stubPreviewAPI) SetInstanceRemoteReference(_ context.Context, instanceID, resourceID, field string) error {
f.setRemoteReferenceCalls = append(f.setRemoteReferenceCalls, setRemoteReferenceCall{instanceID: instanceID, resourceID: resourceID, field: field})
return f.setRemoteReferenceErr
}

func (f *stubPreviewAPI) DeployEnvironment(_ context.Context, id string) (*types.Environment, error) {
f.deployed = id
f.deployCallsLen++
Expand Down Expand Up @@ -144,6 +162,45 @@ func TestLoadPreviewConfig_ParsesAllFields(t *testing.T) {
if len(chat.Secrets) != 1 || chat.Secrets[0].Name != "STRIPE_KEY" {
t.Errorf("chatdb secrets wrong: %+v", chat.Secrets)
}
wantRefs := []environment.PreviewRemoteReference{
{ResourceID: "a1b2c3d4-0000-0000-0000-000000000000", Field: "kubernetes_cluster"},
{ResourceID: "demo-production-sharedvpc.vpc", Field: "vpc"},
}
if !reflect.DeepEqual(chat.RemoteReferences, wantRefs) {
t.Errorf("chatdb remoteReferences = %+v, want %+v", chat.RemoteReferences, wantRefs)
}
}

func TestLoadPreviewConfig_RejectsUnknownKeys(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{
name: "top-level typo",
body: "project: demo\nbaseEnviroment: production\n",
want: "baseEnviroment",
},
{
name: "instance-level typo",
body: "project: demo\nbaseEnvironment: production\ninstances:\n chatsvc:\n remoteRefs:\n - resourceId: x\n field: y\n",
want: "remoteRefs",
},
{
name: "remote reference entry typo",
body: "project: demo\nbaseEnvironment: production\ninstances:\n chatsvc:\n remoteReferences:\n - resource: x\n field: y\n",
want: "resource",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := environment.LoadPreviewConfig(writeConfig(t, tt.body))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("expected unknown-key error mentioning %q, got %v", tt.want, err)
}
})
}
}

func TestLoadPreviewConfig_RejectsMissingProject(t *testing.T) {
Expand Down Expand Up @@ -194,11 +251,69 @@ func TestRunPreview_HappyPath(t *testing.T) {
if len(api.setSecretCalls) != 1 || api.setSecretCalls[0].name != "STRIPE_KEY" {
t.Errorf("setSecret calls wrong: %+v", api.setSecretCalls)
}
wantRefs := []setRemoteReferenceCall{
{instanceID: "demo-pr123-chatdb", resourceID: "a1b2c3d4-0000-0000-0000-000000000000", field: "kubernetes_cluster"},
{instanceID: "demo-pr123-chatdb", resourceID: "demo-production-sharedvpc.vpc", field: "vpc"},
}
if !reflect.DeepEqual(api.setRemoteReferenceCalls, wantRefs) {
t.Errorf("setRemoteReference calls = %+v, want %+v", api.setRemoteReferenceCalls, wantRefs)
}
if api.deployed != "demo-pr123" {
t.Errorf("deployed = %q, want demo-pr123", api.deployed)
}
}

func TestRunPreview_RemoteReferenceValidation(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{
name: "missing resourceId",
body: "project: demo\nbaseEnvironment: production\ninstances:\n chatsvc:\n remoteReferences:\n - field: vpc\n",
want: "instances.chatsvc.remoteReferences[0]: `resourceId` is required",
},
{
name: "missing field",
body: "project: demo\nbaseEnvironment: production\ninstances:\n chatsvc:\n remoteReferences:\n - resourceId: demo-production-sharedvpc.vpc\n",
want: "instances.chatsvc.remoteReferences[0]: `field` is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := environment.LoadPreviewConfig(writeConfig(t, tt.body))
if err != nil {
t.Fatalf("LoadPreviewConfig: %v", err)
}
api := &stubPreviewAPI{}
_, runErr := environment.RunPreview(t.Context(), api, cfg, environment.PreviewOptions{ID: "pr1"})
if runErr == nil || !strings.Contains(runErr.Error(), tt.want) {
t.Errorf("expected %q, got %v", tt.want, runErr)
}
if api.forkParent != "" {
t.Error("fork should not run when validation fails")
}
})
}
}

func TestRunPreview_PropagatesRemoteReferenceFailure(t *testing.T) {
cfg, err := environment.LoadPreviewConfig(writeConfig(t, sampleConfig))
if err != nil {
t.Fatalf("LoadPreviewConfig: %v", err)
}

api := &stubPreviewAPI{setRemoteReferenceErr: errors.New("resource not found")}
_, runErr := environment.RunPreview(t.Context(), api, cfg, environment.PreviewOptions{ID: "pr1"})
if runErr == nil || !strings.Contains(runErr.Error(), "set remote reference kubernetes_cluster: resource not found") {
t.Errorf("expected remote reference error, got %v", runErr)
}
if api.deployCallsLen != 0 {
t.Error("deploy should not have been called after remote reference failure")
}
}

func TestRunPreview_PropagatesForkFailure(t *testing.T) {
path := writeConfig(t, sampleConfig)
cfg, _ := environment.LoadPreviewConfig(path)
Expand Down
Loading