graph/task.go (387 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package graph
import (
"context"
"fmt"
"log"
"os"
"runtime"
"strings"
"github.com/Azure/acr-builder/pkg/volume"
"github.com/Azure/acr-builder/secretmgmt"
"github.com/Azure/acr-builder/util"
"github.com/pkg/errors"
yaml "gopkg.in/yaml.v2"
)
const (
// The default step timeout is 10 minutes.
defaultStepTimeoutInSeconds = 60 * 10
// The default step retry delay is 5 seconds.
defaultStepRetryDelayInSeconds = 5
// currentTaskVersion is the most recent Task version
currentTaskVersion = "v1.0.0"
noTaskNamePlaceholder = "quickrun"
)
var (
validTaskVersions = map[string]bool{
"1.0-preview-1": true,
currentTaskVersion: true,
"v1.1.0": true,
}
)
// ResolvedRegistryCred is a credential with resolved username/password for the registry
type ResolvedRegistryCred struct {
Username *secretmgmt.Secret
Password *secretmgmt.Secret
}
// RegistryLoginCredentials is a map of registryName -> ResolvedRegistryCred
type RegistryLoginCredentials map[string]*ResolvedRegistryCred
// Task represents a task execution.
type Task struct {
Steps []*Step `yaml:"steps"`
StepTimeout int `yaml:"stepTimeout,omitempty"`
Secrets []*secretmgmt.Secret `yaml:"secrets,omitempty"`
Networks []*Network `yaml:"networks,omitempty"`
Volumes []*volume.Volume `yaml:"volumes,omitempty"`
Envs []string `yaml:"env,omitempty"`
WorkingDirectory string `yaml:"workingDirectory,omitempty"`
Version string `yaml:"version,omitempty"`
RegistryName string
Registry string
TaskName string // Used to form the build cache image tag.
Credentials []*RegistryCredential
RegistryLoginCredentials RegistryLoginCredentials
Dag *Dag
IsBuildTask bool // Used to skip the default network creation for build.
InitBuildkitContainer bool // Used to initialize buildkit container if a build step is using build cache.
}
// TaskOptions are used to configure a new Task
type TaskOptions struct {
// DefaultWorkingDir is the default working directory for the Task
DefaultWorkingDir string
// Network is the network the Task runs on
Network string
// Envs is a list of Task environment variables
Envs []string
// Credentials is a list of Registry credentials required to run a Task
Credentials []*RegistryCredential
// TaskName is the name of the Task
TaskName string
// Registry is the login server for Task
Registry string
// DoPreprocessing is a property to decide whether we use Alias
DoPreprocessing bool
// GlobalAliases keeps track of all the Task native global aliases
GlobalAliases []byte
}
// UnmarshalTaskFromString unmarshals a Task from a raw string.
func UnmarshalTaskFromString(ctx context.Context, data string, opts *TaskOptions) (*Task, error) {
t, err := NewTaskFromString(data)
if err != nil {
return t, errors.Wrap(err, "failed to deserialize task and validate")
}
err = t.AddTaskDefaults(ctx, opts)
if err != nil {
return t, err
}
return t, err
}
// AddTaskDefaults prepares a Task with remaining parameters
func (t *Task) AddTaskDefaults(ctx context.Context, opts *TaskOptions) error {
if opts.DefaultWorkingDir != "" && t.WorkingDirectory == "" {
t.WorkingDirectory = opts.DefaultWorkingDir
}
// Merge in the defaults with the Task's specific environment variables.
// NB: Order is important here. Allow the Task's environment variables to override the defaults provided.
newEnvs, err := mergeEnvs(t.Envs, opts.Envs)
if err != nil {
return err
}
t.Envs = newEnvs
t.Credentials = opts.Credentials
if opts.TaskName != "" {
t.TaskName = opts.TaskName
} else {
t.TaskName = noTaskNamePlaceholder
}
t.Registry = opts.Registry
// External network parsed in from CLI will be set as default network, it will be used for any step if no network provide for them
// The external network is append at the end of the list of networks, later we will do reverse iteration to get this network
if opts.Network != "" {
var externalNetwork *Network
externalNetwork, err = NewNetwork(opts.Network, false, "external", true, true)
if err != nil {
return err
}
t.Networks = append(t.Networks, externalNetwork)
}
err = t.initialize(ctx)
return err
}
// UnmarshalTaskFromFile unmarshals a Task from a file.
func UnmarshalTaskFromFile(ctx context.Context, file string, opts *TaskOptions) (*Task, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, err
}
t, err := NewTaskFromBytes(data)
if err != nil {
return t, errors.Wrap(err, "failed to deserialize task and validate")
}
t.Credentials = opts.Credentials
if opts.TaskName != "" {
t.TaskName = opts.TaskName
} else {
t.TaskName = noTaskNamePlaceholder
}
err = t.initialize(ctx)
return t, err
}
// NewTaskFromString unmarshals a Task from string without any initialization.
func NewTaskFromString(data string) (*Task, error) {
return NewTaskFromBytes([]byte(data))
}
// NewTaskFromBytes unmarshals a Task from given bytes without any initialization.
func NewTaskFromBytes(data []byte) (*Task, error) {
t := &Task{}
if err := yaml.Unmarshal(data, t); err != nil {
return t, err
}
return t, t.Validate()
}
// Validate validates the task and returns an error if the Task has problems.
func (t *Task) Validate() error {
// Validate secrets if exists
idMap := make(map[string]struct{}, len(t.Secrets))
for _, secret := range t.Secrets {
err := secret.Validate()
if err != nil {
if secret.ID == "" {
return err
}
return errors.Wrap(err, fmt.Sprintf("failed to validate secret with ID: %s", secret.ID))
}
if _, exists := idMap[secret.ID]; exists {
return fmt.Errorf("duplicate secret found with ID: %s", secret.ID)
}
idMap[secret.ID] = struct{}{}
}
// Validate Volumes if exists
if err := ValidateVolumes(t.Volumes); err != nil {
return err
}
// Validate that mounts reference a volume that exists
for _, s := range t.Steps {
if err := s.ValidateMountVolumeNames(t.Volumes); err != nil {
return err
}
}
return nil
}
// NewTask returns a default Task object.
func NewTask(
ctx context.Context,
steps []*Step,
secrets []*secretmgmt.Secret,
registry string,
credentials []*RegistryCredential,
isBuildTask bool,
defaultWorkDir string,
taskName string) (*Task, error) {
t := &Task{
Steps: steps,
StepTimeout: defaultStepTimeoutInSeconds,
Secrets: secrets,
RegistryName: registry,
Credentials: credentials,
IsBuildTask: isBuildTask,
TaskName: taskName,
}
if defaultWorkDir != "" && t.WorkingDirectory == "" {
t.WorkingDirectory = defaultWorkDir
}
if taskName != "" {
t.TaskName = taskName
} else {
t.TaskName = noTaskNamePlaceholder
}
err := t.initialize(ctx)
return t, err
}
// initialize normalizes a Task's values.
func (t *Task) initialize(ctx context.Context) error {
newDefaultNetworkName := DefaultNetworkName
addDefaultNetworkToSteps := false
// Default the Task's to the latest version if it's unspecified.
if t.Version == "" {
t.Version = currentTaskVersion
}
if err := validateTaskVersion(t.Version); err != nil {
return err
}
// Reverse iterate the list to get the default network
for i := len(t.Networks) - 1; i >= 0; i-- {
network := t.Networks[i]
if network.IsDefault {
newDefaultNetworkName = network.Name
addDefaultNetworkToSteps = true
break
}
}
// Add the default network if none are specified.
// Only add the default network if we're using tasks.
if !t.IsBuildTask && len(t.Networks) == 0 {
defaultNetwork, err := NewNetwork(newDefaultNetworkName, false, "bridge", false, true)
if err != nil {
return err
}
if runtime.GOOS == util.WindowsOS {
defaultNetwork.Driver = "nat"
}
t.Networks = append(t.Networks, defaultNetwork)
addDefaultNetworkToSteps = true
}
if t.StepTimeout <= 0 {
t.StepTimeout = defaultStepTimeoutInSeconds
}
for i, s := range t.Steps {
// If individual steps don't have step timeouts specified,
// stamp the global timeout on them.
if s.Timeout <= 0 {
s.Timeout = t.StepTimeout
}
if s.RetryDelayInSeconds <= 0 {
s.RetryDelayInSeconds = defaultStepRetryDelayInSeconds
}
if addDefaultNetworkToSteps && s.Network == "" {
s.Network = newDefaultNetworkName
}
newEnvs, err := mergeEnvs(s.Envs, t.Envs)
if err != nil {
return errors.Wrap(err, "failed to merge task and step environment variables")
}
s.Envs = newEnvs
if s.ID == "" {
s.ID = fmt.Sprintf("acb_step_%d", i)
}
// Override the step's working directory to be the parent's working directory.
if s.WorkingDirectory == "" && t.WorkingDirectory != "" {
s.WorkingDirectory = t.WorkingDirectory
}
// Initialize a completion channel for each step.
if s.CompletedChan == nil {
s.CompletedChan = make(chan bool)
}
// Mark the step as skipped initially
s.StepStatus = Skipped
if s.IsBuildStep() {
if len(s.Tags) == 0 {
s.Tags = util.ParseTags(s.Build)
}
s.BuildArgs = util.ParseBuildArgs(s.Build)
if s.UseBuildCacheForBuildStep() {
if runtime.GOOS == util.LinuxOS {
if buildStepWithBuildCache, err := s.GetCmdWithCacheFlags(t.TaskName, t.Registry); err != nil {
log.Printf("error creating build cache command %v\n", err)
} else {
// update the Build cmd with buildx cache flags
s.Build = buildStepWithBuildCache
t.InitBuildkitContainer = true
}
} else {
log.Println("build cache is not supported on windows. Will use standard docker build")
}
}
} else if s.IsPushStep() {
s.Push = getNormalizedDockerImageNames(s.Push)
}
}
var err error
t.RegistryLoginCredentials, err = ResolveCustomRegistryCredentials(ctx, t.Credentials)
if err != nil {
return err
}
t.Dag, err = NewDagFromTask(t)
return err
}
// UsingRegistryCreds determines whether or not the Task is using registry creds.
func (t *Task) UsingRegistryCreds() bool {
return len(t.RegistryLoginCredentials) > 0
}
// getNormalizedDockerImageNames normalizes the list of docker images
// and removes any duplicates.
func getNormalizedDockerImageNames(dockerImages []string) []string {
if len(dockerImages) == 0 {
return dockerImages
}
dict := map[string]bool{}
normalizedDockerImages := []string{}
for _, dockerImage := range dockerImages {
d := util.NormalizeImageTag(dockerImage)
if dict[d] {
continue
}
dict[d] = true
normalizedDockerImages = append(normalizedDockerImages, d)
}
return normalizedDockerImages
}
// mergeEnvs merges the src environment variables into dest.
func mergeEnvs(dest []string, src []string) ([]string, error) {
if len(src) < 1 {
return dest, nil
}
var newEnvs []string
for _, env := range src {
newEnv := strings.Split(env, ",")
newEnvs = append(newEnvs, newEnv...)
}
var stepmap = make(map[string]string)
for _, env := range dest {
pair := strings.SplitN(env, "=", 2)
if len(pair) != 2 {
err := fmt.Errorf("cannot parse step environment variable %s correctly", env)
return dest, err
}
stepmap[pair[0]] = pair[1]
}
for _, env := range newEnvs {
pair := strings.SplitN(env, "=", 2)
if len(pair) != 2 {
err := fmt.Errorf("cannot parse task environment variable %s correctly", env)
return dest, err
}
if _, ok := stepmap[pair[0]]; !ok {
dest = append(dest, pair[0]+"="+pair[1])
}
}
return dest, nil
}
// validateTaskVersion validates the specified version and returns an error if it isn't valid.
func validateTaskVersion(version string) error {
vLower := strings.ToLower(version)
if _, ok := validTaskVersions[vLower]; !ok {
return fmt.Errorf("invalid version specified: %q, the current version is %q", version, currentTaskVersion)
}
return nil
}
// ResolveCustomRegistryCredentials resolves all the registry login credentials
func ResolveCustomRegistryCredentials(ctx context.Context, credentials []*RegistryCredential) (RegistryLoginCredentials, error) {
resolvedCreds := make(RegistryLoginCredentials)
var unresolvedCreds []*secretmgmt.Secret
for _, cred := range credentials {
if cred == nil {
continue
}
resolvedCreds[cred.Registry] = &ResolvedRegistryCred{
Username: &secretmgmt.Secret{
ID: cred.Registry,
},
Password: &secretmgmt.Secret{
ID: cred.Registry,
},
}
isMSI := false
usernameSecretObject := resolvedCreds[cred.Registry].Username
passwordSecretObject := resolvedCreds[cred.Registry].Password
switch cred.UsernameType {
case Opaque:
usernameSecretObject.ResolvedValue = cred.Username
case VaultSecret:
usernameSecretObject.KeyVault = cred.Username
usernameSecretObject.MsiClientID = cred.Identity
unresolvedCreds = append(unresolvedCreds, usernameSecretObject)
case "":
isMSI = true
}
switch cred.PasswordType {
case Opaque:
passwordSecretObject.ResolvedValue = cred.Password
case VaultSecret:
passwordSecretObject.KeyVault = cred.Password
passwordSecretObject.MsiClientID = cred.Identity
unresolvedCreds = append(unresolvedCreds, passwordSecretObject)
}
if isMSI {
usernameSecretObject.ResolvedValue = "00000000-0000-0000-0000-000000000000"
passwordSecretObject.MsiClientID = cred.Identity
passwordSecretObject.AadResourceID = cred.AadResourceID
unresolvedCreds = append(unresolvedCreds, passwordSecretObject)
}
}
secretResolver, err := secretmgmt.NewSecretResolver(nil, secretmgmt.DefaultSecretResolveTimeout)
if err != nil {
return nil, errors.Wrap(err, "failed to create secret resolver")
}
err = secretResolver.ResolveSecrets(ctx, unresolvedCreds)
if err != nil {
return nil, errors.Wrap(err, "failed to resolve secrets")
}
return resolvedCreds, nil
}
// ValidateVolumes checks each volume is well formed and each container path is unique
func ValidateVolumes(volMounts []*volume.Volume) error {
duplicate := make(map[string]struct{}, len(volMounts))
for _, v := range volMounts {
// call v.Validate() for each mount
if err := v.Validate(); err != nil {
return err
}
// make sure each volume name provided is unique
if _, exists := duplicate[v.Name]; exists {
return errors.New("volume with duplicate name found")
}
duplicate[v.Name] = struct{}{}
}
return nil
}