From e4c33b8d4b06d8d2f33da2882d91840ba171b5bf Mon Sep 17 00:00:00 2001 From: Victor Morales Date: Mon, 3 Feb 2025 20:49:54 -0800 Subject: [PATCH] Add unit tests for create resources use case --- cmd/kar/app/flag.go | 2 +- cmd/kar/app/opts.go | 2 +- cmd/kar/app/root.go | 7 ++- cmd/kar/app/root_test.go | 17 +++++-- cmd/kar/main.go | 44 +++++++++++++----- go.mod | 4 +- go.sum | 4 ++ internal/app_suite_test.go | 28 +++++++++++ internal/errors.go | 28 +++++++++++ internal/runner.go | 74 +++++++++++++++++------------ internal/runner_test.go | 95 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 253 insertions(+), 52 deletions(-) create mode 100644 internal/app_suite_test.go create mode 100644 internal/errors.go create mode 100644 internal/runner_test.go diff --git a/cmd/kar/app/flag.go b/cmd/kar/app/flag.go index af05d2b..07ae0c6 100644 --- a/cmd/kar/app/flag.go +++ b/cmd/kar/app/flag.go @@ -31,7 +31,7 @@ func installFlags(flags *pflag.FlagSet, cmdOptions *Opts) { v.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) v.AutomaticEnv() - flags.StringVarP(&cmdOptions.VmTemplate, "kubevirt-vm-template", "t", "vm-template", + flags.StringVarP(&cmdOptions.VMTemplate, "kubevirt-vm-template", "t", "vm-template", "The VirtualMachine resource to use as the template.") flags.StringVarP(&cmdOptions.RunnerName, "runner-name", "r", "runner", "The name of the runner.") diff --git a/cmd/kar/app/opts.go b/cmd/kar/app/opts.go index 350e952..2f61838 100644 --- a/cmd/kar/app/opts.go +++ b/cmd/kar/app/opts.go @@ -18,7 +18,7 @@ package app // Opts stores all the options for configuring the root kar command. type Opts struct { - VmTemplate string + VMTemplate string RunnerName string JitConfig string } diff --git a/cmd/kar/app/root.go b/cmd/kar/app/root.go index fe09f68..cbec2a8 100644 --- a/cmd/kar/app/root.go +++ b/cmd/kar/app/root.go @@ -18,9 +18,9 @@ package app import ( "context" - "errors" runner "github.com/electrocucaracha/kubevirt-actions-runner/internal" + "github.com/pkg/errors" "github.com/spf13/cobra" ) @@ -42,7 +42,10 @@ func NewRootCommand(ctx context.Context, runner runner.Runner, opts Opts) *cobra } func run(ctx context.Context, runner runner.Runner, opts Opts) error { - runner.CreateResources(ctx, opts.VmTemplate, opts.RunnerName, opts.JitConfig) + err := runner.CreateResources(ctx, opts.VMTemplate, opts.RunnerName, opts.JitConfig) + if err != nil { + return errors.Wrap(err, "fail to create resources") + } defer runner.DeleteResources(ctx) runner.WaitForVirtualMachineInstance(ctx) diff --git a/cmd/kar/app/root_test.go b/cmd/kar/app/root_test.go index 9609128..adaba19 100644 --- a/cmd/kar/app/root_test.go +++ b/cmd/kar/app/root_test.go @@ -19,11 +19,12 @@ package app_test import ( "context" + "slices" + "github.com/electrocucaracha/kubevirt-actions-runner/cmd/kar/app" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/cobra" - "slices" ) type mock struct { @@ -40,20 +41,26 @@ func (m *mock) Failed() bool { return m.failed } -func (m *mock) CreateResources(ctx context.Context, vmTemplate, runnerName, jitConfig string, -) { +func (m *mock) GetVMIName() string { + return m.runnerName +} + +func (m *mock) CreateResources(_ context.Context, vmTemplate, runnerName, jitConfig string, +) error { m.vmTemplate = vmTemplate m.runnerName = runnerName m.jitConfig = jitConfig m.createCalled = true + + return nil } -func (m *mock) WaitForVirtualMachineInstance(ctx context.Context) { +func (m *mock) WaitForVirtualMachineInstance(_ context.Context) { m.waitCalled = true } -func (m *mock) DeleteResources(ctx context.Context) { +func (m *mock) DeleteResources(_ context.Context) { m.deleteCalled = true } diff --git a/cmd/kar/main.go b/cmd/kar/main.go index 88fd35f..2df96d3 100644 --- a/cmd/kar/main.go +++ b/cmd/kar/main.go @@ -26,6 +26,8 @@ import ( "github.com/electrocucaracha/kubevirt-actions-runner/cmd/kar/app" runner "github.com/electrocucaracha/kubevirt-actions-runner/internal" "github.com/pkg/errors" + "github.com/spf13/pflag" + "kubevirt.io/client-go/kubecli" ) type buildInfo struct { @@ -36,31 +38,49 @@ type buildInfo struct { } func getBuildInfo() buildInfo { - b := buildInfo{} + out := buildInfo{} + if info, ok := debug.ReadBuildInfo(); ok { - b.goVersion = info.GoVersion - for _, kv := range info.Settings { - switch kv.Key { + out.goVersion = info.GoVersion + + for _, setting := range info.Settings { + switch setting.Key { case "vcs.revision": - b.gitCommit = kv.Value + out.gitCommit = setting.Value case "vcs.time": - b.buildDate = kv.Value + out.buildDate = setting.Value case "vcs.modified": - b.gitTreeModified = kv.Value + out.gitTreeModified = setting.Value } } } - return b + return out } func main() { - var opts app.Opts + var ( + opts app.Opts + err error + ) + + buildInfo := getBuildInfo() + log.Printf("starting kubevirt action runner\ncommit: %v\tmodified:%v\n", + buildInfo.gitCommit, buildInfo.gitTreeModified) + + clientConfig := kubecli.DefaultClientConfig(&pflag.FlagSet{}) - b := getBuildInfo() - log.Printf("starting kubevirt action runner\ncommit: %v\tmodified:%v\n", b.gitCommit, b.gitTreeModified) + namespace, _, err := clientConfig.Namespace() + if err != nil { + log.Fatalf("error in namespace : %v\n", err) + } + + virtClient, err := kubecli.GetKubevirtClientFromClientConfig(clientConfig) + if err != nil { + log.Fatalf("cannot obtain KubeVirt client: %v\n", err) + } - runner := runner.NewRunner() + runner := runner.NewRunner(namespace, virtClient) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() diff --git a/go.mod b/go.mod index 45bfeca..b8e1d2a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.23.4 replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f require ( + github.com/golang/mock v1.6.0 github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 github.com/pkg/errors v0.9.1 @@ -36,7 +37,6 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.1.0 // indirect - github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -79,11 +79,13 @@ require ( golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.24.0 // indirect google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.31.0 // indirect + k8s.io/apiserver v0.31.0 // indirect k8s.io/client-go v0.31.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.31.0 // indirect diff --git a/go.sum b/go.sum index e5daa0e..0de2026 100644 --- a/go.sum +++ b/go.sum @@ -797,6 +797,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -837,6 +839,8 @@ k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRp k8s.io/apimachinery v0.23.3/go.mod h1:BEuFMMBaIbcOqVIJqNZJXGFTP4W6AycEpb5+m/97hrM= k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4= k8s.io/apimachinery v0.31.3/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apiserver v0.31.0 h1:p+2dgJjy+bk+B1Csz+mc2wl5gHwvNkC9QJV+w55LVrY= +k8s.io/apiserver v0.31.0/go.mod h1:KI9ox5Yu902iBnnyMmy7ajonhKnkeZYJhTZ/YI+WEMk= k8s.io/client-go v0.0.0-20181115111358-9bea17718df8/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= k8s.io/client-go v0.19.0/go.mod h1:H9E/VT95blcFQnlyShFgnFT9ZnJOAceiUHM3MlRC+mU= k8s.io/client-go v0.20.0/go.mod h1:4KWh/g+Ocd8KkCwKF8vUNnmqgv+EVnQDK4MBF4oB5tY= diff --git a/internal/app_suite_test.go b/internal/app_suite_test.go new file mode 100644 index 0000000..b680ab7 --- /dev/null +++ b/internal/app_suite_test.go @@ -0,0 +1,28 @@ +/* +Copyright © 2025 +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 runner_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestApp(t *testing.T) { + t.Parallel() + + RegisterFailHandler(Fail) + RunSpecs(t, "Runner App Suite") +} diff --git a/internal/errors.go b/internal/errors.go new file mode 100644 index 0000000..c4653e4 --- /dev/null +++ b/internal/errors.go @@ -0,0 +1,28 @@ +/* +Copyright © 2025 + +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 runner + +import "errors" + +// ErrEmptyVMTemplate indicates that virtual machine template provided is empty. +var ErrEmptyVMTemplate = errors.New("empty vm template") + +// ErrEmptyRunnerName indicates that runner name provided is empty. +var ErrEmptyRunnerName = errors.New("empty runner name") + +// ErrEmptyJitConfig indicates that Just-in-Time configuration provided is empty. +var ErrEmptyJitConfig = errors.New("empty jit config") diff --git a/internal/runner.go b/internal/runner.go index 471205d..cd17a44 100644 --- a/internal/runner.go +++ b/internal/runner.go @@ -23,7 +23,7 @@ import ( "log" "time" - "github.com/spf13/pflag" + "github.com/pkg/errors" k8scorev1 "k8s.io/api/core/v1" k8smetav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -40,10 +40,11 @@ const ( ) type Runner interface { - CreateResources(context.Context, string, string, string) - WaitForVirtualMachineInstance(context.Context) - DeleteResources(context.Context) + CreateResources(ctx context.Context, vmTemplate string, runnerName string, jitConfig string) error + WaitForVirtualMachineInstance(ctx context.Context) + DeleteResources(ctx context.Context) Failed() bool + GetVMIName() string } type KubevirtRunner struct { @@ -61,13 +62,17 @@ func (rc *KubevirtRunner) Failed() bool { return rc.currentStatus == v1.Failed } +func (rc *KubevirtRunner) GetVMIName() string { + return rc.virtualMachineInstance +} + func (rc *KubevirtRunner) getResources(ctx context.Context, vmTemplate, runnerName, jitConfig string) ( - *v1.VirtualMachineInstance, *v1beta1.DataVolume, + *v1.VirtualMachineInstance, *v1beta1.DataVolume, error, ) { virtualMachine, err := rc.virtClient.VirtualMachine(rc.namespace).Get( ctx, vmTemplate, k8smetav1.GetOptions{}) if err != nil { - log.Fatalf("cannot obtain KubeVirt vm list: %v\n", err) + return nil, nil, errors.Wrap(err, "cannot obtain KubeVirt vm list") } virtualMachineInstance := v1.NewVMIReferenceFromNameWithNS(rc.namespace, runnerName) @@ -83,7 +88,7 @@ func (rc *KubevirtRunner) getResources(ctx context.Context, vmTemplate, runnerNa out, err := json.Marshal(jri) if err != nil { - log.Fatalf("cannot marshal jitConfig: %v\n", err) + return nil, nil, errors.Wrap(err, "cannot marshal jitConfig") } virtualMachineInstance.Annotations[runnerInfoAnnotation] = string(out) @@ -109,7 +114,7 @@ func (rc *KubevirtRunner) getResources(ctx context.Context, vmTemplate, runnerNa virtualMachineInstance.Spec.Volumes = append(virtualMachineInstance.Spec.Volumes, generateRunnerInfoVolume()) - return virtualMachineInstance, dataVolume + return virtualMachineInstance, dataVolume, nil } func generateRunnerInfoVolume() v1.Volume { @@ -132,15 +137,30 @@ func generateRunnerInfoVolume() v1.Volume { func (rc *KubevirtRunner) CreateResources(ctx context.Context, vmTemplate, runnerName, jitConfig string, -) { - virtualMachineInstance, dataVolume := rc.getResources(ctx, vmTemplate, runnerName, jitConfig) +) error { + if len(vmTemplate) == 0 { + return ErrEmptyVMTemplate + } + + if len(runnerName) == 0 { + return ErrEmptyRunnerName + } + + if len(jitConfig) == 0 { + return ErrEmptyJitConfig + } + + virtualMachineInstance, dataVolume, err := rc.getResources(ctx, vmTemplate, runnerName, jitConfig) + if err != nil { + return err + } log.Printf("Creating %s Virtual Machine Instance\n", virtualMachineInstance.Name) vmi, err := rc.virtClient.VirtualMachineInstance(rc.namespace).Create(ctx, virtualMachineInstance, k8smetav1.CreateOptions{}) if err != nil { - log.Fatal(err.Error()) + return errors.Wrap(err, "fail to create runner instance") } rc.virtualMachineInstance = virtualMachineInstance.Name @@ -160,16 +180,20 @@ func (rc *KubevirtRunner) CreateResources(ctx context.Context, if _, err := rc.cdiClient.CdiV1beta1().DataVolumes( rc.namespace).Create(ctx, dataVolume, k8smetav1.CreateOptions{}); err != nil { - log.Fatalf("cannot create data volume: %v\n", err) + return errors.Wrap(err, "cannot create data volume") } rc.dataVolume = dataVolume.Name } + + return nil } func (rc *KubevirtRunner) WaitForVirtualMachineInstance(ctx context.Context) { log.Printf("Watching %s Virtual Machine Instance\n", rc.virtualMachineInstance) + const reportingElapse = 5.0 + watch, err := rc.virtClient.VirtualMachineInstance(rc.namespace).Watch(ctx, k8smetav1.ListOptions{}) if err != nil { log.Fatalf("Failed to watch Virtual Machine Instance: %v", err) @@ -183,19 +207,23 @@ func (rc *KubevirtRunner) WaitForVirtualMachineInstance(ctx context.Context) { if ok && vmi.Name == rc.virtualMachineInstance { if vmi.Status.Phase != rc.currentStatus { rc.currentStatus = vmi.Status.Phase - log.Printf("%s has transitioned to %s phase \n", rc.virtualMachineInstance, rc.currentStatus) lastTimeChecked = time.Now() switch rc.currentStatus { case v1.Succeeded: - log.Printf("%s has successfuly completed\n", rc.virtualMachineInstance) + log.Printf("%s has successfully completed\n", rc.virtualMachineInstance) + return case v1.Failed: log.Printf("%s has failed\n", rc.virtualMachineInstance) + return + default: + log.Printf("%s has transitioned to %s phase \n", rc.virtualMachineInstance, rc.currentStatus) } - } else if time.Since(lastTimeChecked).Minutes() > 5.0 { + } else if time.Since(lastTimeChecked).Minutes() > reportingElapse { log.Printf("%s is in %s phase \n", rc.virtualMachineInstance, rc.currentStatus) + lastTimeChecked = time.Now() } } @@ -219,21 +247,7 @@ func (rc *KubevirtRunner) DeleteResources(ctx context.Context) { } } -func NewRunner() *KubevirtRunner { - var err error - - clientConfig := kubecli.DefaultClientConfig(&pflag.FlagSet{}) - - namespace, _, err := clientConfig.Namespace() - if err != nil { - log.Fatalf("error in namespace : %v\n", err) - } - - virtClient, err := kubecli.GetKubevirtClientFromClientConfig(clientConfig) - if err != nil { - log.Fatalf("cannot obtain KubeVirt client: %v\n", err) - } - +func NewRunner(namespace string, virtClient kubecli.KubevirtClient) *KubevirtRunner { return &KubevirtRunner{ namespace: namespace, virtClient: virtClient, diff --git a/internal/runner_test.go b/internal/runner_test.go new file mode 100644 index 0000000..b6b6ff7 --- /dev/null +++ b/internal/runner_test.go @@ -0,0 +1,95 @@ +/* +Copyright © 2023 + +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 runner_test + +import ( + "context" + + runner "github.com/electrocucaracha/kubevirt-actions-runner/internal" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + k8sv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "kubevirt.io/api/core/v1" + cdifake "kubevirt.io/client-go/containerizeddataimporter/fake" + "kubevirt.io/client-go/kubecli" + kubevirtfake "kubevirt.io/client-go/kubevirt/fake" +) + +var _ = Describe("Runner", func() { + var virtClient *kubecli.MockKubevirtClient + var virtClientset *kubevirtfake.Clientset + var karRunner runner.Runner + + BeforeEach(func() { + cdiClientset := cdifake.NewSimpleClientset() + virtClient = kubecli.NewMockKubevirtClient(gomock.NewController(GinkgoT())) + + virtClient.EXPECT().CdiClient().Return(cdiClientset).AnyTimes() + + karRunner = runner.NewRunner(k8sv1.NamespaceDefault, virtClient) + }) + + DescribeTable("create resources", func(shouldSucceed bool, vmTemplate, runnerName, jitConfig string) { + vm := NewVirtualMachine(vmTemplate) + virtClientset = kubevirtfake.NewSimpleClientset(vm) + + if shouldSucceed { + virtClient.EXPECT().VirtualMachine(k8sv1.NamespaceDefault).Return( + virtClientset.KubevirtV1().VirtualMachines(k8sv1.NamespaceDefault), + ) + virtClient.EXPECT().VirtualMachineInstance(k8sv1.NamespaceDefault).Return( + virtClientset.KubevirtV1().VirtualMachineInstances(k8sv1.NamespaceDefault), + ) + } + + err := karRunner.CreateResources(context.TODO(), vmTemplate, runnerName, jitConfig) + + if shouldSucceed { + Expect(err).NotTo(HaveOccurred()) + Expect(karRunner.GetVMIName()).Should(Equal(runnerName)) + } else { + Expect(err).To(HaveOccurred()) + if len(vmTemplate) == 0 { + Expect(err).Should(Equal(runner.ErrEmptyVMTemplate)) + } + if len(runnerName) == 0 { + Expect(err).Should(Equal(runner.ErrEmptyRunnerName)) + } + if len(jitConfig) == 0 { + Expect(err).Should(Equal(runner.ErrEmptyJitConfig)) + } + } + }, + Entry("when the valid information is provided", true, "vmTemplate", "runnerName", "jitConfig"), + Entry("when empty vm template is provided", false, "", "runnerName", "jitConfig"), + Entry("when empty runner name is provided", false, "vmTemplate", "", "jitConfig"), + Entry("when empty jit config is provided", false, "vmTemplate", "runnerName", ""), + ) +}) + +func NewVirtualMachine(name string) *v1.VirtualMachine { + return &v1.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k8sv1.NamespaceDefault, ResourceVersion: "1", UID: "vm-uid"}, + Spec: v1.VirtualMachineSpec{ + Template: &v1.VirtualMachineInstanceTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + }, + }, + } +}