Skip to content

Commit e9b48df

Browse files
authored
commands/.../scorecard: improve crd resources check (#1027)
**Description of the change:** Changes the crd resources check so that the command actually verifies what resources the operator touches. It also makes a separate function to get the proxy logs so that multiple tests can more easily use proxy logs. **Motivation for the change:** This makes the test more accurate and can tell the user if they missed a resource that the operator interacts with.
1 parent f409157 commit e9b48df

File tree

7 files changed

+178
-46
lines changed

7 files changed

+178
-46
lines changed

commands/operator-sdk/cmd/scorecard/basic_tests.go

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
package scorecard
1616

1717
import (
18-
"bytes"
1918
"context"
2019
"encoding/json"
2120
"fmt"
@@ -24,17 +23,10 @@ import (
2423
"strings"
2524
"time"
2625

27-
"github.com/operator-framework/operator-sdk/internal/util/fileutil"
28-
29-
log "github.com/sirupsen/logrus"
3026
"github.com/spf13/viper"
31-
appsv1 "k8s.io/api/apps/v1"
32-
v1 "k8s.io/api/core/v1"
3327
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
34-
"k8s.io/apimachinery/pkg/labels"
3528
"k8s.io/apimachinery/pkg/types"
3629
"k8s.io/apimachinery/pkg/util/wait"
37-
"k8s.io/client-go/kubernetes"
3830
"sigs.k8s.io/controller-runtime/pkg/client"
3931
)
4032

@@ -172,38 +164,10 @@ func modifySpecAndCheck(specMap map[string]interface{}, obj *unstructured.Unstru
172164
// and/or POST requests to the API server, which should mean that it is creating or modifying resources.
173165
func writingIntoCRsHasEffect(obj *unstructured.Unstructured) (string, error) {
174166
test := scorecardTest{testType: basicOperator, name: "Writing into CRs has an effect", maximumPoints: 1}
175-
kubeclient, err := kubernetes.NewForConfig(kubeconfig)
176-
if err != nil {
177-
return "", fmt.Errorf("failed to create kubeclient: %v", err)
178-
}
179-
dep := &appsv1.Deployment{}
180-
err = runtimeClient.Get(context.TODO(), types.NamespacedName{Namespace: obj.GetNamespace(), Name: deploymentName}, dep)
181-
if err != nil {
182-
return "", fmt.Errorf("failed to get newly created operator deployment: %v", err)
183-
}
184-
set := labels.Set(dep.Spec.Selector.MatchLabels)
185-
pods := &v1.PodList{}
186-
err = runtimeClient.List(context.TODO(), &client.ListOptions{LabelSelector: set.AsSelector()}, pods)
187-
if err != nil {
188-
return "", fmt.Errorf("failed to get list of pods in deployment: %v", err)
189-
}
190-
proxyPod = &pods.Items[0]
191-
req := kubeclient.CoreV1().Pods(obj.GetNamespace()).GetLogs(proxyPod.GetName(), &v1.PodLogOptions{Container: "scorecard-proxy"})
192-
readCloser, err := req.Stream()
193-
if err != nil {
194-
return "", fmt.Errorf("failed to get logs: %v", err)
195-
}
196-
defer func() {
197-
if err := readCloser.Close(); err != nil && !fileutil.IsClosedError(err) {
198-
log.Errorf("Failed to close pod log reader: (%v)", err)
199-
}
200-
}()
201-
buf := new(bytes.Buffer)
202-
_, err = buf.ReadFrom(readCloser)
167+
logs, err := getProxyLogs()
203168
if err != nil {
204-
return "", fmt.Errorf("test failed and failed to read pod logs: %v", err)
169+
return "", err
205170
}
206-
logs := buf.String()
207171
msgMap := make(map[string]interface{})
208172
for _, msg := range strings.Split(logs, "\n") {
209173
if err := json.Unmarshal([]byte(msg), &msgMap); err != nil {
@@ -222,5 +186,5 @@ func writingIntoCRsHasEffect(obj *unstructured.Unstructured) (string, error) {
222186
if test.earnedPoints != 1 {
223187
scSuggestions = append(scSuggestions, "The operator should write into objects to update state. No PUT or POST requests from you operator were recorded by the scorecard.")
224188
}
225-
return buf.String(), nil
189+
return logs, nil
226190
}

commands/operator-sdk/cmd/scorecard/olm_tests.go

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package scorecard
1616

1717
import (
1818
"context"
19+
"encoding/json"
1920
"fmt"
2021
"strings"
2122

@@ -25,6 +26,7 @@ import (
2526
log "github.com/sirupsen/logrus"
2627
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
2728
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
29+
"k8s.io/apimachinery/pkg/runtime/schema"
2830
"k8s.io/apimachinery/pkg/types"
2931
"sigs.k8s.io/controller-runtime/pkg/client"
3032
)
@@ -110,12 +112,38 @@ func crdsHaveValidation(crdsDir string, runtimeClient client.Client, obj *unstru
110112
}
111113

112114
// crdsHaveResources checks to make sure that all owned CRDs have resources listed
113-
func crdsHaveResources(csv *olmapiv1alpha1.ClusterServiceVersion) {
115+
// Until there is full support for multiple CRs, we will only be able to check the
116+
// actual used resources of one CRD, but only the existence of a resources section
117+
// for other CRDs
118+
func crdsHaveResources(obj *unstructured.Unstructured, csv *olmapiv1alpha1.ClusterServiceVersion) {
114119
test := scorecardTest{testType: olmIntegration, name: "Owned CRDs have resources listed"}
115120
for _, crd := range csv.Spec.CustomResourceDefinitions.Owned {
116121
test.maximumPoints++
117-
if len(crd.Resources) > 0 {
118-
test.earnedPoints++
122+
gvk := obj.GroupVersionKind()
123+
if strings.EqualFold(crd.Version, gvk.Version) && matchKind(gvk.Kind, crd.Kind) {
124+
resources, err := getUsedResources()
125+
if err != nil {
126+
log.Warningf("getUsedResource failed: %v", err)
127+
}
128+
allResourcesListed := true
129+
for _, resource := range resources {
130+
foundResource := false
131+
for _, listedResource := range crd.Resources {
132+
if matchKind(resource.Kind, listedResource.Kind) && strings.EqualFold(resource.Version, listedResource.Version) {
133+
foundResource = true
134+
}
135+
}
136+
if foundResource == false {
137+
allResourcesListed = false
138+
}
139+
}
140+
if allResourcesListed {
141+
test.earnedPoints++
142+
}
143+
} else {
144+
if len(crd.Resources) > 0 {
145+
test.earnedPoints++
146+
}
119147
}
120148
}
121149
scTests = append(scTests, test)
@@ -124,6 +152,91 @@ func crdsHaveResources(csv *olmapiv1alpha1.ClusterServiceVersion) {
124152
}
125153
}
126154

155+
func getUsedResources() ([]schema.GroupVersionKind, error) {
156+
logs, err := getProxyLogs()
157+
if err != nil {
158+
return nil, err
159+
}
160+
resources := map[schema.GroupVersionKind]bool{}
161+
for _, line := range strings.Split(logs, "\n") {
162+
logMap := make(map[string]interface{})
163+
err := json.Unmarshal([]byte(line), &logMap)
164+
if err != nil {
165+
// it is very common to get "unexpected end of JSON input", so we'll leave this at the debug level
166+
log.Debugf("could not unmarshal line: %v", err)
167+
continue
168+
}
169+
/*
170+
There are 6 formats a resource uri can have:
171+
Cluster-Scoped:
172+
Collection: /apis/GROUP/VERSION/KIND
173+
Individual: /apis/GROUP/VERSION/KIND/NAME
174+
Core: /api/v1/KIND
175+
Namespaces:
176+
All Namespaces: /apis/GROUP/VERSION/KIND (same as cluster collection)
177+
Collection in Namespace: /apis/GROUP/VERSION/namespaces/NAMESPACE/KIND
178+
Individual: /apis/GROUP/VERSION/namespaces/NAMESPACE/KIND/NAME
179+
Core: /api/v1/namespaces/NAMESPACE/KIND
180+
181+
These urls are also often appended with options, which are denoted by the '?' symbol
182+
*/
183+
if msg, ok := logMap["msg"].(string); !ok || msg != "Request Info" {
184+
continue
185+
}
186+
uri, ok := logMap["uri"].(string)
187+
if !ok {
188+
log.Warn("URI type is not string")
189+
continue
190+
}
191+
removedOptions := strings.Split(uri, "?")[0]
192+
splitURI := strings.Split(removedOptions, "/")
193+
// first string is empty string ""
194+
if len(splitURI) < 2 {
195+
log.Warnf("Invalid URI: \"%s\"", uri)
196+
continue
197+
}
198+
splitURI = splitURI[1:]
199+
switch len(splitURI) {
200+
case 3:
201+
if splitURI[0] == "api" {
202+
resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[2]}] = true
203+
break
204+
} else if splitURI[0] == "apis" {
205+
// this situation happens when the client enumerates the available resources of the server
206+
// Example: "/apis/apps/v1?timeout=32s"
207+
break
208+
}
209+
log.Warnf("Invalid URI: \"%s\"", uri)
210+
case 4:
211+
if splitURI[0] == "apis" {
212+
resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[3]}] = true
213+
break
214+
}
215+
log.Warnf("Invalid URI: \"%s\"", uri)
216+
case 5:
217+
if splitURI[0] == "api" {
218+
resources[schema.GroupVersionKind{Version: splitURI[1], Kind: splitURI[4]}] = true
219+
break
220+
} else if splitURI[0] == "apis" {
221+
resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[3]}] = true
222+
break
223+
}
224+
log.Warnf("Invalid URI: \"%s\"", uri)
225+
case 6, 7:
226+
if splitURI[0] == "apis" {
227+
resources[schema.GroupVersionKind{Group: splitURI[1], Version: splitURI[2], Kind: splitURI[5]}] = true
228+
break
229+
}
230+
log.Warnf("Invalid URI: \"%s\"", uri)
231+
}
232+
}
233+
var resourcesArr []schema.GroupVersionKind
234+
for gvk := range resources {
235+
resourcesArr = append(resourcesArr, gvk)
236+
}
237+
return resourcesArr, nil
238+
}
239+
127240
// annotationsContainExamples makes sure that the CSVs list at least 1 example for the CR
128241
func annotationsContainExamples(csv *olmapiv1alpha1.ClusterServiceVersion) {
129242
test := scorecardTest{testType: olmIntegration, name: "CRs have at least 1 example", maximumPoints: 1}

commands/operator-sdk/cmd/scorecard/resource_handler.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
proxyConf "github.com/operator-framework/operator-sdk/pkg/ansible/proxy/kubeconfig"
2828
"github.com/operator-framework/operator-sdk/pkg/k8sutil"
2929
"github.com/spf13/viper"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
3031

3132
"github.com/ghodss/yaml"
3233
log "github.com/sirupsen/logrus"
@@ -35,9 +36,11 @@ import (
3536
apierrors "k8s.io/apimachinery/pkg/api/errors"
3637
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3738
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
39+
"k8s.io/apimachinery/pkg/labels"
3840
"k8s.io/apimachinery/pkg/runtime"
3941
"k8s.io/apimachinery/pkg/types"
4042
"k8s.io/apimachinery/pkg/util/wait"
43+
"k8s.io/client-go/kubernetes"
4144
)
4245

4346
// yamlToUnstructured decodes a yaml file into an unstructured object
@@ -126,6 +129,37 @@ func createFromYAMLFile(yamlPath string) error {
126129
}
127130
}
128131
addResourceCleanup(obj, types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()})
132+
if obj.GetKind() == "Deployment" {
133+
dep := &appsv1.Deployment{}
134+
err = runtimeClient.Get(context.TODO(), types.NamespacedName{Namespace: viper.GetString(NamespaceOpt), Name: deploymentName}, dep)
135+
if err != nil {
136+
return fmt.Errorf("failed to get newly created deployment: %v", err)
137+
}
138+
set := labels.Set(dep.Spec.Selector.MatchLabels)
139+
// in some cases, the pod from the old deployment will be picked up instead of the new one
140+
err = wait.PollImmediate(time.Second*1, time.Second*60, func() (bool, error) {
141+
pods := &v1.PodList{}
142+
err = runtimeClient.List(context.TODO(), &client.ListOptions{LabelSelector: set.AsSelector()}, pods)
143+
if err != nil {
144+
return false, fmt.Errorf("failed to get list of pods in deployment: %v", err)
145+
}
146+
// make sure the pods exist
147+
// there should only be 1 pod per deployment
148+
if len(pods.Items) == 1 {
149+
// if the pod has a deletion timestamp, it is the old pod; wait for pod with no deletion timestamp
150+
if pods.Items[0].GetDeletionTimestamp() == nil {
151+
proxyPod = &pods.Items[0]
152+
return true, nil
153+
}
154+
} else {
155+
log.Debug("Operator deployment has more than 1 pod")
156+
}
157+
return false, nil
158+
})
159+
if err != nil {
160+
return fmt.Errorf("failed to get proxyPod: %s", err)
161+
}
162+
}
129163
}
130164
if err := scanner.Err(); err != nil {
131165
return fmt.Errorf("failed to scan %s: (%v)", yamlPath, err)
@@ -299,3 +333,23 @@ func addResourceCleanup(obj runtime.Object, key types.NamespacedName) {
299333
return nil
300334
})
301335
}
336+
337+
func getProxyLogs() (string, error) {
338+
// need a standard kubeclient for pod logs
339+
kubeclient, err := kubernetes.NewForConfig(kubeconfig)
340+
if err != nil {
341+
return "", fmt.Errorf("failed to create kubeclient: %v", err)
342+
}
343+
req := kubeclient.CoreV1().Pods(proxyPod.GetNamespace()).GetLogs(proxyPod.GetName(), &v1.PodLogOptions{Container: "scorecard-proxy"})
344+
readCloser, err := req.Stream()
345+
if err != nil {
346+
return "", fmt.Errorf("failed to get logs: %v", err)
347+
}
348+
defer readCloser.Close()
349+
buf := new(bytes.Buffer)
350+
_, err = buf.ReadFrom(readCloser)
351+
if err != nil {
352+
return "", fmt.Errorf("test failed and failed to read pod logs: %v", err)
353+
}
354+
return buf.String(), nil
355+
}

