diff --git a/go.mod b/go.mod index 72ef698a18b..639a01221fb 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( contrib.go.opencensus.io/exporter/prometheus v0.4.2 contrib.go.opencensus.io/exporter/stackdriver v0.13.14 github.com/davecgh/go-spew v1.1.1 + github.com/dominikbraun/graph v0.16.2 github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v1.2.4 github.com/go-logr/zapr v1.2.3 diff --git a/go.sum b/go.sum index da6ede985ba..24fcabfc8fd 100644 --- a/go.sum +++ b/go.sum @@ -406,6 +406,8 @@ github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arX github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dominikbraun/graph v0.16.2 h1:EUndsCgHNQDHBdT4Q4M9GBePH3Tt0sV7DDPVWzfbEh4= +github.com/dominikbraun/graph v0.16.2/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= diff --git a/pkg/controller/expansion/expansion_controller.go b/pkg/controller/expansion/expansion_controller.go index be462937a65..cb87095fc79 100644 --- a/pkg/controller/expansion/expansion_controller.go +++ b/pkg/controller/expansion/expansion_controller.go @@ -8,6 +8,7 @@ import ( "github.com/open-policy-agent/frameworks/constraint/pkg/externaldata" "github.com/open-policy-agent/gatekeeper/apis/expansion/unversioned" "github.com/open-policy-agent/gatekeeper/apis/expansion/v1alpha1" + expansionv1alpha1 "github.com/open-policy-agent/gatekeeper/apis/expansion/v1alpha1" "github.com/open-policy-agent/gatekeeper/apis/status/v1beta1" statusv1beta1 "github.com/open-policy-agent/gatekeeper/apis/status/v1beta1" "github.com/open-policy-agent/gatekeeper/pkg/expansion" @@ -20,10 +21,12 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -33,6 +36,9 @@ import ( var log = logf.Log.WithName("controller").WithValues("kind", "ExpansionTemplate", logging.Process, "template_expansion_controller") +// eventQueueSize is how many events to queue before blocking. +const eventQueueSize = 1024 + type Adder struct { WatchManager *watch.Manager ExpansionSystem *expansion.System @@ -78,11 +84,18 @@ type Reconciler struct { registry *etRegistry statusClient client.StatusClient tracker *readiness.Tracker + events chan event.GenericEvent + eventSource source.Source getPod func(context.Context) (*corev1.Pod, error) } -func newReconciler(mgr manager.Manager, system *expansion.System, getPod func(ctx context.Context) (*corev1.Pod, error), tracker *readiness.Tracker) *Reconciler { +func newReconciler(mgr manager.Manager, + system *expansion.System, + getPod func(ctx context.Context) (*corev1.Pod, error), + tracker *readiness.Tracker, +) *Reconciler { + ev := make(chan event.GenericEvent, eventQueueSize) return &Reconciler{ Client: mgr.GetClient(), system: system, @@ -91,15 +104,28 @@ func newReconciler(mgr manager.Manager, system *expansion.System, getPod func(ct statusClient: mgr.GetClient(), getPod: getPod, tracker: tracker, + events: ev, + eventSource: &source.Channel{Source: ev, DestBufferSize: 1024}, } } -func add(mgr manager.Manager, r reconcile.Reconciler) error { +func add(mgr manager.Manager, r *Reconciler) error { c, err := controller.New("expansion-template-controller", mgr, controller.Options{Reconciler: r}) if err != nil { return err } + // Watch for enqueued events + if r.eventSource != nil { + err = c.Watch( + r.eventSource, + &handler.EnqueueRequestForObject{}) + if err != nil { + return err + } + } + + // Watch for changes to ExpansionTemplates return c.Watch( &source.Kind{Type: &v1alpha1.ExpansionTemplate{}}, &handler.EnqueueRequestForObject{}) @@ -107,7 +133,7 @@ func add(mgr manager.Manager, r reconcile.Reconciler) error { func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { defer r.registry.report(ctx) - log.Info("Reconcile", "request", request, "namespace", request.Namespace, "name", request.Name) + log.V(logging.DebugLevel).Info("Reconcile", "request", request, "namespace", request.Namespace, "name", request.Name) deleted := false versionedET := &v1alpha1.ExpansionTemplate{} @@ -123,6 +149,11 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( if err := r.scheme.Convert(versionedET, et, nil); err != nil { return reconcile.Result{}, err } + oldConflicts := r.system.GetConflicts() + + if !et.GetDeletionTimestamp().IsZero() { + deleted = true + } if deleted { // et will be an empty struct. We set the metadata name, which is @@ -132,9 +163,10 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( r.getTracker().TryCancelExpect(versionedET) return reconcile.Result{}, err } - log.Info("removed expansion template", "template name", et.GetName()) + log.V(logging.DebugLevel).Info("removed expansion template", "template name", et.GetName()) r.registry.remove(request.NamespacedName) r.getTracker().CancelExpect(versionedET) + r.queueConflicts(oldConflicts) return reconcile.Result{}, r.deleteStatus(ctx, request.NamespacedName.Name) } @@ -149,9 +181,38 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( log.Error(upsertErr, "upserting template", "template_name", et.GetName()) } + r.queueConflicts(oldConflicts) return reconcile.Result{}, r.updateOrCreatePodStatus(ctx, et, upsertErr) } +func (r *Reconciler) queueConflicts(old expansion.IDSet) { + for tID := range symmetricDiff(old, r.system.GetConflicts()) { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(expansionv1alpha1.GroupVersion.WithKind("ExpansionTemplate")) + // ExpansionTemplate is cluster-scoped, so we do not set namespace + u.SetName(string(tID)) + + r.events <- event.GenericEvent{Object: u} + } +} + +func symmetricDiff(x, y expansion.IDSet) expansion.IDSet { + sDiff := make(expansion.IDSet) + + for id := range x { + if _, exists := y[id]; !exists { + sDiff[id] = true + } + } + for id := range y { + if _, exists := x[id]; !exists { + sDiff[id] = true + } + } + + return sDiff +} + func (r *Reconciler) deleteStatus(ctx context.Context, etName string) error { status := &v1beta1.ExpansionTemplatePodStatus{} pod, err := r.getPod(ctx) @@ -227,5 +288,5 @@ func setStatusError(status *v1beta1.ExpansionTemplatePodStatus, etErr error) { } e := &v1beta1.ExpansionTemplateError{Message: etErr.Error()} - status.Status.Errors = append(status.Status.Errors, e) + status.Status.Errors = []*statusv1beta1.ExpansionTemplateError{e} } diff --git a/pkg/expansion/db.go b/pkg/expansion/db.go new file mode 100644 index 00000000000..997f3ba1adb --- /dev/null +++ b/pkg/expansion/db.go @@ -0,0 +1,290 @@ +package expansion + +import ( + "errors" + "fmt" + + "github.com/dominikbraun/graph" + expansionunversioned "github.com/open-policy-agent/gatekeeper/apis/expansion/unversioned" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type templateDB interface { + upsert(*expansionunversioned.ExpansionTemplate) error + remove(*expansionunversioned.ExpansionTemplate) + templatesForGVK(gvk schema.GroupVersionKind) []*expansionunversioned.ExpansionTemplate + getConflicts() IDSet +} + +var _ templateDB = &db{} + +type adjList map[schema.GroupVersionKind]IDSet + +// hashID is passed to the graphing package. +var hashID = func(id TemplateID) string { + return string(id) +} + +type templateState struct { + template *expansionunversioned.ExpansionTemplate + hasConflicts bool +} + +type edge struct { + x TemplateID + y TemplateID +} + +type db struct { + store map[TemplateID]*templateState + + // graph stores a graph of ExpansionTemplate. A directed edge from template A + // to B means that the template A's `generatedGVK` matches template B's `applyTo`. + graph graph.Graph[string, TemplateID] + + // `matchers` and `generators` creates the necessary mappings to be able to + // determine the inbound and outbound edges of a given template in O(1). + // matchers is a mapping of GVKs to templates that match (applyTo) that GVK. + matchers adjList + // generators is a mapping of GVKs to templates that generate that GVK. + generators adjList +} + +func newDB() *db { + return &db{ + store: make(map[TemplateID]*templateState), + graph: graph.New(hashID, graph.Directed()), + matchers: make(adjList), + generators: make(adjList), + } +} + +func (d *db) getConflicts() IDSet { + confs := make(IDSet) + for tID, tState := range d.store { + if tState.hasConflicts { + confs[tID] = true + } + } + return confs +} + +// handleAdd adds the template to the DB, returning true if a cycle was created. +// The template is added even if a cycle was found. +func (d *db) handleAdd(template *expansionunversioned.ExpansionTemplate) (bool, error) { + id := keyForTemplate(template) + + // We should always remove the old template before handleAdd. If we + // didn't, that's a bug. + if _, exists := d.store[id]; exists { + panic(fmt.Errorf("tried to add template %q that already exists", id)) + } + + // Update storage + d.store[id] = &templateState{template: template.DeepCopy(), hasConflicts: false} + + // Update generators + genGVK := genGVKToSchemaGVK(template.Spec.GeneratedGVK) + if _, exists := d.generators[genGVK]; !exists { + d.generators[genGVK] = make(map[TemplateID]bool) + } + d.generators[genGVK][id] = true + + // Update matchers + matches := applyToGVKs(template) + for _, m := range matches { + if _, exists := d.matchers[m]; !exists { + d.matchers[m] = make(map[TemplateID]bool) + } + d.matchers[m][id] = true + } + + // Add vertex if DNE + if _, err := d.graph.Vertex(hashID(id)); err != nil { + if errors.Is(err, graph.ErrVertexNotFound) { + if err := d.graph.AddVertex(id); err != nil { + return false, fmt.Errorf("adding vertex to graph: %w", err) + } + } else { + return false, fmt.Errorf("unexpected error getting vertex for template %s: %w", id, err) + } + } + + // Add edges + edges := d.edgesForTemplate(template) + cycle := false + for _, e := range edges { + from := hashID(e.x) + to := hashID(e.y) + + createsCycle, err := graph.CreatesCycle(d.graph, from, to) + if err != nil { + return false, fmt.Errorf("checking cycle for template %s: %w", id, err) + } + cycle = createsCycle || cycle + + if err = d.graph.AddEdge(from, to); err != nil { + return false, fmt.Errorf("adding edge for template %s: %w", id, err) + } + } + + return cycle, nil +} + +func (d *db) edgesForTemplate(template *expansionunversioned.ExpansionTemplate) []edge { + var edges []edge + id := keyForTemplate(template) + genGVK := genGVKToSchemaGVK(template.Spec.GeneratedGVK) + + // Add out-bound edges (from this template's generated GVK to other + // templates' matched GVKs) + for t := range d.matchers[genGVK] { + edges = append(edges, edge{id, t}) + } + + // Add in-bound edges (from other templates' generated GVK to this template's + // matched GVK) + for _, gen := range applyToGVKs(template) { + for t := range d.generators[gen] { + edges = append(edges, edge{t, id}) + } + } + + return edges +} + +func (d *db) handleRemove(id TemplateID) { + // The template must exist. Existence checks should be done upstream. + if _, exists := d.store[id]; !exists { + panic(fmt.Errorf("called handleRemove for template %q, but template DNE in store", id)) + } + template := d.store[id].template + + // Update storage + delete(d.store, id) + + // Update generators + genGVK := genGVKToSchemaGVK(template.Spec.GeneratedGVK) + if _, exists := d.generators[genGVK]; exists { + delete(d.generators[genGVK], id) + if len(d.generators[genGVK]) == 0 { + delete(d.generators, genGVK) + } + } else { + panic(fmt.Errorf("[template %q] inconsistent db - expected key %s to exist in generators", id, genGVK)) + } + + // Update matchers + matches := applyToGVKs(template) + for _, m := range matches { + if _, exists := d.matchers[m]; exists { + delete(d.matchers[m], id) + if len(d.matchers[m]) == 0 { + delete(d.matchers, m) + } + } else { + panic(fmt.Errorf("[template %q] inconsistent db - expected key %s to exist in matches", id, m)) + } + } + + // Remove edges + edges := d.edgesForTemplate(template) + for _, e := range edges { + from := hashID(e.x) + to := hashID(e.y) + if err := d.graph.RemoveEdge(from, to); err != nil { + panic(fmt.Errorf("[template %q] unexpected error removing edge: %w", id, err)) + } + } +} + +func (d *db) updateCycles() { + // First reset all conflicts + for _, ts := range d.store { + ts.hasConflicts = false + } + + conflicts, err := graph.StronglyConnectedComponents(d.graph) + if err != nil { + panic(fmt.Errorf("error getting SCCs: %w", err)) + } + // All strongly connect components containing more than 1 vertex are a cycle + for _, scc := range conflicts { + if len(scc) <= 1 { + continue + } + for _, id := range scc { + d.store[TemplateID(id)].hasConflicts = true + } + } +} + +func (d *db) upsert(template *expansionunversioned.ExpansionTemplate) error { + id := keyForTemplate(template) + old, hasOld := d.store[id] + if hasOld { + d.handleRemove(id) + } + + newCycle, err := d.handleAdd(template) + if err != nil { + return fmt.Errorf("adding template to db: %w", err) + } + // If the new/updated template caused a cycle, or the previous template belonged + // to a cycle, then we need to re-check the graph for cycles + if newCycle || (hasOld && old.hasConflicts) { + d.updateCycles() + } + + if newCycle { + return fmt.Errorf("template forms expansion cycle") + } + return nil +} + +func (d *db) remove(template *expansionunversioned.ExpansionTemplate) { + id := keyForTemplate(template) + old, exists := d.store[id] + if !exists { + return + } + + d.handleRemove(id) + // If the removed template was part of a cycle, we need to recheck the graph + // in case that cycle was resolved + if old.hasConflicts { + d.updateCycles() + } +} + +func (d *db) templatesForGVK(gvk schema.GroupVersionKind) []*expansionunversioned.ExpansionTemplate { + var tset []*expansionunversioned.ExpansionTemplate + for tID := range d.matchers[gvk] { + // Sanity check. In theory, this should never happen, but if it does, we + // can't recover. + if _, exists := d.store[tID]; !exists { + panic(fmt.Errorf("inconsistent db - key %s exists in matchers but not cache", tID)) + } + + tState := d.store[tID] + if !tState.hasConflicts { + tset = append(tset, tState.template) + } + } + + return tset +} + +func applyToGVKs(template *expansionunversioned.ExpansionTemplate) []schema.GroupVersionKind { + var gvks []schema.GroupVersionKind + for _, apply := range template.Spec.ApplyTo { + for _, g := range apply.Groups { + for _, v := range apply.Versions { + for _, k := range apply.Kinds { + gvks = append(gvks, schema.GroupVersionKind{Group: g, Version: v, Kind: k}) + } + } + } + } + return gvks +} diff --git a/pkg/expansion/db_test.go b/pkg/expansion/db_test.go new file mode 100644 index 00000000000..32ce4051200 --- /dev/null +++ b/pkg/expansion/db_test.go @@ -0,0 +1,799 @@ +package expansion + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/open-policy-agent/gatekeeper/apis/expansion/unversioned" + "github.com/open-policy-agent/gatekeeper/pkg/expansion/fixtures" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + addOp = "UPSERT" + rmOp = "REMOVE" +) + +type templateOperation struct { + op string + template unversioned.ExpansionTemplate + + // wantErr is only relevant for add operations. + wantErr bool +} + +func TestDB(t *testing.T) { + tests := []struct { + name string + ops []templateOperation + wantMatchers adjList + wantGenerators adjList + wantStore map[TemplateID]*templateState + }{ + { + name: "add 1 template", + ops: []templateOperation{ + { + op: addOp, + template: *fixtures.TestTemplate("foo", 1, 2), + wantErr: false, + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("foo", 1, 2)): { + template: fixtures.TestTemplate("foo", 1, 2), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{ + Group: "group1", + Version: "v1", + Kind: "kind1", + }: { + keyForTemplate(fixtures.TestTemplate("foo", 1, 2)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{ + Group: "group2", + Version: "v2", + Kind: "kind2", + }: { + keyForTemplate(fixtures.TestTemplate("foo", 1, 2)): true, + }, + }, + }, + { + name: "add 1 template that has many applyTo", + ops: []templateOperation{ + { + op: addOp, + template: *fixtures.TempMultApply(), + wantErr: false, + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TempMultApply()): { + template: fixtures.TempMultApply(), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TempMultApply()): true, + }, + schema.GroupVersionKind{Group: "group11", Version: "v11", Kind: "kind11"}: { + keyForTemplate(fixtures.TempMultApply()): true, + }, + schema.GroupVersionKind{Group: "group11", Version: "v22", Kind: "kind11"}: { + keyForTemplate(fixtures.TempMultApply()): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TempMultApply()): true, + }, + }, + }, + { + name: "add 2 templates with same applyTo and genGVK", + ops: []templateOperation{ + { + op: addOp, + template: *fixtures.TestTemplate("t1", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t2", 1, 2), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("t1", 1, 2)): { + template: fixtures.TestTemplate("t1", 1, 2), + hasConflicts: false, + }, + keyForTemplate(fixtures.TestTemplate("t2", 1, 2)): { + template: fixtures.TestTemplate("t2", 1, 2), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{ + Group: "group1", + Version: "v1", + Kind: "kind1", + }: { + keyForTemplate(fixtures.TestTemplate("t1", 1, 2)): true, + keyForTemplate(fixtures.TestTemplate("t2", 1, 2)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{ + Group: "group2", + Version: "v2", + Kind: "kind2", + }: { + keyForTemplate(fixtures.TestTemplate("t1", 1, 2)): true, + keyForTemplate(fixtures.TestTemplate("t2", 1, 2)): true, + }, + }, + }, + { + name: "removing non-existing template does nothing", + ops: []templateOperation{ + { + op: addOp, + template: *fixtures.TestTemplate("foo", 1, 2), + }, + { + op: rmOp, + template: *fixtures.TestTemplate("DNE", 1, 2), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("foo", 1, 2)): { + template: fixtures.TestTemplate("foo", 1, 2), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("foo", 1, 2)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("foo", 1, 2)): true, + }, + }, + }, + { + name: "update existing template", + ops: []templateOperation{ + { + op: addOp, + template: *fixtures.TestTemplate("foo", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("foo", 3, 4), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("foo", 3, 4)): { + template: fixtures.TestTemplate("foo", 3, 4), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("foo", 3, 4)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group4", Version: "v4", Kind: "kind4"}: { + keyForTemplate(fixtures.TestTemplate("foo", 3, 4)): true, + }, + }, + }, + { + name: "adding cycle disables cyclic templates and leaves non-cyclic enabled", + ops: []templateOperation{ + // t1 -> t2 -> t3 -> t1 -> ... forms cycle + { + op: addOp, + template: *fixtures.TestTemplate("t1-2", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t2-3", 2, 3), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t3-1", 3, 1), + wantErr: true, + }, + // t2-8 is "touching" the cycle, but not part of it + { + op: addOp, + template: *fixtures.TestTemplate("t2-8", 2, 8), + }, + // t5 is completely disjoint from rest of graph + { + op: addOp, + template: *fixtures.TestTemplate("t5", 5, 6), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): { + template: fixtures.TestTemplate("t1-2", 1, 2), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): { + template: fixtures.TestTemplate("t2-3", 2, 3), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t3-1", 3, 1)): { + template: fixtures.TestTemplate("t3-1", 3, 1), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t2-8", 2, 8)): { + template: fixtures.TestTemplate("t2-8", 2, 8), + hasConflicts: false, + }, + keyForTemplate(fixtures.TestTemplate("t5", 5, 6)): { + template: fixtures.TestTemplate("t5", 5, 6), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + keyForTemplate(fixtures.TestTemplate("t2-8", 2, 8)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t3-1", 3, 1)): true, + }, + schema.GroupVersionKind{Group: "group5", Version: "v5", Kind: "kind5"}: { + keyForTemplate(fixtures.TestTemplate("t5", 5, 6)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t3-1", 3, 1)): true, + }, + schema.GroupVersionKind{Group: "group6", Version: "v6", Kind: "kind6"}: { + keyForTemplate(fixtures.TestTemplate("t5", 5, 6)): true, + }, + schema.GroupVersionKind{Group: "group8", Version: "v8", Kind: "kind8"}: { + keyForTemplate(fixtures.TestTemplate("t2-8", 2, 8)): true, + }, + }, + }, + { + name: "update template to produce cycle", + ops: []templateOperation{ + // t5 is completely disjoint from rest of graph + { + op: addOp, + template: *fixtures.TestTemplate("t5", 5, 6), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t3-4", 3, 4), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t1-2", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t2-3", 2, 3), + }, + // update 3-4 to form a cycle + { + op: addOp, + template: *fixtures.TestTemplate("t3-4", 3, 1), + wantErr: true, + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): { + template: fixtures.TestTemplate("t1-2", 1, 2), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): { + template: fixtures.TestTemplate("t2-3", 2, 3), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t3-4", 1, 1)): { + template: fixtures.TestTemplate("t3-4", 3, 1), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t5", 5, 6)): { + template: fixtures.TestTemplate("t5", 5, 6), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t3-4", 3, 4)): true, + }, + schema.GroupVersionKind{Group: "group5", Version: "v5", Kind: "kind5"}: { + keyForTemplate(fixtures.TestTemplate("t5", 5, 6)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t3-4", 3, 1)): true, + }, + schema.GroupVersionKind{Group: "group6", Version: "v6", Kind: "kind6"}: { + keyForTemplate(fixtures.TestTemplate("t5", 5, 6)): true, + }, + }, + }, + { + name: "fixing cycle re-enables templates but existing cycles still disabled", + ops: []templateOperation{ + // t7-8 and t8-7 form a cycle unconnected to the t1-t2-t3 cycle + { + op: addOp, + template: *fixtures.TestTemplate("t7-8", 7, 8), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t8-7", 8, 7), + wantErr: true, + }, + // t1 -> t2 -> t3 -> t1 -> ... forms cycle + { + op: addOp, + template: *fixtures.TestTemplate("t1-2", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t2-3", 2, 3), + }, + // t3-1 produces cycle + { + op: addOp, + template: *fixtures.TestTemplate("t3-1", 3, 1), + wantErr: true, + }, + // fix t3-1 to behavior like t3-4, thus fixing cycle + { + op: addOp, + template: *fixtures.TestTemplate("t3-1", 3, 4), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("t7-8", 7, 8)): { + template: fixtures.TestTemplate("t7-8", 7, 8), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t8-7", 8, 7)): { + template: fixtures.TestTemplate("t8-7", 8, 7), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): { + template: fixtures.TestTemplate("t1-2", 1, 2), + hasConflicts: false, + }, + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): { + template: fixtures.TestTemplate("t2-3", 2, 3), + hasConflicts: false, + }, + keyForTemplate(fixtures.TestTemplate("t3-1", 3, 4)): { + template: fixtures.TestTemplate("t3-1", 3, 4), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t3-1", 3, 1)): true, + }, + schema.GroupVersionKind{Group: "group7", Version: "v7", Kind: "kind7"}: { + keyForTemplate(fixtures.TestTemplate("t7-8", 7, 8)): true, + }, + schema.GroupVersionKind{Group: "group8", Version: "v8", Kind: "kind8"}: { + keyForTemplate(fixtures.TestTemplate("t8-7", 8, 7)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group4", Version: "v4", Kind: "kind4"}: { + keyForTemplate(fixtures.TestTemplate("t3-1", 3, 4)): true, + }, + schema.GroupVersionKind{Group: "group8", Version: "v8", Kind: "kind8"}: { + keyForTemplate(fixtures.TestTemplate("t7-8", 7, 8)): true, + }, + schema.GroupVersionKind{Group: "group7", Version: "v7", Kind: "kind7"}: { + keyForTemplate(fixtures.TestTemplate("t8-7", 8, 7)): true, + }, + }, + }, + { + name: "removing cycle re-enables templates but existing cycles still disabled", + ops: []templateOperation{ + // t7-8 and t8-7 form a cycle unconnected to the t1-t2-t3 cycle + { + op: addOp, + template: *fixtures.TestTemplate("t7-8", 7, 8), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t8-7", 8, 7), + wantErr: true, + }, + // t1 -> t2 -> t3 -> t1 -> ... forms cycle + { + op: addOp, + template: *fixtures.TestTemplate("t1-2", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t2-3", 2, 3), + }, + // t3-1 produces cycle + { + op: addOp, + template: *fixtures.TestTemplate("t3-1", 3, 1), + wantErr: true, + }, + // remove t3-1 to fix cycle + { + op: rmOp, + template: *fixtures.TestTemplate("t3-1", 3, 1), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("t7-8", 7, 8)): { + template: fixtures.TestTemplate("t7-8", 7, 8), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t8-7", 8, 7)): { + template: fixtures.TestTemplate("t8-7", 8, 7), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): { + template: fixtures.TestTemplate("t1-2", 1, 2), + hasConflicts: false, + }, + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): { + template: fixtures.TestTemplate("t2-3", 2, 3), + hasConflicts: false, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group7", Version: "v7", Kind: "kind7"}: { + keyForTemplate(fixtures.TestTemplate("t7-8", 7, 8)): true, + }, + schema.GroupVersionKind{Group: "group8", Version: "v8", Kind: "kind8"}: { + keyForTemplate(fixtures.TestTemplate("t8-7", 8, 7)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group8", Version: "v8", Kind: "kind8"}: { + keyForTemplate(fixtures.TestTemplate("t7-8", 7, 8)): true, + }, + schema.GroupVersionKind{Group: "group7", Version: "v7", Kind: "kind7"}: { + keyForTemplate(fixtures.TestTemplate("t8-7", 8, 7)): true, + }, + }, + }, + { + name: "removing 1 edge from double-edged cycle does not re-enable templates", + ops: []templateOperation{ + // t1 -> t2 -> t3 -> t1 -> ... forms cycle + { + op: addOp, + template: *fixtures.TestTemplate("t1-2", 1, 2), + }, + { + op: addOp, + template: *fixtures.TestTemplate("t2-3", 2, 3), + }, + // t3-1 produces cycle + { + op: addOp, + template: *fixtures.TestTemplate("t3-1", 3, 1), + wantErr: true, + }, + // ta3-1 produces double-edged cycle + { + op: addOp, + template: *fixtures.TestTemplate("ta3-1", 3, 1), + wantErr: true, + }, + // remove t3-1, but cycle still exists with ta3-1 + { + op: rmOp, + template: *fixtures.TestTemplate("t3-1", 3, 1), + }, + }, + wantStore: map[TemplateID]*templateState{ + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): { + template: fixtures.TestTemplate("t1-2", 1, 2), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): { + template: fixtures.TestTemplate("t2-3", 2, 3), + hasConflicts: true, + }, + keyForTemplate(fixtures.TestTemplate("ta3-1", 3, 1)): { + template: fixtures.TestTemplate("ta3-1", 3, 1), + hasConflicts: true, + }, + }, + wantMatchers: adjList{ + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("ta3-1", 3, 1)): true, + }, + }, + wantGenerators: adjList{ + schema.GroupVersionKind{Group: "group2", Version: "v2", Kind: "kind2"}: { + keyForTemplate(fixtures.TestTemplate("t1-2", 1, 2)): true, + }, + schema.GroupVersionKind{Group: "group3", Version: "v3", Kind: "kind3"}: { + keyForTemplate(fixtures.TestTemplate("t2-3", 2, 3)): true, + }, + schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}: { + keyForTemplate(fixtures.TestTemplate("ta3-1", 3, 1)): true, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := newDB() + + // Execute all the upsert/remove calls + executeOps(d, tc.ops, t) + + if df := cmp.Diff(d.matchers, tc.wantMatchers); df != "" { + t.Errorf("got matchers: %v\nbut want: %v\ndiff: %s", d.matchers, tc.wantMatchers, df) + } + + if df := cmp.Diff(d.generators, tc.wantGenerators); df != "" { + t.Errorf("got generators: %v\nbut want: %v\ndiff: %s", d.generators, tc.wantGenerators, df) + } + + if df := cmp.Diff(d.store, tc.wantStore, cmp.AllowUnexported(templateState{})); df != "" { + t.Errorf("got store: %v\nbut want: %v\ndiff: %s", d.generators, tc.wantGenerators, df) + } + }) + } +} + +func executeOps(db templateDB, ops []templateOperation, t *testing.T) { + for i := 0; i < len(ops); i++ { + op := ops[i] + switch op.op { + case addOp: + gotErr := db.upsert(&op.template) + if op.wantErr { + require.Error(t, gotErr, "want err: %t, got: %s", op.wantErr, gotErr) + } else if gotErr != nil { + t.Errorf("unexpected err upserting: %v", gotErr) + } + case rmOp: + if op.wantErr { + t.Fatalf("cannot set errFn for remove operation") + } + db.remove(&op.template) + default: + t.Fatalf("unrecognized operation: %s", op.op) + } + } +} + +func sortTemplates(temps []*unversioned.ExpansionTemplate) { + sort.Slice(temps, func(i, j int) bool { + return keyForTemplate(temps[i]) > keyForTemplate(temps[j]) + }) +} + +func TestTemplatesForGVK(t *testing.T) { + tests := []struct { + name string + add []*unversioned.ExpansionTemplate + gvk schema.GroupVersionKind + want []*unversioned.ExpansionTemplate + }{ + { + name: "no templates in db returns nothing", + gvk: schema.GroupVersionKind{Group: "a", Version: "b", Kind: "c"}, + }, + { + name: "2 templates no cycles", + gvk: schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}, + add: []*unversioned.ExpansionTemplate{ + fixtures.TestTemplate("t1-2", 1, 2), + fixtures.TestTemplate("t2-3", 2, 3), + }, + want: []*unversioned.ExpansionTemplate{ + fixtures.TestTemplate("t1-2", 1, 2), + }, + }, + { + name: "cycle not returned", + gvk: schema.GroupVersionKind{Group: "group1", Version: "v1", Kind: "kind1"}, + add: []*unversioned.ExpansionTemplate{ + fixtures.TestTemplate("t1-2", 1, 2), + fixtures.TestTemplate("t2-3", 2, 3), + fixtures.TestTemplate("t3-1", 3, 1), + }, + }, + { + name: "cycle not returned but non-cyclics are", + gvk: schema.GroupVersionKind{Group: "group4", Version: "v4", Kind: "kind4"}, + add: []*unversioned.ExpansionTemplate{ + fixtures.TestTemplate("t1-2", 1, 2), + fixtures.TestTemplate("t2-3", 2, 3), + fixtures.TestTemplate("t3-1", 3, 1), + fixtures.TestTemplate("t4-5", 4, 5), + fixtures.TestTemplate("t4-6", 4, 6), + }, + want: []*unversioned.ExpansionTemplate{ + fixtures.TestTemplate("t4-5", 4, 5), + fixtures.TestTemplate("t4-6", 4, 6), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := newDB() + for _, t := range tc.add { + _ = d.upsert(t) + } + + got := d.templatesForGVK(tc.gvk) + sortTemplates(got) + sortTemplates(tc.want) + + require.Len(t, got, len(tc.want)) + for i := 0; i < len(got); i++ { + if diff := cmp.Diff(got[i], tc.want[i]); diff != "" { + t.Errorf("got template %v\nwanted: %v\ndiff: %s", got[i], tc.want[i], diff) + } + } + }) + } +} + +func TestGetConflicts(t *testing.T) { + tests := []struct { + name string + seed []templateState + want map[TemplateID]bool + }{ + { + name: "empty db, empty conflicts", + }, + { + name: "2 conflicts", + seed: []templateState{ + { + hasConflicts: true, + template: fixtures.TestTemplate("t1-2", 1, 2), + }, + { + hasConflicts: true, + template: fixtures.TestTemplate("t2-1", 2, 1), + }, + }, + want: map[TemplateID]bool{ + "t1-2": true, + "t2-1": true, + }, + }, + { + name: "2 conflicts, 1 non", + seed: []templateState{ + { + hasConflicts: true, + template: fixtures.TestTemplate("t1-2", 1, 2), + }, + { + hasConflicts: true, + template: fixtures.TestTemplate("t2-1", 2, 1), + }, + { + hasConflicts: false, + template: fixtures.TestTemplate("t4-5", 4, 5), + }, + }, + want: map[TemplateID]bool{ + "t1-2": true, + "t2-1": true, + }, + }, + { + name: "no conflicts", + seed: []templateState{ + { + hasConflicts: false, + template: fixtures.TestTemplate("t1-2", 1, 2), + }, + { + hasConflicts: false, + template: fixtures.TestTemplate("t2-3", 2, 3), + }, + { + hasConflicts: false, + template: fixtures.TestTemplate("t4-5", 4, 5), + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := newDB() + for i := range tc.seed { + d.store[keyForTemplate(tc.seed[i].template)] = &tc.seed[i] + } + + got := d.getConflicts() + + require.Len(t, got, len(tc.want)) + for k := range tc.want { + if _, exists := got[k]; !exists { + t.Errorf("wanted template ID %s, but not returned", k) + } + } + }) + } +} diff --git a/pkg/expansion/fixtures/fixtures.go b/pkg/expansion/fixtures/fixtures.go index 8434259f272..99f0eb6b646 100644 --- a/pkg/expansion/fixtures/fixtures.go +++ b/pkg/expansion/fixtures/fixtures.go @@ -18,6 +18,77 @@ spec: version: "v1" ` + TempExpReplicaDeploymentExpandsPods = ` +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-deployments-replicas +spec: + applyTo: + - groups: ["apps"] + kinds: ["Deployment", "ReplicaSet"] + versions: ["v1"] + templateSource: "spec.template" + generatedGVK: + kind: "Pod" + group: "" + version: "v1" +` + + TempExpMultipleApplyTo = ` +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-many-things +spec: + applyTo: + - groups: ["apps", "traps"] + kinds: ["Deployment", "ReplicaSet"] + versions: ["v1", "v1beta1"] + - groups: [""] + kinds: ["CoreKind"] + versions: ["v1"] + templateSource: "spec.template" + generatedGVK: + kind: "Pod" + group: "" + version: "v1" +` + + TempExpCronJob = ` +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-cronjobs +spec: + applyTo: + - groups: ["batch"] + kinds: ["CronJob"] + versions: ["v1"] + templateSource: "spec.jobTemplate" + generatedGVK: + kind: "Job" + group: "batch" + version: "v1" +` + + TempExpJob = ` +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-jobs +spec: + applyTo: + - groups: ["batch"] + kinds: ["Job"] + versions: ["v1"] + templateSource: "spec.template" + generatedGVK: + kind: "Pod" + group: "" + version: "v1" +` + TempExpDeploymentExpandsPodsEnforceDryrun = ` apiVersion: expansion.gatekeeper.sh/v1alpha1 kind: ExpansionTemplate @@ -419,4 +490,63 @@ metadata: spec: loud: very ` + + GeneratorCronJob = ` +apiVersion: batch/v1 +kind: CronJob +metadata: + name: my-cronjob +spec: + schedule: "* * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - args: + - "/bin/sh" + image: nginx:1.14.2 + imagePullPolicy: Always + name: nginx + ports: + - containerPort: '80' +` + + ResultantJob = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: my-cronjob-job + namespace: default +spec: + template: + spec: + containers: + - args: + - "/bin/sh" + image: nginx:1.14.2 + imagePullPolicy: Always + name: nginx + ports: + - containerPort: '80' +` + + ResultantRecursivePod = ` +apiVersion: v1 +kind: Pod +metadata: + annotations: + owner: admin + name: my-cronjob-job-pod + namespace: default +spec: + containers: + - args: + - "/bin/sh" + image: nginx:1.14.2 + imagePullPolicy: Always + name: nginx + ports: + - containerPort: '80' +` ) diff --git a/pkg/expansion/fixtures/load.go b/pkg/expansion/fixtures/load.go new file mode 100644 index 00000000000..feb47fe903c --- /dev/null +++ b/pkg/expansion/fixtures/load.go @@ -0,0 +1,166 @@ +package fixtures + +import ( + "encoding/json" + "fmt" + "testing" + + expansionunversioned "github.com/open-policy-agent/gatekeeper/apis/expansion/unversioned" + mutationsunversioned "github.com/open-policy-agent/gatekeeper/apis/mutations/unversioned" + "github.com/open-policy-agent/gatekeeper/pkg/mutation/match" + "github.com/open-policy-agent/gatekeeper/pkg/mutation/mutators/assign" + "github.com/open-policy-agent/gatekeeper/pkg/mutation/mutators/assignimage" + "github.com/open-policy-agent/gatekeeper/pkg/mutation/mutators/assignmeta" + "github.com/open-policy-agent/gatekeeper/pkg/mutation/types" + "gopkg.in/yaml.v3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +type TemplateData struct { + Name string + Apply []match.ApplyTo + Source string + GenGVK expansionunversioned.GeneratedGVK + EnforcementAction string +} + +func NewTemplate(data *TemplateData) *expansionunversioned.ExpansionTemplate { + return &expansionunversioned.ExpansionTemplate{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExpansionTemplate", + APIVersion: "expansiontemplates.gatekeeper.sh/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: data.Name, + }, + Spec: expansionunversioned.ExpansionTemplateSpec{ + ApplyTo: data.Apply, + TemplateSource: data.Source, + GeneratedGVK: data.GenGVK, + EnforcementAction: data.EnforcementAction, + }, + } +} + +func LoadFixture(f string, t *testing.T) *unstructured.Unstructured { + obj := make(map[string]interface{}) + if err := yaml.Unmarshal([]byte(f), obj); err != nil { + t.Fatalf("error unmarshaling yaml for fixture: %s\n%s", err, f) + } + + jsonBytes, err := json.Marshal(obj) + if err != nil { + t.Fatalf("error marshaling json for fixture: %s", err) + } + + if err = json.Unmarshal(jsonBytes, &obj); err != nil { + t.Fatalf("error unmarshaling json for fixture: %s", err) + } + + u := unstructured.Unstructured{} + u.SetUnstructuredContent(obj) + return &u +} + +func LoadTemplate(f string, t *testing.T) *expansionunversioned.ExpansionTemplate { + u := LoadFixture(f, t) + te := &expansionunversioned.ExpansionTemplate{} + err := convertUnstructuredToTyped(u, te) + if err != nil { + t.Fatalf("error converting template expansion: %s", err) + } + return te +} + +func LoadAssign(f string, t *testing.T) types.Mutator { + u := LoadFixture(f, t) + a := &mutationsunversioned.Assign{} + err := convertUnstructuredToTyped(u, a) + if err != nil { + t.Fatalf("error converting assign: %s", err) + } + mut, err := assign.MutatorForAssign(a) + if err != nil { + t.Fatalf("error creating assign: %s", err) + } + return mut +} + +func LoadAssignImage(f string, t *testing.T) types.Mutator { + u := LoadFixture(f, t) + a := &mutationsunversioned.AssignImage{} + err := convertUnstructuredToTyped(u, a) + if err != nil { + t.Fatalf("error converting assignImage: %s", err) + } + mut, err := assignimage.MutatorForAssignImage(a) + if err != nil { + t.Fatalf("error creating assignimage: %s", err) + } + return mut +} + +func LoadAssignMeta(f string, t *testing.T) types.Mutator { + u := LoadFixture(f, t) + a := &mutationsunversioned.AssignMetadata{} + err := convertUnstructuredToTyped(u, a) + if err != nil { + t.Fatalf("error converting assignmeta: %s", err) + } + mut, err := assignmeta.MutatorForAssignMetadata(a) + if err != nil { + t.Fatalf("error creating assignmeta: %s", err) + } + return mut +} + +func convertUnstructuredToTyped(u *unstructured.Unstructured, obj interface{}) error { + if u == nil { + return fmt.Errorf("cannot convert nil unstructured to type") + } + err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), obj) + return err +} + +func TestTemplate(name string, applyID, genID int) *expansionunversioned.ExpansionTemplate { + return NewTemplate(&TemplateData{ + Name: name, + Apply: []match.ApplyTo{{ + Groups: []string{fmt.Sprintf("group%d", applyID)}, + Versions: []string{fmt.Sprintf("v%d", applyID)}, + Kinds: []string{fmt.Sprintf("kind%d", applyID)}, + }}, + Source: "spec.template", + GenGVK: expansionunversioned.GeneratedGVK{ + Group: fmt.Sprintf("group%d", genID), + Version: fmt.Sprintf("v%d", genID), + Kind: fmt.Sprintf("kind%d", genID), + }, + }) +} + +func TempMultApply() *expansionunversioned.ExpansionTemplate { + return NewTemplate(&TemplateData{ + Name: "t2", + Apply: []match.ApplyTo{ + { + Groups: []string{"group1"}, + Versions: []string{"v1"}, + Kinds: []string{"kind1"}, + }, + { + Groups: []string{"group11"}, + Versions: []string{"v11", "v22"}, + Kinds: []string{"kind11"}, + }, + }, + Source: "spec.template", + GenGVK: expansionunversioned.GeneratedGVK{ + Group: "group2", + Version: "v2", + Kind: "kind2", + }, + }) +} diff --git a/pkg/expansion/system.go b/pkg/expansion/system.go index 6984b247b96..3cede9b4295 100644 --- a/pkg/expansion/system.go +++ b/pkg/expansion/system.go @@ -8,14 +8,24 @@ import ( "sync" expansionunversioned "github.com/open-policy-agent/gatekeeper/apis/expansion/unversioned" + "github.com/open-policy-agent/gatekeeper/pkg/logging" "github.com/open-policy-agent/gatekeeper/pkg/mutation" mutationtypes "github.com/open-policy-agent/gatekeeper/pkg/mutation/types" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + logf "sigs.k8s.io/controller-runtime/pkg/log" ) -var ExpansionEnabled *bool +var ( + ExpansionEnabled *bool + log = logf.Log.WithName("expansion").WithValues(logging.Process, "expansion") +) + +// maxRecursionDepth specifies the maximum call depth for recursive expansion. +// Theoretically, it should be impossible for a cycle to be created but this +// measure is put in place as a safeguard. +const maxRecursionDepth = 30 func init() { ExpansionEnabled = flag.Bool("enable-generator-resource-expansion", false, "(alpha) Enable the expansion of generator resources") @@ -23,8 +33,8 @@ func init() { type System struct { lock sync.RWMutex - templates map[string]*expansionunversioned.ExpansionTemplate mutationSystem *mutation.System + db templateDB } type Resultant struct { @@ -33,20 +43,24 @@ type Resultant struct { EnforcementAction string } -func keyForTemplate(template *expansionunversioned.ExpansionTemplate) string { - return template.ObjectMeta.Name +type TemplateID string + +type IDSet map[TemplateID]bool + +func keyForTemplate(template *expansionunversioned.ExpansionTemplate) TemplateID { + return TemplateID(template.ObjectMeta.Name) } func (s *System) UpsertTemplate(template *expansionunversioned.ExpansionTemplate) error { s.lock.Lock() defer s.lock.Unlock() + log.V(1).Info("upserting ExpansionTemplate", "template name", template.GetName()) if err := ValidateTemplate(template); err != nil { return err } - s.templates[keyForTemplate(template)] = template.DeepCopy() - return nil + return s.db.upsert(template) } func (s *System) RemoveTemplate(template *expansionunversioned.ExpansionTemplate) error { @@ -58,10 +72,14 @@ func (s *System) RemoveTemplate(template *expansionunversioned.ExpansionTemplate return fmt.Errorf("cannot remove template with empty name") } - delete(s.templates, k) + s.db.remove(template) return nil } +func (s *System) GetConflicts() IDSet { + return s.db.getConflicts() +} + func ValidateTemplate(template *expansionunversioned.ExpansionTemplate) error { k := keyForTemplate(template) if k == "" { @@ -73,6 +91,18 @@ func ValidateTemplate(template *expansionunversioned.ExpansionTemplate) error { if template.Spec.GeneratedGVK == (expansionunversioned.GeneratedGVK{}) { return fmt.Errorf("ExpansionTemplate %s has empty generatedGVK field", k) } + if len(template.Spec.ApplyTo) == 0 { + return fmt.Errorf("ExpansionTemplate %s must specify ApplyTo", k) + } + // Make sure template does not form a self-edge (i.e. a template configured + // to expand its own output) + genGVK := genGVKToSchemaGVK(template.Spec.GeneratedGVK) + for _, apply := range template.Spec.ApplyTo { + if apply.Matches(genGVK) { + return fmt.Errorf("ExpansionTemplate %s generates GVK %v, but also applies to that same GVK", k, genGVK) + } + } + return nil } @@ -96,34 +126,54 @@ func genGVKToSchemaGVK(gvk expansionunversioned.GeneratedGVK) schema.GroupVersio } } -// templatesForGVK returns a slice of ExpansionTemplates that match a given gvk. -func (s *System) templatesForGVK(gvk schema.GroupVersionKind) []*expansionunversioned.ExpansionTemplate { +// Expand expands `base` into resultant resources, and applies any applicable +// mutators. If no ExpansionTemplates match `base`, an empty slice +// will be returned. If `s.mutationSystem` is nil, no mutations will be applied. +func (s *System) Expand(base *mutationtypes.Mutable) ([]*Resultant, error) { s.lock.RLock() defer s.lock.RUnlock() - var templates []*expansionunversioned.ExpansionTemplate - for _, t := range s.templates { - for _, apply := range t.Spec.ApplyTo { - if apply.Matches(gvk) { - templates = append(templates, t) - } + var res []*Resultant + if err := s.expandRecursive(base, &res, 0); err != nil { + return nil, err + } + return res, nil +} + +func (s *System) expandRecursive(base *mutationtypes.Mutable, resultants *[]*Resultant, depth int) error { + if depth >= maxRecursionDepth { + return fmt.Errorf("maximum recursion depth of %d reached", maxRecursionDepth) + } + + res, err := s.expand(base) + if err != nil { + return err + } + + for _, r := range res { + mut := &mutationtypes.Mutable{ + Object: r.Obj, + Namespace: base.Namespace, + Username: base.Username, + Source: base.Source, + } + if err := s.expandRecursive(mut, resultants, depth+1); err != nil { + return err } } - return templates + *resultants = append(*resultants, res...) + return nil } -// Expand expands `base` into resultant resources, and applies any applicable -// mutators. If no ExpansionTemplates match `base`, an empty slice -// will be returned. If `s.mutationSystem` is nil, no mutations will be applied. -func (s *System) Expand(base *mutationtypes.Mutable) ([]*Resultant, error) { +func (s *System) expand(base *mutationtypes.Mutable) ([]*Resultant, error) { gvk := base.Object.GroupVersionKind() if gvk == (schema.GroupVersionKind{}) { return nil, fmt.Errorf("cannot expand resource %s with empty GVK", base.Object.GetName()) } var resultants []*Resultant - templates := s.templatesForGVK(gvk) + templates := s.db.templatesForGVK(gvk) for _, te := range templates { res, err := expandResource(base.Object, base.Namespace, te) @@ -176,7 +226,7 @@ func expandResource(obj *unstructured.Unstructured, ns *corev1.Namespace, templa return nil, fmt.Errorf("could not extract source field from unstructured: %w", err) } if !ok { - return nil, fmt.Errorf("could not find source field %q in Obj", srcPath) + return nil, fmt.Errorf("could not find source field %q in resource %s", srcPath, obj.GetName()) } resource := &unstructured.Unstructured{} @@ -205,7 +255,7 @@ func mockNameForResource(gen *unstructured.Unstructured, gvk schema.GroupVersion func NewSystem(mutationSystem *mutation.System) *System { return &System{ lock: sync.RWMutex{}, - templates: map[string]*expansionunversioned.ExpansionTemplate{}, mutationSystem: mutationSystem, + db: newDB(), } } diff --git a/pkg/expansion/system_test.go b/pkg/expansion/system_test.go index b62d3696e70..a12a7252f11 100644 --- a/pkg/expansion/system_test.go +++ b/pkg/expansion/system_test.go @@ -1,589 +1,21 @@ package expansion import ( - "encoding/json" - "fmt" "sort" + "strings" "testing" "github.com/google/go-cmp/cmp" expansionunversioned "github.com/open-policy-agent/gatekeeper/apis/expansion/unversioned" - mutationsunversioned "github.com/open-policy-agent/gatekeeper/apis/mutations/unversioned" "github.com/open-policy-agent/gatekeeper/pkg/expansion/fixtures" "github.com/open-policy-agent/gatekeeper/pkg/mutation" "github.com/open-policy-agent/gatekeeper/pkg/mutation/match" - "github.com/open-policy-agent/gatekeeper/pkg/mutation/mutators/assign" - "github.com/open-policy-agent/gatekeeper/pkg/mutation/mutators/assignimage" - "github.com/open-policy-agent/gatekeeper/pkg/mutation/mutators/assignmeta" "github.com/open-policy-agent/gatekeeper/pkg/mutation/types" - "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" ) -type templateData struct { - name string - apply []match.ApplyTo - source string - generatedGVK expansionunversioned.GeneratedGVK - enforcementAction string -} - -func newTemplate(data *templateData) *expansionunversioned.ExpansionTemplate { - return &expansionunversioned.ExpansionTemplate{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExpansionTemplate", - APIVersion: "expansiontemplates.gatekeeper.sh/v1beta1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: data.name, - }, - Spec: expansionunversioned.ExpansionTemplateSpec{ - ApplyTo: data.apply, - TemplateSource: data.source, - GeneratedGVK: data.generatedGVK, - EnforcementAction: data.enforcementAction, - }, - } -} - -func TestUpsertRemoveTemplate(t *testing.T) { - tests := []struct { - name string - add []*expansionunversioned.ExpansionTemplate - remove []*expansionunversioned.ExpansionTemplate - check []*expansionunversioned.ExpansionTemplate - wantAddErr bool - wantRemoveErr bool - }{ - { - name: "adding 2 valid templates", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Foo"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Bar", - }, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Foo"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Bar", - }, - }), - }, - wantAddErr: false, - }, - { - name: "adding template with empty name returns error", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{}, - wantAddErr: true, - }, - { - name: "adding template with empty source returns error", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "hello", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{}, - wantAddErr: true, - }, - { - name: "adding template with empty GVK returns error", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "hello", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{}, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{}, - wantAddErr: true, - }, - { - name: "removing a template with empty name returns error", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - remove: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - wantAddErr: false, - wantRemoveErr: true, - }, - { - name: "adding 2 templates, removing 1", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - remove: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - wantAddErr: false, - wantRemoveErr: false, - }, - { - name: "updating an existing template", - add: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"Baz"}, - Kinds: []string{"Foo"}, - Versions: []string{"v9000"}, - }}, - source: "spec.something", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v9000", - Kind: "Bar", - }, - }), - }, - check: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"Baz"}, - Kinds: []string{"Foo"}, - Versions: []string{"v9000"}, - }}, - source: "spec.something", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v9000", - Kind: "Bar", - }, - }), - }, - wantAddErr: false, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ec := NewSystem(mutation.NewSystem(mutation.SystemOpts{})) - - for _, templ := range tc.add { - err := ec.UpsertTemplate(templ) - if tc.wantAddErr && err == nil { - t.Errorf("expected error, got nil") - } else if !tc.wantAddErr && err != nil { - t.Errorf("failed to add template: %s", err) - } - } - - for _, templ := range tc.remove { - err := ec.RemoveTemplate(templ) - if tc.wantRemoveErr && err == nil { - t.Errorf("expected error, got nil") - } else if !tc.wantRemoveErr && err != nil { - t.Errorf("failed to remove template: %s", err) - } - } - - if len(ec.templates) != len(tc.check) { - t.Errorf("incorrect number of templates in cache, got %d, want %d", len(ec.templates), len(tc.check)) - } - for _, templ := range tc.check { - k := templ.ObjectMeta.Name - got, exists := ec.templates[k] - if !exists { - t.Errorf("could not find template with key %q", k) - } - if cmp.Diff(got, templ) != "" { - t.Errorf("got value: \n%s\n, wanted: \n%s\n\n diff: \n%s", prettyResource(got), prettyResource(templ), cmp.Diff(got, templ)) - } - } - }) - } -} - -func TestTemplatesForGVK(t *testing.T) { - tests := []struct { - name string - gvk expansionunversioned.GeneratedGVK - addTemplates []*expansionunversioned.ExpansionTemplate - want []*expansionunversioned.ExpansionTemplate - }{ - { - name: "adding 2 templates, 1 match", - gvk: expansionunversioned.GeneratedGVK{ - Group: "apps", - Version: "v1", - Kind: "Deployment", - }, - addTemplates: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Foo"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Bar", - }, - }), - }, - want: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - }, - { - name: "adding 2 templates, 2 matches", - gvk: expansionunversioned.GeneratedGVK{ - Group: "apps", - Version: "v1", - Kind: "Deployment", - }, - addTemplates: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Bar", - }, - }), - }, - want: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - newTemplate(&templateData{ - name: "test2", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Bar", - }, - }), - }, - }, - { - name: "adding 1 templates, 0 match", - addTemplates: []*expansionunversioned.ExpansionTemplate{ - newTemplate(&templateData{ - name: "test1", - apply: []match.ApplyTo{{ - Groups: []string{"apps"}, - Kinds: []string{"Deployment"}, - Versions: []string{"v1"}, - }}, - source: "spec.template", - generatedGVK: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }), - }, - want: []*expansionunversioned.ExpansionTemplate{}, - gvk: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v9000", - Kind: "CronJob", - }, - }, - { - name: "no templates, no matches", - addTemplates: []*expansionunversioned.ExpansionTemplate{}, - want: []*expansionunversioned.ExpansionTemplate{}, - gvk: expansionunversioned.GeneratedGVK{ - Group: "", - Version: "v1", - Kind: "Pod", - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ec := NewSystem(mutation.NewSystem(mutation.SystemOpts{})) - for _, te := range tc.addTemplates { - if err := ec.UpsertTemplate(te); err != nil { - t.Fatalf("error upserting template: %s", err) - } - } - - got := ec.templatesForGVK(genGVKToSchemaGVK(tc.gvk)) - sortTemplates(got) - wantSorted := make([]*expansionunversioned.ExpansionTemplate, len(tc.want)) - copy(wantSorted, tc.want) - sortTemplates(wantSorted) - - if len(got) != len(wantSorted) { - t.Errorf("want %d templates, got %d", len(wantSorted), len(got)) - } - for i := 0; i < len(got); i++ { - diff := cmp.Diff(got[i], wantSorted[i]) - if diff != "" { - t.Errorf("got = \n%s\n, want = \n%s\n\n diff \n%s", prettyResource(got[i]), prettyResource(wantSorted[i]), diff) - } - } - }) - } -} - func TestExpand(t *testing.T) { tests := []struct { name string @@ -596,193 +28,209 @@ func TestExpand(t *testing.T) { }{ { name: "generator with no templates or mutators", - generator: loadFixture(fixtures.GeneratorCat, t), + generator: fixtures.LoadFixture(fixtures.GeneratorCat, t), }, { name: "generator with no gvk returns error", - generator: loadFixture(fixtures.DeploymentNoGVK, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNoGVK, t), expectErr: true, }, { name: "generator with non-matching template", - generator: loadFixture(fixtures.GeneratorCat, t), + generator: fixtures.LoadFixture(fixtures.GeneratorCat, t), templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{}, }, { name: "no mutators basic deployment expands pod", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{}, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodNoMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodNoMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "deployment expands pod with enforcement action override", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{}, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPodsEnforceDryrun, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPodsEnforceDryrun, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodNoMutate, t), EnforcementAction: "dryrun", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodNoMutate, t), EnforcementAction: "dryrun", TemplateName: "expand-deployments"}, }, }, { name: "1 mutator basic deployment expands pod", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignPullImage, t), + fixtures.LoadAssign(fixtures.AssignPullImage, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "expand with nil namespace returns error", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: nil, mutators: []types.Mutator{ - loadAssign(fixtures.AssignPullImage, t), + fixtures.LoadAssign(fixtures.AssignPullImage, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, expectErr: true, }, { name: "1 mutator source All deployment expands pod and mutates", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignPullImageSourceAll, t), + fixtures.LoadAssign(fixtures.AssignPullImageSourceAll, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "1 mutator source empty deployment expands pod and mutates", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignPullImageSourceEmpty, t), + fixtures.LoadAssign(fixtures.AssignPullImageSourceEmpty, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "1 mutator source Original deployment expands pod but does not mutate", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignHostnameSourceOriginal, t), + fixtures.LoadAssign(fixtures.AssignHostnameSourceOriginal, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodNoMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodNoMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "2 mutators, only 1 match, basic deployment expands pod", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignPullImage, t), - loadAssignMeta(fixtures.AssignMetaAnnotateKitten, t), // should not match + fixtures.LoadAssign(fixtures.AssignPullImage, t), + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotateKitten, t), // should not match }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodImagePullMutate, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "2 mutators, 2 matches, basic deployment expands pod", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignPullImage, t), - loadAssignMeta(fixtures.AssignMetaAnnotatePod, t), + fixtures.LoadAssign(fixtures.AssignPullImage, t), + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotatePod, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodImagePullMutateAnnotated, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodImagePullMutateAnnotated, t), EnforcementAction: "", TemplateName: "expand-deployments"}, }, }, { name: "custom CR with 2 different resultant kinds, with mutators", - generator: loadFixture(fixtures.GeneratorCat, t), + generator: fixtures.LoadFixture(fixtures.GeneratorCat, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignKittenAge, t), - loadAssignMeta(fixtures.AssignMetaAnnotatePurr, t), - loadAssignMeta(fixtures.AssignMetaAnnotateKitten, t), + fixtures.LoadAssign(fixtures.AssignKittenAge, t), + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotatePurr, t), + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotateKitten, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TemplateCatExpandsKitten, t), - loadTemplate(fixtures.TemplateCatExpandsPurr, t), + fixtures.LoadTemplate(fixtures.TemplateCatExpandsKitten, t), + fixtures.LoadTemplate(fixtures.TemplateCatExpandsPurr, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.ResultantKitten, t), EnforcementAction: "dryrun", TemplateName: "expand-cats-kitten"}, - {Obj: loadFixture(fixtures.ResultantPurr, t), EnforcementAction: "warn", TemplateName: "expand-cats-purr"}, + {Obj: fixtures.LoadFixture(fixtures.ResultantKitten, t), EnforcementAction: "dryrun", TemplateName: "expand-cats-kitten"}, + {Obj: fixtures.LoadFixture(fixtures.ResultantPurr, t), EnforcementAction: "warn", TemplateName: "expand-cats-purr"}, }, }, { name: "custom CR with 2 different resultant kinds, with mutators and non-matching configs", - generator: loadFixture(fixtures.GeneratorCat, t), + generator: fixtures.LoadFixture(fixtures.GeneratorCat, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssign(fixtures.AssignKittenAge, t), - loadAssignMeta(fixtures.AssignMetaAnnotatePurr, t), - loadAssignMeta(fixtures.AssignMetaAnnotateKitten, t), - loadAssign(fixtures.AssignPullImage, t), // should not match + fixtures.LoadAssign(fixtures.AssignKittenAge, t), + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotatePurr, t), + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotateKitten, t), + fixtures.LoadAssign(fixtures.AssignPullImage, t), // should not match }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TemplateCatExpandsKitten, t), - loadTemplate(fixtures.TemplateCatExpandsPurr, t), - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), // should not match + fixtures.LoadTemplate(fixtures.TemplateCatExpandsKitten, t), + fixtures.LoadTemplate(fixtures.TemplateCatExpandsPurr, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), // should not match }, want: []*Resultant{ - {Obj: loadFixture(fixtures.ResultantKitten, t), EnforcementAction: "dryrun", TemplateName: "expand-cats-kitten"}, - {Obj: loadFixture(fixtures.ResultantPurr, t), EnforcementAction: "warn", TemplateName: "expand-cats-purr"}, + {Obj: fixtures.LoadFixture(fixtures.ResultantKitten, t), EnforcementAction: "dryrun", TemplateName: "expand-cats-kitten"}, + {Obj: fixtures.LoadFixture(fixtures.ResultantPurr, t), EnforcementAction: "warn", TemplateName: "expand-cats-purr"}, }, }, { name: "1 mutator deployment expands pod with AssignImage", - generator: loadFixture(fixtures.DeploymentNginx, t), + generator: fixtures.LoadFixture(fixtures.DeploymentNginx, t), ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, mutators: []types.Mutator{ - loadAssignImage(fixtures.AssignImage, t), + fixtures.LoadAssignImage(fixtures.AssignImage, t), }, templates: []*expansionunversioned.ExpansionTemplate{ - loadTemplate(fixtures.TempExpDeploymentExpandsPods, t), + fixtures.LoadTemplate(fixtures.TempExpDeploymentExpandsPods, t), }, want: []*Resultant{ - {Obj: loadFixture(fixtures.PodMutateImage, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + {Obj: fixtures.LoadFixture(fixtures.PodMutateImage, t), EnforcementAction: "", TemplateName: "expand-deployments"}, + }, + }, + { + name: "recursive expansion with mutators", + generator: fixtures.LoadFixture(fixtures.GeneratorCronJob, t), + ns: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + mutators: []types.Mutator{ + fixtures.LoadAssignMeta(fixtures.AssignMetaAnnotatePod, t), + }, + templates: []*expansionunversioned.ExpansionTemplate{ + fixtures.LoadTemplate(fixtures.TempExpCronJob, t), + fixtures.LoadTemplate(fixtures.TempExpJob, t), + }, + want: []*Resultant{ + {Obj: fixtures.LoadFixture(fixtures.ResultantJob, t), EnforcementAction: "", TemplateName: "expand-cronjobs"}, + {Obj: fixtures.LoadFixture(fixtures.ResultantRecursivePod, t), EnforcementAction: "", TemplateName: "expand-jobs"}, }, }, } @@ -826,8 +274,8 @@ func compareResults(got []*Resultant, want []*Resultant, t *testing.T) { return } - sortReusultants(got) - sortReusultants(want) + sortResultants(got) + sortResultants(want) for i := 0; i < len(got); i++ { if diff := cmp.Diff(got[i], want[i]); diff != "" { @@ -836,7 +284,7 @@ func compareResults(got []*Resultant, want []*Resultant, t *testing.T) { } } -func sortReusultants(objs []*Resultant) { +func sortResultants(objs []*Resultant) { sortKey := func(r *Resultant) string { return r.Obj.GetName() + r.Obj.GetAPIVersion() } @@ -845,88 +293,118 @@ func sortReusultants(objs []*Resultant) { }) } -func loadFixture(f string, t *testing.T) *unstructured.Unstructured { - obj := make(map[string]interface{}) - if err := yaml.Unmarshal([]byte(f), obj); err != nil { - t.Fatalf("error unmarshaling yaml for fixture: %s", err) - } - - jsonBytes, err := json.Marshal(obj) - if err != nil { - t.Fatalf("error marshaling json for fixture: %s", err) - } - - if err = json.Unmarshal(jsonBytes, &obj); err != nil { - t.Fatalf("error unmarshaling json for fixture: %s", err) - } - - u := unstructured.Unstructured{} - u.SetUnstructuredContent(obj) - return &u -} - -func loadTemplate(f string, t *testing.T) *expansionunversioned.ExpansionTemplate { - u := loadFixture(f, t) - te := &expansionunversioned.ExpansionTemplate{} - err := convertUnstructuredToTyped(u, te) - if err != nil { - t.Fatalf("error converting template expansion: %s", err) - } - return te -} - -func loadAssign(f string, t *testing.T) types.Mutator { - u := loadFixture(f, t) - a := &mutationsunversioned.Assign{} - err := convertUnstructuredToTyped(u, a) - if err != nil { - t.Fatalf("error converting assign: %s", err) +func TestValidateTemplate(t *testing.T) { + tests := []struct { + name string + errFn func(e error, t *testing.T) + temp expansionunversioned.ExpansionTemplate + }{ + { + name: "valid expansion template", + errFn: noError, + temp: *fixtures.TestTemplate("foo", 1, 2), + }, + { + name: "missing name", + temp: *fixtures.NewTemplate(&fixtures.TemplateData{ + Apply: []match.ApplyTo{{ + Groups: []string{"apps"}, + Kinds: []string{"Deployment"}, + Versions: []string{"v1"}, + }}, + Source: "spec.template", + GenGVK: expansionunversioned.GeneratedGVK{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + }), + errFn: matchErr("empty name"), + }, + { + name: "missing source", + temp: *fixtures.NewTemplate(&fixtures.TemplateData{ + Name: "test1", + Apply: []match.ApplyTo{{ + Groups: []string{"apps"}, + Kinds: []string{"Deployment"}, + Versions: []string{"v1"}, + }}, + GenGVK: expansionunversioned.GeneratedGVK{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + }), + errFn: matchErr("empty source"), + }, + { + name: "missing generated GVK", + temp: *fixtures.NewTemplate(&fixtures.TemplateData{ + Name: "test1", + Apply: []match.ApplyTo{{ + Groups: []string{"apps"}, + Kinds: []string{"Deployment"}, + Versions: []string{"v1"}, + }}, + Source: "spec.template", + }), + errFn: matchErr("empty generatedGVK"), + }, + { + name: "missing applyTo", + temp: *fixtures.NewTemplate(&fixtures.TemplateData{ + Name: "test1", + Source: "spec.template", + GenGVK: expansionunversioned.GeneratedGVK{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + }), + errFn: matchErr("specify ApplyTo"), + }, + { + name: "loop", + temp: *fixtures.NewTemplate(&fixtures.TemplateData{ + Name: "test1", + Apply: []match.ApplyTo{{ + Groups: []string{""}, + Kinds: []string{"Pod"}, + Versions: []string{"v1"}, + }}, + Source: "spec.template", + GenGVK: expansionunversioned.GeneratedGVK{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + }), + errFn: matchErr("also applies to that same GVK"), + }, } - mut, err := assign.MutatorForAssign(a) - if err != nil { - t.Fatalf("error creating assign: %s", err) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.errFn(ValidateTemplate(&tc.temp), t) + }) } - return mut } -func loadAssignImage(f string, t *testing.T) types.Mutator { - u := loadFixture(f, t) - a := &mutationsunversioned.AssignImage{} - err := convertUnstructuredToTyped(u, a) - if err != nil { - t.Fatalf("error converting assignImage: %s", err) - } - mut, err := assignimage.MutatorForAssignImage(a) - if err != nil { - t.Fatalf("error creating assignimage: %s", err) +func noError(e error, t *testing.T) { + if e != nil { + t.Errorf("did want want error, but got %s", e) } - return mut } -func loadAssignMeta(f string, t *testing.T) types.Mutator { - u := loadFixture(f, t) - a := &mutationsunversioned.AssignMetadata{} - err := convertUnstructuredToTyped(u, a) - if err != nil { - t.Fatalf("error converting assignmeta: %s", err) - } - mut, err := assignmeta.MutatorForAssignMetadata(a) - if err != nil { - t.Fatalf("error creating assignmeta: %s", err) - } - return mut -} +func matchErr(substr string) func(error, *testing.T) { + return func(err error, t *testing.T) { + if err == nil { + t.Error("expected err but got nil") + return + } -func convertUnstructuredToTyped(u *unstructured.Unstructured, obj interface{}) error { - if u == nil { - return fmt.Errorf("cannot convert nil unstructured to type") + if !strings.Contains(err.Error(), substr) { + t.Errorf("expected error to contain %q, but got %q", substr, err.Error()) + } } - err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), obj) - return err -} - -func sortTemplates(templates []*expansionunversioned.ExpansionTemplate) { - sort.SliceStable(templates, func(x, y int) bool { - return templates[x].Name < templates[y].Name - }) } diff --git a/pkg/readiness/ready_tracker.go b/pkg/readiness/ready_tracker.go index b35dc972588..aa4043c1814 100644 --- a/pkg/readiness/ready_tracker.go +++ b/pkg/readiness/ready_tracker.go @@ -160,7 +160,7 @@ func (t *Tracker) For(gvk schema.GroupVersionKind) Expectations { return t.assignImage } return noopExpectations{} - case gvk.GroupVersion() == expansionv1alpha1.GroupVersion && gvk.Kind == "ExpansionTemplate": + case gvk.Group == expansionv1alpha1.GroupVersion.Group && gvk.Kind == "ExpansionTemplate": if t.expansionEnabled { return t.expansions } diff --git a/test/bats/test.bats b/test/bats/test.bats index 4a53e1f1186..e158a97169d 100644 --- a/test/bats/test.bats +++ b/test/bats/test.bats @@ -465,6 +465,24 @@ __expansion_audit_test() { run kubectl run nginx --image=nginx --dry-run=server --output json assert_success + # test recursive expansion cronjob->job->pod triggers pod violation when creating cronjob + run kubectl apply -f test/expansion/expand_cronjob_job_pod.yaml + wait_for_process ${WAIT_TIME} ${SLEEP_TIME} "kubectl get expansiontemplate expand-cronjobs -ojson | jq -r -e '.status.byPod[0]'" + wait_for_process ${WAIT_TIME} ${SLEEP_TIME} "kubectl get expansiontemplate expand-jobs -ojson | jq -r -e '.status.byPod[0]'" + run kubectl apply -f test/expansion/cronjob.yaml + assert_failure + + # test adding a ExpansionTemplate that creates a cycle updates template's status + run kubectl apply -f test/expansion/expand_pod_cronjob.yaml + wait_for_process ${WAIT_TIME} ${SLEEP_TIME} "kubectl get -f test/expansion/expand_pod_cronjob.yaml -ojson | jq -r -e '.status.byPod[0]'" + # expand-cronjobs, expand-jobs, and expand_pod_cronjob should each have an error set in their status + local status_err=$(kubectl get -f test/expansion/expand_pod_cronjob.yaml -o jsonpath='{.status.byPod[0].errors}' | grep "template forms expansion cycle" | wc -l) + assert_match "${status_err}" "1" + local status_err2=$(kubectl get expansiontemplate expand-cronjobs -o jsonpath='{.status.byPod[0].errors}' | grep "template forms expansion cycle" | wc -l) + assert_match "${status_err2}" "1" + local status_err3=$(kubectl get expansiontemplate expand-jobs -o jsonpath='{.status.byPod[0].errors}' | grep "template forms expansion cycle" | wc -l) + assert_match "${status_err3}" "1" + # cleanup run kubectl delete --ignore-not-found namespace loadbalancers run kubectl delete --ignore-not-found -f test/expansion/expand_deployments.yaml @@ -475,4 +493,7 @@ __expansion_audit_test() { run kubectl delete --ignore-not-found -f test/expansion/assignmeta_env.yaml run kubectl delete --ignore-not-found -f test/expansion/deployment_no_label.yaml run kubectl delete --ignore-not-found -f test/expansion/deployment_with_label.yaml + run kubectl delete --ignore-not-found -f test/expansion/cronjob.yaml + run kubectl delete --ignore-not-found -f test/expansion/expand_cronjob_job_pod.yaml + run kubectl delete --ignore-not-found -f test/expansion/expand_pod_cronjob.yaml } diff --git a/test/expansion/cronjob.yaml b/test/expansion/cronjob.yaml new file mode 100644 index 00000000000..4f7db934506 --- /dev/null +++ b/test/expansion/cronjob.yaml @@ -0,0 +1,23 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: my-cronjob + namespace: "loadbalancers" +spec: + schedule: "* * * * *" + jobTemplate: + spec: + template: + metadata: + namespace: "loadbalancers" + spec: + containers: + - args: + - "/bin/sh" + image: nginx:1.14.2 + imagePullPolicy: Always + name: nginx + ports: + - containerPort: 80 + restartPolicy: OnFailure + diff --git a/test/expansion/expand_cronjob_job_pod.yaml b/test/expansion/expand_cronjob_job_pod.yaml new file mode 100644 index 00000000000..de73c0ed591 --- /dev/null +++ b/test/expansion/expand_cronjob_job_pod.yaml @@ -0,0 +1,29 @@ +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-cronjobs +spec: + applyTo: + - groups: [ "batch" ] + kinds: [ "CronJob" ] + versions: [ "v1" ] + templateSource: "spec.jobTemplate" + generatedGVK: + kind: "Job" + group: "batch" + version: "v1" +--- +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-jobs +spec: + applyTo: + - groups: [ "batch" ] + kinds: [ "Job" ] + versions: [ "v1" ] + templateSource: "spec.template" + generatedGVK: + kind: "Pod" + group: "" + version: "v1" diff --git a/test/expansion/expand_pod_cronjob.yaml b/test/expansion/expand_pod_cronjob.yaml new file mode 100644 index 00000000000..0a41cfbf454 --- /dev/null +++ b/test/expansion/expand_pod_cronjob.yaml @@ -0,0 +1,14 @@ +apiVersion: expansion.gatekeeper.sh/v1alpha1 +kind: ExpansionTemplate +metadata: + name: expand-pods +spec: + applyTo: + - groups: [ "" ] + kinds: [ "Pod" ] + versions: [ "v1" ] + templateSource: "spec.foo" + generatedGVK: + kind: "CronJob" + group: "batch" + version: "v1" diff --git a/vendor/github.com/dominikbraun/graph/.gitignore b/vendor/github.com/dominikbraun/graph/.gitignore new file mode 100644 index 00000000000..ee770a66d6c --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/.gitignore @@ -0,0 +1,17 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +.idea/ diff --git a/vendor/github.com/dominikbraun/graph/.golangci.yml b/vendor/github.com/dominikbraun/graph/.golangci.yml new file mode 100644 index 00000000000..cf3f55159a2 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/.golangci.yml @@ -0,0 +1,25 @@ +run: + timeout: 5m + +linters: + disable-all: true + enable: + - govet + - gofumpt + - errcheck + - gosimple + - ineffassign + - staticcheck + - typecheck + - unused + +linters-settings: + govet: + enable-all: true + disable: + - stdmethods + - fieldalignment + + gofumpt: + extra-rules: false + module-path: github.com/dominikbraun/graph \ No newline at end of file diff --git a/vendor/github.com/dominikbraun/graph/CHANGELOG.md b/vendor/github.com/dominikbraun/graph/CHANGELOG.md new file mode 100644 index 00000000000..94c07c9ea95 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/CHANGELOG.md @@ -0,0 +1,221 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.16.2] - 2023-03-27 + +### Fixed +* Fixed `ShortestPath` for an edge case. + +## [0.16.1] - 2023-03-06 + +### Fixed +* Fixed `TransitiveReduction` not to incorrectly report cycles. + +## [0.16.0] - 2023-03-01 + +**This release contains breaking changes of the public API (see "Changed").** + +### Added +* Added the `Store` interface, introducing support for custom storage implementations. +* Added the `NewWithStore` function for explicitly initializing a graph with a `Store` instance. +* Added the `EdgeData` functional option that can be used with `AddEdge`, introducing support for arbitrary data. +* Added the `Data` field to `EdgeProperties` for retrieving data added using `EdgeData`. + +### Changed +* Changed `Order` to additionally return an error instance (breaking change). +* Changed `Size` to additionally return an error instance (breaking change). + +## [0.15.1] - 2023-01-18 + +### Changed +* Changed `ShortestPath` to return `ErrTargetNotReachable` if the target vertex is not reachable. + +### Fixed +* Fixed `ShortestPath` to return correct results for large unweighted graphs. + +## [0.15.0] - 2022-11-25 + +### Added +* Added the `ErrVertexAlreadyExists` error instance. Use `errors.Is` to check for this instance. +* Added the `ErrEdgeAlreadyExists` error instance. Use `errors.Is` to check for this instance. +* Added the `ErrEdgeCreatesCycle` error instance. Use `errors.Is` to check for this instance. + +### Changed +* Changed `AddVertex` to return `ErrVertexAlreadyExists` if the vertex already exists. +* Changed `VertexWithProperties` to return `ErrVertexNotFound` if the vertex doesn't exist. +* Changed `AddEdge` to return `ErrVertexNotFound` if either vertex doesn't exist. +* Changed `AddEdge` to return `ErrEdgeAlreadyExists` if the edge already exists. +* Changed `AddEdge` to return `ErrEdgeCreatesCycle` if cycle prevention is active and the edge would create a cycle. +* Changed `Edge` to return `ErrEdgeNotFound` if the edge doesn't exist. +* Changed `RemoveEdge` to return the error instances returned by `Edge`. + +## [0.14.0] - 2022-11-01 + +### Added +* Added the `ErrVertexNotFound` error instance. + +### Changed +* Changed `TopologicalSort` to fail at runtime when a cycle is detected. +* Changed `TransitiveReduction` to return the transitive reduction as a new graph and fail at runtime when a cycle is detected. +* Changed `Vertex` to return `ErrVertexNotFound` if the desired vertex couldn't be found. + +## [0.13.0] - 2022-10-15 + +### Added +* Added the `VertexProperties` type for storing vertex-related properties. +* Added the `VertexWithProperties` method for retrieving a vertex and its properties. +* Added the `VertexWeight` functional option that can be used for `AddVertex`. +* Added the `VertexAttribute` functional option that can be used for `AddVertex`. +* Added support for rendering vertices with attributes using `draw.DOT`. + +### Changed +* Changed `AddVertex` to accept functional options. +* Renamed `PermitCycles` to `PreventCycles`. This seems to be the price to pay if English isn't a library author's native language. + +### Fixed +* Fixed the behavior of `ShortestPath` when the target vertex is not reachable from one of the visited vertices. + +## [0.12.0] - 2022-09-19 + +### Added +* Added the `PermitCycles` option to explicitly prevent the creation of cycles. + +### Changed +* Changed the `Acyclic` option to not implicitly impose cycle checks for operations like `AddEdge`. To prevent the creation of cycles, use `PermitCycles`. +* Changed `TopologicalSort` to only work for graphs created with `PermitCycles`. This is temporary. +* Changed `TransitiveReduction` to only work for graphs created with `PermitCycles`. This is temporary. + +## [0.11.0] - 2022-09-15 + +### Added +* Added the `Order` method for retrieving the number of vertices in the graph. +* Added the `Size` method for retrieving the number of edges in the graph. + +### Changed +* Changed the `graph` logo. +* Changed an internal operation of `ShortestPath` from O(n) to O(log(n)) by implementing the priority queue as a binary heap. Note that the actual complexity might still be defined by `ShortestPath` itself. + +### Fixed +* Fixed `draw.DOT` to work correctly with vertices that contain special characters and whitespaces. + +## [0.10.0] - 2022-09-09 + +### Added +* Added the `PredecessorMap` method for obtaining a map with all predecessors of each vertex. +* Added the `RemoveEdge` method for removing the edge between two vertices. +* Added the `Clone` method for retrieving a deep copy of the graph. +* Added the `TopologicalSort` function for obtaining the topological order of the vertices in the graph. +* Added the `TransitiveReduction` function for transforming the graph into its transitive reduction. + +### Changed +* Changed the `visit` function of `DFS` to accept a vertex hash instead of the vertex value (i.e. `K` instead of `T`). +* Changed the `visit` function of `BFS` to accept a vertex hash instead of the vertex value (i.e. `K` instead of `T`). + +### Removed +* Removed the `Predecessors` function. Use `PredecessorMap` instead and look up the respective vertex. + +## [0.9.0] - 2022-08-17 + +### Added +* Added the `Graph.AddVertex` method for adding a vertex. This replaces `Graph.Vertex`. +* Added the `Graph.AddEdge` method for creating an edge. This replaces `Graph.Edge`. +* Added the `Graph.Vertex` method for retrieving a vertex by its hash. This is not to be confused with the old `Graph.Vertex` function for adding vertices that got replaced with `Graph.AddVertex`. +* Added the `Graph.Edge` method for retrieving an edge. This is not to be confused with the old `Graph.Edge` function for creating an edge that got replaced with `Graph.AddEdge`. +* Added the `Graph.Predecessors` function for retrieving a vertex' predecessors. +* Added the `DFS` function. +* Added the `BFS` function. +* Added the `CreatesCycle` function. +* Added the `StronglyConnectedComponents` function. +* Added the `ShortestPath` function. +* Added the `ErrEdgeNotFound` error indicating that a desired edge could not be found. + +### Removed +* Removed the `Graph.EdgeByHashes` method. Use `Graph.AddEdge` instead. +* Removed the `Graph.GetEdgeByHashes` method. Use `Graph.Edge` instead. +* Removed the `Graph.DegreeByHash` method. Use `Graph.Degree` instead. +* Removed the `Graph.Degree` method. +* Removed the `Graph.DFS` and `Graph.DFSByHash` methods. Use `DFS` instead. +* Removed the `Graph.BFS` and `Graph.BFSByHash` methods. Use `BFS` instead. +* Removed the `Graph.CreatesCycle` and `Graph.CreatesCycleByHashes` methods. Use `CreatesCycle` instead. +* Removed the `Graph.StronglyConnectedComponents` method. Use `StronglyConnectedComponents` instead. +* Removed the `Graph.ShortestPath` and `Graph.ShortestPathByHash` methods. Use `ShortestPath` instead. + +## [0.8.0] - 2022-08-01 + +### Added +* Added the `EdgeWeight` and `EdgeAttribute` functional options. +* Added the `Properties` field to `Edge`. + +### Changed +* Changed `Edge` to accept a variadic `options` parameter. +* Changed `EdgeByHashes` to accept a variadic `options` parameter. +* Renamed `draw.Graph` to `draw.DOT` for more clarity regarding the rendering format. + +### Removed +* Removed the `WeightedEdge` function. Use `Edge` with the `EdgeWeight` functional option instead. +* Removed the `WeightedEdgeByHashes` function. Use `EdgeByHashes` with the `EdgeWeight` functional option instead. + +### Fixed +* Fixed missing edge attributes when drawing a graph using `draw.DOT`. + +## [0.7.0] - 2022-07-26 + +### Added +* Added `draw` package for graph visualization using DOT-compatible renderers. +* Added `Traits` function for retrieving the graph's traits. + +## [0.6.0] - 2022-07-22 + +### Added +* Added `AdjacencyMap` function for retrieving an adjancency map for all vertices. + +### Removed +* Removed the `AdjacencyList` function. + +## [0.5.0] - 2022-07-21 + +### Added +* Added `AdjacencyList` function for retrieving an adjacency list for all vertices. + +### Changed +* Updated the examples in the documentation. + +## [0.4.0] - 2022-07-01 + +### Added +* Added `ShortestPath` function for computing shortest paths. + +### Changed +* Changed the term "properties" to "traits" in the code and documentation. +* Don't traverse all vertices in disconnected graphs by design. + +## [0.3.0] - 2022-06-27 + +### Added +* Added `StronglyConnectedComponents` function for detecting SCCs. +* Added various images to usage examples. + +## [0.2.0] - 2022-06-20 + +### Added +* Added `Degree` and `DegreeByHash` functions for determining vertex degrees. +* Added cycle checks when adding an edge using the `Edge` functions. + +## [0.1.0] - 2022-06-19 + +### Added +* Added `CreatesCycle` and `CreatesCycleByHashes` functions for predicting cycles. + +## [0.1.0-beta] - 2022-06-17 + +### Changed +* Introduced dedicated types for directed and undirected graphs, making `Graph[K, T]` an interface. + +## [0.1.0-alpha] - 2022-06-13 + +### Added +* Introduced core types and methods. diff --git a/vendor/github.com/dominikbraun/graph/LICENSE b/vendor/github.com/dominikbraun/graph/LICENSE new file mode 100644 index 00000000000..261eeb9e9f8 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/github.com/dominikbraun/graph/README.md b/vendor/github.com/dominikbraun/graph/README.md new file mode 100644 index 00000000000..e736935f665 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/README.md @@ -0,0 +1,413 @@ +# ![dominikbraun/graph](img/logo.svg) + +A library for creating generic graph data structures and modifying, analyzing, and visualizing them. + +# Features + +* Generic vertices of any type, such as `int` or `City`. +* Graph traits with corresponding validations, such as cycle checks in acyclic graphs. +* Algorithms for finding paths or components, such as shortest paths or strongly connected components. +* Algorithms for transformations and representations, such as transitive reduction or topological order. +* Algorithms for non-recursive graph traversal, such as DFS or BFS. +* Vertices and edges with optional metadata, such as weights or custom attributes. +* Visualization of graphs using the DOT language and Graphviz. +* Integrate any storage backend by using your own `Store` implementation. +* Extensive tests with ~90% coverage, and zero dependencies. + +> Status: Because `graph` is in version 0, the public API shouldn't be considered stable. + +> This README may contain unreleased changes. Check out the [latest documentation](https://pkg.go.dev/github.com/dominikbraun/graph). + +# Getting started + +``` +go get github.com/dominikbraun/graph +``` + +# Quick examples + +## Create a graph of integers + +![graph of integers](img/simple.svg) + +```go +g := graph.New(graph.IntHash) + +_ = g.AddVertex(1) +_ = g.AddVertex(2) +_ = g.AddVertex(3) +_ = g.AddVertex(4) +_ = g.AddVertex(5) + +_ = g.AddEdge(1, 2) +_ = g.AddEdge(1, 4) +_ = g.AddEdge(2, 3) +_ = g.AddEdge(2, 4) +_ = g.AddEdge(2, 5) +_ = g.AddEdge(3, 5) +``` + +## Create a directed acyclic graph of integers + +![directed acyclic graph](img/dag.svg) + +```go +g := graph.New(graph.IntHash, graph.Directed(), graph.Acyclic()) + +_ = g.AddVertex(1) +_ = g.AddVertex(2) +_ = g.AddVertex(3) +_ = g.AddVertex(4) + +_ = g.AddEdge(1, 2) +_ = g.AddEdge(1, 3) +_ = g.AddEdge(2, 3) +_ = g.AddEdge(2, 4) +_ = g.AddEdge(3, 4) +``` + +## Create a graph of a custom type + +To understand this example in detail, see the [concept of hashes](#hashes). + +```go +type City struct { + Name string +} + +cityHash := func(c City) string { + return c.Name +} + +g := graph.New(cityHash) + +_ = g.AddVertex(london) +``` + +## Create a weighted graph + +![weighted graph](img/cities.svg) + +```go +g := graph.New(cityHash, graph.Weighted()) + +_ = g.AddVertex(london) +_ = g.AddVertex(munich) +_ = g.AddVertex(paris) +_ = g.AddVertex(madrid) + +_ = g.AddEdge("london", "munich", graph.EdgeWeight(3)) +_ = g.AddEdge("london", "paris", graph.EdgeWeight(2)) +_ = g.AddEdge("london", "madrid", graph.EdgeWeight(5)) +_ = g.AddEdge("munich", "madrid", graph.EdgeWeight(6)) +_ = g.AddEdge("munich", "paris", graph.EdgeWeight(2)) +_ = g.AddEdge("paris", "madrid", graph.EdgeWeight(4)) +``` + +## Perform a Depth-First Search + +This example traverses and prints all vertices in the graph in DFS order. + +![depth-first search](img/dfs.svg) + +```go +g := graph.New(graph.IntHash, graph.Directed()) + +_ = g.AddVertex(1) +_ = g.AddVertex(2) +_ = g.AddVertex(3) +_ = g.AddVertex(4) + +_ = g.AddEdge(1, 2) +_ = g.AddEdge(1, 3) +_ = g.AddEdge(3, 4) + +_ = graph.DFS(g, 1, func(value int) bool { + fmt.Println(value) + return false +}) +``` + +``` +1 3 4 2 +``` + +## Find strongly connected components + +![strongly connected components](img/scc.svg) + +```go +g := graph.New(graph.IntHash) + +// Add vertices and edges ... + +scc, _ := graph.StronglyConnectedComponents(g) + +fmt.Println(scc) +``` + +``` +[[1 2 5] [3 4 8] [6 7]] +``` + +## Find the shortest path + +![shortest path algorithm](img/dijkstra.svg) + +```go +g := graph.New(graph.StringHash, graph.Weighted()) + +// Add vertices and weighted edges ... + +path, _ := graph.ShortestPath(g, "A", "B") + +fmt.Println(path) +``` + +``` +[A C E B] +``` + +## Perform a topological sort + +![topological sort](img/topological-sort.svg) + +```go +g := graph.New(graph.IntHash, graph.Directed(), graph.PreventCycles()) + +// Add vertices and edges ... + +order, _ := graph.TopologicalSort(g) + +fmt.Println(order) +``` + +``` +[1 2 3 4 5] +``` + +## Perform a transitive reduction + +![transitive reduction](img/transitive-reduction-before.svg) + +```go +g := graph.New(graph.StringHash, graph.Directed(), graph.PreventCycles()) + +// Add vertices and edges ... + +transitiveReduction, _ := graph.TransitiveReduction(g) +``` + +![transitive reduction](img/transitive-reduction-after.svg) + +## Prevent the creation of cycles + +![cycle checks](img/cycles.svg) + +```go +g := graph.New(graph.IntHash, graph.PreventCycles()) + +_ = g.AddVertex(1) +_ = g.AddVertex(2) +_ = g.AddVertex(3) + +_ = g.AddEdge(1, 2) +_ = g.AddEdge(1, 3) + +if err := g.AddEdge(2, 3); err != nil { + panic(err) +} +``` + +``` +panic: an edge between 2 and 3 would introduce a cycle +``` + +## Visualize a graph using Graphviz + +The following example will generate a DOT description for `g` and write it into the given file. + +```go +g := graph.New(graph.IntHash, graph.Directed()) + +_ = g.AddVertex(1) +_ = g.AddVertex(2) +_ = g.AddVertex(3) + +_ = g.AddEdge(1, 2) +_ = g.AddEdge(1, 3) + +file, _ := os.Create("./mygraph.gv") +_ = draw.DOT(g, file) +``` + +To generate an SVG from the created file using Graphviz, use a command such as the following: + +``` +dot -Tsvg -O mygraph.gv +``` + +### Draw a graph as in this documentation + +![simple graph](img/simple.svg) + +This graph has been rendered using the following program: + +```go +package main + +import ( + "os" + + "github.com/dominikbraun/graph" + "github.com/dominikbraun/graph/draw" +) + +func main() { + g := graph.New(graph.IntHash) + + _ = g.AddVertex(1, graph.VertexAttribute("colorscheme", "blues3"), graph.VertexAttribute("style", "filled"), graph.VertexAttribute("color", "2"), graph.VertexAttribute("fillcolor", "1")) + _ = g.AddVertex(2, graph.VertexAttribute("colorscheme", "greens3"), graph.VertexAttribute("style", "filled"), graph.VertexAttribute("color", "2"), graph.VertexAttribute("fillcolor", "1")) + _ = g.AddVertex(3, graph.VertexAttribute("colorscheme", "purples3"), graph.VertexAttribute("style", "filled"), graph.VertexAttribute("color", "2"), graph.VertexAttribute("fillcolor", "1")) + _ = g.AddVertex(4, graph.VertexAttribute("colorscheme", "ylorbr3"), graph.VertexAttribute("style", "filled"), graph.VertexAttribute("color", "2"), graph.VertexAttribute("fillcolor", "1")) + _ = g.AddVertex(5, graph.VertexAttribute("colorscheme", "reds3"), graph.VertexAttribute("style", "filled"), graph.VertexAttribute("color", "2"), graph.VertexAttribute("fillcolor", "1")) + + _ = g.AddEdge(1, 2) + _ = g.AddEdge(1, 4) + _ = g.AddEdge(2, 3) + _ = g.AddEdge(2, 4) + _ = g.AddEdge(2, 5) + _ = g.AddEdge(3, 5) + + file, _ := os.Create("./simple.gv") + _ = draw.DOT(g, file) +} +``` + +It has been rendered using the `neato` engine: + +``` +dot -Tsvg -Kneato -O simple.gv +``` + +The example uses the [Brewer color scheme](https://graphviz.org/doc/info/colors.html#brewer) supported by Graphviz. + +## Storing edge attributes + +Edges may have one or more attributes which can be used to store metadata. Attributes will be taken +into account when [visualizing a graph](#visualize-a-graph-using-graphviz). For example, this edge +will be rendered in red color: + +```go +_ = g.AddEdge(1, 2, graph.EdgeAttribute("color", "red")) +``` + +To get an overview of all supported attributes, take a look at the +[DOT documentation](https://graphviz.org/doc/info/attrs.html). + +The stored attributes can be retrieved by getting the edge and accessing the `Properties.Attributes` +field. + +```go +edge, _ := g.Edge(1, 2) +color := edge.Properties.Attributes["color"] +``` + +## Storing edge data + +It is also possible to store arbitrary data inside edges, not just key-value string pairs. This data +is of type `any`. + +```go +_ = g.AddEdge(1, 2, graph.EdgeData(myData)) +``` + +The stored data can be retrieved by getting the edge and accessing the `Properties.Data` field. + +```go +edge, _ := g.Edge(1, 2) +myData := edge.Properties.Data +``` + +## Storing vertex attributes + +Vertices may have one or more attributes which can be used to store metadata. Attributes will be +taken into account when [visualizing a graph](#visualize-a-graph-using-graphviz). For example, this +vertex will be rendered in red color: + +```go +_ = g.AddVertex(1, graph.VertexAttribute("style", "filled")) +``` + +The stored data can be retrieved by getting the vertex using `VertexWithProperties` and accessing +the `Attributes` field. + +```go +vertex, properties, _ := g.VertexWithProperties(1) +style := properties.Attributes["style"] +``` + +To get an overview of all supported attributes, take a look at the +[DOT documentation](https://graphviz.org/doc/info/attrs.html). + +## Store the graph in a custom storage + +You can integrate any storage backend by implementing the `Store` interface and initializing a new +graph with it: + +```go +g := graph.NewWithStore(graph.IntHash, myStore) +``` + +To implement the `Store` interface appropriately, take a look at the [documentation](https://pkg.go.dev/github.com/dominikbraun/graph#Store). +[`graph-sql`](https://github.com/dominikbraun/graph-sql) is a ready-to-use SQL store implementation. + +# Concepts + +## Hashes + +A graph consists of nodes (or vertices) of type `T`, which are identified by a hash value of type +`K`. The hash value is obtained using the hashing function passed to `graph.New`. + +### Primitive types + +For primitive types such as `string` or `int`, you may use a predefined hashing function such as +`graph.IntHash` – a function that takes an integer and uses it as a hash value at the same time: + +```go +g := graph.New(graph.IntHash) +``` + +> This also means that only one vertex with a value like `5` can exist in the graph if +> `graph.IntHash` used. + +### Custom types + +For storing custom data types, you need to provide your own hashing function. This example function +takes a `City` and returns the city name as an unique hash value: + +```go +cityHash := func(c City) string { + return c.Name +} +``` + +Creating a graph using this hashing function will yield a graph with vertices of type `City` +identified by hash values of type `string`. + +```go +g := graph.New(cityHash) +``` + +## Traits + +The behavior of a graph, for example when adding or retrieving edges, depends on its traits. You +can set the graph's traits using the functional options provided by this library: + +```go +g := graph.New(graph.IntHash, graph.Directed(), graph.Weighted()) +``` + +# Documentation + +The full documentation is available at [pkg.go.dev](https://pkg.go.dev/github.com/dominikbraun/graph). diff --git a/vendor/github.com/dominikbraun/graph/collection.go b/vendor/github.com/dominikbraun/graph/collection.go new file mode 100644 index 00000000000..17f95424b5c --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/collection.go @@ -0,0 +1,107 @@ +package graph + +import ( + "container/heap" + "errors" +) + +// priorityQueue is a priority queue implementation for minimum priorities, meaning that smaller +// values will be prioritized. It maintains a list of priority items in descending order. +// +// This implementation is built on top of heap.Interface with some adjustments to comply with the +// usage of generics. +type priorityQueue[T comparable] struct { + items *minHeap[T] + cache map[T]*priorityItem[T] +} + +// priorityItem is an item in the priority queue, consisting of a priority and an actual value. +type priorityItem[T comparable] struct { + value T + priority float64 + // index is used internally by heap.Interface to re-organize items in the queue. + index int +} + +func newPriorityQueue[T comparable]() *priorityQueue[T] { + return &priorityQueue[T]{ + items: &minHeap[T]{}, + cache: map[T]*priorityItem[T]{}, + } +} + +func (p *priorityQueue[T]) Len() int { + return p.items.Len() +} + +// Push pushes a new item with the given priority into the queue. +func (p *priorityQueue[T]) Push(item T, priority float64) { + if _, ok := p.cache[item]; ok { + return + } + + newItem := &priorityItem[T]{ + value: item, + priority: priority, + index: 0, + } + + heap.Push(p.items, newItem) + p.cache[item] = newItem +} + +// Pop returns the item with the smallest priority from the queue and removes that item. +func (p *priorityQueue[T]) Pop() (T, error) { + if len(*p.items) == 0 { + var empty T + return empty, errors.New("priority queue is empty") + } + + item := heap.Pop(p.items).(*priorityItem[T]) + delete(p.cache, item.value) + + return item.value, nil +} + +// UpdatePriority updates the priority of a given item to the given priority. The item must be +// pushed into the queue first. If the item doesn't exist, nothing happens. +func (p *priorityQueue[T]) UpdatePriority(item T, priority float64) { + targetItem, ok := p.cache[item] + if !ok { + return + } + + targetItem.priority = priority + heap.Fix(p.items, targetItem.index) +} + +// minHeap is a binary min heap that implements heap.Interface. +type minHeap[T comparable] []*priorityItem[T] + +func (m *minHeap[T]) Len() int { + return len(*m) +} + +func (m *minHeap[T]) Less(i, j int) bool { + return (*m)[i].priority < (*m)[j].priority +} + +func (m *minHeap[T]) Swap(i, j int) { + (*m)[i], (*m)[j] = (*m)[j], (*m)[i] + (*m)[i].index = i + (*m)[j].index = j +} + +func (m *minHeap[T]) Push(item interface{}) { + i := item.(*priorityItem[T]) + i.index = len(*m) + *m = append(*m, i) +} + +func (m *minHeap[T]) Pop() interface{} { + old := *m + item := old[len(old)-1] + *m = old[:len(old)-1] + + return item +} diff --git a/vendor/github.com/dominikbraun/graph/dag.go b/vendor/github.com/dominikbraun/graph/dag.go new file mode 100644 index 00000000000..ab6cb52b0ad --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/dag.go @@ -0,0 +1,144 @@ +package graph + +import ( + "errors" + "fmt" +) + +// TopologicalSort performs a topological sort on a given graph and returns the vertex hashes in +// topological order. A topological order is a non-unique order of the vertices in a directed graph +// where an edge from vertex A to vertex B implies that vertex A appears before vertex B. +// +// TopologicalSort only works for directed acyclic graphs. The current implementation works non- +// recursively and uses Kahn's algorithm. +func TopologicalSort[K comparable, T any](g Graph[K, T]) ([]K, error) { + if !g.Traits().IsDirected { + return nil, fmt.Errorf("topological sort cannot be computed on undirected graph") + } + + predecessorMap, err := g.PredecessorMap() + if err != nil { + return nil, fmt.Errorf("failed to get predecessor map: %w", err) + } + + queue := make([]K, 0) + + for vertex, predecessors := range predecessorMap { + if len(predecessors) == 0 { + queue = append(queue, vertex) + } + } + + order := make([]K, 0, len(predecessorMap)) + visited := make(map[K]struct{}) + + for len(queue) > 0 { + currentVertex := queue[0] + queue = queue[1:] + + if _, ok := visited[currentVertex]; ok { + continue + } + + order = append(order, currentVertex) + visited[currentVertex] = struct{}{} + + for vertex, predecessors := range predecessorMap { + delete(predecessors, currentVertex) + + if len(predecessors) == 0 { + queue = append(queue, vertex) + } + } + } + + gOrder, err := g.Order() + if err != nil { + return nil, fmt.Errorf("failed to get graph order: %w", err) + } + + if len(order) != gOrder { + return nil, errors.New("topological sort cannot be computed on graph with cycles") + } + + return order, nil +} + +// TransitiveReduction returns a new graph with the same vertices and the same reachability as the +// given graph, but with as few edges as possible. This significantly reduces its complexity. +// +// With a time complexity of O(V(V+E)), TransitiveReduction is a very costly operation. Note that +func TransitiveReduction[K comparable, T any](g Graph[K, T]) (Graph[K, T], error) { + if !g.Traits().IsDirected { + return nil, fmt.Errorf("transitive reduction cannot be performed on undirected graph") + } + + transitiveReduction, err := g.Clone() + if err != nil { + return nil, fmt.Errorf("failed to clone the graph: %w", err) + } + + adjacencyMap, err := transitiveReduction.AdjacencyMap() + if err != nil { + return nil, fmt.Errorf("failed to get adajcency map: %w", err) + } + + for vertex, successors := range adjacencyMap { + // For each direct successor of the current vertex, run a DFS starting from that successor. + // Then, for each vertex visited in the DFS, inspect all of its edges. Remove the edges that + // also appear in the edges of the top-level iteration vertex. + // + // These edges are redundant because their targets obviously are reachable through the DFS, + // hence they can be removed from the top-level vertex. + tOrder, err := transitiveReduction.Order() + if err != nil { + return nil, fmt.Errorf("failed to get graph order: %w", err) + } + for successor := range successors { + stack := make([]K, 0, tOrder) + visited := make(map[K]struct{}, tOrder) + onStack := make(map[K]bool, tOrder) + + stack = append(stack, successor) + + for len(stack) > 0 { + current := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + // If the vertex has already been visited, remove it from the stack and continue + // with the next vertex. Otherwise, proceed by putting it onto the stack. + if _, ok := visited[current]; ok { + onStack[current] = false + continue + } + + visited[current] = struct{}{} + onStack[current] = true + stack = append(stack, current) + + // Also, if the vertex is a leaf node, remove it from the stack. + if len(adjacencyMap[current]) == 0 { + onStack[current] = false + } + + for adjacency := range adjacencyMap[current] { + if _, ok := visited[adjacency]; ok { + if onStack[adjacency] { + // If the current adjacency is both on the stack and has already been + // visited, there is a cycle and an error is returned. + return nil, fmt.Errorf("transitive reduction cannot be performed on graph with cycle") + } + continue + } + + if _, ok := adjacencyMap[vertex][adjacency]; ok { + _ = transitiveReduction.RemoveEdge(vertex, adjacency) + } + stack = append(stack, adjacency) + } + } + } + } + + return transitiveReduction, nil +} diff --git a/vendor/github.com/dominikbraun/graph/directed.go b/vendor/github.com/dominikbraun/graph/directed.go new file mode 100644 index 00000000000..ce7ad33fc76 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/directed.go @@ -0,0 +1,229 @@ +package graph + +import ( + "errors" + "fmt" +) + +type directed[K comparable, T any] struct { + hash Hash[K, T] + traits *Traits + store Store[K, T] +} + +func newDirected[K comparable, T any](hash Hash[K, T], traits *Traits, store Store[K, T]) *directed[K, T] { + return &directed[K, T]{ + hash: hash, + traits: traits, + store: store, + } +} + +func (d *directed[K, T]) Traits() *Traits { + return d.traits +} + +func (d *directed[K, T]) AddVertex(value T, options ...func(*VertexProperties)) error { + hash := d.hash(value) + properties := VertexProperties{ + Weight: 0, + Attributes: make(map[string]string), + } + + for _, option := range options { + option(&properties) + } + + return d.store.AddVertex(hash, value, properties) +} + +func (d *directed[K, T]) Vertex(hash K) (T, error) { + vertex, _, err := d.store.Vertex(hash) + return vertex, err +} + +func (d *directed[K, T]) VertexWithProperties(hash K) (T, VertexProperties, error) { + vertex, properties, err := d.store.Vertex(hash) + if err != nil { + return vertex, VertexProperties{}, err + } + + return vertex, properties, nil +} + +func (d *directed[K, T]) AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error { + _, _, err := d.store.Vertex(sourceHash) + if err != nil { + return fmt.Errorf("source vertex %v: %w", sourceHash, err) + } + + _, _, err = d.store.Vertex(targetHash) + if err != nil { + return fmt.Errorf("target vertex %v: %w", targetHash, err) + } + + if _, err := d.Edge(sourceHash, targetHash); !errors.Is(err, ErrEdgeNotFound) { + return ErrEdgeAlreadyExists + } + + // If the user opted in to preventing cycles, run a cycle check. + if d.traits.PreventCycles { + createsCycle, err := CreatesCycle[K, T](d, sourceHash, targetHash) + if err != nil { + return fmt.Errorf("check for cycles: %w", err) + } + if createsCycle { + return ErrEdgeCreatesCycle + } + } + + edge := Edge[K]{ + Source: sourceHash, + Target: targetHash, + Properties: EdgeProperties{ + Attributes: make(map[string]string), + }, + } + + for _, option := range options { + option(&edge.Properties) + } + + return d.addEdge(sourceHash, targetHash, edge) +} + +func (d *directed[K, T]) Edge(sourceHash, targetHash K) (Edge[T], error) { + edge, err := d.store.Edge(sourceHash, targetHash) + if err != nil { + return Edge[T]{}, err + } + + sourceVertex, _, err := d.store.Vertex(sourceHash) + if err != nil { + return Edge[T]{}, err + } + + targetVertex, _, err := d.store.Vertex(targetHash) + if err != nil { + return Edge[T]{}, err + } + + return Edge[T]{ + Source: sourceVertex, + Target: targetVertex, + Properties: EdgeProperties{ + Weight: edge.Properties.Weight, + Attributes: edge.Properties.Attributes, + Data: edge.Properties.Data, + }, + }, nil +} + +func (d *directed[K, T]) RemoveEdge(source, target K) error { + if _, err := d.Edge(source, target); err != nil { + return err + } + + if err := d.store.RemoveEdge(source, target); err != nil { + return fmt.Errorf("failed to remove edge from %v to %v: %w", source, target, err) + } + + return nil +} + +func (d *directed[K, T]) AdjacencyMap() (map[K]map[K]Edge[K], error) { + vertices, err := d.store.ListVertices() + if err != nil { + return nil, fmt.Errorf("failed to list vertices: %w", err) + } + + edges, err := d.store.ListEdges() + if err != nil { + return nil, fmt.Errorf("failed to list edges: %w", err) + } + + m := make(map[K]map[K]Edge[K]) + + for _, vertex := range vertices { + m[vertex] = make(map[K]Edge[K]) + } + + for _, edge := range edges { + m[edge.Source][edge.Target] = edge + } + + return m, nil +} + +func (d *directed[K, T]) PredecessorMap() (map[K]map[K]Edge[K], error) { + m := make(map[K]map[K]Edge[K]) + + vertices, err := d.store.ListVertices() + if err != nil { + return nil, fmt.Errorf("failed to list vertices: %w", err) + } + + edges, err := d.store.ListEdges() + if err != nil { + return nil, fmt.Errorf("failed to list edges: %w", err) + } + + for _, vertex := range vertices { + m[vertex] = make(map[K]Edge[K]) + } + + for _, edge := range edges { + if _, ok := m[edge.Target]; !ok { + m[edge.Target] = make(map[K]Edge[K]) + } + m[edge.Target][edge.Source] = edge + } + + return m, nil +} + +func (d *directed[K, T]) addEdge(sourceHash, targetHash K, edge Edge[K]) error { + return d.store.AddEdge(sourceHash, targetHash, edge) +} + +func (d *directed[K, T]) Clone() (Graph[K, T], error) { + traits := &Traits{ + IsDirected: d.traits.IsDirected, + IsAcyclic: d.traits.IsAcyclic, + IsWeighted: d.traits.IsWeighted, + IsRooted: d.traits.IsRooted, + } + + return &directed[K, T]{ + hash: d.hash, + traits: traits, + store: d.store, + }, nil +} + +func (d *directed[K, T]) Order() (int, error) { + return d.store.VertexCount() +} + +func (d *directed[K, T]) Size() (int, error) { + size := 0 + outEdges, err := d.AdjacencyMap() + if err != nil { + return 0, fmt.Errorf("failed to get adjacency map: %w", err) + } + + for _, outEdges := range outEdges { + size += len(outEdges) + } + + return size, nil +} + +func (d *directed[K, T]) edgesAreEqual(a, b Edge[T]) bool { + aSourceHash := d.hash(a.Source) + aTargetHash := d.hash(a.Target) + bSourceHash := d.hash(b.Source) + bTargetHash := d.hash(b.Target) + + return aSourceHash == bSourceHash && aTargetHash == bTargetHash +} diff --git a/vendor/github.com/dominikbraun/graph/graph.go b/vendor/github.com/dominikbraun/graph/graph.go new file mode 100644 index 00000000000..f4e4daf32ec --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/graph.go @@ -0,0 +1,249 @@ +// Package graph provides types and functions for creating generic graph data structures and +// modifying, analyzing, and visualizing them. +package graph + +import "errors" + +var ( + ErrVertexNotFound = errors.New("vertex not found") + ErrVertexAlreadyExists = errors.New("vertex already exists") + ErrEdgeNotFound = errors.New("edge not found") + ErrEdgeAlreadyExists = errors.New("edge already exists") + ErrEdgeCreatesCycle = errors.New("edge would create a cycle") +) + +// Graph represents a generic graph data structure consisting of vertices and edges. Its vertices +// are of type T, and each vertex is identified by a hash of type K. +type Graph[K comparable, T any] interface { + // Traits returns the graph's traits. Those traits must be set when creating a graph using New. + Traits() *Traits + + // AddVertex creates a new vertex in the graph. If the vertex already exists in the graph, + // ErrVertexAlreadyExists will be returned if no custom Store implementation is used. + // + // AddVertex accepts a variety of functional options to set further edge details such as the + // weight or an attribute: + // + // _ = graph.AddVertex("A", "B", graph.VertexWeight(4), graph.VertexAttribute("label", "my-label")) + // + AddVertex(value T, options ...func(*VertexProperties)) error + + // Vertex returns the vertex with the given hash or ErrVertexNotFound if it doesn't exist. + Vertex(hash K) (T, error) + + // VertexWithProperties returns the vertex with the given hash along with its properties or + // ErrVertexNotFound if it doesn't exist. + VertexWithProperties(hash K) (T, VertexProperties, error) + + // AddEdge creates an edge between the source and the target vertex. If the Directed option has + // been called on the graph, this is a directed edge. If either vertex can't be found, + // ErrVertexNotFound will be returned. If the edge already exists, ErrEdgeAlreadyExists will be + // returned. If cycle prevention has been activated using PreventCycles and adding the edge + // would create a cycle, ErrEdgeCreatesCycle will be returned. + // + // AddEdge accepts a variety of functional options to set further edge details such as the + // weight or an attribute: + // + // _ = graph.AddEdge("A", "B", graph.EdgeWeight(4), graph.EdgeAttribute("label", "my-label")) + // + AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error + + // Edge returns the edge joining two given vertices or an error if the edge doesn't exist. In an + // undirected graph, an edge with swapped source and target vertices does match. + // + // If the edge doesn't exist, ErrEdgeNotFound will be returned. + Edge(sourceHash, targetHash K) (Edge[T], error) + + // RemoveEdge removes the edge between the given source and target vertices. If the edge doesn't + // exist, ErrEdgeNotFound will be returned if no custom Store implementation is used. + RemoveEdge(source, target K) error + + // AdjacencyMap computes and returns an adjacency map containing all vertices in the graph. + // + // There is an entry for each vertex, and each of those entries is another map whose keys are + // the hash values of the adjacent vertices. The value is an Edge instance that stores the + // source and target hash values (these are the same as the map keys) as well as edge metadata. + // + // For a graph with edges AB and AC, the adjacency map would look as follows: + // + // map[string]map[string]Edge[string]{ + // "A": map[string]Edge[string]{ + // "B": {Source: "A", Target: "B"} + // "C": {Source: "A", Target: "C"} + // } + // } + // + // This design makes AdjacencyMap suitable for a wide variety of scenarios and demands. + AdjacencyMap() (map[K]map[K]Edge[K], error) + + // PredecessorMap computes and returns a predecessors map containing all vertices in the graph. + // + // The map layout is the same as for AdjacencyMap. + // + // For an undirected graph, PredecessorMap is the same as AdjacencyMap. For a directed graph, + // PredecessorMap is the complement of AdjacencyMap. This is because in a directed graph, only + // vertices joined by an outgoing edge are considered adjacent to the current vertex, whereas + // predecessors are the vertices joined by an ingoing edge. + PredecessorMap() (map[K]map[K]Edge[K], error) + + // Clone creates an independent deep copy of the graph and returns that cloned graph. + Clone() (Graph[K, T], error) + + // Order computes and returns the number of vertices in the graph. + Order() (int, error) + + // Size computes and returns the number of edges in the graph. + Size() (int, error) +} + +// Edge represents a graph edge with a source and target vertex as well as a weight, which has the +// same value for all edges in an unweighted graph. Even though the vertices are referred to as +// source and target, whether the graph is directed or not is determined by its traits. +type Edge[T any] struct { + Source T + Target T + Properties EdgeProperties +} + +// EdgeProperties represents a set of properties that each edge possesses. They can be set when +// adding a new edge using the functional options provided by this library: +// +// g.AddEdge("A", "B", graph.EdgeWeight(2), graph.EdgeAttribute("color", "red")) +// +// The example above will create an edge with weight 2 and a "color" attribute with value "red". +type EdgeProperties struct { + Attributes map[string]string + Weight int + Data any +} + +// Hash is a hashing function that takes a vertex of type T and returns a hash value of type K. +// +// Every graph has a hashing function and uses that function to retrieve the hash values of its +// vertices. You can either use one of the predefined hashing functions, or, if you want to store a +// custom data type, provide your own function: +// +// cityHash := func(c City) string { +// return c.Name +// } +// +// The cityHash function returns the city name as a hash value. The types of T and K, in this case +// City and string, also define the types T and K of the graph. +type Hash[K comparable, T any] func(T) K + +// New creates a new graph with vertices of type T, identified by hash values of type K. These hash +// values will be obtained using the provided hash function (see Hash). +// +// For primitive vertex values, you may use the predefined hashing functions. As an example, this +// graph stores integer vertices: +// +// g := graph.New(graph.IntHash) +// _ = g.AddVertex(1) +// _ = g.AddVertex(2) +// _ = g.AddVertex(3) +// +// The provided IntHash hashing function takes an integer and uses it as a hash value at the same +// time. In a more complex scenario with custom objects, you should define your own function: +// +// type City struct { +// Name string +// } +// +// cityHash := func(c City) string { +// return c.Name +// } +// +// g := graph.New(cityHash) +// _ = g.AddVertex(london) +// +// This graph will store vertices of type City, identified by hashes of type string. Both type +// parameters can be inferred from the hashing function. +// +// All traits of the graph can be set using the predefined functional options. They can be combined +// arbitrarily. This example creates a directed acyclic graph: +// +// g := graph.New(graph.IntHash, graph.Directed(), graph.Acyclic()) +// +// Which Graph implementation will be returned depends on these traits. +func New[K comparable, T any](hash Hash[K, T], options ...func(*Traits)) Graph[K, T] { + return NewWithStore(hash, newMemoryStore[K, T](), options...) +} + +// NewWithStore creates a new graph same as New, but uses the provided store instead of the default +// memory store. +func NewWithStore[K comparable, T any](hash Hash[K, T], store Store[K, T], options ...func(*Traits)) Graph[K, T] { + var p Traits + + for _, option := range options { + option(&p) + } + + if p.IsDirected { + return newDirected(hash, &p, store) + } + + return newUndirected(hash, &p, store) +} + +// StringHash is a hashing function that accepts a string and uses that exact string as a hash +// value. Using it as Hash will yield a Graph[string, string]. +func StringHash(v string) string { + return v +} + +// IntHash is a hashing function that accepts an integer and uses that exact integer as a hash +// value. Using it as Hash will yield a Graph[int, int]. +func IntHash(v int) int { + return v +} + +// EdgeWeight returns a function that sets the weight of an edge to the given weight. This is a +// functional option for the Edge and AddEdge methods. +func EdgeWeight(weight int) func(*EdgeProperties) { + return func(e *EdgeProperties) { + e.Weight = weight + } +} + +// EdgeAttribute returns a function that adds the given key-value pair to the attributes of an +// edge. This is a functional option for the Edge and AddEdge methods. +func EdgeAttribute(key, value string) func(*EdgeProperties) { + return func(e *EdgeProperties) { + e.Attributes[key] = value + } +} + +// EdgeData returns a function that sets the data of an edge to the given value. This is a +// functional option for the Edge and AddEdge methods. +func EdgeData(data any) func(*EdgeProperties) { + return func(e *EdgeProperties) { + e.Data = data + } +} + +// VertexProperties represents a set of properties that each vertex has. They can be set when adding +// a new vertex using the functional options provided by this library: +// +// _ = g.AddVertex("A", "B", graph.VertexWeight(2), graph.VertexAttribute("color", "red")) +// +// The example above will create a vertex with weight 2 and a "color" attribute with value "red". +type VertexProperties struct { + Attributes map[string]string + Weight int +} + +// VertexWeight returns a function that sets the weight of a vertex to the given weight. This is a +// functional option for the Vertex and AddVertex methods. +func VertexWeight(weight int) func(*VertexProperties) { + return func(e *VertexProperties) { + e.Weight = weight + } +} + +// VertexAttribute returns a function that adds the given key-value pair to the attributes of a +// vertex. This is a functional option for the Vertex and AddVertex methods. +func VertexAttribute(key, value string) func(*VertexProperties) { + return func(e *VertexProperties) { + e.Attributes[key] = value + } +} diff --git a/vendor/github.com/dominikbraun/graph/paths.go b/vendor/github.com/dominikbraun/graph/paths.go new file mode 100644 index 00000000000..e6f16e3f26b --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/paths.go @@ -0,0 +1,237 @@ +package graph + +import ( + "errors" + "fmt" + "math" +) + +var ErrTargetNotReachable = errors.New("target vertex not reachable from source") + +// CreatesCycle determines whether an edge between the given source and target vertices would +// introduce a cycle. It won't create that edge in any case. +// +// A potential edge would create a cycle if the target vertex is also a parent of the source vertex. +// Given a graph A-B-C-D, adding an edge DA would introduce a cycle: +// +// A - +// | | +// B | +// | | +// C | +// | | +// D - +// +// CreatesCycle backtracks the ingoing edges of D, resulting in a reverse walk C-B-A. +func CreatesCycle[K comparable, T any](g Graph[K, T], source, target K) (bool, error) { + if _, err := g.Vertex(source); err != nil { + return false, fmt.Errorf("could not get vertex with hash %v: %w", source, err) + } + + if _, err := g.Vertex(target); err != nil { + return false, fmt.Errorf("could not get vertex with hash %v: %w", target, err) + } + + if source == target { + return true, nil + } + + predecessorMap, err := g.PredecessorMap() + if err != nil { + return false, fmt.Errorf("failed to get predecessor map: %w", err) + } + + stack := make([]K, 0) + visited := make(map[K]bool) + + stack = append(stack, source) + + for len(stack) > 0 { + currentHash := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + if _, ok := visited[currentHash]; !ok { + // If the current vertex, e.g. an adjacency of the source vertex, also is the target + // vertex, an edge between these two would create a cycle. + if currentHash == target { + return true, nil + } + visited[currentHash] = true + + for adjacency := range predecessorMap[currentHash] { + stack = append(stack, adjacency) + } + } + } + + return false, nil +} + +// ShortestPath computes the shortest path between a source and a target vertex using the edge +// weights and returns the hash values of the vertices forming that path using Dijkstra's algorithm. +// This search runs in O(|V|+|E|log(|V|)) time. +// +// The returned path includes the source and target vertices. If the target cannot be reached +// from the source vertex, ErrTargetNotReachable will be returned. If there are multiple shortest +// paths, an arbitrary one will be returned. +func ShortestPath[K comparable, T any](g Graph[K, T], source, target K) ([]K, error) { + weights := make(map[K]float64) + visited := make(map[K]bool) + + weights[source] = 0 + visited[target] = true + + queue := newPriorityQueue[K]() + adjacencyMap, err := g.AdjacencyMap() + if err != nil { + return nil, fmt.Errorf("could not get adjacency map: %w", err) + } + + for hash := range adjacencyMap { + if hash != source { + weights[hash] = math.Inf(1) + visited[hash] = false + } + + queue.Push(hash, weights[hash]) + } + + // bestPredecessors stores the best (i.e. cheapest or least-weighted) predecessor for each + // vertex. If there is an edge AC with weight 4 and an edge BC with weight 2, the best + // predecessor for C is B. + bestPredecessors := make(map[K]K) + + for queue.Len() > 0 { + vertex, _ := queue.Pop() + hasInfiniteWeight := math.IsInf(weights[vertex], 1) + + for adjacency, edge := range adjacencyMap[vertex] { + edgeWeight := edge.Properties.Weight + + // Setting the weight to 1 is required for unweighted graphs whose + // edge weights are 0. Otherwise, all paths would have a sum of 0 + // and a random path would be returned. + if !g.Traits().IsWeighted { + edgeWeight = 1 + } + + weight := weights[vertex] + float64(edgeWeight) + + if weight < weights[adjacency] && !hasInfiniteWeight { + weights[adjacency] = weight + bestPredecessors[adjacency] = vertex + queue.UpdatePriority(adjacency, weight) + } + } + } + + // Backtrack the predecessors from target to source. These are the least-weighted edges. + path := []K{target} + hashCursor := target + + for hashCursor != source { + // If hashCursor is not a present key in bestPredecessors, hashCursor is set to the zero + // value. Without this check, this leads to endless prepending of zeros to the path. + if _, ok := bestPredecessors[hashCursor]; !ok { + return nil, ErrTargetNotReachable + } + hashCursor = bestPredecessors[hashCursor] + path = append([]K{hashCursor}, path...) + } + + return path, nil +} + +type sccState[K comparable] struct { + adjacencyMap map[K]map[K]Edge[K] + components [][]K + stack []K + onStack map[K]bool + visited map[K]struct{} + lowlink map[K]int + index map[K]int + time int +} + +// StronglyConnectedComponents detects all strongly connected components within the given graph +// and returns the hashes of the vertices shaping these components, so each component is a []K. +// +// The current implementation uses Tarjan's algorithm and runs recursively. +func StronglyConnectedComponents[K comparable, T any](g Graph[K, T]) ([][]K, error) { + if !g.Traits().IsDirected { + return nil, errors.New("SCCs can only be detected in directed graphs") + } + + adjacencyMap, err := g.AdjacencyMap() + if err != nil { + return nil, fmt.Errorf("could not get adjacency map: %w", err) + } + + state := &sccState[K]{ + adjacencyMap: adjacencyMap, + components: make([][]K, 0), + stack: make([]K, 0), + onStack: make(map[K]bool), + visited: make(map[K]struct{}), + lowlink: make(map[K]int), + index: make(map[K]int), + } + + for hash := range state.adjacencyMap { + if _, ok := state.visited[hash]; !ok { + findSCC(hash, state) + } + } + + return state.components, nil +} + +func findSCC[K comparable](vertexHash K, state *sccState[K]) { + state.stack = append(state.stack, vertexHash) + state.onStack[vertexHash] = true + state.visited[vertexHash] = struct{}{} + state.index[vertexHash] = state.time + state.lowlink[vertexHash] = state.time + + state.time++ + + for adjacency := range state.adjacencyMap[vertexHash] { + if _, ok := state.visited[adjacency]; !ok { + findSCC(adjacency, state) + + smallestLowlink := math.Min( + float64(state.lowlink[vertexHash]), + float64(state.lowlink[adjacency]), + ) + state.lowlink[vertexHash] = int(smallestLowlink) + } else { + // If the adjacent vertex already is on the stack, the edge joining the current and the + // adjacent vertex is a back edge. Therefore, update the vertex' lowlink value to the + // index of the adjacent vertex if it is smaller than the lowlink value. + if state.onStack[adjacency] { + smallestLowlink := math.Min( + float64(state.lowlink[vertexHash]), + float64(state.index[adjacency]), + ) + state.lowlink[vertexHash] = int(smallestLowlink) + } + } + } + + // If the lowlink value of the vertex is equal to its DFS index, this is th head vertex of a + // strongly connected component, shaped by this vertex and the vertices on the stack. + if state.lowlink[vertexHash] == state.index[vertexHash] { + var hash K + var component []K + + for hash != vertexHash { + hash = state.stack[len(state.stack)-1] + state.stack = state.stack[:len(state.stack)-1] + state.onStack[hash] = false + + component = append(component, hash) + } + + state.components = append(state.components, component) + } +} diff --git a/vendor/github.com/dominikbraun/graph/store.go b/vendor/github.com/dominikbraun/graph/store.go new file mode 100644 index 00000000000..bbcf5e8df30 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/store.go @@ -0,0 +1,179 @@ +package graph + +import "sync" + +// Store represents a storage for vertices and edges. The graph library provides an in-memory store +// by default and accepts any Store implementation to work with - for example, an SQL store. +// +// When implementing your own Store, make sure the individual methods and their behavior adhere to +// this documentation. Otherwise, the graphs aren't guaranteed to behave as expected. +type Store[K comparable, T any] interface { + // AddVertex should add the given vertex with the given hash value and vertex properties to the + // graph. If the vertex already exists, it is up to you whether ErrVertexAlreadyExists or no + // error should be returned. + AddVertex(hash K, value T, properties VertexProperties) error + + // Vertex should return the vertex and vertex properties with the given hash value. If the + // vertex doesn't exist, ErrVertexNotFound should be returned. + Vertex(hash K) (T, VertexProperties, error) + + // ListVertices should return all vertices in the graph in a slice. + ListVertices() ([]K, error) + + // VertexCount should return the number of vertices in the graph. This should be equal to the + // length of the slice returned by ListVertices. + VertexCount() (int, error) + + // AddEdge should add an edge between the vertices with the given source and target hashes. + // + // If either vertex doesn't exit, ErrVertexNotFound should be returned for the respective + // vertex. If the edge already exists, ErrEdgeAlreadyExists should be returned. + AddEdge(sourceHash, targetHash K, edge Edge[K]) error + + // RemoveEdge should remove the edge between the vertices with the given source and target + // hashes. + // + // If either vertex doesn't exist, it is up to you whether ErrVertexNotFound or no error should + // be returned. If the edge doesn't exist, it is up to you whether ErrEdgeNotFound or no error + // should be returned. + RemoveEdge(sourceHash, targetHash K) error + + // Edge should return the edge joining the vertices with the given hash values. It should + // exclusively look for an edge between the source and the target vertex, not vice versa. The + // graph implementation does this for undirected graphs itself. + // + // Note that unlike Graph.Edge, this function is supposed to return an Edge[K], i.e. an edge + // that only contains the vertex hashes instead of the vertices themselves. + // + // If the edge doesn't exist, ErrEdgeNotFound should be returned. + Edge(sourceHash, targetHash K) (Edge[K], error) + + // ListEdges should return all edges in the graph in a slice. + ListEdges() ([]Edge[K], error) +} + +type memoryStore[K comparable, T any] struct { + lock sync.RWMutex + vertices map[K]T + vertexProperties map[K]VertexProperties + + // outEdges and inEdges store all outgoing and ingoing edges for all vertices. For O(1) access, + // these edges themselves are stored in maps whose keys are the hashes of the target vertices. + outEdges map[K]map[K]Edge[K] // source -> target + inEdges map[K]map[K]Edge[K] // target -> source +} + +func newMemoryStore[K comparable, T any]() Store[K, T] { + return &memoryStore[K, T]{ + vertices: make(map[K]T), + vertexProperties: make(map[K]VertexProperties), + outEdges: make(map[K]map[K]Edge[K]), + inEdges: make(map[K]map[K]Edge[K]), + } +} + +func (s *memoryStore[K, T]) AddVertex(k K, t T, p VertexProperties) error { + s.lock.Lock() + defer s.lock.Unlock() + + if _, ok := s.vertices[k]; ok { + return ErrVertexAlreadyExists + } + + s.vertices[k] = t + s.vertexProperties[k] = p + + return nil +} + +func (s *memoryStore[K, T]) ListVertices() ([]K, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + var hashes []K + for k := range s.vertices { + hashes = append(hashes, k) + } + + return hashes, nil +} + +func (s *memoryStore[K, T]) VertexCount() (int, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + return len(s.vertices), nil +} + +func (s *memoryStore[K, T]) Vertex(k K) (T, VertexProperties, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + var v T + var ok bool + v, ok = s.vertices[k] + if !ok { + return v, VertexProperties{}, ErrVertexNotFound + } + + p := s.vertexProperties[k] + return v, p, nil +} + +func (s *memoryStore[K, T]) AddEdge(sourceHash, targetHash K, edge Edge[K]) error { + s.lock.Lock() + defer s.lock.Unlock() + + if _, ok := s.outEdges[sourceHash]; !ok { + s.outEdges[sourceHash] = make(map[K]Edge[K]) + } + + s.outEdges[sourceHash][targetHash] = edge + + if _, ok := s.inEdges[targetHash]; !ok { + s.inEdges[targetHash] = make(map[K]Edge[K]) + } + + s.inEdges[targetHash][sourceHash] = edge + + return nil +} + +func (s *memoryStore[K, T]) RemoveEdge(sourceHash, targetHash K) error { + s.lock.Lock() + defer s.lock.Unlock() + + delete(s.inEdges[sourceHash], targetHash) + delete(s.outEdges[sourceHash], targetHash) + return nil +} + +func (s *memoryStore[K, T]) Edge(sourceHash, targetHash K) (Edge[K], error) { + s.lock.RLock() + defer s.lock.RUnlock() + + sourceEdges, ok := s.outEdges[sourceHash] + if !ok { + return Edge[K]{}, ErrEdgeNotFound + } + + edge, ok := sourceEdges[targetHash] + if !ok { + return Edge[K]{}, ErrEdgeNotFound + } + + return edge, nil +} + +func (s *memoryStore[K, T]) ListEdges() ([]Edge[K], error) { + s.lock.RLock() + defer s.lock.RUnlock() + + res := make([]Edge[K], 0) + for _, edges := range s.outEdges { + for _, edge := range edges { + res = append(res, edge) + } + } + return res, nil +} diff --git a/vendor/github.com/dominikbraun/graph/traits.go b/vendor/github.com/dominikbraun/graph/traits.go new file mode 100644 index 00000000000..11b735766b5 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/traits.go @@ -0,0 +1,63 @@ +package graph + +// Traits represents a set of graph traits and types, such as directedness or acyclicness. These +// traits can be set when creating a graph by passing the corresponding functional options, for +// example: +// +// g := graph.New(graph.IntHash, graph.Directed()) +// +// This will set the IsDirected field to true. +type Traits struct { + IsDirected bool + IsAcyclic bool + IsWeighted bool + IsRooted bool + PreventCycles bool +} + +// Directed creates a directed graph. This has implications on graph traversal and the order of +// arguments of the Edge and AddEdge functions. +func Directed() func(*Traits) { + return func(t *Traits) { + t.IsDirected = true + } +} + +// Acyclic creates an acyclic graph. Note that creating edges that form a cycle will still be +// possible. To prevent this explicitly, use PreventCycles. +func Acyclic() func(*Traits) { + return func(t *Traits) { + t.IsAcyclic = true + } +} + +// Weighted creates a weighted graph. To set weights, use the Edge and AddEdge functions. +func Weighted() func(*Traits) { + return func(t *Traits) { + t.IsWeighted = true + } +} + +// Rooted creates a rooted graph. This is particularly common for building tree data structures. +func Rooted() func(*Traits) { + return func(t *Traits) { + t.IsRooted = true + } +} + +// Tree is an alias for Acyclic and Rooted, since most trees in Computer Science are rooted trees. +func Tree() func(*Traits) { + return func(t *Traits) { + Acyclic()(t) + Rooted()(t) + } +} + +// PreventCycles creates an acyclic graph that prevents and proactively prevents the creation of +// cycles. These cycle checks affect the performance and complexity of operations such as AddEdge. +func PreventCycles() func(*Traits) { + return func(t *Traits) { + Acyclic()(t) + t.PreventCycles = true + } +} diff --git a/vendor/github.com/dominikbraun/graph/traversal.go b/vendor/github.com/dominikbraun/graph/traversal.go new file mode 100644 index 00000000000..af5197c35ad --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/traversal.go @@ -0,0 +1,137 @@ +package graph + +import "fmt" + +// DFS performs a depth-first search on the graph, starting from the given vertex. The visit +// function will be invoked with the hash of the vertex currently visited. If it returns false, DFS +// will continue traversing the graph, and if it returns true, the traversal will be stopped. In +// case the graph is disconnected, only the vertices joined with the starting vertex are visited. +// +// This example prints all vertices of the graph in DFS-order: +// +// g := graph.New(graph.IntHash) +// +// _ = g.AddVertex(1) +// _ = g.AddVertex(2) +// _ = g.AddVertex(3) +// +// _ = g.AddEdge(1, 2) +// _ = g.AddEdge(2, 3) +// _ = g.AddEdge(3, 1) +// +// _ = graph.DFS(g, 1, func(value int) bool { +// fmt.Println(value) +// return false +// }) +// +// Similarly, if you have a graph of City vertices and the traversal should stop at London, the +// visit function would look as follows: +// +// func(c City) bool { +// return c.Name == "London" +// } +// +// DFS is non-recursive and maintains a stack instead. +func DFS[K comparable, T any](g Graph[K, T], start K, visit func(K) bool) error { + adjacencyMap, err := g.AdjacencyMap() + if err != nil { + return fmt.Errorf("could not get adjacency map: %w", err) + } + + if _, ok := adjacencyMap[start]; !ok { + return fmt.Errorf("could not find start vertex with hash %v", start) + } + + stack := make([]K, 0) + visited := make(map[K]bool) + + stack = append(stack, start) + + for len(stack) > 0 { + currentHash := stack[len(stack)-1] + + stack = stack[:len(stack)-1] + + if _, ok := visited[currentHash]; !ok { + // Stop traversing the graph if the visit function returns true. + if stop := visit(currentHash); stop { + break + } + visited[currentHash] = true + + for adjacency := range adjacencyMap[currentHash] { + stack = append(stack, adjacency) + } + } + } + + return nil +} + +// BFS performs a breadth-first search on the graph, starting from the given vertex. The visit +// function will be invoked with the hash of the vertex currently visited. If it returns false, BFS +// will continue traversing the graph, and if it returns true, the traversal will be stopped. In +// case the graph is disconnected, only the vertices joined with the starting vertex are visited. +// +// This example prints all vertices of the graph in BFS-order: +// +// g := graph.New(graph.IntHash) +// +// _ = g.AddVertex(1) +// _ = g.AddVertex(2) +// _ = g.AddVertex(3) +// +// _ = g.AddEdge(1, 2) +// _ = g.AddEdge(2, 3) +// _ = g.AddEdge(3, 1) +// +// _ = graph.BFS(g, 1, func(value int) bool { +// fmt.Println(value) +// return false +// }) +// +// Similarly, if you have a graph of City vertices and the traversal should stop at London, the +// visit function would look as follows: +// +// func(c City) bool { +// return c.Name == "London" +// } +// +// BFS is non-recursive and maintains a stack instead. +func BFS[K comparable, T any](g Graph[K, T], start K, visit func(K) bool) error { + adjacencyMap, err := g.AdjacencyMap() + if err != nil { + return fmt.Errorf("could not get adjacency map: %w", err) + } + + if _, ok := adjacencyMap[start]; !ok { + return fmt.Errorf("could not find start vertex with hash %v", start) + } + + queue := make([]K, 0) + visited := make(map[K]bool) + + visited[start] = true + queue = append(queue, start) + + for len(queue) > 0 { + currentHash := queue[0] + + queue = queue[1:] + + // Stop traversing the graph if the visit function returns true. + if stop := visit(currentHash); stop { + break + } + + for adjacency := range adjacencyMap[currentHash] { + if _, ok := visited[adjacency]; !ok { + visited[adjacency] = true + queue = append(queue, adjacency) + } + } + + } + + return nil +} diff --git a/vendor/github.com/dominikbraun/graph/undirected.go b/vendor/github.com/dominikbraun/graph/undirected.go new file mode 100644 index 00000000000..10828483696 --- /dev/null +++ b/vendor/github.com/dominikbraun/graph/undirected.go @@ -0,0 +1,249 @@ +package graph + +import ( + "errors" + "fmt" +) + +type undirected[K comparable, T any] struct { + hash Hash[K, T] + traits *Traits + store Store[K, T] +} + +func newUndirected[K comparable, T any](hash Hash[K, T], traits *Traits, store Store[K, T]) *undirected[K, T] { + return &undirected[K, T]{ + hash: hash, + traits: traits, + store: store, + } +} + +func (u *undirected[K, T]) Traits() *Traits { + return u.traits +} + +func (u *undirected[K, T]) AddVertex(value T, options ...func(*VertexProperties)) error { + hash := u.hash(value) + + prop := VertexProperties{ + Weight: 0, + Attributes: make(map[string]string), + } + + for _, option := range options { + option(&prop) + } + + return u.store.AddVertex(hash, value, prop) +} + +func (u *undirected[K, T]) Vertex(hash K) (T, error) { + vertex, _, err := u.store.Vertex(hash) + return vertex, err +} + +func (u *undirected[K, T]) VertexWithProperties(hash K) (T, VertexProperties, error) { + vertex, prop, err := u.store.Vertex(hash) + if err != nil { + return vertex, VertexProperties{}, err + } + + return vertex, prop, nil +} + +func (u *undirected[K, T]) AddEdge(sourceHash, targetHash K, options ...func(*EdgeProperties)) error { + if _, _, err := u.store.Vertex(sourceHash); err != nil { + return fmt.Errorf("could not find source vertex with hash %v: %w", sourceHash, err) + } + + if _, _, err := u.store.Vertex(targetHash); err != nil { + return fmt.Errorf("could not find target vertex with hash %v: %w", targetHash, err) + } + + // nolint: govet // false positive err shawdowing + if _, err := u.Edge(sourceHash, targetHash); !errors.Is(err, ErrEdgeNotFound) { + return ErrEdgeAlreadyExists + } + + // If the user opted in to preventing cycles, run a cycle check. + if u.traits.PreventCycles { + createsCycle, err := CreatesCycle[K, T](u, sourceHash, targetHash) + if err != nil { + return fmt.Errorf("check for cycles: %w", err) + } + if createsCycle { + return ErrEdgeCreatesCycle + } + } + + edge := Edge[K]{ + Source: sourceHash, + Target: targetHash, + Properties: EdgeProperties{ + Attributes: make(map[string]string), + }, + } + + for _, option := range options { + option(&edge.Properties) + } + + if err := u.addEdge(sourceHash, targetHash, edge); err != nil { + return fmt.Errorf("failed to add edge: %w", err) + } + + return nil +} + +func (u *undirected[K, T]) Edge(sourceHash, targetHash K) (Edge[T], error) { + // In an undirected graph, since multigraphs aren't supported, the edge AB is the same as BA. + // Therefore, if source[target] cannot be found, this function also looks for target[source]. + + edge, err := u.store.Edge(sourceHash, targetHash) + if errors.Is(err, ErrEdgeNotFound) { + edge, err = u.store.Edge(targetHash, sourceHash) + } + + if err != nil { + return Edge[T]{}, err + } + + sourceVertex, _, err := u.store.Vertex(sourceHash) + if err != nil { + return Edge[T]{}, err + } + + targetVertex, _, err := u.store.Vertex(targetHash) + if err != nil { + return Edge[T]{}, err + } + + return Edge[T]{ + Source: sourceVertex, + Target: targetVertex, + Properties: EdgeProperties{ + Weight: edge.Properties.Weight, + Attributes: edge.Properties.Attributes, + Data: edge.Properties.Data, + }, + }, nil +} + +func (u *undirected[K, T]) RemoveEdge(source, target K) error { + if _, err := u.Edge(source, target); err != nil { + return err + } + + if err := u.store.RemoveEdge(source, target); err != nil { + return fmt.Errorf("failed to remove edge from %v to %v: %w", source, target, err) + } + + if err := u.store.RemoveEdge(target, source); err != nil { + return fmt.Errorf("failed to remove edge from %v to %v: %w", target, source, err) + } + + return nil +} + +func (u *undirected[K, T]) AdjacencyMap() (map[K]map[K]Edge[K], error) { + vertices, err := u.store.ListVertices() + if err != nil { + return nil, fmt.Errorf("failed to list vertices: %w", err) + } + + edges, err := u.store.ListEdges() + if err != nil { + return nil, fmt.Errorf("failed to list edges: %w", err) + } + + m := make(map[K]map[K]Edge[K]) + + for _, vertex := range vertices { + m[vertex] = make(map[K]Edge[K]) + } + + for _, edge := range edges { + m[edge.Source][edge.Target] = edge + } + + return m, nil +} + +func (u *undirected[K, T]) PredecessorMap() (map[K]map[K]Edge[K], error) { + return u.AdjacencyMap() +} + +func (u *undirected[K, T]) Clone() (Graph[K, T], error) { + traits := &Traits{ + IsDirected: u.traits.IsDirected, + IsAcyclic: u.traits.IsAcyclic, + IsWeighted: u.traits.IsWeighted, + IsRooted: u.traits.IsRooted, + } + + return &undirected[K, T]{ + hash: u.hash, + traits: traits, + store: u.store, + }, nil +} + +func (u *undirected[K, T]) Order() (int, error) { + return u.store.VertexCount() +} + +func (u *undirected[K, T]) Size() (int, error) { + size := 0 + outEdges, err := u.AdjacencyMap() + if err != nil { + return 0, fmt.Errorf("failed to get adjacency map: %w", err) + } + for _, outEdges := range outEdges { + size += len(outEdges) + } + + // Divide by 2 since every add edge operation on undirected graph is counted twice. + return size / 2, nil +} + +func (u *undirected[K, T]) edgesAreEqual(a, b Edge[T]) bool { + aSourceHash := u.hash(a.Source) + aTargetHash := u.hash(a.Target) + bSourceHash := u.hash(b.Source) + bTargetHash := u.hash(b.Target) + + if aSourceHash == bSourceHash && aTargetHash == bTargetHash { + return true + } + + if !u.traits.IsDirected { + return aSourceHash == bTargetHash && aTargetHash == bSourceHash + } + + return false +} + +func (u *undirected[K, T]) addEdge(sourceHash, targetHash K, edge Edge[K]) error { + err := u.store.AddEdge(sourceHash, targetHash, edge) + if err != nil { + return err + } + + rEdge := Edge[K]{ + Source: edge.Target, + Target: edge.Source, + Properties: EdgeProperties{ + Weight: edge.Properties.Weight, + Attributes: edge.Properties.Attributes, + Data: edge.Properties.Data, + }, + } + + err = u.store.AddEdge(targetHash, sourceHash, rEdge) + if err != nil { + return err + } + + return nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index f05af212456..0bf61e6212f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -180,6 +180,9 @@ github.com/docker/go-metrics # github.com/docker/go-units v0.4.0 ## explicit github.com/docker/go-units +# github.com/dominikbraun/graph v0.16.2 +## explicit; go 1.18 +github.com/dominikbraun/graph # github.com/emicklei/go-restful/v3 v3.9.0 ## explicit; go 1.13 github.com/emicklei/go-restful/v3