From d53cce7e8ff634c0281776d541f703f4896102b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mudrini=C4=87?= Date: Fri, 26 May 2023 23:06:04 +0200 Subject: [PATCH] Implement OBS staging workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marko Mudrinić --- cmd/krel/cmd/obs_stage.go | 97 +++++++++ pkg/obs/obs.go | 405 ++++++++++++++++++++++++++++++++++++++ pkg/obs/prerequisites.go | 150 ++++++++++++++ pkg/obs/stage.go | 377 +++++++++++++++++++++++++++++++++++ 4 files changed, 1029 insertions(+) create mode 100644 cmd/krel/cmd/obs_stage.go create mode 100644 pkg/obs/obs.go create mode 100644 pkg/obs/prerequisites.go create mode 100644 pkg/obs/stage.go diff --git a/cmd/krel/cmd/obs_stage.go b/cmd/krel/cmd/obs_stage.go new file mode 100644 index 000000000000..f0bed8a76ab5 --- /dev/null +++ b/cmd/krel/cmd/obs_stage.go @@ -0,0 +1,97 @@ +/* +Copyright 2020 The Kubernetes Authors. + +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. +*/ + +// TODO(xmudrii): add variables for flags, add flags for missing options, etc. +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + stage "k8s.io/release/pkg/obs" + "k8s.io/release/pkg/release" +) + +// stageCmd represents the subcommand for `krel stage` +var obsStageCmd = &cobra.Command{ + Use: "stage", + Short: "Run OBS stage workflow", + Long: "Run OBS stage workflow.", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runOBSStage(obsStageOptions) + }, +} + +var ( + obsStageOptions = stage.DefaultStageOptions() +) + +const ( + obsBuildVersionFlag = "build-version" +) + +func init() { + obsStageCmd.PersistentFlags(). + StringVar( + &obsStageOptions.ReleaseType, + "type", + obsStageOptions.ReleaseType, + fmt.Sprintf("The release type, must be one of: '%s'", + strings.Join([]string{ + release.ReleaseTypeAlpha, + release.ReleaseTypeBeta, + release.ReleaseTypeRC, + release.ReleaseTypeOfficial, + }, "', '"), + )) + + obsStageCmd.PersistentFlags(). + StringVar( + &obsStageOptions.ReleaseBranch, + "branch", + obsStageOptions.ReleaseBranch, + "The release branch for which the release should be build", + ) + + obsStageCmd.PersistentFlags(). + StringVar( + &obsStageOptions.BuildVersion, + buildVersionFlag, + "", + "The build version to be released.", + ) + + // for _, flag := range []string{buildVersionFlag, submitJobFlag} { + // if err := stageCmd.PersistentFlags().MarkHidden(flag); err != nil { + // logrus.Fatal(err) + // } + // } + + obsCmd.AddCommand(obsStageCmd) +} + +func runOBSStage(options *stage.StageOptions) error { + options.NoMock = rootOpts.nomock + // TODO(xmudrii): solve this + options.BuildVersion = "v1.26.4-64+8b09b36478f65c" + stageRun := stage.NewStage(options) + + return stageRun.Run() +} diff --git a/pkg/obs/obs.go b/pkg/obs/obs.go new file mode 100644 index 000000000000..dfcfd11a51e0 --- /dev/null +++ b/pkg/obs/obs.go @@ -0,0 +1,405 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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. +*/ + +// TODO(xmudrii): add submit option +package obs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/blang/semver/v4" + "github.com/sirupsen/logrus" + + "k8s.io/release/pkg/release" + "sigs.k8s.io/release-sdk/git" + "sigs.k8s.io/release-utils/log" + "sigs.k8s.io/release-utils/util" + "sigs.k8s.io/release-utils/version" +) + +const ( + // OBSKubernetesProject is name of the organization/project on openSUSE's + // OBS instance where packages are built and published. + OBSKubernetesProject = "isv:kubernetes" + + // OBSNamespaceStable is part of the subproject name that's used for stable + // packages. + OBSNamespaceStable = "stable" + // OBSNamespaceStable is part of the subproject name that's used for + // prerelease (alpha, beta, rc) packages. + OBSNamespacePrerelease = "prerelease" + + // workspaceDir is the global directory where the stage and release process + // happens. + workspaceDir = "/workspace" + + // defaultSpecTemplatePath is path inside Google Cloud Build where package + // specs for kubeadm, kubectl, and kubelet are located. + defaultSpecTemplatePath = workspaceDir + "/go/src/k8s.io/release/cmd/krel/templates/latest" + + // obsRoot is path inside Google Cloud Build where OBS project and packages + // are checked out. + obsRoot = workspaceDir + "/src/obs" + + // obsAPIURL is the URL of openSUSE's OpenBuildService instance. + obsAPIURL = "https://api.opensuse.org" + + // obsK8sUsername is username for Kubernetes Release Bot account. + obsK8sUsername = "k8s-release-bot" + + // OBSPasswordKey is name of the environment variable with password for + // Kubernetes Release Bot account. + OBSPasswordKey = "OBS_PASSWORD" +) + +// Options are settings which will be used by `StageOptions` as well as +// `ReleaseOptions`. +type Options struct { + // Run the whole process in non-mocked mode. Which means that it doesn't + // push specs and artifacts to OpenBuildService. + NoMock bool + + // SpecTemplatePath is path to a directory with spec template files. + SpecTemplatePath string + + // Packages that should be built and published to OpenBuildService. + Packages []string + + /** + * Parameters used for core packages. + * Core packages are: kubeadm, kubectl, kubelet, cri-tools, kubernetes-cni. + * The API is same as for "krel stage" for consistency reasons. + **/ + + // The release type for which packages are built for. + // Can be either `alpha`, `beta`, `rc` or `official`. + // Mutually exclusive with `Version`, `Project` and `Source`. + ReleaseType string + + // The release branch for which the release should be built. + // Can be `master`/`main` or any `release-x.y` branch. + // Mutually exclusive with `Version`, `Project` and `Source`. + ReleaseBranch string + + // The build version to be released. Has to be specified in the format: + // `vX.Y.Z-[alpha|beta|rc].N.C+SHA` + // Mutually exclusive with `Version`, `Project` and `Source`. + BuildVersion string + + /** + * Parameters used for non-core packages. + * Core packages are: kubeadm, kubectl, kubelet, cri-tools, kubernetes-cni. + **/ + + // Version of packages to build. Same version is used for all provided + // packages. + // Mutually exclusive with `ReleaseType`, `ReleaseBranch`, and + // `BuildVersion`. + Version string + + // Project is name of the OBS project where packages are built. + // Mutually exclusive with `ReleaseType`, `ReleaseBranch`, and + // `BuildVersion`. + Project string + + // Source is https:// or gs:// URL where to download binaries for + // packages from. + // Mutually exclusive with `ReleaseType`, `ReleaseBranch`, and + // `BuildVersion`. + Source string +} + +// DefaultOptions returns a new `Options` instance. +func DefaultOptions() *Options { + return &Options{ + Packages: []string{"kubeadm", "kubectl", "kubelet"}, + SpecTemplatePath: defaultSpecTemplatePath, + } +} + +// String returns a string representation for the `Options` type. +func (o *Options) String() string { + return fmt.Sprintf( + "NoMock: %v, Packages: %s, BuildVersion: %q, Version: %s, Project: %q, ", + o.NoMock, o.Packages, o.BuildVersion, o.Version, o.Project, + ) +} + +// Validate if the options are correctly set. +func (o *Options) Validate() error { + logrus.Infof("Validating generic options: %s", o.String()) + + // Ensure provided SpecTemplatePath exists. + if _, err := os.Stat(o.SpecTemplatePath); err != nil { + return fmt.Errorf("invalid spec template path: %w", err) + } + + // Ensure specs for given packages exist. + for _, pkg := range o.Packages { + if _, err := os.Stat(filepath.Join(o.SpecTemplatePath, pkg)); err != nil { + return fmt.Errorf("specs for package %s doesn't exist", pkg) + } + } + + var ( + foundK8sOption bool + foundManualOption bool + ) + + // Ensure exclusive mutual exclusivity of options is respected. + if o.ReleaseType != "" || o.ReleaseBranch != "" || o.BuildVersion != "" { + foundK8sOption = true + } + if o.Project != "" || o.Source != "" || o.Version != "" { + foundManualOption = true + } + + if foundK8sOption && foundManualOption { + return errors.New("kubernetes and manual options are mutually exclusive") + } + if !foundK8sOption && !foundManualOption { + return errors.New("one of kubernetes or manual options are required") + } + + // Validate other options depending on release type. + if foundK8sOption { + if o.ReleaseType != release.ReleaseTypeAlpha && + o.ReleaseType != release.ReleaseTypeBeta && + o.ReleaseType != release.ReleaseTypeRC && + o.ReleaseType != release.ReleaseTypeOfficial { + return fmt.Errorf("invalid release type: %s", o.ReleaseType) + } + + if !git.IsReleaseBranch(o.ReleaseBranch) { + return fmt.Errorf("invalid release branch: %s", o.ReleaseBranch) + } + + if o.BuildVersion != "" { + return errors.New("build version is required") + } + } else if foundManualOption { + if o.Project == "" { + return errors.New("project is required") + } + if o.Source == "" { + return errors.New("source is required") + } + if o.Version == "" { + return errors.New("version is required") + } + } + + return nil +} + +// ValidateBuildVersion validates the provided build version. +func (o *Options) ValidateBuildVersion(state *State) error { + // Verify the build version is correct: + correct, err := release.IsValidReleaseBuild(o.BuildVersion) + if err != nil { + return fmt.Errorf("checking for a valid build version: %w", err) + } + if !correct { + return errors.New("invalid BuildVersion specified") + } + + semverBuildVersion, err := util.TagStringToSemver(o.BuildVersion) + if err != nil { + return fmt.Errorf("invalid build version: %s: %w", o.BuildVersion, err) + } + state.semverBuildVersion = semverBuildVersion + return nil +} + +// Bucket returns the Google Cloud Bucket for these `Options`. +func (o *Options) Bucket() string { + if o.NoMock { + return release.ProductionBucket + } + return release.TestBucket +} + +// State holds all inferred and calculated values from the stage/release +// process, it's state mutates as each step es executed +type State struct { + // corePackages is an indicator if we're releasing core/Kubernetes + // packages. This is set upon validation. + corePackages bool + + // obsProject is parametrized OBS project name. + // For Kubernetes packages, this is autogenerated based on given release + // type, branch, and build version. + // For non-Kubernetes packages, this is the same as `Project`. + // This is set after GenerateOBSProject() + obsProject string + + // packageVersion is parametrized package version. + // For Kubernetes packages, this is autogenerated based on given release + // type, branch, and build version. + // For non-Kubernetes packages, this is the same as `Project`. + // This is set after GeneratePackageVersion() + packageVersion string + + // semverBuildVersion is the parsed build version which is set after the + // validation. + semverBuildVersion semver.Version + + // The release versions generated after GenerateReleaseVersion() + versions *release.Versions + + // Indicates if creating a release branch is needed. This parameter is + // used when determining release versions + createReleaseBranch bool + + // startTime is the time when stage/release starts + startTime time.Time +} + +// DefaultState returns a new empty State +func DefaultState() *State { + // The default state is empty, it will be initialized after ValidateOptions() + // runs in Stage/Release. It will change as the stage/release processes move forward + return &State{ + startTime: time.Now(), + } +} + +// StageState holds the stage/release process state +type StageState struct { + *State +} + +// DefaultStageState create a new default `StageState`. +func DefaultStageState() *StageState { + return &StageState{ + State: DefaultState(), + } +} + +// StageOptions contains the options for running `Stage`. +type StageOptions struct { + *Options +} + +// DefaultStageOptions create a new default `StageOptions`. +func DefaultStageOptions() *StageOptions { + return &StageOptions{ + Options: DefaultOptions(), + } +} + +// String returns a string representation for the `StageOptions` type. +func (s *StageOptions) String() string { + return s.Options.String() +} + +// Validate validates the stage options. +func (s *StageOptions) Validate(state *State) error { + if err := s.Options.Validate(); err != nil { + return fmt.Errorf("validating generic options: %w", err) + } + + if s.Options.BuildVersion != "" { + if err := s.Options.ValidateBuildVersion(state); err != nil { + return fmt.Errorf("validating build version") + } + } + + if s.Options.ReleaseType != "" || s.Options.ReleaseBranch != "" || s.Options.BuildVersion != "" { + state.corePackages = true + } + + return nil +} + +// Stage is the structure to be used for staging packages. +type Stage struct { + client stageClient +} + +// NewStage creates a new `Stage` instance. +func NewStage(options *StageOptions) *Stage { + return &Stage{NewDefaultStage(options)} +} + +// SetClient can be used to set the internal stage client. +func (s *Stage) SetClient(client stageClient) { + s.client = client +} + +// Run for the `Stage` struct prepares a release and pushes specs and archives +// to OpenBuildService. +func (s *Stage) Run() error { + s.client.InitState() + + logger := log.NewStepLogger(11) + v := version.GetVersionInfo() + logger.Infof("Using krel version: %s", v.GitVersion) + + logger.WithStep().Info("Validating options") + if err := s.client.ValidateOptions(); err != nil { + return fmt.Errorf("validating options: %w", err) + } + + logger.WithStep().Info("Initializing OBS root and config") + if err := s.client.InitOBSRoot(); err != nil { + return fmt.Errorf("initializing obs root: %w", err) + } + + logger.WithStep().Info("Checking prerequisites") + if err := s.client.CheckPrerequisites(); err != nil { + return fmt.Errorf("check prerequisites: %w", err) + } + + logger.WithStep().Info("Checking release branch state") + if err := s.client.CheckReleaseBranchState(); err != nil { + return fmt.Errorf("checking release branch state: %w", err) + } + + logger.WithStep().Info("Generating release version") + if err := s.client.GenerateReleaseVersion(); err != nil { + return fmt.Errorf("generating release version: %w", err) + } + + logger.WithStep().Info("Generating package version") + s.client.GeneratePackageVersion() + + logger.WithStep().Info("Generating OBS project name") + if err := s.client.GenerateOBSProject(); err != nil { + return fmt.Errorf("generating obs project name: %w", err) + } + + logger.WithStep().Info("Checking out OBS project") + if err := s.client.CheckoutOBSProject(); err != nil { + return fmt.Errorf("checking out obs project: %w", err) + } + + logger.WithStep().Info("Generating spec files and artifact archives") + if err := s.client.GeneratePackageArtifacts(); err != nil { + return fmt.Errorf("generating package artifacts: %w", err) + } + + logger.WithStep().Info("Pushing packages to OBS") + if err := s.client.Push(); err != nil { + return fmt.Errorf("pushing packages to obs: %w", err) + } + + return nil +} diff --git a/pkg/obs/prerequisites.go b/pkg/obs/prerequisites.go new file mode 100644 index 000000000000..e9348a19c30d --- /dev/null +++ b/pkg/obs/prerequisites.go @@ -0,0 +1,150 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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 obs + +import ( + "errors" + "fmt" + "strings" + + "github.com/shirou/gopsutil/v3/disk" + "github.com/sirupsen/logrus" + + "sigs.k8s.io/release-sdk/osc" + "sigs.k8s.io/release-utils/command" + "sigs.k8s.io/release-utils/env" +) + +// PrerequisitesChecker is the main type for checking the prerequisites for +// OBS operations. +type PrerequisitesChecker struct { + impl prerequisitesCheckerImpl + opts *PrerequisitesCheckerOptions +} + +// Type prerequisites checker +type PrerequisitesCheckerOptions struct { + CheckOBSPassword bool +} + +var DefaultPrerequisitesCheckerOptions = &PrerequisitesCheckerOptions{ + CheckOBSPassword: true, +} + +// NewPrerequisitesChecker creates a new PrerequisitesChecker instance. +func NewPrerequisitesChecker() *PrerequisitesChecker { + return &PrerequisitesChecker{ + &defaultPrerequisitesChecker{}, + DefaultPrerequisitesCheckerOptions, + } +} + +// Options return the options from the prereq checker +func (p *PrerequisitesChecker) Options() *PrerequisitesCheckerOptions { + return p.opts +} + +// SetImpl can be used to set the internal PrerequisitesChecker implementation. +func (p *PrerequisitesChecker) SetImpl(impl prerequisitesCheckerImpl) { + p.impl = impl +} + +//counterfeiter:generate . prerequisitesCheckerImpl +type prerequisitesCheckerImpl interface { + CommandAvailable(commands ...string) bool + OSCOutput(args ...string) (string, error) + IsEnvSet(key string) bool + Usage(dir string) (*disk.UsageStat, error) +} + +type defaultPrerequisitesChecker struct{} + +func (*defaultPrerequisitesChecker) CommandAvailable( + commands ...string, +) bool { + return command.Available(commands...) +} + +func (*defaultPrerequisitesChecker) OSCOutput( + args ...string, +) (string, error) { + return osc.Output("", args...) +} + +func (*defaultPrerequisitesChecker) IsEnvSet(key string) bool { + return env.IsSet(key) +} + +func (*defaultPrerequisitesChecker) Usage(dir string) (*disk.UsageStat, error) { + return disk.Usage(dir) +} + +func (p *PrerequisitesChecker) Run(workdir string) error { + // Command checks + commands := []string{"osc"} + logrus.Infof( + "Verifying that the commands %s exist in $PATH.", + strings.Join(commands, ", "), + ) + + if !p.impl.CommandAvailable(commands...) { + return errors.New("not all commands available") + } + + // osc checks + logrus.Info("Verifying OpenBuildService access") + ver, err := p.impl.OSCOutput("--version") + if err != nil { + return fmt.Errorf("running osc --version: %w", err) + } + logrus.Infof("Using osc version: %s", ver) + + user, err := p.impl.OSCOutput("whois") + if err != nil { + return fmt.Errorf("running osc whois: %w", err) + } + logrus.Infof("Using OpenBuildService user: %s", user) + + // Environment checks + if p.opts.CheckOBSPassword { + logrus.Infof( + "Verifying that %s environment variable is set", OBSPasswordKey, + ) + if !p.impl.IsEnvSet(OBSPasswordKey) { + return fmt.Errorf("no %s env variable set", OBSPasswordKey) + } + } + + // Disk space check + const minDiskSpaceGiB = 10 + logrus.Infof( + "Checking available disk space (%dGB) for %s", minDiskSpaceGiB, workdir, + ) + res, err := p.impl.Usage(workdir) + if err != nil { + return fmt.Errorf("check available disk space: %w", err) + } + diskSpaceGiB := res.Free / 1024 / 1024 / 1024 + if diskSpaceGiB < minDiskSpaceGiB { + return fmt.Errorf( + "not enough disk space available. Got %dGiB, need at least %dGiB", + diskSpaceGiB, minDiskSpaceGiB, + ) + } + + return nil +} diff --git a/pkg/obs/stage.go b/pkg/obs/stage.go new file mode 100644 index 000000000000..5e3f2aef8aab --- /dev/null +++ b/pkg/obs/stage.go @@ -0,0 +1,377 @@ +/* +Copyright 2023 The Kubernetes Authors. + +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 obs + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/blang/semver/v4" + "github.com/sirupsen/logrus" + + "k8s.io/release/pkg/obs/specs" + "k8s.io/release/pkg/release" + "sigs.k8s.io/release-sdk/osc" + "sigs.k8s.io/release-utils/util" +) + +// stageClient is a client for staging releases. +// +//counterfeiter:generate . stageClient +type stageClient interface { + // InitState initializes the default internal state. + InitState() + + // Validate if the provided `StageOptions` are correctly set. + ValidateOptions() error + + // InitOBSRoot initializes the OBS root directory. + InitOBSRoot() error + + // CheckPrerequisites verifies that a valid OBS_PASSWORD environment + // variable is set. It also checks for the existence and version of + // required packages and if the correct Google Cloud project is set. A + // basic hardware check will ensure that enough disk space is available, + // too. + CheckPrerequisites() error + + // CheckReleaseBranchState discovers if the provided release branch has to + // be created. This is used to correctly determine release versions that + // packages are built for. + CheckReleaseBranchState() error + + // GenerateReleaseVersion discovers the next versions to be released. + GenerateReleaseVersion() error + + // GeneratePackageVersion discovers the package version. + GeneratePackageVersion() + + // GenerateOBSProject discovers the OBS project name for the release. + GenerateOBSProject() error + + // CheckoutOBSProject checkouts the OBS project in the provided working + // directory. + CheckoutOBSProject() error + + // GeneratePackageArtifacts generates spec file and archive with binaries + // for the given package. + GeneratePackageArtifacts() error + + // Push pushes the package (spec file and archive) to OBS which triggers + // the build. + Push() error +} + +// DefaultStage is the default staging implementation used in production. +type DefaultStage struct { + impl stageImpl + options *StageOptions + state *StageState +} + +// NewDefaultStage creates a new defaultStage instance. +func NewDefaultStage(options *StageOptions) *DefaultStage { + return &DefaultStage{&defaultStageImpl{}, options, nil} +} + +// SetImpl can be used to set the internal stage implementation. +func (d *DefaultStage) SetImpl(impl stageImpl) { + d.impl = impl +} + +// SetState fixes the current state. Mainly used for passing +// arbitrary values during testing +func (d *DefaultStage) SetState(state *StageState) { + d.state = state +} + +// State returns the internal state. +func (d *DefaultStage) State() *StageState { + return d.state +} + +// defaultStageImpl is the default internal stage client implementation. +type defaultStageImpl struct{} + +// stageImpl is the implementation of the stage client. +// +//counterfeiter:generate . stageImpl +type stageImpl interface { + CheckPrerequisites() error + MkdirAll(path string) error + RemovePackageFiles(path string) error + GenerateReleaseVersion( + releaseType, version, branch string, branchFromMaster bool, + ) (*release.Versions, error) + BranchNeedsCreation( + branch, releaseType string, buildVersion semver.Version, + ) (bool, error) + GenerateSpecsAndArtifacts(options *specs.Options) error + CreateOBSConfigFile(username, password string) error + CheckoutProject(project string) error + AddRemoveChanges(project string) error + CommitChanges(project, packageName, message string) error +} + +func (d *defaultStageImpl) CheckPrerequisites() error { + return NewPrerequisitesChecker().Run(workspaceDir) +} + +func (d *defaultStageImpl) MkdirAll(path string) error { + return os.MkdirAll(path, os.ModePerm) +} + +// RemoveNonHiddenFiles removes everything in the package directory except +// `.osc` directory which contains the package metadata. +func (d *defaultStageImpl) RemovePackageFiles(path string) error { + return filepath.Walk(path, func(fullPath string, f os.FileInfo, err error) error { + if err != nil { + return err + } + + // Make sure we don't delete the root directory + if path == fullPath { + return nil + } + + if filepath.Base(fullPath) == ".osc" { + return fs.SkipDir + } + + logrus.Infof("Removing path: %s", fullPath) + return os.RemoveAll(fullPath) + }) +} + +func (d *defaultStageImpl) BranchNeedsCreation( + branch, releaseType string, buildVersion semver.Version, +) (bool, error) { + return release.NewBranchChecker().NeedsCreation( + branch, releaseType, buildVersion, + ) +} + +func (d *defaultStageImpl) GenerateReleaseVersion( + releaseType, version, branch string, branchFromMaster bool, +) (*release.Versions, error) { + return release.GenerateReleaseVersion( + releaseType, version, branch, branchFromMaster, + ) +} + +// GenerateSpecsAndArtifacts creates spec file and artifacts archive for the +// given package (`krel obs specs`). +func (d *defaultStageImpl) GenerateSpecsAndArtifacts(options *specs.Options) error { + return specs.New(options).Run() +} + +// CreateOBSConfigFile creates `~/.oscrc` file which contains the OBS API URL +// and credentials for the k8s-release-bot user. +func (d *defaultStageImpl) CreateOBSConfigFile(username, password string) error { + return osc.CreateOSCConfigFile(obsAPIURL, username, password) +} + +// CheckoutProject runs `osc checkout` in the project directory. +func (d *defaultStageImpl) CheckoutProject(project string) error { + // TODO(xmudrii): figure out how to stream output. + return osc.OSC(obsRoot, "checkout", project) +} + +// AddRemovePackage run `osc addremove` in the project directory. +func (d *defaultStageImpl) AddRemoveChanges(project string) error { + // TODO(xmudrii): figure out how to stream output. + return osc.OSC(filepath.Join(obsRoot, project), "addremove") +} + +// CommitChanges runs `osc commit` in the package directory. +func (d *defaultStageImpl) CommitChanges(project, packageName, message string) error { + // TODO(xmudrii): figure out how to stream output. + return osc.OSC(filepath.Join(obsRoot, project, packageName), "commit", "-m", message) +} + +func (d *DefaultStage) InitState() { + d.state = &StageState{DefaultState()} +} + +// InitOBSRoot creates the OBS root directory and the OBS config file. +func (d *DefaultStage) InitOBSRoot() error { + password := os.Getenv(OBSPasswordKey) + if password == "" { + return fmt.Errorf("%s environment variable not set", OBSPasswordKey) + } + + if err := d.impl.CreateOBSConfigFile(obsK8sUsername, password); err != nil { + return fmt.Errorf("creating obs config file: %w", err) + } + + return d.impl.MkdirAll(obsRoot) +} + +// ValidateOptions validates the stage options. +func (d *DefaultStage) ValidateOptions() error { + if err := d.options.Validate(d.state.State); err != nil { + return fmt.Errorf("validating options: %w", err) + } + return nil +} + +// CheckPrerequisites checks if all prerequisites for the stage process +// are met. +func (d *DefaultStage) CheckPrerequisites() error { + return d.impl.CheckPrerequisites() +} + +func (d *DefaultStage) CheckReleaseBranchState() error { + if d.state.corePackages { + logrus.Info("Skipping checking release branch state because non-core package is being built.") + + return nil + } + + createReleaseBranch, err := d.impl.BranchNeedsCreation( + d.options.ReleaseBranch, + d.options.ReleaseType, + d.state.semverBuildVersion, + ) + if err != nil { + return fmt.Errorf("check if release branch needs creation: %w", err) + } + d.state.createReleaseBranch = createReleaseBranch + return nil +} + +func (d *DefaultStage) GenerateReleaseVersion() error { + if d.state.corePackages { + logrus.Info("Skipping generating release version because non-core package is being built.") + + return nil + } + + versions, err := d.impl.GenerateReleaseVersion( + d.options.ReleaseType, + d.options.BuildVersion, + d.options.ReleaseBranch, + d.state.createReleaseBranch, + ) + if err != nil { + return fmt.Errorf("generating release versions for stage: %w", err) + } + // Set the versions on the state + d.state.versions = versions + return nil +} + +// GeneratePackageVersion generates the package version for the release. +// Uses the version from the options if set, otherwise uses the prime version. +func (d *DefaultStage) GeneratePackageVersion() { + if d.options.Version != "" { + d.state.packageVersion = d.options.Version + } else { + // TODO(xmudrii): We probably want to build non prime versions as well? + d.state.packageVersion = util.TrimTagPrefix(d.state.versions.Prime()) + } + + logrus.Infof("Using package version: %s", d.state.packageVersion) +} + +// GenerateOBSProject generates the OBS project name for the release. +// Uses the project from the options if set, otherwise generates the project +// name based on the release type. +func (d *DefaultStage) GenerateOBSProject() error { + if d.options.Project != "" { + logrus.Infof("Using provided OBS project: %s", d.state.obsProject) + d.state.obsProject = d.options.Project + + return nil + } + + primeSemver, err := util.TagStringToSemver(d.state.versions.Prime()) + if err != nil { + return fmt.Errorf("parsing prime version as semver: %w", err) + } + + namespace := OBSNamespacePrerelease + if d.options.ReleaseType == release.ReleaseTypeOfficial { + namespace = OBSNamespaceStable + } + + d.state.obsProject = fmt.Sprintf("%s:core:%s:v%d.%d:build", OBSKubernetesProject, namespace, primeSemver.Major, primeSemver.Minor) + + logrus.Infof("Using OBS project: %s", d.state.obsProject) + + return nil +} + +// CheckoutOBSProject checks out the OBS project. +func (d *DefaultStage) CheckoutOBSProject() error { + if err := d.impl.CheckoutProject(d.state.obsProject); err != nil { + return fmt.Errorf("checking out obs project: %w", err) + } + + return nil +} + +// GeneratePackageArtifacts generates the spec file and artifacts archive +// for packages that are built. +func (d *DefaultStage) GeneratePackageArtifacts() error { + for _, pkg := range d.options.Packages { + // TODO(xmudrii): add options for other options (e.g. revision, architecture). + opts := specs.DefaultOptions() + opts.Package = pkg + opts.Version = d.state.packageVersion + opts.PackageSourceBase = d.options.Source + if d.state.corePackages { + opts.PackageSourceBase = fmt.Sprintf("gs://%s/stage/%s/%s/gcs-stage", d.options.Bucket(), d.options.BuildVersion, d.state.versions.Prime()) + } + opts.SpecTemplatePath = d.options.SpecTemplatePath + opts.SpecOutputPath = filepath.Join(obsRoot, d.state.obsProject, pkg) + + if err := d.impl.RemovePackageFiles(opts.SpecOutputPath); err != nil { + return fmt.Errorf("cleaning up package %s directory: %w", pkg, err) + } + + if err := d.impl.GenerateSpecsAndArtifacts(opts); err != nil { + return fmt.Errorf("building specs and artifacts for %s: %w", pkg, err) + } + } + + return nil +} + +// Push pushes changes to OpenBuildService which triggers the build. +func (d *DefaultStage) Push() error { + if !d.options.NoMock { + logrus.Info("Running stage in mock, skipping pushing changes to OBS.") + + // TODO(xmudrii): revert this + // return nil + } + + if err := d.impl.AddRemoveChanges(d.state.obsProject); err != nil { + return fmt.Errorf("adding/removing package files: %w", err) + } + + for _, pkg := range d.options.Packages { + if err := d.impl.CommitChanges(d.state.obsProject, pkg, d.state.versions.Prime()); err != nil { + return fmt.Errorf("committing packages: %w", err) + } + } + + return nil +}