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
40 changes: 30 additions & 10 deletions cmd/doctor_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -897,16 +898,15 @@ 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
// fault of the workspace, reported as one — unlike the branch above,
// 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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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:
Expand Down
93 changes: 93 additions & 0 deletions cmd/doctor_workspace_hop_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading