diff --git a/cli/cmd/cmds/run.go b/cli/cmd/cmds/run.go index 887d919f..2fc0f3ab 100644 --- a/cli/cmd/cmds/run.go +++ b/cli/cmd/cmds/run.go @@ -26,7 +26,7 @@ func (c *RunCmd) Run(ctx run.RunContext) error { } ctx.Logger.Info("Executing Earthly target", "project", project.Path, "target", ref.Target) - runner := run.NewDefaultProjectRunner(ctx, &project) + runner := earthly.NewDefaultProjectRunner(ctx, &project) if err := runner.RunTarget( ref.Target, generateOpts(c, ctx)..., diff --git a/cli/pkg/run/mocks/runner.go b/cli/pkg/earthly/mocks/runner.go similarity index 84% rename from cli/pkg/run/mocks/runner.go rename to cli/pkg/earthly/mocks/runner.go index 8975759c..30feb9ab 100644 --- a/cli/pkg/run/mocks/runner.go +++ b/cli/pkg/earthly/mocks/runner.go @@ -5,26 +5,25 @@ package mocks import ( "github.com/input-output-hk/catalyst-forge/cli/pkg/earthly" - "github.com/input-output-hk/catalyst-forge/cli/pkg/run" "sync" ) -// Ensure, that ProjectRunnerMock does implement run.ProjectRunner. +// Ensure, that ProjectRunnerMock does implement earthly.ProjectRunner. // If this is not the case, regenerate this file with moq. -var _ run.ProjectRunner = &ProjectRunnerMock{} +var _ earthly.ProjectRunner = &ProjectRunnerMock{} -// ProjectRunnerMock is a mock implementation of run.ProjectRunner. +// ProjectRunnerMock is a mock implementation of earthly.ProjectRunner. // // func TestSomethingThatUsesProjectRunner(t *testing.T) { // -// // make and configure a mocked run.ProjectRunner +// // make and configure a mocked earthly.ProjectRunner // mockedProjectRunner := &ProjectRunnerMock{ // RunTargetFunc: func(target string, opts ...earthly.EarthlyExecutorOption) error { // panic("mock out the RunTarget method") // }, // } // -// // use mockedProjectRunner in code that requires run.ProjectRunner +// // use mockedProjectRunner in code that requires earthly.ProjectRunner // // and then make assertions. // // } diff --git a/cli/pkg/earthly/project.go b/cli/pkg/earthly/project.go new file mode 100644 index 00000000..70652b96 --- /dev/null +++ b/cli/pkg/earthly/project.go @@ -0,0 +1,183 @@ +package earthly + +import ( + "fmt" + "log/slog" + "regexp" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "github.com/input-output-hk/catalyst-forge/cli/pkg/executor" + "github.com/input-output-hk/catalyst-forge/cli/pkg/run" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/secrets" + "github.com/input-output-hk/catalyst-forge/lib/schema" + sp "github.com/input-output-hk/catalyst-forge/lib/schema/blueprint/project" +) + +var ( + ErrNoMatchingTargets = fmt.Errorf("no matching targets found") +) + +//go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/runner.go . ProjectRunner + +// ProjectRunner is an interface for running Earthly targets for a project. +type ProjectRunner interface { + RunTarget(target string, opts ...EarthlyExecutorOption) error +} + +// DefaultProjectRunner is the default implementation of the ProjectRunner interface. +type DefaultProjectRunner struct { + ctx run.RunContext + exectuor executor.Executor + logger *slog.Logger + project *project.Project + store secrets.SecretStore +} + +// RunTarget runs the given Earthly target. +func (p *DefaultProjectRunner) RunTarget( + target string, + opts ...EarthlyExecutorOption, +) error { + popts, err := p.generateOpts(target) + if err != nil { + return err + } + + return NewEarthlyExecutor( + p.project.Path, + target, + p.exectuor, + p.store, + p.logger, + append(popts, opts...)..., + ).Run() +} + +// generateOpts generates the options for the Earthly executor. +func (p *DefaultProjectRunner) generateOpts(target string) ([]EarthlyExecutorOption, error) { + var opts []EarthlyExecutorOption + + if schema.HasProjectCiDefined(p.project.Blueprint) { + targetConfig, err := p.unifyTargets(p.project.Blueprint.Project.Ci.Targets, target) + if err != nil && err != ErrNoMatchingTargets { + return nil, err + } else if err != ErrNoMatchingTargets { + if len(targetConfig.Args) > 0 { + var args []string + for k, v := range targetConfig.Args { + args = append(args, fmt.Sprintf("--%s", k), v) + } + + opts = append(opts, WithTargetArgs(args...)) + } + + // We only run multiple platforms in CI mode to avoid issues with local builds. + if targetConfig.Platforms != nil && p.ctx.CI { + opts = append(opts, WithPlatforms(targetConfig.Platforms...)) + } + + if targetConfig.Privileged { + opts = append(opts, WithPrivileged()) + } + + if targetConfig.Retries > 0 { + opts = append(opts, WithRetries(int(targetConfig.Retries))) + } + + if len(targetConfig.Secrets) > 0 { + opts = append(opts, WithSecrets(targetConfig.Secrets)) + } + } + } + + if schema.HasEarthlyProviderDefined(p.project.Blueprint) { + if p.project.Blueprint.Global.Ci.Providers.Earthly.Satellite != "" && !p.ctx.Local { + opts = append(opts, WithSatellite(p.project.Blueprint.Global.Ci.Providers.Earthly.Satellite)) + } + } + + if schema.HasGlobalCIDefined(p.project.Blueprint) { + if len(p.project.Blueprint.Global.Ci.Secrets) > 0 { + opts = append(opts, WithSecrets(p.project.Blueprint.Global.Ci.Secrets)) + } + } + + return opts, nil +} + +// unifyTargets unifies the targets that match the given name. +func (p *DefaultProjectRunner) unifyTargets( + Targets map[string]sp.Target, + name string, +) (sp.Target, error) { + var targets []string + for target := range Targets { + filter, err := regexp.Compile(target) + if err != nil { + return sp.Target{}, fmt.Errorf("failed to compile target name '%s' to regex: %w", name, err) + } + + if filter.MatchString(name) { + targets = append(targets, target) + } + } + + if len(targets) == 0 { + return sp.Target{}, ErrNoMatchingTargets + } + + var rt cue.Value + ctx := cuecontext.New() + for _, target := range targets { + rt = rt.Unify(ctx.Encode(Targets[target])) + } + + if rt.Err() != nil { + return sp.Target{}, fmt.Errorf("failed to unify targets: %w", rt.Err()) + } + + var target sp.Target + if err := rt.Decode(&target); err != nil { + return sp.Target{}, fmt.Errorf("failed to decode unified targets: %w", err) + } + + return target, nil +} + +// NewDefaultProjectRunner creates a new DefaultProjectRunner instance. +func NewDefaultProjectRunner( + ctx run.RunContext, + project *project.Project, +) DefaultProjectRunner { + e := executor.NewLocalExecutor( + ctx.Logger, + executor.WithRedirect(), + ) + + return DefaultProjectRunner{ + ctx: ctx, + exectuor: e, + logger: ctx.Logger, + project: project, + store: ctx.SecretStore, + } +} + +// NewCustomDefaultProjectRunner creates a new DefaultProjectRunner instance with custom dependencies. +func NewCustomDefaultProjectRunner( + ctx run.RunContext, + exec executor.Executor, + logger *slog.Logger, + project *project.Project, + store secrets.SecretStore, +) DefaultProjectRunner { + return DefaultProjectRunner{ + ctx: ctx, + exectuor: exec, + logger: logger, + project: project, + store: store, + } +} diff --git a/cli/pkg/earthly/project_test.go b/cli/pkg/earthly/project_test.go new file mode 100644 index 00000000..b508bbb4 --- /dev/null +++ b/cli/pkg/earthly/project_test.go @@ -0,0 +1,180 @@ +package earthly + +import ( + "log/slog" + "testing" + + "cuelang.org/go/cue/cuecontext" + "github.com/input-output-hk/catalyst-forge/cli/internal/testutils" + emocks "github.com/input-output-hk/catalyst-forge/cli/pkg/executor/mocks" + "github.com/input-output-hk/catalyst-forge/cli/pkg/run" + "github.com/input-output-hk/catalyst-forge/lib/project/blueprint" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/secrets" + smocks "github.com/input-output-hk/catalyst-forge/lib/project/secrets/mocks" + schema "github.com/input-output-hk/catalyst-forge/lib/schema/blueprint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_generateOpts(t *testing.T) { + tests := []struct { + name string + target string + src string + projectPath string + ctx run.RunContext + err bool + validate func(t *testing.T, ee EarthlyExecutor) + }{ + { + name: "full", + target: "target", + src: ` + { + global: ci: { + providers: earthly: satellite: "sat" + secrets: [ + { + name: "bar" + provider: "mock" + path: "baz" + } + ] + } + project: ci: targets: { + nontarget: { + args: { + bar: "baz" + } + } + target: { + args: { + foo: "bar" + } + platforms: ["linux/amd64"] + privileged: true + retries: 3 + secrets: [ + { + name: "foo" + provider: "mock" + path: "bar" + } + ] + } + } + }`, + ctx: run.RunContext{ + CI: true, + }, + validate: func(t *testing.T, ee EarthlyExecutor) { + assert.Contains(t, ee.targetArgs, "--foo") + assert.Contains(t, ee.targetArgs, "bar") + assert.NotContains(t, ee.targetArgs, "--bar") + assert.NotContains(t, ee.targetArgs, "baz") + assert.Contains(t, ee.opts.platforms, "linux/amd64") + assert.Contains(t, ee.earthlyArgs, "--allow-privileged") + assert.Equal(t, 3, ee.opts.retries) + assert.Len(t, ee.secrets, 2) + + assert.Contains(t, ee.earthlyArgs, "--sat") + assert.Contains(t, ee.earthlyArgs, "sat") + }, + }, + { + name: "unified", + target: "target", + src: ` + { + project: ci: targets: { + ".*": { + args: { + bar: "baz" + } + } + "tar\\w+": { + privileged: true + } + target: { + args: { + foo: "bar" + } + } + } + }`, + ctx: run.RunContext{}, + validate: func(t *testing.T, ee EarthlyExecutor) { + assert.Contains(t, ee.targetArgs, "--foo") + assert.Contains(t, ee.targetArgs, "bar") + assert.Contains(t, ee.targetArgs, "--bar") + assert.Contains(t, ee.targetArgs, "baz") + assert.Contains(t, ee.earthlyArgs, "--allow-privileged") + }, + }, + { + name: "bad unified", + target: "target", + src: ` + { + project: ci: targets: { + ".*": { + platforms: ["linux/arm64"] + } + target: { + platforms: ["linux/amd64"] + } + } + }`, + ctx: run.RunContext{}, + err: true, + validate: func(t *testing.T, ee EarthlyExecutor) {}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := cuecontext.New() + rbp := ctx.CompileString(tt.src) + require.NoError(t, rbp.Err()) + + var bp schema.Blueprint + require.NoError(t, rbp.Decode(&bp)) + + executor := emocks.ExecutorMock{ + ExecuteFunc: func(command string, args ...string) ([]byte, error) { + return nil, nil + }, + } + + store := secrets.NewSecretStore(map[secrets.Provider]func(*slog.Logger) (secrets.SecretProvider, error){ + secrets.Provider("mock"): func(logger *slog.Logger) (secrets.SecretProvider, error) { + return &smocks.SecretProviderMock{}, nil + }, + }) + + p := &DefaultProjectRunner{ + ctx: tt.ctx, + exectuor: &executor, + logger: testutils.NewNoopLogger(), + project: &project.Project{ + Blueprint: bp, + RawBlueprint: blueprint.NewRawBlueprint(rbp), + }, + store: store, + } + + ee := EarthlyExecutor{} + opts, err := p.generateOpts(tt.target) + if !tt.err { + require.NoError(t, err) + } + + for _, opt := range opts { + opt(&ee) + } + + tt.validate(t, ee) + }) + } +} diff --git a/cli/pkg/release/providers/docker.go b/cli/pkg/release/providers/docker.go index 4eb085eb..bc29a9da 100644 --- a/cli/pkg/release/providers/docker.go +++ b/cli/pkg/release/providers/docker.go @@ -34,7 +34,7 @@ type DockerReleaser struct { project project.Project release sp.Release releaseName string - runner run.ProjectRunner + runner earthly.ProjectRunner } func (r *DockerReleaser) Release() error { @@ -238,7 +238,7 @@ func NewDockerReleaser( docker := executor.NewLocalWrappedExecutor(exec, "docker") handler := events.NewDefaultEventHandler(ctx.Logger) - runner := run.NewDefaultProjectRunner(ctx, &project) + runner := earthly.NewDefaultProjectRunner(ctx, &project) return &DockerReleaser{ config: config, docker: docker, diff --git a/cli/pkg/release/providers/github.go b/cli/pkg/release/providers/github.go index 986d75d7..1ef8dd77 100644 --- a/cli/pkg/release/providers/github.go +++ b/cli/pkg/release/providers/github.go @@ -33,7 +33,7 @@ type GithubReleaser struct { project project.Project release sp.Release releaseName string - runner run.ProjectRunner + runner earthly.ProjectRunner workdir string } @@ -210,7 +210,7 @@ func NewGithubReleaser( } handler := events.NewDefaultEventHandler(ctx.Logger) - runner := run.NewDefaultProjectRunner(ctx, &project) + runner := earthly.NewDefaultProjectRunner(ctx, &project) return &GithubReleaser{ config: config, client: client, diff --git a/cli/pkg/release/providers/providers_test.go b/cli/pkg/release/providers/providers_test.go index a90893f1..c35bd061 100644 --- a/cli/pkg/release/providers/providers_test.go +++ b/cli/pkg/release/providers/providers_test.go @@ -5,8 +5,8 @@ import ( "cuelang.org/go/cue" "github.com/input-output-hk/catalyst-forge/cli/pkg/earthly" + emocks "github.com/input-output-hk/catalyst-forge/cli/pkg/earthly/mocks" evmocks "github.com/input-output-hk/catalyst-forge/cli/pkg/events/mocks" - rmocks "github.com/input-output-hk/catalyst-forge/cli/pkg/run/mocks" "github.com/input-output-hk/catalyst-forge/lib/project/project" ) @@ -18,8 +18,8 @@ func newReleaseEventHandlerMock(firing bool) *evmocks.EventHandlerMock { } } -func newProjectRunnerMock(fail bool) *rmocks.ProjectRunnerMock { - return &rmocks.ProjectRunnerMock{ +func newProjectRunnerMock(fail bool) *emocks.ProjectRunnerMock { + return &emocks.ProjectRunnerMock{ RunTargetFunc: func(target string, opts ...earthly.EarthlyExecutorOption) error { if fail { return fmt.Errorf("failed to run release target") diff --git a/cli/pkg/run/project.go b/cli/pkg/run/project.go deleted file mode 100644 index 0edea48f..00000000 --- a/cli/pkg/run/project.go +++ /dev/null @@ -1,126 +0,0 @@ -package run - -import ( - "fmt" - "log/slog" - - "github.com/input-output-hk/catalyst-forge/cli/pkg/earthly" - "github.com/input-output-hk/catalyst-forge/cli/pkg/executor" - "github.com/input-output-hk/catalyst-forge/lib/project/project" - "github.com/input-output-hk/catalyst-forge/lib/project/secrets" - "github.com/input-output-hk/catalyst-forge/lib/schema" -) - -//go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/runner.go . ProjectRunner - -type ProjectRunner interface { - RunTarget(target string, opts ...earthly.EarthlyExecutorOption) error -} - -type DefaultProjectRunner struct { - ctx RunContext - exectuor executor.Executor - logger *slog.Logger - project *project.Project - store secrets.SecretStore -} - -// RunTarget runs the given Earthly target. -func (p *DefaultProjectRunner) RunTarget( - target string, - opts ...earthly.EarthlyExecutorOption, -) error { - return earthly.NewEarthlyExecutor( - p.project.Path, - target, - p.exectuor, - p.store, - p.logger, - append(p.generateOpts(target), opts...)..., - ).Run() -} - -// generateOpts generates the options for the Earthly executor. -func (p *DefaultProjectRunner) generateOpts(target string) []earthly.EarthlyExecutorOption { - var opts []earthly.EarthlyExecutorOption - - if schema.HasProjectCiDefined(p.project.Blueprint) { - if _, ok := p.project.Blueprint.Project.Ci.Targets[target]; ok { - targetConfig := p.project.Blueprint.Project.Ci.Targets[target] - - if len(targetConfig.Args) > 0 { - var args []string - for k, v := range targetConfig.Args { - args = append(args, fmt.Sprintf("--%s", k), v) - } - - opts = append(opts, earthly.WithTargetArgs(args...)) - } - - // We only run multiple platforms in CI mode to avoid issues with local builds. - if targetConfig.Platforms != nil && p.ctx.CI { - opts = append(opts, earthly.WithPlatforms(targetConfig.Platforms...)) - } - - if targetConfig.Privileged { - opts = append(opts, earthly.WithPrivileged()) - } - - if targetConfig.Retries > 0 { - opts = append(opts, earthly.WithRetries(int(targetConfig.Retries))) - } - - if len(targetConfig.Secrets) > 0 { - opts = append(opts, earthly.WithSecrets(targetConfig.Secrets)) - } - } - } - - if schema.HasEarthlyProviderDefined(p.project.Blueprint) { - if p.project.Blueprint.Global.Ci.Providers.Earthly.Satellite != "" && !p.ctx.Local { - opts = append(opts, earthly.WithSatellite(p.project.Blueprint.Global.Ci.Providers.Earthly.Satellite)) - } - } - - if schema.HasGlobalCIDefined(p.project.Blueprint) { - if len(p.project.Blueprint.Global.Ci.Secrets) > 0 { - opts = append(opts, earthly.WithSecrets(p.project.Blueprint.Global.Ci.Secrets)) - } - } - - return opts -} - -func NewDefaultProjectRunner( - ctx RunContext, - project *project.Project, -) DefaultProjectRunner { - e := executor.NewLocalExecutor( - ctx.Logger, - executor.WithRedirect(), - ) - - return DefaultProjectRunner{ - ctx: ctx, - exectuor: e, - logger: ctx.Logger, - project: project, - store: ctx.SecretStore, - } -} - -func NewCustomDefaultProjectRunner( - ctx RunContext, - exec executor.Executor, - logger *slog.Logger, - project *project.Project, - store secrets.SecretStore, -) DefaultProjectRunner { - return DefaultProjectRunner{ - ctx: ctx, - exectuor: exec, - logger: logger, - project: project, - store: store, - } -} diff --git a/cli/pkg/run/test.cue b/cli/pkg/run/test.cue new file mode 100644 index 00000000..7469c33e --- /dev/null +++ b/cli/pkg/run/test.cue @@ -0,0 +1,12 @@ +{ + project: ci: targets { + target: { + args: { + foo: "bar" + } + platforms: ["linux/amd64"] + privileged: true + retries: 3 + } + } +} \ No newline at end of file diff --git a/cli/tui/ci/ci.go b/cli/tui/ci/ci.go index 8ee92a56..2dbeb746 100644 --- a/cli/tui/ci/ci.go +++ b/cli/tui/ci/ci.go @@ -232,7 +232,7 @@ func (c *CIRun) Run() tea.Msg { c.logger.Info("Running target", "project", c.Project.Path, "target", c.Target) c.Status = RunStatusRunning - runner := run.NewCustomDefaultProjectRunner( + runner := earthly.NewCustomDefaultProjectRunner( c.runctx, executor.NewLocalExecutor(c.logger, executor.WithRedirectTo(&c.stdout, &c.stderr)), c.logger,