graph/preprocessor.go (232 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
// Alias preprocessor:
// The set of elements here are meant to process the alias definition portion of task.yaml
// files. This is done by unmarshaling these elements which will then be added in a hierarchical
// manner. Note the input must still be valid YAML.
package graph
import (
"bufio"
"bytes"
"io"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"time"
"github.com/Azure/acr-builder/util"
"github.com/pkg/errors"
yaml "gopkg.in/yaml.v2"
)
var (
errImproperDirectiveLength = errors.New("$ directive can only be overwritten by a single character")
errImproperKeyName = errors.New("alias key names only support alphanumeric characters")
errImproperDirectiveChoice = errors.New("overwritten directives may not be alphanumeric characters")
defaultDirective = '$'
aliasFormat = regexp.MustCompile(`\A[a-zA-Z0-9]+\z`)
)
const versionKey = "version"
// Alias intermediate step for processing before complete unmarshal
type Alias struct {
AliasSrc []string `yaml:"src"`
AliasMap map[string]string `yaml:"values"`
DirectiveParsed string `yaml:"directive"`
directive rune
}
// Validates aliases making sure all are alphanumeric
// Additionally sets and validates directive overrides
func (alias *Alias) resolveMapAndValidate() error {
// Set directive from Map
alias.directive = defaultDirective
if alias.DirectiveParsed != "" {
val := []rune(alias.DirectiveParsed)
if len(val) != 1 {
return errImproperDirectiveLength
}
if matched := aliasFormat.MatchString(alias.DirectiveParsed); matched {
return errImproperDirectiveChoice
}
alias.directive = val[0]
}
// Values may support all characters, no escaping and so forth necessary
for key := range alias.AliasMap {
matched := aliasFormat.MatchString(key)
if !matched {
return errImproperKeyName
}
}
return nil
}
// Loads in all Aliases defined as being a part of external resources.
func (alias *Alias) loadExternalAlias() error {
// Iterating in reverse to easily and efficiently handle hierarchy. The later
// declared the higher in the hierarchy of alias definitions.
for i := len(alias.AliasSrc) - 1; i >= 0; i-- {
aliasURI := alias.AliasSrc[i]
if util.IsURL(aliasURI) {
if err := addAliasFromRemote(alias, aliasURI); err != nil {
return err
}
} else {
if err := addAliasFromFile(alias, aliasURI); err != nil {
return err
}
}
}
return nil
}
// Loads in all global aliases switching definition based on os
func (alias *Alias) loadGlobalAlias() {
//Identify defaults location.
if runtime.GOOS == util.WindowsOS {
readAliasFromBytes([]byte(globalDefaultYamlWindows), alias)
} else { // Looking at Linux
readAliasFromBytes([]byte(globalDefaultYamlLinux), alias)
}
}
// Fetches and parses out remote alias files and adds their content
// to the passed in Alias. Note alias definitions already in alias
// will not be overwritten.
func addAliasFromRemote(alias *Alias, url string) error {
remoteClient := &http.Client{
Timeout: time.Second * 2, // Maximum of 2 secs
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
res, getErr := remoteClient.Do(req)
if getErr != nil {
return getErr
}
if res.StatusCode > 299 {
httpErr, err := io.ReadAll(res.Body)
if err != nil {
return err
}
return errors.New(string(httpErr))
}
defer res.Body.Close()
data, readErr := io.ReadAll(res.Body)
if readErr != nil {
return readErr
}
return readAliasFromBytes(data, alias)
}
// Parses out local alias files and adds their content to the passed in
// Alias. Note alias definitions already in alias will not be
// overwritten.
func addAliasFromFile(alias *Alias, fileURI string) error {
data, fileReadingError := os.ReadFile(fileURI)
if fileReadingError != nil {
return fileReadingError
}
return readAliasFromBytes(data, alias)
}
// Parses out alias definitions from a given bytes array and appends
// them to the Alias. Note alias definitions already in alias will
// not be overwritten even if present in the array.
func readAliasFromBytes(data []byte, alias *Alias) error {
aliasMap := &map[string]string{}
if err := yaml.Unmarshal(data, aliasMap); err != nil {
return err
}
for key, value := range *aliasMap {
if _, ok := alias.AliasMap[key]; !ok {
alias.AliasMap[key] = value
}
}
return nil
}
// preprocessString handles the preprocessing (string replacement and resolution)
// of all aliases in an input yaml (passed in as a string). The resolved aliases are
// defined in the input alias file.
func preprocessString(alias *Alias, str string) (string, error) {
// Load Remote/Local alias definitions
if externalDefinitionErr := alias.loadExternalAlias(); externalDefinitionErr != nil {
return "", externalDefinitionErr
}
alias.loadGlobalAlias()
// Validate alias definitions
if improperFormatErr := alias.resolveMapAndValidate(); improperFormatErr != nil {
return "", improperFormatErr
}
var out strings.Builder
var command strings.Builder
ongoingCmd := false
// Search and replace all strings with the directive
// (sam) we add a placeholder space at the end of the string below
// to force the state machine to END. We remove it before returning
// the result to user
for _, char := range str + " " {
if ongoingCmd {
if char == alias.directive && command.Len() == 0 { // Escape Character Triggered
out.WriteRune(alias.directive)
ongoingCmd = false
} else if !isAlphanumeric(char) { // Delineates the end of an alias
resolvedCommand, commandPresent := alias.AliasMap[command.String()]
// If command is not found we assume this to be the expect item itself.
if !commandPresent {
out.WriteString(string(alias.directive) + command.String() + string(char))
ongoingCmd = false
command.Reset()
} else {
out.WriteString(resolvedCommand)
if char != alias.directive {
ongoingCmd = false
out.WriteRune(char)
}
command.Reset()
}
} else {
command.WriteRune(char)
}
} else if char == alias.directive {
ongoingCmd = true
} else {
out.WriteRune(char)
}
}
return strings.TrimSuffix(out.String(), " "), nil
}
// PreprocessBytes handles byte encoded data that can be parsed through pre processing
func PreprocessBytes(data []byte) ([]byte, *Alias, error) {
aliasData, remainingData := SeparateAliasFromRest(data)
return SearchReplaceAlias(data, aliasData, remainingData)
}
// SearchReplaceAlias replaces aliasData in the Task
func SearchReplaceAlias(originalData, aliasData, data []byte) ([]byte, *Alias, error) {
type wrapper struct {
Alias Alias `yaml:"alias,omitempty"`
}
wrap := &wrapper{}
if errUnmarshal := yaml.Unmarshal(aliasData, wrap); errUnmarshal != nil {
return originalData, &Alias{}, errors.Wrap(errUnmarshal, "error during alias unmarshaling")
}
alias := &wrap.Alias
// Alias Src defined. Guarantees alias map can be populated
if alias.AliasMap == nil {
alias.AliasMap = make(map[string]string)
}
// Search and Replace
parsedStr, err := preprocessString(alias, string(data))
return []byte(parsedStr), alias, err
}
// ExpandCommandAliases will resolve image names in cmd steps that are aliased without using directive.
// Invoked after resolving all directive using aliases
func ExpandCommandAliases(alias *Alias, task *Task) {
for i, step := range task.Steps {
parts := strings.Split(strings.TrimSpace(step.Cmd), " ")
if val, ok := alias.AliasMap[parts[0]]; ok {
// Image name should always go first
parts[0] = val
task.Steps[i].Cmd = strings.Join(parts, " ")
}
}
}
// SeparateAliasFromRest separates out alias blurb from the rest of the Task
func SeparateAliasFromRest(data []byte) ([]byte, []byte) {
reader := bytes.NewReader(data)
scanner := bufio.NewScanner(reader)
scanner.Split(bufio.ScanLines)
var aliasBuffer bytes.Buffer
var buffer bytes.Buffer
inside := false
aliasFieldName := regexp.MustCompile(`\Aalias\s*:.*\z`)
genericTopLevelRe := regexp.MustCompile(`\A[^\s:]+[^:]*:.*\z`)
commentRe := regexp.MustCompile(`\A\s*#.*`)
for scanner.Scan() {
text := scanner.Text()
if matched := commentRe.MatchString(text); matched {
continue
}
if matched := aliasFieldName.MatchString(text); matched && !inside {
inside = true
} else if matched := genericTopLevelRe.MatchString(text); matched && inside {
inside = false
}
if inside {
aliasBuffer.WriteString(text + "\n")
} else {
buffer.WriteString(text + "\n")
}
}
return aliasBuffer.Bytes(), buffer.Bytes()
}
// FindVersion determines the current version of a task file
func FindVersion(data []byte) string {
reader := bytes.NewReader(data)
scanner := bufio.NewScanner(reader)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
text := scanner.Text()
trimmedText := strings.TrimSpace(text)
// Skip comments and just whitespace.
if trimmedText == "" || strings.HasPrefix(trimmedText, "#") {
continue
}
// use text instead of trimmedText since ' version: ' is also invalid.
if strings.HasPrefix(strings.TrimLeft(text, "'\""), versionKey) {
tokens := strings.SplitN(text, ":", 2)
if len(tokens) == 2 && strings.Trim(strings.TrimSpace(tokens[0]), "'\"") == versionKey {
return strings.Trim(strings.TrimSpace(tokens[1]), "'\"")
}
}
}
return ""
}