commands/operator-sdk/cmd/scorecard/scorecard.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ func ScorecardTests(cmd *cobra.Command, args []string) error {
241241
return err
242242
}
243243
fmt.Println("Checking for CRD resources")
244-
crdsHaveResources(csv)
244+
crdsHaveResources(obj, csv)
245245
fmt.Println("Checking for existence of example CRs")
246246
annotationsContainExamples(csv)
247247
fmt.Println("Checking spec descriptors")

hack/tests/scorecard-subcommand.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ commandoutput="$(operator-sdk scorecard \
1818
--proxy-image "$DEST_IMAGE" \
1919
--proxy-pull-policy Never \
2020
2>&1)"
21-
echo $commandoutput | grep "Total Score: 6/8 points"
21+
echo $commandoutput | grep "Total Score: 5/8 points"
2222

2323
# test config file
2424
commandoutput2="$(operator-sdk scorecard \
2525
--proxy-image "$DEST_IMAGE" \
2626
--config "$CONFIG_PATH")"
27-
echo $commandoutput2 | grep "Total Score: 6/8 points"
27+
echo $commandoutput2 | grep "Total Score: 5/8 points"
2828
popd

test/test-framework/.test-osdk-scorecard.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ init-timeout: 60
33
csv-path: "deploy/memcachedoperator.0.0.2.csv.yaml"
44
proxy-image: "scorecard-proxy"
55
proxy-pull-policy: "Never"
6+
verbose: true

test/test-framework/deploy/memcachedoperator.0.0.2.csv.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ spec:
5858
version: v1alpha1
5959
resources:
6060
- kind: Deployment
61-
version: v1beta2
61+
version: v1
6262
- kind: ReplicaSet
6363
version: v1beta2
6464
- kind: Pod

0 commit comments

Comments
 (0)