diff --git a/cmd/ci-operator-prowgen/main.go b/cmd/ci-operator-prowgen/main.go index 0a20f8ade24..fa2dfec5fdf 100644 --- a/cmd/ci-operator-prowgen/main.go +++ b/cmd/ci-operator-prowgen/main.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/ghodss/yaml" "github.com/sirupsen/logrus" prowconfig "sigs.k8s.io/prow/pkg/config" @@ -20,6 +21,7 @@ import ( "github.com/openshift/ci-tools/pkg/prowgen" "github.com/openshift/ci-tools/pkg/registry" "github.com/openshift/ci-tools/pkg/util" + "github.com/openshift/ci-tools/pkg/util/gzip" ) type options struct { @@ -27,6 +29,7 @@ type options struct { fromDir string fromReleaseRepo bool + fromFile string toDir string toReleaseRepo bool @@ -44,6 +47,7 @@ func bindOptions(flag *flag.FlagSet) *options { flag.StringVar(&opt.fromDir, "from-dir", "", "Path to a directory with a directory structure holding ci-operator configuration files for multiple components") flag.BoolVar(&opt.fromReleaseRepo, "from-release-repo", false, "If set, it behaves like --from-dir=$GOPATH/src/github.com/openshift/release/ci-operator/config") + flag.StringVar(&opt.fromFile, "from-file", "", "Path to a single ci-operator configuration file (metadata is read from zz_generated_metadata in the file)") flag.StringVar(&opt.toDir, "to-dir", "", "Path to a directory with a directory structure holding Prow job configuration files for multiple components") flag.BoolVar(&opt.toReleaseRepo, "to-release-repo", false, "If set, it behaves like --to-dir=$GOPATH/src/github.com/openshift/release/ci-operator/jobs") @@ -74,21 +78,27 @@ func (o *options) process() error { } } - if o.fromDir == "" { - return fmt.Errorf("ci-operator-prowgen needs exactly one of `--from-{dir,release-repo}` options") + if o.fromFile == "" && o.fromDir == "" { + return fmt.Errorf("ci-operator-prowgen needs exactly one of `--from-{dir,release-repo,file}` options") + } + + if o.fromFile != "" && o.fromDir != "" { + return fmt.Errorf("ci-operator-prowgen accepts only one of `--from-{dir,release-repo}` and `--from-file` options") } if o.toDir == "" { return fmt.Errorf("ci-operator-prowgen needs exactly one of `--to-{dir,release-repo}` options") } - // TODO: deprecate --from-dir - o.ConfigDir = o.fromDir - if err := o.Options.Validate(); err != nil { - return fmt.Errorf("failed to validate config options: %w", err) - } - if err := o.Options.Complete(); err != nil { - return fmt.Errorf("failed to complete config options: %w", err) + if o.fromFile == "" { + // TODO: deprecate --from-dir + o.ConfigDir = o.fromDir + if err := o.Options.Validate(); err != nil { + return fmt.Errorf("failed to validate config options: %w", err) + } + if err := o.Options.Complete(); err != nil { + return fmt.Errorf("failed to complete config options: %w", err) + } } if o.registryPath != "" { refs, chains, workflows, _, _, _, observers, err := load.Registry(o.registryPath, load.RegistryFlag(0)) @@ -100,6 +110,46 @@ func (o *options) process() error { return nil } +func (o *options) generateJobsFromFile() error { + logrus.Infof("Reading config from %s", o.fromFile) + data, err := gzip.ReadFileMaybeGZIP(o.fromFile) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + var configSpec cioperatorapi.ReleaseBuildConfiguration + if err := yaml.Unmarshal(data, &configSpec); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + info := configSpec.Metadata + if info.Org == "" || info.Repo == "" || info.Branch == "" { + return fmt.Errorf("zz_generated_metadata in %s must specify org, repo, and branch", o.fromFile) + } + logrus.Infof("Loaded config for %s/%s@%s", info.Org, info.Repo, info.Branch) + if o.resolver != nil { + resolved, err := registry.ResolveConfig(o.resolver, configSpec) + if err != nil { + return fmt.Errorf("failed to resolve configuration: %w", err) + } + configSpec = resolved + } + configSpec.UnresolvedConfigPath = o.fromFile + generated, err := prowgen.GenerateJobs(&configSpec, &info) + if err != nil { + return err + } + orgRepo := fmt.Sprintf("%s/%s", info.Org, info.Repo) + logrus.Infof("Generated %d presubmits, %d postsubmits, %d periodics", + len(generated.PresubmitsStatic[orgRepo]), + len(generated.PostsubmitsStatic[orgRepo]), + len(generated.Periodics)) + logrus.Infof("Writing jobs to %s/%s/%s", o.toDir, info.Org, info.Repo) + if err := jc.WriteBranchToDir(o.toDir, info.Org, info.Repo, generated, prowgen.Generator); err != nil { + return err + } + logrus.Info("Done") + return nil +} + // generateJobsToDir generates prow job configuration into the dir provided by // consuming ci-operator configuration. func (o *options) generateJobsToDir(subDir string) error { @@ -194,15 +244,22 @@ func main() { logrus.WithError(err).Fatal("Failed to process arguments") } - args := flagSet.Args() - if len(args) == 0 { - args = append(args, "") - } - logger := logrus.WithFields(logrus.Fields{"target": opt.toDir, "source": opt.fromDir}) - for _, subDir := range args { - logger = logger.WithFields(logrus.Fields{"subdir": subDir}) - if err := opt.generateJobsToDir(subDir); err != nil { - logger.WithError(err).Fatal("Failed to generate jobs") + if opt.fromFile != "" { + logger := logrus.WithFields(logrus.Fields{"target": opt.toDir, "source": opt.fromFile}) + if err := opt.generateJobsFromFile(); err != nil { + logger.WithError(err).Fatal("Failed to generate jobs from file") + } + } else { + args := flagSet.Args() + if len(args) == 0 { + args = append(args, "") + } + logger := logrus.WithFields(logrus.Fields{"target": opt.toDir, "source": opt.fromDir}) + for _, subDir := range args { + logger = logger.WithFields(logrus.Fields{"subdir": subDir}) + if err := opt.generateJobsToDir(subDir); err != nil { + logger.WithError(err).Fatal("Failed to generate jobs") + } } } } diff --git a/cmd/in-repo-config-plugin/bootstrap.go b/cmd/in-repo-config-plugin/bootstrap.go new file mode 100644 index 00000000000..bd606ff29ef --- /dev/null +++ b/cmd/in-repo-config-plugin/bootstrap.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + prowv1 "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" + prowconfig "sigs.k8s.io/prow/pkg/config" + + jc "github.com/openshift/ci-tools/pkg/jobconfig" +) + +func generateBootstrapJobs(org, repo, branch, prowgenImage, checkconfigImage string) *prowconfig.JobConfig { + orgrepo := fmt.Sprintf("%s/%s", org, repo) + branchRegex := jc.ExactlyBranch(branch) + + return &prowconfig.JobConfig{ + PresubmitsStatic: map[string][]prowconfig.Presubmit{ + orgrepo: {generateConfigCheckerPresubmit(org, repo, branch, branchRegex, checkconfigImage)}, + }, + PostsubmitsStatic: map[string][]prowconfig.Postsubmit{ + orgrepo: {generateProwgenPostsubmit(org, repo, branch, branchRegex, prowgenImage)}, + }, + } +} + +func generateConfigCheckerPresubmit(org, repo, branch, branchRegex, image string) prowconfig.Presubmit { + name := fmt.Sprintf("pull-ci-%s-%s-%s-ci-operator-config-check", org, repo, branch) + trueBool := true + repoPath := fmt.Sprintf("/home/prow/go/src/github.com/%s/%s", org, repo) + + return prowconfig.Presubmit{ + JobBase: prowconfig.JobBase{ + Name: name, + Agent: string(prowv1.KubernetesAgent), + Labels: map[string]string{ + jc.LabelGenerator: string(pluginGenerator), + }, + Spec: &corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "checkconfig", + Image: image, + Command: []string{"ci-operator-checkconfig"}, + Args: []string{ + fmt.Sprintf("--config-dir=%s/%s", repoPath, ciOperatorDir), + }, + }}, + }, + UtilityConfig: prowconfig.UtilityConfig{ + Decorate: &trueBool, + }, + }, + Brancher: prowconfig.Brancher{ + Branches: []string{branchRegex}, + }, + RegexpChangeMatcher: prowconfig.RegexpChangeMatcher{ + RunIfChanged: `\.ci-operator/`, + }, + AlwaysRun: false, + } +} + +func generateProwgenPostsubmit(org, repo, branch, branchRegex, image string) prowconfig.Postsubmit { + name := fmt.Sprintf("branch-ci-%s-%s-%s-prowgen", org, repo, branch) + trueBool := true + repoPath := fmt.Sprintf("/home/prow/go/src/github.com/%s/%s", org, repo) + + return prowconfig.Postsubmit{ + JobBase: prowconfig.JobBase{ + Name: name, + Agent: string(prowv1.KubernetesAgent), + Labels: map[string]string{ + jc.LabelGenerator: string(pluginGenerator), + }, + Spec: &corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "prowgen", + Image: image, + Command: []string{"ci-operator-prowgen"}, + Args: []string{ + fmt.Sprintf("--from-file=%s/.ci-operator.yaml", repoPath), + "--to-dir=/etc/jobs", + }, + VolumeMounts: []corev1.VolumeMount{{ + Name: "job-configs", + MountPath: "/etc/jobs", + }}, + }}, + Volumes: []corev1.Volume{{ + Name: "job-configs", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: "job-configs-nfs", + }, + }, + }}, + }, + UtilityConfig: prowconfig.UtilityConfig{ + Decorate: &trueBool, + }, + MaxConcurrency: 1, + }, + Brancher: prowconfig.Brancher{ + Branches: []string{branchRegex}, + }, + } +} diff --git a/cmd/in-repo-config-plugin/bootstrap_test.go b/cmd/in-repo-config-plugin/bootstrap_test.go new file mode 100644 index 00000000000..473e83a622d --- /dev/null +++ b/cmd/in-repo-config-plugin/bootstrap_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "testing" +) + +func TestGenerateBootstrapJobs(t *testing.T) { + org := "openshift" + repo := "installer" + branch := "main" + prowgenImage := "quay.io/openshift/ci-operator-prowgen:latest" + checkconfigImage := "quay.io/openshift/ci-operator-checkconfig:latest" + + jobConfig := generateBootstrapJobs(org, repo, branch, prowgenImage, checkconfigImage) + + orgrepo := "openshift/installer" + + presubmits := jobConfig.PresubmitsStatic[orgrepo] + if len(presubmits) != 1 { + t.Fatalf("expected 1 presubmit, got %d", len(presubmits)) + } + + pre := presubmits[0] + expectedPreName := "pull-ci-openshift-installer-main-ci-operator-config-check" + if pre.Name != expectedPreName { + t.Errorf("expected presubmit name %q, got %q", expectedPreName, pre.Name) + } + if len(pre.Branches) != 1 || pre.Branches[0] != "^main$" { + t.Errorf("expected branch regex ^main$, got %v", pre.Branches) + } + if pre.RunIfChanged != `\.ci-operator/` { + t.Errorf("expected run_if_changed %q, got %q", `\.ci-operator/`, pre.RunIfChanged) + } + if pre.AlwaysRun { + t.Error("expected always_run to be false") + } + if pre.Spec == nil || len(pre.Spec.Containers) != 1 { + t.Fatal("expected 1 container in presubmit spec") + } + if pre.Spec.Containers[0].Image != checkconfigImage { + t.Errorf("expected image %q, got %q", checkconfigImage, pre.Spec.Containers[0].Image) + } + + postsubmits := jobConfig.PostsubmitsStatic[orgrepo] + if len(postsubmits) != 1 { + t.Fatalf("expected 1 postsubmit, got %d", len(postsubmits)) + } + + post := postsubmits[0] + expectedPostName := "branch-ci-openshift-installer-main-prowgen" + if post.Name != expectedPostName { + t.Errorf("expected postsubmit name %q, got %q", expectedPostName, post.Name) + } + if len(post.Branches) != 1 || post.Branches[0] != "^main$" { + t.Errorf("expected branch regex ^main$, got %v", post.Branches) + } + if post.MaxConcurrency != 1 { + t.Errorf("expected max_concurrency 1, got %d", post.MaxConcurrency) + } + if post.Spec == nil || len(post.Spec.Containers) != 1 { + t.Fatal("expected 1 container in postsubmit spec") + } + container := post.Spec.Containers[0] + if container.Image != prowgenImage { + t.Errorf("expected image %q, got %q", prowgenImage, container.Image) + } + if len(container.VolumeMounts) != 1 || container.VolumeMounts[0].MountPath != "/etc/jobs" { + t.Error("expected volume mount at /etc/jobs") + } + if len(post.Spec.Volumes) != 1 || post.Spec.Volumes[0].PersistentVolumeClaim == nil { + t.Error("expected PVC volume for job-configs") + } + if post.Spec.Volumes[0].PersistentVolumeClaim.ClaimName != "job-configs-nfs" { + t.Errorf("expected PVC claim name job-configs-nfs, got %q", post.Spec.Volumes[0].PersistentVolumeClaim.ClaimName) + } +} + +func TestGenerateBootstrapJobsEscapesBranch(t *testing.T) { + jobConfig := generateBootstrapJobs("org", "repo", "release-4.15", "img", "img") + orgrepo := "org/repo" + + pre := jobConfig.PresubmitsStatic[orgrepo][0] + if pre.Branches[0] != `^release-4\.15$` { + t.Errorf("expected escaped branch regex, got %q", pre.Branches[0]) + } + + post := jobConfig.PostsubmitsStatic[orgrepo][0] + if post.Branches[0] != `^release-4\.15$` { + t.Errorf("expected escaped branch regex, got %q", post.Branches[0]) + } + + expectedPreName := "pull-ci-org-repo-release-4.15-ci-operator-config-check" + if pre.Name != expectedPreName { + t.Errorf("expected %q, got %q", expectedPreName, pre.Name) + } +} diff --git a/cmd/in-repo-config-plugin/main.go b/cmd/in-repo-config-plugin/main.go new file mode 100644 index 00000000000..572e687b584 --- /dev/null +++ b/cmd/in-repo-config-plugin/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/sirupsen/logrus" + + pjapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" + "sigs.k8s.io/prow/pkg/config/secret" + prowflagutil "sigs.k8s.io/prow/pkg/flagutil" + "sigs.k8s.io/prow/pkg/githubeventserver" + "sigs.k8s.io/prow/pkg/interrupts" + "sigs.k8s.io/prow/pkg/logrusutil" + "sigs.k8s.io/prow/pkg/pjutil" + "k8s.io/client-go/rest" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/apimachinery/pkg/runtime" +) + +const pluginName = "in-repo-config" + +type options struct { + logLevel string + githubEventServerOptions githubeventserver.Options + github prowflagutil.GitHubOptions + webhookSecretFile string + jobConfigDir string + releaseRepoDir string + prowgenImage string + checkconfigImage string + namespace string +} + +func gatherOptions() options { + o := options{} + fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + + fs.StringVar(&o.logLevel, "log-level", "info", "Level at which to log output.") + fs.StringVar(&o.webhookSecretFile, "hmac-secret-file", "/etc/webhook/hmac", "Path to the file containing the GitHub HMAC secret.") + fs.StringVar(&o.jobConfigDir, "job-config-dir", "", "Path to the EFS-mounted job config directory.") + fs.StringVar(&o.releaseRepoDir, "release-repo-dir", "", "Path to the git-sync'd openshift/release repository directory.") + fs.StringVar(&o.prowgenImage, "prowgen-image", "", "Container image for ci-operator-prowgen used in the bootstrap postsubmit.") + fs.StringVar(&o.checkconfigImage, "checkconfig-image", "", "Container image for ci-operator-checkconfig used in the bootstrap presubmit.") + fs.StringVar(&o.namespace, "namespace", "ci", "Namespace where ProwJobs will be created.") + + o.github.AddFlags(fs) + o.githubEventServerOptions.Bind(fs) + + if err := fs.Parse(os.Args[1:]); err != nil { + logrus.WithError(err).Fatalf("cannot parse args: '%s'", os.Args[1:]) + } + return o +} + +func (o *options) Validate() error { + if _, err := logrus.ParseLevel(o.logLevel); err != nil { + return fmt.Errorf("invalid --log-level: %w", err) + } + if o.jobConfigDir == "" { + return fmt.Errorf("--job-config-dir must be set") + } + if o.releaseRepoDir == "" { + return fmt.Errorf("--release-repo-dir must be set") + } + if o.prowgenImage == "" { + return fmt.Errorf("--prowgen-image must be set") + } + if o.checkconfigImage == "" { + return fmt.Errorf("--checkconfig-image must be set") + } + return o.githubEventServerOptions.DefaultAndValidate() +} + +func main() { + logrusutil.ComponentInit() + logger := logrus.WithField("plugin", pluginName) + + o := gatherOptions() + if err := o.Validate(); err != nil { + logger.Fatalf("Invalid options: %v", err) + } + + level, _ := logrus.ParseLevel(o.logLevel) + logrus.SetLevel(level) + + var tokens []string + if o.github.TokenPath != "" { + tokens = append(tokens, o.github.TokenPath) + } + if o.github.AppPrivateKeyPath != "" { + tokens = append(tokens, o.github.AppPrivateKeyPath) + } + tokens = append(tokens, o.webhookSecretFile) + + if err := secret.Add(tokens...); err != nil { + logger.WithError(err).Fatal("Error starting secrets agent.") + } + + getWebhookHMAC := secret.GetTokenGenerator(o.webhookSecretFile) + + githubClient, err := o.github.GitHubClient(false) + if err != nil { + logger.WithError(err).Fatal("Error getting GitHub client.") + } + + clusterConfig, err := rest.InClusterConfig() + if err != nil { + logger.WithError(err).Fatal("Error getting in-cluster config.") + } + scheme := runtime.NewScheme() + if err := pjapi.AddToScheme(scheme); err != nil { + logger.WithError(err).Fatal("Error adding ProwJob scheme.") + } + pjclient, err := ctrlruntimeclient.New(clusterConfig, ctrlruntimeclient.Options{Scheme: scheme}) + if err != nil { + logger.WithError(err).Fatal("Error creating ProwJob client.") + } + + serv := &server{ + ghc: githubClient, + trustedChecker: &githubTrustedChecker{githubClient: githubClient}, + pjclient: pjclient, + namespace: o.namespace, + jobConfigDir: o.jobConfigDir, + releaseRepoDir: o.releaseRepoDir, + prowgenImage: o.prowgenImage, + checkconfigImage: o.checkconfigImage, + } + + eventServer := githubeventserver.New(o.githubEventServerOptions, getWebhookHMAC, logger) + eventServer.RegisterHandleIssueCommentEvent(serv.handleIssueComment) + eventServer.RegisterHandlePullRequestEvent(serv.handlePullRequest) + eventServer.RegisterHelpProvider(helpProvider, logger) + + interrupts.OnInterrupt(func() { + eventServer.GracefulShutdown() + }) + + health := pjutil.NewHealth() + health.ServeReady() + + interrupts.ListenAndServe(eventServer, time.Second*30) + interrupts.WaitForGracefulShutdown() +} diff --git a/cmd/in-repo-config-plugin/server.go b/cmd/in-repo-config-plugin/server.go new file mode 100644 index 00000000000..158b354fc7c --- /dev/null +++ b/cmd/in-repo-config-plugin/server.go @@ -0,0 +1,423 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + "github.com/sirupsen/logrus" + + prowconfig "sigs.k8s.io/prow/pkg/config" + "sigs.k8s.io/prow/pkg/github" + pjapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" + "sigs.k8s.io/prow/pkg/pjutil" + "sigs.k8s.io/prow/pkg/pluginhelp" + "sigs.k8s.io/prow/pkg/plugins/trigger" + ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" + + cioperatorapi "github.com/openshift/ci-tools/pkg/api" + jc "github.com/openshift/ci-tools/pkg/jobconfig" + "github.com/openshift/ci-tools/pkg/prowgen" +) + +const ( + onboardCommand = "/onboard" + newTestPrefix = "/new-test" + ciOperatorDir = ".ci-operator" +) + +var pluginGenerator = jc.Generator(pluginName) + +type githubClient interface { + CreateComment(owner, repo string, number int, comment string) error + GetPullRequest(org, repo string, number int) (*github.PullRequest, error) + GetDirectory(org, repo, dirpath, commit string) ([]github.DirectoryContent, error) + GetFile(org, repo, filepath, commit string) ([]byte, error) +} + +type trustedChecker interface { + trustedUser(author, org, repo string, num int) (bool, error) +} + +type githubTrustedChecker struct { + githubClient github.Client +} + +func (c *githubTrustedChecker) trustedUser(author, org, repo string, _ int) (bool, error) { + resp, err := trigger.TrustedUser(c.githubClient, false, []string{}, "", author, org, repo) + if err != nil { + return false, fmt.Errorf("error checking %s for trust: %w", author, err) + } + return resp.IsTrusted, nil +} + +type server struct { + ghc githubClient + trustedChecker trustedChecker + pjclient ctrlruntimeclient.Client + namespace string + jobConfigDir string + releaseRepoDir string + prowgenImage string + checkconfigImage string +} + +func helpProvider(_ []prowconfig.OrgRepo) (*pluginhelp.PluginHelp, error) { + pluginHelp := &pluginhelp.PluginHelp{ + Description: "The in-repo-config plugin helps repos onboard to in-repo CI configuration and manage ephemeral test jobs from PRs.", + } + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/onboard", + Description: "Bootstrap in-repo CI config by generating a config-checker presubmit and a prowgen postsubmit on EFS.", + WhoCanUse: "Members of the trusted organization for the repo.", + Examples: []string{"/onboard"}, + }) + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/new-test [testname]", + Description: "Generate ephemeral ProwJob definitions from the PR's .ci-operator/ configs so new tests are immediately available.", + WhoCanUse: "Members of the trusted organization for the repo.", + Examples: []string{"/new-test e2e", "/new-test unit"}, + }) + return pluginHelp, nil +} + +func (s *server) handleIssueComment(l *logrus.Entry, ic github.IssueCommentEvent) { + if ic.Action != github.IssueCommentActionCreated { + return + } + if !ic.Issue.IsPullRequest() { + return + } + + body := strings.TrimSpace(ic.Comment.Body) + switch { + case body == onboardCommand: + s.handleOnboard(l, ic) + case strings.HasPrefix(body, newTestPrefix): + s.handleNewTest(l, ic) + } +} + +func (s *server) handleOnboard(l *logrus.Entry, ic github.IssueCommentEvent) { + org := ic.Repo.Owner.Login + repo := ic.Repo.Name + number := ic.Issue.Number + user := ic.Comment.User.Login + + logger := l.WithFields(logrus.Fields{ + "org": org, "repo": repo, "pr": number, "command": "onboard", + }) + + trusted, err := s.trustedChecker.trustedUser(user, org, repo, number) + if err != nil { + logger.WithError(err).Error("could not check if user is trusted") + s.commentError(org, repo, number, user, "onboard", err, logger) + return + } + if !trusted { + logger.WithField("user", user).Warn("untrusted user") + s.ghc.CreateComment(org, repo, number, fmt.Sprintf("@%s: you are not trusted to use `/onboard`.", user)) + return + } + + efsPath := filepath.Join(s.jobConfigDir, org, repo) + releasePath := filepath.Join(s.releaseRepoDir, "ci-operator/config", org, repo) + + efsExists := dirExists(efsPath) + releaseExists := dirExists(releasePath) + + if releaseExists { + msg := fmt.Sprintf("@%s: jobs for `%s/%s` already exist in the centralized openshift/release repository at `ci-operator/config/%s/%s`. "+ + "Please remove them from openshift/release before onboarding to in-repo config.", user, org, repo, org, repo) + s.ghc.CreateComment(org, repo, number, msg) + return + } + if efsExists { + msg := fmt.Sprintf("@%s: jobs for `%s/%s` already exist on EFS. "+ + "If you need to re-onboard, please remove the existing job configs first.", user, org, repo) + s.ghc.CreateComment(org, repo, number, msg) + return + } + + pr, err := s.ghc.GetPullRequest(org, repo, number) + if err != nil { + logger.WithError(err).Error("could not get pull request") + s.commentError(org, repo, number, user, "onboard", err, logger) + return + } + branch := pr.Base.Ref + + jobConfig := generateBootstrapJobs(org, repo, branch, s.prowgenImage, s.checkconfigImage) + if err := jc.WriteToDir(s.jobConfigDir, org, repo, jobConfig, pluginGenerator, nil); err != nil { + logger.WithError(err).Error("could not write bootstrap jobs to EFS") + s.commentError(org, repo, number, user, "onboard", err, logger) + return + } + + msg := fmt.Sprintf("@%s: successfully onboarded `%s/%s` (branch `%s`) to in-repo CI config.\n\n"+ + "Bootstrap jobs created:\n"+ + "- `pull-ci-%s-%s-%s-ci-operator-config-check` (presubmit): validates `.ci-operator/` configs\n"+ + "- `branch-ci-%s-%s-%s-prowgen` (postsubmit): generates permanent job definitions on merge\n\n"+ + "Jobs will be available within ~1 second via Prow hot-reload.", + user, org, repo, branch, + org, repo, branch, + org, repo, branch) + s.ghc.CreateComment(org, repo, number, msg) + logger.Info("onboarding complete") +} + +func (s *server) handleNewTest(l *logrus.Entry, ic github.IssueCommentEvent) { + org := ic.Repo.Owner.Login + repo := ic.Repo.Name + number := ic.Issue.Number + user := ic.Comment.User.Login + + logger := l.WithFields(logrus.Fields{ + "org": org, "repo": repo, "pr": number, "command": "new-test", + }) + + trusted, err := s.trustedChecker.trustedUser(user, org, repo, number) + if err != nil { + logger.WithError(err).Error("could not check if user is trusted") + s.commentError(org, repo, number, user, "new-test", err, logger) + return + } + if !trusted { + logger.WithField("user", user).Warn("untrusted user") + s.ghc.CreateComment(org, repo, number, fmt.Sprintf("@%s: you are not trusted to use `/new-test`.", user)) + return + } + + pr, err := s.ghc.GetPullRequest(org, repo, number) + if err != nil { + logger.WithError(err).Error("could not get pull request") + s.commentError(org, repo, number, user, "new-test", err, logger) + return + } + sha := pr.Head.SHA + branch := pr.Base.Ref + + configs, err := s.fetchConfigs(org, repo, sha, logger) + if err != nil { + logger.WithError(err).Error("could not fetch .ci-operator/ configs") + s.commentError(org, repo, number, user, "new-test", err, logger) + return + } + if len(configs) == 0 { + s.ghc.CreateComment(org, repo, number, fmt.Sprintf("@%s: no `.ci-operator/` configs found in this PR at commit %s.", user, shortSHA(sha))) + return + } + + orgrepo := fmt.Sprintf("%s/%s", org, repo) + allJobs := &prowconfig.JobConfig{ + PresubmitsStatic: map[string][]prowconfig.Presubmit{}, + PostsubmitsStatic: map[string][]prowconfig.Postsubmit{}, + } + + for filename, configSpec := range configs { + info := metadataFromFilename(filename, org, repo, branch) + configSpec.UnresolvedConfigPath = cioperatorapi.CIOperatorInrepoConfigFileName + generated, err := prowgen.GenerateJobs(configSpec, info) + if err != nil { + logger.WithError(err).WithField("file", filename).Error("prowgen failed") + s.commentError(org, repo, number, user, "new-test", fmt.Errorf("prowgen failed for %s: %w", filename, err), logger) + return + } + jc.Append(allJobs, generated) + } + + existingNames := map[string]bool{} + permanentPath := filepath.Join(s.jobConfigDir, org, repo) + if dirExists(permanentPath) { + existing, err := jc.ReadFromDir(permanentPath) + if err != nil { + logger.WithError(err).Warn("could not read existing jobs from EFS") + } else { + for _, jobs := range existing.PresubmitsStatic { + for _, j := range jobs { + existingNames[j.Name] = true + } + } + for _, jobs := range existing.PostsubmitsStatic { + for _, j := range jobs { + existingNames[j.Name] = true + } + } + for _, j := range existing.Periodics { + existingNames[j.Name] = true + } + } + } + + for key := range allJobs.PresubmitsStatic { + var filtered []prowconfig.Presubmit + for _, j := range allJobs.PresubmitsStatic[key] { + if !existingNames[j.Name] { + filtered = append(filtered, j) + } + } + allJobs.PresubmitsStatic[key] = filtered + } + for key := range allJobs.PostsubmitsStatic { + var filtered []prowconfig.Postsubmit + for _, j := range allJobs.PostsubmitsStatic[key] { + if !existingNames[j.Name] { + filtered = append(filtered, j) + } + } + allJobs.PostsubmitsStatic[key] = filtered + } + var filteredPeriodics []prowconfig.Periodic + for _, j := range allJobs.Periodics { + if !existingNames[j.Name] { + filteredPeriodics = append(filteredPeriodics, j) + } + } + allJobs.Periodics = filteredPeriodics + + var jobNames []string + for _, j := range allJobs.PresubmitsStatic[orgrepo] { + jobNames = append(jobNames, j.Name) + } + for _, j := range allJobs.PostsubmitsStatic[orgrepo] { + jobNames = append(jobNames, j.Name) + } + for _, j := range allJobs.Periodics { + jobNames = append(jobNames, j.Name) + } + + if len(jobNames) == 0 { + s.ghc.CreateComment(org, repo, number, fmt.Sprintf("@%s: all tests from `.ci-operator/` configs already have permanent jobs on EFS. No new ephemeral jobs needed.", user)) + return + } + + refs := &pjapi.Refs{ + Org: org, + Repo: repo, + BaseRef: branch, + BaseSHA: pr.Base.SHA, + Pulls: []pjapi.Pull{{Number: number, Author: user, SHA: sha}}, + } + + var created []string + for _, job := range allJobs.PresubmitsStatic[orgrepo] { + pj := pjutil.NewProwJob(pjutil.PresubmitSpec(job, *refs), job.Labels, job.Annotations) + pj.Namespace = s.namespace + if err := s.pjclient.Create(context.Background(), &pj); err != nil { + logger.WithError(err).WithField("job", job.Name).Error("could not create ProwJob") + s.commentError(org, repo, number, user, "new-test", fmt.Errorf("could not create ProwJob %s: %w", job.Name, err), logger) + return + } + created = append(created, job.Name) + } + + var sb strings.Builder + fmt.Fprintf(&sb, "@%s: ProwJobs created from `.ci-operator/` configs (commit %s):\n\n", user, shortSHA(sha)) + for _, name := range created { + fmt.Fprintf(&sb, "- `%s`\n", name) + } + sb.WriteString("\nJobs have been submitted directly and should appear shortly.") + s.ghc.CreateComment(org, repo, number, sb.String()) + logger.WithField("jobs", len(created)).Info("ProwJobs created") +} + +func (s *server) handlePullRequest(_ *logrus.Entry, _ github.PullRequestEvent) { +} + +func (s *server) fetchConfigs(org, repo, sha string, l *logrus.Entry) (map[string]*cioperatorapi.ReleaseBuildConfiguration, error) { + entries, err := s.ghc.GetDirectory(org, repo, ciOperatorDir, sha) + if err != nil { + l.WithError(err).Debug("could not list .ci-operator directory, trying single-file config") + return s.fetchSingleConfig(org, repo, sha, l) + } + + configs := map[string]*cioperatorapi.ReleaseBuildConfiguration{} + for _, entry := range entries { + if entry.Type != "file" { + continue + } + if !strings.HasSuffix(entry.Name, ".yaml") && !strings.HasSuffix(entry.Name, ".yml") { + continue + } + if !strings.HasPrefix(entry.Name, "ci-operator") { + continue + } + + content, err := s.ghc.GetFile(org, repo, entry.Path, sha) + if err != nil { + return nil, fmt.Errorf("could not fetch %s: %w", entry.Path, err) + } + if content == nil { + l.WithField("file", entry.Path).Warn("file not found") + continue + } + + var cfg cioperatorapi.ReleaseBuildConfiguration + if err := yaml.Unmarshal(content, &cfg); err != nil { + return nil, fmt.Errorf("could not parse %s: %w", entry.Path, err) + } + configs[entry.Name] = &cfg + } + return configs, nil +} + +// fetchSingleConfig handles the case where the repo uses a single .ci-operator.yaml +// file at the root instead of a .ci-operator/ directory. +func (s *server) fetchSingleConfig(org, repo, sha string, l *logrus.Entry) (map[string]*cioperatorapi.ReleaseBuildConfiguration, error) { + const singleFile = ".ci-operator.yaml" + content, err := s.ghc.GetFile(org, repo, singleFile, sha) + if err != nil { + return nil, fmt.Errorf("could not fetch %s: %w", singleFile, err) + } + if content == nil { + return nil, nil + } + + var cfg cioperatorapi.ReleaseBuildConfiguration + if err := yaml.Unmarshal(content, &cfg); err != nil { + return nil, fmt.Errorf("could not parse %s: %w", singleFile, err) + } + l.WithField("file", singleFile).Debug("using single-file config") + return map[string]*cioperatorapi.ReleaseBuildConfiguration{ + "ci-operator.yaml": &cfg, + }, nil +} + +func (s *server) commentError(org, repo string, number int, user, command string, err error, l *logrus.Entry) { + comment := fmt.Sprintf("@%s: `%s` error:\n```\n%v\n```", user, command, err) + if commentErr := s.ghc.CreateComment(org, repo, number, comment); commentErr != nil { + l.WithError(commentErr).Error("failed to create error comment") + } +} + +func metadataFromFilename(filename, org, repo, branch string) *cioperatorapi.Metadata { + base := strings.TrimSuffix(filename, filepath.Ext(filename)) + var variant string + if _, after, found := strings.Cut(base, "__"); found { + variant = after + } + return &cioperatorapi.Metadata{ + Org: org, + Repo: repo, + Branch: branch, + Variant: variant, + } +} + +func shortSHA(s string) string { + if len(s) > 7 { + return s[:7] + } + return s +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/cmd/in-repo-config-plugin/server_test.go b/cmd/in-repo-config-plugin/server_test.go new file mode 100644 index 00000000000..09265b1eb17 --- /dev/null +++ b/cmd/in-repo-config-plugin/server_test.go @@ -0,0 +1,415 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sirupsen/logrus" + + "k8s.io/apimachinery/pkg/runtime" + pjapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" + "sigs.k8s.io/prow/pkg/github" + fakectrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + + cioperatorapi "github.com/openshift/ci-tools/pkg/api" +) + +type fakeGithubClient struct { + comments []comment + prs map[string]*github.PullRequest + dirs map[string][]github.DirectoryContent + files map[string][]byte + commentErr error +} + +type comment struct { + org, repo string + number int + body string +} + +func (c *fakeGithubClient) CreateComment(owner, repo string, number int, body string) error { + c.comments = append(c.comments, comment{org: owner, repo: repo, number: number, body: body}) + return c.commentErr +} + +func (c *fakeGithubClient) GetPullRequest(org, repo string, number int) (*github.PullRequest, error) { + key := fmt.Sprintf("%s/%s#%d", org, repo, number) + pr, ok := c.prs[key] + if !ok { + return nil, fmt.Errorf("PR not found: %s", key) + } + return pr, nil +} + +func (c *fakeGithubClient) GetDirectory(org, repo, dirpath, commit string) ([]github.DirectoryContent, error) { + key := fmt.Sprintf("%s/%s/%s@%s", org, repo, dirpath, commit) + entries, ok := c.dirs[key] + if !ok { + return nil, &github.FileNotFound{} + } + return entries, nil +} + +func (c *fakeGithubClient) GetFile(org, repo, path, commit string) ([]byte, error) { + key := fmt.Sprintf("%s/%s/%s@%s", org, repo, path, commit) + content, ok := c.files[key] + if !ok { + return nil, nil + } + return content, nil +} + +type fakeTrustedChecker struct { + trusted bool +} + +func (c *fakeTrustedChecker) trustedUser(_, _, _ string, _ int) (bool, error) { + return c.trusted, nil +} + +func makePR(org, repo string, number int, branch, sha string) *github.PullRequest { + return &github.PullRequest{ + Number: number, + Base: github.PullRequestBranch{ + Ref: branch, + Repo: github.Repo{Owner: github.User{Login: org}, Name: repo}, + }, + Head: github.PullRequestBranch{ + SHA: sha, + }, + } +} + +func makeIssueCommentEvent(org, repo string, number int, user, body string) github.IssueCommentEvent { + return github.IssueCommentEvent{ + Action: github.IssueCommentActionCreated, + Repo: github.Repo{Owner: github.User{Login: org}, Name: repo}, + Issue: github.Issue{ + Number: number, + PullRequest: &struct{}{}, + }, + Comment: github.IssueComment{ + Body: body, + User: github.User{Login: user}, + }, + } +} + +func TestHandleOnboard(t *testing.T) { + testCases := []struct { + name string + efsExists bool + releaseExists bool + trusted bool + expectComment string + expectJobsOnEFS bool + }{ + { + name: "successful onboard creates bootstrap jobs", + trusted: true, + expectComment: "successfully onboarded", + expectJobsOnEFS: true, + }, + { + name: "existing release repo config blocks onboard", + trusted: true, + releaseExists: true, + expectComment: "already exist in the centralized openshift/release", + }, + { + name: "existing EFS jobs blocks onboard", + trusted: true, + efsExists: true, + expectComment: "already exist on EFS", + }, + { + name: "untrusted user is rejected", + trusted: false, + expectComment: "not trusted", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + releaseDir := t.TempDir() + + org, repo := "testorg", "testrepo" + + if tc.efsExists { + os.MkdirAll(filepath.Join(tmpDir, org, repo), os.ModePerm) + } + if tc.releaseExists { + os.MkdirAll(filepath.Join(releaseDir, "ci-operator/config", org, repo), os.ModePerm) + } + + ghc := &fakeGithubClient{ + prs: map[string]*github.PullRequest{ + fmt.Sprintf("%s/%s#%d", org, repo, 1): makePR(org, repo, 1, "main", "abc1234567"), + }, + } + + s := &server{ + ghc: ghc, + trustedChecker: &fakeTrustedChecker{trusted: tc.trusted}, + jobConfigDir: tmpDir, + releaseRepoDir: releaseDir, + prowgenImage: "quay.io/test/prowgen:latest", + checkconfigImage: "quay.io/test/checkconfig:latest", + } + + ic := makeIssueCommentEvent(org, repo, 1, "testuser", "/onboard") + s.handleIssueComment(logrus.NewEntry(logrus.StandardLogger()), ic) + + if len(ghc.comments) == 0 { + t.Fatal("expected a comment to be created") + } + + lastComment := ghc.comments[len(ghc.comments)-1].body + if !strings.Contains(lastComment, tc.expectComment) { + t.Errorf("expected comment to contain %q, got: %s", tc.expectComment, lastComment) + } + + efsPath := filepath.Join(tmpDir, org, repo) + if tc.expectJobsOnEFS { + if !dirExists(efsPath) { + t.Error("expected bootstrap jobs to be written to EFS") + } + files, _ := os.ReadDir(efsPath) + if len(files) == 0 { + t.Error("expected job config files in EFS directory") + } + } + }) + } +} + +func TestHandleNewTest(t *testing.T) { + org, repo := "testorg", "testrepo" + sha := "abc1234567890" + + ciOpConfig := ` +build_root: + image_stream_tag: + name: release + namespace: openshift + tag: golang-1.21 +tests: +- as: e2e + steps: + test: + - as: test + commands: make e2e + from: src + resources: + requests: + cpu: 100m +` + + testCases := []struct { + name string + trusted bool + body string + hasConfigs bool + expectComment string + }{ + { + name: "creates ProwJobs", + trusted: true, + body: "/new-test e2e", + hasConfigs: true, + expectComment: "ProwJobs created", + }, + { + name: "no configs found", + trusted: true, + body: "/new-test e2e", + hasConfigs: false, + expectComment: "no `.ci-operator/` configs found", + }, + { + name: "untrusted user rejected", + trusted: false, + body: "/new-test e2e", + expectComment: "not trusted", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + + ghc := &fakeGithubClient{ + prs: map[string]*github.PullRequest{ + fmt.Sprintf("%s/%s#%d", org, repo, 1): makePR(org, repo, 1, "main", sha), + }, + dirs: map[string][]github.DirectoryContent{}, + files: map[string][]byte{}, + } + + if tc.hasConfigs { + dirKey := fmt.Sprintf("%s/%s/%s@%s", org, repo, ciOperatorDir, sha) + ghc.dirs[dirKey] = []github.DirectoryContent{ + {Type: "file", Name: "ci-operator.yaml", Path: ".ci-operator/ci-operator.yaml"}, + } + fileKey := fmt.Sprintf("%s/%s/%s@%s", org, repo, ".ci-operator/ci-operator.yaml", sha) + ghc.files[fileKey] = []byte(ciOpConfig) + } + + pjc := fakectrlruntimeclient.NewClientBuilder().WithScheme(pjScheme()).Build() + + s := &server{ + ghc: ghc, + trustedChecker: &fakeTrustedChecker{trusted: tc.trusted}, + pjclient: pjc, + namespace: "test-ns", + jobConfigDir: tmpDir, + } + + ic := makeIssueCommentEvent(org, repo, 1, "testuser", tc.body) + s.handleIssueComment(logrus.NewEntry(logrus.StandardLogger()), ic) + + if len(ghc.comments) == 0 { + t.Fatal("expected a comment to be created") + } + + lastComment := ghc.comments[len(ghc.comments)-1].body + if !strings.Contains(lastComment, tc.expectComment) { + t.Errorf("expected comment to contain %q, got: %s", tc.expectComment, lastComment) + } + }) + } +} + +func pjScheme() *runtime.Scheme { + s := runtime.NewScheme() + pjapi.AddToScheme(s) + return s +} + +func TestMetadataFromFilename(t *testing.T) { + testCases := []struct { + filename string + expected *cioperatorapi.Metadata + }{ + { + filename: "ci-operator.yaml", + expected: &cioperatorapi.Metadata{Org: "org", Repo: "repo", Branch: "main"}, + }, + { + filename: "ci-operator__aws.yaml", + expected: &cioperatorapi.Metadata{Org: "org", Repo: "repo", Branch: "main", Variant: "aws"}, + }, + { + filename: "ci-operator__multi-arch.yml", + expected: &cioperatorapi.Metadata{Org: "org", Repo: "repo", Branch: "main", Variant: "multi-arch"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.filename, func(t *testing.T) { + result := metadataFromFilename(tc.filename, "org", "repo", "main") + if result.Org != tc.expected.Org || result.Repo != tc.expected.Repo || + result.Branch != tc.expected.Branch || result.Variant != tc.expected.Variant { + t.Errorf("expected %+v, got %+v", tc.expected, result) + } + }) + } +} + +func TestHandleIssueCommentDispatch(t *testing.T) { + testCases := []struct { + name string + body string + action github.IssueCommentEventAction + isPR bool + expectComments int + }{ + { + name: "dispatches /onboard", + body: "/onboard", + action: github.IssueCommentActionCreated, + isPR: true, + expectComments: 1, + }, + { + name: "dispatches /new-test", + body: "/new-test e2e", + action: github.IssueCommentActionCreated, + isPR: true, + expectComments: 1, + }, + { + name: "ignores non-created actions", + body: "/onboard", + action: github.IssueCommentActionDeleted, + isPR: true, + expectComments: 0, + }, + { + name: "ignores non-PR issues", + body: "/onboard", + action: github.IssueCommentActionCreated, + isPR: false, + expectComments: 0, + }, + { + name: "ignores unrelated comments", + body: "LGTM", + action: github.IssueCommentActionCreated, + isPR: true, + expectComments: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + ghc := &fakeGithubClient{ + prs: map[string]*github.PullRequest{ + "org/repo#1": makePR("org", "repo", 1, "main", "abc1234567890"), + }, + dirs: map[string][]github.DirectoryContent{}, + files: map[string][]byte{}, + } + + pjc := fakectrlruntimeclient.NewClientBuilder().WithScheme(pjScheme()).Build() + + s := &server{ + ghc: ghc, + trustedChecker: &fakeTrustedChecker{trusted: true}, + pjclient: pjc, + namespace: "test-ns", + jobConfigDir: tmpDir, + releaseRepoDir: tmpDir, + prowgenImage: "img", + checkconfigImage: "img", + } + + ic := github.IssueCommentEvent{ + Action: tc.action, + Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, + Issue: github.Issue{ + Number: 1, + }, + Comment: github.IssueComment{ + Body: tc.body, + User: github.User{Login: "testuser"}, + }, + } + if tc.isPR { + ic.Issue.PullRequest = &struct{}{} + } + + s.handleIssueComment(logrus.NewEntry(logrus.StandardLogger()), ic) + + if len(ghc.comments) != tc.expectComments { + t.Errorf("expected %d comments, got %d: %+v", tc.expectComments, len(ghc.comments), ghc.comments) + } + }) + } +} diff --git a/images/in-repo-config-plugin/Dockerfile b/images/in-repo-config-plugin/Dockerfile new file mode 100644 index 00000000000..ffe79d154c8 --- /dev/null +++ b/images/in-repo-config-plugin/Dockerfile @@ -0,0 +1,3 @@ +FROM quay.io/centos/centos:stream8 +ADD in-repo-config-plugin /usr/bin/in-repo-config-plugin +ENTRYPOINT ["/usr/bin/in-repo-config-plugin"] diff --git a/pkg/api/types.go b/pkg/api/types.go index 1af626139c2..e480e04ac8f 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -73,6 +73,9 @@ type ReleaseBuildConfiguration struct { Prowgen *ProwgenOverrides `json:"prowgen,omitempty"` + // Set by prowgen when generating from --from-file; not serialized. + UnresolvedConfigPath string `json:"-"` + InputConfiguration `json:",inline"` // BinaryBuildCommands will create a "bin" image based on "src" that diff --git a/pkg/jobconfig/files.go b/pkg/jobconfig/files.go index c8af4601508..96bdf2540a6 100644 --- a/pkg/jobconfig/files.go +++ b/pkg/jobconfig/files.go @@ -371,6 +371,63 @@ func WriteToDir(jobDir, org, repo string, jobConfig *prowconfig.JobConfig, gener return nil } +// WriteBranchToDir writes only the files for branches present in the given +// jobConfig, without touching files for other branches. This is used by +// --from-file mode where prowgen generates jobs for a single branch and +// must not prune jobs from other branches. +func WriteBranchToDir(jobDir, org, repo string, jobConfig *prowconfig.JobConfig, generator Generator) error { + files := map[string]*prowconfig.JobConfig{} + key := fmt.Sprintf("%s/%s", org, repo) + for _, job := range jobConfig.PresubmitsStatic[key] { + job.Labels[LabelGenerator] = string(generator) + branch := "main" + if len(job.Branches) > 0 { + branch = MakeRegexFilenameLabel(job.Branches[0]) + } + file := fmt.Sprintf("%s-%s-%s-presubmits.yaml", org, repo, branch) + if _, ok := files[file]; !ok { + files[file] = &prowconfig.JobConfig{PresubmitsStatic: map[string][]prowconfig.Presubmit{}} + } + files[file].PresubmitsStatic[key] = append(files[file].PresubmitsStatic[key], job) + } + for _, job := range jobConfig.PostsubmitsStatic[key] { + job.Labels[LabelGenerator] = string(generator) + branch := "main" + if len(job.Branches) > 0 { + branch = MakeRegexFilenameLabel(job.Branches[0]) + } + file := fmt.Sprintf("%s-%s-%s-postsubmits.yaml", org, repo, branch) + if _, ok := files[file]; !ok { + files[file] = &prowconfig.JobConfig{PostsubmitsStatic: map[string][]prowconfig.Postsubmit{}} + } + files[file].PostsubmitsStatic[key] = append(files[file].PostsubmitsStatic[key], job) + } + for _, job := range jobConfig.Periodics { + if len(job.ExtraRefs) == 0 || job.ExtraRefs[0].Org != org || job.ExtraRefs[0].Repo != repo { + continue + } + job.Labels[LabelGenerator] = string(generator) + branch := MakeRegexFilenameLabel(job.ExtraRefs[0].BaseRef) + file := fmt.Sprintf("%s-%s-%s-periodics.yaml", org, repo, branch) + if _, ok := files[file]; !ok { + files[file] = &prowconfig.JobConfig{} + } + files[file].Periodics = append(files[file].Periodics, job) + } + + jobDirForComponent := filepath.Join(jobDir, org, repo) + if err := os.MkdirAll(jobDirForComponent, os.ModePerm); err != nil { + return err + } + for file, jc := range files { + sortConfigFields(jc) + if err := WriteToFile(filepath.Join(jobDirForComponent, file), jc); err != nil { + return err + } + } + return nil +} + // Given two JobConfig, merge jobs from the `source` one to `destination` // one. Jobs are matched by name. All jobs from `source` will be present in // `destination` - if there were jobs with the same name in `destination`, they diff --git a/pkg/prowgen/jobbase.go b/pkg/prowgen/jobbase.go index a56b40bedbf..0d0d9f03ae0 100644 --- a/pkg/prowgen/jobbase.go +++ b/pkg/prowgen/jobbase.go @@ -39,6 +39,9 @@ func sparseCheckoutFiles(configSpec *cioperatorapi.ReleaseBuildConfiguration) [] if fromRepositorySet(configSpec) { files.Insert(cioperatorapi.CIOperatorInrepoConfigFileName) } + if configSpec.UnresolvedConfigPath != "" { + files.Insert(path.Base(configSpec.UnresolvedConfigPath)) + } for _, image := range configSpec.Images.Items { if image.DockerfileLiteral != nil { continue @@ -129,6 +132,10 @@ func NewProwJobBaseBuilder(configSpec *cioperatorapi.ReleaseBuildConfiguration, b.base.UtilityConfig.PathAlias = *configSpec.CanonicalGoRepository } + if configSpec.UnresolvedConfigPath != "" { + b.PodSpec.Add(UnresolvedConfig(configSpec.UnresolvedConfigPath)) + } + if private && !expose { b.base.Hidden = true } diff --git a/pkg/prowgen/jobbase_test.go b/pkg/prowgen/jobbase_test.go index e8d5be899e7..2dcfabd485a 100644 --- a/pkg/prowgen/jobbase_test.go +++ b/pkg/prowgen/jobbase_test.go @@ -22,11 +22,12 @@ func TestProwJobBaseBuilder(t *testing.T) { testCases := []struct { name string - inputs ciop.InputConfiguration - images ciop.ImageConfiguration - binCommand string - testBinCommand string - prowgenOverrides *ciop.ProwgenOverrides + inputs ciop.InputConfiguration + images ciop.ImageConfiguration + binCommand string + testBinCommand string + prowgenOverrides *ciop.ProwgenOverrides + unresolvedConfigPath string podSpecBuilder CiOperatorPodSpecGenerator info *ciop.Metadata @@ -180,6 +181,13 @@ func TestProwJobBaseBuilder(t *testing.T) { prefix: "default", podSpecBuilder: NewCiOperatorPodSpecGenerator(), }, + { + name: "job with unresolved config, including podspec", + info: defaultInfo, + unresolvedConfigPath: ".ci-operator.yaml", + prefix: "default", + podSpecBuilder: NewCiOperatorPodSpecGenerator(), + }, } for _, tc := range testCases { @@ -193,6 +201,7 @@ func TestProwJobBaseBuilder(t *testing.T) { TestBinaryBuildCommands: tc.testBinCommand, Metadata: *tc.info, Prowgen: tc.prowgenOverrides, + UnresolvedConfigPath: tc.unresolvedConfigPath, } b := NewProwJobBaseBuilder(ciopconfig, tc.info, tc.podSpecBuilder).Build(tc.prefix) testhelper.CompareWithFixture(t, b) diff --git a/pkg/prowgen/podspec.go b/pkg/prowgen/podspec.go index 734ec9aff92..2644475044e 100644 --- a/pkg/prowgen/podspec.go +++ b/pkg/prowgen/podspec.go @@ -563,6 +563,14 @@ func GSMConfig() PodSpecMutator { } } +func UnresolvedConfig(configPath string) PodSpecMutator { + return func(spec *corev1.PodSpec) error { + container := &spec.Containers[0] + addUniqueParameter(container, fmt.Sprintf("--unresolved-config=%s", configPath)) + return nil + } +} + func Variant(variant string) PodSpecMutator { return func(spec *corev1.PodSpec) error { if len(variant) > 0 { diff --git a/pkg/prowgen/testdata/zz_fixture_TestProwJobBaseBuilder_job_with_unresolved_config__including_podspec.yaml b/pkg/prowgen/testdata/zz_fixture_TestProwJobBaseBuilder_job_with_unresolved_config__including_podspec.yaml new file mode 100644 index 00000000000..022ecee3413 --- /dev/null +++ b/pkg/prowgen/testdata/zz_fixture_TestProwJobBaseBuilder_job_with_unresolved_config__including_podspec.yaml @@ -0,0 +1,45 @@ +agent: kubernetes +decorate: true +decoration_config: + sparse_checkout_files: + - .ci-operator.yaml +name: default-ci-org-repo-branch- +spec: + containers: + - args: + - --gcs-upload-secret=/secrets/gcs/service-account.json + - --image-import-pull-secret=/etc/pull-secret/.dockerconfigjson + - --report-credentials-file=/etc/report/credentials + - --unresolved-config=.ci-operator.yaml + command: + - ci-operator + image: quay-proxy.ci.openshift.org/openshift/ci:ci_ci-operator_latest + imagePullPolicy: Always + name: "" + resources: + requests: + cpu: 10m + volumeMounts: + - mountPath: /secrets/gcs + name: gcs-credentials + readOnly: true + - mountPath: /secrets/manifest-tool + name: manifest-tool-local-pusher + readOnly: true + - mountPath: /etc/pull-secret + name: pull-secret + readOnly: true + - mountPath: /etc/report + name: result-aggregator + readOnly: true + serviceAccountName: ci-operator + volumes: + - name: manifest-tool-local-pusher + secret: + secretName: manifest-tool-local-pusher + - name: pull-secret + secret: + secretName: registry-pull-credentials + - name: result-aggregator + secret: + secretName: result-aggregator