diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..17f8c14 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,18 @@ +name: Unit tests +on: + push: + branches: + - master + pull_request: + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Run tests + run: | + ./earthly.sh +test diff --git a/.gitignore b/.gitignore index 0b66eef..938e380 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - # Binaries for programs and plugins *.exe *.exe~ @@ -24,4 +23,4 @@ testbin/* *.swo *~ -/helm-chart \ No newline at end of file +/helm-chart diff --git a/Earthfile b/Earthfile index 0c5a3b5..0d720d9 100644 --- a/Earthfile +++ b/Earthfile @@ -1,7 +1,7 @@ VERSION 0.6 ARG BASE_IMAGE=quay.io/kairos/core-opensuse:latest ARG OSBUILDER_IMAGE=quay.io/kairos/osbuilder-tools - +ARG GO_VERSION=1.18 build-challenger: FROM golang:alpine @@ -29,3 +29,20 @@ iso: RUN sha256sum $ISO_NAME.iso > $ISO_NAME.iso.sha256 SAVE ARTIFACT /build/$ISO_NAME.iso kairos.iso AS LOCAL build/$ISO_NAME.iso SAVE ARTIFACT /build/$ISO_NAME.iso.sha256 kairos.iso.sha256 AS LOCAL build/$ISO_NAME.iso.sha256 + +test: + ARG GO_VERSION + FROM golang:$GO_VERSION + ENV CGO_ENABLED=0 + + WORKDIR /work + + # Cache layer for modules + COPY go.mod go.sum ./ + RUN go mod download && go mod verify + + RUN go install github.com/onsi/ginkgo/v2/ginkgo + + COPY . /work + RUN PATH=$PATH:$GOPATH/bin ginkgo run --covermode=atomic --coverprofile=coverage.out -p -r pkg/challenger + SAVE ARTIFACT coverage.out AS LOCAL coverage.out diff --git a/api/v1alpha1/sealedvolume_types.go b/api/v1alpha1/sealedvolume_types.go index b077ac1..358bbe6 100644 --- a/api/v1alpha1/sealedvolume_types.go +++ b/api/v1alpha1/sealedvolume_types.go @@ -25,9 +25,19 @@ import ( // SealedVolumeSpec defines the desired state of SealedVolume type SealedVolumeSpec struct { - TPMHash string `json:"TPMHash,omitempty"` - Passphrase map[string]*SecretSpec `json:"partitionSecrets,omitempty"` - Quarantined bool `json:"quarantined,omitempty"` + TPMHash string `json:"TPMHash,omitempty"` + Partitions []PartitionSpec `json:"partitions,omitempty"` + Quarantined bool `json:"quarantined,omitempty"` +} + +// PartitionSpec defines a Partition. A partition can be identified using +// any of the fields: Label, DeviceName, UUID. The Secret defines the secret +// which decrypts the partition. +type PartitionSpec struct { + Label string `json:"label,omitempty"` + DeviceName string `json:"deviceName,omitempty"` + UUID string `json:"uuid,omitempty"` + Secret *SecretSpec `json:"secret,omitempty"` } type SecretSpec struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index fec9629..f1d2f6b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -25,6 +25,22 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PartitionSpec) DeepCopyInto(out *PartitionSpec) { + *out = *in + out.Secret = in.Secret +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PartitionSpec. +func (in *PartitionSpec) DeepCopy() *PartitionSpec { + if in == nil { + return nil + } + out := new(PartitionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SealedVolume) DeepCopyInto(out *SealedVolume) { *out = *in @@ -87,20 +103,10 @@ func (in *SealedVolumeList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SealedVolumeSpec) DeepCopyInto(out *SealedVolumeSpec) { *out = *in - if in.Passphrase != nil { - in, out := &in.Passphrase, &out.Passphrase - *out = make(map[string]*SecretSpec, len(*in)) - for key, val := range *in { - var outVal *SecretSpec - if val == nil { - (*out)[key] = nil - } else { - in, out := &val, &outVal - *out = new(SecretSpec) - **out = **in - } - (*out)[key] = outVal - } + if in.Partitions != nil { + in, out := &in.Partitions, &out.Partitions + *out = make([]PartitionSpec, len(*in)) + copy(*out, *in) } } diff --git a/cmd/discovery/main.go b/cmd/discovery/main.go index 35f1543..c8f4a21 100644 --- a/cmd/discovery/main.go +++ b/cmd/discovery/main.go @@ -48,7 +48,7 @@ func readServer() string { return server } -func waitPass(label string, attempts int) (pass string, err error) { +func waitPass(p *block.Partition, attempts int) (pass string, err error) { for tries := 0; tries < attempts; tries++ { server := readServer() if server == "" { @@ -56,7 +56,7 @@ func waitPass(label string, attempts int) (pass string, err error) { continue } - pass, err = getPass(server, label) + pass, err = getPass(server, p) if pass != "" || err == nil { return pass, err } @@ -65,8 +65,11 @@ func waitPass(label string, attempts int) (pass string, err error) { return } -func getPass(server, label string) (string, error) { - msg, err := tpm.Get(server, tpm.WithAdditionalHeader("label", label)) +func getPass(server string, partition *block.Partition) (string, error) { + msg, err := tpm.Get(server, + tpm.WithAdditionalHeader("label", partition.Label), + tpm.WithAdditionalHeader("name", partition.Name), + tpm.WithAdditionalHeader("uuid", partition.UUID)) if err != nil { return "", err } @@ -79,7 +82,7 @@ func getPass(server, label string) (string, error) { if ok { return fmt.Sprint(p), nil } - return "", fmt.Errorf("pass for label not found") + return "", fmt.Errorf("pass for partition not found") } type config struct { @@ -102,14 +105,10 @@ func start() error { } } - // TODO: This should be 1 call, send both name and label to controller - pass, err := waitPass(b.Label, 30) - if err != nil || pass == "" { - pass, err = waitPass(b.Name, 30) - if err != nil { - return pluggable.EventResponse{ - Error: fmt.Sprintf("failed getting pass: %s", err.Error()), - } + pass, err := waitPass(b, 30) + if err != nil { + return pluggable.EventResponse{ + Error: fmt.Sprintf("failed getting pass: %s", err.Error()), } } diff --git a/config/crd/bases/keyserver.kairos.io_sealedvolumes.yaml b/config/crd/bases/keyserver.kairos.io_sealedvolumes.yaml index 6f91639..37bb4e7 100644 --- a/config/crd/bases/keyserver.kairos.io_sealedvolumes.yaml +++ b/config/crd/bases/keyserver.kairos.io_sealedvolumes.yaml @@ -37,15 +37,27 @@ spec: properties: TPMHash: type: string - partitionSecrets: - additionalProperties: + partitions: + items: + description: 'PartitionSpec defines a Partition. A partition can + be identified using any of the fields: Label, DeviceName, UUID. + The Secret defines the secret which decrypts the partition.' properties: - name: + deviceName: type: string - path: + label: + type: string + secret: + properties: + name: + type: string + path: + type: string + type: object + uuid: type: string type: object - type: object + type: array quarantined: type: boolean type: object diff --git a/earthly.sh b/earthly.sh new file mode 100755 index 0000000..12b82a9 --- /dev/null +++ b/earthly.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker run --privileged -v /var/run/docker.sock:/var/run/docker.sock --rm -t -v $(pwd):/workspace -v earthly-tmp:/tmp/earthly:rw earthly/earthly:v0.6.21 --allow-privileged $@ \ No newline at end of file diff --git a/go.mod b/go.mod index 8939004..edbe420 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/kairos-io/kcrypt v0.0.0-20221006145351-cabc24dc37a7 github.com/mudler/go-pluggable v0.0.0-20220716112424-189d463e3ff3 github.com/onsi/ginkgo v1.16.5 + github.com/onsi/ginkgo/v2 v2.1.4 github.com/onsi/gomega v1.20.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.24.2 @@ -46,6 +47,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.5 // indirect github.com/go-openapi/swag v0.19.14 // indirect + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect @@ -57,6 +59,7 @@ require ( github.com/google/go-tpm-tools v0.3.2 // indirect github.com/google/go-tspi v0.2.1-0.20190423175329-115dea689aad // indirect github.com/google/gofuzz v1.1.0 // indirect + github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.1.2 // indirect github.com/gookit/color v1.5.0 // indirect @@ -98,6 +101,7 @@ require ( golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect + golang.org/x/tools v0.1.10 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.0 // indirect diff --git a/go.sum b/go.sum index af58547..71adbd6 100644 --- a/go.sum +++ b/go.sum @@ -326,6 +326,7 @@ github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -448,6 +449,7 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/rpmpack v0.0.0-20191226140753-aa36bfddb3a0/go.mod h1:RaTPr0KUf2K7fnZYLNDrr8rxAamWs3iNywJLtQ2AzBg= @@ -719,6 +721,7 @@ github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9k github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.1.4 h1:GNapqRSid3zijZ9H77KrgVG4/8KqiyRsxcSxe+7ApXY= +github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= @@ -1352,6 +1355,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10-0.20220218145154-897bd77cd717/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/challenger/challenger.go b/pkg/challenger/challenger.go index 33262e5..65030a6 100644 --- a/pkg/challenger/challenger.go +++ b/pkg/challenger/challenger.go @@ -19,6 +19,21 @@ import ( "github.com/gorilla/websocket" ) +// PassphraseRequestData is a struct that holds all the information needed in +// order to lookup a passphrase for a specific tpm hash. +type PassphraseRequestData struct { + TPMHash string + Label string + DeviceName string + UUID string +} + +type SealedVolumeData struct { + Quarantined bool + SecretName string + SecretPath string +} + var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, @@ -71,6 +86,8 @@ func Start(ctx context.Context, kclient *kubernetes.Clientset, reconciler *contr token := r.Header.Get("Authorization") label := r.Header.Get("label") + name := r.Header.Get("name") + uuid := r.Header.Get("uuid") ek, at, err := tpm.GetAttestationData(token) if err != nil { fmt.Println("Failed getting tpm token") @@ -85,28 +102,17 @@ func Start(ctx context.Context, kclient *kubernetes.Clientset, reconciler *contr return } - found := false - var volume keyserverv1alpha1.SealedVolume - var passsecret *keyserverv1alpha1.SecretSpec - for _, v := range volumeList.Items { - if hashEncoded == v.Spec.TPMHash { - for l, secretRef := range v.Spec.Passphrase { - if l == label { - found = true - volume = v - passsecret = secretRef - } - } - } - } + sealedVolumeData := findSecretFor(PassphraseRequestData{ + TPMHash: hashEncoded, + Label: label, + DeviceName: name, + UUID: uuid, + }, volumeList) - if !found { + if sealedVolumeData == nil { fmt.Println("No TPM Hash found for", hashEncoded) conn.Close() - // conn.Close() - // return - continue //will iterate until a TPM is available - + return } secret, challenge, err := tpm.GenerateChallenge(ek, at) @@ -124,10 +130,10 @@ func Start(ctx context.Context, kclient *kubernetes.Clientset, reconciler *contr writer, _ := conn.NextWriter(websocket.BinaryMessage) - if !volume.Spec.Quarantined { - secret, err := kclient.CoreV1().Secrets(namespace).Get(ctx, passsecret.Name, v1.GetOptions{}) + if !sealedVolumeData.Quarantined { + secret, err := kclient.CoreV1().Secrets(namespace).Get(ctx, sealedVolumeData.SecretName, v1.GetOptions{}) if err == nil { - passphrase := secret.Data[passsecret.Path] + passphrase := secret.Data[sealedVolumeData.SecretPath] json.NewEncoder(writer).Encode(map[string]string{"passphrase": string(passphrase)}) } } else { @@ -142,7 +148,7 @@ func Start(ctx context.Context, kclient *kubernetes.Clientset, reconciler *contr go func() { err := s.ListenAndServe() - if err != nil { + if err != nil && err != http.ErrServerClosed { panic(err) } }() @@ -152,3 +158,21 @@ func Start(ctx context.Context, kclient *kubernetes.Clientset, reconciler *contr s.Shutdown(ctx) }() } + +func findSecretFor(requestData PassphraseRequestData, volumeList *keyserverv1alpha1.SealedVolumeList) *SealedVolumeData { + for _, v := range volumeList.Items { + if requestData.TPMHash == v.Spec.TPMHash { + for _, p := range v.Spec.Partitions { + if p.Label == requestData.Label || p.DeviceName == requestData.DeviceName || p.UUID == requestData.UUID { + return &SealedVolumeData{ + Quarantined: v.Spec.Quarantined, + SecretName: p.Secret.Name, + SecretPath: p.Secret.Path, + } + } + } + } + } + + return nil +} diff --git a/pkg/challenger/challenger_test.go b/pkg/challenger/challenger_test.go new file mode 100644 index 0000000..199d896 --- /dev/null +++ b/pkg/challenger/challenger_test.go @@ -0,0 +1,127 @@ +// [✓] Setup a cluster +// [✓] install crds on it +// - run the server locally +// - make requests to the server to see if we can get passphrases back +package challenger + +import ( + keyserverv1alpha1 "github.com/kairos-io/kairos-challenger/api/v1alpha1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("challenger", func() { + Describe("findSecretFor", func() { + var requestData PassphraseRequestData + var volumeList *keyserverv1alpha1.SealedVolumeList + + BeforeEach(func() { + requestData = PassphraseRequestData{ + TPMHash: "1234", + DeviceName: "/dev/sda1", + UUID: "sda1_uuid", + Label: "COS_PERSISTENT", + } + }) + + When("a sealedvolume matching the label exists", func() { + BeforeEach(func() { + volumeList = volumeListWithPartitionSpec( + keyserverv1alpha1.PartitionSpec{ + Label: requestData.Label, + DeviceName: "not_matching", + UUID: "not_matching", + Secret: &keyserverv1alpha1.SecretSpec{ + Name: "the_secret", + Path: "the_path", + }}) + }) + + It("returns the sealed volume data", func() { + volumeData := findSecretFor(requestData, volumeList) + Expect(volumeData).ToNot(BeNil()) + Expect(volumeData.Quarantined).To(BeFalse()) + Expect(volumeData.SecretName).To(Equal("the_secret")) + Expect(volumeData.SecretPath).To(Equal("the_path")) + }) + }) + + When("a sealedvolume matching the device name exists", func() { + BeforeEach(func() { + volumeList = volumeListWithPartitionSpec( + keyserverv1alpha1.PartitionSpec{ + Label: "not_matching", + DeviceName: requestData.DeviceName, + UUID: "not_matching", + Secret: &keyserverv1alpha1.SecretSpec{ + Name: "the_secret", + Path: "the_path", + }}) + }) + + It("returns the sealed volume data", func() { + volumeData := findSecretFor(requestData, volumeList) + Expect(volumeData).ToNot(BeNil()) + Expect(volumeData.Quarantined).To(BeFalse()) + Expect(volumeData.SecretName).To(Equal("the_secret")) + Expect(volumeData.SecretPath).To(Equal("the_path")) + }) + }) + + When("a sealedvolume matching the UUID exists", func() { + BeforeEach(func() { + volumeList = volumeListWithPartitionSpec( + keyserverv1alpha1.PartitionSpec{ + Label: "not_matching", + DeviceName: "not_matching", + UUID: requestData.UUID, + Secret: &keyserverv1alpha1.SecretSpec{ + Name: "the_secret", + Path: "the_path", + }}) + }) + + It("returns the sealed volume data", func() { + volumeData := findSecretFor(requestData, volumeList) + Expect(volumeData).ToNot(BeNil()) + Expect(volumeData.Quarantined).To(BeFalse()) + Expect(volumeData.SecretName).To(Equal("the_secret")) + Expect(volumeData.SecretPath).To(Equal("the_path")) + }) + }) + + When("a matching sealedvolume doesn't exist", func() { + BeforeEach(func() { + volumeList = volumeListWithPartitionSpec( + keyserverv1alpha1.PartitionSpec{ + Label: "not_matching", + DeviceName: "not_matching", + UUID: "not_matching", + Secret: &keyserverv1alpha1.SecretSpec{ + Name: "the_secret", + Path: "the_path", + }}) + }) + + It("returns nil sealedVolumeData", func() { + volumeData := findSecretFor(requestData, volumeList) + Expect(volumeData).To(BeNil()) + }) + }) + }) +}) + +func volumeListWithPartitionSpec(partitionSpec keyserverv1alpha1.PartitionSpec) *keyserverv1alpha1.SealedVolumeList { + return &keyserverv1alpha1.SealedVolumeList{ + Items: []keyserverv1alpha1.SealedVolume{ + {Spec: keyserverv1alpha1.SealedVolumeSpec{ + TPMHash: "1234", + Partitions: []keyserverv1alpha1.PartitionSpec{ + partitionSpec, + }, + Quarantined: false, + }, + }, + }, + } +} diff --git a/pkg/challenger/suite_test.go b/pkg/challenger/suite_test.go new file mode 100644 index 0000000..3a34fab --- /dev/null +++ b/pkg/challenger/suite_test.go @@ -0,0 +1,13 @@ +package challenger_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEpinio(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Kcrypt challenger suite") +}