From e0240bfd8a31f0171de91df5c238f86c0af85ff8 Mon Sep 17 00:00:00 2001 From: Sascha Grunert Date: Thu, 15 Feb 2024 13:26:51 +0100 Subject: [PATCH] Allow in-memory GCB jobs This allows submitting GCB jobs via `krel` without having a need for the k/release repository available. Signed-off-by: Sascha Grunert --- gcb/gcb.go | 96 +++++++++++++++++ gcb/gcb_test.go | 168 ++++++++++++++++++++++++++++++ gcb/gcbfakes/fake_impl.go | 214 ++++++++++++++++++++++++++++++++++++++ gcb/impl.go | 37 +++++++ pkg/gcp/gcb/gcb.go | 80 ++++++++------ 5 files changed, 563 insertions(+), 32 deletions(-) create mode 100644 gcb/gcb.go create mode 100644 gcb/gcb_test.go create mode 100644 gcb/gcbfakes/fake_impl.go create mode 100644 gcb/impl.go diff --git a/gcb/gcb.go b/gcb/gcb.go new file mode 100644 index 00000000000..89330b20e84 --- /dev/null +++ b/gcb/gcb.go @@ -0,0 +1,96 @@ +/* +Copyright 2024 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 gcb + +import ( + _ "embed" + "fmt" + "path/filepath" + + "k8s.io/release/pkg/gcp/build" +) + +// All available job types +const ( + JobTypeStage = "stage" + JobTypeRelease = "release" + JobTypeFastForward = "fast-forward" + JobTypeObsStage = "obs-stage" + JobTypeObsRelease = "obs-release" +) + +var ( + //go:embed stage/cloudbuild.yaml + stageCloudBuild []byte + + //go:embed release/cloudbuild.yaml + releaseCloudBuild []byte + + //go:embed fast-forward/cloudbuild.yaml + fastForwardCloudBuild []byte + + //go:embed obs-stage/cloudbuild.yaml + obsStageCloudBuild []byte + + //go:embed obs-release/cloudbuild.yaml + obsReleaseCloudBuild []byte +) + +// CloudBuild is the main type of this package. +type CloudBuild struct { + impl +} + +// New creates a new CloudBuild instance. +func New() *CloudBuild { + return &CloudBuild{impl: &defaultImpl{}} +} + +// DirForJobType creates a temp directory containing the default cloudbuild +// file for the provided job type. +func (c *CloudBuild) DirForJobType(jobType string) (string, error) { + tempDir, err := c.impl.MkdirTemp("", "krel-cloudbuild-*") + if err != nil { + return "", fmt.Errorf("create temp cloudbuild dir: %w", err) + } + + var content []byte + switch jobType { + case JobTypeStage: + content = stageCloudBuild + case JobTypeRelease: + content = releaseCloudBuild + case JobTypeFastForward: + content = fastForwardCloudBuild + case JobTypeObsStage: + content = obsStageCloudBuild + case JobTypeObsRelease: + content = obsReleaseCloudBuild + default: + return "", fmt.Errorf("unknown job type: %s", jobType) + } + + if err := c.impl.WriteFile( + filepath.Join(tempDir, build.DefaultCloudbuildFile), + content, + 0o600, + ); err != nil { + return "", fmt.Errorf("write cloudbuild file: %w", err) + } + + return tempDir, nil +} diff --git a/gcb/gcb_test.go b/gcb/gcb_test.go new file mode 100644 index 00000000000..f31c8f2cd7a --- /dev/null +++ b/gcb/gcb_test.go @@ -0,0 +1,168 @@ +/* +Copyright 2024 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 gcb + +import ( + "errors" + "io/fs" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "k8s.io/release/gcb/gcbfakes" + "k8s.io/release/pkg/gcp/build" +) + +func TestDirForJobType(t *testing.T) { + t.Parallel() + + const testDir = "testDir" + errTest := errors.New("test") + + for _, tc := range []struct { + name, jobType string + prepare func(*gcbfakes.FakeImpl) + assert func(string, error) + }{ + { + name: "success stage", + jobType: JobTypeStage, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + mock.WriteFileCalls(func(name string, content []byte, mode fs.FileMode) error { + assert.Contains(t, string(content), "- STAGE") + assert.Equal(t, filepath.Join(testDir, build.DefaultCloudbuildFile), name) + return nil + }) + }, + assert: func(dir string, err error) { + assert.NoError(t, err) + assert.Equal(t, testDir, dir) + }, + }, + { + name: "success release", + jobType: JobTypeRelease, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + mock.WriteFileCalls(func(name string, content []byte, mode fs.FileMode) error { + assert.Contains(t, string(content), "- RELEASE") + assert.Equal(t, filepath.Join(testDir, build.DefaultCloudbuildFile), name) + return nil + }) + }, + assert: func(dir string, err error) { + assert.NoError(t, err) + assert.Equal(t, testDir, dir) + }, + }, + { + name: "success fast forward", + jobType: JobTypeFastForward, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + mock.WriteFileCalls(func(name string, content []byte, mode fs.FileMode) error { + assert.Contains(t, string(content), "- FAST_FORWARD") + assert.Equal(t, filepath.Join(testDir, build.DefaultCloudbuildFile), name) + return nil + }) + }, + assert: func(dir string, err error) { + assert.NoError(t, err) + assert.Equal(t, testDir, dir) + }, + }, + { + name: "success obs stage", + jobType: JobTypeObsStage, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + mock.WriteFileCalls(func(name string, content []byte, mode fs.FileMode) error { + assert.Contains(t, string(content), "- OBS_STAGE") + assert.Equal(t, filepath.Join(testDir, build.DefaultCloudbuildFile), name) + return nil + }) + }, + assert: func(dir string, err error) { + assert.NoError(t, err) + assert.Equal(t, testDir, dir) + }, + }, + { + name: "success obs release", + jobType: JobTypeObsRelease, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + mock.WriteFileCalls(func(name string, content []byte, mode fs.FileMode) error { + assert.Contains(t, string(content), "- OBS_RELEASE") + assert.Equal(t, filepath.Join(testDir, build.DefaultCloudbuildFile), name) + return nil + }) + }, + assert: func(dir string, err error) { + assert.NoError(t, err) + assert.Equal(t, testDir, dir) + }, + }, + { + name: "failure on temp dir creation", + jobType: JobTypeStage, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns("", errTest) + }, + assert: func(dir string, err error) { + assert.Error(t, err) + assert.Empty(t, dir) + }, + }, + { + name: "failure on file write", + jobType: JobTypeStage, + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + mock.WriteFileReturns(errTest) + }, + assert: func(dir string, err error) { + assert.Error(t, err) + assert.Empty(t, dir) + }, + }, + { + name: "failure unknown job type", + jobType: "wrong", + prepare: func(mock *gcbfakes.FakeImpl) { + mock.MkdirTempReturns(testDir, nil) + }, + assert: func(dir string, err error) { + assert.Error(t, err) + assert.Empty(t, dir) + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + mock := &gcbfakes.FakeImpl{} + sut := New() + sut.impl = mock + tc.prepare(mock) + + dir, err := sut.DirForJobType(tc.jobType) + tc.assert(dir, err) + }) + } +} diff --git a/gcb/gcbfakes/fake_impl.go b/gcb/gcbfakes/fake_impl.go new file mode 100644 index 00000000000..55cfcae42a8 --- /dev/null +++ b/gcb/gcbfakes/fake_impl.go @@ -0,0 +1,214 @@ +/* +Copyright 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. +*/ + +// Code generated by counterfeiter. DO NOT EDIT. +package gcbfakes + +import ( + "io/fs" + "sync" +) + +type FakeImpl struct { + MkdirTempStub func(string, string) (string, error) + mkdirTempMutex sync.RWMutex + mkdirTempArgsForCall []struct { + arg1 string + arg2 string + } + mkdirTempReturns struct { + result1 string + result2 error + } + mkdirTempReturnsOnCall map[int]struct { + result1 string + result2 error + } + WriteFileStub func(string, []byte, fs.FileMode) error + writeFileMutex sync.RWMutex + writeFileArgsForCall []struct { + arg1 string + arg2 []byte + arg3 fs.FileMode + } + writeFileReturns struct { + result1 error + } + writeFileReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeImpl) MkdirTemp(arg1 string, arg2 string) (string, error) { + fake.mkdirTempMutex.Lock() + ret, specificReturn := fake.mkdirTempReturnsOnCall[len(fake.mkdirTempArgsForCall)] + fake.mkdirTempArgsForCall = append(fake.mkdirTempArgsForCall, struct { + arg1 string + arg2 string + }{arg1, arg2}) + stub := fake.MkdirTempStub + fakeReturns := fake.mkdirTempReturns + fake.recordInvocation("MkdirTemp", []interface{}{arg1, arg2}) + fake.mkdirTempMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeImpl) MkdirTempCallCount() int { + fake.mkdirTempMutex.RLock() + defer fake.mkdirTempMutex.RUnlock() + return len(fake.mkdirTempArgsForCall) +} + +func (fake *FakeImpl) MkdirTempCalls(stub func(string, string) (string, error)) { + fake.mkdirTempMutex.Lock() + defer fake.mkdirTempMutex.Unlock() + fake.MkdirTempStub = stub +} + +func (fake *FakeImpl) MkdirTempArgsForCall(i int) (string, string) { + fake.mkdirTempMutex.RLock() + defer fake.mkdirTempMutex.RUnlock() + argsForCall := fake.mkdirTempArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeImpl) MkdirTempReturns(result1 string, result2 error) { + fake.mkdirTempMutex.Lock() + defer fake.mkdirTempMutex.Unlock() + fake.MkdirTempStub = nil + fake.mkdirTempReturns = struct { + result1 string + result2 error + }{result1, result2} +} + +func (fake *FakeImpl) MkdirTempReturnsOnCall(i int, result1 string, result2 error) { + fake.mkdirTempMutex.Lock() + defer fake.mkdirTempMutex.Unlock() + fake.MkdirTempStub = nil + if fake.mkdirTempReturnsOnCall == nil { + fake.mkdirTempReturnsOnCall = make(map[int]struct { + result1 string + result2 error + }) + } + fake.mkdirTempReturnsOnCall[i] = struct { + result1 string + result2 error + }{result1, result2} +} + +func (fake *FakeImpl) WriteFile(arg1 string, arg2 []byte, arg3 fs.FileMode) error { + var arg2Copy []byte + if arg2 != nil { + arg2Copy = make([]byte, len(arg2)) + copy(arg2Copy, arg2) + } + fake.writeFileMutex.Lock() + ret, specificReturn := fake.writeFileReturnsOnCall[len(fake.writeFileArgsForCall)] + fake.writeFileArgsForCall = append(fake.writeFileArgsForCall, struct { + arg1 string + arg2 []byte + arg3 fs.FileMode + }{arg1, arg2Copy, arg3}) + stub := fake.WriteFileStub + fakeReturns := fake.writeFileReturns + fake.recordInvocation("WriteFile", []interface{}{arg1, arg2Copy, arg3}) + fake.writeFileMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeImpl) WriteFileCallCount() int { + fake.writeFileMutex.RLock() + defer fake.writeFileMutex.RUnlock() + return len(fake.writeFileArgsForCall) +} + +func (fake *FakeImpl) WriteFileCalls(stub func(string, []byte, fs.FileMode) error) { + fake.writeFileMutex.Lock() + defer fake.writeFileMutex.Unlock() + fake.WriteFileStub = stub +} + +func (fake *FakeImpl) WriteFileArgsForCall(i int) (string, []byte, fs.FileMode) { + fake.writeFileMutex.RLock() + defer fake.writeFileMutex.RUnlock() + argsForCall := fake.writeFileArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeImpl) WriteFileReturns(result1 error) { + fake.writeFileMutex.Lock() + defer fake.writeFileMutex.Unlock() + fake.WriteFileStub = nil + fake.writeFileReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeImpl) WriteFileReturnsOnCall(i int, result1 error) { + fake.writeFileMutex.Lock() + defer fake.writeFileMutex.Unlock() + fake.WriteFileStub = nil + if fake.writeFileReturnsOnCall == nil { + fake.writeFileReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.writeFileReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeImpl) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.mkdirTempMutex.RLock() + defer fake.mkdirTempMutex.RUnlock() + fake.writeFileMutex.RLock() + defer fake.writeFileMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeImpl) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} diff --git a/gcb/impl.go b/gcb/impl.go new file mode 100644 index 00000000000..f12204ad823 --- /dev/null +++ b/gcb/impl.go @@ -0,0 +1,37 @@ +/* +Copyright 2024 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 gcb + +import "os" + +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate +//counterfeiter:generate . impl +//go:generate /usr/bin/env bash -c "cat ../hack/boilerplate/boilerplate.generatego.txt gcbfakes/fake_impl.go > gcbfakes/_fake_impl.go && mv gcbfakes/_fake_impl.go gcbfakes/fake_impl.go" +type impl interface { + MkdirTemp(string, string) (string, error) + WriteFile(string, []byte, os.FileMode) error +} + +type defaultImpl struct{} + +func (*defaultImpl) MkdirTemp(dir, pattern string) (string, error) { + return os.MkdirTemp(dir, pattern) +} + +func (*defaultImpl) WriteFile(name string, data []byte, perm os.FileMode) error { + return os.WriteFile(name, data, perm) +} diff --git a/pkg/gcp/gcb/gcb.go b/pkg/gcp/gcb/gcb.go index 9e3f9d3700b..cf8c3cfa4e3 100644 --- a/pkg/gcp/gcb/gcb.go +++ b/pkg/gcp/gcb/gcb.go @@ -31,8 +31,10 @@ import ( "strings" "github.com/blang/semver/v4" + gogit "github.com/go-git/go-git/v5" "github.com/sirupsen/logrus" + "k8s.io/release/gcb" "k8s.io/release/pkg/gcp/auth" "k8s.io/release/pkg/gcp/build" "k8s.io/release/pkg/kubecross" @@ -40,6 +42,7 @@ import ( "sigs.k8s.io/release-sdk/gcli" "sigs.k8s.io/release-sdk/git" "sigs.k8s.io/release-utils/util" + utilsversion "sigs.k8s.io/release-utils/version" ) // StringSliceSeparator is the separator used for passing string slices as GCB @@ -217,12 +220,53 @@ func (g *GCB) Submit() error { return fmt.Errorf("pre-checking for GCP package usage: %w", err) } - if err := g.repoClient.Open(); err != nil { - return fmt.Errorf("open release repo: %w", err) + var jobType string + switch { + // TODO: Consider a '--validate' flag to validate the GCB config without submitting + case g.options.Stage: + jobType = gcb.JobTypeStage + case g.options.Release: + jobType = gcb.JobTypeRelease + case g.options.FastForward: + jobType = gcb.JobTypeFastForward + case g.options.OBSStage: + jobType = gcb.JobTypeObsStage + case g.options.OBSRelease: + jobType = gcb.JobTypeObsRelease + default: + return g.listJobs(g.options.Project, g.options.LastJobs) } - if err := g.repoClient.CheckState(toolOrg, toolRepo, toolRef, g.options.NoMock); err != nil { - return fmt.Errorf("verifying repository state: %w", err) + version := utilsversion.GetVersionInfo().GitVersion + if err := g.repoClient.Open(); errors.Is(err, gogit.ErrRepositoryNotExists) { + // Use the embedded cloudbuild files + configDir, err := gcb.New().DirForJobType(jobType) + if err != nil { + return fmt.Errorf("get cloudbuild dir for job type: %w", err) + } + + g.options.ConfigDir = configDir + defer os.RemoveAll(configDir) + } else if err != nil { + // Any other error + return fmt.Errorf("open release repo: %w", err) + } else { + // Using the local k/release repository + if err := g.repoClient.CheckState(toolOrg, toolRepo, toolRef, g.options.NoMock); err != nil { + return fmt.Errorf("verifying repository state: %w", err) + } + + toolRoot, err := os.Getwd() + if err != nil { + return fmt.Errorf("get tool root: %w", err) + } + + g.options.ConfigDir = filepath.Join(toolRoot, "gcb", jobType) + + version, err = g.repoClient.GetTag() + if err != nil { + return fmt.Errorf("getting current tag: %w", err) + } } logrus.Infof("Running GCB with the following options: %+v", g.options) @@ -287,46 +331,18 @@ func (g *GCB) Submit() error { } } - toolRoot, err := os.Getwd() - if err != nil { - return err - } - logrus.Info("Listing GCB substitutions prior to build submission...") for k, v := range gcbSubs { logrus.Infof("%s: %s", k, v) } - var jobType string - switch { - // TODO: Consider a '--validate' flag to validate the GCB config without submitting - case g.options.Stage: - jobType = "stage" - case g.options.Release: - jobType = "release" - case g.options.FastForward: - jobType = "fast-forward" - case g.options.OBSStage: - jobType = "obs-stage" - case g.options.OBSRelease: - jobType = "obs-release" - default: - return g.listJobs(g.options.Project, g.options.LastJobs) - } - gcbSubs["LOG_LEVEL"] = g.options.LogLevel - g.options.ConfigDir = filepath.Join(toolRoot, "gcb", jobType) prepareBuildErr := build.PrepareBuilds(&g.options.Options) if prepareBuildErr != nil { return prepareBuildErr } - version, err := g.repoClient.GetTag() - if err != nil { - return fmt.Errorf("getting current tag: %w", err) - } - if err := build.RunSingleJob(&g.options.Options, "", "", version, gcbSubs); err != nil { return fmt.Errorf("run GCB job: %w", err) }