diff --git a/.github/workflows/test-ci-reusable.yml b/.github/workflows/test-ci-reusable.yml index a1c9be76..860971ac 100644 --- a/.github/workflows/test-ci-reusable.yml +++ b/.github/workflows/test-ci-reusable.yml @@ -118,11 +118,29 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Kind cluster + uses: helm/kind-action@v1.8.0 + with: + config: .github/kind-config.yaml + cluster_name: ci-${{ github.run_id }} + wait: 1m + ignore_failed_clean: true + - name: Set up Ingress Controller run: | - kind create cluster --config=".github/kind-config.yaml" -n ci-${{ github.run_id }} - # Enabling Ingress + # Install nginx ingress controller kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml - kubectl rollout status -w deployment/ingress-nginx-controller -n ingress-nginx --timeout 5m + kubectl rollout status -w \ + deployment/ingress-nginx-controller \ + -n ingress-nginx --timeout 5m + + # Lower the number of worker processes + kubectl patch cm/ingress-nginx-controller \ + --type merge \ + -p '{"data":{"worker-processes":"1"}}' \ + -n ingress-nginx + + # Modify /etc/hosts to resolve hostnames + ip_address=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ci-${{ github.run_id }}-control-plane) + echo "$ip_address testing.cryostat" | sudo tee -a /etc/hosts - name: Install Operator Lifecycle Manager run: curl -sL https://github.com/operator-framework/operator-lifecycle-manager/releases/download/v0.24.0/install.sh | bash -s v0.24.0 - name: Install Cert Manager @@ -140,8 +158,6 @@ jobs: SCORECARD_REGISTRY_PASSWORD="${{ secrets.GITHUB_TOKEN }}" \ BUNDLE_IMG="${{ steps.push-bundle-to-ghcr.outputs.registry-path }}" \ make test-scorecard - - name: Clean up Kind cluster - run: kind delete cluster -n ci-${{ github.run_id }} - name: Set latest commit status as ${{ job.status }} uses: myrotvorets/set-commit-status-action@master if: always() diff --git a/bundle/manifests/cryostat-operator.clusterserviceversion.yaml b/bundle/manifests/cryostat-operator.clusterserviceversion.yaml index 80c9c28d..04a0f5f5 100644 --- a/bundle/manifests/cryostat-operator.clusterserviceversion.yaml +++ b/bundle/manifests/cryostat-operator.clusterserviceversion.yaml @@ -54,7 +54,7 @@ metadata: capabilities: Seamless Upgrades categories: Monitoring, Developer Tools containerImage: quay.io/cryostat/cryostat-operator:2.5.0-dev - createdAt: "2024-02-15T20:45:48Z" + createdAt: "2024-03-06T21:13:39Z" description: JVM monitoring and profiling tool operatorframework.io/initialization-resource: |- { @@ -182,6 +182,12 @@ spec: path: jmxCredentialsDatabaseOptions.databaseSecretName x-descriptors: - urn:alm:descriptor:io.kubernetes:Secret + - description: The maximum number of WebSocket client connections allowed (minimum + 1, default unlimited). + displayName: Max WebSocket Connections + path: maxWsConnections + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:number - description: Options to control how the operator exposes the application outside of the cluster, such as using an Ingress or Route. displayName: Network Options diff --git a/bundle/manifests/operator.cryostat.io_cryostats.yaml b/bundle/manifests/operator.cryostat.io_cryostats.yaml index cf394087..13918ffc 100644 --- a/bundle/manifests/operator.cryostat.io_cryostats.yaml +++ b/bundle/manifests/operator.cryostat.io_cryostats.yaml @@ -127,6 +127,12 @@ spec: credentials database. type: string type: object + maxWsConnections: + description: The maximum number of WebSocket client connections allowed + (minimum 1, default unlimited). + format: int32 + minimum: 1 + type: integer minimal: description: Deploy a pared-down Cryostat instance with no Grafana Dashboard or JFR Data Source. diff --git a/bundle/tests/scorecard/config.yaml b/bundle/tests/scorecard/config.yaml index ba7284c7..12b39361 100644 --- a/bundle/tests/scorecard/config.yaml +++ b/bundle/tests/scorecard/config.yaml @@ -66,10 +66,11 @@ stages: storage: spec: mountPath: {} +- tests: - entrypoint: - cryostat-scorecard-tests - operator-install - image: quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20231011144522 + image: quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20240305020416 labels: suite: cryostat test: operator-install @@ -79,13 +80,23 @@ stages: - entrypoint: - cryostat-scorecard-tests - cryostat-cr - image: quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20231011144522 + image: quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20240305020416 labels: suite: cryostat test: cryostat-cr storage: spec: mountPath: {} + - entrypoint: + - cryostat-scorecard-tests + - cryostat-recording + image: quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20240305020416 + labels: + suite: cryostat + test: cryostat-recording + storage: + spec: + mountPath: {} storage: spec: mountPath: {} diff --git a/config/crd/bases/operator.cryostat.io_cryostats.yaml b/config/crd/bases/operator.cryostat.io_cryostats.yaml index 11de5de7..afa2befd 100644 --- a/config/crd/bases/operator.cryostat.io_cryostats.yaml +++ b/config/crd/bases/operator.cryostat.io_cryostats.yaml @@ -117,6 +117,12 @@ spec: credentials database. type: string type: object + maxWsConnections: + description: The maximum number of WebSocket client connections allowed + (minimum 1, default unlimited). + format: int32 + minimum: 1 + type: integer minimal: description: Deploy a pared-down Cryostat instance with no Grafana Dashboard or JFR Data Source. diff --git a/config/manifests/bases/cryostat-operator.clusterserviceversion.yaml b/config/manifests/bases/cryostat-operator.clusterserviceversion.yaml index a53b0a9f..17d9dda9 100644 --- a/config/manifests/bases/cryostat-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/cryostat-operator.clusterserviceversion.yaml @@ -536,6 +536,12 @@ spec: path: jmxCredentialsDatabaseOptions.databaseSecretName x-descriptors: - urn:alm:descriptor:io.kubernetes:Secret + - description: The maximum number of WebSocket client connections allowed (minimum + 1, default unlimited). + displayName: Max WebSocket Connections + path: maxWsConnections + x-descriptors: + - urn:alm:descriptor:com.tectonic.ui:number - description: Options to control how the operator exposes the application outside of the cluster, such as using an Ingress or Route. displayName: Network Options diff --git a/config/rbac/oauth_client.yaml b/config/rbac/oauth_client.yaml index d8c6693c..b1c50771 100644 --- a/config/rbac/oauth_client.yaml +++ b/config/rbac/oauth_client.yaml @@ -3,7 +3,6 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - creationTimestamp: null name: oauth-client rules: - apiGroups: diff --git a/config/scorecard/bases/config.yaml b/config/scorecard/bases/config.yaml index c7704784..7924d1df 100644 --- a/config/scorecard/bases/config.yaml +++ b/config/scorecard/bases/config.yaml @@ -3,5 +3,7 @@ kind: Configuration metadata: name: config stages: -- parallel: true +- parallel: true # Build-in Tests + tests: [] +- parallel: false # Cryostat Custom Tests tests: [] diff --git a/config/scorecard/patches/custom.config.yaml b/config/scorecard/patches/custom.config.yaml index 786cf388..527eac9a 100644 --- a/config/scorecard/patches/custom.config.yaml +++ b/config/scorecard/patches/custom.config.yaml @@ -3,22 +3,32 @@ path: /serviceaccount value: cryostat-scorecard - op: add - path: /stages/0/tests/- + path: /stages/1/tests/- value: entrypoint: - cryostat-scorecard-tests - operator-install - image: "quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20231011144522" + image: "quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20240305020416" labels: suite: cryostat test: operator-install - op: add - path: /stages/0/tests/- + path: /stages/1/tests/- value: entrypoint: - cryostat-scorecard-tests - cryostat-cr - image: "quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20231011144522" + image: "quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20240305020416" labels: suite: cryostat test: cryostat-cr +- op: add + path: /stages/1/tests/- + value: + entrypoint: + - cryostat-scorecard-tests + - cryostat-recording + image: "quay.io/cryostat/cryostat-operator-scorecard:2.5.0-20240305020416" + labels: + suite: cryostat + test: cryostat-recording diff --git a/hack/custom.config.yaml.in b/hack/custom.config.yaml.in index d6bc3e71..48746200 100644 --- a/hack/custom.config.yaml.in +++ b/hack/custom.config.yaml.in @@ -2,7 +2,7 @@ path: /serviceaccount value: cryostat-scorecard - op: add - path: /stages/0/tests/- + path: /stages/1/tests/- value: entrypoint: - cryostat-scorecard-tests @@ -12,7 +12,7 @@ suite: cryostat test: operator-install - op: add - path: /stages/0/tests/- + path: /stages/1/tests/- value: entrypoint: - cryostat-scorecard-tests @@ -21,3 +21,13 @@ labels: suite: cryostat test: cryostat-cr +- op: add + path: /stages/1/tests/- + value: + entrypoint: + - cryostat-scorecard-tests + - cryostat-recording + image: "${CUSTOM_SCORECARD_IMG}" + labels: + suite: cryostat + test: cryostat-recording diff --git a/internal/images/custom-scorecard-tests/main.go b/internal/images/custom-scorecard-tests/main.go index 310ea1ef..62808a82 100644 --- a/internal/images/custom-scorecard-tests/main.go +++ b/internal/images/custom-scorecard-tests/main.go @@ -79,6 +79,7 @@ func printValidTests() []scapiv1alpha3.TestResult { str := fmt.Sprintf("valid tests for this image include: %s", strings.Join([]string{ tests.OperatorInstallTestName, tests.CryostatCRTestName, + tests.CryostatRecordingTestName, }, ",")) result.Errors = append(result.Errors, str) @@ -90,6 +91,7 @@ func validateTests(testNames []string) bool { switch testName { case tests.OperatorInstallTestName: case tests.CryostatCRTestName: + case tests.CryostatRecordingTestName: default: return false } @@ -108,6 +110,8 @@ func runTests(testNames []string, bundle *apimanifests.Bundle, namespace string, results = append(results, tests.OperatorInstallTest(bundle, namespace)) case tests.CryostatCRTestName: results = append(results, tests.CryostatCRTest(bundle, namespace, openShiftCertManager)) + case tests.CryostatRecordingTestName: + results = append(results, tests.CryostatRecordingTest(bundle, namespace, openShiftCertManager)) default: log.Fatalf("unknown test found: %s", testName) } diff --git a/internal/images/custom-scorecard-tests/rbac/scorecard_role.yaml b/internal/images/custom-scorecard-tests/rbac/scorecard_role.yaml index de76f86d..d350e646 100644 --- a/internal/images/custom-scorecard-tests/rbac/scorecard_role.yaml +++ b/internal/images/custom-scorecard-tests/rbac/scorecard_role.yaml @@ -53,6 +53,55 @@ rules: - cryostats/status verbs: - get +- apiGroups: + - "" + resources: + - secrets + verbs: + - get +# Permissions for default OAuth configurations +- apiGroups: + - operator.cryostat.io + resources: + - cryostats + verbs: + - create + - patch + - delete + - get +- apiGroups: + - "" + resources: + - pods + - pods/exec + - services + verbs: + - create + - patch + - delete + - get +- apiGroups: + - "" + resources: + - replicationcontrollers + - endpoints + verbs: + - get +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - get +- apiGroups: + - apps + resources: + - daemonsets + - replicasets + - statefulsets + verbs: + - get --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/internal/test/scorecard/clients.go b/internal/test/scorecard/clients.go index 2cf994c3..ffe3ad79 100644 --- a/internal/test/scorecard/clients.go +++ b/internal/test/scorecard/clients.go @@ -16,16 +16,25 @@ package scorecard import ( "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" operatorv1beta1 "github.com/cryostatio/cryostat-operator/api/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/util/wait" + + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" ) // CryostatClientset is a Kubernetes Clientset that can also @@ -35,10 +44,15 @@ type CryostatClientset struct { operatorClient *OperatorCRDClient } +// OperatorCRDs returns a OperatorCRDClient +func (c *CryostatClientset) OperatorCRDs() *OperatorCRDClient { + return c.operatorClient +} + // NewClientset creates a CryostatClientset func NewClientset() (*CryostatClientset, error) { // Get in-cluster REST config from pod - config, err := ctrl.GetConfig() + config, err := rest.InClusterConfig() if err != nil { return nil, err } @@ -60,9 +74,19 @@ func NewClientset() (*CryostatClientset, error) { }, nil } -// OperatorCRDs returns a OperatorCRDClient -func (c *CryostatClientset) OperatorCRDs() *OperatorCRDClient { - return c.operatorClient +// OperatorCRDClient is a Kubernetes REST client for performing operations on +// Cryostat Operator custom resources +type OperatorCRDClient struct { + client *rest.RESTClient +} + +// Cryostats returns a CryostatClient configured to a specific namespace +func (c *OperatorCRDClient) Cryostats(namespace string) *CryostatClient { + return &CryostatClient{ + restClient: c.client, + namespace: namespace, + resource: "cryostats", + } } func newOperatorCRDClient(config *rest.Config) (*OperatorCRDClient, error) { @@ -75,19 +99,21 @@ func newOperatorCRDClient(config *rest.Config) (*OperatorCRDClient, error) { }, nil } -// OperatorCRDClient is a Kubernetes REST client for performing operations on -// Cryostat Operator custom resources -type OperatorCRDClient struct { - client *rest.RESTClient +func newCRDClient(config *rest.Config) (*rest.RESTClient, error) { + scheme := runtime.NewScheme() + if err := operatorv1beta1.AddToScheme(scheme); err != nil { + return nil, err + } + return newRESTClientForGV(config, scheme, &operatorv1beta1.GroupVersion) } -// Cryostats returns a CryostatClient configured to a specific namespace -func (c *OperatorCRDClient) Cryostats(namespace string) *CryostatClient { - return &CryostatClient{ - restClient: c.client, - namespace: namespace, - resource: "cryostats", - } +func newRESTClientForGV(config *rest.Config, scheme *runtime.Scheme, gv *schema.GroupVersion) (*rest.RESTClient, error) { + configCopy := *config + configCopy.GroupVersion = gv + configCopy.APIPath = "/apis" + configCopy.ContentType = runtime.ContentTypeJSON + configCopy.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{CodecFactory: serializer.NewCodecFactory(scheme)} + return rest.RESTClientFor(&configCopy) } // CryostatClient contains methods to perform operations on @@ -118,23 +144,6 @@ func (c *CryostatClient) Delete(ctx context.Context, name string, options *metav return delete(ctx, c.restClient, c.resource, c.namespace, name, options) } -func newCRDClient(config *rest.Config) (*rest.RESTClient, error) { - scheme := runtime.NewScheme() - if err := operatorv1beta1.AddToScheme(scheme); err != nil { - return nil, err - } - return newRESTClientForGV(config, scheme, &operatorv1beta1.GroupVersion) -} - -func newRESTClientForGV(config *rest.Config, scheme *runtime.Scheme, gv *schema.GroupVersion) (*rest.RESTClient, error) { - configCopy := *config - configCopy.GroupVersion = gv - configCopy.APIPath = "/apis" - configCopy.ContentType = runtime.ContentTypeJSON - configCopy.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{CodecFactory: serializer.NewCodecFactory(scheme)} - return rest.RESTClientFor(&configCopy) -} - func get[r runtime.Object](ctx context.Context, c rest.Interface, res string, ns string, name string, result r) (r, error) { err := c.Get(). Namespace(ns).Resource(res). @@ -162,3 +171,436 @@ func delete(ctx context.Context, c rest.Interface, res string, ns string, name s Name(name).Body(opts).Do(ctx). Error() } + +// CryostatRESTClientset contains methods to interact with +// the Cryostat API +type CryostatRESTClientset struct { + TargetClient *TargetClient + RecordingClient *RecordingClient + CredentialClient *CredentialClient +} + +func (c *CryostatRESTClientset) Targets() *TargetClient { + return c.TargetClient +} + +func (c *CryostatRESTClientset) Recordings() *RecordingClient { + return c.RecordingClient +} + +func (c *CryostatRESTClientset) Credential() *CredentialClient { + return c.CredentialClient +} + +func NewCryostatRESTClientset(base *url.URL) *CryostatRESTClientset { + commonClient := &commonCryostatRESTClient{ + Base: base, + Client: NewHttpClient(), + } + + return &CryostatRESTClientset{ + TargetClient: &TargetClient{ + commonCryostatRESTClient: commonClient, + }, + RecordingClient: &RecordingClient{ + commonCryostatRESTClient: commonClient, + }, + CredentialClient: &CredentialClient{ + commonCryostatRESTClient: commonClient, + }, + } +} + +type commonCryostatRESTClient struct { + Base *url.URL + *http.Client +} + +// Client for Cryostat Target resources +type TargetClient struct { + *commonCryostatRESTClient +} + +func (client *TargetClient) List(ctx context.Context) ([]Target, error) { + url := client.Base.JoinPath("/api/v1/targets") + header := make(http.Header) + header.Add("Accept", "*/*") + + resp, err := SendRequest(ctx, client.Client, http.MethodGet, url.String(), nil, header) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return nil, fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + targets := make([]Target, 0) + err = ReadJSON(resp, &targets) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return targets, nil +} + +func (client *TargetClient) Create(ctx context.Context, options *Target) (*Target, error) { + url := client.Base.JoinPath("/api/v2/targets") + header := make(http.Header) + header.Add("Content-Type", "application/x-www-form-urlencoded") + header.Add("Accept", "*/*") + body := options.ToFormData() + + resp, err := SendRequest(ctx, client.Client, http.MethodPost, url.String(), &body, header) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return nil, fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + targetResp := &CustomTargetResponse{} + err = ReadJSON(resp, targetResp) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return targetResp.Data.Result, nil +} + +// Client for Cryostat Recording resources +type RecordingClient struct { + *commonCryostatRESTClient +} + +func (client *RecordingClient) List(ctx context.Context, connectUrl string) ([]Recording, error) { + url := client.Base.JoinPath(fmt.Sprintf("/api/v1/targets/%s/recordings", url.PathEscape(connectUrl))) + header := make(http.Header) + header.Add("Accept", "*/*") + + resp, err := SendRequest(ctx, client.Client, http.MethodGet, url.String(), nil, header) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return nil, fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + recordings := make([]Recording, 0) + err = ReadJSON(resp, &recordings) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return recordings, nil +} + +func (client *RecordingClient) Get(ctx context.Context, connectUrl string, recordingName string) (*Recording, error) { + recordings, err := client.List(ctx, connectUrl) + if err != nil { + return nil, err + } + + for _, rec := range recordings { + if rec.Name == recordingName { + return &rec, nil + } + } + + return nil, fmt.Errorf("recording %s does not exist for target %s", recordingName, connectUrl) +} + +func (client *RecordingClient) Create(ctx context.Context, connectUrl string, options *RecordingCreateOptions) (*Recording, error) { + url := client.Base.JoinPath(fmt.Sprintf("/api/v1/targets/%s/recordings", url.PathEscape(connectUrl))) + body := options.ToFormData() + header := make(http.Header) + header.Add("Content-Type", "application/x-www-form-urlencoded") + header.Add("Accept", "*/*") + + resp, err := SendRequest(ctx, client.Client, http.MethodPost, url.String(), &body, header) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return nil, fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + recording := &Recording{} + err = ReadJSON(resp, recording) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return recording, err +} + +func (client *RecordingClient) Archive(ctx context.Context, connectUrl string, recordingName string) (string, error) { + url := client.Base.JoinPath(fmt.Sprintf("/api/v1/targets/%s/recordings/%s", url.PathEscape(connectUrl), url.PathEscape(recordingName))) + body := "SAVE" + header := make(http.Header) + header.Add("Content-Type", "text/plain") + header.Add("Accept", "*/*") + + resp, err := SendRequest(ctx, client.Client, http.MethodPatch, url.String(), &body, header) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return "", fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + bodyAsString, err := ReadString(resp) + if err != nil { + return "", fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return bodyAsString, nil +} + +func (client *RecordingClient) Stop(ctx context.Context, connectUrl string, recordingName string) error { + url := client.Base.JoinPath(fmt.Sprintf("/api/v1/targets/%s/recordings/%s", url.PathEscape(connectUrl), url.PathEscape(recordingName))) + body := "STOP" + header := make(http.Header) + header.Add("Content-Type", "text/plain") + header.Add("Accept", "*/*") + + resp, err := SendRequest(ctx, client.Client, http.MethodPatch, url.String(), &body, header) + if err != nil { + return err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + return nil +} + +func (client *RecordingClient) Delete(ctx context.Context, connectUrl string, recordingName string) error { + url := client.Base.JoinPath(fmt.Sprintf("/api/v1/targets/%s/recordings/%s", url.PathEscape(connectUrl), url.PathEscape(recordingName))) + header := make(http.Header) + + resp, err := SendRequest(ctx, client.Client, http.MethodDelete, url.String(), nil, header) + if err != nil { + return err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + return nil +} + +func (client *RecordingClient) GenerateReport(ctx context.Context, connectUrl string, recordingName *Recording) (map[string]interface{}, error) { + reportURL := recordingName.ReportURL + + if len(reportURL) < 1 { + return nil, fmt.Errorf("report URL is not available") + } + + header := make(http.Header) + header.Add("Accept", "application/json") + + resp, err := SendRequest(ctx, client.Client, http.MethodGet, reportURL, nil, header) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return nil, fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + report := make(map[string]interface{}, 0) + err = ReadJSON(resp, &report) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return report, nil +} + +func (client *RecordingClient) ListArchives(ctx context.Context, connectUrl string) ([]Archive, error) { + url := client.Base.JoinPath("/api/v2.2/graphql") + + query := &GraphQLQuery{ + Query: ` + query ArchivedRecordingsForTarget($connectUrl: String) { + archivedRecordings(filter: { sourceTarget: $connectUrl }) { + data { + name + downloadUrl + reportUrl + metadata { + labels + } + size + } + } + } + `, + Variables: map[string]string{ + connectUrl: connectUrl, + }, + } + queryJSON, err := query.ToJSON() + if err != nil { + return nil, fmt.Errorf("failed to construct graph query: %s", err.Error()) + } + body := string(queryJSON) + + header := make(http.Header) + header.Add("Content-Type", "application/json") + header.Add("Accept", "*/*") + + resp, err := SendRequest(ctx, client.Client, http.MethodPost, url.String(), &body, header) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return nil, fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + graphQLResponse := &ArchiveGraphQLResponse{} + err = ReadJSON(resp, graphQLResponse) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + return graphQLResponse.Data.ArchivedRecordings.Data, nil +} + +type CredentialClient struct { + *commonCryostatRESTClient +} + +func (client *CredentialClient) Create(ctx context.Context, credential *Credential) error { + url := client.Base.JoinPath("/api/v2.2/credentials") + body := credential.ToFormData() + header := make(http.Header) + header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, err := SendRequest(ctx, client.Client, http.MethodPost, url.String(), &body, header) + if err != nil { + return err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + return fmt.Errorf("API request failed with status code: %d, response body: %s, and headers:\n%s", resp.StatusCode, ReadError(resp), ReadHeader(resp)) + } + + return nil +} + +func ReadJSON(resp *http.Response, result interface{}) error { + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + err = json.Unmarshal(body, result) + if err != nil { + return err + } + return nil +} + +func ReadString(resp *http.Response) (string, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} + +func ReadHeader(resp *http.Response) string { + header := "" + for name, value := range resp.Header { + for _, h := range value { + header += fmt.Sprintf("%s: %s\n", name, h) + } + } + return header +} + +func ReadError(resp *http.Response) string { + body, _ := ReadString(resp) + return body +} + +func NewHttpClient() *http.Client { + client := &http.Client{ + Timeout: testTimeout, + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + // Ignore verifying certs + transport.TLSClientConfig.InsecureSkipVerify = true + + client.Transport = transport + return client +} + +func NewHttpRequest(ctx context.Context, method string, url string, body *string, header http.Header) (*http.Request, error) { + var reqBody io.Reader + if body != nil { + reqBody = strings.NewReader(*body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return nil, err + } + if header != nil { + req.Header = header + } + // Authentication is only enabled on OCP. Ignored on k8s. + config, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("failed to get in-cluster configurations: %s", err.Error()) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(config.BearerToken)))) + return req, nil +} + +func StatusOK(statusCode int) bool { + return statusCode >= 200 && statusCode < 300 +} + +func SendRequest(ctx context.Context, httpClient *http.Client, method string, url string, body *string, header http.Header) (*http.Response, error) { + var response *http.Response + err := wait.PollImmediateUntilWithContext(ctx, time.Second, func(ctx context.Context) (done bool, err error) { + // Create a new request + req, err := NewHttpRequest(ctx, method, url, body, header) + if err != nil { + return false, fmt.Errorf("failed to create a Cryostat REST request: %s", err.Error()) + } + + resp, err := httpClient.Do(req) + if err != nil { + // Retry when connection is closed. + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + response = resp + return true, nil + }) + + return response, err +} diff --git a/internal/test/scorecard/common_utils.go b/internal/test/scorecard/common_utils.go new file mode 100644 index 00000000..0b8daa58 --- /dev/null +++ b/internal/test/scorecard/common_utils.go @@ -0,0 +1,399 @@ +// Copyright The Cryostat 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 scorecard + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + operatorv1beta1 "github.com/cryostatio/cryostat-operator/api/v1beta1" + scapiv1alpha3 "github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes/scheme" +) + +const ( + operatorDeploymentName string = "cryostat-operator-controller-manager" + testTimeout time.Duration = time.Minute * 10 +) + +type TestResources struct { + OpenShift bool + Client *CryostatClientset + *scapiv1alpha3.TestResult +} + +func waitForDeploymentAvailability(ctx context.Context, client *CryostatClientset, namespace string, + name string, r *scapiv1alpha3.TestResult) error { + err := wait.PollImmediateUntilWithContext(ctx, time.Second, func(ctx context.Context) (done bool, err error) { + deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if kerrors.IsNotFound(err) { + r.Log += fmt.Sprintf("deployment %s is not yet found\n", name) + return false, nil // Retry + } + return false, fmt.Errorf("failed to get deployment: %s", err.Error()) + } + // Check for Available condition + for _, condition := range deploy.Status.Conditions { + if condition.Type == appsv1.DeploymentAvailable && + condition.Status == corev1.ConditionTrue { + r.Log += fmt.Sprintf("deployment %s is available\n", deploy.Name) + return true, nil + } + if condition.Type == appsv1.DeploymentReplicaFailure && + condition.Status == corev1.ConditionTrue { + r.Log += fmt.Sprintf("deployment %s is failing, %s: %s\n", deploy.Name, + condition.Reason, condition.Message) + } + } + r.Log += fmt.Sprintf("deployment %s is not yet available\n", deploy.Name) + return false, nil + }) + if err != nil { + logErr := logWorkloadEvents(r, client, namespace, name) + if logErr != nil { + r.Log += fmt.Sprintf("failed to look up deployment errors: %s\n", logErr.Error()) + } + } + return err +} + +func logError(r *scapiv1alpha3.TestResult, message string) { + r.State = scapiv1alpha3.FailState + r.Errors = append(r.Errors, message) +} + +func fail(r scapiv1alpha3.TestResult, message string) scapiv1alpha3.TestResult { + r.State = scapiv1alpha3.FailState + r.Errors = append(r.Errors, message) + return r +} + +func logWorkloadEvents(r *scapiv1alpha3.TestResult, client *CryostatClientset, namespace string, name string) error { + ctx := context.Background() + deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + // Log deployment conditions and events + r.Log += fmt.Sprintf("deployment %s conditions:\n", deploy.Name) + for _, condition := range deploy.Status.Conditions { + r.Log += fmt.Sprintf("\t%s == %s, %s: %s\n", condition.Type, + condition.Status, condition.Reason, condition.Message) + } + + r.Log += fmt.Sprintf("deployment %s warning events:\n", deploy.Name) + err = logEvents(r, client, namespace, scheme.Scheme, deploy) + if err != nil { + return err + } + + // Look up replica sets for deployment and log conditions and events + selector, err := metav1.LabelSelectorAsSelector(deploy.Spec.Selector) + if err != nil { + return err + } + replicaSets, err := client.AppsV1().ReplicaSets(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: selector.String(), + }) + if err != nil { + return err + } + for _, rs := range replicaSets.Items { + r.Log += fmt.Sprintf("replica set %s conditions:\n", rs.Name) + for _, condition := range rs.Status.Conditions { + r.Log += fmt.Sprintf("\t%s == %s, %s: %s\n", condition.Type, condition.Status, + condition.Reason, condition.Message) + } + r.Log += fmt.Sprintf("replica set %s warning events:\n", rs.Name) + err = logEvents(r, client, namespace, scheme.Scheme, &rs) + if err != nil { + return err + } + } + + // Look up pods for deployment and log conditions and events + pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: selector.String(), + }) + if err != nil { + return err + } + for _, pod := range pods.Items { + r.Log += fmt.Sprintf("pod %s phase: %s\n", pod.Name, pod.Status.Phase) + r.Log += fmt.Sprintf("pod %s conditions:\n", pod.Name) + for _, condition := range pod.Status.Conditions { + r.Log += fmt.Sprintf("\t%s == %s, %s: %s\n", condition.Type, condition.Status, + condition.Reason, condition.Message) + } + r.Log += fmt.Sprintf("pod %s warning events:\n", pod.Name) + err = logEvents(r, client, namespace, scheme.Scheme, &pod) + if err != nil { + return err + } + } + return nil +} + +func logEvents(r *scapiv1alpha3.TestResult, client *CryostatClientset, namespace string, + scheme *runtime.Scheme, obj runtime.Object) error { + events, err := client.CoreV1().Events(namespace).Search(scheme, obj) + if err != nil { + return err + } + for _, event := range events.Items { + if event.Type == corev1.EventTypeWarning { + r.Log += fmt.Sprintf("\t%s: %s\n", event.Reason, event.Message) + } + } + return nil +} + +func newEmptyTestResult(testName string) *scapiv1alpha3.TestResult { + return &scapiv1alpha3.TestResult{ + Name: testName, + State: scapiv1alpha3.PassState, + Errors: make([]string, 0), + Suggestions: make([]string, 0), + } +} + +func newTestResources(testName string) *TestResources { + return &TestResources{ + TestResult: newEmptyTestResult(testName), + } +} + +func setupCRTestResources(tr *TestResources, openShiftCertManager bool) error { + r := tr.TestResult + + // Create a new Kubernetes REST client for this test + client, err := NewClientset() + if err != nil { + logError(r, fmt.Sprintf("failed to create client: %s", err.Error())) + return err + } + tr.Client = client + + openshift, err := isOpenShift(client) + if err != nil { + logError(r, fmt.Sprintf("could not determine whether platform is OpenShift: %s", err.Error())) + return err + } + tr.OpenShift = openshift + + if openshift && openShiftCertManager { + err := installOpenShiftCertManager(r) + if err != nil { + logError(r, fmt.Sprintf("failed to install cert-manager Operator for Red Hat OpenShift: %s", err.Error())) + return err + } + } + return nil +} + +func newCryostatCR(name string, namespace string, withIngress bool) *operatorv1beta1.Cryostat { + cr := &operatorv1beta1.Cryostat{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: operatorv1beta1.CryostatSpec{ + Minimal: false, + EnableCertManager: &[]bool{true}[0], + }, + } + + if withIngress { + pathType := netv1.PathTypePrefix + cr.Spec.NetworkOptions = &operatorv1beta1.NetworkConfigurationList{ + CoreConfig: &operatorv1beta1.NetworkConfiguration{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + }, + IngressSpec: &netv1.IngressSpec{ + TLS: []netv1.IngressTLS{{}}, + Rules: []netv1.IngressRule{ + { + Host: "testing.cryostat", + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + { + Path: "/", + PathType: &pathType, + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: name, + Port: netv1.ServiceBackendPort{ + Number: 8181, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + GrafanaConfig: &operatorv1beta1.NetworkConfiguration{ + Annotations: map[string]string{ + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + }, + IngressSpec: &netv1.IngressSpec{ + TLS: []netv1.IngressTLS{{}}, + Rules: []netv1.IngressRule{ + { + Host: "testing.cryostat-grafana", + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + { + Path: "/", + PathType: &pathType, + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: fmt.Sprintf("%s-grafana", name), + Port: netv1.ServiceBackendPort{ + Number: 3000, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + } + return cr +} + +func createAndWaitTillCryostatAvailable(cr *operatorv1beta1.Cryostat, resources *TestResources) (*operatorv1beta1.Cryostat, error) { + client := resources.Client + r := resources.TestResult + + cr, err := client.OperatorCRDs().Cryostats(cr.Namespace).Create(context.Background(), cr) + if err != nil { + logError(r, fmt.Sprintf("failed to create Cryostat CR: %s", err.Error())) + return nil, err + } + + // Poll the deployment until it becomes available or we timeout + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + err = waitForDeploymentAvailability(ctx, client, cr.Namespace, cr.Name, r) + if err != nil { + logError(r, fmt.Sprintf("Cryostat main deployment did not become available: %s", err.Error())) + return nil, err + } + + err = wait.PollImmediateUntilWithContext(ctx, time.Second, func(ctx context.Context) (done bool, err error) { + cr, err = client.OperatorCRDs().Cryostats(cr.Namespace).Get(ctx, cr.Name) + if err != nil { + return false, fmt.Errorf("failed to get Cryostat CR: %s", err.Error()) + } + if len(cr.Status.ApplicationURL) > 0 { + return true, nil + } + r.Log += "application URL is not yet available\n" + return false, nil + }) + if err != nil { + logError(r, fmt.Sprintf("application URL not found in CR: %s", err.Error())) + return nil, err + } + r.Log += fmt.Sprintf("application is available at %s\n", cr.Status.ApplicationURL) + + return cr, nil +} + +func waitTillCryostatReady(base *url.URL, resources *TestResources) error { + client := NewHttpClient() + r := resources.TestResult + + ctx, cancel := context.WithTimeout(context.Background(), testTimeout) + defer cancel() + + err := wait.PollImmediateUntilWithContext(ctx, time.Second, func(ctx context.Context) (done bool, err error) { + url := base.JoinPath("/health") + req, err := NewHttpRequest(ctx, http.MethodGet, url.String(), nil, make(http.Header)) + if err != nil { + return false, fmt.Errorf("failed to create a Cryostat REST request: %s", err.Error()) + } + req.Header.Add("Accept", "*/*") + + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if !StatusOK(resp.StatusCode) { + if resp.StatusCode == http.StatusServiceUnavailable { + r.Log += fmt.Sprintf("application is not yet reachable at %s\n", base.String()) + return false, nil // Try again + } + return false, fmt.Errorf("API request failed with status code %d: %s", resp.StatusCode, ReadError(resp)) + } + + health := &HealthResponse{} + err = ReadJSON(resp, health) + if err != nil { + return false, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + if err = health.Ready(); err != nil { + r.Log += fmt.Sprintf("application is not yet ready: %s\n", err.Error()) + return false, nil // Try again + } + + r.Log += fmt.Sprintf("application is ready at %s\n", base.String()) + return true, nil + }) + + return err +} + +func cleanupCryostat(r *scapiv1alpha3.TestResult, client *CryostatClientset, name string, namespace string) { + cr := &operatorv1beta1.Cryostat{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + ctx := context.Background() + err := client.OperatorCRDs().Cryostats(cr.Namespace).Delete(ctx, + cr.Name, &metav1.DeleteOptions{}) + if err != nil { + r.Log += fmt.Sprintf("failed to delete Cryostat: %s\n", err.Error()) + } +} diff --git a/internal/test/scorecard/openshift.go b/internal/test/scorecard/openshift.go index dee12c4d..247068a4 100644 --- a/internal/test/scorecard/openshift.go +++ b/internal/test/scorecard/openshift.go @@ -27,12 +27,15 @@ import ( ctrl "sigs.k8s.io/controller-runtime" configv1 "github.com/openshift/api/config/v1" + routev1 "github.com/openshift/api/route/v1" corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/discovery" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" ) @@ -202,3 +205,7 @@ func installOpenShiftCertManager(r *scapiv1alpha3.TestResult) error { return false, nil }) } + +func isOpenShift(client discovery.DiscoveryInterface) (bool, error) { + return discovery.IsResourceEnabled(client, routev1.GroupVersion.WithResource("routes")) +} diff --git a/internal/test/scorecard/tests.go b/internal/test/scorecard/tests.go index b2aab776..2860aa65 100644 --- a/internal/test/scorecard/tests.go +++ b/internal/test/scorecard/tests.go @@ -17,338 +17,195 @@ package scorecard import ( "context" "fmt" + "net/url" "time" - operatorv1beta1 "github.com/cryostatio/cryostat-operator/api/v1beta1" scapiv1alpha3 "github.com/operator-framework/api/pkg/apis/scorecard/v1alpha3" apimanifests "github.com/operator-framework/api/pkg/manifests" - - routev1 "github.com/openshift/api/route/v1" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - netv1 "k8s.io/api/networking/v1" - kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/discovery" - "k8s.io/client-go/kubernetes/scheme" ) const ( - OperatorInstallTestName string = "operator-install" - CryostatCRTestName string = "cryostat-cr" - operatorDeploymentName string = "cryostat-operator-controller-manager" - testTimeout time.Duration = time.Minute * 10 + OperatorInstallTestName string = "operator-install" + CryostatCRTestName string = "cryostat-cr" + CryostatRecordingTestName string = "cryostat-recording" ) // OperatorInstallTest checks that the operator installed correctly func OperatorInstallTest(bundle *apimanifests.Bundle, namespace string) scapiv1alpha3.TestResult { - r := scapiv1alpha3.TestResult{} - r.Name = OperatorInstallTestName - r.State = scapiv1alpha3.PassState - r.Errors = make([]string, 0) - r.Suggestions = make([]string, 0) + r := newEmptyTestResult(OperatorInstallTestName) // Create a new Kubernetes REST client for this test client, err := NewClientset() if err != nil { - return fail(r, fmt.Sprintf("failed to create client: %s", err.Error())) + return fail(*r, fmt.Sprintf("failed to create client: %s", err.Error())) } // Poll the deployment until it becomes available or we timeout ctx, cancel := context.WithTimeout(context.Background(), testTimeout) defer cancel() - err = waitForDeploymentAvailability(ctx, client, namespace, operatorDeploymentName, &r) + err = waitForDeploymentAvailability(ctx, client, namespace, operatorDeploymentName, r) if err != nil { - return fail(r, fmt.Sprintf("operator deployment did not become available: %s", err.Error())) + return fail(*r, fmt.Sprintf("operator deployment did not become available: %s", err.Error())) } - return r + return *r } // CryostatCRTest checks that the operator installs Cryostat in response to a Cryostat CR func CryostatCRTest(bundle *apimanifests.Bundle, namespace string, openShiftCertManager bool) scapiv1alpha3.TestResult { - r := scapiv1alpha3.TestResult{} - r.Name = CryostatCRTestName - r.State = scapiv1alpha3.PassState - r.Errors = make([]string, 0) - r.Suggestions = make([]string, 0) + tr := newTestResources(CryostatCRTestName) + r := tr.TestResult - // Create a new Kubernetes REST client for this test - client, err := NewClientset() + err := setupCRTestResources(tr, openShiftCertManager) if err != nil { - return fail(r, fmt.Sprintf("failed to create client: %s", err.Error())) + return fail(*r, fmt.Sprintf("failed to set up %s test: %s", CryostatCRTestName, err.Error())) } - defer cleanupCryostat(&r, client, namespace) - openshift, err := isOpenShift(client.DiscoveryClient) + // Create a default Cryostat CR + _, err = createAndWaitTillCryostatAvailable(newCryostatCR(CryostatCRTestName, namespace, !tr.OpenShift), tr) if err != nil { - return fail(r, fmt.Sprintf("could not determine whether platform is OpenShift: %s", err.Error())) + return fail(*r, fmt.Sprintf("%s test failed: %s", CryostatCRTestName, err.Error())) } + defer cleanupCryostat(r, tr.Client, CryostatCRTestName, namespace) - if openshift && openShiftCertManager { - err := installOpenShiftCertManager(&r) - if err != nil { - return fail(r, fmt.Sprintf("failed to install cert-manager Operator for Red Hat OpenShift: %s", err.Error())) - } - } + return *r +} - // Create a default Cryostat CR - cr := newCryostatCR(namespace, !openshift) +// TODO add a built in discovery test too +func CryostatRecordingTest(bundle *apimanifests.Bundle, namespace string, openShiftCertManager bool) scapiv1alpha3.TestResult { + tr := newTestResources(CryostatRecordingTestName) + r := tr.TestResult - ctx := context.Background() - cr, err = client.OperatorCRDs().Cryostats(namespace).Create(ctx, cr) + err := setupCRTestResources(tr, openShiftCertManager) if err != nil { - return fail(r, fmt.Sprintf("failed to create Cryostat CR: %s", err.Error())) + return fail(*r, fmt.Sprintf("failed to set up %s test: %s", CryostatRecordingTestName, err.Error())) } - // Poll the deployment until it becomes available or we timeout - ctx, cancel := context.WithTimeout(context.Background(), testTimeout) - defer cancel() - err = waitForDeploymentAvailability(ctx, client, cr.Namespace, cr.Name, &r) + // Create a default Cryostat CR + cr, err := createAndWaitTillCryostatAvailable(newCryostatCR(CryostatRecordingTestName, namespace, !tr.OpenShift), tr) if err != nil { - return fail(r, fmt.Sprintf("Cryostat main deployment did not become available: %s", err.Error())) + return fail(*r, fmt.Sprintf("failed to determine application URL: %s", err.Error())) } + defer cleanupCryostat(r, tr.Client, CryostatRecordingTestName, namespace) - err = wait.PollImmediateUntilWithContext(ctx, time.Second, func(ctx context.Context) (done bool, err error) { - cr, err = client.OperatorCRDs().Cryostats(namespace).Get(ctx, cr.Name) - if err != nil { - return false, fmt.Errorf("failed to get Cryostat CR: %s", err.Error()) - } - if len(cr.Status.ApplicationURL) > 0 { - return true, nil - } - r.Log += "Application URL is not yet available\n" - return false, nil - }) + base, err := url.Parse(cr.Status.ApplicationURL) if err != nil { - return fail(r, fmt.Sprintf("Application URL not found in CR: %s", err.Error())) + return fail(*r, fmt.Sprintf("application URL is invalid: %s", err.Error())) } - r.Log += fmt.Sprintf("Application is ready at %s\n", cr.Status.ApplicationURL) - - return r -} -func waitForDeploymentAvailability(ctx context.Context, client *CryostatClientset, namespace string, - name string, r *scapiv1alpha3.TestResult) error { - err := wait.PollImmediateUntilWithContext(ctx, time.Second, func(ctx context.Context) (done bool, err error) { - deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - if kerrors.IsNotFound(err) { - r.Log += fmt.Sprintf("deployment %s is not yet found\n", name) - return false, nil // Retry - } - return false, fmt.Errorf("failed to get deployment: %s", err.Error()) - } - // Check for Available condition - for _, condition := range deploy.Status.Conditions { - if condition.Type == appsv1.DeploymentAvailable && - condition.Status == corev1.ConditionTrue { - r.Log += fmt.Sprintf("deployment %s is available\n", deploy.Name) - return true, nil - } - if condition.Type == appsv1.DeploymentReplicaFailure && - condition.Status == corev1.ConditionTrue { - r.Log += fmt.Sprintf("deployment %s is failing, %s: %s\n", deploy.Name, - condition.Reason, condition.Message) - } - } - r.Log += fmt.Sprintf("deployment %s is not yet available\n", deploy.Name) - return false, nil - }) + err = waitTillCryostatReady(base, tr) if err != nil { - logErr := logErrors(r, client, namespace, name) - if logErr != nil { - r.Log += fmt.Sprintf("failed to look up deployment errors: %s\n", logErr.Error()) - } + return fail(*r, fmt.Sprintf("failed to reach the application: %s", err.Error())) } - return err -} -func fail(r scapiv1alpha3.TestResult, message string) scapiv1alpha3.TestResult { - r.State = scapiv1alpha3.FailState - r.Errors = append(r.Errors, message) - return r -} + apiClient := NewCryostatRESTClientset(base) -func cleanupCryostat(r *scapiv1alpha3.TestResult, client *CryostatClientset, namespace string) { - cr := &operatorv1beta1.Cryostat{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cryostat-cr-test", - Namespace: namespace, - }, + // Create a custom target for test + targetOptions := &Target{ + ConnectUrl: "service:jmx:rmi:///jndi/rmi://localhost:0/jmxrmi", + Alias: "customTarget", } - ctx := context.Background() - err := client.OperatorCRDs().Cryostats(cr.Namespace).Delete(ctx, - cr.Name, &metav1.DeleteOptions{}) + target, err := apiClient.Targets().Create(context.Background(), targetOptions) if err != nil { - r.Log += fmt.Sprintf("failed to delete Cryostat: %s\n", err.Error()) + return fail(*r, fmt.Sprintf("failed to create a target: %s", err.Error())) } -} + r.Log += fmt.Sprintf("created a custom target: %+v\n", target) + connectUrl := target.ConnectUrl -func logErrors(r *scapiv1alpha3.TestResult, client *CryostatClientset, namespace string, name string) error { - ctx := context.Background() - deploy, err := client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + jmxSecretName := CryostatRecordingTestName + "-jmx-auth" + secret, err := tr.Client.CoreV1().Secrets(namespace).Get(context.Background(), jmxSecretName, metav1.GetOptions{}) if err != nil { - return err + return fail(*r, fmt.Sprintf("failed to get jmx credentials: %s", err.Error())) } - // Log deployment conditions and events - r.Log += fmt.Sprintf("deployment %s conditions:\n", deploy.Name) - for _, condition := range deploy.Status.Conditions { - r.Log += fmt.Sprintf("\t%s == %s, %s: %s\n", condition.Type, - condition.Status, condition.Reason, condition.Message) + + credential := &Credential{ + UserName: string(secret.Data["CRYOSTAT_RJMX_USER"]), + Password: string(secret.Data["CRYOSTAT_RJMX_PASS"]), + MatchExpression: fmt.Sprintf("target.alias==\"%s\"", target.Alias), } - r.Log += fmt.Sprintf("deployment %s warning events:\n", deploy.Name) - err = logEvents(r, client, namespace, scheme.Scheme, deploy) + err = apiClient.CredentialClient.Create(context.Background(), credential) if err != nil { - return err + return fail(*r, fmt.Sprintf("failed to create stored credential: %s", err.Error())) } + r.Log += fmt.Sprintf("created stored credential with match expression: %s\n", credential.MatchExpression) + + // Wait for Cryostat to update the discovery tree + time.Sleep(2 * time.Second) - // Look up replica sets for deployment and log conditions and events - selector, err := metav1.LabelSelectorAsSelector(deploy.Spec.Selector) + // Create a recording + options := &RecordingCreateOptions{ + RecordingName: "scorecard_test_rec", + Events: "template=ALL", + Duration: 0, // Continuous + ToDisk: true, + MaxSize: 0, + MaxAge: 0, + } + rec, err := apiClient.Recordings().Create(context.Background(), connectUrl, options) if err != nil { - return err + return fail(*r, fmt.Sprintf("failed to create a recording: %s", err.Error())) } - replicaSets, err := client.AppsV1().ReplicaSets(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: selector.String(), - }) + r.Log += fmt.Sprintf("created a recording: %+v\n", rec) + + // View the current recording list after creating one + recs, err := apiClient.Recordings().List(context.Background(), connectUrl) if err != nil { - return err + return fail(*r, fmt.Sprintf("failed to list recordings: %s", err.Error())) } - for _, rs := range replicaSets.Items { - r.Log += fmt.Sprintf("replica set %s conditions:\n", rs.Name) - for _, condition := range rs.Status.Conditions { - r.Log += fmt.Sprintf("\t%s == %s, %s: %s\n", condition.Type, condition.Status, - condition.Reason, condition.Message) - } - r.Log += fmt.Sprintf("replica set %s warning events:\n", rs.Name) - err = logEvents(r, client, namespace, scheme.Scheme, &rs) - if err != nil { - return err - } + r.Log += fmt.Sprintf("current list of recordings: %+v\n", recs) + + // Allow the recording to run for 10s + time.Sleep(30 * time.Second) + + // Archive the recording + archiveName, err := apiClient.Recordings().Archive(context.Background(), connectUrl, rec.Name) + if err != nil { + return fail(*r, fmt.Sprintf("failed to archive the recording: %s", err.Error())) } + r.Log += fmt.Sprintf("archived the recording %s at: %s\n", rec.Name, archiveName) - // Look up pods for deployment and log conditions and events - pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: selector.String(), - }) + archives, err := apiClient.Recordings().ListArchives(context.Background(), connectUrl) if err != nil { - return err + return fail(*r, fmt.Sprintf("failed to list archives: %s", err.Error())) } - for _, pod := range pods.Items { - r.Log += fmt.Sprintf("pod %s phase: %s\n", pod.Name, pod.Status.Phase) - r.Log += fmt.Sprintf("pod %s conditions:\n", pod.Name) - for _, condition := range pod.Status.Conditions { - r.Log += fmt.Sprintf("\t%s == %s, %s: %s\n", condition.Type, condition.Status, - condition.Reason, condition.Message) - } - r.Log += fmt.Sprintf("pod %s warning events:\n", pod.Name) - err = logEvents(r, client, namespace, scheme.Scheme, &pod) - if err != nil { - return err - } + r.Log += fmt.Sprintf("current list of archives: %+v\n", archives) + + report, err := apiClient.Recordings().GenerateReport(context.Background(), connectUrl, rec) + if err != nil { + return fail(*r, fmt.Sprintf("failed to generate report for the recording: %s", err.Error())) } - return nil -} + r.Log += fmt.Sprintf("generated report for the recording %s: %+v\n", rec.Name, report) -func logEvents(r *scapiv1alpha3.TestResult, client *CryostatClientset, namespace string, - scheme *runtime.Scheme, obj runtime.Object) error { - events, err := client.CoreV1().Events(namespace).Search(scheme, obj) + // Stop the recording + err = apiClient.Recordings().Stop(context.Background(), connectUrl, rec.Name) if err != nil { - return err + return fail(*r, fmt.Sprintf("failed to stop the recording %s: %s", rec.Name, err.Error())) } - for _, event := range events.Items { - if event.Type == corev1.EventTypeWarning { - r.Log += fmt.Sprintf("\t%s: %s\n", event.Reason, event.Message) - } + // Get the recording to verify its state + rec, err = apiClient.Recordings().Get(context.Background(), connectUrl, rec.Name) + if err != nil { + return fail(*r, fmt.Sprintf("failed to get the recordings: %s", err.Error())) } - return nil -} + if rec.State != "STOPPED" { + return fail(*r, fmt.Sprintf("recording %s failed to stop: %s", rec.Name, err.Error())) + } + r.Log += fmt.Sprintf("stopped the recording: %s\n", rec.Name) -func newCryostatCR(namespace string, withIngress bool) *operatorv1beta1.Cryostat { - cr := &operatorv1beta1.Cryostat{ - ObjectMeta: metav1.ObjectMeta{ - Name: "cryostat-cr-test", - Namespace: namespace, - }, - Spec: operatorv1beta1.CryostatSpec{ - Minimal: false, - EnableCertManager: &[]bool{true}[0], - }, + // Delete the recording + err = apiClient.Recordings().Delete(context.Background(), connectUrl, rec.Name) + if err != nil { + return fail(*r, fmt.Sprintf("failed to delete the recording %s: %s", rec.Name, err.Error())) } + r.Log += fmt.Sprintf("deleted the recording: %s\n", rec.Name) - if withIngress { - pathType := netv1.PathTypePrefix - cr.Spec.NetworkOptions = &operatorv1beta1.NetworkConfigurationList{ - CoreConfig: &operatorv1beta1.NetworkConfiguration{ - Annotations: map[string]string{ - "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", - }, - IngressSpec: &netv1.IngressSpec{ - TLS: []netv1.IngressTLS{{}}, - Rules: []netv1.IngressRule{ - { - Host: "testing.cryostat", - IngressRuleValue: netv1.IngressRuleValue{ - HTTP: &netv1.HTTPIngressRuleValue{ - Paths: []netv1.HTTPIngressPath{ - { - Path: "/", - PathType: &pathType, - Backend: netv1.IngressBackend{ - Service: &netv1.IngressServiceBackend{ - Name: "cryostat-cr-test", - Port: netv1.ServiceBackendPort{ - Number: 8181, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - GrafanaConfig: &operatorv1beta1.NetworkConfiguration{ - Annotations: map[string]string{ - "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", - }, - IngressSpec: &netv1.IngressSpec{ - TLS: []netv1.IngressTLS{{}}, - Rules: []netv1.IngressRule{ - { - Host: "testing.cryostat-grafana", - IngressRuleValue: netv1.IngressRuleValue{ - HTTP: &netv1.HTTPIngressRuleValue{ - Paths: []netv1.HTTPIngressPath{ - { - Path: "/", - PathType: &pathType, - Backend: netv1.IngressBackend{ - Service: &netv1.IngressServiceBackend{ - Name: "cryostat-cr-test-grafana", - Port: netv1.ServiceBackendPort{ - Number: 3000, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } + // View the current recording list after deleting one + recs, err = apiClient.Recordings().List(context.Background(), connectUrl) + if err != nil { + return fail(*r, fmt.Sprintf("failed to list recordings: %s", err.Error())) } - return cr -} + r.Log += fmt.Sprintf("current list of recordings: %+v\n", recs) -func isOpenShift(client discovery.DiscoveryInterface) (bool, error) { - return discovery.IsResourceEnabled(client, routev1.GroupVersion.WithResource("routes")) + return *r } diff --git a/internal/test/scorecard/types.go b/internal/test/scorecard/types.go new file mode 100644 index 00000000..7e34ff4a --- /dev/null +++ b/internal/test/scorecard/types.go @@ -0,0 +1,146 @@ +// Copyright The Cryostat 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 scorecard + +import ( + "encoding/json" + "errors" + "net/url" + "strconv" +) + +type HealthResponse struct { + CryostatVersion string `json:"cryostatVersion"` + DashboardAvailable bool `json:"dashboardAvailable"` + DashboardConfigured bool `json:"dashboardConfigured"` + DataSourceAvailable bool `json:"datasourceAvailable"` + DataSourceConfigured bool `json:"datasourceConfigured"` + ReportsAvailable bool `json:"reportsAvailable"` + ReportsConfigured bool `json:"reportsConfigured"` +} + +func (health *HealthResponse) Ready() error { + if !health.DashboardAvailable { + return errors.New("dashboard is not available") + } + + if !health.DataSourceAvailable { + return errors.New("datasource is not available") + } + + if !health.ReportsAvailable { + return errors.New("report is not available") + } + return nil +} + +type RecordingCreateOptions struct { + RecordingName string + Events string + Duration int32 + ToDisk bool + MaxSize int32 + MaxAge int32 +} + +func (opts *RecordingCreateOptions) ToFormData() string { + formData := &url.Values{} + + formData.Add("recordingName", opts.RecordingName) + formData.Add("events", opts.Events) + formData.Add("duration", strconv.Itoa(int(opts.Duration))) + formData.Add("toDisk", strconv.FormatBool(opts.ToDisk)) + formData.Add("maxSize", strconv.Itoa(int(opts.MaxSize))) + formData.Add("maxAge", strconv.Itoa(int(opts.MaxAge))) + + return formData.Encode() +} + +type Credential struct { + UserName string + Password string + MatchExpression string +} + +func (cred *Credential) ToFormData() string { + formData := &url.Values{} + + formData.Add("username", cred.UserName) + formData.Add("password", cred.Password) + formData.Add("matchExpression", cred.MatchExpression) + + return formData.Encode() +} + +type Recording struct { + DownloadURL string `json:"downloadUrl"` + ReportURL string `json:"reportUrl"` + Id uint32 `json:"id"` + Name string `json:"name"` + StartTime uint64 `json:"startTime"` + State string `json:"state"` + Duration int32 `json:"duration"` + Continuous bool `json:"continuous"` + ToDisk bool `json:"toDisk"` + MaxSize int32 `json:"maxSize"` + MaxAge int32 `json:"maxAge"` +} + +type Archive struct { + Name string + DownloadUrl string + ReportUrl string + Metadata struct { + Labels map[string]interface{} + } + Size int32 +} + +type CustomTargetResponse struct { + Data struct { + Result *Target `json:"result"` + } `json:"data"` +} + +type Target struct { + ConnectUrl string `json:"connectUrl"` + Alias string `json:"alias,omitempty"` +} + +func (target *Target) ToFormData() string { + formData := &url.Values{} + + formData.Add("connectUrl", target.ConnectUrl) + formData.Add("alias", target.Alias) + + return formData.Encode() +} + +type GraphQLQuery struct { + Query string `json:"query"` + Variables map[string]string `json:"variables,omitempty"` +} + +func (query *GraphQLQuery) ToJSON() ([]byte, error) { + return json.Marshal(query) +} + +type ArchiveGraphQLResponse struct { + Data struct { + ArchivedRecordings struct { + Data []Archive `json:"data"` + } `json:"archivedRecordings"` + } `json:"data"` +}