Skip to content

Commit

Permalink
Feat: Support for named resources (#253)
Browse files Browse the repository at this point in the history
* Modification of GetResourcesDynamically for named resources

* Feat: add named resource support for kubernetes resourceRule

* Cleanup appends and logging

* Update to single resource for named collection

* Feat: update resource and test

* Adding documentation to support Kubernetes domain

* Update OPA provider docs as they are outdated
  • Loading branch information
brandtkeller authored Mar 6, 2024
1 parent f56e411 commit 48a1cdd
Show file tree
Hide file tree
Showing 8 changed files with 242 additions and 67 deletions.
98 changes: 98 additions & 0 deletions docs/kubernetes-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Kubernetes Domain

The Kubernetes domain provides Lula with a common interface for data collection of Kubernetes artifacts for use across many Lula Providers.

## Payload Expectation

The validation performed when using the Kubernetes domain is as follows:

```yaml
resources:
- name: podsvt # Required - Identifier for use in the rego below
resourceRule: # Required - resource selection criteria, at least one resource rule is required
Name: # Optional - Used to retrieve a specific resource in a single namespace
Group: # Required - empty or "" for core group
Version: v1 # Required - Version of resource
Resource: pods # Required - Resource type
Namespaces: [validation-test] # Required - Namespaces to validate the above resources in. Empty or "" for all namespace pr non-namespaced resources
```
> [!Tip]
> Lula supports eventual-consistency through use of an optional `wait` field.

```yaml
wait:
condition: Ready
kind: pod/test-pod-wait
namespace: validation-test
timeout: 30s
resources:
- name: podsvt
resourceRule:
Group:
Version: v1
Resource: pods
Namespaces: [validation-test]
```

## Lists vs Named Resource

When Lula retrieves all targeted resources (bounded by namespace when applicable), the payload is a list of resources. When a resource Name is specified - with a target Namespace - the payload will be a single object.

### Example

Let's get all pods in the `validation-test` namespace and evaluate them with the OPA provider:
```yaml
target:
provider: opa
domain: kubernetes
payload:
resources:
- name: podsvt
resourceRule:
Group:
Version: v1
Resource: pods
Namespaces: [validation-test]
rego: |
package validate
import future.keywords.every
validate {
every pod in input.podsvt {
podLabel := pod.metadata.labels.foo
podLabel == "bar"
}
}
```

> [!IMPORTANT]
> Note how the payload contains a list of items that can be iterated over. The `podsvt` field is the name of the field in the payload that contains the list of items.

Now let's retrieve a single pod from the `validation-test` namespace:

```yaml
target:
provider: opa
domain: kubernetes
payload:
resources:
- name: podvt
resourceRule:
Name: test-pod-label
Group:
Version: v1
Resource: pods
Namespaces: [validation-test]
rego: |
package validate
validate {
podLabel := input.podvt.metadata.labels.foo
podLabel == "bar"
}
```

> [!IMPORTANT]
> Note how the payload now contains a single object called `podvt`. This is the name of the resource that is being validated.
41 changes: 25 additions & 16 deletions docs/opa-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,34 @@

The OPA provider provides Lula with the capability to evaluate the `domain` in target against a rego policy.

# Payload Expectation
## Payload Expectation

The validation performed should be in the form of provider, domain, and payload.

Example:
```yaml
- provider: "opa"
domain: "kubernetes"
payload:
resourceRules: # Mandatory, resource selection criteria, at least one resource rule is required
- Group: # empty or "" for core group
Version: v1 # Version of resource
Resource: pods # Resource type
Namespaces: [validation-test] # Namespaces to validate the above resources in. Empty or "" for all namespaces or non-namespaced resources
rego: |
package validate
validate {
input.kind == "Pod"
podLabel := input.metadata.labels.foo
target:
provider: opa
domain: kubernetes
payload:
resources:
- name: podsvt
resourceRule:
Group:
Version: v1
Resource: pods
Namespaces: [validation-test]
rego: | # Required - Rego policy used for data validation
package validate # Required - Package name
import future.keywords.every # Optional - Any imported keywords
validate { # Required - Rule Name for evaluation - "validate" is the only supported rule
every pod in input.podsvt {
podLabel := pod.metadata.labels.foo
podLabel == "bar"
}
}
```
Expand Down Expand Up @@ -68,4 +74,7 @@ rego: |
validate {
foolabel
}
```
```

> [!IMPORTANT]
> `package validate` and `validate` are required package and rule for Lula use currently.
3 changes: 1 addition & 2 deletions src/cmd/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -203,7 +202,7 @@ func ValidateOnCompDef(compDef oscalTypes_1_1_2.ComponentDefinition) (map[string

observation.RelevantEvidence = []oscalTypes_1_1_2.RelevantEvidence{
{
Description: fmt.Sprintf("Result: %s - Passing Resources: %s - Failing Resources %s\n", result.State, strconv.Itoa(result.Passing), strconv.Itoa(result.Failing)),
Description: fmt.Sprintf("Result: %s\n", result.State),
},
}

Expand Down
100 changes: 60 additions & 40 deletions src/pkg/common/kubernetes/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,74 +23,82 @@ func QueryCluster(ctx context.Context, resources []types.Resource) (map[string]i
collections := make(map[string]interface{}, 0)

for _, resource := range resources {
collection := make([]map[string]interface{}, 0)
rule := resource.ResourceRule
if len(rule.Namespaces) == 0 {
items, err := GetResourcesDynamically(ctx,
rule.Group, rule.Version, rule.Resource, "")
if err != nil {
return nil, err
}

for _, item := range items {
collection = append(collection, item.Object)
}
} else {
for _, namespace := range rule.Namespaces {
items, err := GetResourcesDynamically(ctx,
rule.Group, rule.Version, rule.Resource, namespace)
if err != nil {
return nil, err
}

for _, item := range items {
collection = append(collection, item.Object)
}
}
collection, err := GetResourcesDynamically(ctx, resource.ResourceRule)
// log error but continue with other resources
if err != nil {
return nil, err
}

if len(collection) > 0 {
// Append to collections if not empty collection
// Adding the collection to the map when empty will result in a false positive for the validation in OPA?
// TODO: add warning log here
collections[resource.Name] = collection
// convert to object if named resource
if resource.ResourceRule.Name != "" {
collections[resource.Name] = collection[0]
} else {
collections[resource.Name] = collection
}
}
}
return collections, nil
}

// GetResourcesDynamically() requires a dynamic interface and processes GVR to return []unstructured.Unstructured
// GetResourcesDynamically() requires a dynamic interface and processes GVR to return []map[string]interface{}
// This function is used to query the cluster for specific subset of resources required for processing
func GetResourcesDynamically(ctx context.Context,
group string, version string, resource string, namespace string) (
[]unstructured.Unstructured, error) {
resource types.ResourceRule) (
[]map[string]interface{}, error) {

config, err := ctrl.GetConfig()
if err != nil {
return nil, fmt.Errorf("Error with connection to the Cluster")
return nil, fmt.Errorf("error with connection to the Cluster")
}
dynamic := dynamic.NewForConfigOrDie(config)

resourceId := schema.GroupVersionResource{
Group: group,
Version: version,
Resource: resource,
Group: resource.Group,
Version: resource.Version,
Resource: resource.Resource,
}
collection := make([]map[string]interface{}, 0)

list, err := dynamic.Resource(resourceId).Namespace(namespace).
List(ctx, metav1.ListOptions{})
namespaces := []string{""}
if len(resource.Namespaces) != 0 {
namespaces = resource.Namespaces
}
for _, namespace := range namespaces {
list, err := dynamic.Resource(resourceId).Namespace(namespace).
List(ctx, metav1.ListOptions{})

if err != nil {
return nil, err
if err != nil {
return nil, err
}

// Reduce if named resource
if resource.Name != "" {
// requires single specified namespace
if len(resource.Namespaces) == 1 {
item, err := reduceByName(resource.Name, list.Items)
if err != nil {
return nil, err
}
collection = append(collection, item)
return collection, nil
}

} else {
for _, item := range list.Items {
collection = append(collection, item.Object)
}
}
}

return list.Items, nil
return collection, nil
}

func getGroupVersionResource(kind string) (gvr *schema.GroupVersionResource, err error) {
config, err := ctrl.GetConfig()
if err != nil {
return nil, fmt.Errorf("Error with connection to the Cluster")
return nil, fmt.Errorf("error with connection to the Cluster")
}
name := strings.Split(kind, "/")[0]

Expand Down Expand Up @@ -119,3 +127,15 @@ func getGroupVersionResource(kind string) (gvr *schema.GroupVersionResource, err

return nil, fmt.Errorf("kind %s not found", kind)
}

// reduceByName() takes a name and loops over all items to return the first match
func reduceByName(name string, items []unstructured.Unstructured) (map[string]interface{}, error) {

for _, item := range items {
if item.GetName() == name {
return item.Object, nil
}
}

return nil, fmt.Errorf("no resource found with name %s", name)
}
18 changes: 12 additions & 6 deletions src/pkg/common/kubernetes/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,22 @@ func WaitForExistence(kind string, namespace string, timeout time.Duration) (err
return err
}

resources, err := GetResourcesDynamically(context.TODO(), gvr.Group, gvr.Version, gvr.Resource, namespace)
resourceRule := types.ResourceRule{
Group: gvr.Group,
Version: gvr.Version,
Resource: gvr.Resource,
Namespaces: []string{namespace},
Name: name,
}

resources, err := GetResourcesDynamically(context.TODO(), resourceRule)
if err != nil {
return err
}

for _, resource := range resources {
if resource.GetName() == name {
// Success
return nil
}
if len(resources) > 0 {
// success
return nil
}
}
}
Expand Down
15 changes: 13 additions & 2 deletions src/test/e2e/pod_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestPodLabelValidation(t *testing.T) {
if err = config.Client().Resources().Create(ctx, pod); err != nil {
t.Fatal(err)
}
err = wait.For(conditions.New(config.Client().Resources()).PodConditionMatch(pod, corev1.PodReady, corev1.ConditionTrue), wait.WithTimeout(time.Minute*5))
err = wait.For(conditions.New(config.Client().Resources()).PodConditionMatch(pod, corev1.PodReady, corev1.ConditionTrue), wait.WithTimeout(time.Minute*1))
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -104,7 +104,13 @@ func TestPodLabelValidation(t *testing.T) {
if err := config.Client().Resources().Delete(ctx, pod); err != nil {
t.Fatal(err)
}
err := os.Remove("sar-test.yaml")

err := wait.For(conditions.New(config.Client().Resources()).ResourceDeleted(pod), wait.WithTimeout(time.Minute*1))
if err != nil {
t.Fatal(err)
}

err = os.Remove("sar-test.yaml")
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -150,6 +156,11 @@ func TestPodLabelValidation(t *testing.T) {
if err := config.Client().Resources().Delete(ctx, pod); err != nil {
t.Fatal(err)
}
err := wait.For(conditions.New(config.Client().Resources()).ResourceDeleted(pod), wait.WithTimeout(time.Minute*1))
if err != nil {
t.Fatal(err)
}

return ctx
}).Feature()

Expand Down
Loading

0 comments on commit 48a1cdd

Please sign in to comment.