From 8a596f1998b8d9dcaf82c7658ec46f8fe61d27a0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:39:30 -0400 Subject: [PATCH] fix(doctor): the bindings check takes a naming scope, and --json keeps stdout for the document `doctor workspace` compared runnable-bindings with the preparation in the environment's declared naming scope only. Bindings generated for a run under `--naming-scope` resolve other addresses, so a workspace that runs under a scope could never pass doctor. Doctor now takes `--naming-scope`, read as `run` and `generate runnable-bindings` read it (omitted keeps the environment's, empty clears it), and the remedy names the scope. With `--json`, core's configuration reader logged its warnings on a context without a CLI logger, so they reached wool's fallback Console on stdout ahead of the report. `ProtectResultStream`, the guard the composition verbs already use, now also points wool's fallback at stderr, and doctor installs it under `--json`. Closes #1254 Co-Authored-By: Claude Opus 5.5 --- cmd/composition/result_stream_test.go | 3 ++ cmd/deploy_readiness.go | 2 +- cmd/doctor_workspace.go | 39 ++++++++++++++---- cmd/doctor_workspace_binding_values_test.go | 40 ++++++++++++++++++- ...ctor_workspace_fixture_composition_test.go | 36 +++++++++++++++++ cmd/doctor_workspace_test.go | 4 +- cmd/environment.go | 2 +- cmd/generate/runnable_bindings.go | 6 +++ docs/cli-reference.md | 19 ++++++--- docs/commands.md | 10 ++++- pkg/cli/logger.go | 14 +++++++ 11 files changed, 155 insertions(+), 20 deletions(-) diff --git a/cmd/composition/result_stream_test.go b/cmd/composition/result_stream_test.go index 6913d0178..46e93afc0 100644 --- a/cmd/composition/result_stream_test.go +++ b/cmd/composition/result_stream_test.go @@ -1,6 +1,7 @@ package composition import ( + "context" "encoding/json" "io" "os" @@ -26,6 +27,8 @@ func TestCompositionResultStreamSurvivesConcurrentNarration(t *testing.T) { restore := cli.ProtectResultStream() cli.Info("reaping stale managed process group record=%s", "/runs/2049.pgid") cli.Focus("reconciled managed process group") + // A record on a context with no wool provider takes wool's fallback. + wool.Get(context.Background()).Warn("composed modules disagree on a workspace configuration") restore() require.NoError(t, write.Close()) diff --git a/cmd/deploy_readiness.go b/cmd/deploy_readiness.go index 830dd7d64..25d68be77 100644 --- a/cmd/deploy_readiness.go +++ b/cmd/deploy_readiness.go @@ -22,7 +22,7 @@ const deployReadinessTimeout = 30 * time.Second // `environment.PostImportValidate` uses, for the same reason. func init() { deploy.WorkspaceReadiness = func(ctx context.Context, env, module string) error { - report := workspaceReadiness(ctx, workspaceReadinessOptions{ + report := workspaceReadiness(ctx, &workspaceReadinessOptions{ env: env, module: module, timeout: deployReadinessTimeout, diff --git a/cmd/doctor_workspace.go b/cmd/doctor_workspace.go index fb051bff2..c3ff9c9b5 100644 --- a/cmd/doctor_workspace.go +++ b/cmd/doctor_workspace.go @@ -17,6 +17,7 @@ import ( "github.com/codefly-dev/cli/cmd/generate" "github.com/codefly-dev/cli/cmd/publish" + "github.com/codefly-dev/cli/pkg/cli" "github.com/codefly-dev/cli/pkg/composition" "github.com/codefly-dev/cli/pkg/environments" "github.com/codefly-dev/cli/pkg/orchestration" @@ -120,6 +121,10 @@ type workspaceReadinessOptions struct { module string service string timeout time.Duration + // namingScope, when set, replaces the environment's declared naming scope + // as `codefly run --naming-scope` does: the addresses the runnable + // bindings are checked against are those of a run in that scope. + namingScope *string } // workspaceReadiness runs the read-only readiness validation and always @@ -127,7 +132,7 @@ type workspaceReadinessOptions struct { // must not create, modify, or delete any file, and must not start agents, // containers, or services. Secret references are resolved in memory through // the environment's configured backend and the values discarded immediately. -func workspaceReadiness(ctx context.Context, opts workspaceReadinessOptions) *workspaceReadinessReport { +func workspaceReadiness(ctx context.Context, opts *workspaceReadinessOptions) *workspaceReadinessReport { if opts.timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, opts.timeout) @@ -157,6 +162,9 @@ func workspaceReadiness(ctx context.Context, opts workspaceReadinessOptions) *wo if env == nil { return report } + if opts.namingScope != nil { + env.NamingScope = *opts.namingScope + } checkProviderBindings(ctx, ws, env, report) @@ -192,7 +200,7 @@ func workspaceReadiness(ctx context.Context, opts workspaceReadinessOptions) *wo return report } -func checkWorkspace(ctx context.Context, opts workspaceReadinessOptions, report *workspaceReadinessReport) *resources.Workspace { +func checkWorkspace(ctx context.Context, opts *workspaceReadinessOptions, report *workspaceReadinessReport) *resources.Workspace { dir := opts.dir if dir == "" { found, err := resources.FindUp[resources.Workspace](ctx) @@ -1312,7 +1320,7 @@ func checkRunnableBindingsGroup(ctx context.Context, ws *resources.Workspace, en missing = append(missing, key) } } - remedy := fmt.Sprintf("run `codefly generate runnable-bindings --env %s`", env.Name) + remedy := fmt.Sprintf("run `%s`", generate.RunnableBindingsRegenerateCommand(env)) switch { case len(missing) > 0 && info == nil: report.add(codeConfigurationMissing, "workspace configurations", checkStatusFail, @@ -1734,6 +1742,8 @@ var ( doctorWorkspaceService string doctorWorkspaceJSON bool doctorWorkspaceTimeout time.Duration + + doctorWorkspaceNamingScope string ) var DoctorWorkspaceCmd = &cobra.Command{ @@ -1759,7 +1769,8 @@ Exit codes: 0 the workspace is ready (warnings allowed) 1 at least one check failed, or the command itself failed -With --json, a versioned report is printed to stdout: +With --json, a versioned report is printed to stdout, and stdout carries +nothing else: every log line goes to stderr. {schema_version, workspace, workspace_dir, environment, environment_declared, module?, service?, status: ready|not_ready, checks: [{code, name, status, message, remediation?}]} @@ -1768,6 +1779,11 @@ With --json, a versioned report is printed to stdout: dependencies, so a sibling unit's missing configuration does not fail the check. --service is the narrower of the two and wins when both are given. +--naming-scope checks the generated runnable bindings against the addresses +of a run in that scope, as ` + "`codefly run --naming-scope`" + ` and +` + "`codefly generate runnable-bindings --naming-scope`" + ` read it: omitted keeps +the environment's declared scope, empty clears it. + Stable diagnostic codes: workspace_not_found, workspace_invalid, environment_not_found, module_not_found, service_not_found, module_reference_unresolved, @@ -1785,12 +1801,20 @@ timeout. External provider binding checks add external_provider.* codes (bindings_unreadable, bindings_schema_unknown, and the per-binding validation codes).`, RunE: func(cmd *cobra.Command, _ []string) error { - report := workspaceReadiness(cmd.Context(), workspaceReadinessOptions{ + if doctorWorkspaceJSON { + // stdout carries the report alone; every log line goes to stderr. + defer cli.ProtectResultStream()() + } + opts := workspaceReadinessOptions{ env: doctorWorkspaceEnv, module: doctorWorkspaceModule, service: doctorWorkspaceService, timeout: doctorWorkspaceTimeout, - }) + } + if cmd.Flags().Changed("naming-scope") { + opts.namingScope = &doctorWorkspaceNamingScope + } + report := workspaceReadiness(cmd.Context(), &opts) if doctorWorkspaceJSON { payload, err := json.MarshalIndent(report, "", " ") @@ -1837,7 +1861,8 @@ func init() { DoctorWorkspaceCmd.Flags().StringVar(&doctorWorkspaceEnv, "env", "local", "Environment to validate against") DoctorWorkspaceCmd.Flags().StringVar(&doctorWorkspaceModule, "module", "", "Restrict validation to one module's services and their declared configuration dependencies") DoctorWorkspaceCmd.Flags().StringVar(&doctorWorkspaceService, "service", "", "Restrict validation to one service's declared configuration dependencies") - DoctorWorkspaceCmd.Flags().BoolVar(&doctorWorkspaceJSON, "json", false, "Print a machine-readable report to stdout") + DoctorWorkspaceCmd.Flags().BoolVar(&doctorWorkspaceJSON, "json", false, "Print a machine-readable report to stdout; log lines go to stderr") + DoctorWorkspaceCmd.Flags().StringVar(&doctorWorkspaceNamingScope, "naming-scope", "", "Naming `scope` the runnable bindings are checked in, as codefly run --naming-scope reads it: omitted keeps the environment's, empty clears it") DoctorWorkspaceCmd.Flags().DurationVar(&doctorWorkspaceTimeout, "timeout", 30*time.Second, "Overall bound; secret resolution is cancelled when it expires") DoctorCmd.AddCommand(DoctorWorkspaceCmd) } diff --git a/cmd/doctor_workspace_binding_values_test.go b/cmd/doctor_workspace_binding_values_test.go index 035da4c48..2fb32b1d6 100644 --- a/cmd/doctor_workspace_binding_values_test.go +++ b/cmd/doctor_workspace_binding_values_test.go @@ -13,7 +13,11 @@ import ( "github.com/stretchr/testify/require" ) -func TestDoctorUsesEffectiveRunnableValueAndPreservesAuthoredFile(t *testing.T) { +// derivedRunnableWorkspace is a workspace whose module backend serves one +// HTTP endpoint with derived runnable operations, as `generate contracts` and +// `generate runnables` leave it, with the current directory set to it. +func derivedRunnableWorkspace(t *testing.T) string { + t.Helper() artifact, err := os.ReadFile("generate/testdata/solution-runnables/api.swagger.json") require.NoError(t, err) dir := singleServiceWorkspace(t, testWorkspaceYAMLLocalDeclared, []string{generate.RunnableBindingsGroup}, map[string]string{ @@ -46,6 +50,11 @@ destinations: t.Chdir(dir) require.NoError(t, generate.ContractsCmd.RunE(generate.ContractsCmd, []string{"backend"})) require.NoError(t, generate.RunnablesCmd.RunE(generate.RunnablesCmd, []string{"backend"})) + return dir +} + +func TestDoctorUsesEffectiveRunnableValueAndPreservesAuthoredFile(t *testing.T) { + dir := derivedRunnableWorkspace(t) require.NoError(t, generate.RunnableBindingsCmd.RunE(generate.RunnableBindingsCmd, nil)) path := filepath.Join(dir, "configurations/local/runnable-bindings.env") fresh, err := os.ReadFile(path) @@ -83,3 +92,32 @@ destinations: }) } } + +// A run under a naming scope resolves other addresses, so its bindings are +// generated in that scope. Doctor checks them in the scope it is told, as +// `codefly run --naming-scope` and the generator read it, and in the +// environment's own when it is told none. +func TestDoctorChecksRunnableBindingsInTheNamedScope(t *testing.T) { + dir := derivedRunnableWorkspace(t) + flags := generate.RunnableBindingsCmd.Flags() + require.NoError(t, flags.Set("naming-scope", "gate-x")) + t.Cleanup(func() { + require.NoError(t, flags.Set("naming-scope", "")) + flags.Lookup("naming-scope").Changed = false + }) + require.NoError(t, generate.RunnableBindingsCmd.RunE(generate.RunnableBindingsCmd, nil)) + + report := runReadiness(t, workspaceReadinessOptions{dir: dir, namingScope: new("gate-x")}) + require.Equal(t, readinessStatusReady, report.Status, reportJSON(t, report)) + + for remedy, opts := range map[string]workspaceReadinessOptions{ + "run `codefly generate runnable-bindings --env local`": {dir: dir}, + "run `codefly generate runnable-bindings --env local --naming-scope gate-y`": {dir: dir, namingScope: new("gate-y")}, + } { + t.Run(remedy, func(t *testing.T) { + diagnostic := requireCode(t, runReadiness(t, opts), codeConfigurationInvalid, checkStatusFail) + require.Contains(t, diagnostic.Message, "out of date") + require.Equal(t, remedy, diagnostic.Remediation) + }) + } +} diff --git a/cmd/doctor_workspace_fixture_composition_test.go b/cmd/doctor_workspace_fixture_composition_test.go index ecdbe193a..69797332d 100644 --- a/cmd/doctor_workspace_fixture_composition_test.go +++ b/cmd/doctor_workspace_fixture_composition_test.go @@ -1,6 +1,9 @@ package cmd import ( + "context" + "encoding/json" + "io" "os" "path/filepath" "strings" @@ -89,3 +92,36 @@ func TestDoctorWorkspaceStillRefusesTwoProvidersOfAnOrdinaryGroup(t *testing.T) require.True(t, strings.Contains(d.Message, "engine"), d.Message) require.False(t, strings.Contains(d.Message, fixturefragments.Group), d.Message) } + +// --json makes stdout the report and nothing else. Core logs the composed +// modules' disagreement over fixture-composition while reading the +// configurations, on a context with no logger of the CLI's, so the line takes +// wool's fallback; it belongs on stderr, where it stays visible. +func TestDoctorWorkspaceJSONKeepsStdoutForTheReport(t *testing.T) { + t.Chdir(twoFragmentWorkspace(t, nil)) + stdout, stderr := os.Stdout, os.Stderr + outRead, outWrite, err := os.Pipe() + require.NoError(t, err) + errRead, errWrite, err := os.Pipe() + require.NoError(t, err) + os.Stdout, os.Stderr = outWrite, errWrite + t.Cleanup(func() { os.Stdout, os.Stderr = stdout, stderr }) + require.NoError(t, DoctorWorkspaceCmd.Flags().Set("json", "true")) + t.Cleanup(func() { doctorWorkspaceJSON = false; DoctorWorkspaceCmd.Flags().Lookup("json").Changed = false }) + DoctorWorkspaceCmd.SetContext(context.Background()) + + runErr := DoctorWorkspaceCmd.RunE(DoctorWorkspaceCmd, nil) + require.NoError(t, outWrite.Close()) + require.NoError(t, errWrite.Close()) + os.Stdout, os.Stderr = stdout, stderr + emitted, err := io.ReadAll(outRead) + require.NoError(t, err) + logged, err := io.ReadAll(errRead) + require.NoError(t, err) + + require.NoError(t, runErr, string(emitted)) + require.Contains(t, string(logged), "composed modules disagree", "the fixture no longer produces the warning this test is about") + var report workspaceReadinessReport + require.NoError(t, json.Unmarshal(emitted, &report), "stdout is not the report alone:\n%s", emitted) + require.Equal(t, readinessStatusReady, report.Status) +} diff --git a/cmd/doctor_workspace_test.go b/cmd/doctor_workspace_test.go index 109141b17..de2e3e0f9 100644 --- a/cmd/doctor_workspace_test.go +++ b/cmd/doctor_workspace_test.go @@ -100,7 +100,7 @@ func runReadiness(t *testing.T, opts workspaceReadinessOptions) *workspaceReadin if opts.timeout == 0 { opts.timeout = 30 * time.Second } - return workspaceReadiness(context.Background(), opts) + return workspaceReadiness(context.Background(), &opts) } func findDiagnostics(report *workspaceReadinessReport, code string) []workspaceDiagnostic { @@ -992,7 +992,7 @@ func TestDoctorWorkspaceConcurrentChecksAreIndependent(t *testing.T) { dir, baseline = failingDir, failingBaseline } wg.Go(func() { - report := workspaceReadiness(context.Background(), workspaceReadinessOptions{dir: dir, env: "local", timeout: 30 * time.Second}) + report := workspaceReadiness(context.Background(), &workspaceReadinessOptions{dir: dir, env: "local", timeout: 30 * time.Second}) data, err := json.Marshal(report) if err != nil { errs <- err diff --git a/cmd/environment.go b/cmd/environment.go index 34eaf2934..47d17772c 100644 --- a/cmd/environment.go +++ b/cmd/environment.go @@ -14,7 +14,7 @@ import ( // cannot import cmd back to call workspaceReadiness directly. func init() { environment.PostImportValidate = func(ctx context.Context, dir, env string) error { - report := workspaceReadiness(ctx, workspaceReadinessOptions{dir: dir, env: env}) + report := workspaceReadiness(ctx, &workspaceReadinessOptions{dir: dir, env: env}) fmt.Println(tui.RenderHeader(1, fmt.Sprintf("codefly doctor workspace --env %s", env))) for i := range report.Checks { printWorkspaceDiagnostic(&report.Checks[i]) diff --git a/cmd/generate/runnable_bindings.go b/cmd/generate/runnable_bindings.go index a833ccd37..6cd42edff 100644 --- a/cmd/generate/runnable_bindings.go +++ b/cmd/generate/runnable_bindings.go @@ -255,6 +255,12 @@ func runnableBindingsRegenerate(env *environments.Environment, callEndpoint stri return command } +// RunnableBindingsRegenerateCommand is the command that regenerates the +// bindings env is checked against, naming its scope when it has one. +func RunnableBindingsRegenerateCommand(env *environments.Environment) string { + return runnableBindingsRegenerate(env, env.RunnableCallEndpoint) +} + // preparedRunnableBindings is what one environment's preparation produced: // the configuration file, how many operations it binds, and the keys the // environment declined. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 44e5283fb..331d74313 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2679,7 +2679,8 @@ Exit codes: 0 the workspace is ready (warnings allowed) 1 at least one check failed, or the command itself failed -With --json, a versioned report is printed to stdout: +With --json, a versioned report is printed to stdout, and stdout carries +nothing else: every log line goes to stderr. {schema_version, workspace, workspace_dir, environment, environment_declared, module?, service?, status: ready|not_ready, checks: [{code, name, status, message, remediation?}]} @@ -2688,6 +2689,11 @@ With --json, a versioned report is printed to stdout: dependencies, so a sibling unit's missing configuration does not fail the check. --service is the narrower of the two and wins when both are given. +--naming-scope checks the generated runnable bindings against the addresses +of a run in that scope, as `codefly run --naming-scope` and +`codefly generate runnable-bindings --naming-scope` read it: omitted keeps +the environment's declared scope, empty clears it. + Stable diagnostic codes: workspace_not_found, workspace_invalid, environment_not_found, module_not_found, service_not_found, module_reference_unresolved, @@ -2713,11 +2719,12 @@ codefly doctor workspace [flags] Flags: ``` - --env string Environment to validate against (default "local") - --json Print a machine-readable report to stdout - --module string Restrict validation to one module's services and their declared configuration dependencies - --service string Restrict validation to one service's declared configuration dependencies - --timeout duration Overall bound; secret resolution is cancelled when it expires (default 30s) + --env string Environment to validate against (default "local") + --json Print a machine-readable report to stdout; log lines go to stderr + --module string Restrict validation to one module's services and their declared configuration dependencies + --naming-scope scope Naming scope the runnable bindings are checked in, as codefly run --naming-scope reads it: omitted keeps the environment's, empty clears it + --service string Restrict validation to one service's declared configuration dependencies + --timeout duration Overall bound; secret resolution is cancelled when it expires (default 30s) ``` ## `codefly endpoint` diff --git a/docs/commands.md b/docs/commands.md index 01e96b8c3..a79c7eadd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4664,6 +4664,7 @@ codefly doctor workspace --env staging # validate a declared environment codefly doctor workspace --module payments # restrict to one module's services codefly doctor workspace --service api # restrict to one service's declared dependencies codefly doctor workspace --json # machine-readable report (for worktree managers) +codefly doctor workspace --naming-scope gate # check runnable bindings generated for a run in scope `gate` codefly doctor workspace --timeout 10s # bound secret-provider resolution ``` @@ -4712,7 +4713,11 @@ What it checks, in order: value is ok; a selected binding the group does not hold fails as `configuration_missing` with `codefly generate runnable-bindings --env ` as its remedy. Existing binding values are also compared with the - generator's current preparation: changed addresses, contracts or policies, + generator's current preparation, in the naming scope `--naming-scope` + names, read as `codefly run --naming-scope` and `generate runnable-bindings + --naming-scope` read it (omitted keeps the environment's declared scope, + empty clears it), so bindings generated for a run in a scope are checked in + that scope and the remedy names it: changed addresses, contracts or policies, obsolete extra bindings and output left after the last operation disappears fail as `configuration_invalid`. This comparison checks effective values; the generator's `--check` additionally checks exact file bytes. The delivery @@ -4748,7 +4753,8 @@ narrower of the two and wins when both are given. **Exit codes:** `0` — ready (warnings allowed); `1` — at least one check failed (or the command itself failed). -**JSON contract** (`--json`, stdout): `{schema_version: 1, workspace, +**JSON contract** (`--json`, stdout — and stdout carries nothing else: every +log line, including core's warnings, goes to stderr): `{schema_version: 1, workspace, workspace_dir, environment, environment_declared, module?, service?, status: "ready"|"not_ready", checks: [{code, name, status: "ok"|"warn"|"fail", message, remediation?}]}`. Output never contains configuration values, raw diff --git a/pkg/cli/logger.go b/pkg/cli/logger.go index 01ffa8876..69b006b02 100644 --- a/pkg/cli/logger.go +++ b/pkg/cli/logger.go @@ -125,6 +125,11 @@ func RestoreOutput() { // for `| jq` and for anything reading it back. Diagnostics go to stderr // instead of being dropped, so a failure is still visible. // +// A record logged on a context that carries no wool provider — core's +// configuration reader logs on the command's own context — reaches wool's +// fallback Console, which prints to stdout; the guard points that fallback at +// stderr too. +// // It is a no-op when something already owns the stream (pkg/mcp installs the // same guard for JSON-RPC), so a nested call cannot hand stdout back early. func ProtectResultStream() func() { @@ -132,16 +137,25 @@ func ProtectResultStream() func() { return func() {} } SetOutputSink(func(_ wool.Loglevel, message string) { fmt.Fprintln(os.Stderr, message) }) + wool.SetFallbackLogger(stderrLogger{}) SuppressOutput() var undo sync.Once return func() { undo.Do(func() { RestoreOutput() + wool.SetFallbackLogger(nil) SetOutputSink(nil) }) } } +// stderrLogger prints a wool record to stderr. wool has already filtered it +// against the effective level, scope overrides included, so it does not +// filter again. +type stderrLogger struct{} + +func (stderrLogger) Process(log *wool.Log) { fmt.Fprintln(os.Stderr, log.String()) } + // OutputSuppressed reports whether narration is currently held off stdout. func OutputSuppressed() bool { return cliLogger.isSuppressed()