Skip to content

Commit bf465c9

Browse files
Merge pull request #700 from codefly-dev/issue-691-fixtures-validate-fixture-at-load-and-add-codefly
feat: validate --fixture at load and add `codefly show fixtures` (#691)
2 parents 8694f06 + 7296c57 commit bf465c9

10 files changed

Lines changed: 1021 additions & 4 deletions

File tree

‎cmd/run/fixture_test.go‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package run
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
corecomposition "github.com/codefly-dev/core/composition"
10+
"github.com/codefly-dev/core/resources"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
const runFixturePackageManifest = `kind: module-package
15+
schema: codefly/module-package/v2
16+
id: saas-starter
17+
version: 1.0.0
18+
minimum-codefly-version: ">=0.3.32"
19+
artifact-roots:
20+
- contracts
21+
contracts:
22+
composition: ">=0.1.0"
23+
fixtures: ">=0.1.0"
24+
fixtures:
25+
- name: dev-admin
26+
description: Seeded administrator
27+
principals:
28+
- id: user-admin
29+
email: admin@example.com
30+
role: admin
31+
token: dev-admin-token
32+
`
33+
34+
func composedFixtureWorkspace(t *testing.T) *resources.Workspace {
35+
t.Helper()
36+
root := t.TempDir()
37+
dir := filepath.Join(root, "saas")
38+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "contracts"), 0o755))
39+
require.NoError(t, os.WriteFile(
40+
filepath.Join(dir, corecomposition.PackageManifestFileName),
41+
[]byte(runFixturePackageManifest), 0o644))
42+
43+
override := "saas"
44+
workspace := &resources.Workspace{
45+
Name: "wiki",
46+
Modules: []*resources.ModuleReference{{Name: "saas", PathOverride: &override}},
47+
}
48+
workspace.WithDir(root)
49+
return workspace
50+
}
51+
52+
func useRunFixtureFlag(t *testing.T, value string) {
53+
t.Helper()
54+
previous := fixture
55+
t.Cleanup(func() { fixture = previous })
56+
fixture = value
57+
}
58+
59+
// The run path must verify the fixture it actually runs with, not the flag. An
60+
// environment declares the fixture its runtime uses, so this workspace runs
61+
// with --fixture unset and a typo in workspace.codefly.yaml reaches the stack
62+
// exactly as an unverified flag would — validating only the flag let it boot
63+
// the whole solution and fail somewhere inside.
64+
func TestRunFixtureRejectsATypoDeclaredByTheEnvironment(t *testing.T) {
65+
useRunFixtureFlag(t, "")
66+
workspace := composedFixtureWorkspace(t)
67+
68+
_, err := runFixture(context.Background(), workspace, &resources.Environment{Fixture: "dev-admn"})
69+
require.Error(t, err, "an environment-declared typo reached the run unverified")
70+
require.Contains(t, err.Error(), "dev-admin", "the error does not name the available fixture")
71+
}
72+
73+
func TestRunFixtureAcceptsAFixtureDeclaredByTheEnvironment(t *testing.T) {
74+
useRunFixtureFlag(t, "")
75+
workspace := composedFixtureWorkspace(t)
76+
77+
selected, err := runFixture(context.Background(), workspace, &resources.Environment{Fixture: "dev-admin"})
78+
require.NoError(t, err)
79+
require.Equal(t, "dev-admin", selected)
80+
}
81+
82+
func TestRunFixtureRejectsATypoPassedAsTheFlag(t *testing.T) {
83+
useRunFixtureFlag(t, "dev-admn")
84+
workspace := composedFixtureWorkspace(t)
85+
86+
_, err := runFixture(context.Background(), workspace, &resources.Environment{})
87+
require.Error(t, err)
88+
}
89+
90+
// The flag wins over the declaration, so the flag's value is the one verified.
91+
func TestRunFixtureVerifiesTheOverrideRatherThanTheDeclaration(t *testing.T) {
92+
useRunFixtureFlag(t, "dev-admin")
93+
workspace := composedFixtureWorkspace(t)
94+
95+
selected, err := runFixture(context.Background(), workspace, &resources.Environment{Fixture: "dev-admn"})
96+
require.NoError(t, err, "the overridden declaration was verified instead of the override")
97+
require.Equal(t, "dev-admin", selected)
98+
}
99+
100+
func TestRunFixtureIgnoresAnUnsetSelection(t *testing.T) {
101+
useRunFixtureFlag(t, "")
102+
workspace := composedFixtureWorkspace(t)
103+
104+
selected, err := runFixture(context.Background(), workspace, &resources.Environment{})
105+
require.NoError(t, err)
106+
require.Empty(t, selected)
107+
}

