mirror of https://github.com/fluxcd/cli-utils.git
feat: MultiError for invalid annotations
- Move Validator to pkg/object/validation - Replace ValidationError with validation.Error - Replace MultiValidationError with generic MultiError - Update Validator & SortObjs to use MultiError - Add ResourceReferenceFromObjMetadata - Rename NewResourceReference -> ResourceReferenceFromUnstructured - Delete duplicate ResourceReference.ObjMetadata() - Modify some error messages for consistency and clarity - Use templating to generate some test artifacts BREAKING CHANGE: apply-time-mutation namespace required for namespace-scoped resources
This commit is contained in:
parent
0c9b214db3
commit
f67aaa87ac
|
@ -27,6 +27,7 @@ import (
|
|||
"sigs.k8s.io/cli-utils/pkg/kstatus/polling"
|
||||
"sigs.k8s.io/cli-utils/pkg/kstatus/polling/engine"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/validation"
|
||||
"sigs.k8s.io/cli-utils/pkg/ordering"
|
||||
)
|
||||
|
||||
|
@ -143,9 +144,8 @@ func (a *Applier) Run(ctx context.Context, invInfo inventory.InventoryInfo, obje
|
|||
|
||||
// Validate the resources to make sure we catch those problems early
|
||||
// before anything has been updated in the cluster.
|
||||
if err := (&object.Validator{
|
||||
Mapper: mapper,
|
||||
}).Validate(objects); err != nil {
|
||||
validator := &validation.Validator{Mapper: mapper}
|
||||
if err := validator.Validate(objects); err != nil {
|
||||
handleError(eventChannel, err)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ func (atm *ApplyTimeMutator) Mutate(ctx context.Context, obj *unstructured.Unstr
|
|||
mutated := false
|
||||
reason := ""
|
||||
|
||||
targetRef := mutation.NewResourceReference(obj)
|
||||
targetRef := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
|
||||
if !mutation.HasAnnotation(obj) {
|
||||
return mutated, reason, nil
|
||||
|
@ -177,7 +177,7 @@ func (atm *ApplyTimeMutator) getObject(ctx context.Context, mapping *meta.RESTMa
|
|||
if ref.Kind == "" {
|
||||
return nil, fmt.Errorf("invalid source reference: empty kind")
|
||||
}
|
||||
id := ref.ObjMetadata()
|
||||
id := ref.ToObjMetadata()
|
||||
|
||||
// get resource from cache
|
||||
if atm.ResourceCache != nil {
|
||||
|
@ -225,7 +225,7 @@ func computeStatus(obj *unstructured.Unstructured) cache.ResourceStatus {
|
|||
result, err := status.Compute(obj)
|
||||
if err != nil {
|
||||
if klog.V(3).Enabled() {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
klog.Info("failed to compute resource status (%s): %d", ref, err)
|
||||
}
|
||||
return cache.ResourceStatus{
|
||||
|
|
|
@ -410,7 +410,7 @@ func TestMutate(t *testing.T) {
|
|||
reason: "",
|
||||
// exact error message isn't very important. Feel free to update if the error text changes.
|
||||
errMsg: `failed to read annotation in resource (v1/namespaces/map-namespace/ConfigMap/map3-name): ` +
|
||||
`failed to parse apply-time-mutation annotation: "not a valid substitution list": ` +
|
||||
`failed to parse apply-time-mutation annotation: ` +
|
||||
`error unmarshaling JSON: ` +
|
||||
`while decoding JSON: ` +
|
||||
`json: cannot unmarshal string into Go value of type mutation.ApplyTimeMutation`,
|
||||
|
|
|
@ -156,7 +156,7 @@ func (cic *ClusterInventoryClient) Replace(localInv InventoryInfo, objs object.O
|
|||
}
|
||||
clusterObjs, err := cic.GetClusterObjs(localInv, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to read inventory objects from cluster: %w", err)
|
||||
}
|
||||
if objs.Equal(clusterObjs) {
|
||||
klog.V(4).Infof("applied objects same as cluster inventory: do nothing")
|
||||
|
@ -164,7 +164,7 @@ func (cic *ClusterInventoryClient) Replace(localInv InventoryInfo, objs object.O
|
|||
}
|
||||
clusterInv, err := cic.GetClusterInventoryInfo(localInv, dryRun)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to read inventory from cluster: %w", err)
|
||||
}
|
||||
clusterInv, err = cic.replaceInventory(clusterInv, objs)
|
||||
if err != nil {
|
||||
|
@ -173,7 +173,7 @@ func (cic *ClusterInventoryClient) Replace(localInv InventoryInfo, objs object.O
|
|||
klog.V(4).Infof("replace cluster inventory: %s/%s", clusterInv.GetNamespace(), clusterInv.GetName())
|
||||
klog.V(4).Infof("replace cluster inventory %d objects", len(objs))
|
||||
if err := cic.applyInventoryObj(clusterInv, dryRun); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to write updated inventory to cluster: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -225,7 +225,7 @@ func (cic *ClusterInventoryClient) GetClusterObjs(localInv InventoryInfo, dryRun
|
|||
var objs object.ObjMetadataSet
|
||||
clusterInv, err := cic.GetClusterInventoryInfo(localInv, dryRun)
|
||||
if err != nil {
|
||||
return objs, err
|
||||
return objs, fmt.Errorf("failed to read inventory from cluster: %w", err)
|
||||
}
|
||||
// First time; no inventory obj yet.
|
||||
if clusterInv == nil {
|
||||
|
@ -247,7 +247,7 @@ func (cic *ClusterInventoryClient) GetClusterObjs(localInv InventoryInfo, dryRun
|
|||
func (cic *ClusterInventoryClient) GetClusterInventoryInfo(inv InventoryInfo, dryRun common.DryRunStrategy) (*unstructured.Unstructured, error) {
|
||||
clusterInvObjects, err := cic.GetClusterInventoryObjs(inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to read inventory objects from cluster: %w", err)
|
||||
}
|
||||
|
||||
var clusterInv *unstructured.Unstructured
|
||||
|
|
|
@ -17,9 +17,10 @@ import (
|
|||
"github.com/spyzhov/ajson"
|
||||
)
|
||||
|
||||
// Get evaluates the yq expression to extract values from the input map.
|
||||
// Get evaluates the JSONPath expression to extract values from the input map.
|
||||
// Returns the node values that were found (zero or more), or an error.
|
||||
// For details about the yq expression language, see: https://mikefarah.gitbook.io/yq/
|
||||
// For details about the JSONPath expression language, see:
|
||||
// https://goessner.net/articles/JsonPath/
|
||||
func Get(obj map[string]interface{}, expression string) ([]interface{}, error) {
|
||||
// format input object as json for input into jsonpath library
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
|
@ -65,9 +66,10 @@ func Get(obj map[string]interface{}, expression string) ([]interface{}, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// Set evaluates the yq expression to set a value in the input map.
|
||||
// Set evaluates the JSONPath expression to set a value in the input map.
|
||||
// Returns the number of matching nodes that were updated, or an error.
|
||||
// For details about the yq expression language, see: https://mikefarah.gitbook.io/yq/
|
||||
// For details about the JSONPath expression language, see:
|
||||
// https://goessner.net/articles/JsonPath/
|
||||
func Set(obj map[string]interface{}, expression string, value interface{}) (int, error) {
|
||||
// format input object as json for input into jsonpath library
|
||||
jsonBytes, err := json.Marshal(obj)
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
// Copyright 2022 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package multierror
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Prefix = "- "
|
||||
const Indent = " "
|
||||
|
||||
type Interface interface {
|
||||
Errors() []error
|
||||
}
|
||||
|
||||
// New returns a new MultiError wrapping the specified error list.
|
||||
func New(causes ...error) *MultiError {
|
||||
return &MultiError{
|
||||
Causes: causes,
|
||||
}
|
||||
}
|
||||
|
||||
// MultiError wraps multiple errors and formats them for multi-line output.
|
||||
type MultiError struct {
|
||||
Causes []error
|
||||
}
|
||||
|
||||
func (mve *MultiError) Errors() []error {
|
||||
return mve.Causes
|
||||
}
|
||||
|
||||
func (mve *MultiError) Error() string {
|
||||
if len(mve.Causes) == 1 {
|
||||
return mve.Causes[0].Error()
|
||||
}
|
||||
var b strings.Builder
|
||||
_, _ = fmt.Fprintf(&b, "%d errors:\n", len(mve.Causes))
|
||||
for _, err := range mve.Causes {
|
||||
_, _ = fmt.Fprintf(&b, "%s\n", formatError(err))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatError(err error) string {
|
||||
lines := strings.Split(err.Error(), "\n")
|
||||
return Prefix + strings.Join(lines, fmt.Sprintf("\n%s", Indent))
|
||||
}
|
||||
|
||||
// Wrap merges zero or more errors and/or MultiErrors into one error.
|
||||
// MultiErrors are recursively unwrapped to reduce depth.
|
||||
// If only one error is received, that error is returned without a wrapper.
|
||||
func Wrap(errs ...error) error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
errs = Unwrap(errs...)
|
||||
var err error
|
||||
switch {
|
||||
case len(errs) == 0:
|
||||
err = nil
|
||||
case len(errs) == 1:
|
||||
err = errs[0]
|
||||
case len(errs) > 1:
|
||||
err = &MultiError{
|
||||
Causes: errs,
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Unwrap flattens zero or more errors and/or MultiErrors into a list of errors.
|
||||
// MultiErrors are recursively unwrapped to reduce depth.
|
||||
func Unwrap(errs ...error) []error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var errors []error
|
||||
for _, err := range errs {
|
||||
if mve, ok := err.(Interface); ok {
|
||||
// Recursively unwrap MultiErrors
|
||||
for _, cause := range mve.Errors() {
|
||||
errors = append(errors, Unwrap(cause)...)
|
||||
}
|
||||
} else {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
|
@ -42,7 +42,7 @@ func ReadAnnotation(u *unstructured.Unstructured) (DependencySet, error) {
|
|||
|
||||
depSet, err := ParseDependencySet(depSetStr)
|
||||
if err != nil {
|
||||
return depSet, fmt.Errorf("failed to parse dependency set: %w", err)
|
||||
return depSet, fmt.Errorf("failed to parse depends-on annotation: %w", err)
|
||||
}
|
||||
return depSet, nil
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ func WriteAnnotation(obj *unstructured.Unstructured, depSet DependencySet) error
|
|||
|
||||
depSetStr, err := FormatDependencySet(depSet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format dependency set: %w", err)
|
||||
return fmt.Errorf("failed to format depends-on annotation: %w", err)
|
||||
}
|
||||
|
||||
a := obj.GetAnnotations()
|
||||
|
|
|
@ -114,8 +114,8 @@ func ParseObjMetadata(objStr string) (object.ObjMetadata, error) {
|
|||
fields := strings.Split(objStr, fieldSeparator)
|
||||
|
||||
if len(fields) != numFieldsClusterScoped && len(fields) != numFieldsNamespacedScoped {
|
||||
return obj, fmt.Errorf("too many fields (expected %d or %d): %q",
|
||||
numFieldsClusterScoped, numFieldsNamespacedScoped, objStr)
|
||||
return obj, fmt.Errorf("expected %d or %d fields, found %d: %q",
|
||||
numFieldsClusterScoped, numFieldsNamespacedScoped, len(fields), objStr)
|
||||
}
|
||||
|
||||
group = fields[0]
|
||||
|
|
|
@ -7,13 +7,16 @@
|
|||
package graph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/klog/v2"
|
||||
"sigs.k8s.io/cli-utils/pkg/multierror"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/dependson"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/mutation"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/validation"
|
||||
"sigs.k8s.io/cli-utils/pkg/ordering"
|
||||
)
|
||||
|
||||
|
@ -22,28 +25,38 @@ import (
|
|||
// the returned applied sets is a topological ordering of the sets to apply.
|
||||
// Returns an single empty apply set if there are no objects to apply.
|
||||
func SortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, error) {
|
||||
var objSets []object.UnstructuredSet
|
||||
if len(objs) == 0 {
|
||||
return []object.UnstructuredSet{}, nil
|
||||
return objSets, nil
|
||||
}
|
||||
var errors []error
|
||||
// Convert to IDs (same length & order as objs)
|
||||
ids := object.UnstructuredSetToObjMetadataSet(objs)
|
||||
// Create the graph, and build a map of object metadata to the object (Unstructured).
|
||||
g := New()
|
||||
objToUnstructured := map[object.ObjMetadata]*unstructured.Unstructured{}
|
||||
for _, obj := range objs {
|
||||
id := object.UnstructuredToObjMetadata(obj)
|
||||
for i, obj := range objs {
|
||||
id := ids[i]
|
||||
objToUnstructured[id] = obj
|
||||
}
|
||||
// Add object vertices and dependency edges to graph.
|
||||
addApplyTimeMutationEdges(g, objs)
|
||||
addDependsOnEdges(g, objs)
|
||||
addNamespaceEdges(g, objs)
|
||||
addCRDEdges(g, objs)
|
||||
// Add objects as graph vertices
|
||||
addVertices(g, ids)
|
||||
// Add dependencies as graph edges
|
||||
addCRDEdges(g, objs, ids)
|
||||
addNamespaceEdges(g, objs, ids)
|
||||
if err := addDependsOnEdges(g, objs, ids); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
if err := addApplyTimeMutationEdges(g, objs, ids); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
// Run topological sort on the graph.
|
||||
objSets := []object.UnstructuredSet{}
|
||||
sortedObjSets, err := g.Sort()
|
||||
if err != nil {
|
||||
return []object.UnstructuredSet{}, err
|
||||
errors = append(errors, err)
|
||||
}
|
||||
// Map the object metadata back to the sorted sets of unstructured objects.
|
||||
// Ignore any edges that aren't part of the input set (don't wait for them).
|
||||
for _, objSet := range sortedObjSets {
|
||||
currentSet := object.UnstructuredSet{}
|
||||
for _, id := range objSet {
|
||||
|
@ -57,6 +70,9 @@ func SortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, error) {
|
|||
sort.Sort(ordering.SortableUnstructureds(currentSet))
|
||||
objSets = append(objSets, currentSet)
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
return objSets, multierror.Wrap(errors...)
|
||||
}
|
||||
return objSets, nil
|
||||
}
|
||||
|
||||
|
@ -65,7 +81,7 @@ func ReverseSortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, err
|
|||
// Sorted objects using normal ordering.
|
||||
s, err := SortObjs(objs)
|
||||
if err != nil {
|
||||
return []object.UnstructuredSet{}, err
|
||||
return s, err
|
||||
}
|
||||
// Reverse the ordering of the object sets using swaps.
|
||||
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
|
||||
|
@ -80,73 +96,140 @@ func ReverseSortObjs(objs object.UnstructuredSet) ([]object.UnstructuredSet, err
|
|||
return s, nil
|
||||
}
|
||||
|
||||
// addApplyTimeMutationEdges updates the graph with edges from objects
|
||||
// with an explicit "apply-time-mutation" annotation.
|
||||
func addApplyTimeMutationEdges(g *Graph, objs object.UnstructuredSet) {
|
||||
for _, obj := range objs {
|
||||
id := object.UnstructuredToObjMetadata(obj)
|
||||
// addVertices adds all the IDs in the set as graph vertices.
|
||||
func addVertices(g *Graph, ids object.ObjMetadataSet) {
|
||||
for _, id := range ids {
|
||||
klog.V(3).Infof("adding vertex: %s", id)
|
||||
g.AddVertex(id)
|
||||
if mutation.HasAnnotation(obj) {
|
||||
subs, err := mutation.ReadAnnotation(obj)
|
||||
if err != nil {
|
||||
// TODO: fail task if parse errors?
|
||||
klog.V(3).Infof("failed to add edges from: %s: %s", id, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// addApplyTimeMutationEdges updates the graph with edges from objects
|
||||
// with an explicit "apply-time-mutation" annotation.
|
||||
// The objs and ids must match in order and length (optimization).
|
||||
func addApplyTimeMutationEdges(g *Graph, objs object.UnstructuredSet, ids object.ObjMetadataSet) error {
|
||||
var errors []error
|
||||
for i, obj := range objs {
|
||||
if !mutation.HasAnnotation(obj) {
|
||||
continue
|
||||
}
|
||||
id := ids[i]
|
||||
subs, err := mutation.ReadAnnotation(obj)
|
||||
if err != nil {
|
||||
klog.V(3).Infof("failed to add edges from: %s: %v", id, err)
|
||||
errors = append(errors, validation.NewError(err, id))
|
||||
continue
|
||||
}
|
||||
seen := make(map[object.ObjMetadata]struct{})
|
||||
var objErrors []error
|
||||
for _, sub := range subs {
|
||||
dep := sub.SourceRef.ToObjMetadata()
|
||||
// Duplicate dependencies can be safely skipped.
|
||||
if _, found := seen[dep]; found {
|
||||
continue
|
||||
}
|
||||
for _, sub := range subs {
|
||||
// TODO: fail task if it's not in the inventory?
|
||||
dep := sub.SourceRef.ObjMetadata()
|
||||
klog.V(3).Infof("adding edge from: %s, to: %s", id, dep)
|
||||
g.AddEdge(id, dep)
|
||||
// Mark as seen
|
||||
seen[dep] = struct{}{}
|
||||
// Require dependencies to be in the same resource group.
|
||||
// Waiting for external dependencies isn't implemented (yet).
|
||||
if !ids.Contains(dep) {
|
||||
err := fmt.Errorf("invalid %q annotation: dependency not in object set: %s",
|
||||
mutation.Annotation, sub.SourceRef)
|
||||
objErrors = append(objErrors, err)
|
||||
klog.V(3).Infof("failed to add edges from: %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
klog.V(3).Infof("adding edge from: %s, to: %s", id, dep)
|
||||
g.AddEdge(id, dep)
|
||||
}
|
||||
if len(objErrors) > 0 {
|
||||
errors = append(errors,
|
||||
validation.NewError(multierror.Wrap(objErrors...), id))
|
||||
}
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
return multierror.Wrap(errors...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addDependsOnEdges updates the graph with edges from objects
|
||||
// with an explicit "depends-on" annotation.
|
||||
func addDependsOnEdges(g *Graph, objs object.UnstructuredSet) {
|
||||
for _, obj := range objs {
|
||||
id := object.UnstructuredToObjMetadata(obj)
|
||||
klog.V(3).Infof("adding vertex: %s", id)
|
||||
g.AddVertex(id)
|
||||
deps, err := dependson.ReadAnnotation(obj)
|
||||
if err != nil {
|
||||
// TODO: fail if annotation fails to parse?
|
||||
klog.V(3).Infof("failed to add edges from: %s: %s", id, err)
|
||||
// The objs and ids must match in order and length (optimization).
|
||||
func addDependsOnEdges(g *Graph, objs object.UnstructuredSet, ids object.ObjMetadataSet) error {
|
||||
var errors []error
|
||||
for i, obj := range objs {
|
||||
if !dependson.HasAnnotation(obj) {
|
||||
continue
|
||||
}
|
||||
id := ids[i]
|
||||
deps, err := dependson.ReadAnnotation(obj)
|
||||
if err != nil {
|
||||
klog.V(3).Infof("failed to add edges from: %s: %v", id, err)
|
||||
errors = append(errors, validation.NewError(err, id))
|
||||
continue
|
||||
}
|
||||
seen := make(map[object.ObjMetadata]struct{})
|
||||
var objErrors []error
|
||||
for _, dep := range deps {
|
||||
// TODO: fail if depe is not in the inventory?
|
||||
// Duplicate dependencies in the same annotation are not allowed.
|
||||
// Having duplicates won't break the graph, but skip it anyway.
|
||||
if _, found := seen[dep]; found {
|
||||
// Won't error - already passed validation
|
||||
depStr, _ := dependson.FormatObjMetadata(dep)
|
||||
err := fmt.Errorf("invalid %q annotation: duplicate reference: %s",
|
||||
dependson.Annotation, depStr)
|
||||
objErrors = append(objErrors, err)
|
||||
klog.V(3).Infof("failed to add edges from: %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
// Mark as seen
|
||||
seen[dep] = struct{}{}
|
||||
// Require dependencies to be in the same resource group.
|
||||
// Waiting for external dependencies isn't implemented (yet).
|
||||
if !ids.Contains(dep) {
|
||||
err := fmt.Errorf("invalid %q annotation: dependency not in object set: %s",
|
||||
dependson.Annotation, mutation.ResourceReferenceFromObjMetadata(dep))
|
||||
objErrors = append(objErrors, err)
|
||||
klog.V(3).Infof("failed to add edges from: %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
klog.V(3).Infof("adding edge from: %s, to: %s", id, dep)
|
||||
g.AddEdge(id, dep)
|
||||
}
|
||||
if len(objErrors) > 0 {
|
||||
errors = append(errors,
|
||||
validation.NewError(multierror.Wrap(objErrors...), id))
|
||||
}
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
return multierror.Wrap(errors...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addCRDEdges adds edges to the dependency graph from custom
|
||||
// resources to their definitions to ensure the CRD's exist
|
||||
// before applying the custom resources created with the definition.
|
||||
func addCRDEdges(g *Graph, objs object.UnstructuredSet) {
|
||||
// The objs and ids must match in order and length (optimization).
|
||||
func addCRDEdges(g *Graph, objs object.UnstructuredSet, ids object.ObjMetadataSet) {
|
||||
crds := map[string]object.ObjMetadata{}
|
||||
// First create a map of all the CRD's.
|
||||
for _, u := range objs {
|
||||
for i, u := range objs {
|
||||
if object.IsCRD(u) {
|
||||
groupKind, found := object.GetCRDGroupKind(u)
|
||||
if found {
|
||||
obj := object.UnstructuredToObjMetadata(u)
|
||||
crds[groupKind.String()] = obj
|
||||
crds[groupKind.String()] = ids[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Iterate through all resources to see if we are applying any
|
||||
// custom resources defined by previously recorded CRD's.
|
||||
for _, u := range objs {
|
||||
for i, u := range objs {
|
||||
gvk := u.GroupVersionKind()
|
||||
groupKind := gvk.GroupKind()
|
||||
if to, found := crds[groupKind.String()]; found {
|
||||
from := object.UnstructuredToObjMetadata(u)
|
||||
from := ids[i]
|
||||
klog.V(3).Infof("adding edge from: custom resource %s, to CRD: %s", from, to)
|
||||
g.AddEdge(from, to)
|
||||
}
|
||||
|
@ -156,25 +239,25 @@ func addCRDEdges(g *Graph, objs object.UnstructuredSet) {
|
|||
// addNamespaceEdges adds edges to the dependency graph from namespaced
|
||||
// objects to the namespace objects. Ensures the namespaces exist
|
||||
// before the resources in those namespaces are applied.
|
||||
func addNamespaceEdges(g *Graph, objs object.UnstructuredSet) {
|
||||
// The objs and ids must match in order and length (optimization).
|
||||
func addNamespaceEdges(g *Graph, objs object.UnstructuredSet, ids object.ObjMetadataSet) {
|
||||
namespaces := map[string]object.ObjMetadata{}
|
||||
// First create a map of all the namespaces objects live in.
|
||||
for _, obj := range objs {
|
||||
for i, obj := range objs {
|
||||
if object.IsKindNamespace(obj) {
|
||||
id := object.UnstructuredToObjMetadata(obj)
|
||||
namespace := obj.GetName()
|
||||
namespaces[namespace] = id
|
||||
namespaces[namespace] = ids[i]
|
||||
}
|
||||
}
|
||||
// Next, if the namespace of a namespaced object is being applied,
|
||||
// then create an edge from the namespaced object to its namespace.
|
||||
for _, obj := range objs {
|
||||
for i, obj := range objs {
|
||||
if object.IsNamespaced(obj) {
|
||||
objNamespace := obj.GetNamespace()
|
||||
if namespace, found := namespaces[objNamespace]; found {
|
||||
id := object.UnstructuredToObjMetadata(obj)
|
||||
klog.V(3).Infof("adding edge from: %s to namespace: %s", id, namespace)
|
||||
g.AddEdge(id, namespace)
|
||||
if to, found := namespaces[objNamespace]; found {
|
||||
from := ids[i]
|
||||
klog.V(3).Infof("adding edge from: %s to namespace: %s", from, to)
|
||||
g.AddEdge(from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,13 +4,18 @@
|
|||
package graph
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"sigs.k8s.io/cli-utils/pkg/multierror"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/dependson"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/mutation"
|
||||
mutationutil "sigs.k8s.io/cli-utils/pkg/object/mutation/testutil"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/validation"
|
||||
"sigs.k8s.io/cli-utils/pkg/testutil"
|
||||
)
|
||||
|
||||
|
@ -371,8 +376,9 @@ func TestReverseSortObjs(t *testing.T) {
|
|||
|
||||
func TestApplyTimeMutationEdges(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
objs []*unstructured.Unstructured
|
||||
expected []Edge
|
||||
objs []*unstructured.Unstructured
|
||||
expected []Edge
|
||||
expectedError error
|
||||
}{
|
||||
"no objects adds no graph edges": {
|
||||
objs: []*unstructured.Unstructured{},
|
||||
|
@ -398,7 +404,9 @@ func TestApplyTimeMutationEdges(t *testing.T) {
|
|||
resources["deployment"],
|
||||
mutationutil.AddApplyTimeMutation(t, &mutation.ApplyTimeMutation{
|
||||
{
|
||||
SourceRef: mutation.NewResourceReference(testutil.Unstructured(t, resources["secret"])),
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["secret"]),
|
||||
),
|
||||
SourcePath: "unused",
|
||||
TargetPath: "unused",
|
||||
Token: "unused",
|
||||
|
@ -421,7 +429,9 @@ func TestApplyTimeMutationEdges(t *testing.T) {
|
|||
resources["deployment"],
|
||||
mutationutil.AddApplyTimeMutation(t, &mutation.ApplyTimeMutation{
|
||||
{
|
||||
SourceRef: mutation.NewResourceReference(testutil.Unstructured(t, resources["secret"])),
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["secret"]),
|
||||
),
|
||||
SourcePath: "unused",
|
||||
TargetPath: "unused",
|
||||
Token: "unused",
|
||||
|
@ -433,7 +443,9 @@ func TestApplyTimeMutationEdges(t *testing.T) {
|
|||
resources["pod"],
|
||||
mutationutil.AddApplyTimeMutation(t, &mutation.ApplyTimeMutation{
|
||||
{
|
||||
SourceRef: mutation.NewResourceReference(testutil.Unstructured(t, resources["secret"])),
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["secret"]),
|
||||
),
|
||||
SourcePath: "unused",
|
||||
TargetPath: "unused",
|
||||
Token: "unused",
|
||||
|
@ -460,13 +472,17 @@ func TestApplyTimeMutationEdges(t *testing.T) {
|
|||
resources["pod"],
|
||||
mutationutil.AddApplyTimeMutation(t, &mutation.ApplyTimeMutation{
|
||||
{
|
||||
SourceRef: mutation.NewResourceReference(testutil.Unstructured(t, resources["secret"])),
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["secret"]),
|
||||
),
|
||||
SourcePath: "unused",
|
||||
TargetPath: "unused",
|
||||
Token: "unused",
|
||||
},
|
||||
{
|
||||
SourceRef: mutation.NewResourceReference(testutil.Unstructured(t, resources["deployment"])),
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
),
|
||||
SourcePath: "unused",
|
||||
TargetPath: "unused",
|
||||
Token: "unused",
|
||||
|
@ -487,12 +503,133 @@ func TestApplyTimeMutationEdges(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
"error: invalid annotation": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
"annotations": map[string]interface{}{
|
||||
mutation.Annotation: "invalid-mutation",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: validation.NewError(
|
||||
errors.New("failed to parse apply-time-mutation annotation: "+
|
||||
"error unmarshaling JSON: "+
|
||||
"while decoding JSON: json: "+
|
||||
"cannot unmarshal string into Go value of type mutation.ApplyTimeMutation"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
},
|
||||
"error: dependency not in object set": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, resources["pod"],
|
||||
mutationutil.AddApplyTimeMutation(t, &mutation.ApplyTimeMutation{
|
||||
{
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: validation.NewError(
|
||||
errors.New(`invalid "config.kubernetes.io/apply-time-mutation" annotation: `+
|
||||
"dependency not in object set: "+
|
||||
"apps/namespaces/test-namespace/Deployment/foo"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
},
|
||||
Name: "test-pod",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
),
|
||||
},
|
||||
"error: two invalid objects": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
"annotations": map[string]interface{}{
|
||||
mutation.Annotation: "invalid-mutation",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
testutil.Unstructured(t, resources["pod"],
|
||||
mutationutil.AddApplyTimeMutation(t, &mutation.ApplyTimeMutation{
|
||||
{
|
||||
SourceRef: mutation.ResourceReferenceFromObjMetadata(
|
||||
testutil.ToIdentifier(t, resources["secret"]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: multierror.New(
|
||||
validation.NewError(
|
||||
errors.New("failed to parse apply-time-mutation annotation: "+
|
||||
"error unmarshaling JSON: "+
|
||||
"while decoding JSON: json: "+
|
||||
"cannot unmarshal string into Go value of type mutation.ApplyTimeMutation"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
validation.NewError(
|
||||
errors.New(`invalid "config.kubernetes.io/apply-time-mutation" annotation: `+
|
||||
"dependency not in object set: "+
|
||||
"/namespaces/test-namespace/Secret/secret"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
},
|
||||
Name: "test-pod",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
g := New()
|
||||
addApplyTimeMutationEdges(g, tc.objs)
|
||||
ids := object.UnstructuredSetToObjMetadataSet(tc.objs)
|
||||
err := addApplyTimeMutationEdges(g, tc.objs, ids)
|
||||
if tc.expectedError != nil {
|
||||
assert.EqualError(t, err, tc.expectedError.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
actual := g.GetEdges()
|
||||
verifyEdges(t, tc.expected, actual)
|
||||
})
|
||||
|
@ -501,8 +638,9 @@ func TestApplyTimeMutationEdges(t *testing.T) {
|
|||
|
||||
func TestAddDependsOnEdges(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
objs []*unstructured.Unstructured
|
||||
expected []Edge
|
||||
objs []*unstructured.Unstructured
|
||||
expected []Edge
|
||||
expectedError error
|
||||
}{
|
||||
"no objects adds no graph edges": {
|
||||
objs: []*unstructured.Unstructured{},
|
||||
|
@ -575,12 +713,184 @@ func TestAddDependsOnEdges(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
"error: invalid annotation": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
"annotations": map[string]interface{}{
|
||||
dependson.Annotation: "invalid-obj-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: validation.NewError(
|
||||
errors.New("failed to parse depends-on annotation: "+
|
||||
"failed to parse object metadata: "+
|
||||
"expected 3 or 5 fields, found 1: "+
|
||||
`"invalid-obj-ref"`),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
},
|
||||
"error: duplicate reference": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, resources["pod"],
|
||||
testutil.AddDependsOn(t,
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
),
|
||||
),
|
||||
testutil.Unstructured(t, resources["deployment"]),
|
||||
},
|
||||
expected: []Edge{
|
||||
{
|
||||
From: testutil.ToIdentifier(t, resources["pod"]),
|
||||
To: testutil.ToIdentifier(t, resources["deployment"]),
|
||||
},
|
||||
},
|
||||
expectedError: validation.NewError(
|
||||
errors.New(`invalid "config.kubernetes.io/depends-on" annotation: `+
|
||||
"duplicate reference: "+
|
||||
"apps/namespaces/test-namespace/Deployment/foo"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
},
|
||||
Name: "test-pod",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
),
|
||||
},
|
||||
"error: dependency not in object set": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, resources["pod"],
|
||||
testutil.AddDependsOn(t,
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
),
|
||||
),
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: validation.NewError(
|
||||
errors.New(`invalid "config.kubernetes.io/depends-on" annotation: `+
|
||||
"dependency not in object set: "+
|
||||
"apps/namespaces/test-namespace/Deployment/foo"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
},
|
||||
Name: "test-pod",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
),
|
||||
},
|
||||
"error: two invalid objects": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
"annotations": map[string]interface{}{
|
||||
dependson.Annotation: "invalid-obj-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
testutil.Unstructured(t, resources["pod"],
|
||||
testutil.AddDependsOn(t,
|
||||
testutil.ToIdentifier(t, resources["secret"]),
|
||||
),
|
||||
),
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: multierror.New(
|
||||
validation.NewError(
|
||||
errors.New("failed to parse depends-on annotation: "+
|
||||
"failed to parse object metadata: "+
|
||||
"expected 3 or 5 fields, found 1: "+
|
||||
`"invalid-obj-ref"`),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
validation.NewError(
|
||||
errors.New(`invalid "config.kubernetes.io/depends-on" annotation: `+
|
||||
"dependency not in object set: "+
|
||||
"/namespaces/test-namespace/Secret/secret"),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
},
|
||||
Name: "test-pod",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
"error: one object with two errors": {
|
||||
objs: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, resources["pod"],
|
||||
testutil.AddDependsOn(t,
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
testutil.ToIdentifier(t, resources["deployment"]),
|
||||
),
|
||||
),
|
||||
},
|
||||
expected: []Edge{},
|
||||
expectedError: validation.NewError(
|
||||
multierror.New(
|
||||
errors.New(`invalid "config.kubernetes.io/depends-on" annotation: `+
|
||||
"dependency not in object set: "+
|
||||
"apps/namespaces/test-namespace/Deployment/foo"),
|
||||
errors.New(`invalid "config.kubernetes.io/depends-on" annotation: `+
|
||||
"duplicate reference: "+
|
||||
"apps/namespaces/test-namespace/Deployment/foo"),
|
||||
),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Pod",
|
||||
},
|
||||
Name: "test-pod",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
g := New()
|
||||
addDependsOnEdges(g, tc.objs)
|
||||
ids := object.UnstructuredSetToObjMetadataSet(tc.objs)
|
||||
err := addDependsOnEdges(g, tc.objs, ids)
|
||||
if tc.expectedError != nil {
|
||||
assert.EqualError(t, err, tc.expectedError.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
actual := g.GetEdges()
|
||||
verifyEdges(t, tc.expected, actual)
|
||||
})
|
||||
|
@ -656,7 +966,8 @@ func TestAddNamespaceEdges(t *testing.T) {
|
|||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
g := New()
|
||||
addNamespaceEdges(g, tc.objs)
|
||||
ids := object.UnstructuredSetToObjMetadataSet(tc.objs)
|
||||
addNamespaceEdges(g, tc.objs, ids)
|
||||
actual := g.GetEdges()
|
||||
verifyEdges(t, tc.expected, actual)
|
||||
})
|
||||
|
@ -707,7 +1018,8 @@ func TestAddCRDEdges(t *testing.T) {
|
|||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
g := New()
|
||||
addCRDEdges(g, tc.objs)
|
||||
ids := object.UnstructuredSetToObjMetadataSet(tc.objs)
|
||||
addCRDEdges(g, tc.objs, ids)
|
||||
actual := g.GetEdges()
|
||||
verifyEdges(t, tc.expected, actual)
|
||||
})
|
||||
|
|
|
@ -10,7 +10,9 @@ import (
|
|||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"sigs.k8s.io/cli-utils/pkg/multierror"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/validation"
|
||||
)
|
||||
|
||||
// Graph is contains a directed set of edges, implemented as
|
||||
|
@ -43,6 +45,17 @@ func (g *Graph) AddVertex(v object.ObjMetadata) {
|
|||
}
|
||||
}
|
||||
|
||||
// GetVertices returns an unsorted set of unique vertices in the graph.
|
||||
func (g *Graph) GetVertices() object.ObjMetadataSet {
|
||||
keys := make(object.ObjMetadataSet, len(g.edges))
|
||||
i := 0
|
||||
for k := range g.edges {
|
||||
keys[i] = k
|
||||
i++
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// AddEdge adds a edge from one ObjMetadata vertex to another. The
|
||||
// direction of the edge is "from" -> "to".
|
||||
func (g *Graph) AddEdge(from object.ObjMetadata, to object.ObjMetadata) {
|
||||
|
@ -121,9 +134,10 @@ func (g *Graph) Sort() ([]object.ObjMetadataSet, error) {
|
|||
// No leaf vertices means cycle in the directed graph,
|
||||
// where remaining edges define the cycle.
|
||||
if len(leafVertices) == 0 {
|
||||
return []object.ObjMetadataSet{}, CyclicDependencyError{
|
||||
// Error can be ignored, so return the full set list
|
||||
return sorted, validation.NewError(CyclicDependencyError{
|
||||
Edges: g.GetEdges(),
|
||||
}
|
||||
}, g.GetVertices()...)
|
||||
}
|
||||
// Remove all edges to leaf vertices.
|
||||
for _, v := range leafVertices {
|
||||
|
@ -142,11 +156,11 @@ type CyclicDependencyError struct {
|
|||
|
||||
func (cde CyclicDependencyError) Error() string {
|
||||
var errorBuf bytes.Buffer
|
||||
errorBuf.WriteString("cyclic dependency")
|
||||
errorBuf.WriteString("cyclic dependency:\n")
|
||||
for _, edge := range cde.Edges {
|
||||
from := fmt.Sprintf("%s/%s", edge.From.Namespace, edge.From.Name)
|
||||
to := fmt.Sprintf("%s/%s", edge.To.Namespace, edge.To.Name)
|
||||
errorBuf.WriteString(fmt.Sprintf("\n\t%s -> %s", from, to))
|
||||
errorBuf.WriteString(fmt.Sprintf("%s%s -> %s\n", multierror.Prefix, from, to))
|
||||
}
|
||||
return errorBuf.String()
|
||||
}
|
||||
|
|
|
@ -36,12 +36,12 @@ func ReadAnnotation(obj *unstructured.Unstructured) (ApplyTimeMutation, error) {
|
|||
return mutation, nil
|
||||
}
|
||||
if klog.V(5).Enabled() {
|
||||
klog.Infof("resource (%v) has apply-time-mutation annotation:\n%s", NewResourceReference(obj), mutationYaml)
|
||||
klog.Infof("resource (%v) has apply-time-mutation annotation:\n%s", ResourceReferenceFromUnstructured(obj), mutationYaml)
|
||||
}
|
||||
|
||||
err := yaml.Unmarshal([]byte(mutationYaml), &mutation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse apply-time-mutation annotation: %q: %v", mutationYaml, err)
|
||||
return nil, fmt.Errorf("failed to parse apply-time-mutation annotation: %v", err)
|
||||
}
|
||||
return mutation, nil
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ func WriteAnnotation(obj *unstructured.Unstructured, mutation ApplyTimeMutation)
|
|||
}
|
||||
yamlBytes, err := yaml.Marshal(mutation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format apply-time-mutation annotation: %#v: %v", mutation, err)
|
||||
return fmt.Errorf("failed to format apply-time-mutation annotation: %v", err)
|
||||
}
|
||||
a := obj.GetAnnotations()
|
||||
if a == nil {
|
||||
|
|
|
@ -95,8 +95,8 @@ type ResourceReference struct {
|
|||
Namespace string `json:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// NewResourceReference returns the object as a ResourceReference
|
||||
func NewResourceReference(obj *unstructured.Unstructured) ResourceReference {
|
||||
// ResourceReferenceFromUnstructured returns the object as a ResourceReference
|
||||
func ResourceReferenceFromUnstructured(obj *unstructured.Unstructured) ResourceReference {
|
||||
return ResourceReference{
|
||||
Name: obj.GetName(),
|
||||
Namespace: obj.GetNamespace(),
|
||||
|
@ -105,6 +105,16 @@ func NewResourceReference(obj *unstructured.Unstructured) ResourceReference {
|
|||
}
|
||||
}
|
||||
|
||||
// ResourceReferenceFromObjMetadata returns the object as a ResourceReference
|
||||
func ResourceReferenceFromObjMetadata(id object.ObjMetadata) ResourceReference {
|
||||
return ResourceReference{
|
||||
Name: id.Name,
|
||||
Namespace: id.Namespace,
|
||||
Kind: id.GroupKind.Kind,
|
||||
Group: id.GroupKind.Group,
|
||||
}
|
||||
}
|
||||
|
||||
// GroupVersionKind satisfies the ObjectKind interface for all objects that
|
||||
// embed TypeMeta. Prefers Group over APIVersion.
|
||||
func (r ResourceReference) GroupVersionKind() schema.GroupVersionKind {
|
||||
|
@ -114,16 +124,6 @@ func (r ResourceReference) GroupVersionKind() schema.GroupVersionKind {
|
|||
return schema.FromAPIVersionAndKind(r.APIVersion, r.Kind)
|
||||
}
|
||||
|
||||
// ObjMetadata returns the name, namespace, group, and kind of the
|
||||
// ResourceReference, wrapped in a new ObjMetadata object.
|
||||
func (r ResourceReference) ObjMetadata() object.ObjMetadata {
|
||||
return object.ObjMetadata{
|
||||
Name: r.Name,
|
||||
Namespace: r.Namespace,
|
||||
GroupKind: r.GroupVersionKind().GroupKind(),
|
||||
}
|
||||
}
|
||||
|
||||
// ToUnstructured returns the name, namespace, group, version, and kind of the
|
||||
// ResourceReference, wrapped in a new Unstructured object.
|
||||
// This is useful for performing operations with
|
||||
|
|
|
@ -1,157 +0,0 @@
|
|||
// Copyright 2021 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package object
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
)
|
||||
|
||||
// MultiValidationError captures validation errors for multiple resources.
|
||||
type MultiValidationError struct {
|
||||
Errors []*ValidationError
|
||||
}
|
||||
|
||||
func (ae MultiValidationError) Error() string {
|
||||
var b strings.Builder
|
||||
_, _ = fmt.Fprintf(&b, "%d resources failed validation\n", len(ae.Errors))
|
||||
for _, e := range ae.Errors {
|
||||
b.WriteString(e.Error())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ValidationError captures errors resulting from validation of a resources.
|
||||
type ValidationError struct {
|
||||
GroupVersionKind schema.GroupVersionKind
|
||||
Name string
|
||||
Namespace string
|
||||
FieldErrors field.ErrorList
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Resource: %q, Name: %q, Namespace: %q\n",
|
||||
e.GroupVersionKind.String(), e.Name, e.Namespace))
|
||||
b.WriteString(e.FieldErrors.ToAggregate().Error())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Validator contains functionality for validating a set of resources prior
|
||||
// to being used by the Apply functionality. This imposes some constraint not
|
||||
// always required, such as namespaced resources must have the namespace set.
|
||||
type Validator struct {
|
||||
Mapper meta.RESTMapper
|
||||
}
|
||||
|
||||
// Validate validates the provided resources. A RESTMapper will be used
|
||||
// to fetch type information from the live cluster.
|
||||
func (v *Validator) Validate(resources []*unstructured.Unstructured) error {
|
||||
crds := findCRDs(resources)
|
||||
var errs []*ValidationError
|
||||
for _, r := range resources {
|
||||
var errList field.ErrorList
|
||||
if err := v.validateKind(r); err != nil {
|
||||
if fieldErr, ok := isFieldError(err); ok {
|
||||
errList = append(errList, fieldErr)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := v.validateName(r); err != nil {
|
||||
if fieldErr, ok := isFieldError(err); ok {
|
||||
errList = append(errList, fieldErr)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := v.validateNamespace(r, crds); err != nil {
|
||||
if fieldErr, ok := isFieldError(err); ok {
|
||||
errList = append(errList, fieldErr)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(errList) > 0 {
|
||||
errs = append(errs, &ValidationError{
|
||||
GroupVersionKind: r.GroupVersionKind(),
|
||||
Name: r.GetName(),
|
||||
Namespace: r.GetNamespace(),
|
||||
FieldErrors: errList,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return &MultiValidationError{
|
||||
Errors: errs,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isFieldError checks if an error is of type *field.Error. If so,
|
||||
// a reference to an error of that type is returned.
|
||||
func isFieldError(err error) (*field.Error, bool) {
|
||||
var fieldErr *field.Error
|
||||
if errors.As(err, &fieldErr) {
|
||||
return fieldErr, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// findCRDs looks through the provided resources and returns a slice with
|
||||
// the resources that are CRDs.
|
||||
func findCRDs(us []*unstructured.Unstructured) []*unstructured.Unstructured {
|
||||
var crds []*unstructured.Unstructured
|
||||
for _, u := range us {
|
||||
if IsCRD(u) {
|
||||
crds = append(crds, u)
|
||||
}
|
||||
}
|
||||
return crds
|
||||
}
|
||||
|
||||
// validateKind validates the value of the kind field of the resource.
|
||||
func (v *Validator) validateKind(u *unstructured.Unstructured) error {
|
||||
if u.GetKind() == "" {
|
||||
return field.Required(field.NewPath("kind"), "kind is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateName validates the value of the name field of the resource.
|
||||
func (v *Validator) validateName(u *unstructured.Unstructured) error {
|
||||
if u.GetName() == "" {
|
||||
return field.Required(field.NewPath("metadata", "name"), "name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNamespace validates the value of the namespace field of the resource.
|
||||
func (v *Validator) validateNamespace(u *unstructured.Unstructured, crds []*unstructured.Unstructured) error {
|
||||
// skip namespace validation if kind is missing (avoid redundant error)
|
||||
if u.GetKind() == "" {
|
||||
return nil
|
||||
}
|
||||
scope, err := LookupResourceScope(u, crds, v.Mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ns := u.GetNamespace()
|
||||
if scope == meta.RESTScopeNamespace && ns == "" {
|
||||
return field.Required(field.NewPath("metadata", "namespace"), "namespace is required")
|
||||
}
|
||||
if scope == meta.RESTScopeRoot && ns != "" {
|
||||
return field.Invalid(field.NewPath("metadata", "namespace"), ns, "namespace must be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -1,289 +0,0 @@
|
|||
// Copyright 2021 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package object_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/testutil"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
resources []*unstructured.Unstructured
|
||||
expectedError error
|
||||
}{
|
||||
"missing kind": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: &object.MultiValidationError{
|
||||
Errors: []*object.ValidationError{
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "apps",
|
||||
Version: "v1",
|
||||
Kind: "",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "kind",
|
||||
BadValue: "",
|
||||
Detail: "kind is required",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"errors are reported for resources": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
`,
|
||||
),
|
||||
},
|
||||
expectedError: &object.MultiValidationError{
|
||||
Errors: []*object.ValidationError{
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "apps",
|
||||
Version: "v1",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "",
|
||||
Namespace: "",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
BadValue: "",
|
||||
Detail: "name is required",
|
||||
},
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "",
|
||||
Detail: "namespace is required",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"error is reported for all resources": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
namespace: default
|
||||
`),
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
namespace: default
|
||||
`,
|
||||
),
|
||||
},
|
||||
expectedError: &object.MultiValidationError{
|
||||
Errors: []*object.ValidationError{
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "apps",
|
||||
Version: "v1",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "",
|
||||
Namespace: "default",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
BadValue: "",
|
||||
Detail: "name is required",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "apps",
|
||||
Version: "v1",
|
||||
Kind: "StatefulSet",
|
||||
},
|
||||
Name: "",
|
||||
Namespace: "default",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
BadValue: "",
|
||||
Detail: "name is required",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"error is reported if a cluster-scoped resource has namespace set": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: foo
|
||||
namespace: default
|
||||
`,
|
||||
),
|
||||
},
|
||||
expectedError: &object.MultiValidationError{
|
||||
Errors: []*object.ValidationError{
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Kind: "Namespace",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeInvalid,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "default",
|
||||
Detail: "namespace must be empty",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"error is reported if a namespace-scoped resource doesn't have namespace set": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: foo
|
||||
`,
|
||||
),
|
||||
},
|
||||
expectedError: &object.MultiValidationError{
|
||||
Errors: []*object.ValidationError{
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "apps",
|
||||
Version: "v1",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "",
|
||||
Detail: "namespace is required",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope for CRs are found in CRDs if available": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: customs.custom.io
|
||||
spec:
|
||||
group: custom.io
|
||||
names:
|
||||
kind: Custom
|
||||
scope: Cluster
|
||||
versions:
|
||||
- name: v1
|
||||
`,
|
||||
),
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: custom.io/v1
|
||||
kind: Custom
|
||||
metadata:
|
||||
name: foo
|
||||
namespace: default
|
||||
`,
|
||||
),
|
||||
},
|
||||
expectedError: &object.MultiValidationError{
|
||||
Errors: []*object.ValidationError{
|
||||
{
|
||||
GroupVersionKind: schema.GroupVersionKind{
|
||||
Group: "custom.io",
|
||||
Version: "v1",
|
||||
Kind: "Custom",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
FieldErrors: []*field.Error{
|
||||
{
|
||||
Type: field.ErrorTypeInvalid,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "default",
|
||||
Detail: "namespace must be empty",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
tf := cmdtesting.NewTestFactory().WithNamespace("test-ns")
|
||||
defer tf.Cleanup()
|
||||
|
||||
mapper, err := tf.ToRESTMapper()
|
||||
require.NoError(t, err)
|
||||
crdGV := schema.GroupVersion{Group: "apiextensions.k8s.io", Version: "v1"}
|
||||
crdMapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{crdGV})
|
||||
crdMapper.AddSpecific(crdGV.WithKind("CustomResourceDefinition"),
|
||||
crdGV.WithResource("customresourcedefinitions"),
|
||||
crdGV.WithResource("customresourcedefinition"), meta.RESTScopeRoot)
|
||||
mapper = meta.MultiRESTMapper([]meta.RESTMapper{mapper, crdMapper})
|
||||
|
||||
validator := &object.Validator{
|
||||
Mapper: mapper,
|
||||
}
|
||||
err = validator.Validate(tc.resources)
|
||||
if tc.expectedError == nil {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.EqualError(t, err, tc.expectedError.Error())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2022 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
)
|
||||
|
||||
func NewError(cause error, ids ...object.ObjMetadata) *Error {
|
||||
return &Error{
|
||||
ids: object.ObjMetadataSet(ids),
|
||||
cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// Error wraps an error with the object or objects it applies to.
|
||||
type Error struct {
|
||||
ids object.ObjMetadataSet
|
||||
cause error
|
||||
}
|
||||
|
||||
// Identifiers returns zero or more object IDs which are invalid.
|
||||
func (ve *Error) Identifiers() object.ObjMetadataSet {
|
||||
return ve.ids
|
||||
}
|
||||
|
||||
// Unwrap returns the cause of the error.
|
||||
// This may be useful when printing the cause without printing the identifiers.
|
||||
func (ve *Error) Unwrap() error {
|
||||
return ve.cause
|
||||
}
|
||||
|
||||
// Error stringifies the the error.
|
||||
func (ve *Error) Error() string {
|
||||
switch {
|
||||
case len(ve.ids) == 0:
|
||||
return fmt.Sprintf("validation error: %v", ve.cause.Error())
|
||||
case len(ve.ids) == 1:
|
||||
return fmt.Sprintf("invalid object: %q: %v", ve.ids[0], ve.cause.Error())
|
||||
default:
|
||||
var b strings.Builder
|
||||
_, _ = fmt.Fprintf(&b, "invalid objects: [%q", ve.ids[0])
|
||||
for _, id := range ve.ids[1:] {
|
||||
_, _ = fmt.Fprintf(&b, ", %q", id)
|
||||
}
|
||||
_, _ = fmt.Fprintf(&b, "] %v", ve.cause)
|
||||
return b.String()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright 2022 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"sigs.k8s.io/cli-utils/pkg/multierror"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
)
|
||||
|
||||
// Validator contains functionality for validating a set of resources prior
|
||||
// to being used by the Apply functionality. This imposes some constraint not
|
||||
// always required, such as namespaced resources must have the namespace set.
|
||||
type Validator struct {
|
||||
Mapper meta.RESTMapper
|
||||
}
|
||||
|
||||
// Validate validates the provided resources. A RESTMapper will be used
|
||||
// to fetch type information from the live cluster.
|
||||
func (v *Validator) Validate(objs []*unstructured.Unstructured) error {
|
||||
crds := findCRDs(objs)
|
||||
var errs []error
|
||||
for _, obj := range objs {
|
||||
var objErrors []error
|
||||
if err := v.validateKind(obj); err != nil {
|
||||
objErrors = append(objErrors, err)
|
||||
}
|
||||
if err := v.validateName(obj); err != nil {
|
||||
objErrors = append(objErrors, err)
|
||||
}
|
||||
if err := v.validateNamespace(obj, crds); err != nil {
|
||||
objErrors = append(objErrors, err)
|
||||
}
|
||||
if len(objErrors) > 0 {
|
||||
errs = append(errs, NewError(multierror.Wrap(objErrors...),
|
||||
object.UnstructuredToObjMetadata(obj)))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return multierror.Wrap(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findCRDs looks through the provided resources and returns a slice with
|
||||
// the resources that are CRDs.
|
||||
func findCRDs(us []*unstructured.Unstructured) []*unstructured.Unstructured {
|
||||
var crds []*unstructured.Unstructured
|
||||
for _, u := range us {
|
||||
if object.IsCRD(u) {
|
||||
crds = append(crds, u)
|
||||
}
|
||||
}
|
||||
return crds
|
||||
}
|
||||
|
||||
// validateKind validates the value of the kind field of the resource.
|
||||
func (v *Validator) validateKind(u *unstructured.Unstructured) error {
|
||||
if u.GetKind() == "" {
|
||||
return field.Required(field.NewPath("kind"), "kind is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateName validates the value of the name field of the resource.
|
||||
func (v *Validator) validateName(u *unstructured.Unstructured) error {
|
||||
if u.GetName() == "" {
|
||||
return field.Required(field.NewPath("metadata", "name"), "name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNamespace validates the value of the namespace field of the resource.
|
||||
func (v *Validator) validateNamespace(u *unstructured.Unstructured, crds []*unstructured.Unstructured) error {
|
||||
// skip namespace validation if kind is missing (avoid redundant error)
|
||||
if u.GetKind() == "" {
|
||||
return nil
|
||||
}
|
||||
scope, err := object.LookupResourceScope(u, crds, v.Mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ns := u.GetNamespace()
|
||||
if scope == meta.RESTScopeNamespace && ns == "" {
|
||||
return field.Required(field.NewPath("metadata", "namespace"), "namespace is required")
|
||||
}
|
||||
if scope == meta.RESTScopeRoot && ns != "" {
|
||||
return field.Invalid(field.NewPath("metadata", "namespace"), ns, "namespace must be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,274 @@
|
|||
// Copyright 2022 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package validation_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
|
||||
"sigs.k8s.io/cli-utils/pkg/multierror"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/object/validation"
|
||||
"sigs.k8s.io/cli-utils/pkg/testutil"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
resources []*unstructured.Unstructured
|
||||
expectedError error
|
||||
}{
|
||||
"missing kind": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: validation.NewError(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "kind",
|
||||
BadValue: "",
|
||||
Detail: "kind is required",
|
||||
},
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
},
|
||||
"multiple errors in one object": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: validation.NewError(
|
||||
multierror.New(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
BadValue: "",
|
||||
Detail: "name is required",
|
||||
},
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "",
|
||||
Detail: "namespace is required",
|
||||
},
|
||||
),
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "",
|
||||
Namespace: "",
|
||||
},
|
||||
),
|
||||
},
|
||||
"one error in multiple object": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": map[string]interface{}{
|
||||
"namespace": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "StatefulSet",
|
||||
"metadata": map[string]interface{}{
|
||||
"namespace": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: multierror.New(
|
||||
validation.NewError(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
BadValue: "",
|
||||
Detail: "name is required",
|
||||
},
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
validation.NewError(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.name",
|
||||
BadValue: "",
|
||||
Detail: "name is required",
|
||||
},
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "StatefulSet",
|
||||
},
|
||||
Name: "",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
"namespace must be empty (cluster-scoped)": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Namespace",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
"namespace": "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: validation.NewError(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeInvalid,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "default",
|
||||
Detail: "namespace must be empty",
|
||||
},
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "",
|
||||
Kind: "Namespace",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
},
|
||||
"namespace is required (namespace-scoped)": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedError: validation.NewError(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeRequired,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "",
|
||||
Detail: "namespace is required",
|
||||
},
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "",
|
||||
},
|
||||
),
|
||||
},
|
||||
"scope for CRs are found in CRDs if available": {
|
||||
resources: []*unstructured.Unstructured{
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: customs.custom.io
|
||||
spec:
|
||||
group: custom.io
|
||||
names:
|
||||
kind: Custom
|
||||
scope: Cluster
|
||||
versions:
|
||||
- name: v1
|
||||
`,
|
||||
),
|
||||
testutil.Unstructured(t, `
|
||||
apiVersion: custom.io/v1
|
||||
kind: Custom
|
||||
metadata:
|
||||
name: foo
|
||||
namespace: default
|
||||
`,
|
||||
),
|
||||
},
|
||||
expectedError: validation.NewError(
|
||||
&field.Error{
|
||||
Type: field.ErrorTypeInvalid,
|
||||
Field: "metadata.namespace",
|
||||
BadValue: "default",
|
||||
Detail: "namespace must be empty",
|
||||
},
|
||||
object.ObjMetadata{
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "custom.io",
|
||||
Kind: "Custom",
|
||||
},
|
||||
Name: "foo",
|
||||
Namespace: "default",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
tf := cmdtesting.NewTestFactory().WithNamespace("test-ns")
|
||||
defer tf.Cleanup()
|
||||
|
||||
mapper, err := tf.ToRESTMapper()
|
||||
require.NoError(t, err)
|
||||
crdGV := schema.GroupVersion{Group: "apiextensions.k8s.io", Version: "v1"}
|
||||
crdMapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{crdGV})
|
||||
crdMapper.AddSpecific(crdGV.WithKind("CustomResourceDefinition"),
|
||||
crdGV.WithResource("customresourcedefinitions"),
|
||||
crdGV.WithResource("customresourcedefinition"), meta.RESTScopeRoot)
|
||||
mapper = meta.MultiRESTMapper([]meta.RESTMapper{mapper, crdMapper})
|
||||
|
||||
validator := &validation.Validator{
|
||||
Mapper: mapper,
|
||||
}
|
||||
err = validator.Validate(tc.resources)
|
||||
if tc.expectedError == nil {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.EqualError(t, err, tc.expectedError.Error())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -95,23 +95,25 @@ spec:
|
|||
image: k8s.gcr.io/pause:2.0
|
||||
`))
|
||||
|
||||
var podA = []byte(strings.TrimSpace(`
|
||||
var podATemplate = `
|
||||
kind: Pod
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: pod-a
|
||||
namespace: test
|
||||
namespace: {{.Namespace}}
|
||||
annotations:
|
||||
config.kubernetes.io/apply-time-mutation: |
|
||||
- sourceRef:
|
||||
kind: Pod
|
||||
name: pod-b
|
||||
namespace: {{.Namespace}}
|
||||
sourcePath: $.status.podIP
|
||||
targetPath: $.spec.containers[?(@.name=="nginx")].env[?(@.name=="SERVICE_HOST")].value
|
||||
token: ${pob-b-ip}
|
||||
- sourceRef:
|
||||
kind: Pod
|
||||
name: pod-b
|
||||
namespace: {{.Namespace}}
|
||||
sourcePath: $.spec.containers[?(@.name=="nginx")].ports[?(@.name=="tcp")].containerPort
|
||||
targetPath: $.spec.containers[?(@.name=="nginx")].env[?(@.name=="SERVICE_HOST")].value
|
||||
token: ${pob-b-port}
|
||||
|
@ -125,14 +127,14 @@ spec:
|
|||
env:
|
||||
- name: SERVICE_HOST
|
||||
value: "${pob-b-ip}:${pob-b-port}"
|
||||
`))
|
||||
`
|
||||
|
||||
var podB = []byte(strings.TrimSpace(`
|
||||
var podBTemplate = `
|
||||
kind: Pod
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: pod-b
|
||||
namespace: test
|
||||
namespace: {{.Namespace}}
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
|
@ -140,4 +142,4 @@ spec:
|
|||
ports:
|
||||
- name: tcp
|
||||
containerPort: 80
|
||||
`))
|
||||
`
|
||||
|
|
|
@ -4,8 +4,10 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -91,7 +93,7 @@ func withDependsOn(obj *unstructured.Unstructured, dep string) *unstructured.Uns
|
|||
}
|
||||
|
||||
func deleteUnstructuredAndWait(ctx context.Context, c client.Client, obj *unstructured.Unstructured) {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
|
||||
err := c.Delete(ctx, obj,
|
||||
client.PropagationPolicy(metav1.DeletePropagationForeground))
|
||||
|
@ -102,7 +104,7 @@ func deleteUnstructuredAndWait(ctx context.Context, c client.Client, obj *unstru
|
|||
}
|
||||
|
||||
func waitForDeletion(ctx context.Context, c client.Client, obj *unstructured.Unstructured) {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
resultObj := ref.ToUnstructured()
|
||||
|
||||
timeout := 30 * time.Second
|
||||
|
@ -133,7 +135,7 @@ func waitForDeletion(ctx context.Context, c client.Client, obj *unstructured.Uns
|
|||
}
|
||||
|
||||
func createUnstructuredAndWait(ctx context.Context, c client.Client, obj *unstructured.Unstructured) {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
|
||||
err := c.Create(ctx, obj)
|
||||
Expect(err).NotTo(HaveOccurred(),
|
||||
|
@ -143,7 +145,7 @@ func createUnstructuredAndWait(ctx context.Context, c client.Client, obj *unstru
|
|||
}
|
||||
|
||||
func waitForCreation(ctx context.Context, c client.Client, obj *unstructured.Unstructured) {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
resultObj := ref.ToUnstructured()
|
||||
|
||||
timeout := 30 * time.Second
|
||||
|
@ -175,7 +177,7 @@ func waitForCreation(ctx context.Context, c client.Client, obj *unstructured.Uns
|
|||
}
|
||||
|
||||
func assertUnstructuredExists(ctx context.Context, c client.Client, obj *unstructured.Unstructured) *unstructured.Unstructured {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
resultObj := ref.ToUnstructured()
|
||||
|
||||
err := c.Get(ctx, types.NamespacedName{
|
||||
|
@ -188,7 +190,7 @@ func assertUnstructuredExists(ctx context.Context, c client.Client, obj *unstruc
|
|||
}
|
||||
|
||||
func assertUnstructuredDoesNotExist(ctx context.Context, c client.Client, obj *unstructured.Unstructured) {
|
||||
ref := mutation.NewResourceReference(obj)
|
||||
ref := mutation.ResourceReferenceFromUnstructured(obj)
|
||||
resultObj := ref.ToUnstructured()
|
||||
|
||||
err := c.Get(ctx, types.NamespacedName{
|
||||
|
@ -284,3 +286,16 @@ func manifestToUnstructured(manifest []byte) *unstructured.Unstructured {
|
|||
Object: u,
|
||||
}
|
||||
}
|
||||
|
||||
func templateToUnstructured(tmpl string, data interface{}) *unstructured.Unstructured {
|
||||
t, err := template.New("manifest").Parse(tmpl)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to parse manifest go-template: %w", err))
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
err = t.Execute(&buffer, data)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to execute manifest go-template: %w", err))
|
||||
}
|
||||
return manifestToUnstructured(buffer.Bytes())
|
||||
}
|
||||
|
|
|
@ -142,14 +142,15 @@ var _ = Describe("Applier", func() {
|
|||
ctx, cancel = context.WithTimeout(context.Background(), defaultAfterTestTimeout)
|
||||
defer cancel()
|
||||
// clean up resources created by the tests
|
||||
fields := struct{ Namespace string }{Namespace: namespace.GetName()}
|
||||
objs := []*unstructured.Unstructured{
|
||||
manifestToUnstructured(cr),
|
||||
manifestToUnstructured(crd),
|
||||
withNamespace(manifestToUnstructured(pod1), namespace.GetName()),
|
||||
withNamespace(manifestToUnstructured(pod2), namespace.GetName()),
|
||||
withNamespace(manifestToUnstructured(pod3), namespace.GetName()),
|
||||
withNamespace(manifestToUnstructured(podA), namespace.GetName()),
|
||||
withNamespace(manifestToUnstructured(podB), namespace.GetName()),
|
||||
templateToUnstructured(podATemplate, fields),
|
||||
templateToUnstructured(podBTemplate, fields),
|
||||
withNamespace(manifestToUnstructured(deployment1), namespace.GetName()),
|
||||
manifestToUnstructured(apiservice1),
|
||||
}
|
||||
|
|
|
@ -38,8 +38,9 @@ func mutationTest(ctx context.Context, c client.Client, invConfig InventoryConfi
|
|||
|
||||
inv := invConfig.InvWrapperFunc(invConfig.InventoryFactoryFunc(inventoryName, namespaceName, "test"))
|
||||
|
||||
podAObj := withNamespace(manifestToUnstructured(podA), namespaceName)
|
||||
podBObj := withNamespace(manifestToUnstructured(podB), namespaceName)
|
||||
fields := struct{ Namespace string }{Namespace: namespaceName}
|
||||
podAObj := templateToUnstructured(podATemplate, fields)
|
||||
podBObj := templateToUnstructured(podBTemplate, fields)
|
||||
|
||||
// Dependency order: podA -> podB
|
||||
// Apply order: podB, podA
|
||||
|
|
Loading…
Reference in New Issue