Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bootstrap preflight checks #92

Merged
merged 1 commit into from
Aug 24, 2023
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
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ linters-settings:
- github.com/rhysd/actionlint
- github.com/magefile/mage/sh
- github.com/julienschmidt/httprouter
- github.com/stretchr/testify/assert
3 changes: 3 additions & 0 deletions cmd/gale/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions cmd/gale/validate/validate.go
Original file line number Diff line number Diff line change
@@ -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 <workflow> <job> [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
}
2 changes: 2 additions & 0 deletions pkg/preflight/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package preflight contains set of checks that ensures the environment is ready for the execution.
package preflight
86 changes: 86 additions & 0 deletions pkg/preflight/preflight.go
Original file line number Diff line number Diff line change
@@ -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()}}}
}
60 changes: 60 additions & 0 deletions pkg/preflight/preflight_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
69 changes: 69 additions & 0 deletions pkg/preflight/reporter.go
Original file line number Diff line number Diff line change
@@ -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)
}
33 changes: 33 additions & 0 deletions pkg/preflight/task.go
Original file line number Diff line number Diff line change
@@ -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),
}
}
Loading
Loading