‎cmd/run/service.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/codefly-dev/cli/cmd/common"
1818
"github.com/codefly-dev/cli/pkg/cli"
19+
"github.com/codefly-dev/cli/pkg/composition"
1920
"github.com/codefly-dev/cli/pkg/engine"
2021
"github.com/codefly-dev/cli/pkg/orchestration"
2122
"github.com/codefly-dev/cli/pkg/processgroup"
@@ -702,6 +703,15 @@ func runEnvironment(workspace *resources.Workspace) (*resources.Environment, err
702703
return env, nil
703704
}
704705

706+
// runFixture resolves the fixture this run uses and verifies it against the
707+
// composed packages. The verified value is the RESOLVED one, not the --fixture
708+
// flag: an environment declares the fixture its runtime uses, so a workspace
709+
// naming one in workspace.codefly.yaml runs with the flag unset, and a typo
710+
// there reaches the stack exactly as an unverified flag would.
711+
func runFixture(ctx context.Context, workspace *resources.Workspace, env *resources.Environment) (string, error) {
712+
return composition.ResolveFixtureSelection(ctx, workspace, env, fixture)
713+
}
714+
705715
// newRunFlow selects the environment and wires the run flow up to — but
706716
// excluding — agent creation, so this call site stays testable without
707717
// spawning agent processes.
@@ -761,7 +771,11 @@ func newRunFlow(ctx context.Context, workspace *resources.Workspace, module *res
761771
flow.WithStartDocker(startDocker)
762772
flow.WithDockerStatus(probeDocker(ctx))
763773
}
764-
flow.WithFixture(orchestration.SelectedFixture(env, fixture))
774+
selectedFixture, err := runFixture(ctx, workspace, env)
775+
if err != nil {
776+
return nil, w.Wrap(err)
777+
}
778+
flow.WithFixture(selectedFixture)
765779
overrides, err := parseSetOverrides(setOverrides)
766780
if err != nil {
767781
return nil, w.Wrap(err)

‎cmd/show.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ var ShowCmd = &cobra.Command{
1616

1717
func init() {
1818
ShowCmd.AddCommand(show.DependenciesCmd)
19+
ShowCmd.AddCommand(show.FixturesCmd)
1920
ShowCmd.AddCommand(show.NetworkCmd)
2021
ShowCmd.AddCommand(show.RunnableCmd)
2122
}

‎cmd/show/commands_test.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
)
88

99
func TestShowCommandsReturnErrorsThroughCobra(t *testing.T) {
10-
for _, command := range []*cobra.Command{DependenciesCmd, NetworkCmd, RunnableCmd} {
10+
for _, command := range []*cobra.Command{DependenciesCmd, FixturesCmd, NetworkCmd, RunnableCmd} {
1111
if command.RunE == nil || command.Run != nil {
1212
t.Errorf("%s is not exclusively RunE", command.Name())
1313
}
@@ -18,6 +18,9 @@ func TestShowCommandsReturnErrorsThroughCobra(t *testing.T) {
1818
if err := NetworkCmd.Args(NetworkCmd, []string{"extra"}); err == nil {
1919
t.Fatal("network accepted a positional argument")
2020
}
21+
if err := FixturesCmd.Args(FixturesCmd, []string{"extra"}); err == nil {
22+
t.Fatal("fixtures accepted a positional argument")
23+
}
2124
}
2225

2326
func TestNetworkMissingWorkspaceReturnsErrorWithoutServiceSelection(t *testing.T) {

‎cmd/show/fixtures.go‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package show
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/codefly-dev/cli/cmd/common"
9+
clicomposition "github.com/codefly-dev/cli/pkg/composition"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var showFixturesJSON bool
14+
15+
// FixturesCmd lists the fixtures the workspace's composed packages declare, with
16+
// the principals each one seeds. A fixture is the contract a solution test
17+
// resolves an identity against instead of hardcoding a seeded login, so this
18+
// reports what `--fixture` can name and what authenticating as one gets you.
19+
var FixturesCmd = &cobra.Command{
20+
Use: "fixtures",
21+
Short: "Show the fixtures the workspace's composed packages declare",
22+
Long: `List every fixture the packages this workspace composes declare, with the
23+
principals each seeds.
24+
25+
A fixture names the state a composed host boots with under CODEFLY__FIXTURE, so
26+
these are exactly the names "codefly run solution --fixture" accepts. Each
27+
principal is reported by id, email and role; role is the lookup key a test
28+
resolves an identity by. Seed tokens are not printed.
29+
30+
A package that cannot be read, and a name two packages both declare, are
31+
reported as problems — after the listing, so the fixtures that did resolve are
32+
still named. The command exits non-zero when it reports one.
33+
34+
Examples:
35+
codefly show fixtures
36+
codefly show fixtures --json`,
37+
Args: cobra.NoArgs,
38+
RunE: func(cmd *cobra.Command, _ []string) error {
39+
ctx, done := common.NewContext()
40+
defer done()
41+
42+
// Deliberately the plain loader: materializing pinned modules writes the
43+
// workspace overlay and .gitignore, which an inspection command must not
44+
// do. Resolving a composed module below still materializes into the
45+
// content-addressed cache, which leaves the workspace untouched.
46+
workspace, err := common.LoadWorkspace(ctx)
47+
if err != nil {
48+
return fmt.Errorf("cannot load workspace: %w", err)
49+
}
50+
51+
fixtures, problems := clicomposition.WorkspaceFixtures(ctx, workspace)
52+
53+
report := fixturesReport{Workspace: workspace.Name, Fixtures: []fixtureReport{}, Problems: []string{}}
54+
for _, fixture := range fixtures {
55+
entry := fixtureReport{
56+
Name: fixture.Name,
57+
Description: fixture.Description,
58+
Principals: []fixturePrincipalReport{},
59+
}
60+
for _, principal := range fixture.Principals {
61+
entry.Principals = append(entry.Principals, fixturePrincipalReport{
62+
ID: principal.ID,
63+
Email: principal.Email,
64+
Role: principal.Role,
65+
})
66+
}
67+
report.Fixtures = append(report.Fixtures, entry)
68+
}
69+
for _, problem := range problems {
70+
report.Problems = append(report.Problems, problem.Error())
71+
}
72+
73+
out := cmd.OutOrStdout()
74+
if showFixturesJSON {
75+
encoder := json.NewEncoder(out)
76+
encoder.SetIndent("", " ")
77+
if err := encoder.Encode(report); err != nil {
78+
return err
79+
}
80+
return errors.Join(problems...)
81+
}
82+
83+
if len(report.Fixtures) == 0 {
84+
fmt.Fprintf(out, "Workspace %q composes no package declaring a fixture.\n", report.Workspace)
85+
} else {
86+
fmt.Fprintf(out, "Fixtures declared by the packages workspace %q composes:\n\n", report.Workspace)
87+
for _, fixture := range report.Fixtures {
88+
fmt.Fprintf(out, "• %s", fixture.Name)
89+
if fixture.Description != "" {
90+
fmt.Fprintf(out, " — %s", fixture.Description)
91+
}
92+
fmt.Fprintln(out)
93+
if len(fixture.Principals) == 0 {
94+
fmt.Fprintln(out, " (seeds no principal)")
95+
continue
96+
}
97+
for _, principal := range fixture.Principals {
98+
fmt.Fprintf(out, " %-16s %-32s [%s]\n", principal.ID, principal.Email, principal.Role)
99+
}
100+
}
101+
}
102+
return errors.Join(problems...)
103+
},
104+
}
105+
106+
// fixturePrincipalReport omits the principal's token: the manifest ships inside
107+
// the package, so the token is a development seed credential rather than a
108+
// secret, but printing it on an inspection command puts it in scrollback and CI
109+
// logs for no one who needed it there.
110+
type fixturePrincipalReport struct {
111+
ID string `json:"id"`
112+
Email string `json:"email"`
113+
Role string `json:"role"`
114+
}
115+
116+
type fixtureReport struct {
117+
Name string `json:"name"`
118+
Description string `json:"description,omitempty"`
119+
Principals []fixturePrincipalReport `json:"principals"`
120+
}
121+
122+
type fixturesReport struct {
123+
Workspace string `json:"workspace"`
124+
Fixtures []fixtureReport `json:"fixtures"`
125+
Problems []string `json:"problems"`
126+
}
127+
128+
func init() {
129+
FixturesCmd.Flags().BoolVar(&showFixturesJSON, "json", false, "Emit machine-readable JSON")
130+
}

‎cmd/test/service.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/codefly-dev/cli/cmd/common"
1111
"github.com/codefly-dev/cli/cmd/run"
1212
"github.com/codefly-dev/cli/pkg/cli"
13+
clicomposition "github.com/codefly-dev/cli/pkg/composition"
1314
"github.com/codefly-dev/cli/pkg/orchestration"
1415
"github.com/codefly-dev/cli/pkg/solutionrun"
1516
runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0"
@@ -274,7 +275,10 @@ func initRunService(ctx context.Context, workspace *resources.Workspace, module
274275
flow.WithInitOnly(initOnly)
275276
flow.WithRuntimeContext(runtimeContext)
276277
flow.WithTestRequest(request)
277-
selectedFixture := orchestration.SelectedFixture(env, testFixture)
278+
selectedFixture, err := clicomposition.ResolveFixtureSelection(ctx, workspace, env, testFixture)
279+
if err != nil {
280+
return nil, w.Wrap(err)
281+
}
278282
flow.WithFixture(selectedFixture)
279283
flow.WithOutputEnv(outputEnv)
280284
flow.WithOverrides(derived.Overrides)

0 commit comments

Comments
 (0)