Skip to content

Commit

Permalink
Fix env tags on ADO (#10902)
Browse files Browse the repository at this point in the history
* Replace all spaces for ADO Tags parameter

* Add support for AzureDevOps CI System

* Rename loadProwJobGitState to loadADOGitState

* Format double quotes for ADO tags parameter

* Use BUILD_BUILDID env variable

* Add comment for BUILD_BUILDID env variable

* Add comment for beautiful ReplaceAll command

* Send only values in Tags parameter

* Send only values in Tags parameter

* Revert "Send only values in Tags parameter"

This reverts commit c8bd817.

* Revert "Send only values in Tags parameter"

This reverts commit 4c6eb9e.

* Revert "Revert "Send only values in Tags parameter""

This reverts commit c1b516a.

* Revert "Revert "Send only values in Tags parameter""

This reverts commit 6804bea.

* Send only values in Tags parameter

* Use double quotes in the test

* Encode Tags to base64

* Add --parse-tags-only-base64

* Improve debug message

* Improve debug message

* Improve debug message

* Add additional tag-base64 flag

* Fix unit tests

* Replace VERSION with GOLANG_VERSION

* Support tags passed as comma separated base64 encoded string.

* Set fake github action output for dryRun mode.

* Fix expected test result.

* Encode Tags parameter in pipelines package instead in image-builder. Encoding should be done for each pipeline call not only from image-builder.

* Fix test expected value

* Set EncodedTags parameter to true when Tags parameter value is not empty.

* Add missing expected test value

* Remove unused function.

---------

Co-authored-by: dekiel <[email protected]>
  • Loading branch information
Sawthis and dekiel authored Jun 21, 2024
1 parent 8f30c30 commit 63f4f2b
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 64 deletions.
4 changes: 3 additions & 1 deletion cmd/image-builder/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,9 @@ func determineUsedCISystem(envGetter func(key string) string, envLookup func(key
return Prow, nil
}

isAdo := envGetter("CI_SYSTEM") == "AzureDevOps"
// BUILD_BUILDID environment variable is set in Azure DevOps pipeline
// See: https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services
_, isAdo := envLookup("BUILD_BUILDID")
if isAdo {
return AzureDevOps, nil
}
Expand Down
185 changes: 126 additions & 59 deletions cmd/image-builder/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bufio"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
Expand All @@ -26,6 +27,7 @@ import (
"github.com/kyma-project/test-infra/pkg/sign"
"github.com/kyma-project/test-infra/pkg/tags"
"github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelines"
"go.uber.org/zap"
"golang.org/x/net/context"
errutil "k8s.io/apimachinery/pkg/util/errors"
)
Expand All @@ -43,6 +45,7 @@ type options struct {
silent bool
isCI bool
tags sets.Tags
tagsBase64 string
buildArgs sets.Tags
platforms sets.Strings
exportTags bool
Expand All @@ -58,6 +61,8 @@ type options struct {
azureAccessToken string
ciSystem CISystem
gitState GitStateConfig
debug bool
dryRun bool
}

// parseVariable returns a build-arg.
Expand Down Expand Up @@ -230,6 +235,8 @@ func prepareADOTemplateParameters(options options) (adopipelines.OCIImageBuilder

if len(options.tags) > 0 {
templateParameters.SetImageTags(options.tags.String())
// TODO: Remove setting EncodedTags when we switch fully to base64 encoding Tags parameter.
templateParameters.SetEncodedTags(true)
}

if options.ciSystem == GithubActions {
Expand Down Expand Up @@ -268,12 +275,14 @@ func buildInADO(o options) error {
}

// Getting Azure DevOps Personal Access Token (ADO_PAT) from environment variable for authentication with ADO API when it's not set via flag.
if o.azureAccessToken == "" {
if o.azureAccessToken == "" && !o.dryRun {
adoPAT, present := os.LookupEnv("ADO_PAT")
if !present {
return fmt.Errorf("build in ADO failed, ADO_PAT environment variable is not set, please set it to valid ADO PAT")
}
o.azureAccessToken = adoPAT
} else if o.dryRun {
fmt.Println("Running in dry-run mode. Skipping getting ADO PAT.")
}

fmt.Println("Preparing ADO template parameters.")
Expand Down Expand Up @@ -303,49 +312,66 @@ func buildInADO(o options) error {
}

fmt.Println("Triggering ADO build pipeline")
ctx := context.Background()
// Triggering ADO build pipeline.
pipelineRun, err := adoClient.RunPipeline(ctx, runPipelineArgs)
if err != nil {
return fmt.Errorf("build in ADO failed, failed running ADO pipeline, err: %s", err)
}
var (
pipelineRunResult *pipelines.RunResult
logs string
)
if !o.dryRun {
ctx := context.Background()
// Triggering ADO build pipeline.
pipelineRun, err := adoClient.RunPipeline(ctx, runPipelineArgs)
if err != nil {
return fmt.Errorf("build in ADO failed, failed running ADO pipeline, err: %s", err)
}

// If running in preview mode, print the final yaml of ADO pipeline run for provided ADO pipeline definition and return.
if o.adoPreviewRun {
if pipelineRun.FinalYaml != nil {
fmt.Printf("ADO pipeline preview run final yaml\n: %s", *pipelineRun.FinalYaml)
} else {
fmt.Println("ADO pipeline preview run final yaml is empty")
// If running in preview mode, print the final yaml of ADO pipeline run for provided ADO pipeline definition and return.
if o.adoPreviewRun {
if pipelineRun.FinalYaml != nil {
fmt.Printf("ADO pipeline preview run final yaml\n: %s", *pipelineRun.FinalYaml)
} else {
fmt.Println("ADO pipeline preview run final yaml is empty")
}
return nil
}
return nil
}

// Fetch the ADO pipeline run result.
// GetRunResult function waits for the pipeline runs to finish and returns the result.
// TODO(dekiel) make the timeout configurable instead of hardcoding it.
pipelineRunResult, err := adopipelines.GetRunResult(ctx, adoClient, o.AdoConfig.GetADOConfig(), pipelineRun.Id, 30*time.Second)
if err != nil {
return fmt.Errorf("build in ADO failed, failed getting ADO pipeline run result, err: %s", err)
}
fmt.Printf("ADO pipeline run finished with status: %s\n", *pipelineRunResult)
// Fetch the ADO pipeline run result.
// GetRunResult function waits for the pipeline runs to finish and returns the result.
// TODO(dekiel) make the timeout configurable instead of hardcoding it.
pipelineRunResult, err = adopipelines.GetRunResult(ctx, adoClient, o.AdoConfig.GetADOConfig(), pipelineRun.Id, 30*time.Second)
if err != nil {
return fmt.Errorf("build in ADO failed, failed getting ADO pipeline run result, err: %s", err)
}
fmt.Printf("ADO pipeline run finished with status: %s\n", *pipelineRunResult)

// Fetch the ADO pipeline run logs.
fmt.Println("Getting ADO pipeline run logs.")
// Creating a new ADO build client.
adoBuildClient, err := adopipelines.NewBuildClient(o.AdoConfig.ADOOrganizationURL, o.azureAccessToken)
if err != nil {
fmt.Printf("Can't read ADO pipeline run logs, failed creating ADO build client, err: %s", err)
}
logs, err := adopipelines.GetRunLogs(ctx, adoBuildClient, &http.Client{}, o.AdoConfig.GetADOConfig(), pipelineRun.Id, o.azureAccessToken)
if err != nil {
fmt.Printf("Failed read ADO pipeline run logs, err: %s", err)
// Fetch the ADO pipeline run logs.
fmt.Println("Getting ADO pipeline run logs.")
// Creating a new ADO build client.
adoBuildClient, err := adopipelines.NewBuildClient(o.AdoConfig.ADOOrganizationURL, o.azureAccessToken)
if err != nil {
fmt.Printf("Can't read ADO pipeline run logs, failed creating ADO build client, err: %s", err)
}
logs, err := adopipelines.GetRunLogs(ctx, adoBuildClient, &http.Client{}, o.AdoConfig.GetADOConfig(), pipelineRun.Id, o.azureAccessToken)
if err != nil {
fmt.Printf("Failed read ADO pipeline run logs, err: %s", err)
} else {
fmt.Printf("ADO pipeline image build logs:\n%s", logs)
}
} else {
fmt.Printf("ADO pipeline image build logs:\n%s", logs)
dryRunPipelineRunResult := pipelines.RunResult("Succeeded")
pipelineRunResult = &dryRunPipelineRunResult
}

// TODO: Setting github outputs should happen outside buildInADO function.
// buildInADO should return required data and caller should handle it.
// if run in github actions, set output parameters
if o.ciSystem == GithubActions {
images := extractImagesFromADOLogs(logs)
var images []string
if !o.dryRun {
images = extractImagesFromADOLogs(logs)
} else {
fmt.Println("Running in dry-run mode. Skipping extracting images and results from ADO.")
images = []string{"registry/repo/image1:tag1", "registry/repo/image2:tag2"}
}
data, err := json.Marshal(images)
if err != nil {
return fmt.Errorf("cannot marshal list of images: %w", err)
Expand Down Expand Up @@ -501,7 +527,8 @@ func appendMissing(target *map[string]string, source []tags.Tag) {
}
}

// appendToTags appends key-value pairs from source map to target slice and returns the result
// appendToTags appends key-value pairs from source map to target slice of tags.Tag
// This allows creation of image tags from key value pairs.
func appendToTags(target *[]tags.Tag, source map[string]string) {
for key, value := range source {
*target = append(*target, tags.Tag{Name: key, Value: value})
Expand Down Expand Up @@ -695,36 +722,39 @@ func validateOptions(o options) error {
return errutil.NewAggregate(errs)
}

// loadEnv loads environment variables into application runtime from key=value list
// loadEnv creates environment variables in application runtime from a file with key=value data
func loadEnv(vfs fs.FS, envFile string) (map[string]string, error) {
if len(envFile) == 0 {
// file is empty - ignore
return nil, nil
}
f, err := vfs.Open(envFile)
file, err := vfs.Open(envFile)
if err != nil {
return nil, fmt.Errorf("open env file: %w", err)
}
s := bufio.NewScanner(f)
fileReader := bufio.NewScanner(file)
vars := make(map[string]string)
for s.Scan() {
kv := s.Text()
sp := strings.SplitN(kv, "=", 2)
key, val := sp[0], sp[1]
if len(sp) > 2 {
return nil, fmt.Errorf("env var split incorrectly: 2 != %v", len(sp))
}
if _, ok := os.LookupEnv(key); ok {
// do not override env variable if it's already present in the runtime
// do not include in vars map since dev should not have access to it anyway
continue
for fileReader.Scan() {
line := fileReader.Text()
separatedValues := strings.SplitN(line, "=", 2)
if len(separatedValues) > 2 {
return nil, fmt.Errorf("env var split incorrectly, got more than two values, expected only two, values: %v", separatedValues)
}
err := os.Setenv(key, val)
if err != nil {
return nil, fmt.Errorf("setenv: %w", err)
// ignore empty lines, setup environment variable only if key and value are present
if len(separatedValues) == 2 {
key, val := separatedValues[0], separatedValues[1]
if _, ok := os.LookupEnv(key); ok {
// do not override env variable if it's already present in the runtime
// do not include in vars map since dev should not have access to it anyway
continue
}
err := os.Setenv(key, val)
if err != nil {
return nil, fmt.Errorf("setenv: %w", err)
}
// add value to the vars that will be injected as build args
vars[key] = val
}
// add value to the vars that will be injected as build args
vars[key] = val
}
return vars, nil
}
Expand Down Expand Up @@ -754,9 +784,12 @@ func (o *options) gatherOptions(flagSet *flag.FlagSet) *flag.FlagSet {
flagSet.StringVar(&o.dockerfile, "dockerfile", "dockerfile", "Path to dockerfile file relative to context")
flagSet.StringVar(&o.variant, "variant", "", "If variants.yaml file is present, define which variant should be built. If variants.yaml is not present, this flag will be ignored")
flagSet.StringVar(&o.logDir, "log-dir", "/logs/artifacts", "Path to logs directory where GCB logs will be stored")
flagSet.BoolVar(&o.debug, "debug", false, "Enable debug logging")
flagSet.BoolVar(&o.dryRun, "dry-run", false, "Do not build the image, only print a ADO API call pipeline parameters")
// TODO: What is expected value repo only or org/repo? How this flag influence an image builder behaviour?
flagSet.StringVar(&o.orgRepo, "repo", "", "Load repository-specific configuration, for example, signing configuration")
flagSet.Var(&o.tags, "tag", "Additional tag that the image will be tagged with. Optionally you can pass the name in the format name=value which will be used by export-tags")
flagSet.StringVar(&o.tagsBase64, "tag-base64", "", "String representation of all tags encoded by base64. String representation must be in format as output of kyma-project/test-infra/pkg/tags.Tags.String() method")
flagSet.Var(&o.buildArgs, "build-arg", "Flag to pass additional arguments to build dockerfile. It can be used in the name=value format.")
flagSet.Var(&o.platforms, "platform", "Only supported with BuildKit. Platform of the image that is built")
flagSet.BoolVar(&o.exportTags, "export-tags", false, "Export parsed tags as build-args into dockerfile. Each tag will have format TAG_x, where x is the tag name passed along with the tag")
Expand All @@ -782,6 +815,21 @@ func main() {
os.Exit(1)
}

var (
zapLogger *zap.Logger
err error
)

if o.debug {
zapLogger, err = zap.NewDevelopment()
} else {
zapLogger, err = zap.NewProduction()
}
if err != nil {
log.Fatalf("Failed to initialize logger: %s", err)
}
logger := zapLogger.Sugar()

// If running inside some CI system, determine which system is used
if o.isCI {
ciSystem, err := DetermineUsedCISystem()
Expand Down Expand Up @@ -824,29 +872,30 @@ func main() {
if o.parseTagsOnly {
dockerfilePath, err := getDockerfileDirPath(o)
if err != nil {
fmt.Println(err)
logger.Errorw("Failed to get dockerfile path", "error", err)
os.Exit(1)
}
// Load environment variables from the envFile.
var envs map[string]string
if len(o.envFile) > 0 {
envs, err = loadEnv(os.DirFS(dockerfilePath), o.envFile)
if err != nil {
fmt.Println(err)
logger.Errorw("Failed to load env file", "error", err)
os.Exit(1)
}
}

parsedTags, err := parseTags(o)
if err != nil {
fmt.Println(err)
logger.Errorw("Failed to parse tags", "error", err)
os.Exit(1)
}
// Append environment variables to tags
appendToTags(&parsedTags, envs)
// Print parsed tags to stdout as json
jsonTags, err := json.Marshal(parsedTags)
if err != nil {
fmt.Println(err)
logger.Errorw("Failed to marshal tags to json", "error", err)
os.Exit(1)
}
fmt.Printf("%s\n", jsonTags)
Expand Down Expand Up @@ -879,6 +928,24 @@ func parseTags(o options) ([]tags.Tag, error) {
if sha == "" {
return nil, fmt.Errorf("sha still empty")
}

// read tags from base64 encoded string if provided
if o.tagsBase64 != "" {
decoded, err := base64.StdEncoding.DecodeString(o.tagsBase64)
if err != nil {
fmt.Printf("Failed to decode tags, error: %s", err)
os.Exit(1)
}
splitedTags := strings.Split(string(decoded), ",")
for _, tag := range splitedTags {
err = o.tags.Set(tag)
if err != nil {
fmt.Printf("Failed to set tag, tag: %s, error: %s", tag, err)
os.Exit(1)
}
}
}

parsedTags, err := getTags(pr, sha, append(o.tags, o.TagTemplate))
if err != nil {
return nil, err
Expand Down
47 changes: 47 additions & 0 deletions cmd/image-builder/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"testing/fstest"

"github.com/kyma-project/test-infra/pkg/azuredevops/pipelines"
"github.com/kyma-project/test-infra/pkg/sets"
"github.com/kyma-project/test-infra/pkg/sign"
"github.com/kyma-project/test-infra/pkg/tags"
Expand Down Expand Up @@ -619,6 +620,52 @@ func Test_parseTags(t *testing.T) {
}
}

func Test_prepareADOTemplateParameters(t *testing.T) {
tests := []struct {
name string
options options
want pipelines.OCIImageBuilderTemplateParams
wantErr bool
}{
{
name: "Tag with parentheses",
options: options{
gitState: GitStateConfig{
JobType: "postsubmit",
},
tags: sets.Tags{
{Name: "{{ .Env \"GOLANG_VERSION\" }}-ShortSHA", Value: "{{ .Env \"GOLANG_VERSION\" }}-{{ .ShortSHA }}"},
},
},
want: pipelines.OCIImageBuilderTemplateParams{
"Context": "",
"Dockerfile": "",
"EncodedTags": "true",
"ExportTags": "false",
"JobType": "postsubmit",
"Name": "",
"PullBaseSHA": "",
"RepoName": "",
"RepoOwner": "",
"Tags": "e3sgLkVudiAiR09MQU5HX1ZFUlNJT04iIH19LVNob3J0U0hBPXt7IC5FbnYgIkdPTEFOR19WRVJTSU9OIiB9fS17eyAuU2hvcnRTSEEgfX0=",
"UseKanikoConfigFromPR": "false",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := prepareADOTemplateParameters(tt.options)
if (err != nil) != tt.wantErr {
t.Errorf("prepareADOTemplateParameters() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("prepareADOTemplateParameters() got = %v, want %v", got, tt.want)
}
})
}
}

func Test_extractImagesFromADOLogs(t *testing.T) {
tc := []struct {
name string
Expand Down
Loading

0 comments on commit 63f4f2b

Please sign in to comment.