diff --git a/cmd/registry-replacer/main.go b/cmd/registry-replacer/main.go index 2dc2463dfef..b11db5c23e1 100644 --- a/cmd/registry-replacer/main.go +++ b/cmd/registry-replacer/main.go @@ -8,8 +8,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" - "strings" "sync" "github.com/sirupsen/logrus" @@ -30,6 +28,7 @@ import ( "github.com/openshift/ci-tools/pkg/api" "github.com/openshift/ci-tools/pkg/api/ocpbuilddata" "github.com/openshift/ci-tools/pkg/config" + cidockerfile "github.com/openshift/ci-tools/pkg/dockerfile" "github.com/openshift/ci-tools/pkg/github" "github.com/openshift/ci-tools/pkg/load" "github.com/openshift/ci-tools/pkg/registry" @@ -296,9 +295,9 @@ func replacer( continue } config.BaseImages[foundTag.String()] = api.ImageStreamTagReference{ - Namespace: foundTag.org, - Name: foundTag.repo, - Tag: foundTag.tag, + Namespace: foundTag.Org, + Name: foundTag.Repo, + Tag: foundTag.Tag, } } @@ -356,37 +355,17 @@ func replacer( } } -var registryRegex = regexp.MustCompile(`registry\.(|svc\.)ci\.openshift\.org/\S+`) - -type orgRepoTag struct{ org, repo, tag string } - -func (ort orgRepoTag) String() string { - return ort.org + "_" + ort.repo + "_" + ort.tag -} - -func ensureReplacement(image *api.ProjectDirectoryImageBuildStepConfiguration, dockerfile []byte) ([]orgRepoTag, error) { - var toReplace []string - for _, line := range bytes.Split(dockerfile, []byte("\n")) { - if !bytes.Contains(line, []byte("FROM")) && !bytes.Contains(line, []byte("COPY")) && !bytes.Contains(line, []byte("copy")) { - continue - } - match := registryRegex.Find(line) - if match == nil { - continue - } - - toReplace = append(toReplace, string(match)) - } - - var result []orgRepoTag +func ensureReplacement(image *api.ProjectDirectoryImageBuildStepConfiguration, dockerfile []byte) ([]cidockerfile.OrgRepoTag, error) { + toReplace := cidockerfile.ExtractRegistryReferences(dockerfile) + var result []cidockerfile.OrgRepoTag for _, toReplace := range toReplace { - orgRepoTag, err := orgRepoTagFromPullString(toReplace) + orgRepoTag, err := cidockerfile.OrgRepoTagFromPullString(toReplace) if err != nil { return nil, fmt.Errorf("failed to parse string %s as pullspec: %w", toReplace, err) } // Assume ppl know what they are doing - if hasReplacementFor(image, toReplace) { + if cidockerfile.HasManualReplacementFor(image.Inputs, toReplace) { continue } @@ -403,40 +382,6 @@ func ensureReplacement(image *api.ProjectDirectoryImageBuildStepConfiguration, d return result, nil } -func hasReplacementFor(image *api.ProjectDirectoryImageBuildStepConfiguration, target string) bool { - for _, input := range image.Inputs { - if sets.New[string](input.As...).Has(target) { - return true - } - } - - return false -} - -func orgRepoTagFromPullString(pullString string) (orgRepoTag, error) { - res := orgRepoTag{tag: "latest"} - slashSplit := strings.Split(pullString, "/") - n := len(slashSplit) - - switch { - case n == 1: - res.org = "_" - res.repo = slashSplit[0] - case n >= 2: - res.org = slashSplit[n-2] - res.repo = slashSplit[n-1] - default: - return res, fmt.Errorf("pull string %q couldn't be parsed, got %d components", pullString, n) - } - - if repoTag := strings.Split(res.repo, ":"); len(repoTag) == 2 { - res.repo = repoTag[0] - res.tag = repoTag[1] - } - - return res, nil -} - func upsertPR(gc pgithub.Client, dir, githubUsername string, token []byte, selfApprove, pruneUnusedReplacements, ensureCorrectPromotionDockerfile bool) error { if err := os.Chdir(dir); err != nil { return fmt.Errorf("failed to chdir into %s: %w", dir, err) @@ -584,11 +529,11 @@ func pruneUnusedReplacements(config *api.ReleaseBuildConfiguration, replacementC func pruneOCPBuilderReplacements(config *api.ReleaseBuildConfiguration) error { return pruneReplacements(config, func(asDirective string, imageKey string) (bool, error) { - orgRepoTag, err := orgRepoTagFromPullString(asDirective) + orgRepoTag, err := cidockerfile.OrgRepoTagFromPullString(asDirective) if err != nil { return false, fmt.Errorf("failed to extract org and tag from pull spec %s: %w", asDirective, err) } - if orgRepoTag.org != "ocp" || orgRepoTag.repo != "builder" { + if orgRepoTag.Org != "ocp" || orgRepoTag.Repo != "builder" { return true, nil } @@ -612,7 +557,7 @@ func pruneOCPBuilderReplacements(config *api.ReleaseBuildConfiguration) error { } // Fun special case: We set up a replacement for this ourselves to prevent direct references to api.ci - if imagestreamTagReference.Namespace == orgRepoTag.org && imagestreamTagReference.Name == orgRepoTag.repo && imagestreamTagReference.Tag == orgRepoTag.tag { + if imagestreamTagReference.Namespace == orgRepoTag.Org && imagestreamTagReference.Name == orgRepoTag.Repo && imagestreamTagReference.Tag == orgRepoTag.Tag { return true, nil } @@ -705,7 +650,7 @@ func pruneUnusedBaseImages(config *api.ReleaseBuildConfiguration, resolvedConfig pruneImage := func(images *map[string]api.ImageStreamTagReference, sourceImage string) error { var keep bool for candidate := range usedBaseImages { - orgRepoTag, err := orgRepoTagFromPullString(candidate) + orgRepoTag, err := cidockerfile.OrgRepoTagFromPullString(candidate) if err != nil { return fmt.Errorf("failed to parse string %s as pullspec: %w", candidate, err) } diff --git a/cmd/registry-replacer/main_test.go b/cmd/registry-replacer/main_test.go index b78ee6a8661..77d09f5494d 100644 --- a/cmd/registry-replacer/main_test.go +++ b/cmd/registry-replacer/main_test.go @@ -8,11 +8,12 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "k8s.io/apimachinery/pkg/util/sets" - utilpointer "k8s.io/utils/pointer" + "k8s.io/utils/ptr" "github.com/openshift/ci-tools/pkg/api" "github.com/openshift/ci-tools/pkg/api/ocpbuilddata" "github.com/openshift/ci-tools/pkg/config" + cidockerfile "github.com/openshift/ci-tools/pkg/dockerfile" "github.com/openshift/ci-tools/pkg/github" "github.com/openshift/ci-tools/pkg/testhelper" ) @@ -50,7 +51,7 @@ func TestReplacer(t *testing.T) { { name: "Use dockerfile_literal if present", config: &api.ReleaseBuildConfiguration{ - Images: []api.ProjectDirectoryImageBuildStepConfiguration{{ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{DockerfileLiteral: utilpointer.String("FROM registry.svc.ci.openshift.org/org/repo:tag")}}}, + Images: []api.ProjectDirectoryImageBuildStepConfiguration{{ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{DockerfileLiteral: ptr.To("FROM registry.svc.ci.openshift.org/org/repo:tag")}}}, }, expectWrite: true, }, @@ -947,102 +948,10 @@ func TestRegistryRegex(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - actual := registryRegex.Find([]byte(tc.line)) + actual := cidockerfile.RegistryRegex.Find([]byte(tc.line)) if diff := cmp.Diff(tc.expected, string(actual)); diff != "" { t.Errorf("actual does not match expected, diff: %s", diff) } }) } } - -func TestOrgRepoTagFromPullString(t *testing.T) { - tests := []struct { - name string - pullString string - expected orgRepoTag - expectErr bool - }{ - { - name: "single component (repo only)", - pullString: "redis", - expected: orgRepoTag{org: "_", repo: "redis", tag: "latest"}, - }, - { - name: "single component with tag", - pullString: "redis:6.0", - expected: orgRepoTag{org: "_", repo: "redis", tag: "6.0"}, - }, - { - name: "two components (org/repo)", - pullString: "library/redis", - expected: orgRepoTag{org: "library", repo: "redis", tag: "latest"}, - }, - { - name: "two components with tag", - pullString: "library/redis:6.0", - expected: orgRepoTag{org: "library", repo: "redis", tag: "6.0"}, - }, - { - name: "three components (registry/org/repo)", - pullString: "docker.io/library/redis", - expected: orgRepoTag{org: "library", repo: "redis", tag: "latest"}, - }, - { - name: "three components with tag", - pullString: "docker.io/library/redis:6.0", - expected: orgRepoTag{org: "library", repo: "redis", tag: "6.0"}, - }, - { - name: "four components (the failing case from the error)", - pullString: "quay.io/redhat-services-prod/openshift/boilerplate", - expected: orgRepoTag{org: "openshift", repo: "boilerplate", tag: "latest"}, - }, - { - name: "four components with tag", - pullString: "quay.io/redhat-services-prod/openshift/boilerplate:image-v7.4.0", - expected: orgRepoTag{org: "openshift", repo: "boilerplate", tag: "image-v7.4.0"}, - }, - { - name: "five components (deeply nested)", - pullString: "registry.com/team/project/subproject/service/image", - expected: orgRepoTag{org: "service", repo: "image", tag: "latest"}, - }, - { - name: "five components with tag", - pullString: "registry.com/team/project/subproject/service/image:v1.2.3", - expected: orgRepoTag{org: "service", repo: "image", tag: "v1.2.3"}, - }, - { - name: "registry.ci.openshift.org example", - pullString: "registry.ci.openshift.org/ocp/4.6:golang", - expected: orgRepoTag{org: "ocp", repo: "4.6", tag: "golang"}, - }, - { - name: "registry.svc.ci.openshift.org example", - pullString: "registry.svc.ci.openshift.org/ocp/builder:rhel-8-golang-1.15", - expected: orgRepoTag{org: "ocp", repo: "builder", tag: "rhel-8-golang-1.15"}, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - result, err := orgRepoTagFromPullString(tc.pullString) - - if tc.expectErr { - if err == nil { - t.Errorf("expected error but got none") - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - if diff := cmp.Diff(tc.expected, result, cmp.AllowUnexported(orgRepoTag{})); diff != "" { - t.Errorf("result does not match expected: %s", diff) - } - }) - } -} diff --git a/pkg/defaults/defaults.go b/pkg/defaults/defaults.go index c51a639bf7b..fbb9cac7f70 100644 --- a/pkg/defaults/defaults.go +++ b/pkg/defaults/defaults.go @@ -32,6 +32,7 @@ import ( "github.com/openshift/ci-tools/pkg/api" "github.com/openshift/ci-tools/pkg/api/configresolver" + "github.com/openshift/ci-tools/pkg/dockerfile" "github.com/openshift/ci-tools/pkg/kubernetes" "github.com/openshift/ci-tools/pkg/labeledclient" "github.com/openshift/ci-tools/pkg/lease" @@ -1037,9 +1038,119 @@ func runtimeStepConfigsForBuild( buildSteps = append(buildSteps, getSourceStepsForJobSpec(jobSpec, injectedTest)...) + // Detect Dockerfile inputs for project images and add InputImageTagStepConfiguration entries + dockerfileInputSteps, images := detectDockerfileInputs(config.Images, config.BaseImages, readFile) + config.Images = images + buildSteps = append(buildSteps, dockerfileInputSteps...) + return buildSteps, nil } +// dockerfileDetails holds the content and path of a Dockerfile +type dockerfileDetails struct { + content []byte + path string +} + +// detectDockerfileInputs reads Dockerfiles for project images and detects registry.ci.openshift.org +// references that should be added as base images. It returns any required InputImageTagStepConfiguration steps to +// import the detected base images, as well as an updated list of image configurations with the detected inputs added +func detectDockerfileInputs(images []api.ProjectDirectoryImageBuildStepConfiguration, baseImages map[string]api.ImageStreamTagReference, readFile readFile) ([]api.StepConfiguration, []api.ProjectDirectoryImageBuildStepConfiguration) { + var steps []api.StepConfiguration + for i, image := range images { + dockerfileDetails, err := readDockerfileForImage(image, readFile) + if err != nil { + logrus.WithError(err).WithField("image", image.To).Debug("Failed to read Dockerfile for input detection, skipping") + continue + } + imageSteps, image := processDetectedBaseImages(baseImages, image, dockerfileDetails) + images[i] = image + steps = append(steps, imageSteps...) + } + + return steps, images +} + +// readDockerfileForImage reads the Dockerfile content for a given image configuration +// Returns the dockerfileDetails and any error encountered +func readDockerfileForImage(image api.ProjectDirectoryImageBuildStepConfiguration, readFile readFile) (dockerfileDetails, error) { + if image.DockerfileLiteral != nil { + return dockerfileDetails{content: []byte(*image.DockerfileLiteral), path: "dockerfile_literal"}, nil + } + details := dockerfileDetails{path: "Dockerfile"} + if image.DockerfilePath != "" { + details.path = image.DockerfilePath + } + dockerfilePath := fmt.Sprintf("./%s", details.path) + if image.ContextDir != "" { + dockerfilePath = fmt.Sprintf("%s/%s", image.ContextDir, details.path) + } + details.path = dockerfilePath + + dockerfileContent, err := readFile(details.path) + if err != nil { + return dockerfileDetails{}, fmt.Errorf("failed to read Dockerfile at %s: %w", dockerfilePath, err) + } + details.content = dockerfileContent + return details, nil +} + +// processDetectedBaseImages detects base images from a Dockerfile and returns any +// required InputImageTagStepConfiguration steps to import them, as well as an updated +// image configuration with the detected inputs added +func processDetectedBaseImages(baseImages map[string]api.ImageStreamTagReference, image api.ProjectDirectoryImageBuildStepConfiguration, details dockerfileDetails) ([]api.StepConfiguration, api.ProjectDirectoryImageBuildStepConfiguration) { + detectedBaseImages := dockerfile.DetectInputsFromDockerfile(details.content, image.Inputs) + if len(detectedBaseImages) == 0 { + return nil, image + } + if image.Inputs == nil { + image.Inputs = make(map[string]api.ImageBuildInputs) + } + if baseImages == nil { + baseImages = make(map[string]api.ImageStreamTagReference) + } + + var steps []api.StepConfiguration + for alias, baseImage := range detectedBaseImages { + if presentAlias := isBaseImagePresent(baseImages, baseImage); presentAlias != "" { + image = appendInputs(image, presentAlias, baseImage) + logrus.WithField("image", image.To).WithField("dockerfile", details.path).WithField("alias", presentAlias).Infof("Detected base image matches existing base image") + continue + } + + image = appendInputs(image, alias, baseImage) + + stepConfig := api.InputImageTagStepConfiguration{ + InputImage: api.InputImage{ + BaseImage: baseImage, + To: api.PipelineImageStreamTagReference(alias), + }, + Sources: []api.ImageStreamSource{{SourceType: api.ImageStreamSourceBase, Name: alias}}, + } + steps = append(steps, api.StepConfiguration{InputImageTagStepConfiguration: &stepConfig}) + + logrus.WithField("image", image.To).WithField("dockerfile", details.path).WithField("alias", alias).Infof("Detected base image, will tag into pipeline:%s", alias) + } + + return steps, image +} + +func appendInputs(image api.ProjectDirectoryImageBuildStepConfiguration, alias string, baseImage api.ImageStreamTagReference) api.ProjectDirectoryImageBuildStepConfiguration { + inputs := image.Inputs[alias] + inputs.As = []string{baseImage.As} + image.Inputs[alias] = inputs + return image +} + +func isBaseImagePresent(baseImages map[string]api.ImageStreamTagReference, baseImage api.ImageStreamTagReference) string { + for presentAlias, presentBaseImage := range baseImages { + if baseImage.Name == presentBaseImage.Name && baseImage.Namespace == presentBaseImage.Namespace && baseImage.Tag == presentBaseImage.Tag { + return presentAlias + } + } + return "" +} + func getSourceStepsForJobSpec(jobSpec *api.JobSpec, injectedTest bool) []api.StepConfiguration { var sourceSteps []api.StepConfiguration primaryRef := determinePrimaryRef(jobSpec, injectedTest) diff --git a/pkg/defaults/defaults_test.go b/pkg/defaults/defaults_test.go index d1860a9abd7..ed4c20a208c 100644 --- a/pkg/defaults/defaults_test.go +++ b/pkg/defaults/defaults_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -17,6 +18,7 @@ import ( meta "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" fakectrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" "sigs.k8s.io/prow/pkg/pod-utils/downwardapi" @@ -2251,3 +2253,361 @@ func TestFilterRequiredBinariesFromSkipped(t *testing.T) { }) } } + +func TestReadDockerfileForImage(t *testing.T) { + testCases := []struct { + name string + image api.ProjectDirectoryImageBuildStepConfiguration + readFile readFile + expectedContent string + expectedPath string + expectError bool + }{ + { + name: "default Dockerfile path", + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "test-image", + }, + readFile: func(path string) ([]byte, error) { + if path == "./Dockerfile" { + return []byte("FROM registry.ci.openshift.org/ocp/4.19:base"), nil + } + return nil, errors.New("file not found") + }, + expectedContent: "FROM registry.ci.openshift.org/ocp/4.19:base", + expectedPath: "./Dockerfile", + expectError: false, + }, + { + name: "custom Dockerfile path", + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "test-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + DockerfilePath: "Dockerfile.rhel", + }, + }, + readFile: func(path string) ([]byte, error) { + if path == "./Dockerfile.rhel" { + return []byte("FROM registry.ci.openshift.org/ocp/builder:rhel-9"), nil + } + return nil, errors.New("file not found") + }, + expectedContent: "FROM registry.ci.openshift.org/ocp/builder:rhel-9", + expectedPath: "./Dockerfile.rhel", + expectError: false, + }, + { + name: "with context directory", + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "test-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + ContextDir: "images/myimage", + DockerfilePath: "Dockerfile.custom", + }, + }, + readFile: func(path string) ([]byte, error) { + if path == "images/myimage/Dockerfile.custom" { + return []byte("FROM registry.ci.openshift.org/ocp/4.19:tools"), nil + } + return nil, errors.New("file not found") + }, + expectedContent: "FROM registry.ci.openshift.org/ocp/4.19:tools", + expectedPath: "images/myimage/Dockerfile.custom", + expectError: false, + }, + { + name: "DockerfileLiteral", + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "test-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + DockerfileLiteral: ptr.To("FROM registry.ci.openshift.org/ocp/4.19:literal"), + }, + }, + readFile: func(path string) ([]byte, error) { + return nil, errors.New("should not be called") + }, + expectedContent: "FROM registry.ci.openshift.org/ocp/4.19:literal", + expectedPath: "dockerfile_literal", + expectError: false, + }, + { + name: "file not found", + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "test-image", + }, + readFile: func(path string) ([]byte, error) { + return nil, errors.New("no such file") + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + details, err := readDockerfileForImage(tc.image, tc.readFile) + + if tc.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if string(details.content) != tc.expectedContent { + t.Errorf("expected content %q, got %q", tc.expectedContent, string(details.content)) + } + + if details.path != tc.expectedPath { + t.Errorf("expected path %q, got %q", tc.expectedPath, details.path) + } + }) + } +} + +func TestProcessDetectedBaseImages(t *testing.T) { + testCases := []struct { + name string + baseImages map[string]api.ImageStreamTagReference + image api.ProjectDirectoryImageBuildStepConfiguration + details dockerfileDetails + expectedSteps []api.StepConfiguration + expectedImage api.ProjectDirectoryImageBuildStepConfiguration + }{ + { + name: "single registry reference", + baseImages: map[string]api.ImageStreamTagReference{}, + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + }, + details: dockerfileDetails{ + content: []byte("FROM registry.ci.openshift.org/ocp/4.19:base\nRUN echo hello"), + path: "Dockerfile", + }, + expectedSteps: []api.StepConfiguration{ + { + InputImageTagStepConfiguration: &api.InputImageTagStepConfiguration{ + InputImage: api.InputImage{ + BaseImage: api.ImageStreamTagReference{ + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + To: api.PipelineImageStreamTagReference("ocp_4.19_base"), + }, + Sources: []api.ImageStreamSource{{SourceType: "base_image", Name: "ocp_4.19_base"}}, + }, + }, + }, + expectedImage: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "ocp_4.19_base": { + As: []string{"registry.ci.openshift.org/ocp/4.19:base"}, + }, + }, + }, + }, + }, + { + name: "multiple registry references", + baseImages: map[string]api.ImageStreamTagReference{}, + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + }, + details: dockerfileDetails{ + content: []byte("FROM registry.ci.openshift.org/ocp/4.19:base AS builder\nCOPY --from=registry.ci.openshift.org/ocp/4.19:tools /bin/tool /bin/\nRUN echo hello"), + path: "Dockerfile", + }, + expectedSteps: []api.StepConfiguration{ + { + InputImageTagStepConfiguration: &api.InputImageTagStepConfiguration{ + InputImage: api.InputImage{ + BaseImage: api.ImageStreamTagReference{ + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + To: api.PipelineImageStreamTagReference("ocp_4.19_base"), + }, + Sources: []api.ImageStreamSource{{SourceType: "base_image", Name: "ocp_4.19_base"}}, + }, + }, + { + InputImageTagStepConfiguration: &api.InputImageTagStepConfiguration{ + InputImage: api.InputImage{ + BaseImage: api.ImageStreamTagReference{ + Namespace: "ocp", + Name: "4.19", + Tag: "tools", + As: "registry.ci.openshift.org/ocp/4.19:tools", + }, + To: api.PipelineImageStreamTagReference("ocp_4.19_tools"), + }, + Sources: []api.ImageStreamSource{{SourceType: "base_image", Name: "ocp_4.19_tools"}}, + }, + }, + }, + expectedImage: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "ocp_4.19_base": { + As: []string{"registry.ci.openshift.org/ocp/4.19:base"}, + }, + "ocp_4.19_tools": { + As: []string{"registry.ci.openshift.org/ocp/4.19:tools"}, + }, + }, + }, + }, + }, + { + name: "no registry references", + baseImages: map[string]api.ImageStreamTagReference{}, + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + }, + details: dockerfileDetails{ + content: []byte("FROM docker.io/library/golang:1.21\nRUN echo hello"), + path: "Dockerfile", + }, + expectedSteps: nil, + expectedImage: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + }, + }, + { + name: "skip when manual inputs exist", + baseImages: map[string]api.ImageStreamTagReference{}, + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "custom": { + As: []string{"registry.ci.openshift.org/ocp/4.19:base"}, + }, + }, + }, + }, + details: dockerfileDetails{ + content: []byte("FROM registry.ci.openshift.org/ocp/4.19:base\nRUN echo hello"), + path: "Dockerfile", + }, + expectedSteps: nil, + expectedImage: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "custom": { + As: []string{"registry.ci.openshift.org/ocp/4.19:base"}, + }, + }, + }, + }, + }, + { + name: "manual path exist", + baseImages: map[string]api.ImageStreamTagReference{}, + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "custom": { + Paths: []api.ImageSourcePath{ + {SourcePath: "/custom/path", DestinationDir: "."}, + }, + }, + }, + }, + }, + details: dockerfileDetails{ + content: []byte("FROM registry.ci.openshift.org/ocp/4.19:base\nCOPY /custom/path /custom/path\nRUN echo hello"), + path: "Dockerfile", + }, + expectedSteps: []api.StepConfiguration{ + { + InputImageTagStepConfiguration: &api.InputImageTagStepConfiguration{ + InputImage: api.InputImage{ + BaseImage: api.ImageStreamTagReference{ + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + To: api.PipelineImageStreamTagReference("ocp_4.19_base"), + }, + Sources: []api.ImageStreamSource{{SourceType: "base_image", Name: "ocp_4.19_base"}}, + }, + }, + }, + expectedImage: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "custom": { + Paths: []api.ImageSourcePath{ + {SourcePath: "/custom/path", DestinationDir: "."}, + }, + }, + "ocp_4.19_base": { + As: []string{"registry.ci.openshift.org/ocp/4.19:base"}, + }, + }, + }, + }, + }, + { + name: "use existing base_images", + baseImages: map[string]api.ImageStreamTagReference{ + "existing": { + Namespace: "ocp", + Name: "4.18", + Tag: "base", + }, + }, + image: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + }, + details: dockerfileDetails{ + content: []byte("FROM registry.ci.openshift.org/ocp/4.18:base\nRUN echo hello"), + path: "Dockerfile", + }, + expectedSteps: nil, + expectedImage: api.ProjectDirectoryImageBuildStepConfiguration{ + To: "my-image", + ProjectDirectoryImageBuildInputs: api.ProjectDirectoryImageBuildInputs{ + Inputs: map[string]api.ImageBuildInputs{ + "existing": { + As: []string{"registry.ci.openshift.org/ocp/4.18:base"}, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + steps, image := processDetectedBaseImages(tc.baseImages, tc.image, tc.details) + less := func(a, b api.StepConfiguration) bool { + return string(a.InputImageTagStepConfiguration.To) < string(b.InputImageTagStepConfiguration.To) + } + if diff := cmp.Diff(tc.expectedSteps, steps, cmpopts.SortSlices(less)); diff != "" { + t.Errorf("%s: result didn't match expected, diff: %v", tc.name, diff) + } + comparer := func(a, b api.ProjectDirectoryImageBuildStepConfiguration) bool { + return cmp.Equal(a.ProjectDirectoryImageBuildInputs, b.ProjectDirectoryImageBuildInputs) + } + if diff := cmp.Diff(tc.expectedImage, image, cmp.Comparer(comparer)); diff != "" { + t.Errorf("%s: result didn't match expected, diff: %v", tc.name, diff) + } + }) + } +} diff --git a/pkg/dockerfile/extract.go b/pkg/dockerfile/extract.go new file mode 100644 index 00000000000..475fc2aaf18 --- /dev/null +++ b/pkg/dockerfile/extract.go @@ -0,0 +1,106 @@ +package dockerfile + +import ( + "bytes" + "fmt" + "regexp" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" + + "github.com/openshift/ci-tools/pkg/api" +) + +// RegistryRegex matches registry references to registry.ci.openshift.org or quay-proxy.ci.openshift.org +var RegistryRegex = regexp.MustCompile(`(registry\.(?:svc\.)?ci\.openshift\.org|quay-proxy\.ci\.openshift\.org)/[^\s\\]+`) + +// OrgRepoTag represents a parsed image reference +type OrgRepoTag struct { + Org, Repo, Tag string +} + +func (ort OrgRepoTag) String() string { + return ort.Org + "_" + ort.Repo + "_" + ort.Tag +} + +// ExtractRegistryReferences finds all registry.ci.openshift.org and quay-proxy.ci.openshift.org references in the Dockerfile +func ExtractRegistryReferences(dockerfile []byte) []string { + var refs []string + seen := sets.Set[string]{} + + for _, line := range bytes.Split(dockerfile, []byte("\n")) { + upper := bytes.ToUpper(line) + if !bytes.Contains(upper, []byte("FROM")) && !bytes.Contains(upper, []byte("COPY")) { + continue + } + + match := RegistryRegex.Find(line) + if match == nil { + continue + } + + ref := string(match) + if !seen.Has(ref) { + refs = append(refs, ref) + seen.Insert(ref) + } + } + + return refs +} + +// HasManualReplacementFor checks if there's already a manual input configuration for the given reference +func HasManualReplacementFor(inputs map[string]api.ImageBuildInputs, target string) bool { + for _, input := range inputs { + if sets.New(input.As...).Has(target) { + return true + } + } + return false +} + +// OrgRepoTagFromPullString parses a pull string like "registry.ci.openshift.org/ocp/4.19:base" +// into its component parts (org, repo, tag) +// For quay-proxy references, the tag contains org_repo_tag format that needs special parsing +func OrgRepoTagFromPullString(pullString string) (OrgRepoTag, error) { + res := OrgRepoTag{Tag: "latest"} + + slashSplit := strings.Split(pullString, "/") + n := len(slashSplit) + + switch { + case n == 1: + res.Org = "_" + res.Repo = slashSplit[0] + case n >= 2: + res.Org = slashSplit[n-2] + res.Repo = slashSplit[n-1] + default: + return res, fmt.Errorf("pull string %q couldn't be parsed, got %d components", pullString, n) + } + if repoTag := strings.Split(res.Repo, ":"); len(repoTag) == 2 { + res.Repo = repoTag[0] + res.Tag = repoTag[1] + } + + if strings.Contains(pullString, "quay-proxy.ci.openshift.org/openshift/ci") { + return orgRepoTagFromQuayProxyTag(res.Tag) + } + + return res, nil +} + +// orgRepoTagFromQuayProxyTag parses a quay-proxy tag like "ocp_builder_rhel-9-golang-1.21-openshift-4.16" +// which encodes org_repo_tag format, into its component parts +func orgRepoTagFromQuayProxyTag(quayTag string) (OrgRepoTag, error) { + parts := strings.SplitN(quayTag, "_", 3) + if len(parts) < 3 { + return OrgRepoTag{}, fmt.Errorf("quay-proxy tag %q doesn't match org_repo_tag format", quayTag) + } + + return OrgRepoTag{ + Org: parts[0], + Repo: parts[1], + Tag: parts[2], + }, nil +} diff --git a/pkg/dockerfile/extract_test.go b/pkg/dockerfile/extract_test.go new file mode 100644 index 00000000000..055cfc89e18 --- /dev/null +++ b/pkg/dockerfile/extract_test.go @@ -0,0 +1,113 @@ +package dockerfile + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestOrgRepoTagFromPullString(t *testing.T) { + testCases := []struct { + name string + pullString string + expected OrgRepoTag + expectError bool + }{ + { + name: "single component (repo only)", + pullString: "redis", + expected: OrgRepoTag{Org: "_", Repo: "redis", Tag: "latest"}, + }, + { + name: "single component with tag", + pullString: "redis:6.0", + expected: OrgRepoTag{Org: "_", Repo: "redis", Tag: "6.0"}, + }, + { + name: "two components (org/repo)", + pullString: "library/redis", + expected: OrgRepoTag{Org: "library", Repo: "redis", Tag: "latest"}, + }, + { + name: "two components with tag", + pullString: "library/redis:6.0", + expected: OrgRepoTag{Org: "library", Repo: "redis", Tag: "6.0"}, + }, + { + name: "three components (registry/org/repo)", + pullString: "docker.io/library/redis", + expected: OrgRepoTag{Org: "library", Repo: "redis", Tag: "latest"}, + }, + { + name: "three components with tag", + pullString: "docker.io/library/redis:6.0", + expected: OrgRepoTag{Org: "library", Repo: "redis", Tag: "6.0"}, + }, + { + name: "four components (the failing case from the error)", + pullString: "quay.io/redhat-services-prod/openshift/boilerplate", + expected: OrgRepoTag{Org: "openshift", Repo: "boilerplate", Tag: "latest"}, + }, + { + name: "four components with tag", + pullString: "quay.io/redhat-services-prod/openshift/boilerplate:image-v7.4.0", + expected: OrgRepoTag{Org: "openshift", Repo: "boilerplate", Tag: "image-v7.4.0"}, + }, + { + name: "five components (deeply nested)", + pullString: "registry.com/team/project/subproject/service/image", + expected: OrgRepoTag{Org: "service", Repo: "image", Tag: "latest"}, + }, + { + name: "five components with tag", + pullString: "registry.com/team/project/subproject/service/image:v1.2.3", + expected: OrgRepoTag{Org: "service", Repo: "image", Tag: "v1.2.3"}, + }, + { + name: "registry.svc.ci.openshift.org example", + pullString: "registry.svc.ci.openshift.org/ocp/builder:rhel-8-golang-1.15", + expected: OrgRepoTag{Org: "ocp", Repo: "builder", Tag: "rhel-8-golang-1.15"}, + }, + { + name: "full reference with tag", + pullString: "registry.ci.openshift.org/ocp/4.19:base", + expected: OrgRepoTag{ + Org: "ocp", + Repo: "4.19", + Tag: "base", + }, + }, + { + name: "quay-proxy reference with encoded tag", + pullString: "quay-proxy.ci.openshift.org/openshift/ci:ocp_builder_rhel-9-golang-1.21-openshift-4.16", + expected: OrgRepoTag{ + Org: "ocp", + Repo: "builder", + Tag: "rhel-9-golang-1.21-openshift-4.16", + }, + }, + { + name: "wrong quay registry format", + pullString: "quay-proxy.ci.openshift.org/openshift/ci:latest", + expected: OrgRepoTag{}, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := OrgRepoTagFromPullString(tc.pullString) + + if tc.expectError && err == nil { + t.Error("expected error but got none") + } + if !tc.expectError && err != nil { + t.Errorf("unexpected error: %v", err) + } + + if diff := cmp.Diff(tc.expected, result); diff != "" { + t.Errorf("result differs from expected:\n%s", diff) + } + }) + } +} diff --git a/pkg/dockerfile/inputs.go b/pkg/dockerfile/inputs.go new file mode 100644 index 00000000000..b97f7150bc4 --- /dev/null +++ b/pkg/dockerfile/inputs.go @@ -0,0 +1,36 @@ +package dockerfile + +import ( + "github.com/sirupsen/logrus" + + "github.com/openshift/ci-tools/pkg/api" +) + +// DetectInputsFromDockerfile parses a Dockerfile and detects registry references that need to be added as base images +// Returns a map of base image names to ImageStreamTagReferences +// The ImageStreamTagReference.As field contains the original registry reference from the Dockerfile +func DetectInputsFromDockerfile(dockerfile []byte, existingInputs map[string]api.ImageBuildInputs) map[string]api.ImageStreamTagReference { + registryRefs := ExtractRegistryReferences(dockerfile) + baseImages := make(map[string]api.ImageStreamTagReference) + + for _, ref := range registryRefs { + if HasManualReplacementFor(existingInputs, ref) { + logrus.WithField("reference", ref).Debug("Skipping Dockerfile inputs detection: manual replacement exists") + continue + } + orgRepoTag, err := OrgRepoTagFromPullString(ref) + if err != nil { + logrus.WithField("reference", ref).WithError(err).Debug("Failed to parse registry reference, skipping") + continue + } + baseImageKey := orgRepoTag.String() + baseImages[baseImageKey] = api.ImageStreamTagReference{ + Namespace: orgRepoTag.Org, + Name: orgRepoTag.Repo, + Tag: orgRepoTag.Tag, + As: ref, + } + } + + return baseImages +} diff --git a/pkg/dockerfile/inputs_test.go b/pkg/dockerfile/inputs_test.go new file mode 100644 index 00000000000..a8e4e79ace9 --- /dev/null +++ b/pkg/dockerfile/inputs_test.go @@ -0,0 +1,154 @@ +package dockerfile + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/openshift/ci-tools/pkg/api" +) + +func TestDetectInputsFromDockerfile(t *testing.T) { + testCases := []struct { + name string + dockerfile string + existingInputs map[string]api.ImageBuildInputs + expected map[string]api.ImageStreamTagReference + }{ + { + name: "single registry reference", + dockerfile: `FROM registry.ci.openshift.org/ocp/4.19:base +RUN echo "hello" +`, + expected: map[string]api.ImageStreamTagReference{ + "ocp_4.19_base": { + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + }, + }, + { + name: "multiple registry references", + dockerfile: `FROM registry.ci.openshift.org/ocp/4.19:base AS builder +COPY --from=registry.ci.openshift.org/ocp/4.19:tools /usr/bin/tool /usr/bin/ +RUN echo "building" +`, + expected: map[string]api.ImageStreamTagReference{ + "ocp_4.19_base": { + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + "ocp_4.19_tools": { + Namespace: "ocp", + Name: "4.19", + Tag: "tools", + As: "registry.ci.openshift.org/ocp/4.19:tools", + }, + }, + }, + { + name: "quay-proxy registry reference with normal tag", + dockerfile: `FROM quay-proxy.ci.openshift.org/openshift/release:golang-1.21 +RUN echo "hello" +`, + expected: map[string]api.ImageStreamTagReference{ + "openshift_release_golang-1.21": { + Namespace: "openshift", + Name: "release", + Tag: "golang-1.21", + As: "quay-proxy.ci.openshift.org/openshift/release:golang-1.21", + }, + }, + }, + { + name: "quay-proxy registry reference with encoded tag", + dockerfile: `FROM quay-proxy.ci.openshift.org/openshift/ci:ocp_builder_rhel-9-golang-1.21-openshift-4.16 +RUN echo "hello" +`, + expected: map[string]api.ImageStreamTagReference{ + "ocp_builder_rhel-9-golang-1.21-openshift-4.16": { + Namespace: "ocp", + Name: "builder", + Tag: "rhel-9-golang-1.21-openshift-4.16", + As: "quay-proxy.ci.openshift.org/openshift/ci:ocp_builder_rhel-9-golang-1.21-openshift-4.16", + }, + }, + }, + { + name: "registry.svc.ci.openshift.org reference", + dockerfile: `FROM registry.svc.ci.openshift.org/ocp/builder:rhel-9-golang-1.21-openshift-4.16 +RUN echo "hello" +`, + expected: map[string]api.ImageStreamTagReference{ + "ocp_builder_rhel-9-golang-1.21-openshift-4.16": { + Namespace: "ocp", + Name: "builder", + Tag: "rhel-9-golang-1.21-openshift-4.16", + As: "registry.svc.ci.openshift.org/ocp/builder:rhel-9-golang-1.21-openshift-4.16", + }, + }, + }, + { + name: "skip manual replacement", + dockerfile: `FROM registry.ci.openshift.org/ocp/4.19:base +RUN echo "hello" +`, + existingInputs: map[string]api.ImageBuildInputs{ + "custom_base": { + As: []string{"registry.ci.openshift.org/ocp/4.19:base"}, + }, + }, + expected: map[string]api.ImageStreamTagReference{}, + }, + { + name: "non-registry reference - should be ignored", + dockerfile: `FROM docker.io/library/golang:1.21 +RUN echo "hello" +`, + expected: map[string]api.ImageStreamTagReference{}, + }, + { + name: "mixed references - only registry ones detected", + dockerfile: `FROM registry.ci.openshift.org/ocp/4.19:base AS builder +FROM docker.io/library/alpine:latest +COPY --from=builder /app /app +`, + expected: map[string]api.ImageStreamTagReference{ + "ocp_4.19_base": { + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + }, + }, + { + name: "duplicate references - only one entry", + dockerfile: `FROM registry.ci.openshift.org/ocp/4.19:base AS builder +FROM registry.ci.openshift.org/ocp/4.19:base AS runtime +`, + expected: map[string]api.ImageStreamTagReference{ + "ocp_4.19_base": { + Namespace: "ocp", + Name: "4.19", + Tag: "base", + As: "registry.ci.openshift.org/ocp/4.19:base", + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := DetectInputsFromDockerfile([]byte(tc.dockerfile), tc.existingInputs) + + if diff := cmp.Diff(tc.expected, result); diff != "" { + t.Errorf("result differs from expected:\n%s", diff) + } + }) + } +}