From e7ec033783a5d1d30ea1456b4ec4be1e715e3c02 Mon Sep 17 00:00:00 2001 From: Ali AKCA Date: Wed, 23 Aug 2023 22:21:30 +0200 Subject: [PATCH] feat: bootstrap preflight checks --- .golangci.yaml | 1 + cmd/gale/root.go | 3 ++ cmd/gale/validate/validate.go | 57 ++++++++++++++++++++++ pkg/preflight/doc.go | 2 + pkg/preflight/preflight.go | 86 +++++++++++++++++++++++++++++++++ pkg/preflight/preflight_test.go | 60 +++++++++++++++++++++++ pkg/preflight/reporter.go | 69 ++++++++++++++++++++++++++ pkg/preflight/task.go | 33 +++++++++++++ pkg/preflight/task_dagger.go | 84 ++++++++++++++++++++++++++++++++ pkg/preflight/task_gh.go | 35 ++++++++++++++ pkg/preflight/task_repo.go | 59 ++++++++++++++++++++++ pkg/preflight/types.go | 49 +++++++++++++++++++ 12 files changed, 538 insertions(+) create mode 100644 cmd/gale/validate/validate.go create mode 100644 pkg/preflight/doc.go create mode 100644 pkg/preflight/preflight.go create mode 100644 pkg/preflight/preflight_test.go create mode 100644 pkg/preflight/reporter.go create mode 100644 pkg/preflight/task.go create mode 100644 pkg/preflight/task_dagger.go create mode 100644 pkg/preflight/task_gh.go create mode 100644 pkg/preflight/task_repo.go create mode 100644 pkg/preflight/types.go diff --git a/.golangci.yaml b/.golangci.yaml index 105b33d7..44546063 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -46,3 +46,4 @@ linters-settings: - github.com/rhysd/actionlint - github.com/magefile/mage/sh - github.com/julienschmidt/httprouter + - github.com/stretchr/testify/assert diff --git a/cmd/gale/root.go b/cmd/gale/root.go index ab270ec9..63b201c5 100644 --- a/cmd/gale/root.go +++ b/cmd/gale/root.go @@ -4,6 +4,8 @@ import ( "fmt" "os" + "github.com/aweris/gale/cmd/gale/validate" + "github.com/spf13/cobra" "github.com/aweris/gale/cmd/gale/list" @@ -28,6 +30,7 @@ func Execute() { rootCmd.AddCommand(list.NewCommand()) rootCmd.AddCommand(run.NewCommand()) rootCmd.AddCommand(version.NewCommand()) + rootCmd.AddCommand(validate.NewCommand()) if err := rootCmd.Execute(); err != nil { fmt.Printf("Error executing command: %v", err) diff --git a/cmd/gale/validate/validate.go b/cmd/gale/validate/validate.go new file mode 100644 index 00000000..e5c71e84 --- /dev/null +++ b/cmd/gale/validate/validate.go @@ -0,0 +1,57 @@ +package validate + +import ( + "dagger.io/dagger" + + "github.com/spf13/cobra" + + "github.com/aweris/gale/internal/config" + "github.com/aweris/gale/pkg/preflight" +) + +// NewCommand creates a new root command. +func NewCommand() *cobra.Command { + var ( + runnerImage string // runnerImage is the image used for running the actions. + opts preflight.Options // options for the run command + ) + + cmd := &cobra.Command{ + Use: "validate [flags]", + Short: "Runs preflight checks for the given workflow and job", + Args: cobra.ExactArgs(2), + SilenceUsage: true, // don't print usage when error occurs + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + client, err := dagger.Connect(cmd.Context()) + if err != nil { + return err + } + + config.SetClient(client) + + if runnerImage != "" { + config.SetRunnerImage(runnerImage) + } + + return nil + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + // Close the client connection when the command is done. + return config.Client().Close() + }, + RunE: func(cmd *cobra.Command, args []string) error { + validator := preflight.NewValidator(preflight.NewConsoleReporter()) + + validator.Register(preflight.StandardTasks()...) + + return validator.Validate(opts) + }, + } + + cmd.Flags().StringVar(&runnerImage, "runner", "", "runner image or path to Dockerfile to use for running the actions. If empty, the default runner image will be used.") + cmd.Flags().StringVar(&opts.Repo, "repo", "", "owner/repo to load workflows from. If empty, repository information of the current directory will be used.") + cmd.Flags().StringVar(&opts.Branch, "branch", "", "branch to load workflows from. Only one of branch or tag can be used. Precedence is as follows: tag, branch.") + cmd.Flags().StringVar(&opts.Tag, "tag", "", "tag to load workflows from. Only one of branch or tag can be used. Precedence is as follows: tag, branch.") + + return cmd +} diff --git a/pkg/preflight/doc.go b/pkg/preflight/doc.go new file mode 100644 index 00000000..3442c053 --- /dev/null +++ b/pkg/preflight/doc.go @@ -0,0 +1,2 @@ +// Package preflight contains set of checks that ensures the environment is ready for the execution. +package preflight diff --git a/pkg/preflight/preflight.go b/pkg/preflight/preflight.go new file mode 100644 index 00000000..a4bf8ee0 --- /dev/null +++ b/pkg/preflight/preflight.go @@ -0,0 +1,86 @@ +package preflight + +import "fmt" + +// Validator entrypoint for preflight checks. +type Validator struct { + ctx *Context + tasks map[string]Task + reporter Reporter +} + +// NewValidator creates a new Validator. +func NewValidator(reporter Reporter) *Validator { + return &Validator{ + tasks: make(map[string]Task), + ctx: &Context{}, + reporter: reporter, + } +} + +// Register registers a task to the validator. +func (v *Validator) Register(tasks ...Task) error { + for _, t := range tasks { + if _, exists := v.tasks[t.Name()]; exists { + return fmt.Errorf("task already exists with name %s", t.Name()) + } + + v.tasks[t.Name()] = t + } + + return nil +} + +// Validate validates the preflight checks with given options. Options are optional and only first one is used if multiple +// options are provided. +func (v *Validator) Validate(opts ...Options) error { + o := Options{} + + if len(opts) > 0 { + o = opts[0] + } + + executed := make(map[string]bool) + + for _, t := range v.tasks { + if err := v.executeTask(t, o, executed); err != nil { + v.reporter.Report(t, errToResult(err)) + return err + } + } + + return nil +} + +// executeTask executes a task and its dependencies. +func (v *Validator) executeTask(t Task, opts Options, executed map[string]bool) error { + // If the task is already executed, skip it. + if _, ok := executed[t.Name()]; ok { + return nil + } + + // Execute the dependencies first. + for _, dep := range t.DependsOn() { + // check if the dependency exists + task, exist := v.tasks[dep] + if !exist { + return fmt.Errorf("dependency %s not found for task %s", dep, t.Name()) + } + + // execute the dependency + if err := v.executeTask(task, opts, executed); err != nil { + return err + } + } + + // Execute the task itself and report the result. + v.reporter.Report(t, t.Run(v.ctx, opts)) + + executed[t.Name()] = true + + return nil +} + +func errToResult(err error) Result { + return Result{Status: Failed, Messages: []Message{{Level: Error, Content: err.Error()}}} +} diff --git a/pkg/preflight/preflight_test.go b/pkg/preflight/preflight_test.go new file mode 100644 index 00000000..40860801 --- /dev/null +++ b/pkg/preflight/preflight_test.go @@ -0,0 +1,60 @@ +package preflight_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/aweris/gale/pkg/preflight" +) + +var _ preflight.Task = new(MockTask) + +// Mock implementations for Setup, Check, and Reporter +type MockTask struct { + name string + dependsOn []string + runInvoked bool +} + +func (m *MockTask) Name() string { return m.name } +func (m *MockTask) DependsOn() []string { return m.dependsOn } +func (m *MockTask) Type() preflight.TaskType { return preflight.TaskTypeCheck } +func (m *MockTask) Run(_ *preflight.Context, _ preflight.Options) preflight.Result { + m.runInvoked = true + return preflight.Result{Status: preflight.Passed} +} + +type MockReporter struct { + reports []string +} + +func (m *MockReporter) Report(t preflight.Task, _ preflight.Result) error { + m.reports = append(m.reports, t.Name()) + return nil +} + +func TestValidator(t *testing.T) { + // Create the validator with a mock reporter + reporter := &MockReporter{} + validator := preflight.NewValidator(reporter) + + // Register setups and checks + setupA := &MockTask{name: "setupA"} + checkA := &MockTask{name: "checkA", dependsOn: []string{"setupA"}} + checkB := &MockTask{name: "checkB", dependsOn: []string{"checkA"}} + + assert.NoError(t, validator.Register(setupA)) + assert.NoError(t, validator.Register(checkA)) + assert.NoError(t, validator.Register(checkB)) + + // Execute the validator + opts := preflight.Options{} + assert.NoError(t, validator.Validate(opts)) + + // Ensure that the setups and checks were invoked in the correct order + assert.True(t, setupA.runInvoked) + assert.True(t, checkA.runInvoked) + assert.True(t, checkB.runInvoked) + assert.Equal(t, []string{"setupA", "checkA", "checkB"}, reporter.reports) +} diff --git a/pkg/preflight/reporter.go b/pkg/preflight/reporter.go new file mode 100644 index 00000000..40b2001a --- /dev/null +++ b/pkg/preflight/reporter.go @@ -0,0 +1,69 @@ +package preflight + +import ( + "fmt" + + "github.com/aweris/gale/internal/log" +) + +// Reporter represents anything that can report the validation results. +type Reporter interface { + Report(t Task, result Result) error // Report reports the validation results with the check name and the result. +} + +var _ Reporter = new(ConsoleReporter) + +// ConsoleReporter is a reporter that reports the validation results to the console. +type ConsoleReporter struct{} + +// NewConsoleReporter creates a new ConsoleReporter. +func NewConsoleReporter() *ConsoleReporter { + return &ConsoleReporter{} +} + +func (r *ConsoleReporter) Report(t Task, result Result) error { + var status string + + switch result.Status { + case Failed: + status = colorize(string(result.Status), red) + case Passed: + status = colorize(string(result.Status), green) + } + + log.Info(fmt.Sprintf("[%s] %s: %s", t.Type(), t.Name(), status)) + + // if there is no message then return + if len(result.Messages) == 0 { + return nil + } + + log.StartGroup() + + for _, msg := range result.Messages { + switch msg.Level { + case Error: + log.Error(msg.Content) + case Warning: + log.Warn(msg.Content) + case Info: + log.Info(msg.Content) + } + } + + log.EndGroup() + + return nil +} + +// color is the color of the message. +type color int32 + +const ( + red color = 31 + green color = 32 +) + +func colorize(s string, c color) string { + return fmt.Sprintf("\033[0;%dm%s\033[0m", c, s) +} diff --git a/pkg/preflight/task.go b/pkg/preflight/task.go new file mode 100644 index 00000000..21d4b93b --- /dev/null +++ b/pkg/preflight/task.go @@ -0,0 +1,33 @@ +package preflight + +// TaskType is the type of the task. It can be either check or load. +type TaskType string + +const ( + TaskTypeCheck TaskType = "check" + TaskTypeLoad TaskType = "load" +) + +// Task is a task that can be executed. +type Task interface { + Name() string // Name returns the name of the task. + Type() TaskType // Type returns the type of the task. + DependsOn() []string // DependsOn returns the list of tasks that this task depends on. + Run(ctx *Context, opts Options) Result // Run runs the task and returns the result. +} + +// Task names to make it easier to reference them in dependencies. +const ( + NameDaggerCheck = "Dagger" + NameGHCheck = "GitHub CLI" + NameRepoLoader = "Repo" +) + +// StandardTasks returns the standard tasks that are used in preflight checks. +func StandardTasks() []Task { + return []Task{ + new(DaggerCheck), + new(GHCheck), + new(RepoLoader), + } +} diff --git a/pkg/preflight/task_dagger.go b/pkg/preflight/task_dagger.go new file mode 100644 index 00000000..ad003f22 --- /dev/null +++ b/pkg/preflight/task_dagger.go @@ -0,0 +1,84 @@ +package preflight + +import ( + "context" + "strings" + + "github.com/aweris/gale/internal/config" + "github.com/aweris/gale/internal/core" +) + +var _ Task = new(DaggerCheck) + +// DaggerCheck is a preflight check that checks if dagger is working properly. +type DaggerCheck struct{} + +func (d *DaggerCheck) Name() string { + return NameDaggerCheck +} + +func (d *DaggerCheck) Type() TaskType { + return TaskTypeCheck +} + +func (d *DaggerCheck) DependsOn() []string { + return []string{} +} + +func (d *DaggerCheck) Run(_ *Context, _ Options) Result { + // check if dagger is initialized in global config + client := config.Client() + if client == nil { + return Result{Status: Failed, Messages: []Message{{Level: Error, Content: "Global client is not initialized"}}} + } + + var msgs []Message + + msgs = append(msgs, Message{Level: Info, Content: "Global client is initialized"}) + + // initialize dagger context + dctx := core.NewDaggerContextFromEnv() + + // this is not possible to happen here. Only added for ensuring that the code is working. + if dctx == nil { + msgs = append(msgs, Message{Level: Error, Content: "Dagger context is not initialized from environment"}) + + return Result{Status: Failed, Messages: msgs} + } + + // check if runner host or session is not provided then fallback to docker socket is exists + if dctx.RunnerHost == "" || dctx.Session == "" { + msgs = append(msgs, Message{Level: Info, Content: "Dagger runner host and/or session is not provided. Using docker socket instead."}) + + socket := client.Host().UnixSocket(dctx.DockerSock) + + if _, err := socket.ID(context.Background()); err != nil { + msgs = append(msgs, Message{Level: Error, Content: "Docker socket is not reachable"}) + + return Result{Status: Failed, Messages: msgs} + } + + msgs = append(msgs, Message{Level: Info, Content: "Docker socket is reachable"}) + } + + // check if we can execute a container with given dagger context exist in environment + out, err := client.Container(). + From("alpine:latest"). + WithExec([]string{"echo", "Hello World"}). + Stdout(context.Background()) + if err != nil { + msgs = append(msgs, Message{Level: Error, Content: "Container execution is not successful"}) + + return Result{Status: Failed, Messages: msgs} + } + + if strings.TrimSpace(out) != "Hello World" { + msgs = append(msgs, Message{Level: Error, Content: "Container output is not correct. Expected: Hello World, Actual: " + out}) + + return Result{Status: Failed, Messages: msgs} + } + + msgs = append(msgs, Message{Level: Info, Content: "Container execution is successful"}) + + return Result{Status: Passed, Messages: msgs} +} diff --git a/pkg/preflight/task_gh.go b/pkg/preflight/task_gh.go new file mode 100644 index 00000000..a34d718a --- /dev/null +++ b/pkg/preflight/task_gh.go @@ -0,0 +1,35 @@ +package preflight + +import "github.com/aweris/gale/internal/core" + +var _ Task = new(GHCheck) + +type GHCheck struct{} + +func (c *GHCheck) Name() string { + return NameGHCheck +} + +func (c *GHCheck) Type() TaskType { + return TaskTypeCheck +} + +func (c *GHCheck) DependsOn() []string { + return []string{} +} + +func (c *GHCheck) Run(_ *Context, _ Options) Result { + // try to get token from github cli to make sure it is configured properly. + _, err := core.GetToken() + if err != nil { + return Result{ + Status: Failed, + Messages: []Message{ + {Level: Warning, Content: "GitHub CLI is exist or configured properly"}, + {Level: Error, Content: err.Error()}, + }, + } + } + + return Result{Status: Passed, Messages: []Message{{Level: Info, Content: "GitHub CLI is exist and configured properly"}}} +} diff --git a/pkg/preflight/task_repo.go b/pkg/preflight/task_repo.go new file mode 100644 index 00000000..9be8cc83 --- /dev/null +++ b/pkg/preflight/task_repo.go @@ -0,0 +1,59 @@ +package preflight + +import ( + "fmt" + + "github.com/aweris/gale/internal/core" +) + +var _ Task = new(RepoLoader) + +type RepoLoader struct{} + +func (c *RepoLoader) Name() string { + return NameRepoLoader +} + +func (c *RepoLoader) Type() TaskType { + return TaskTypeLoad +} + +func (c *RepoLoader) DependsOn() []string { + return []string{NameDaggerCheck, NameGHCheck} +} + +func (c *RepoLoader) Run(ctx *Context, opts Options) Result { + var msg []Message + + if opts.Repo != "" { + repo, err := core.GetRepository(opts.Repo, core.GetRepositoryOpts{Branch: opts.Branch, Tag: opts.Tag}) + if err != nil { + return Result{ + Status: Failed, + Messages: []Message{ + {Level: Error, Content: fmt.Sprintf("Get repository %s failed: %s", opts.Repo, err.Error())}, + }, + } + } + + ctx.Repo = repo + + msg = append(msg, Message{Level: Info, Content: fmt.Sprintf("Repository %s is loaded", opts.Repo)}) + } else { + repo, err := core.GetCurrentRepository() + if err != nil { + return Result{ + Status: Failed, + Messages: []Message{ + {Level: Error, Content: fmt.Sprintf("Get current repository failed: %s", err.Error())}, + }, + } + } + + ctx.Repo = repo + + msg = append(msg, Message{Level: Info, Content: fmt.Sprintf("Current repository %s is loaded", repo.Name)}) + } + + return Result{Status: Passed, Messages: msg} +} diff --git a/pkg/preflight/types.go b/pkg/preflight/types.go new file mode 100644 index 00000000..6e1de0d5 --- /dev/null +++ b/pkg/preflight/types.go @@ -0,0 +1,49 @@ +package preflight + +import ( + "context" + + "github.com/aweris/gale/internal/core" +) + +// Options represents the options for the preflight checks. +type Options struct { + Repo string // Repo is the name of the repository. It should be in the format of owner/repo. + Branch string // Branch is the name of the branch. + Tag string // Tag is the name of the tag. +} + +// Context represents the context of the preflight checks. +type Context struct { + Context context.Context // Context is the context of the preflight checks. + Repo *core.Repository // Repo represents a GitHub repository that is used for the preflight checks. +} + +// MessageLevel is the level of the message. It can be INFO, WARNING, or ERROR. +type MessageLevel string + +const ( + Info MessageLevel = "INFO" + Warning MessageLevel = "WARNING" + Error MessageLevel = "ERROR" +) + +// Message contains the level and the content of the message. +type Message struct { + Level MessageLevel // Level is the level of the message. + Content string // Content is the content of the message. +} + +// ResultStatus is the status of the executed check. It can be PASSED or FAILED. +type ResultStatus string + +const ( + Passed ResultStatus = "PASSED" // Passed represents a successful check. + Failed ResultStatus = "FAILED" // Failed represents a failed check. +) + +// Result contains the status of the check and the messages. +type Result struct { + Status ResultStatus // Status is the status of the check. + Messages []Message // Messages are the messages returned by the check. +}