From 1001aacb7c62d119620e3d97b1b6089135c748ea Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 22:10:51 -0400 Subject: [PATCH 1/2] feat(resources): reference an out-of-repo module in a workspace (#350) Allow a workspace module reference to resolve to an out-of-repo module via a relative path override, so a solution repo can be the composition root that references a host module (a sibling checkout) without vendoring a copy of it. Module references are a composition root and now permit upward-traversing relative overrides (e.g. `path: ../module-saas-starter`), matching the existing precedent that absolute overrides are a deliberate external location. Service/job/application overrides stay confined to their owning resource. Module-graph loading and cross-boundary dependency wiring already work once the reference resolves. Co-Authored-By: Claude Opus 4.8 --- resources/out_of_repo_module_test.go | 51 +++++++++++++++++++ resources/path_validation.go | 19 ++++++- resources/resource_path_boundaries_test.go | 18 +++++++ .../out-of-repo/host/module.codefly.yaml | 4 ++ .../services/gateway/service.codefly.yaml | 11 ++++ .../modules/platform/module.codefly.yaml | 4 ++ .../services/api/service.codefly.yaml | 13 +++++ .../solution/workspace.codefly.yaml | 6 +++ 8 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 resources/out_of_repo_module_test.go create mode 100644 resources/testdata/out-of-repo/host/module.codefly.yaml create mode 100644 resources/testdata/out-of-repo/host/services/gateway/service.codefly.yaml create mode 100644 resources/testdata/out-of-repo/solution/modules/platform/module.codefly.yaml create mode 100644 resources/testdata/out-of-repo/solution/modules/platform/services/api/service.codefly.yaml create mode 100644 resources/testdata/out-of-repo/solution/workspace.codefly.yaml 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 From 61da57c98c8456dae6f9d9e9dfb81bb7a7bd7a61 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Mon, 24 Aug 2026 06:57:39 -0400 Subject: [PATCH 2/2] fix(resources): reject empty module path override validateModuleReferencePathOverride only rejected NUL/backslash, so an empty override (`path: ""`) passed validation where the prior generic validator rejected it (filepath.IsLocal("") is false). An empty override then resolved via ModulePath to the workspace root itself and failed late with an opaque "cannot load module" instead of a clear validation error. Restore the guard the loosening dropped. Also document why a module's own path override stays confined while a workspace-level ModuleReference may resolve out-of-repo, so the two "module" validators no longer read as an accidental inconsistency. Co-Authored-By: Claude Opus 4.8 --- resources/path_validation.go | 7 +++++++ resources/resource_path_boundaries_test.go | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/resources/path_validation.go b/resources/path_validation.go index f33c81b2..6281e3cc 100644 --- a/resources/path_validation.go +++ b/resources/path_validation.go @@ -58,6 +58,9 @@ func validateModuleReferencePathOverride(override *string) error { if override == nil { return nil } + if *override == "" { + return fmt.Errorf("module path override cannot be empty") + } if strings.ContainsAny(*override, "\x00\\") { return fmt.Errorf("module path override %q must not contain NUL or backslash", *override) } @@ -132,6 +135,10 @@ func (mod *Module) validatePaths() error { if err := validateResourcePathComponent("module", mod.Name); err != nil { return err } + // A module's own override stays confined: out-of-repo composition is + // expressed by the consuming workspace's ModuleReference (see + // validateModuleReferencePathOverride), never by a module declaring itself + // external in its own file. if err := validateResourcePathOverride("module", mod.PathOverride); err != nil { return err } diff --git a/resources/resource_path_boundaries_test.go b/resources/resource_path_boundaries_test.go index 8010f44c..ede4ec57 100644 --- a/resources/resource_path_boundaries_test.go +++ b/resources/resource_path_boundaries_test.go @@ -110,7 +110,7 @@ func TestModuleReferenceOverrideAllowsOutOfRepoPath(t *testing.T) { 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"} { + 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)