diff --git a/go/nautobotop/api/v1alpha1/nautobot_types.go b/go/nautobotop/api/v1alpha1/nautobot_types.go index 4ee115e94..3d7607b4e 100644 --- a/go/nautobotop/api/v1alpha1/nautobot_types.go +++ b/go/nautobotop/api/v1alpha1/nautobot_types.go @@ -51,6 +51,7 @@ type NautobotSpec struct { RoleRef []ConfigMapRef `json:"roleRef,omitempty"` TenantGroupRef []ConfigMapRef `json:"tenantGroupRef,omitempty"` TenantRef []ConfigMapRef `json:"tenantRef,omitempty"` + DeviceRef []ConfigMapRef `json:"deviceRef,omitempty"` } // NautobotStatus defines the observed state of Nautobot. diff --git a/go/nautobotop/api/v1alpha1/zz_generated.deepcopy.go b/go/nautobotop/api/v1alpha1/zz_generated.deepcopy.go index 92f3841fb..e198dfd17 100644 --- a/go/nautobotop/api/v1alpha1/zz_generated.deepcopy.go +++ b/go/nautobotop/api/v1alpha1/zz_generated.deepcopy.go @@ -236,6 +236,13 @@ func (in *NautobotSpec) DeepCopyInto(out *NautobotSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.DeviceRef != nil { + in, out := &in.DeviceRef, &out.DeviceRef + *out = make([]ConfigMapRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NautobotSpec. diff --git a/go/nautobotop/config/crd/bases/sync.rax.io_nautobots.yaml b/go/nautobotop/config/crd/bases/sync.rax.io_nautobots.yaml index b00c05de8..7a5598c1b 100644 --- a/go/nautobotop/config/crd/bases/sync.rax.io_nautobots.yaml +++ b/go/nautobotop/config/crd/bases/sync.rax.io_nautobots.yaml @@ -117,6 +117,31 @@ spec: - configMapSelector type: object type: array + deviceRef: + items: + description: ConfigMapRef defines a reference to a specific ConfigMap + properties: + configMapSelector: + description: The name of the ConfigMap resource being referred + to + properties: + key: + description: The key in the ConfigMap data + type: string + name: + description: The name of the ConfigMap + minLength: 1 + type: string + namespace: + description: The namespace where the ConfigMap resides + type: string + required: + - name + type: object + required: + - configMapSelector + type: object + type: array deviceTypeRef: items: description: ConfigMapRef defines a reference to a specific ConfigMap diff --git a/go/nautobotop/helm/crds/clients.yaml b/go/nautobotop/helm/crds/clients.yaml index b00c05de8..7a5598c1b 100644 --- a/go/nautobotop/helm/crds/clients.yaml +++ b/go/nautobotop/helm/crds/clients.yaml @@ -117,6 +117,31 @@ spec: - configMapSelector type: object type: array + deviceRef: + items: + description: ConfigMapRef defines a reference to a specific ConfigMap + properties: + configMapSelector: + description: The name of the ConfigMap resource being referred + to + properties: + key: + description: The key in the ConfigMap data + type: string + name: + description: The name of the ConfigMap + minLength: 1 + type: string + namespace: + description: The namespace where the ConfigMap resides + type: string + required: + - name + type: object + required: + - configMapSelector + type: object + type: array deviceTypeRef: items: description: ConfigMapRef defines a reference to a specific ConfigMap diff --git a/go/nautobotop/internal/controller/dag.go b/go/nautobotop/internal/controller/dag.go new file mode 100644 index 000000000..f94efedfc --- /dev/null +++ b/go/nautobotop/internal/controller/dag.go @@ -0,0 +1,90 @@ +package controller + +import ( + "fmt" + "sort" +) + +// ResourceNode defines a sync resource with explicit dependency declarations. +// The topological sort uses DependsOn to determine execution order automatically. +type ResourceNode struct { + Name string + DependsOn []string +} + +// topologicalSort using Kahn's algorithm here +// a deterministic execution order that respects all declared dependencies. +// +// Returns an error if: +// - A cycle is detected (not a DAG) +// - A node references a dependency that doesn't exist in the input +// - Duplicate node names are provided +func topologicalSort(nodes []ResourceNode) ([]string, error) { + if len(nodes) == 0 { + return nil, nil + } + nodeSet := make(map[string]struct{}, len(nodes)) + for _, node := range nodes { + if _, exists := nodeSet[node.Name]; exists { + return nil, fmt.Errorf("duplicate node name: %q", node.Name) + } + nodeSet[node.Name] = struct{}{} + } + + for _, node := range nodes { + for _, dep := range node.DependsOn { + if _, exists := nodeSet[dep]; !exists { + return nil, fmt.Errorf("node %q depends on %q which does not exist", node.Name, dep) + } + } + } + + inDegree := make(map[string]int, len(nodes)) + dependents := make(map[string][]string, len(nodes)) + + for _, node := range nodes { + inDegree[node.Name] = len(node.DependsOn) + for _, dep := range node.DependsOn { + dependents[dep] = append(dependents[dep], node.Name) + } + } + + var queue []string + for _, node := range nodes { + if inDegree[node.Name] == 0 { + queue = append(queue, node.Name) + } + } + sort.Strings(queue) + + result := make([]string, 0, len(nodes)) + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + result = append(result, current) + + children := dependents[current] + sort.Strings(children) + for _, child := range children { + inDegree[child]-- + if inDegree[child] == 0 { + queue = append(queue, child) + } + } + sort.Strings(queue) + } + + if len(result) != len(nodes) { + var cycleNodes []string + for name, degree := range inDegree { + if degree > 0 { + cycleNodes = append(cycleNodes, name) + } + } + sort.Strings(cycleNodes) + return nil, fmt.Errorf("dependency cycle detected involving nodes: %v", cycleNodes) + } + + return result, nil +} diff --git a/go/nautobotop/internal/controller/dag_test.go b/go/nautobotop/internal/controller/dag_test.go new file mode 100644 index 000000000..80c7228af --- /dev/null +++ b/go/nautobotop/internal/controller/dag_test.go @@ -0,0 +1,255 @@ +package controller + +import ( + "testing" +) + +func TestTopologicalSort(t *testing.T) { + tests := []struct { + name string + nodes []ResourceNode + want []string + wantErr bool + errSubstr string + }{ + { + name: "empty input returns nil", + nodes: nil, + want: nil, + }, + { + name: "single node with no dependencies", + nodes: []ResourceNode{ + {Name: "a"}, + }, + want: []string{"a"}, + }, + { + name: "two independent nodes sorted alphabetically", + nodes: []ResourceNode{ + {Name: "b"}, + {Name: "a"}, + }, + want: []string{"a", "b"}, + }, + { + name: "linear chain a -> b -> c", + nodes: []ResourceNode{ + {Name: "c", DependsOn: []string{"b"}}, + {Name: "a"}, + {Name: "b", DependsOn: []string{"a"}}, + }, + want: []string{"a", "b", "c"}, + }, + { + name: "diamond dependency: d depends on b and c, both depend on a", + nodes: []ResourceNode{ + {Name: "d", DependsOn: []string{"b", "c"}}, + {Name: "b", DependsOn: []string{"a"}}, + {Name: "c", DependsOn: []string{"a"}}, + {Name: "a"}, + }, + want: []string{"a", "b", "c", "d"}, + }, + { + name: "multiple roots with shared dependency", + nodes: []ResourceNode{ + {Name: "location", DependsOn: []string{"locationTypes"}}, + {Name: "locationTypes"}, + {Name: "role"}, + {Name: "rack", DependsOn: []string{"location"}}, + {Name: "device", DependsOn: []string{"location", "rack", "role"}}, + }, + want: []string{"locationTypes", "location", "rack", "role", "device"}, + }, + { + name: "real-world resource ordering matches expected", + nodes: []ResourceNode{ + {Name: "locationTypes"}, + {Name: "location", DependsOn: []string{"locationTypes"}}, + {Name: "rir"}, + {Name: "role"}, + {Name: "deviceType"}, + {Name: "rackGroup", DependsOn: []string{"location"}}, + {Name: "vlanGroup", DependsOn: []string{"location"}}, + {Name: "rack", DependsOn: []string{"location", "rackGroup"}}, + {Name: "tenantGroup"}, + {Name: "tenant", DependsOn: []string{"tenantGroup"}}, + {Name: "device", DependsOn: []string{"deviceType", "location", "rack", "role", "tenant"}}, + {Name: "clusterType"}, + {Name: "clusterGroup"}, + {Name: "cluster", DependsOn: []string{"clusterType", "clusterGroup", "location", "device"}}, + {Name: "namespace", DependsOn: []string{"location", "tenant"}}, + {Name: "vlan", DependsOn: []string{"vlanGroup", "location", "tenant", "role"}}, + {Name: "prefix", DependsOn: []string{"namespace", "rir", "location", "vlan", "tenant", "role"}}, + }, + want: []string{ + "clusterGroup", "clusterType", "deviceType", "locationTypes", + "location", + "rackGroup", "rir", "role", "tenantGroup", "vlanGroup", + "rack", "tenant", + "device", "namespace", "vlan", + "cluster", + "prefix", + }, + }, + { + name: "simple cycle between two nodes", + nodes: []ResourceNode{ + {Name: "a", DependsOn: []string{"b"}}, + {Name: "b", DependsOn: []string{"a"}}, + }, + wantErr: true, + errSubstr: "dependency cycle detected", + }, + { + name: "three-node cycle", + nodes: []ResourceNode{ + {Name: "a", DependsOn: []string{"c"}}, + {Name: "b", DependsOn: []string{"a"}}, + {Name: "c", DependsOn: []string{"b"}}, + }, + wantErr: true, + errSubstr: "dependency cycle detected", + }, + { + name: "self-referencing node", + nodes: []ResourceNode{ + {Name: "a", DependsOn: []string{"a"}}, + }, + wantErr: true, + errSubstr: "dependency cycle detected", + }, + { + name: "dependency on non-existent node", + nodes: []ResourceNode{ + {Name: "a", DependsOn: []string{"ghost"}}, + }, + wantErr: true, + errSubstr: "does not exist", + }, + { + name: "duplicate node names", + nodes: []ResourceNode{ + {Name: "a"}, + {Name: "a"}, + }, + wantErr: true, + errSubstr: "duplicate node name", + }, + { + name: "complex graph with independent subtrees", + nodes: []ResourceNode{ + {Name: "x1"}, + {Name: "x2", DependsOn: []string{"x1"}}, + {Name: "x3", DependsOn: []string{"x1"}}, + {Name: "y1"}, + {Name: "y2", DependsOn: []string{"y1"}}, + {Name: "z", DependsOn: []string{"x2", "y2"}}, + }, + want: []string{"x1", "x2", "x3", "y1", "y2", "z"}, + }, + { + name: "wide fan-out from single root", + nodes: []ResourceNode{ + {Name: "root"}, + {Name: "child1", DependsOn: []string{"root"}}, + {Name: "child2", DependsOn: []string{"root"}}, + {Name: "child3", DependsOn: []string{"root"}}, + {Name: "child4", DependsOn: []string{"root"}}, + }, + want: []string{"root", "child1", "child2", "child3", "child4"}, + }, + { + name: "wide fan-in to single leaf", + nodes: []ResourceNode{ + {Name: "a"}, + {Name: "b"}, + {Name: "c"}, + {Name: "leaf", DependsOn: []string{"a", "b", "c"}}, + }, + want: []string{"a", "b", "c", "leaf"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := topologicalSort(tt.nodes) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.errSubstr) + } + if tt.errSubstr != "" && !containsSubstring(err.Error(), tt.errSubstr) { + t.Fatalf("expected error containing %q, got: %v", tt.errSubstr, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.want == nil && got == nil { + return + } + + if len(got) != len(tt.want) { + t.Fatalf("length mismatch: got %d, want %d\ngot: %v\nwant: %v", len(got), len(tt.want), got, tt.want) + } + + // Verify the order respects all dependencies (not just exact match) + verifyDependencyOrder(t, tt.nodes, got) + + // Also verify exact expected output for determinism + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("position %d: got %q, want %q\nfull got: %v\nfull want: %v", i, got[i], tt.want[i], got, tt.want) + break + } + } + }) + } +} + +// verifyDependencyOrder checks that for every node, all its dependencies +// appear earlier in the result slice. +func verifyDependencyOrder(t *testing.T, nodes []ResourceNode, result []string) { + t.Helper() + + position := make(map[string]int, len(result)) + for i, name := range result { + position[name] = i + } + + for _, node := range nodes { + nodePos, exists := position[node.Name] + if !exists { + t.Errorf("node %q missing from result", node.Name) + continue + } + for _, dep := range node.DependsOn { + depPos, exists := position[dep] + if !exists { + t.Errorf("dependency %q of node %q missing from result", dep, node.Name) + continue + } + if depPos >= nodePos { + t.Errorf("dependency violation: %q (pos %d) must come before %q (pos %d)", dep, depPos, node.Name, nodePos) + } + } + } +} + +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/go/nautobotop/internal/controller/nautobot_controller.go b/go/nautobotop/internal/controller/nautobot_controller.go index b3fc10212..8bd485e97 100644 --- a/go/nautobotop/internal/controller/nautobot_controller.go +++ b/go/nautobotop/internal/controller/nautobot_controller.go @@ -63,9 +63,9 @@ type NautobotReconciler struct { // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/reconcile -// resourceConfig defines configuration for a single resource type type resourceConfig struct { name string + dependsOn []string configRefs []syncv1alpha1.ConfigMapRef syncFunc func(context.Context, *nbClient.NautobotClient, map[string]string) error } @@ -86,31 +86,59 @@ func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c syncInterval := time.Duration(nautobotCR.Spec.SyncIntervalSeconds) * time.Second - // Define all resources to sync + // Define all resources to sync with explicit dependency declarations. + // Order in this slice does NOT matter — topological sort determines execution order. resources := []resourceConfig{ - // No Dependencies, these need to be run first and are independent. + // No dependencies {name: "locationTypes", configRefs: nautobotCR.Spec.LocationTypesRef, syncFunc: r.syncLocationTypes}, - {name: "location", configRefs: nautobotCR.Spec.LocationRef, syncFunc: r.syncLocation}, {name: "rir", configRefs: nautobotCR.Spec.RirRef, syncFunc: r.syncRir}, {name: "role", configRefs: nautobotCR.Spec.RoleRef, syncFunc: r.syncRole}, {name: "deviceType", configRefs: nautobotCR.Spec.DeviceTypesRef, syncFunc: r.syncDeviceTypes}, + {name: "tenantGroup", configRefs: nautobotCR.Spec.TenantGroupRef, syncFunc: r.syncTenantGroup}, {name: "clusterType", configRefs: nautobotCR.Spec.ClusterTypeRef, syncFunc: r.syncClusterType}, {name: "clusterGroup", configRefs: nautobotCR.Spec.ClusterGroupRef, syncFunc: r.syncClusterGroup}, - {name: "cluster", configRefs: nautobotCR.Spec.ClusterRef, syncFunc: r.syncCluster}, - // depends on: location - {name: "rackGroup", configRefs: nautobotCR.Spec.RackGroupRef, syncFunc: r.syncRackGroup}, - {name: "vlanGroup", configRefs: nautobotCR.Spec.VlanGroupRef, syncFunc: r.syncVlanGroup}, - // depends on: location, rackGroup - {name: "rack", configRefs: nautobotCR.Spec.RackRef, syncFunc: r.syncRack}, - // tenancy tenantGroup before tenant - {name: "tenantGroup", configRefs: nautobotCR.Spec.TenantGroupRef, syncFunc: r.syncTenantGroup}, - {name: "tenant", configRefs: nautobotCR.Spec.TenantRef, syncFunc: r.syncTenant}, - // depends on: location, tenant, tenantGroup - {name: "namespace", configRefs: nautobotCR.Spec.NamespaceRef, syncFunc: r.syncNamespace}, - // depends on: vlanGroup, location, tenant, tenantGroup, role - {name: "vlan", configRefs: nautobotCR.Spec.VlanRef, syncFunc: r.syncVlan}, - // depends on: namespace, rir, location, vlanGroup, vlan, tenant, tenantGroup, role - {name: "prefix", configRefs: nautobotCR.Spec.PrefixRef, syncFunc: r.syncPrefix}, + // Depends on: locationTypes + {name: "location", dependsOn: []string{"locationTypes"}, configRefs: nautobotCR.Spec.LocationRef, syncFunc: r.syncLocation}, + // Depends on: tenantGroup + {name: "tenant", dependsOn: []string{"tenantGroup"}, configRefs: nautobotCR.Spec.TenantRef, syncFunc: r.syncTenant}, + // Depends on: location + {name: "rackGroup", dependsOn: []string{"location"}, configRefs: nautobotCR.Spec.RackGroupRef, syncFunc: r.syncRackGroup}, + {name: "vlanGroup", dependsOn: []string{"location"}, configRefs: nautobotCR.Spec.VlanGroupRef, syncFunc: r.syncVlanGroup}, + // Depends on: location, rackGroup + {name: "rack", dependsOn: []string{"location", "rackGroup"}, configRefs: nautobotCR.Spec.RackRef, syncFunc: r.syncRack}, + // Depends on: location, tenant + {name: "namespace", dependsOn: []string{"location", "tenant"}, configRefs: nautobotCR.Spec.NamespaceRef, syncFunc: r.syncNamespace}, + // Depends on: deviceType, location, rack, role, tenant + {name: "device", dependsOn: []string{"deviceType", "location", "rack", "role", "tenant"}, configRefs: nautobotCR.Spec.DeviceRef, syncFunc: r.syncDevice}, + // Depends on: vlanGroup, location, tenant, role + {name: "vlan", dependsOn: []string{"vlanGroup", "location", "tenant", "role"}, configRefs: nautobotCR.Spec.VlanRef, syncFunc: r.syncVlan}, + // Depends on: clusterType, clusterGroup, location, device + {name: "cluster", dependsOn: []string{"clusterType", "clusterGroup", "location", "device"}, configRefs: nautobotCR.Spec.ClusterRef, syncFunc: r.syncCluster}, + // Depends on: namespace, rir, location, vlan, tenant, role + {name: "prefix", dependsOn: []string{"namespace", "rir", "location", "vlan", "tenant", "role"}, configRefs: nautobotCR.Spec.PrefixRef, syncFunc: r.syncPrefix}, + } + + // Resolve execution order using topological sort (Kahn's algorithm) + dagNodes := make([]ResourceNode, len(resources)) + for i, res := range resources { + dagNodes[i] = ResourceNode{Name: res.name, DependsOn: res.dependsOn} + } + topologicalSort, err := topologicalSort(dagNodes) + if err != nil { + log.Error(err, "failed to resolve resource sync order") + return ctrl.Result{}, err + } + + // Build lookup for quick access by name + resourceByName := make(map[string]resourceConfig, len(resources)) + for _, res := range resources { + resourceByName[res.name] = res + } + + // Reorder resources according to topological sort result + orderedResources := make([]resourceConfig, 0, len(topologicalSort)) + for _, name := range topologicalSort { + orderedResources = append(orderedResources, resourceByName[name]) } // Aggregate data and check sync decisions for all resources @@ -167,8 +195,8 @@ func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c log.Info("released cache and idle connections after reconcile") }() - // Sync resources that need updating - for _, res := range resources { + // Sync resources that need updating (in topologically sorted order) + for _, res := range orderedResources { if dataMap, ok := resourcesToSync[res.name]; ok { if err := res.syncFunc(ctx, nautobotClient, dataMap); err != nil { log.Error(err, "failed to sync resource", "resource", res.name) @@ -477,6 +505,22 @@ func (r *NautobotReconciler) syncTenant(ctx context.Context, return nil } +func (r *NautobotReconciler) syncDevice(ctx context.Context, + nautobotClient *nbClient.NautobotClient, + deviceData map[string]string, +) error { + log := logf.FromContext(ctx) + log.Info("syncing devices", "count", len(deviceData)) + if len(deviceData) == 0 { + return nil + } + syncSvc := sync.NewDeviceSync(nautobotClient) + if err := syncSvc.SyncAll(ctx, deviceData); err != nil { + return fmt.Errorf("failed to sync devices: %w", err) + } + return nil +} + // getAuthTokenFromSecretRef: this will fetch Nautobot auth token from the given refer. func (r *NautobotReconciler) getAuthTokenFromSecretRef(ctx context.Context, nautobotCR syncv1alpha1.Nautobot) (string, string, error) { var username, token string diff --git a/go/nautobotop/internal/nautobot/dcim/device.go b/go/nautobotop/internal/nautobot/dcim/device.go index 3c0cfb434..a664433c3 100644 --- a/go/nautobotop/internal/nautobot/dcim/device.go +++ b/go/nautobotop/internal/nautobot/dcim/device.go @@ -2,7 +2,9 @@ package dcim import ( "context" + "net/http" + "github.com/charmbracelet/log" "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/cache" "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" @@ -20,6 +22,18 @@ func NewDeviceService(nautobotClient *client.NautobotClient) *DeviceService { } } +func (s *DeviceService) Create(ctx context.Context, req nb.WritableDeviceRequest) (*nb.Device, error) { + device, resp, err := s.client.APIClient.DcimAPI.DcimDevicesCreate(ctx).WritableDeviceRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("CreateDevice", "failed to create", "name", req.GetName(), "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("CreateDevice", "created device", device.GetName()) + cache.AddToCollection(s.client.Cache, "devices", *device) + return device, nil +} + func (s *DeviceService) GetByName(ctx context.Context, name string) nb.Device { if device, ok := cache.FindByName(s.client.Cache, "devices", name, func(d nb.Device) string { return d.GetName() @@ -43,6 +57,88 @@ func (s *DeviceService) GetByName(ctx context.Context, name string) nb.Device { return list.Results[0] } +func (s *DeviceService) GetByID(ctx context.Context, id string) nb.Device { + if id == "" { + return nb.Device{} + } + if device, ok := cache.FindByID(s.client.Cache, "devices", id, func(d nb.Device) *string { + return d.Id + }); ok { + return device + } + + list, resp, err := s.client.APIClient.DcimAPI.DcimDevicesList(ctx).Depth(2).Id([]string{id}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetDeviceByID", "failed to get", "id", id, "error", err.Error(), "response_body", bodyString) + return nb.Device{} + } + if list == nil || len(list.Results) == 0 || list.Results[0].Id == nil { + return nb.Device{} + } + + return list.Results[0] +} + +func (s *DeviceService) ListAll(ctx context.Context) []nb.Device { + return helpers.PaginatedList( + ctx, + func(ctx context.Context, limit, offset int32) ([]nb.Device, int32, *http.Response, error) { + list, resp, err := s.client.APIClient.DcimAPI.DcimDevicesList(ctx). + Limit(limit). + Offset(offset). + Depth(2). + Execute() + if err != nil { + return nil, 0, resp, err + } + if list == nil { + return nil, 0, resp, nil + } + return list.Results, list.Count, resp, nil + }, + s.client.AddReport, + "ListAllDevices", + ) +} + +func (s *DeviceService) Update(ctx context.Context, id string, req nb.WritableDeviceRequest) (*nb.Device, error) { + device, resp, err := s.client.APIClient.DcimAPI.DcimDevicesUpdate(ctx, id).WritableDeviceRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("UpdateDevice", "failed to update", "id", id, "name", req.GetName(), "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("successfully updated device", "id", id, "name", device.GetName()) + cache.UpdateInCollection(s.client.Cache, "devices", *device, func(d nb.Device) bool { + return d.Id != nil && *d.Id == id + }) + return device, nil +} + +func (s *DeviceService) Destroy(ctx context.Context, id string) error { + owned, err := s.client.IsCreatedByUser(ctx, id) + if err != nil { + s.client.AddReport("DestroyDevice", "failed to check ownership", "id", id, "error", err.Error()) + return err + } + if !owned { + log.Warn("skipping destroy, object not created by user", "id", id, "user", s.client.Username) + return nil + } + + resp, err := s.client.APIClient.DcimAPI.DcimDevicesDestroy(ctx, id).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("DestroyDevice", "failed to destroy", "id", id, "error", err.Error(), "response_body", bodyString) + return err + } + cache.RemoveFromCollection(s.client.Cache, "devices", func(d nb.Device) bool { + return d.Id != nil && *d.Id == id + }) + return nil +} + // ListByCluster returns all devices currently assigned to a cluster func (s *DeviceService) ListByCluster(ctx context.Context, clusterID string) []nb.Device { list, resp, err := s.client.APIClient.DcimAPI.DcimDevicesList(ctx).Depth(2).Cluster([]string{clusterID}).Execute() diff --git a/go/nautobotop/internal/nautobot/models/device.go b/go/nautobotop/internal/nautobot/models/device.go new file mode 100644 index 000000000..ae97761c3 --- /dev/null +++ b/go/nautobotop/internal/nautobot/models/device.go @@ -0,0 +1,22 @@ +package models + +type Devices struct { + Devices []Device +} + +type Device struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + DeviceType string `json:"device_type" yaml:"device_type"` + Role string `json:"role" yaml:"role"` + Serial string `json:"serial" yaml:"serial"` + AssetTag string `json:"asset_tag" yaml:"asset_tag"` + Status string `json:"status" yaml:"status"` + Location string `json:"location" yaml:"location"` + Rack string `json:"rack" yaml:"rack"` + Position int `json:"position" yaml:"position"` + Face string `json:"face" yaml:"face"` + Tenant string `json:"tenant" yaml:"tenant"` + Platform string `json:"platform" yaml:"platform"` + Comments string `json:"comments" yaml:"comments"` +} diff --git a/go/nautobotop/internal/nautobot/sync/device.go b/go/nautobotop/internal/nautobot/sync/device.go new file mode 100644 index 000000000..0ea504890 --- /dev/null +++ b/go/nautobotop/internal/nautobot/sync/device.go @@ -0,0 +1,259 @@ +package sync + +import ( + "context" + "fmt" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/dcim" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/extras" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/models" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/tenancy" + "github.com/samber/lo" + "go.yaml.in/yaml/v3" +) + +type DeviceSync struct { + client *client.NautobotClient + deviceSvc *dcim.DeviceService + deviceTypeSvc *dcim.DeviceTypeService + locationSvc *dcim.LocationService + rackSvc *dcim.RackService + statusSvc *dcim.StatusService + roleSvc *extras.RoleService + tenantSvc *tenancy.TenantService +} + +func NewDeviceSync(nautobotClient *client.NautobotClient) *DeviceSync { + return &DeviceSync{ + client: nautobotClient.GetClient(), + deviceSvc: dcim.NewDeviceService(nautobotClient.GetClient()), + deviceTypeSvc: dcim.NewDeviceTypeService(nautobotClient.GetClient()), + locationSvc: dcim.NewLocationService(nautobotClient.GetClient()), + rackSvc: dcim.NewRackService(nautobotClient), + statusSvc: dcim.NewStatusService(nautobotClient.GetClient()), + roleSvc: extras.NewRoleService(nautobotClient.GetClient()), + tenantSvc: tenancy.NewTenantService(nautobotClient.GetClient()), + } +} + +func (s *DeviceSync) SyncAll(ctx context.Context, data map[string]string) error { + var devices models.Devices + for key, f := range data { + var yml models.Device + if err := yaml.Unmarshal([]byte(f), &yml); err != nil { + s.client.AddReport("yamlFailed", "file: "+key+" error: "+err.Error()) + return err + } + devices.Devices = append(devices.Devices, yml) + } + + for _, device := range devices.Devices { + if err := s.syncSingleDevice(ctx, device); err != nil { + return err + } + } + + s.deleteObsoleteDevices(ctx, devices) + + log.Info("SyncAllDevices Completed") + return nil +} + +func (s *DeviceSync) syncSingleDevice(ctx context.Context, device models.Device) error { + existingDevice := s.deviceSvc.GetByID(ctx, device.ID) + + // Resolve device type reference (required) + deviceTypeRef, err := s.buildDeviceTypeReference(ctx, device.DeviceType) + if err != nil { + return fmt.Errorf("failed to build device type reference for device %s: %w", device.Name, err) + } + + // Resolve status reference (required) + statusRef, err := s.buildStatusReference(ctx, device.Status) + if err != nil { + return fmt.Errorf("failed to build status reference for device %s: %w", device.Name, err) + } + + // Resolve role reference (required) + roleRef, err := s.buildRoleReference(ctx, device.Role) + if err != nil { + return fmt.Errorf("failed to build role reference for device %s: %w", device.Name, err) + } + + // Resolve location reference (required) + locationRef, err := s.buildLocationReference(ctx, device.Location) + if err != nil { + return fmt.Errorf("failed to build location reference for device %s: %w", device.Name, err) + } + + deviceRequest := nb.WritableDeviceRequest{ + Id: optionalID(device.ID), + DeviceType: deviceTypeRef, + Status: statusRef, + Role: roleRef, + Location: locationRef, + Comments: nb.PtrString(device.Comments), + } + + // Set name + if device.Name != "" { + deviceRequest.Name = *nb.NewNullableString(nb.PtrString(device.Name)) + } + + // Set serial + if device.Serial != "" { + deviceRequest.Serial = nb.PtrString(device.Serial) + } + + // Set asset tag (optional) + if device.AssetTag != "" { + deviceRequest.AssetTag = *nb.NewNullableString(nb.PtrString(device.AssetTag)) + } + + // Set rack (optional) + if device.Rack != "" { + rackRef, err := s.buildRackReference(ctx, device.Rack) + if err != nil { + return fmt.Errorf("failed to build rack reference for device %s: %w", device.Name, err) + } + deviceRequest.Rack = rackRef + } + + // Set position (optional) + if device.Position > 0 { + deviceRequest.Position = *nb.NewNullableInt32(nb.PtrInt32(int32(device.Position))) + } + + // Set face (optional) + if device.Face != "" { + face, err := nb.NewRackFaceFromValue(device.Face) + if err != nil { + s.client.AddReport("syncDevice", "invalid face value, skipping face", "name", device.Name, "face", device.Face, "error", err.Error()) + } else { + deviceRequest.Face = face + } + } + + // Set tenant (optional) + if device.Tenant != "" { + tenantRef, err := s.buildTenantReference(ctx, device.Tenant) + if err != nil { + return fmt.Errorf("failed to build tenant reference for device %s: %w", device.Name, err) + } + deviceRequest.Tenant = tenantRef + } + + // Set platform (optional) + if device.Platform != "" { + platformRef := s.buildPlatformReference(device.Platform) + deviceRequest.Platform = platformRef + } + + if existingDevice.Id == nil { + return s.createDevice(ctx, deviceRequest, device.Name) + } + + if !helpers.CompareJSONFields(existingDevice, deviceRequest) { + return s.updateDevice(ctx, *existingDevice.Id, deviceRequest, device.Name) + } + + log.Info("device unchanged, skipping update", "name", device.Name) + return nil +} + +func (s *DeviceSync) createDevice(ctx context.Context, request nb.WritableDeviceRequest, name string) error { + createdDevice, err := s.deviceSvc.Create(ctx, request) + if err != nil || createdDevice == nil { + return fmt.Errorf("failed to create device %s: %w", name, err) + } + log.Info("device created", "name", name) + return nil +} + +func (s *DeviceSync) updateDevice(ctx context.Context, id string, request nb.WritableDeviceRequest, name string) error { + updatedDevice, err := s.deviceSvc.Update(ctx, id, request) + if err != nil || updatedDevice == nil { + return fmt.Errorf("failed to update device %s: %w", name, err) + } + log.Info("device updated", "name", name) + return nil +} + +func (s *DeviceSync) deleteObsoleteDevices(ctx context.Context, devices models.Devices) { + desiredDevices := make(map[string]models.Device) + for _, device := range devices.Devices { + desiredDevices[device.Name] = device + } + + existingDevices := s.deviceSvc.ListAll(ctx) + existingMap := make(map[string]nb.Device, len(existingDevices)) + for _, device := range existingDevices { + existingMap[device.GetName()] = device + } + + obsoleteDevices := lo.OmitByKeys(existingMap, lo.Keys(desiredDevices)) + for _, obsoleteDevice := range obsoleteDevices { + if obsoleteDevice.Id != nil { + err := s.deviceSvc.Destroy(ctx, *obsoleteDevice.Id) + if err != nil { + log.Error("failed to delete obsolete device", "name", obsoleteDevice.GetName()) + } + } + } +} + +func (s *DeviceSync) buildDeviceTypeReference(ctx context.Context, name string) (nb.ApprovalWorkflowStageResponseApprovalWorkflowStage, error) { + deviceType := s.deviceTypeSvc.GetByName(ctx, name) + if deviceType.Id == nil { + return nb.ApprovalWorkflowStageResponseApprovalWorkflowStage{}, fmt.Errorf("device type '%s' not found in Nautobot", name) + } + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*deviceType.Id), nil +} + +func (s *DeviceSync) buildStatusReference(ctx context.Context, name string) (nb.ApprovalWorkflowStageResponseApprovalWorkflowStage, error) { + status := s.statusSvc.GetByName(ctx, name) + if status.Id == nil { + return nb.ApprovalWorkflowStageResponseApprovalWorkflowStage{}, fmt.Errorf("status '%s' not found in Nautobot", name) + } + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*status.Id), nil +} + +func (s *DeviceSync) buildRoleReference(ctx context.Context, name string) (nb.ApprovalWorkflowStageResponseApprovalWorkflowStage, error) { + role := s.roleSvc.GetByName(ctx, name) + if role.Id == nil { + return nb.ApprovalWorkflowStageResponseApprovalWorkflowStage{}, fmt.Errorf("role '%s' not found in Nautobot", name) + } + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*role.Id), nil +} + +func (s *DeviceSync) buildLocationReference(ctx context.Context, name string) (nb.ApprovalWorkflowStageResponseApprovalWorkflowStage, error) { + location := s.locationSvc.GetByName(ctx, name) + if location.Id == nil { + return nb.ApprovalWorkflowStageResponseApprovalWorkflowStage{}, fmt.Errorf("location '%s' not found in Nautobot", name) + } + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*location.Id), nil +} + +func (s *DeviceSync) buildRackReference(ctx context.Context, name string) (nb.NullableApprovalWorkflowUser, error) { + rack := s.rackSvc.GetByName(ctx, name) + if rack.Id == nil { + return nb.NullableApprovalWorkflowUser{}, fmt.Errorf("rack '%s' not found in Nautobot", name) + } + return helpers.BuildNullableApprovalWorkflowUser(*rack.Id), nil +} + +func (s *DeviceSync) buildTenantReference(ctx context.Context, name string) (nb.NullableApprovalWorkflowUser, error) { + tenant := s.tenantSvc.GetByName(ctx, name) + if tenant.Id == nil { + return nb.NullableApprovalWorkflowUser{}, fmt.Errorf("tenant '%s' not found in Nautobot", name) + } + return helpers.BuildNullableApprovalWorkflowUser(*tenant.Id), nil +} + +func (s *DeviceSync) buildPlatformReference(platformID string) nb.NullableApprovalWorkflowUser { + return helpers.BuildNullableApprovalWorkflowUser(platformID) +}