diff --git a/cmd/gale/validate/validate.go b/cmd/gale/validate/validate.go index e5c71e84..b8385bc2 100644 --- a/cmd/gale/validate/validate.go +++ b/cmd/gale/validate/validate.go @@ -40,10 +40,14 @@ func NewCommand() *cobra.Command { return config.Client().Close() }, RunE: func(cmd *cobra.Command, args []string) error { - validator := preflight.NewValidator(preflight.NewConsoleReporter()) + validator := preflight.NewValidator(cmd.Context(), preflight.NewConsoleReporter()) validator.Register(preflight.StandardTasks()...) + // set the workflow and job names + opts.Workflow = args[0] + opts.Job = args[1] + return validator.Validate(opts) }, } @@ -52,6 +56,7 @@ func NewCommand() *cobra.Command { 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.") + cmd.Flags().StringVar(&opts.WorkflowsDir, "workflows-dir", "", "directory to load workflows from. If empty, workflows will be loaded from the default directory.") return cmd } diff --git a/pkg/preflight/preflight.go b/pkg/preflight/preflight.go index a4bf8ee0..f09d3c2a 100644 --- a/pkg/preflight/preflight.go +++ b/pkg/preflight/preflight.go @@ -1,6 +1,9 @@ package preflight -import "fmt" +import ( + "context" + "fmt" +) // Validator entrypoint for preflight checks. type Validator struct { @@ -10,10 +13,10 @@ type Validator struct { } // NewValidator creates a new Validator. -func NewValidator(reporter Reporter) *Validator { +func NewValidator(ctx context.Context, reporter Reporter) *Validator { return &Validator{ tasks: make(map[string]Task), - ctx: &Context{}, + ctx: &Context{Context: ctx}, reporter: reporter, } } diff --git a/pkg/preflight/preflight_test.go b/pkg/preflight/preflight_test.go index 40860801..bb646a77 100644 --- a/pkg/preflight/preflight_test.go +++ b/pkg/preflight/preflight_test.go @@ -1,6 +1,7 @@ package preflight_test import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -37,7 +38,7 @@ func (m *MockReporter) Report(t preflight.Task, _ preflight.Result) error { func TestValidator(t *testing.T) { // Create the validator with a mock reporter reporter := &MockReporter{} - validator := preflight.NewValidator(reporter) + validator := preflight.NewValidator(context.Background(), reporter) // Register setups and checks setupA := &MockTask{name: "setupA"} diff --git a/pkg/preflight/reporter.go b/pkg/preflight/reporter.go index 40b2001a..770d466f 100644 --- a/pkg/preflight/reporter.go +++ b/pkg/preflight/reporter.go @@ -48,6 +48,8 @@ func (r *ConsoleReporter) Report(t Task, result Result) error { log.Warn(msg.Content) case Info: log.Info(msg.Content) + case Debug: + log.Debug(msg.Content) } } diff --git a/pkg/preflight/task.go b/pkg/preflight/task.go index 21d4b93b..44eb165d 100644 --- a/pkg/preflight/task.go +++ b/pkg/preflight/task.go @@ -18,9 +18,10 @@ type Task interface { // Task names to make it easier to reference them in dependencies. const ( - NameDaggerCheck = "Dagger" - NameGHCheck = "GitHub CLI" - NameRepoLoader = "Repo" + NameDaggerCheck = "Dagger" + NameGHCheck = "GitHub CLI" + NameRepoLoader = "Repo" + NameWorkflowLoader = "Workflow" ) // StandardTasks returns the standard tasks that are used in preflight checks. @@ -29,5 +30,6 @@ func StandardTasks() []Task { new(DaggerCheck), new(GHCheck), new(RepoLoader), + new(WorkflowLoader), } } diff --git a/pkg/preflight/task_workflows.go b/pkg/preflight/task_workflows.go new file mode 100644 index 00000000..d4959795 --- /dev/null +++ b/pkg/preflight/task_workflows.go @@ -0,0 +1,174 @@ +package preflight + +import ( + "fmt" + "os" + "strings" + + "github.com/aweris/gale/internal/config" + "github.com/aweris/gale/internal/core" +) + +var _ Task = new(WorkflowLoader) + +type WorkflowLoader struct{} + +func (c *WorkflowLoader) Name() string { + return NameWorkflowLoader +} + +func (c *WorkflowLoader) Type() TaskType { + return TaskTypeLoad +} + +func (c *WorkflowLoader) DependsOn() []string { + return []string{NameDaggerCheck, NameRepoLoader} +} + +func (c *WorkflowLoader) Run(ctx *Context, opts Options) Result { + var msg []Message + + repo := ctx.Repo + + workflows, err := repo.LoadWorkflows(ctx.Context, core.RepositoryLoadWorkflowOpts{WorkflowsDir: opts.WorkflowsDir}) + if err != nil { + return Result{ + Status: Failed, + Messages: []Message{ + {Level: Error, Content: fmt.Sprintf("Load workflows failed: %s", err.Error())}, + }, + } + } + + ctx.Workflows = workflows + + if opts.WorkflowsDir == "" { + msg = append(msg, Message{Level: Info, Content: "Workflows are loaded from .github/workflows"}) + } else { + msg = append(msg, Message{Level: Info, Content: fmt.Sprintf("Workflows are loaded from %s", opts.WorkflowsDir)}) + } + + workflow, ok := workflows[opts.Workflow] + if !ok { + msg = append(msg, Message{Level: Error, Content: fmt.Sprintf("Workflow %s is not found", opts.Workflow)}) + + return Result{Status: Failed, Messages: msg} + } + + ctx.Workflow = workflow + + msg = append(msg, Message{Level: Info, Content: fmt.Sprintf("Workflow %s is loaded", opts.Workflow)}) + + job, ok := workflow.Jobs[opts.Job] + if !ok { + msg = append(msg, Message{Level: Error, Content: fmt.Sprintf("Job %s is not found", opts.Job)}) + + return Result{Status: Failed, Messages: msg} + } + + ctx.Job = &job + + msg = append(msg, Message{Level: Info, Content: fmt.Sprintf("Job %s is loaded", opts.Job)}) + + // TODO: find a better way to do this. Maybe pass target directory as an option or add clean up function to the context and call it at the end. + dir, err := os.MkdirTemp("", "ghx-home") + if err != nil { + msg = append(msg, Message{Level: Error, Content: fmt.Sprintf("Failed to create temporary GHX home directory: %s", err.Error())}) + + return Result{Status: Failed, Messages: msg} + } + + config.SetGhxHome(dir) + + for _, step := range job.Steps { + // check if the step is an action + switch step.Type() { + case core.StepTypeAction: + if ctx.CustomActions == nil { + ctx.CustomActions = make(map[string]*core.CustomAction) + } + + // check if the action is already loaded + if ctx.CustomActions[step.Uses] != nil { + continue + } + + // load the action + ca, err := core.LoadActionFromSource(ctx.Context, step.Uses) + if err != nil { + msg = append(msg, Message{Level: Error, Content: fmt.Sprintf("Load action %s failed: %s", step.Uses, err.Error())}) + + return Result{Status: Failed, Messages: msg} + } + + // add the action to the list + ctx.CustomActions[step.Uses] = ca + + msg = append(msg, Message{Level: Debug, Content: fmt.Sprintf("Action %s is used in the workflow", step.Uses)}) + + // check if the action uses a Docker image + if ca.Meta.Runs.Image != "" && strings.HasPrefix(ca.Meta.Runs.Image, "docker://") { + // trim docker:// prefix + image := strings.TrimPrefix(ca.Meta.Runs.Image, "docker://") + + // check if map is initialized + if ctx.DockerImages == nil { + ctx.DockerImages = make(map[string]bool) + } + + // check if the image is already used + if ctx.DockerImages[image] { + continue + } + + // add the image to the list + ctx.DockerImages[image] = true + + msg = append(msg, Message{Level: Debug, Content: fmt.Sprintf("Docker image %s is used in the workflow", image)}) + } + case core.StepTypeDocker: + // trim docker:// prefix + image := strings.TrimPrefix(step.Uses, "docker://") + + // check if map is initialized + if ctx.DockerImages == nil { + ctx.DockerImages = make(map[string]bool) + } + + // check if the image is already used + if ctx.DockerImages[image] { + continue + } + + // add the image to the list + ctx.DockerImages[image] = true + + msg = append(msg, Message{Level: Debug, Content: fmt.Sprintf("Docker image %s is used in the workflow", image)}) + case core.StepTypeRun: + // check if map is initialized + if ctx.Shells == nil { + ctx.Shells = make(map[string]bool) + } + + // empty shell means the default shell. skip it. + if step.Shell == "" { + continue + } + + // check if the shell is already used + if ctx.Shells[step.Shell] { + continue + } + + // add the shell to the list + + ctx.Shells[step.Shell] = true + + msg = append(msg, Message{Level: Debug, Content: fmt.Sprintf("Shell %s is used in the workflow", step.Shell)}) + default: + msg = append(msg, Message{Level: Warning, Content: fmt.Sprintf("Step %s is not supported", step.Type())}) + } + } + + return Result{Status: Passed, Messages: msg} +} diff --git a/pkg/preflight/types.go b/pkg/preflight/types.go index 6e1de0d5..e0337a8c 100644 --- a/pkg/preflight/types.go +++ b/pkg/preflight/types.go @@ -8,21 +8,31 @@ import ( // 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. + 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. + WorkflowsDir string // WorkflowsDir is the directory that contains the workflows. + Workflow string // Workflow is the name of the workflow. + Job string // Job is the name of the job } // 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. + 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. + Workflows map[string]*core.Workflow // Workflows is the list of workflows that are loaded. + Workflow *core.Workflow // Workflow is the workflow that is used for the preflight checks. + Job *core.Job // Job is the job that is used for the preflight checks. + CustomActions map[string]*core.CustomAction // CustomActions is the list of custom actions that are loaded. + Shells map[string]bool // Shells is the list of shells that are used in the workflow. + DockerImages map[string]bool // DockerImages is the list of Docker images that are used in the workflow. } // MessageLevel is the level of the message. It can be INFO, WARNING, or ERROR. type MessageLevel string const ( + Debug MessageLevel = "DEBUG" Info MessageLevel = "INFO" Warning MessageLevel = "WARNING" Error MessageLevel = "ERROR"