Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/composition/result_stream_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package composition

import (
"context"
"encoding/json"
"io"
"os"
Expand All @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion cmd/deploy_readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 32 additions & 7 deletions cmd/doctor_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -120,14 +121,18 @@ 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
// returns a report; every failure is a diagnostic, never a bare error. It
// 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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1734,6 +1742,8 @@ var (
doctorWorkspaceService string
doctorWorkspaceJSON bool
doctorWorkspaceTimeout time.Duration

doctorWorkspaceNamingScope string
)

var DoctorWorkspaceCmd = &cobra.Command{
Expand All @@ -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?}]}
Expand All @@ -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,
Expand All @@ -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, "", " ")
Expand Down Expand Up @@ -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)
}
40 changes: 39 additions & 1 deletion cmd/doctor_workspace_binding_values_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
})
}
}
36 changes: 36 additions & 0 deletions cmd/doctor_workspace_fixture_composition_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import (
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions cmd/doctor_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
6 changes: 6 additions & 0 deletions cmd/generate/runnable_bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 13 additions & 6 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?}]}
Expand All @@ -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,
Expand All @@ -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`
Expand Down
10 changes: 8 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
<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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions pkg/cli/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,23 +125,37 @@ 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() {
if cliLogger.isSuppressed() {
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()
Expand Down
Loading