diff --git a/docs/generated/mass_environment_preview.md b/docs/generated/mass_environment_preview.md index 405c49aa..969ae0d6 100644 --- a/docs/generated/mass_environment_preview.md +++ b/docs/generated/mass_environment_preview.md @@ -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 `.` (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: diff --git a/docs/helpdocs/environment/preview.md b/docs/helpdocs/environment/preview.md index c1cea58f..f2f45d1d 100644 --- a/docs/helpdocs/environment/preview.md +++ b/docs/helpdocs/environment/preview.md @@ -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 `.` (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: diff --git a/internal/commands/environment/preview.go b/internal/commands/environment/preview.go index bf36a43a..6dece3ee 100644 --- a/internal/commands/environment/preview.go +++ b/internal/commands/environment/preview.go @@ -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 @@ -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. @@ -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 +// `.`; `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"). @@ -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) } @@ -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) } @@ -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 @@ -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) @@ -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 } @@ -249,6 +270,9 @@ 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 { @@ -256,7 +280,7 @@ func LoadPreviewConfig(path string) (*PreviewConfig, error) { } 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 @@ -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 } diff --git a/internal/commands/environment/preview_test.go b/internal/commands/environment/preview_test.go index 74d18915..89af137f 100644 --- a/internal/commands/environment/preview_test.go +++ b/internal/commands/environment/preview_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "strings" "testing" @@ -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: ` @@ -52,6 +58,9 @@ type stubPreviewAPI struct { setSecretCalls []setSecretCall + setRemoteReferenceCalls []setRemoteReferenceCall + setRemoteReferenceErr error + deployed string deployErr error deployCallsLen int @@ -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 @@ -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++ @@ -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) { @@ -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)