diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 837b968..72b0230 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,53 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: + changes: + runs-on: ubuntu-latest + if: >- + ( github.event_name == 'pull_request_review' && github.event.review.state == 'approved' ) || github.event_name != 'pull_request_review' + outputs: + golang: ${{ steps.filter.outputs.golang }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # 3.0.2 + if: ${{ !env.ACT }} + id: filter + with: + token: ${{ secrets.GITHUB_TOKEN }} + filters: | + golang: + - '**.go' + unit-test: + name: Check Go lang unit tests + if: needs.changes.outputs.golang == 'true' + needs: changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # 5.3.0 + with: + go-version: "^1.23" + - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # 4.2.0 + if: ${{ !env.ACT }} + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - uses: GoTestTools/gotestfmt-action@8b4478c7019be847373babde9300210e7de34bfb # 2.2.0 + - name: Run tests + run: | + set -euo pipefail + go test -json -v ./... 2>&1 | tee /tmp/gotest.log | gotestfmt + - name: Upload test log + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # 4.6.0 + if: ${{ !env.ACT }} + with: + name: test-log + path: /tmp/gotest.log + if-no-files-found: error push-registry: name: Push Docker image to Docker Hub runs-on: ubuntu-latest @@ -30,6 +77,7 @@ jobs: contents: read attestations: write id-token: write + needs: unit-test steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 - name: Log in to the Container registry @@ -45,13 +93,13 @@ jobs: images: ghcr.io/${{ github.repository }} - name: Build and push Docker image id: push - uses: docker/build-push-action@b32b51a8eda65d6793cd0494a773d4f6bcef32dc # 6.11.0 + uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # 6.13.0 with: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Generate artifact attestation - uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # 2.1.0 + uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # 2.2.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/cmd/kar/app/app_suite_test.go b/cmd/kar/app/app_suite_test.go new file mode 100644 index 0000000..426a972 --- /dev/null +++ b/cmd/kar/app/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 app_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestApp(t *testing.T) { + t.Parallel() + + RegisterFailHandler(Fail) + RunSpecs(t, "KAR App Suite") +} diff --git a/cmd/kar/app/flag.go b/cmd/kar/app/flag.go index 2be1fe8..af05d2b 100644 --- a/cmd/kar/app/flag.go +++ b/cmd/kar/app/flag.go @@ -31,11 +31,11 @@ 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", + flags.StringVarP(&cmdOptions.RunnerName, "runner-name", "r", "runner", "The name of the runner.") - flags.StringVarP(&cmdOptions.jsonConfig, "actions-runner-input-jitconfig", "c", "", + flags.StringVarP(&cmdOptions.JitConfig, "actions-runner-input-jitconfig", "c", "", "The opaque JIT runner config.") } diff --git a/cmd/kar/app/opts.go b/cmd/kar/app/opts.go index dd50c6f..350e952 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 - runnerName string - jsonConfig string + VmTemplate string + RunnerName string + JitConfig string } diff --git a/cmd/kar/app/root.go b/cmd/kar/app/root.go index 4c1805e..fe09f68 100644 --- a/cmd/kar/app/root.go +++ b/cmd/kar/app/root.go @@ -22,10 +22,9 @@ import ( runner "github.com/electrocucaracha/kubevirt-actions-runner/internal" "github.com/spf13/cobra" - kubevirt_v1 "kubevirt.io/api/core/v1" ) -func NewRootCommand(ctx context.Context, runner *runner.Runner, opts Opts) *cobra.Command { +func NewRootCommand(ctx context.Context, runner runner.Runner, opts Opts) *cobra.Command { cmd := &cobra.Command{ Use: "kar", Short: "Tool that creates a GitHub Self-Host runner with Kubevirt Virtual Machine Instance", @@ -42,13 +41,13 @@ func NewRootCommand(ctx context.Context, runner *runner.Runner, opts Opts) *cobr return cmd } -func run(ctx context.Context, runner *runner.Runner, opts Opts) error { - runner.CreateResources(ctx, opts.vmTemplate, opts.runnerName, opts.jsonConfig) +func run(ctx context.Context, runner runner.Runner, opts Opts) error { + runner.CreateResources(ctx, opts.VmTemplate, opts.RunnerName, opts.JitConfig) defer runner.DeleteResources(ctx) runner.WaitForVirtualMachineInstance(ctx) - if runner.CurrentStatus == kubevirt_v1.Failed { + if runner.Failed() { return errors.New("virtual machine instance has failed") } diff --git a/cmd/kar/app/root_test.go b/cmd/kar/app/root_test.go new file mode 100644 index 0000000..9609128 --- /dev/null +++ b/cmd/kar/app/root_test.go @@ -0,0 +1,101 @@ +/* +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 app_test + +import ( + "context" + + "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 { + failed bool + createCalled bool + waitCalled bool + deleteCalled bool + vmTemplate string + runnerName string + jitConfig string +} + +func (m *mock) Failed() bool { + return m.failed +} + +func (m *mock) CreateResources(ctx context.Context, vmTemplate, runnerName, jitConfig string, +) { + m.vmTemplate = vmTemplate + m.runnerName = runnerName + m.jitConfig = jitConfig + + m.createCalled = true +} + +func (m *mock) WaitForVirtualMachineInstance(ctx context.Context) { + m.waitCalled = true +} + +func (m *mock) DeleteResources(ctx context.Context) { + m.deleteCalled = true +} + +var _ = Describe("Root Command", func() { + var runner mock + var cmd *cobra.Command + var opts app.Opts + + BeforeEach(func() { + runner = mock{} + cmd = app.NewRootCommand(context.TODO(), &runner, opts) + }) + + DescribeTable("initialization process", func(shouldSucceed, failed bool, args ...string) { + cmd.SetArgs(args) + runner.failed = failed + err := cmd.Execute() + + if shouldSucceed { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + + if slices.Contains(args, "-c") { + Expect(runner.jitConfig).To(Equal(args[slices.Index(args, "-c")+1])) + } + if slices.Contains(args, "-r") { + Expect(runner.runnerName).To(Equal(args[slices.Index(args, "-r")+1])) + } + if slices.Contains(args, "-t") { + Expect(runner.vmTemplate).To(Equal(args[slices.Index(args, "-t")+1])) + } + + Expect(runner.createCalled).Should(BeTrue()) + Expect(runner.deleteCalled).Should(BeTrue()) + Expect(runner.waitCalled).Should(BeTrue()) + }, + Entry("when the default options are provided", true, false), + Entry("when config option is provided", true, false, "-c", "test config"), + Entry("when vm template option is provided", true, false, "-t", "vm template"), + Entry("when runner name option is provided", true, false, "-r", "runner name"), + Entry("when the execution failed", false, true), + ) +}) diff --git a/go.mod b/go.mod index 411ccfd..45bfeca 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ toolchain go1.23.4 replace k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f require ( + github.com/onsi/ginkgo/v2 v2.19.0 + github.com/onsi/gomega v1.33.1 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 @@ -31,6 +33,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect + 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 @@ -38,6 +41,7 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect + github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -73,6 +77,7 @@ require ( golang.org/x/term v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect 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/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 317bab7..e5daa0e 100644 --- a/go.sum +++ b/go.sum @@ -129,7 +129,6 @@ github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -288,7 +287,6 @@ github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+ github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= diff --git a/internal/runner.go b/internal/runner.go index 9045ea6..471205d 100644 --- a/internal/runner.go +++ b/internal/runner.go @@ -39,16 +39,29 @@ const ( runnerInfoPath string = "runner-info.json" ) -type Runner struct { +type Runner interface { + CreateResources(context.Context, string, string, string) + WaitForVirtualMachineInstance(context.Context) + DeleteResources(context.Context) + Failed() bool +} + +type KubevirtRunner struct { virtClient kubecli.KubevirtClient cdiClient cdiclient.Interface namespace string dataVolume string virtualMachineInstance string - CurrentStatus v1.VirtualMachineInstancePhase + currentStatus v1.VirtualMachineInstancePhase +} + +var _ Runner = (*KubevirtRunner)(nil) + +func (rc *KubevirtRunner) Failed() bool { + return rc.currentStatus == v1.Failed } -func (rc *Runner) getResources(ctx context.Context, vmTemplate, runnerName, jitConfig string) ( +func (rc *KubevirtRunner) getResources(ctx context.Context, vmTemplate, runnerName, jitConfig string) ( *v1.VirtualMachineInstance, *v1beta1.DataVolume, ) { virtualMachine, err := rc.virtClient.VirtualMachine(rc.namespace).Get( @@ -117,7 +130,7 @@ func generateRunnerInfoVolume() v1.Volume { } } -func (rc *Runner) CreateResources(ctx context.Context, +func (rc *KubevirtRunner) CreateResources(ctx context.Context, vmTemplate, runnerName, jitConfig string, ) { virtualMachineInstance, dataVolume := rc.getResources(ctx, vmTemplate, runnerName, jitConfig) @@ -154,7 +167,7 @@ func (rc *Runner) CreateResources(ctx context.Context, } } -func (rc *Runner) WaitForVirtualMachineInstance(ctx context.Context) { +func (rc *KubevirtRunner) WaitForVirtualMachineInstance(ctx context.Context) { log.Printf("Watching %s Virtual Machine Instance\n", rc.virtualMachineInstance) watch, err := rc.virtClient.VirtualMachineInstance(rc.namespace).Watch(ctx, k8smetav1.ListOptions{}) @@ -168,12 +181,12 @@ func (rc *Runner) WaitForVirtualMachineInstance(ctx context.Context) { for event := range watch.ResultChan() { vmi, ok := event.Object.(*v1.VirtualMachineInstance) 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) + 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 { + switch rc.currentStatus { case v1.Succeeded: log.Printf("%s has successfuly completed\n", rc.virtualMachineInstance) return @@ -182,14 +195,14 @@ func (rc *Runner) WaitForVirtualMachineInstance(ctx context.Context) { return } } else if time.Since(lastTimeChecked).Minutes() > 5.0 { - log.Printf("%s is in %s phase \n", rc.virtualMachineInstance, rc.CurrentStatus) + log.Printf("%s is in %s phase \n", rc.virtualMachineInstance, rc.currentStatus) lastTimeChecked = time.Now() } } } } -func (rc *Runner) DeleteResources(ctx context.Context) { +func (rc *KubevirtRunner) DeleteResources(ctx context.Context) { log.Printf("Cleaning %s Virtual Machine Instance resources\n", rc.virtualMachineInstance) @@ -206,7 +219,7 @@ func (rc *Runner) DeleteResources(ctx context.Context) { } } -func NewRunner() *Runner { +func NewRunner() *KubevirtRunner { var err error clientConfig := kubecli.DefaultClientConfig(&pflag.FlagSet{}) @@ -221,7 +234,7 @@ func NewRunner() *Runner { log.Fatalf("cannot obtain KubeVirt client: %v\n", err) } - return &Runner{ + return &KubevirtRunner{ namespace: namespace, virtClient: virtClient, cdiClient: virtClient.CdiClient(),