From 58f74f9b4ddced0e81d172058abf95809d19a08b Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:22:08 -0400 Subject: [PATCH] fix(doctor): recognize generated workspace configuration supply Co-Authored-By: Claude Opus 5.5 --- cmd/doctor_workspace.go | 40 ++++++++++---- cmd/doctor_workspace_hop_test.go | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 cmd/doctor_workspace_hop_test.go diff --git a/cmd/doctor_workspace.go b/cmd/doctor_workspace.go index 159ed4ed..6717e468 100644 --- a/cmd/doctor_workspace.go +++ b/cmd/doctor_workspace.go @@ -182,9 +182,10 @@ func workspaceReadiness(ctx context.Context, opts workspaceReadinessOptions) *wo checkDevAgents(scope, report) - toResolve, provided := checkConfigurationSources(ctx, ws, env, opts.module != "" || opts.service != "", scope, requiredBy, report) + checked, rootGroups, checkErr := orchestration.WorkspaceConfigurationsForChecking(ctx, ws, env) + toResolve, provided := checkConfigurationSources(ctx, ws, env, checked, opts.module != "" || opts.service != "", scope, requiredBy, report) - checkConfigurationReferences(ctx, ws, env, provided, scope, report) + checkConfigurationReferences(ctx, ws, env, provided, checked, rootGroups, checkErr, scope, report) checkSecretReferences(ctx, env, resolvers, unavailable, toResolve, report) @@ -865,7 +866,7 @@ func checkScope(ctx context.Context, ws *resources.Workspace, moduleName, servic // // Nothing here returns silently: a check that could not run says so, or the // report claims a clean bill the doctor never established. -func checkConfigurationReferences(ctx context.Context, ws *resources.Workspace, env *environments.Environment, provided *configurations.WorkspaceConfigurations, scope []*resources.Service, report *workspaceReadinessReport) { +func checkConfigurationReferences(ctx context.Context, ws *resources.Workspace, env *environments.Environment, provided, checked *configurations.WorkspaceConfigurations, rootGroups []string, checkErr error, scope []*resources.Service, report *workspaceReadinessReport) { if len(scope) == 0 { return } @@ -897,8 +898,7 @@ func checkConfigurationReferences(ctx context.Context, ws *resources.Workspace, // a check that had not happened. One unsupplied ${profile} value in an // unrelated group was enough to reach it, and the workspace was reported // ready with a typo'd producer in a root group. - checked, rootGroups, err := orchestration.WorkspaceConfigurationsForChecking(ctx, ws, env) - if err != nil { + if checkErr != nil { // A failure here is not only "the doctor could not look": the run // resolves these configurations through the same loader, so a workspace // whose configurations do not load has no working run either. It is a @@ -906,7 +906,7 @@ func checkConfigurationReferences(ctx context.Context, ws *resources.Workspace, // where checkConfigurationSources has already reported the read failure // and this is a note beside it. report.add(codeConfigurationInvalid, "configuration references", "fail", - fmt.Sprintf("not checked: %v", err), + fmt.Sprintf("not checked: %v", checkErr), "fix the workspace configurations named above, then run the doctor again") return } @@ -1034,10 +1034,11 @@ type scopedConfiguration struct { // in its tree — through core's configurations.ReadWorkspaceConfigurations, the // one definition of that rule, so the doctor cannot disagree with `run` — and // then the per-service directories. It never creates a directory: a missing -// workspace directory is a failure only for the groups no composed module -// provides. It returns the configurations whose secret values are in scope for +// workspace directory is a failure only for groups neither a composed module +// nor the invocation supplies. checked is the orchestration inventory shared +// with the reference check, including generated groups. It returns the configurations whose secret values are in scope for // reference resolution. -func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env *environments.Environment, serviceScoped bool, scope []*resources.Service, requiredBy map[string][]string, report *workspaceReadinessReport) ([]scopedConfiguration, *configurations.WorkspaceConfigurations) { +func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env *environments.Environment, checked *configurations.WorkspaceConfigurations, serviceScoped bool, scope []*resources.Service, requiredBy map[string][]string, report *workspaceReadinessReport) ([]scopedConfiguration, *configurations.WorkspaceConfigurations) { required := make([]string, 0, len(requiredBy)) var renderOwned []string for name := range requiredBy { @@ -1091,6 +1092,25 @@ func checkConfigurationSources(ctx context.Context, ws *resources.Workspace, env if provided != nil { declared := ownConfigurationNames(wsCfgDir) byName, own, composed := classifyProvidedConfigurations(provided, declared) + // The authored inventory owns file diagnostics and provenance. A run can + // also supply groups without files (generated hop routes, invocation + // overrides). Judge their availability from the same resolved inventory + // whose references are checked below, never from a name exemption. + if checked != nil { + for _, info := range checked.Infos { + if _, authored := byName[info.Name]; authored { + continue + } + byName[info.Name] = info + if !serviceScoped { + toResolve = append(toResolve, scopedConfiguration{origin: "invocation", info: info}) + } + if consumers := requiredBy[info.Name]; len(consumers) > 0 { + report.add("", "workspace configuration "+info.Name, "ok", + fmt.Sprintf("supplied by this invocation (required by %s)", strings.Join(consumers, ", ")), "") + } + } + } // Doctor has no selected runtime. Report native-only supply as a warning, // and exempt no other service that also requires the authored group. if byName[environments.FederationConfiguration] == nil && provided.Ambiguous[environments.FederationConfiguration] == nil && @@ -1171,7 +1191,7 @@ func reportWorkspaceConfigurationDirectory(wsCfgDir, relCfgDir string, required fmt.Sprintf("create %s/ and add the required configuration files — fresh worktrees do not carry ignored *.secret.env files; copy the reference files from your primary checkout (secret values stay in the provider)", relCfgDir)) case !dirExists(wsCfgDir) && len(required) > len(optional): report.add("", "workspace configurations", "ok", - fmt.Sprintf("none present under %s (all %d required configuration(s) are provided by composed modules)", relCfgDir, len(required)-len(optional)), "") + fmt.Sprintf("none present under %s (all %d required configuration(s) are supplied by composed modules or this invocation)", relCfgDir, len(required)-len(optional)), "") case !dirExists(wsCfgDir): report.add("", "workspace configurations", "ok", fmt.Sprintf("none present under %s (none required)", relCfgDir), "") case len(own) == 0: diff --git a/cmd/doctor_workspace_hop_test.go b/cmd/doctor_workspace_hop_test.go new file mode 100644 index 00000000..25ab320f --- /dev/null +++ b/cmd/doctor_workspace_hop_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/codefly-dev/core/composition" + "github.com/stretchr/testify/require" +) + +// Like a records composition, the routed module authors its authority contract +// and publishes an API catalog. Only the authority's hop consumes hop-routes; +// no workspace or module ships an authored route table. +func doctorHopWorkspace(t *testing.T) string { + t.Helper() + const doc = `{"openapi":"3.0.0","info":{"title":"records","version":"1.0.0"},"paths":{"/records":{"get":{"responses":{"200":{"description":"ok"}}}}}}` + const contractPath = "contracts/api/records.json" + digest := composition.APIContractDigest([]byte(doc)) + catalog, err := json.Marshal(composition.APIContractCatalog{ + Schema: composition.APIContractCatalogSchema, Package: "owner/records", Version: "1.0.0", + Endpoints: []composition.APIContractEndpoint{{Service: "api", Endpoint: "web", API: "http", Kind: composition.APIContractKindOpenAPI, Package: "api", Path: contractPath, Digest: digest, Routes: []composition.APIContractRoute{{Method: "GET", Path: "/records"}}}}, + }) + require.NoError(t, err) + return writeTestWorkspace(t, map[string]string{ + "workspace.codefly.yaml": "name: routes\nlayout: modules\nmodules:\n - name: records\n - name: authority\n", + "configurations/local/hop.env": "MODULES=records\n", + "modules/records/module.codefly.yaml": "name: records\nservices:\n - name: api\n", + "modules/records/module.contract.codefly.yaml": "schema: codefly/module-contract/v1\nprincipal: records\nnamespaces: []\nqueues: []\nscope_ceilings: []\nbindings: []\n", + "modules/records/services/api/service.codefly.yaml": testServiceYAML("api") + "endpoints:\n - name: web\n api: http\n visibility: public\n exposure: none\n", + "modules/records/" + contractPath: doc, + "modules/records/" + composition.APIContractCatalogFileName: string(catalog), + "modules/records/module.package.codefly.yaml": "schema: codefly/module-package/v2\nkind: module-package\nid: owner/records\nversion: 1.0.0\nminimum-codefly-version: '>=0.3.32 <1.0.0'\nartifact-roots: [contracts, services]\ncontracts: {composition: '>=2.0.0 <3.0.0'}\nservices:\n - name: api\n endpoints: [web]\n api-contracts:\n - endpoint: web\n kind: openapi\n package: api\n path: " + contractPath + "\n digest: " + digest + "\n", + "modules/authority/module.codefly.yaml": "name: authority\nservices:\n - name: hop\n", + "modules/authority/services/hop/service.codefly.yaml": testServiceYAML("hop", "hop-routes"), + }) +} + +func TestDoctorWorkspaceRecognizesGeneratedHopRoutes(t *testing.T) { + for _, source := range []string{"workspace", "composed module"} { + t.Run(source, func(t *testing.T) { + dir := doctorHopWorkspace(t) + if source == "composed module" { + moduleConfigs := filepath.Join(dir, "modules/authority/configurations/local") + require.NoError(t, os.MkdirAll(moduleConfigs, 0700)) + require.NoError(t, os.Rename(filepath.Join(dir, "configurations/local/hop.env"), filepath.Join(moduleConfigs, "hop.env"))) + require.NoError(t, os.Remove(filepath.Join(dir, "configurations/local"))) + // A composed module ships configurations from its own workspace. + authority := filepath.Join(t.TempDir(), "authority") + require.NoError(t, os.Rename(filepath.Join(dir, "modules/authority"), authority)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspace.codefly.yaml"), []byte("name: routes\nlayout: modules\nmodules:\n - name: records\n - name: authority\n path: "+authority+"\n"), 0600)) + } + before := snapshotTree(t, dir) + for _, opts := range []workspaceReadinessOptions{ + {dir: dir}, {dir: dir, module: "records"}, {dir: dir, module: "authority"}, {dir: dir, service: "authority/hop"}, + } { + report := runReadiness(t, opts) + require.Equal(t, readinessStatusReady, report.Status, reportJSON(t, report)) + requireNoCode(t, report, codeConfigurationMissing) + requireNoCode(t, report, codeConfigurationDirMissing) + require.Equal(t, "ok", findCheck(report, "configuration references").Status) + } + require.Equal(t, before, snapshotTree(t, dir), "doctor must not materialize generated routes") + }) + } +} + +func TestDoctorWorkspaceStillRefusesInvalidHopInputs(t *testing.T) { + for _, tc := range []struct{ name, path, content, code, reason string }{ + {"no selected modules", "configurations/local/hop.env", "", codeConfigurationMissing, "hop-routes"}, + {"missing catalog", "modules/records/" + composition.APIContractCatalogFileName, "", codeConfigurationInvalid, "not checked"}, + {"bad principal", "modules/records/module.contract.codefly.yaml", "schema: codefly/module-contract/v1\nprincipal: other\n", codeConfigurationInvalid, "principal"}, + {"authored routes", "configurations/local/hop-routes.env", "ROUTES={}\n", codeConfigurationInvalid, "remove its authored configuration"}, + {"private endpoint", "modules/records/services/api/service.codefly.yaml", testServiceYAML("api") + "endpoints:\n - name: web\n api: http\n visibility: private\n", codeConfigurationReference, "private"}, + {"missing ordinary group", "modules/authority/services/hop/service.codefly.yaml", testServiceYAML("hop", "hop-routes", "required-settings"), codeConfigurationMissing, "required-settings"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := doctorHopWorkspace(t) + path := filepath.Join(dir, tc.path) + if tc.content == "" { + require.NoError(t, os.Remove(path)) + } else { + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600)) + } + report := runReadiness(t, workspaceReadinessOptions{dir: dir}) + require.Equal(t, readinessStatusNotReady, report.Status, reportJSON(t, report)) + diagnostic := requireCode(t, report, tc.code, "fail") + require.True(t, strings.Contains(diagnostic.Message, tc.reason), diagnostic.Message) + }) + } +}