codecatalyst-runner/pkg/workflows/workflow_plans_provider.go (267 lines of code) (raw):
package workflows
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/aws/codecatalyst-runner-cli/codecatalyst-runner/pkg/actions"
"github.com/aws/codecatalyst-runner-cli/command-runner/pkg/runner"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v2"
)
var ActionsUrlTemplate = "https://amazon-codecatalyst-public-action-source-us-west-2.s3.us-west-2.amazonaws.com/us-west-2/%s/%s/action-repo.zip"
var ActionVersions = map[string]string{
"aws/kubernetes-deploy": "1.0.0",
"aws/ecs-render-task-definition": "1.0.4",
"aws/cfn-deploy": "1.0.5",
"aws/ecs-deploy": "1.0.5",
"aws/cdk-deploy": "1.0.13",
"aws/cdk-bootstrap": "1.0.8",
"aws/s3-publish": "1.0.5",
"aws/lambda-invoke": "1.0.8",
"codecatalyst-labs/provision-with-terraform-community": "1.0.0",
"codecatalyst-labs/scan-with-codeguru-security": "1.0.0",
"codecatalyst-labs/deploy-to-cloudfront-s3": "1.0.1",
"codecatalyst-labs/publish-to-codeartifact": "1.0.1",
"codecatalyst-labs/invalidate-cloudfront-cache": "1.0.0",
"codecatalyst-labs/publish-to-sns": "1.0.0",
"codecatalyst-labs/deploy-to-app-runner": "1.0.3",
"codecatalyst-labs/outgoing-webhook": "1.0.1",
"codecatalyst-labs/deploy-with-sam": "1.0.1",
"codecatalyst-labs/push-to-ecr": "1.0.3",
"codecatalyst-labs/deploy-to-amplify-hosting": "1.0.1",
"mend/mendsca": "1.0.9",
}
// NewWorkflowPlansProviderParams contains the parameters to create a new action plans provider
type NewWorkflowPlansProviderParams struct {
ExecutionType runner.ExecutionType // The [ExecutionType] to use in the created plans
WorkingDir string // The working directory to use for each plan
Action string // the name of the action to run
Workflow *Workflow // The [Workflow] to use
}
// NewWorkflowPlansProvider creates a plan provider based on [Workflow]s
func NewWorkflowPlansProvider(params *NewWorkflowPlansProviderParams) runner.PlansProvider {
return &workflowPlansProvider{
executionType: params.ExecutionType,
workingDir: params.WorkingDir,
action: params.Action,
workflow: params.Workflow,
}
}
type workflowPlansProvider struct {
executionType runner.ExecutionType
workingDir string
action string
workflow *Workflow
}
func (wpp *workflowPlansProvider) Plans(ctx context.Context) ([]runner.Plan, error) {
plans := make([]runner.Plan, 0)
for _, mapItem := range wpp.workflow.Actions {
actionName := mapItem.Key.(string)
var actionOrGroup ActionOrGroup
if buf, err := yaml.Marshal(mapItem.Value); err != nil {
return nil, err
} else if err := yaml.Unmarshal(buf, &actionOrGroup); err != nil {
return nil, err
}
if actionOrGroup.Action.Identifier != "" {
plan, err := wpp.planAction(ctx, actionName, &actionOrGroup.Action)
if err != nil {
return nil, fmt.Errorf("unable to create plan for action %s: %w", actionName, err)
}
if plan != nil {
plans = append(plans, plan)
}
} else {
for subName, action := range actionOrGroup.Actions {
fullName := fmt.Sprintf("%s@%s", actionName, subName)
plan, err := wpp.planAction(ctx, fullName, action)
if err != nil {
return nil, fmt.Errorf("unable to create plan for action %s: %w", fullName, err)
}
if plan != nil {
plans = append(plans, plan)
}
}
}
}
if log.Debug().Enabled() {
log.Debug().Msgf("created plans from workflow=%+v", wpp.workflow)
for _, plan := range plans {
log.Debug().Msgf(" plan=%+v", plan)
}
}
return plans, nil
}
func (wpp *workflowPlansProvider) planAction(ctx context.Context, actionName string, action *Action) (runner.Plan, error) {
if wpp.action != "" && wpp.action != actionName {
return nil, nil
}
log.Ctx(ctx).Debug().Msgf("creating action plan for action %s", action.Identifier)
var plan runner.Plan
var err error
var actionSpec *actions.Action
var steps []string
actionIdentifierParts := strings.Split(action.Identifier, "@")
switch actionIdentifierParts[0] {
case ".":
actionSpec, err = actions.Load(wpp.workingDir)
if err != nil {
return nil, fmt.Errorf("unable to load action file '%s': %w", wpp.workingDir, err)
}
case "aws/build", "aws/managed-test":
var runs actions.Runs
runs = actions.Runs{
Using: actions.UsingTypeDocker,
Image: actions.CodeCatalystImage(),
Entrypoint: "/bin/echo",
}
outputs := actions.Outputs{
Variables: make(map[string]actions.Output),
}
for _, output := range action.Outputs.Variables {
outputs.Variables[output] = actions.Output{}
}
actionSpec = &actions.Action{
SchemaVersion: "1.0",
ID: actionIdentifierParts[0],
Name: actionIdentifierParts[0],
Version: actionIdentifierParts[1],
Runs: runs,
Outputs: outputs,
}
steps = make([]string, 0)
if configSteps, ok := action.Configuration["Steps"].([]interface{}); ok {
for _, step := range configSteps {
steps = append(steps, step.(map[interface{}]interface{})["Run"].(string))
}
}
case "aws/github-actions-runner":
return nil, fmt.Errorf("GitHub actions are not currently supported")
default:
actionSpec, err = loadRemoteAction(ctx, actionIdentifierParts[0])
if err != nil {
return nil, err
}
}
log.Ctx(ctx).Debug().Msgf("actionspec=%+v", actionSpec)
if plan, err = actions.NewActionPlan(&actions.NewActionPlanParams{
Action: actionSpec,
ExecutionType: wpp.executionType,
WorkingDir: wpp.workingDir,
ID: actionName,
Steps: steps,
DependsOn: action.DependsOn,
}); err != nil {
return nil, fmt.Errorf("unable to create new action plan: %w", err)
}
err = applyInputs(plan.EnvironmentConfiguration(), action, actionSpec)
return plan, err
}
func applyInputs(envCfg *runner.EnvironmentConfiguration, action *Action, actionSpec *actions.Action) error {
if envCfg.Env == nil {
envCfg.Env = make(map[string]string)
}
for name, param := range actionSpec.Configuration {
if val, ok := action.Configuration[name]; ok {
envCfg.Env[fmt.Sprintf("INPUT_%s", strings.ToUpper(name))] = val.(string)
} else if param.Required && param.Default == "" {
return fmt.Errorf("input parameter '%s' is required for action '%s'", name, actionSpec.ID)
} else {
envCfg.Env[fmt.Sprintf("INPUT_%s", strings.ToUpper(name))] = param.Default
}
}
return nil
}
func downloadHttpExtractZip(_ context.Context, url string, destDir string) error {
response, err := http.Get(url) // #nosec G107 -- URLs are generated above from trusted host
if err != nil {
return fmt.Errorf("unable to get object from url %s: %w", url, err)
}
defer response.Body.Close()
actionZip, err := os.CreateTemp("", "actions-*.zip")
if err != nil {
return fmt.Errorf("unable to create temp file: %w", err)
}
defer os.Remove(actionZip.Name())
if _, err = io.Copy(actionZip, response.Body); err != nil {
return fmt.Errorf("unable to copy zip to temp file: %w", err)
}
_ = actionZip.Close()
archive, err := zip.OpenReader(actionZip.Name())
if err != nil {
return fmt.Errorf("unable to open zip: %w", err)
}
defer archive.Close()
for _, f := range archive.File {
filePath := filepath.Join(destDir, f.Name) //#nosec G305 -- mitigated through next line
if !strings.HasPrefix(filePath, filepath.Clean(destDir)+string(os.PathSeparator)) {
return fmt.Errorf("invalid file path: %s", filePath)
}
if f.FileInfo().IsDir() {
err = os.MkdirAll(filePath, os.ModePerm)
if err != nil {
return fmt.Errorf("unable to create directory: %w", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
return fmt.Errorf("unable to create directory: %w", err)
}
dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return fmt.Errorf("unable to open file: %w", err)
}
fileInArchive, err := f.Open()
if err != nil {
return fmt.Errorf("unable to open file in archive: %w", err)
}
for {
_, err := io.CopyN(dstFile, fileInArchive, 1024)
if err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("unable to copy file: %w", err)
}
}
dstFile.Close()
fileInArchive.Close()
}
return nil
}
func loadRemoteAction(ctx context.Context, actionID string) (*actions.Action, error) {
if actionVersion, ok := ActionVersions[actionID]; !ok {
return nil, fmt.Errorf("unknown actions %s", actionID)
} else {
actionsURL := fmt.Sprintf(ActionsUrlTemplate, actionID, actionVersion)
log.Ctx(ctx).Info().Msgf("🚚 downloading action %s", actionID)
sha := sha256.Sum256([]byte(actionsURL))
actionsURLHash := hex.EncodeToString(sha[:])
cacheDir, err := os.UserCacheDir()
if err != nil {
return nil, err
}
actionDir := filepath.Join(cacheDir, "codecatalyst-runner", "actions", actionsURLHash)
if err := os.RemoveAll(actionDir); err != nil {
return nil, fmt.Errorf("unable to cleanup actionDir %s: %w", actionDir, err)
}
if err := downloadHttpExtractZip(ctx, actionsURL, actionDir); err != nil {
return nil, fmt.Errorf("unable to download actionsUrl %s: %w", actionsURL, err)
}
if entries, err := os.ReadDir(actionDir); err != nil {
return nil, fmt.Errorf("unable to list actionDir %s: %w", actionDir, err)
} else {
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), "cloned-repo-") {
actionDir = filepath.Join(actionDir, entry.Name())
}
}
}
return actions.Load(actionDir)
}
}