diff --git a/resources/out_of_repo_module_test.go b/resources/out_of_repo_module_test.go new file mode 100644 index 00000000..41ee3734 --- /dev/null +++ b/resources/out_of_repo_module_test.go @@ -0,0 +1,51 @@ +package resources_test + +import ( + "context" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" +) + +// A composition-root workspace references an out-of-repo module by relative +// path (a sibling checkout, not a vendored copy). Its services must load into +// the graph and wire across the reference boundary like in-repo modules. +func TestWorkspaceReferencesOutOfRepoModuleByPath(t *testing.T) { + ctx := context.Background() + workspace, err := resources.LoadWorkspaceFromDir(ctx, "testdata/out-of-repo/solution") + require.NoError(t, err) + + // Portability is the whole point: the committed reference is a relative + // sibling path, not an absolute machine path. ModulePath joins it onto the + // workspace dir, so it resolves the same regardless of invocation cwd. + var saasRef *resources.ModuleReference + for _, ref := range workspace.Modules { + if ref.Name == "saas" { + saasRef = ref + } + } + require.NotNil(t, saasRef) + require.NotNil(t, saasRef.PathOverride) + require.Equal(t, "../host", *saasRef.PathOverride) + + saas, err := workspace.LoadModuleFromName(ctx, "saas") + require.NoError(t, err) + gateway, err := saas.LoadServiceFromName(ctx, "gateway") + require.NoError(t, err) + require.Len(t, gateway.Endpoints, 1) + require.Equal(t, "public-api", gateway.Endpoints[0].Name) + + services, err := workspace.LoadServices(ctx) + require.NoError(t, err) + var names []string + for _, svc := range services { + names = append(names, svc.Name) + } + require.Contains(t, names, "gateway") + require.Contains(t, names, "api") + + // The in-repo consumer depends on the out-of-repo producer's public + // endpoint; visibility wiring resolves across the reference boundary. + require.NoError(t, workspace.ValidateServiceDependencies(ctx)) +} diff --git a/resources/path_validation.go b/resources/path_validation.go index 1e5d50ab..f33c81b2 100644 --- a/resources/path_validation.go +++ b/resources/path_validation.go @@ -47,6 +47,23 @@ func validateResourcePathOverride(kind string, override *string) error { return validateResourceRelativePath(kind+" override", *override) } +// validateModuleReferencePathOverride governs where a workspace module +// reference resolves. Unlike service/job overrides — which stay confined to +// their owning resource — a module reference is a composition root: it may +// point at an out-of-repo module (a sibling checkout next to the workspace) so +// a solution can boot a host it references without vendoring a copy. Absolute +// and upward-traversing relative paths are both allowed; NUL and backslash +// stay rejected as cross-platform hazards. +func validateModuleReferencePathOverride(override *string) error { + if override == nil { + return nil + } + if strings.ContainsAny(*override, "\x00\\") { + return fmt.Errorf("module path override %q must not contain NUL or backslash", *override) + } + return nil +} + func validateModuleReferencePath(ref *ModuleReference) error { if ref == nil { return fmt.Errorf("module reference cannot be nil") @@ -54,7 +71,7 @@ func validateModuleReferencePath(ref *ModuleReference) error { if err := validateResourcePathComponent("module", ref.Name); err != nil { return err } - return validateResourcePathOverride("module", ref.PathOverride) + return validateModuleReferencePathOverride(ref.PathOverride) } func validateServiceReferencePath(ref *ServiceReference) error { diff --git a/resources/resource_path_boundaries_test.go b/resources/resource_path_boundaries_test.go index c0f21dd6..8010f44c 100644 --- a/resources/resource_path_boundaries_test.go +++ b/resources/resource_path_boundaries_test.go @@ -104,3 +104,21 @@ func TestAbsoluteResourceOverridesRemainSupported(t *testing.T) { t.Fatalf("absolute override rejected: %v", err) } } + +func TestModuleReferenceOverrideAllowsOutOfRepoPath(t *testing.T) { + up := "../host" + if err := validateModuleReferencePath(&ModuleReference{Name: "saas", PathOverride: &up}); err != nil { + t.Fatalf("out-of-repo module reference rejected: %v", err) + } + for _, bad := range []string{"../ho\x00st", "..\\host"} { + override := bad + if err := validateModuleReferencePath(&ModuleReference{Name: "saas", PathOverride: &override}); err == nil { + t.Fatalf("module reference override %q was accepted", bad) + } + } + // The out-of-repo escape hatch is for module references only; service + // overrides stay confined to their owning module. + if err := validateServiceReferencePath(&ServiceReference{Name: "gateway", PathOverride: &up}); err == nil { + t.Fatal("traversing service reference override was accepted") + } +} diff --git a/resources/testdata/out-of-repo/host/module.codefly.yaml b/resources/testdata/out-of-repo/host/module.codefly.yaml new file mode 100644 index 00000000..cf7705a9 --- /dev/null +++ b/resources/testdata/out-of-repo/host/module.codefly.yaml @@ -0,0 +1,4 @@ +kind: module +name: saas +services: + - name: gateway diff --git a/resources/testdata/out-of-repo/host/services/gateway/service.codefly.yaml b/resources/testdata/out-of-repo/host/services/gateway/service.codefly.yaml new file mode 100644 index 00000000..ea7e7271 --- /dev/null +++ b/resources/testdata/out-of-repo/host/services/gateway/service.codefly.yaml @@ -0,0 +1,11 @@ +kind: service +name: gateway +version: 0.0.0 +agent: + kind: runtime::service + name: go-grpc + version: 0.0.1 + publisher: codefly.ai +endpoints: + - name: public-api + visibility: public diff --git a/resources/testdata/out-of-repo/solution/modules/platform/module.codefly.yaml b/resources/testdata/out-of-repo/solution/modules/platform/module.codefly.yaml new file mode 100644 index 00000000..71a2c7db --- /dev/null +++ b/resources/testdata/out-of-repo/solution/modules/platform/module.codefly.yaml @@ -0,0 +1,4 @@ +kind: module +name: platform +services: + - name: api diff --git a/resources/testdata/out-of-repo/solution/modules/platform/services/api/service.codefly.yaml b/resources/testdata/out-of-repo/solution/modules/platform/services/api/service.codefly.yaml new file mode 100644 index 00000000..8ca02deb --- /dev/null +++ b/resources/testdata/out-of-repo/solution/modules/platform/services/api/service.codefly.yaml @@ -0,0 +1,13 @@ +kind: service +name: api +version: 0.0.0 +agent: + kind: runtime::service + name: go-grpc + version: 0.0.1 + publisher: codefly.ai +service-dependencies: + - name: gateway + module: saas + endpoints: + - name: public-api diff --git a/resources/testdata/out-of-repo/solution/workspace.codefly.yaml b/resources/testdata/out-of-repo/solution/workspace.codefly.yaml new file mode 100644 index 00000000..83bbd326 --- /dev/null +++ b/resources/testdata/out-of-repo/solution/workspace.codefly.yaml @@ -0,0 +1,6 @@ +name: solution +layout: modules +modules: + - name: platform + - name: saas + path: ../host