diff --git a/pkg/build/pipeline/CiTemplateReadService.go b/pkg/build/pipeline/CiTemplateReadService.go index e615ae8331..47f6b28b30 100644 --- a/pkg/build/pipeline/CiTemplateReadService.go +++ b/pkg/build/pipeline/CiTemplateReadService.go @@ -115,6 +115,9 @@ func (impl *CiTemplateReadServiceImpl) extractBuildConfigBean(templateOverride * impl.Logger.Errorw("error occurred while parsing ci build config", "err", err) } } + // rows saved before args were stripped on write may still carry them; never serve them, + // so API clients can't echo them back and builds can't pick them up + ciBuildConfigBean.ClearDockerBuildArgs() ciBuildConfigBean.GitMaterialId = templateOverride.GitMaterialId ciBuildConfigBean.BuildContextGitMaterialId = templateOverride.BuildContextGitMaterialId return ciBuildConfigBean, nil diff --git a/pkg/build/pipeline/CiTemplateReadService_test.go b/pkg/build/pipeline/CiTemplateReadService_test.go new file mode 100644 index 0000000000..cda5580f82 --- /dev/null +++ b/pkg/build/pipeline/CiTemplateReadService_test.go @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package pipeline + +import ( + "testing" + + "github.com/devtron-labs/devtron/internal/sql/repository/pipelineConfig" + "github.com/devtron-labs/devtron/internal/util" + bean2 "github.com/devtron-labs/devtron/pkg/build/pipeline/bean" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeCiTemplateOverrideRepository struct { + pipelineConfig.CiTemplateOverrideRepository + overrides []*pipelineConfig.CiTemplateOverride +} + +func (f *fakeCiTemplateOverrideRepository) FindByAppId(int) ([]*pipelineConfig.CiTemplateOverride, error) { + return f.overrides, nil +} + +func (f *fakeCiTemplateOverrideRepository) FindByCiPipelineIds([]int) ([]*pipelineConfig.CiTemplateOverride, error) { + return f.overrides, nil +} + +func (f *fakeCiTemplateOverrideRepository) FindByCiPipelineId(int) (*pipelineConfig.CiTemplateOverride, error) { + return f.overrides[0], nil +} + +type fakeCiTemplateRepository struct { + pipelineConfig.CiTemplateRepository + template *pipelineConfig.CiTemplate +} + +func (f *fakeCiTemplateRepository) FindByAppId(int) (*pipelineConfig.CiTemplate, error) { + return f.template, nil +} + +func (f *fakeCiTemplateRepository) FindByAppIds([]int) ([]*pipelineConfig.CiTemplate, error) { + return []*pipelineConfig.CiTemplate{f.template}, nil +} + +// the #2983 override row: args and target platform the editor showed as empty +const staleDockerOverrideMetadata = `{"dockerfileRelativePath":"Dockerfile","dockerfileContent":"","args":{"AWS_ACCESS_KEY_ID":"AKIA-stale","AWS_SECRET_ACCESS_KEY":"stale-secret"},"targetPlatform":"linux/arm64,linux/amd64","dockerBuildOptions":{"network":"host"},"buildContext":"ctx","useBuildx":false,"buildxProvenanceMode":"","buildxDriverImage":""}` + +func overrideRow(buildType bean2.CiBuildType, metadata string) *pipelineConfig.CiTemplateOverride { + return &pipelineConfig.CiTemplateOverride{ + Id: 11, CiPipelineId: 21, GitMaterialId: 31, BuildContextGitMaterialId: 32, DockerfilePath: "legacy/Dockerfile", + DockerRegistryId: "reg", DockerRepository: "repo", Active: true, CiBuildConfigId: 41, + CiBuildConfig: &pipelineConfig.CiBuildConfig{Id: 41, Type: string(buildType), BuildMetadata: metadata}, + } +} + +func newReadService(t *testing.T, templates *fakeCiTemplateRepository, overrides *fakeCiTemplateOverrideRepository) *CiTemplateReadServiceImpl { + logger, err := util.NewSugardLogger() + require.NoError(t, err) + return NewCiTemplateReadServiceImpl(logger, templates, overrides) +} + +// every exported read that goes through extractBuildConfigBean +func readOverrideBuildConfigs(t *testing.T, svc *CiTemplateReadServiceImpl) map[string]*bean2.CiBuildConfigBean { + byPipelineId, err := svc.FindTemplateOverrideByCiPipelineId(21) + require.NoError(t, err) + byAppId, err := svc.FindTemplateOverrideByAppId(1) + require.NoError(t, err) + require.Len(t, byAppId, 1) + byPipelineIds, err := svc.FindTemplateOverrideByCiPipelineIds([]int{21}) + require.NoError(t, err) + require.Len(t, byPipelineIds, 1) + return map[string]*bean2.CiBuildConfigBean{ + "FindTemplateOverrideByCiPipelineId": byPipelineId.CiBuildConfig, + "FindTemplateOverrideByAppId": byAppId[0].CiBuildConfig, + "FindTemplateOverrideByCiPipelineIds": byPipelineIds[0].CiBuildConfig, + } +} + +func TestCiTemplateReadService_OverrideNeverServesDockerBuildArgs(t *testing.T) { + for _, buildType := range []bean2.CiBuildType{bean2.SELF_DOCKERFILE_BUILD_TYPE, bean2.MANAGED_DOCKERFILE_BUILD_TYPE} { + t.Run(string(buildType)+"/stored args are dropped, the rest of the override is served", func(t *testing.T) { + svc := newReadService(t, nil, &fakeCiTemplateOverrideRepository{overrides: []*pipelineConfig.CiTemplateOverride{overrideRow(buildType, staleDockerOverrideMetadata)}}) + for read, cfg := range readOverrideBuildConfigs(t, svc) { + require.NotNil(t, cfg.DockerBuildConfig, read) + assert.Nil(t, cfg.DockerBuildConfig.Args, read) + assert.Equal(t, buildType, cfg.CiBuildType, read) + assert.Equal(t, 41, cfg.Id, read) + assert.Equal(t, 31, cfg.GitMaterialId, read) + assert.Equal(t, 32, cfg.BuildContextGitMaterialId, read) + assert.Equal(t, "Dockerfile", cfg.DockerBuildConfig.DockerfilePath, read) + assert.Equal(t, "linux/arm64,linux/amd64", cfg.DockerBuildConfig.TargetPlatform, read) + assert.Equal(t, map[string]string{"network": "host"}, cfg.DockerBuildConfig.DockerBuildOptions, read) + assert.Equal(t, "ctx", cfg.DockerBuildConfig.BuildContext, read) + } + }) + } + + t.Run("override without args is served unchanged", func(t *testing.T) { + svc := newReadService(t, nil, &fakeCiTemplateOverrideRepository{overrides: []*pipelineConfig.CiTemplateOverride{ + overrideRow(bean2.SELF_DOCKERFILE_BUILD_TYPE, `{"dockerfileRelativePath":"Dockerfile","targetPlatform":"linux/amd64"}`)}}) + for read, cfg := range readOverrideBuildConfigs(t, svc) { + assert.Nil(t, cfg.DockerBuildConfig.Args, read) + assert.Equal(t, "linux/amd64", cfg.DockerBuildConfig.TargetPlatform, read) + } + }) + + t.Run("override without a ci_build_config row falls back to a dockerfile config without args", func(t *testing.T) { + row := overrideRow(bean2.SELF_DOCKERFILE_BUILD_TYPE, "") + row.CiBuildConfig = nil + svc := newReadService(t, nil, &fakeCiTemplateOverrideRepository{overrides: []*pipelineConfig.CiTemplateOverride{row}}) + for read, cfg := range readOverrideBuildConfigs(t, svc) { + assert.Equal(t, bean2.SELF_DOCKERFILE_BUILD_TYPE, cfg.CiBuildType, read) + assert.Equal(t, "legacy/Dockerfile", cfg.DockerBuildConfig.DockerfilePath, read) + assert.Nil(t, cfg.DockerBuildConfig.Args, read) + } + }) + + t.Run("regression: buildpack override keeps its buildpack env args", func(t *testing.T) { + svc := newReadService(t, nil, &fakeCiTemplateOverrideRepository{overrides: []*pipelineConfig.CiTemplateOverride{ + overrideRow(bean2.BUILDPACK_BUILD_TYPE, `{"builderId":"b","args":{"BP_ENV":"x"}}`)}}) + for read, cfg := range readOverrideBuildConfigs(t, svc) { + assert.Nil(t, cfg.DockerBuildConfig, read) + assert.Equal(t, map[string]string{"BP_ENV": "x"}, cfg.BuildPackConfig.Args, read) + } + }) + + t.Run("regression: ci job (skip build) override", func(t *testing.T) { + svc := newReadService(t, nil, &fakeCiTemplateOverrideRepository{overrides: []*pipelineConfig.CiTemplateOverride{overrideRow(bean2.SKIP_BUILD_TYPE, "")}}) + for read, cfg := range readOverrideBuildConfigs(t, svc) { + assert.Equal(t, bean2.SKIP_BUILD_TYPE, cfg.CiBuildType, read) + assert.Nil(t, cfg.DockerBuildConfig, read) + } + }) +} + +// Regression: app level build config owns the args and must keep serving them. +func TestCiTemplateReadService_AppLevelKeepsDockerBuildArgs(t *testing.T) { + t.Run("app level build config row", func(t *testing.T) { + template := &pipelineConfig.CiTemplate{Id: 1, AppId: 1, CiBuildConfig: &pipelineConfig.CiBuildConfig{ + Type: string(bean2.SELF_DOCKERFILE_BUILD_TYPE), BuildMetadata: `{"dockerfileRelativePath":"Dockerfile","args":{"APP":"1"},"targetPlatform":"linux/amd64"}`, + }} + svc := newReadService(t, &fakeCiTemplateRepository{template: template}, nil) + byAppId, err := svc.FindByAppId(1) + require.NoError(t, err) + assert.Equal(t, map[string]string{"APP": "1"}, byAppId.CiBuildConfig.DockerBuildConfig.Args) + assert.Equal(t, "linux/amd64", byAppId.CiBuildConfig.DockerBuildConfig.TargetPlatform) + byAppIds, err := svc.FindByAppIds([]int{1}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"APP": "1"}, byAppIds[1].CiBuildConfig.DockerBuildConfig.Args) + }) + t.Run("legacy app level template without build config row", func(t *testing.T) { + template := &pipelineConfig.CiTemplate{Id: 1, AppId: 1, DockerfilePath: "Dockerfile", Args: `{"LEGACY":"x"}`, TargetPlatform: "linux/arm64"} + svc := newReadService(t, &fakeCiTemplateRepository{template: template}, nil) + byAppId, err := svc.FindByAppId(1) + require.NoError(t, err) + assert.Equal(t, map[string]string{"LEGACY": "x"}, byAppId.CiBuildConfig.DockerBuildConfig.Args) + byAppIds, err := svc.FindByAppIds([]int{1}) + require.NoError(t, err) + assert.Equal(t, map[string]string{"LEGACY": "x"}, byAppIds[1].CiBuildConfig.DockerBuildConfig.Args) + }) +} diff --git a/pkg/build/pipeline/bean/CiBuildConfig.go b/pkg/build/pipeline/bean/CiBuildConfig.go index 39d8671a35..54ffe28384 100644 --- a/pkg/build/pipeline/bean/CiBuildConfig.go +++ b/pkg/build/pipeline/bean/CiBuildConfig.go @@ -41,6 +41,15 @@ type CiBuildConfigBean struct { PipelineType string `json:"pipelineType"` } +// ClearDockerBuildArgs drops docker build args from a CI pipeline's build config override. +// Build args are not part of the override: an overridden pipeline builds with the app level +// args, with the pipeline's own docker args applied on top at trigger time. +func (b *CiBuildConfigBean) ClearDockerBuildArgs() { + if b != nil && b.DockerBuildConfig != nil { + b.DockerBuildConfig.Args = nil + } +} + type DockerBuildConfig struct { DockerfilePath string `json:"dockerfileRelativePath,omitempty"` DockerfileContent string `json:"dockerfileContent"` diff --git a/pkg/build/pipeline/bean/CiBuildConfig_test.go b/pkg/build/pipeline/bean/CiBuildConfig_test.go new file mode 100644 index 0000000000..1a0c961c55 --- /dev/null +++ b/pkg/build/pipeline/bean/CiBuildConfig_test.go @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package bean + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCiBuildConfigBean_ClearDockerBuildArgs(t *testing.T) { + t.Run("nil receiver is a no-op", func(t *testing.T) { + var b *CiBuildConfigBean + assert.NotPanics(t, func() { b.ClearDockerBuildArgs() }) + }) + + t.Run("nil docker build config (buildpack / skip build) is a no-op", func(t *testing.T) { + for _, buildType := range []CiBuildType{BUILDPACK_BUILD_TYPE, SKIP_BUILD_TYPE} { + b := &CiBuildConfigBean{ + CiBuildType: buildType, + BuildPackConfig: &BuildPackConfig{BuilderId: "builder", Args: map[string]string{"BP_KEY": "v"}}, + } + assert.NotPanics(t, func() { b.ClearDockerBuildArgs() }, buildType) + assert.Nil(t, b.DockerBuildConfig, buildType) + // buildpack env args are part of the override ("build the container image" section), never touched + assert.Equal(t, map[string]string{"BP_KEY": "v"}, b.BuildPackConfig.Args, buildType) + } + }) + + argShapes := map[string]map[string]string{ + "nil args": nil, + "empty args": {}, + "populated args": {"AWS_ACCESS_KEY_ID": "AKIA...", "AWS_SECRET_ACCESS_KEY": "secret", "FOO": "bar"}, + } + for _, buildType := range []CiBuildType{SELF_DOCKERFILE_BUILD_TYPE, MANAGED_DOCKERFILE_BUILD_TYPE} { + for name, args := range argShapes { + t.Run(string(buildType)+"/"+name+" are cleared, every other field kept", func(t *testing.T) { + b := &CiBuildConfigBean{ + Id: 7, + GitMaterialId: 3, + BuildContextGitMaterialId: 4, + UseRootBuildContext: true, + CiBuildType: buildType, + PipelineType: "CI_BUILD", + DockerBuildConfig: &DockerBuildConfig{ + DockerfilePath: "Dockerfile", + DockerfileContent: "FROM alpine", + Args: args, + TargetPlatform: "linux/arm64,linux/amd64", + Language: "go", + DockerBuildOptions: map[string]string{"network": "host"}, + BuildContext: "./ctx", + UseBuildx: true, + }, + } + b.ClearDockerBuildArgs() + + assert.Nil(t, b.DockerBuildConfig.Args) + assert.Equal(t, 7, b.Id) + assert.Equal(t, 3, b.GitMaterialId) + assert.Equal(t, 4, b.BuildContextGitMaterialId) + assert.True(t, b.UseRootBuildContext) + assert.Equal(t, buildType, b.CiBuildType) + assert.Equal(t, "CI_BUILD", b.PipelineType) + d := b.DockerBuildConfig + assert.Equal(t, "Dockerfile", d.DockerfilePath) + assert.Equal(t, "FROM alpine", d.DockerfileContent) + // target platform IS part of the override and must survive + assert.Equal(t, "linux/arm64,linux/amd64", d.TargetPlatform) + assert.Equal(t, "go", d.Language) + assert.Equal(t, map[string]string{"network": "host"}, d.DockerBuildOptions) + assert.Equal(t, "./ctx", d.BuildContext) + assert.True(t, d.UseBuildx) + }) + } + } + + t.Run("idempotent", func(t *testing.T) { + b := &CiBuildConfigBean{CiBuildType: SELF_DOCKERFILE_BUILD_TYPE, DockerBuildConfig: &DockerBuildConfig{Args: map[string]string{"A": "1"}}} + b.ClearDockerBuildArgs() + b.ClearDockerBuildArgs() + assert.Nil(t, b.DockerBuildConfig.Args) + }) +} diff --git a/pkg/build/trigger/HandlerService.go b/pkg/build/trigger/HandlerService.go index ff015e8316..c9ea87862c 100644 --- a/pkg/build/trigger/HandlerService.go +++ b/pkg/build/trigger/HandlerService.go @@ -1137,8 +1137,9 @@ func (impl *HandlerServiceImpl) buildWfRequestForCiPipeline(pipeline *pipelineCo } ciBuildConfigBean = templateOverrideBean.CiBuildConfig // updating args coming from ciBaseBuildConfigEntity because it is not part of Ci override - if ciBuildConfigBean != nil && ciBuildConfigBean.DockerBuildConfig != nil && ciBaseBuildConfigBean != nil && ciBaseBuildConfigBean.DockerBuildConfig != nil { - ciBuildConfigBean.DockerBuildConfig.Args = ciBaseBuildConfigBean.DockerBuildConfig.Args + if err := adapter.InheritAppLevelDockerBuildArgs(ciBuildConfigBean, ciTemplate, ciBaseBuildConfigBean); err != nil { + impl.Logger.Errorw("error occurred while reading app level docker build args", "ciTemplateId", ciTemplate.Id, "err", err) + return nil, errors.New("error while parsing ci build config") } templateOverride := templateOverrideBean.CiTemplateOverride checkoutPath = templateOverride.GitMaterial.CheckoutPath diff --git a/pkg/pipeline/CiTemplateService.go b/pkg/pipeline/CiTemplateService.go index 620b2c05e3..b20bac1fde 100644 --- a/pkg/pipeline/CiTemplateService.go +++ b/pkg/pipeline/CiTemplateService.go @@ -50,6 +50,9 @@ func (impl CiTemplateServiceImpl) Save(ciTemplateBean *bean.CiTemplateBean) erro ciTemplateOverrideId := 0 buildConfig := ciTemplateBean.CiBuildConfig + if ciTemplateOverride != nil { + buildConfig.ClearDockerBuildArgs() + } err := impl.CiBuildConfigService.Save(ciTemplateId, ciTemplateOverrideId, buildConfig, ciTemplateBean.UserId) if err != nil { impl.Logger.Errorw("error occurred while saving ci build config", "config", buildConfig, "err", err) @@ -86,6 +89,8 @@ func (impl CiTemplateServiceImpl) Update(ciTemplateBean *bean.CiTemplateBean) er ciTemplateId = ciTemplate.Id } else { ciTemplateOverrideId = ciTemplateOverride.Id + // the full build_metadata is rewritten from the request, so args echoed back by a client are dropped here + ciBuildConfig.ClearDockerBuildArgs() } _, err := impl.CiBuildConfigService.UpdateOrSave(ciTemplateId, ciTemplateOverrideId, ciBuildConfig, ciTemplateBean.UserId) if err != nil { diff --git a/pkg/pipeline/CiTemplateServiceOverrideArgs_test.go b/pkg/pipeline/CiTemplateServiceOverrideArgs_test.go new file mode 100644 index 0000000000..c66b9c6da4 --- /dev/null +++ b/pkg/pipeline/CiTemplateServiceOverrideArgs_test.go @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package pipeline + +import ( + "encoding/json" + "testing" + + "github.com/devtron-labs/devtron/internal/sql/repository/pipelineConfig" + "github.com/devtron-labs/devtron/internal/util" + bean2 "github.com/devtron-labs/devtron/pkg/build/pipeline/bean" + "github.com/devtron-labs/devtron/pkg/pipeline/history" + historyRepository "github.com/devtron-labs/devtron/pkg/pipeline/history/repository" + "github.com/go-pg/pg" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captures what would be written to ci_build_config +type capturingCiBuildConfigRepository struct { + pipelineConfig.CiBuildConfigRepository + saved []*pipelineConfig.CiBuildConfig + updated []*pipelineConfig.CiBuildConfig +} + +func (r *capturingCiBuildConfigRepository) Save(c *pipelineConfig.CiBuildConfig) error { + c.Id = 100 + len(r.saved) + r.saved = append(r.saved, c) + return nil +} + +func (r *capturingCiBuildConfigRepository) Update(c *pipelineConfig.CiBuildConfig) error { + r.updated = append(r.updated, c) + return nil +} + +type noopCiTemplateRepository struct { + pipelineConfig.CiTemplateRepository +} + +func (noopCiTemplateRepository) Save(*pipelineConfig.CiTemplate) error { return nil } +func (noopCiTemplateRepository) Update(*pipelineConfig.CiTemplate) error { return nil } + +type noopCiTemplateOverrideRepository struct { + pipelineConfig.CiTemplateOverrideRepository +} + +func (noopCiTemplateOverrideRepository) Save(o *pipelineConfig.CiTemplateOverride) (*pipelineConfig.CiTemplateOverride, error) { + return o, nil +} +func (noopCiTemplateOverrideRepository) Update(o *pipelineConfig.CiTemplateOverride) (*pipelineConfig.CiTemplateOverride, error) { + return o, nil +} + +func newCiTemplateServiceForTest(t *testing.T) (*CiTemplateServiceImpl, *capturingCiBuildConfigRepository) { + logger, err := util.NewSugardLogger() + require.NoError(t, err) + buildConfigRepo := &capturingCiBuildConfigRepository{} + return NewCiTemplateServiceImpl(logger, NewCiBuildConfigServiceImpl(logger, buildConfigRepo), + noopCiTemplateRepository{}, noopCiTemplateOverrideRepository{}), buildConfigRepo +} + +// the #2983 request: the dashboard echoes the override's hidden args (and target platform) back on save +func echoedDockerOverride(buildType bean2.CiBuildType, id int) *bean2.CiBuildConfigBean { + return &bean2.CiBuildConfigBean{ + Id: id, + GitMaterialId: 1, BuildContextGitMaterialId: 1, + CiBuildType: buildType, + DockerBuildConfig: &bean2.DockerBuildConfig{ + DockerfilePath: "Dockerfile", + Args: map[string]string{"AWS_ACCESS_KEY_ID": "AKIA-stale", "AWS_SECRET_ACCESS_KEY": "stale-secret"}, + TargetPlatform: "linux/arm64,linux/amd64", + DockerBuildOptions: map[string]string{"network": "host"}, + BuildContext: "ctx", + }, + } +} + +func decodeMetadata(t *testing.T, entity *pipelineConfig.CiBuildConfig) map[string]interface{} { + m := map[string]interface{}{} + require.NoError(t, json.Unmarshal([]byte(entity.BuildMetadata), &m)) + return m +} + +func assertOverrideMetadataWithoutArgs(t *testing.T, entity *pipelineConfig.CiBuildConfig) { + m := decodeMetadata(t, entity) + assert.NotContains(t, m, "args") + assert.NotContains(t, entity.BuildMetadata, "AWS_SECRET_ACCESS_KEY") + // everything that IS part of the override is persisted as sent + assert.Equal(t, "linux/arm64,linux/amd64", m["targetPlatform"]) + assert.Equal(t, "Dockerfile", m["dockerfileRelativePath"]) + assert.Equal(t, map[string]interface{}{"network": "host"}, m["dockerBuildOptions"]) + assert.Equal(t, "ctx", m["buildContext"]) +} + +func TestCiTemplateService_OverrideNeverPersistsDockerBuildArgs(t *testing.T) { + for _, buildType := range []bean2.CiBuildType{bean2.SELF_DOCKERFILE_BUILD_TYPE, bean2.MANAGED_DOCKERFILE_BUILD_TYPE} { + t.Run(string(buildType)+"/Save (pipeline create, clone, template, ci switch)", func(t *testing.T) { + svc, repo := newCiTemplateServiceForTest(t) + cfg := echoedDockerOverride(buildType, 0) + override := &pipelineConfig.CiTemplateOverride{CiPipelineId: 5} + require.NoError(t, svc.Save(&bean2.CiTemplateBean{CiTemplateOverride: override, CiBuildConfig: cfg, UserId: 1})) + + require.Len(t, repo.saved, 1) + assertOverrideMetadataWithoutArgs(t, repo.saved[0]) + assert.Equal(t, string(buildType), repo.saved[0].Type) + assert.Equal(t, repo.saved[0].Id, override.CiBuildConfigId) + // the caller's bean is cleared too: PatchMaterialValue/CreateCiConf hand this same bean to the + // pipeline history snapshot and return it in the API response + assert.Nil(t, cfg.DockerBuildConfig.Args) + }) + + t.Run(string(buildType)+"/Update (Update Pipeline)", func(t *testing.T) { + svc, repo := newCiTemplateServiceForTest(t) + cfg := echoedDockerOverride(buildType, 41) + require.NoError(t, svc.Update(&bean2.CiTemplateBean{CiTemplateOverride: &pipelineConfig.CiTemplateOverride{Id: 11, CiPipelineId: 5}, CiBuildConfig: cfg, UserId: 1})) + + require.Len(t, repo.updated, 1) + assert.Empty(t, repo.saved) + assert.Equal(t, 41, repo.updated[0].Id) + assert.Equal(t, 11, repo.updated[0].CiTemplateOverrideId) + assertOverrideMetadataWithoutArgs(t, repo.updated[0]) + assert.Nil(t, cfg.DockerBuildConfig.Args) + }) + + t.Run(string(buildType)+"/Update of an override without build config row saves a new one without args", func(t *testing.T) { + svc, repo := newCiTemplateServiceForTest(t) + cfg := echoedDockerOverride(buildType, 0) + require.NoError(t, svc.Update(&bean2.CiTemplateBean{CiTemplateOverride: &pipelineConfig.CiTemplateOverride{Id: 11}, CiBuildConfig: cfg, UserId: 1})) + require.Len(t, repo.saved, 1) + assertOverrideMetadataWithoutArgs(t, repo.saved[0]) + }) + } + + t.Run("regression: buildpack override keeps its buildpack env args", func(t *testing.T) { + for _, update := range []bool{false, true} { + svc, repo := newCiTemplateServiceForTest(t) + cfg := &bean2.CiBuildConfigBean{Id: 41, CiBuildType: bean2.BUILDPACK_BUILD_TYPE, BuildPackConfig: &bean2.BuildPackConfig{BuilderId: "b", Args: map[string]string{"BP_ENV": "x"}}} + tb := &bean2.CiTemplateBean{CiTemplateOverride: &pipelineConfig.CiTemplateOverride{Id: 11}, CiBuildConfig: cfg, UserId: 1} + var entity *pipelineConfig.CiBuildConfig + if update { + require.NoError(t, svc.Update(tb)) + entity = repo.updated[0] + } else { + require.NoError(t, svc.Save(tb)) + entity = repo.saved[0] + } + assert.Equal(t, map[string]interface{}{"BP_ENV": "x"}, decodeMetadata(t, entity)["args"]) + } + }) + + t.Run("regression: ci job (skip build) override", func(t *testing.T) { + svc, repo := newCiTemplateServiceForTest(t) + cfg := &bean2.CiBuildConfigBean{CiBuildType: bean2.SKIP_BUILD_TYPE} + require.NoError(t, svc.Save(&bean2.CiTemplateBean{CiTemplateOverride: &pipelineConfig.CiTemplateOverride{}, CiBuildConfig: cfg, UserId: 1})) + require.Len(t, repo.saved, 1) + assert.Equal(t, "", repo.saved[0].BuildMetadata) + }) +} + +// Regression: the app level build config (ci_template) owns the args, Save/Update must keep them. +func TestCiTemplateService_AppLevelKeepsDockerBuildArgs(t *testing.T) { + appLevel := func(id int) *bean2.CiBuildConfigBean { + return &bean2.CiBuildConfigBean{Id: id, CiBuildType: bean2.SELF_DOCKERFILE_BUILD_TYPE, DockerBuildConfig: &bean2.DockerBuildConfig{ + DockerfilePath: "Dockerfile", Args: map[string]string{"APP": "1"}, TargetPlatform: "linux/amd64", + }} + } + t.Run("Save", func(t *testing.T) { + svc, repo := newCiTemplateServiceForTest(t) + cfg := appLevel(0) + require.NoError(t, svc.Save(&bean2.CiTemplateBean{CiTemplate: &pipelineConfig.CiTemplate{AppId: 1}, CiBuildConfig: cfg, UserId: 1})) + assert.Equal(t, map[string]interface{}{"APP": "1"}, decodeMetadata(t, repo.saved[0])["args"]) + assert.Equal(t, map[string]string{"APP": "1"}, cfg.DockerBuildConfig.Args) + }) + t.Run("Update", func(t *testing.T) { + svc, repo := newCiTemplateServiceForTest(t) + cfg := appLevel(9) + require.NoError(t, svc.Update(&bean2.CiTemplateBean{CiTemplate: &pipelineConfig.CiTemplate{Id: 3, AppId: 1}, CiBuildConfig: cfg, UserId: 1})) + assert.Equal(t, 3, repo.updated[0].CiTemplateId) + assert.Equal(t, map[string]interface{}{"APP": "1"}, decodeMetadata(t, repo.updated[0])["args"]) + assert.Equal(t, map[string]string{"APP": "1"}, cfg.DockerBuildConfig.Args) + }) +} + +type capturingCiPipelineHistoryRepository struct { + historyRepository.CiPipelineHistoryRepository + saved []*historyRepository.CiPipelineHistory +} + +func (r *capturingCiPipelineHistoryRepository) Save(h *historyRepository.CiPipelineHistory) error { + r.saved = append(r.saved, h) + return nil +} + +type noCiEnvMappingRepository struct { + pipelineConfig.CiPipelineRepository +} + +func (noCiEnvMappingRepository) FindCiEnvMappingByCiPipelineId(int) (*pipelineConfig.CiEnvMapping, error) { + return &pipelineConfig.CiEnvMapping{}, pg.ErrNoRows +} + +// PatchMaterialValue saves the override and then snapshots the very same bean into ci_pipeline_history; +// the snapshot must not keep a copy of the args either. +func TestCiTemplateService_OverrideHistorySnapshotHasNoDockerBuildArgs(t *testing.T) { + logger, err := util.NewSugardLogger() + require.NoError(t, err) + svc, _ := newCiTemplateServiceForTest(t) + historyRepo := &capturingCiPipelineHistoryRepository{} + historySvc := history.NewCiPipelineHistoryServiceImpl(historyRepo, logger, noCiEnvMappingRepository{}) + + tb := &bean2.CiTemplateBean{ + CiTemplateOverride: &pipelineConfig.CiTemplateOverride{Id: 11, CiPipelineId: 5, Active: true}, + CiBuildConfig: echoedDockerOverride(bean2.SELF_DOCKERFILE_BUILD_TYPE, 41), + UserId: 1, + } + require.NoError(t, svc.Update(tb)) + require.NoError(t, historySvc.SaveHistory(&pipelineConfig.CiPipeline{Id: 5, IsDockerConfigOverridden: true}, nil, tb, historyRepository.TRIGGER_UPDATE)) + + require.Len(t, historyRepo.saved, 1) + snapshot := historyRepository.CiPipelineTemplateOverrideHistoryDTO{} + require.NoError(t, json.Unmarshal([]byte(historyRepo.saved[0].CiTemplateOverrideHistory), &snapshot)) + metadata := map[string]interface{}{} + require.NoError(t, json.Unmarshal([]byte(snapshot.BuildMetadata), &metadata)) + assert.NotContains(t, metadata, "args") + assert.Equal(t, "linux/arm64,linux/amd64", metadata["targetPlatform"]) +} diff --git a/pkg/pipeline/adapter/adapter.go b/pkg/pipeline/adapter/adapter.go index 0b966d721c..0b531d947d 100644 --- a/pkg/pipeline/adapter/adapter.go +++ b/pkg/pipeline/adapter/adapter.go @@ -178,6 +178,34 @@ func OverrideCiBuildConfig(dockerfilePath string, oldArgs string, ciLevelArgs st return ciBuildConfigBean, nil } +// InheritAppLevelDockerBuildArgs sets the app level docker build args on the build config override of a +// CI pipeline, replacing any args it carries: build args are not part of the override, an overridden +// pipeline builds with the app level args (pipeline level args are merged on top by OverrideCiBuildConfig). +// appLevelBuildConfig is the converted ciTemplate.CiBuildConfig; it is nil for legacy templates +// that have no ci_build_config row, whose args still live in ci_template.args. +func InheritAppLevelDockerBuildArgs(overrideBuildConfig *bean2.CiBuildConfigBean, ciTemplate *pipelineConfig.CiTemplate, appLevelBuildConfig *bean2.CiBuildConfigBean) error { + if overrideBuildConfig == nil || overrideBuildConfig.DockerBuildConfig == nil { + return nil + } + if appLevelBuildConfig != nil { + var appLevelArgs map[string]string + // app level build may not be dockerfile based (e.g. buildpack), then there are no docker build args to inherit + if appLevelBuildConfig.DockerBuildConfig != nil { + appLevelArgs = appLevelBuildConfig.DockerBuildConfig.Args + } + overrideBuildConfig.DockerBuildConfig.Args = appLevelArgs + return nil + } + appLevelArgs := map[string]string{} + if ciTemplate != nil && ciTemplate.Args != "" { + if err := json.Unmarshal([]byte(ciTemplate.Args), &appLevelArgs); err != nil { + return err + } + } + overrideBuildConfig.DockerBuildConfig.Args = appLevelArgs + return nil +} + func mergeMap(oldDockerArgs map[string]string, ciLevelDockerArgs map[string]string) map[string]string { dockerArgs := make(map[string]string) for key, value := range oldDockerArgs { diff --git a/pkg/pipeline/adapter/adapter_test.go b/pkg/pipeline/adapter/adapter_test.go new file mode 100644 index 0000000000..614f0888de --- /dev/null +++ b/pkg/pipeline/adapter/adapter_test.go @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2024. Devtron Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package adapter + +import ( + "testing" + + "github.com/devtron-labs/devtron/internal/sql/repository/pipelineConfig" + bean2 "github.com/devtron-labs/devtron/pkg/build/pipeline/bean" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// staleOverrideArgs mimics the #2983 data: args saved into the override's build_metadata that the +// pipeline editor never shows, including credentials. +func staleOverrideArgs() map[string]string { + return map[string]string{"AWS_ACCESS_KEY_ID": "AKIA-stale", "AWS_SECRET_ACCESS_KEY": "stale-secret", "SHARED": "from-override"} +} + +func dockerOverride(buildType bean2.CiBuildType, args map[string]string) *bean2.CiBuildConfigBean { + return &bean2.CiBuildConfigBean{ + CiBuildType: buildType, + DockerBuildConfig: &bean2.DockerBuildConfig{ + DockerfilePath: "Dockerfile.override", + Args: args, + TargetPlatform: "linux/arm64,linux/amd64", + DockerBuildOptions: map[string]string{"network": "host"}, + }, + } +} + +func appLevelDocker(args map[string]string) *bean2.CiBuildConfigBean { + return &bean2.CiBuildConfigBean{ + CiBuildType: bean2.SELF_DOCKERFILE_BUILD_TYPE, + DockerBuildConfig: &bean2.DockerBuildConfig{DockerfilePath: "Dockerfile", Args: args, TargetPlatform: "linux/amd64"}, + } +} + +func appLevelBuildpack() *bean2.CiBuildConfigBean { + return &bean2.CiBuildConfigBean{ + CiBuildType: bean2.BUILDPACK_BUILD_TYPE, + BuildPackConfig: &bean2.BuildPackConfig{BuilderId: "b", Args: map[string]string{"BP_ENV": "x"}}, + } +} + +func TestInheritAppLevelDockerBuildArgs(t *testing.T) { + type tc struct { + name string + ciTemplate *pipelineConfig.CiTemplate + appLevel *bean2.CiBuildConfigBean + overrideArgs map[string]string + want map[string]string + wantErr bool + } + cases := []tc{ + // app level build config is dockerfile based + {name: "app level docker args replace stale override args", appLevel: appLevelDocker(map[string]string{"APP": "1", "SHARED": "from-app"}), overrideArgs: staleOverrideArgs(), + want: map[string]string{"APP": "1", "SHARED": "from-app"}}, + {name: "app level docker without args drops stale override args", appLevel: appLevelDocker(nil), overrideArgs: staleOverrideArgs(), want: nil}, + {name: "app level docker with empty args drops stale override args", appLevel: appLevelDocker(map[string]string{}), overrideArgs: staleOverrideArgs(), want: map[string]string{}}, + {name: "app level docker args, override already clean", appLevel: appLevelDocker(map[string]string{"APP": "1"}), overrideArgs: nil, want: map[string]string{"APP": "1"}}, + {name: "app level docker config wins over legacy ci_template.args", ciTemplate: &pipelineConfig.CiTemplate{Args: `{"LEGACY":"x"}`}, + appLevel: appLevelDocker(map[string]string{"APP": "1"}), overrideArgs: staleOverrideArgs(), want: map[string]string{"APP": "1"}}, + // app level build config is not dockerfile based - previously the override args leaked into the build here + {name: "app level buildpack drops stale override args", appLevel: appLevelBuildpack(), overrideArgs: staleOverrideArgs(), want: nil}, + {name: "app level buildpack ignores legacy ci_template.args", ciTemplate: &pipelineConfig.CiTemplate{Args: `{"LEGACY":"x"}`}, + appLevel: appLevelBuildpack(), overrideArgs: staleOverrideArgs(), want: nil}, + {name: "app level skip build drops stale override args", appLevel: &bean2.CiBuildConfigBean{CiBuildType: bean2.SKIP_BUILD_TYPE}, overrideArgs: staleOverrideArgs(), want: nil}, + // legacy template without a ci_build_config row - previously the override args leaked into the build here too + {name: "legacy template args replace stale override args", ciTemplate: &pipelineConfig.CiTemplate{Args: `{"LEGACY":"x","SHARED":"from-legacy"}`}, + overrideArgs: staleOverrideArgs(), want: map[string]string{"LEGACY": "x", "SHARED": "from-legacy"}}, + {name: "legacy template with empty args string", ciTemplate: &pipelineConfig.CiTemplate{Args: ""}, overrideArgs: staleOverrideArgs(), want: map[string]string{}}, + {name: "legacy template with {} args", ciTemplate: &pipelineConfig.CiTemplate{Args: "{}"}, overrideArgs: staleOverrideArgs(), want: map[string]string{}}, + {name: "legacy template with null args", ciTemplate: &pipelineConfig.CiTemplate{Args: "null"}, overrideArgs: staleOverrideArgs(), want: nil}, + {name: "nil template and nil app level config", ciTemplate: nil, overrideArgs: staleOverrideArgs(), want: map[string]string{}}, + {name: "legacy template with malformed args errors", ciTemplate: &pipelineConfig.CiTemplate{Args: `{"broken"`}, overrideArgs: staleOverrideArgs(), wantErr: true}, + } + for _, overrideType := range []bean2.CiBuildType{bean2.SELF_DOCKERFILE_BUILD_TYPE, bean2.MANAGED_DOCKERFILE_BUILD_TYPE} { + for _, c := range cases { + t.Run(string(overrideType)+"/"+c.name, func(t *testing.T) { + override := dockerOverride(overrideType, c.overrideArgs) + err := InheritAppLevelDockerBuildArgs(override, c.ciTemplate, c.appLevel) + if c.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, c.want, override.DockerBuildConfig.Args) + for k := range staleOverrideArgs() { + if k == "SHARED" { + continue + } + assert.NotContains(t, override.DockerBuildConfig.Args, k) + } + // nothing but args is inherited: the rest of the override stays the override's + assert.Equal(t, "Dockerfile.override", override.DockerBuildConfig.DockerfilePath) + assert.Equal(t, "linux/arm64,linux/amd64", override.DockerBuildConfig.TargetPlatform) + assert.Equal(t, map[string]string{"network": "host"}, override.DockerBuildConfig.DockerBuildOptions) + assert.Equal(t, overrideType, override.CiBuildType) + }) + } + } + + t.Run("buildpack override is untouched", func(t *testing.T) { + override := &bean2.CiBuildConfigBean{CiBuildType: bean2.BUILDPACK_BUILD_TYPE, BuildPackConfig: &bean2.BuildPackConfig{Args: map[string]string{"BP": "1"}}} + require.NoError(t, InheritAppLevelDockerBuildArgs(override, &pipelineConfig.CiTemplate{Args: `{"LEGACY":"x"}`}, appLevelDocker(map[string]string{"APP": "1"}))) + assert.Nil(t, override.DockerBuildConfig) + assert.Equal(t, map[string]string{"BP": "1"}, override.BuildPackConfig.Args) + }) + t.Run("skip build (ci job) override is untouched", func(t *testing.T) { + override := &bean2.CiBuildConfigBean{CiBuildType: bean2.SKIP_BUILD_TYPE} + require.NoError(t, InheritAppLevelDockerBuildArgs(override, nil, appLevelDocker(map[string]string{"APP": "1"}))) + assert.Nil(t, override.DockerBuildConfig) + }) + t.Run("nil override is a no-op", func(t *testing.T) { + assert.NoError(t, InheritAppLevelDockerBuildArgs(nil, &pipelineConfig.CiTemplate{Args: `{"broken"`}, nil)) + }) + t.Run("malformed legacy args are not parsed when the override has no docker config", func(t *testing.T) { + override := &bean2.CiBuildConfigBean{CiBuildType: bean2.BUILDPACK_BUILD_TYPE, BuildPackConfig: &bean2.BuildPackConfig{}} + assert.NoError(t, InheritAppLevelDockerBuildArgs(override, &pipelineConfig.CiTemplate{Args: `{"broken"`}, nil)) + }) +} + +// TestOverriddenPipelineEffectiveBuildArgs runs the two adapter calls the CI trigger makes for an overridden +// pipeline (InheritAppLevelDockerBuildArgs, then OverrideCiBuildConfig) and checks what reaches ci-runner. +func TestOverriddenPipelineEffectiveBuildArgs(t *testing.T) { + type tc struct { + name string + ciTemplate *pipelineConfig.CiTemplate + appLevel *bean2.CiBuildConfigBean + pipelineArgs string // ci_pipeline.docker_args, "{}" when empty (as the trigger defaults it) + wantArgs map[string]string + wantTargetPlat string + } + cases := []tc{ + { + // #2983: pipeline args deleted in the editor, override metadata still holds the old args + name: "issue 2983: pipeline args cleared, nothing at app level", ciTemplate: &pipelineConfig.CiTemplate{}, + appLevel: appLevelDocker(nil), pipelineArgs: "{}", wantArgs: map[string]string{}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + { + name: "app level args only", ciTemplate: &pipelineConfig.CiTemplate{}, + appLevel: appLevelDocker(map[string]string{"APP": "1"}), pipelineArgs: "{}", wantArgs: map[string]string{"APP": "1"}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + { + name: "pipeline args only", ciTemplate: &pipelineConfig.CiTemplate{}, + appLevel: appLevelDocker(nil), pipelineArgs: `{"PIPE":"p"}`, wantArgs: map[string]string{"PIPE": "p"}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + { + name: "pipeline args win over app level args on the same key", ciTemplate: &pipelineConfig.CiTemplate{}, + appLevel: appLevelDocker(map[string]string{"APP": "1", "SHARED": "from-app"}), pipelineArgs: `{"SHARED":"from-pipeline","PIPE":"p"}`, + wantArgs: map[string]string{"APP": "1", "SHARED": "from-pipeline", "PIPE": "p"}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + { + name: "buildpack app level: only pipeline args", ciTemplate: &pipelineConfig.CiTemplate{Args: `{"LEGACY":"x"}`}, + appLevel: appLevelBuildpack(), pipelineArgs: `{"PIPE":"p"}`, wantArgs: map[string]string{"PIPE": "p"}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + { + name: "legacy app level: legacy args + pipeline args", ciTemplate: &pipelineConfig.CiTemplate{Args: `{"LEGACY":"x","SHARED":"from-legacy"}`}, + appLevel: nil, pipelineArgs: `{"SHARED":"from-pipeline"}`, wantArgs: map[string]string{"LEGACY": "x", "SHARED": "from-pipeline"}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + { + name: "legacy app level, nothing configured", ciTemplate: &pipelineConfig.CiTemplate{}, + appLevel: nil, pipelineArgs: "{}", wantArgs: map[string]string{}, wantTargetPlat: "linux/arm64,linux/amd64", + }, + } + for _, c := range cases { + // both the read-stripped bean (normal path) and a raw bean still carrying args must give the same result + for _, overrideArgs := range []map[string]string{nil, staleOverrideArgs()} { + name := c.name + "/override args stripped" + if overrideArgs != nil { + name = c.name + "/override args present" + } + t.Run(name, func(t *testing.T) { + override := dockerOverride(bean2.SELF_DOCKERFILE_BUILD_TYPE, overrideArgs) + require.NoError(t, InheritAppLevelDockerBuildArgs(override, c.ciTemplate, c.appLevel)) + got, err := OverrideCiBuildConfig("Dockerfile", c.ciTemplate.Args, c.pipelineArgs, "{}", c.ciTemplate.TargetPlatform, override) + require.NoError(t, err) + assert.Equal(t, c.wantArgs, got.DockerBuildConfig.Args) + assert.NotContains(t, got.DockerBuildConfig.Args, "AWS_ACCESS_KEY_ID") + assert.NotContains(t, got.DockerBuildConfig.Args, "AWS_SECRET_ACCESS_KEY") + assert.Equal(t, c.wantTargetPlat, got.DockerBuildConfig.TargetPlatform) + }) + } + } +} + +// Regression: the non-overridden trigger path (same OverrideCiBuildConfig, fed the app level bean) is unchanged. +func TestOverrideCiBuildConfig_NonOverriddenPipelineRegression(t *testing.T) { + t.Run("app level args merged with pipeline args, pipeline wins", func(t *testing.T) { + got, err := OverrideCiBuildConfig("Dockerfile", "", `{"SHARED":"from-pipeline","PIPE":"p"}`, "{}", "", + appLevelDocker(map[string]string{"APP": "1", "SHARED": "from-app"})) + require.NoError(t, err) + assert.Equal(t, map[string]string{"APP": "1", "SHARED": "from-pipeline", "PIPE": "p"}, got.DockerBuildConfig.Args) + assert.Equal(t, "linux/amd64", got.DockerBuildConfig.TargetPlatform) + }) + t.Run("legacy template without build config row", func(t *testing.T) { + got, err := OverrideCiBuildConfig("Dockerfile", `{"LEGACY":"x","SHARED":"from-legacy"}`, `{"SHARED":"from-pipeline"}`, `{"network":"host"}`, "linux/arm64", nil) + require.NoError(t, err) + assert.Equal(t, bean2.SELF_DOCKERFILE_BUILD_TYPE, got.CiBuildType) + assert.Equal(t, map[string]string{"LEGACY": "x", "SHARED": "from-pipeline"}, got.DockerBuildConfig.Args) + assert.Equal(t, "linux/arm64", got.DockerBuildConfig.TargetPlatform) + assert.Equal(t, map[string]string{"network": "host"}, got.DockerBuildConfig.DockerBuildOptions) + }) + t.Run("buildpack app level is left alone", func(t *testing.T) { + got, err := OverrideCiBuildConfig("Dockerfile", "", `{"PIPE":"p"}`, "{}", "", appLevelBuildpack()) + require.NoError(t, err) + assert.Nil(t, got.DockerBuildConfig) + assert.Equal(t, map[string]string{"BP_ENV": "x"}, got.BuildPackConfig.Args) + }) +}