diff --git a/docs/schema/locations.schema.json b/docs/schema/locations.schema.json new file mode 120000 index 000000000..4ff098f7a --- /dev/null +++ b/docs/schema/locations.schema.json @@ -0,0 +1 @@ +../../schema/locations.schema.json \ No newline at end of file diff --git a/docs/schema/rack-groups.schema.json b/docs/schema/rack-groups.schema.json new file mode 120000 index 000000000..3adf36c76 --- /dev/null +++ b/docs/schema/rack-groups.schema.json @@ -0,0 +1 @@ +../../schema/rack-groups.schema.json \ No newline at end of file diff --git a/go/nautobotop/.gitignore b/go/nautobotop/.gitignore index d23468d96..fbf505796 100644 --- a/go/nautobotop/.gitignore +++ b/go/nautobotop/.gitignore @@ -8,6 +8,7 @@ bin/* dist/* Dockerfile.cross +cmd/cmd # Test binary, built with `go test -c` *.test @@ -25,6 +26,7 @@ dist/ # editor and IDE paraphernalia .idea +.kiro .vscode *.swp *.swo diff --git a/go/nautobotop/api/v1alpha1/nautobot_types.go b/go/nautobotop/api/v1alpha1/nautobot_types.go index 3bfa23935..a032f36b0 100644 --- a/go/nautobotop/api/v1alpha1/nautobot_types.go +++ b/go/nautobotop/api/v1alpha1/nautobot_types.go @@ -33,6 +33,8 @@ type NautobotSpec struct { NautobotServiceRef ServiceSelector `json:"nautobotServiceRef,omitempty"` DeviceTypesRef []ConfigMapRef `json:"deviceTypeRef,omitempty"` LocationTypesRef []ConfigMapRef `json:"locationTypesRef,omitempty"` + LocationRef []ConfigMapRef `json:"locationRef,omitempty"` + RackGroupRef []ConfigMapRef `json:"rackGroupRef,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 1d98b6f55..84930f06e 100644 --- a/go/nautobotop/api/v1alpha1/zz_generated.deepcopy.go +++ b/go/nautobotop/api/v1alpha1/zz_generated.deepcopy.go @@ -138,6 +138,20 @@ func (in *NautobotSpec) DeepCopyInto(out *NautobotSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.LocationRef != nil { + in, out := &in.LocationRef, &out.LocationRef + *out = make([]ConfigMapRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.RackGroupRef != nil { + in, out := &in.RackGroupRef, &out.RackGroupRef + *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 ddfdb3798..8f6dbb5f1 100644 --- a/go/nautobotop/config/crd/bases/sync.rax.io_nautobots.yaml +++ b/go/nautobotop/config/crd/bases/sync.rax.io_nautobots.yaml @@ -68,6 +68,35 @@ spec: - name type: object type: array + locationRef: + 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 + name: + description: Name of this config set (logical name) + type: string + required: + - configMapSelector + - name + type: object + type: array locationTypesRef: items: description: ConfigMapRef defines a reference to a specific ConfigMap @@ -147,6 +176,35 @@ spec: pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string type: object + rackGroupRef: + 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 + name: + description: Name of this config set (logical name) + type: string + required: + - configMapSelector + - name + type: object + type: array requeueAfter: default: 600 type: integer diff --git a/go/nautobotop/helm/crds/clients.yaml b/go/nautobotop/helm/crds/clients.yaml index ddfdb3798..8f6dbb5f1 100644 --- a/go/nautobotop/helm/crds/clients.yaml +++ b/go/nautobotop/helm/crds/clients.yaml @@ -68,6 +68,35 @@ spec: - name type: object type: array + locationRef: + 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 + name: + description: Name of this config set (logical name) + type: string + required: + - configMapSelector + - name + type: object + type: array locationTypesRef: items: description: ConfigMapRef defines a reference to a specific ConfigMap @@ -147,6 +176,35 @@ spec: pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ type: string type: object + rackGroupRef: + 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 + name: + description: Name of this config set (logical name) + type: string + required: + - configMapSelector + - name + type: object + type: array requeueAfter: default: 600 type: integer diff --git a/go/nautobotop/internal/controller/nautobot_controller.go b/go/nautobotop/internal/controller/nautobot_controller.go index b9316d085..69ba3038d 100644 --- a/go/nautobotop/internal/controller/nautobot_controller.go +++ b/go/nautobotop/internal/controller/nautobot_controller.go @@ -61,6 +61,13 @@ 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 + configRefs []syncv1alpha1.ConfigMapRef + syncFunc func(context.Context, *nbClient.NautobotClient, map[string]string) error +} + func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) var nautobotCR syncv1alpha1.Nautobot @@ -68,63 +75,73 @@ func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, client.IgnoreNotFound(err) } - // Aggregate device type data from all referenced ConfigMaps - locationTypeMap, err := r.aggregateDeviceTypeDataFromConfigMap(ctx, nautobotCR.Spec.LocationTypesRef) - if err != nil { - log.Error(err, "failed to aggregate device type data from ConfigMaps") - return ctrl.Result{}, err + syncInterval := time.Duration(nautobotCR.Spec.SyncIntervalSeconds) * time.Second + requeueAfter := time.Duration(nautobotCR.Spec.RequeueAfter) * time.Second + + // Define all resources to sync + // Add more resources here: {name: "location", configRefs: nautobotCR.Spec.LocationsRef, syncFunc: r.syncLocations} + resources := []resourceConfig{ + {name: "locationType", configRefs: nautobotCR.Spec.LocationTypesRef, syncFunc: r.syncLocationTypes}, + {name: "location", configRefs: nautobotCR.Spec.LocationRef, syncFunc: r.syncLocation}, + {name: "rackGroup", configRefs: nautobotCR.Spec.RackGroupRef, syncFunc: r.syncRackGroup}, + {name: "deviceType", configRefs: nautobotCR.Spec.DeviceTypesRef, syncFunc: r.syncDeviceTypes}, } - // Aggregate device type data from all referenced ConfigMaps - deviceTypeMap, err := r.aggregateDeviceTypeDataFromConfigMap(ctx, nautobotCR.Spec.DeviceTypesRef) - if err != nil { - log.Error(err, "failed to aggregate device type data from ConfigMaps") - return ctrl.Result{}, err + // Aggregate data and check sync decisions for all resources + resourcesToSync := make(map[string]map[string]string) + for _, res := range resources { + dataMap, err := r.aggregateDataFromConfigMap(ctx, res.configRefs) + if err != nil { + log.Error(err, "failed to aggregate data", "resource", res.name) + return ctrl.Result{}, err + } + + currentHash := computeHash(dataMap) + previousHash := nautobotCR.GetSyncHash(res.name) + decision := r.shouldSync(nautobotCR.Status.LastSyncedAt, syncInterval, currentHash, previousHash) + + if decision.ShouldSync { + log.Info("resource needs sync", "resource", res.name, "reason", decision.Reason) + resourcesToSync[res.name] = dataMap + } else { + log.Info("skipping resource sync", "resource", res.name, "reason", decision.Reason) + } } - // Check if sync should proceed based on time interval and data changes - syncInterval := time.Duration(nautobotCR.Spec.SyncIntervalSeconds) * time.Second - requestTimeAfter := time.Duration(nautobotCR.Spec.RequeueAfter) * time.Second - currentHash := computeHash(deviceTypeMap) - previousHash := nautobotCR.GetSyncHash("deviceType") - - syncDecision := r.shouldSync(nautobotCR.Status.LastSyncedAt, syncInterval, currentHash, previousHash) - if !syncDecision.ShouldSync { - log.Info("skipping sync", "reason", syncDecision.Reason, "hash", currentHash) - nautobotCR.Status.Message = syncDecision.StatusMessage + // If nothing to sync, update status and requeue + if len(resourcesToSync) == 0 { + nautobotCR.Status.Message = "No changes detected" if err := r.Status().Update(ctx, &nautobotCR); err != nil { log.Error(err, "failed to update status") return ctrl.Result{}, err } - return ctrl.Result{RequeueAfter: requestTimeAfter}, nil + return ctrl.Result{RequeueAfter: requeueAfter}, nil } - log.Info("proceeding with sync", "reason", syncDecision.Reason, "hash", currentHash) - - // Retrieve the Nautobot authentication token from a secret or external source + // Create Nautobot client username, token, err := r.getAuthTokenFromSecretRef(ctx, nautobotCR) if err != nil { - log.Error(err, "failed parse find nautobot auth token") + log.Error(err, "failed to get nautobot auth token") return ctrl.Result{}, err } - - // Create Nautobot client nautobotURL := fmt.Sprintf("http://%s.%s.svc.cluster.local/api", nautobotCR.Spec.NautobotServiceRef.Name, nautobotCR.Spec.NautobotServiceRef.Namespace) nautobotClient := nbClient.NewNautobotClient(nautobotURL, username, token) - if err := r.syncLocationTypes(ctx, nautobotClient, locationTypeMap); err != nil { - log.Error(err, "failed to sync device types") - return ctrl.Result{}, err - } - if err := r.syncDeviceTypes(ctx, nautobotClient, deviceTypeMap); err != nil { - log.Error(err, "failed to sync device types") - return ctrl.Result{}, err + // Sync resources that need updating + for _, res := range resources { + 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) + return ctrl.Result{}, err + } + nautobotCR.SetSyncHash(res.name, computeHash(dataMap)) + } } + // Update status nautobotCR.Status.LastSyncedAt = metav1.Now() nautobotCR.Status.Ready = true nautobotCR.Status.NautobotStatusReport = nautobotClient.Report - nautobotCR.SetSyncHash("deviceType", currentHash) if len(nautobotClient.Report) > 0 { nautobotCR.Status.Message = "sync completed with some errors" } else { @@ -134,15 +151,14 @@ func (r *NautobotReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c log.Error(err, "failed to update status") return ctrl.Result{}, err } - // Successfully completed reconciliation; requeue after configured sync interval + log.Info("sync completed successfully") - return ctrl.Result{RequeueAfter: requestTimeAfter}, nil + return ctrl.Result{RequeueAfter: requeueAfter}, nil } -// aggregateDeviceTypeDataFromConfigMap fetches and merges data from all referenced ConfigMaps. -// It returns a map containing all device type configurations. -func (r *NautobotReconciler) aggregateDeviceTypeDataFromConfigMap(ctx context.Context, refs []syncv1alpha1.ConfigMapRef) (map[string]string, error) { - deviceTypeMap := make(map[string]string) +// aggregateDataFromConfigMap fetches and merges data from all referenced ConfigMaps. +func (r *NautobotReconciler) aggregateDataFromConfigMap(ctx context.Context, refs []syncv1alpha1.ConfigMapRef) (map[string]string, error) { + dataMap := make(map[string]string) for _, ref := range refs { var configMap corev1.ConfigMap @@ -156,42 +172,62 @@ func (r *NautobotReconciler) aggregateDeviceTypeDataFromConfigMap(ctx context.Co namespacedName.Namespace, namespacedName.Name, err) } - // Merge ConfigMap data into the aggregate map - maps.Copy(deviceTypeMap, configMap.Data) + maps.Copy(dataMap, configMap.Data) } - return deviceTypeMap, nil + return dataMap, nil } // syncDeviceTypes syncs device types to Nautobot. -// The hash comparison is now handled in the Reconcile function. func (r *NautobotReconciler) syncDeviceTypes(ctx context.Context, nautobotClient *nbClient.NautobotClient, deviceTypeMap map[string]string, ) error { log := logf.FromContext(ctx) - - log.Info("syncing device types", "deviceTypeCount", len(deviceTypeMap)) + log.Info("syncing device types", "count", len(deviceTypeMap)) syncSvc := sync.NewDeviceTypeSync(nautobotClient) if err := syncSvc.SyncAll(ctx, deviceTypeMap); err != nil { return fmt.Errorf("failed to sync device types: %w", err) } + return nil +} +func (r *NautobotReconciler) syncRackGroup(ctx context.Context, + nautobotClient *nbClient.NautobotClient, + locationType map[string]string, +) error { + log := logf.FromContext(ctx) + log.Info("syncing rack group", "count", len(locationType)) + syncSvc := sync.NewRackGroupSync(nautobotClient) + if err := syncSvc.SyncAll(ctx, locationType); err != nil { + return fmt.Errorf("failed to sync rack group: %w", err) + } return nil } -func (r *NautobotReconciler) syncLocationTypes(ctx context.Context, +func (r *NautobotReconciler) syncLocation(ctx context.Context, nautobotClient *nbClient.NautobotClient, locationType map[string]string, ) error { log := logf.FromContext(ctx) + log.Info("syncing location types", "count", len(locationType)) + syncSvc := sync.NewLocationSync(nautobotClient) + if err := syncSvc.SyncAll(ctx, locationType); err != nil { + return fmt.Errorf("failed to sync location types: %w", err) + } + return nil +} - log.Info("syncing location types", "locationTypeCount", len(locationType)) +func (r *NautobotReconciler) syncLocationTypes(ctx context.Context, + nautobotClient *nbClient.NautobotClient, + locationType map[string]string, +) error { + log := logf.FromContext(ctx) + log.Info("syncing location types", "count", len(locationType)) syncSvc := sync.NewLocationTypeSync(nautobotClient) if err := syncSvc.SyncAll(ctx, locationType); err != nil { return fmt.Errorf("failed to sync location types: %w", err) } - return nil } diff --git a/go/nautobotop/internal/nautobot/dcim/location.go b/go/nautobotop/internal/nautobot/dcim/location.go new file mode 100644 index 000000000..44406924d --- /dev/null +++ b/go/nautobotop/internal/nautobot/dcim/location.go @@ -0,0 +1,87 @@ +package dcim + +import ( + "context" + + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" +) + +type LocationService struct { + client *client.NautobotClient +} + +func NewLocationService(nautobotClient *client.NautobotClient) *LocationService { + return &LocationService{ + client: nautobotClient, + } +} + +func (s *LocationService) Create(ctx context.Context, req nb.LocationRequest) (*nb.Location, error) { + locationType, resp, err := s.client.APIClient.DcimAPI.DcimLocationsCreate(ctx).LocationRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("createNewLocation", "failed to create", "model", req.Name, "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("CreateLocation", "created location", locationType.Name) + return locationType, nil +} + +func (s *LocationService) GetByName(ctx context.Context, name string) nb.Location { + list, resp, err := s.client.APIClient.DcimAPI.DcimLocationsList(ctx).Depth(10).Name([]string{name}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetLocationByName", "failed to get", "name", name, "error", err.Error(), "response_body", bodyString) + return nb.Location{} + } + if list == nil || len(list.Results) == 0 { + return nb.Location{} + } + if list.Results[0].Id == nil { + return nb.Location{} + } + return list.Results[0] +} + +func (s *LocationService) ListAll(ctx context.Context) []nb.Location { + ids := s.client.GetChangeObjectIDS(ctx, "dcim.location") + list, resp, err := s.client.APIClient.DcimAPI.DcimLocationsList(ctx).Id(ids).Depth(10).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("ListAllLocations", "failed to list", "error", err.Error(), "response_body", bodyString) + return []nb.Location{} + } + if list == nil || len(list.Results) == 0 { + return []nb.Location{} + } + if list.Results[0].Id == nil { + return []nb.Location{} + } + + return list.Results +} + +func (s *LocationService) Update(ctx context.Context, id string, req nb.LocationRequest) (*nb.Location, error) { + locationType, resp, err := s.client.APIClient.DcimAPI.DcimLocationsUpdate(ctx, id).LocationRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("UpdateLocation", "failed to update UpdateLocation", "id", id, "model", req.Name, "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("successfully updated location", "id", id, "model", locationType.GetName()) + return locationType, nil +} + +func (s *LocationService) Destroy(ctx context.Context, id string) error { + resp, err := s.client.APIClient.DcimAPI.DcimLocationsDestroy(ctx, id).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("DestroyLocation", "failed to destroy", "id", id, "error", err.Error(), "response_body", bodyString) + return err + } + return nil +} diff --git a/go/nautobotop/internal/nautobot/dcim/rackGroup.go b/go/nautobotop/internal/nautobot/dcim/rackGroup.go new file mode 100644 index 000000000..cc5796ec9 --- /dev/null +++ b/go/nautobotop/internal/nautobot/dcim/rackGroup.go @@ -0,0 +1,87 @@ +package dcim + +import ( + "context" + + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" +) + +type RackGroupService struct { + client *client.NautobotClient +} + +func NewRackGroupService(nautobotClient *client.NautobotClient) *RackGroupService { + return &RackGroupService{ + client: nautobotClient, + } +} + +func (s *RackGroupService) Create(ctx context.Context, req nb.RackGroupRequest) (*nb.RackGroup, error) { + rackGroup, resp, err := s.client.APIClient.DcimAPI.DcimRackGroupsCreate(ctx).RackGroupRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("createNewRackGroup", "failed to create", "model", req.Name, "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("CreateRackGroup", "created rack group", rackGroup.Name) + return rackGroup, nil +} + +func (s *RackGroupService) GetByName(ctx context.Context, name string) nb.RackGroup { + list, resp, err := s.client.APIClient.DcimAPI.DcimRackGroupsList(ctx).Depth(2).Name([]string{name}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetRackGroupByName", "failed to get", "name", name, "error", err.Error(), "response_body", bodyString) + return nb.RackGroup{} + } + if list == nil || len(list.Results) == 0 { + return nb.RackGroup{} + } + if list.Results[0].Id == nil { + return nb.RackGroup{} + } + return list.Results[0] +} + +func (s *RackGroupService) ListAll(ctx context.Context) []nb.RackGroup { + ids := s.client.GetChangeObjectIDS(ctx, "dcim.rackgroup") + list, resp, err := s.client.APIClient.DcimAPI.DcimRackGroupsList(ctx).Id(ids).Depth(2).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("ListAllRackGroups", "failed to list", "error", err.Error(), "response_body", bodyString) + return []nb.RackGroup{} + } + if list == nil || len(list.Results) == 0 { + return []nb.RackGroup{} + } + if list.Results[0].Id == nil { + return []nb.RackGroup{} + } + + return list.Results +} + +func (s *RackGroupService) Update(ctx context.Context, id string, req nb.RackGroupRequest) (*nb.RackGroup, error) { + rackGroup, resp, err := s.client.APIClient.DcimAPI.DcimRackGroupsUpdate(ctx, id).RackGroupRequest(req).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("UpdateRackGroup", "failed to update UpdateRackGroup", "id", id, "model", req.Name, "error", err.Error(), "response_body", bodyString) + return nil, err + } + log.Info("successfully updated rack group", "id", id, "model", rackGroup.GetName()) + return rackGroup, nil +} + +func (s *RackGroupService) Destroy(ctx context.Context, id string) error { + resp, err := s.client.APIClient.DcimAPI.DcimRackGroupsDestroy(ctx, id).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("DestroyRackGroup", "failed to destroy", "id", id, "error", err.Error(), "response_body", bodyString) + return err + } + return nil +} diff --git a/go/nautobotop/internal/nautobot/dcim/status.go b/go/nautobotop/internal/nautobot/dcim/status.go new file mode 100644 index 000000000..78d14db82 --- /dev/null +++ b/go/nautobotop/internal/nautobot/dcim/status.go @@ -0,0 +1,36 @@ +package dcim + +import ( + "context" + + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" +) + +type StatusService struct { + client *client.NautobotClient +} + +func NewStatusService(nautobotClient *client.NautobotClient) *StatusService { + return &StatusService{ + client: nautobotClient, + } +} + +func (s *StatusService) GetByName(ctx context.Context, name string) nb.Status { + list, resp, err := s.client.APIClient.ExtrasAPI.ExtrasStatusesList(ctx).Depth(1).Name([]string{name}).Execute() + if err != nil { + bodyString := helpers.ReadResponseBody(resp) + s.client.AddReport("GetLocationByName", "failed to get", "name", name, "error", err.Error(), "response_body", bodyString) + return nb.Status{} + } + if list == nil || len(list.Results) == 0 { + return nb.Status{} + } + if list.Results[0].Id == nil { + return nb.Status{} + } + return list.Results[0] +} diff --git a/go/nautobotop/internal/nautobot/models/location.go b/go/nautobotop/internal/nautobot/models/location.go new file mode 100644 index 000000000..92ea38b16 --- /dev/null +++ b/go/nautobotop/internal/nautobot/models/location.go @@ -0,0 +1,13 @@ +package models + +type Locations struct { + Location []Location +} + +type Location struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + LocationType string `json:"string" yaml:"location_type"` + Status string `json:"status" yaml:"status"` + Children []Location `json:"children" yaml:"children"` +} diff --git a/go/nautobotop/internal/nautobot/models/rackGroup.go b/go/nautobotop/internal/nautobot/models/rackGroup.go new file mode 100644 index 000000000..7bb2d1c20 --- /dev/null +++ b/go/nautobotop/internal/nautobot/models/rackGroup.go @@ -0,0 +1,12 @@ +package models + +type RackGroups struct { + RackGroup []RackGroup +} + +type RackGroup struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Location string `json:"location" yaml:"location"` + Children []RackGroup `json:"children" yaml:"children"` +} diff --git a/go/nautobotop/internal/nautobot/sync/location.go b/go/nautobotop/internal/nautobot/sync/location.go new file mode 100644 index 000000000..0af84b03c --- /dev/null +++ b/go/nautobotop/internal/nautobot/sync/location.go @@ -0,0 +1,200 @@ +package sync + +import ( + "context" + "fmt" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/dcim" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/models" + "github.com/samber/lo" + + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + "go.yaml.in/yaml/v3" +) + +type LocationSync struct { + client *client.NautobotClient + locationSvc *dcim.LocationService + locationTypes *dcim.LocationTypeService + statusSvc *dcim.StatusService +} + +func NewLocationSync(nautobotClient *client.NautobotClient) *LocationSync { + return &LocationSync{ + client: nautobotClient.GetClient(), + locationSvc: dcim.NewLocationService(nautobotClient.GetClient()), + locationTypes: dcim.NewLocationTypeService(nautobotClient.GetClient()), + statusSvc: dcim.NewStatusService(nautobotClient.GetClient()), + } +} + +func (s *LocationSync) SyncAll(ctx context.Context, data map[string]string) error { + var location models.Locations + for key, f := range data { + var yml []models.Location + if err := yaml.Unmarshal([]byte(f), &yml); err != nil { + s.client.AddReport("yamlFailed", "file: "+key+" error: "+err.Error()) + return err + } + location.Location = append(location.Location, yml...) + } + + for _, l := range location.Location { + if err := s.syncLocationRecursive(ctx, l, nil); err != nil { + return err + } + } + s.deleteObsoleteLocations(ctx, location) + + return nil +} + +// syncLocationRecursive processes a location and all its children recursively +func (s *LocationSync) syncLocationRecursive(ctx context.Context, location models.Location, parentID *string) error { + currentID, err := s.syncSingleLocation(ctx, location, parentID) + if err != nil { + return err + } + + for _, child := range location.Children { + if err := s.syncLocationRecursive(ctx, child, currentID); err != nil { + return err + } + } + + return nil +} + +// syncSingleLocation handles the create/update logic for a single location +func (s *LocationSync) syncSingleLocation(ctx context.Context, location models.Location, parentID *string) (*string, error) { + existingLocation := s.locationSvc.GetByName(ctx, location.Name) + + locationRequest := nb.LocationRequest{ + Name: location.Name, + + Description: nb.PtrString(location.Description), + Parent: buildParentReference(parentID), + LocationType: s.buildLocationTypeReference(ctx, location.LocationType), + Status: s.buildStatusReference(ctx, location.Status), + } + + if existingLocation.Id == nil { + return s.createLocation(ctx, locationRequest) + } + + if !helpers.CompareJSONFields(existingLocation, locationRequest) { + return s.updateLocation(ctx, *existingLocation.Id, locationRequest) + } + + log.Info("location unchanged, skipping update", "name", locationRequest.Name) + return existingLocation.Id, nil +} + +// createLocation creates a new location in Nautobot +func (s *LocationSync) createLocation(ctx context.Context, request nb.LocationRequest) (*string, error) { + createdLocationType, err := s.locationSvc.Create(ctx, request) + if err != nil || createdLocationType == nil { + return nil, fmt.Errorf("failed to create location %s: %w", request.Name, err) + } + log.Info("location created", "name", request.Name) + return createdLocationType.Id, nil +} + +// updateLocation updates an existing location in Nautobot +func (s *LocationSync) updateLocation(ctx context.Context, id string, request nb.LocationRequest) (*string, error) { + updatedLocationType, err := s.locationSvc.Update(ctx, id, request) + if err != nil || updatedLocationType == nil { + return nil, fmt.Errorf("failed to update location %s: %w", request.Name, err) + } + log.Info("location updated", "name", request.Name) + return updatedLocationType.Id, nil +} + +// deleteObsoleteLocations removes location that are not defined in YAML +func (s *LocationSync) deleteObsoleteLocations(ctx context.Context, location models.Locations) { + desiredLocationTypes := make(map[string]models.Location) + for _, locationType := range location.Location { + s.collectAllLocationTypes(locationType, desiredLocationTypes) + } + + existingLocations := s.locationSvc.ListAll(ctx) + existingMap := make(map[string]nb.Location, len(existingLocations)) + for _, locationType := range existingLocations { + existingMap[locationType.Name] = locationType + } + + obsoleteLocations := lo.OmitByKeys(existingMap, lo.Keys(desiredLocationTypes)) + s.deleteLocationWithDependencies(ctx, obsoleteLocations) +} + +// collectAllLocationTypes recursively collects all location including nested children +func (s *LocationSync) collectAllLocationTypes(locationType models.Location, result map[string]models.Location) { + result[locationType.Name] = locationType + for _, child := range locationType.Children { + s.collectAllLocationTypes(child, result) + } +} + +// deleteLocationWithDependencies deletes location in correct order +func (s *LocationSync) deleteLocationWithDependencies(ctx context.Context, obsoleteLocations map[string]nb.Location) { + idToName := make(map[string]string) + for name, locationType := range obsoleteLocations { + if locationType.Id != nil { + idToName[*locationType.Id] = name + } + } + + childrenMap := make(map[string][]string) + for name, locationType := range obsoleteLocations { + parentID := s.getParentID(locationType) + if parentID != "" { + if parentName, exists := idToName[parentID]; exists { + childrenMap[parentName] = append(childrenMap[parentName], name) + } + } + } + + deleted := make(map[string]bool) + for name := range obsoleteLocations { + s.deleteLocationTypeRecursive(ctx, name, obsoleteLocations, childrenMap, deleted) + } +} + +// deleteLocationTypeRecursive deletes a location and all its children recursively +func (s *LocationSync) deleteLocationTypeRecursive(ctx context.Context, name string, obsoleteLocations map[string]nb.Location, childrenMap map[string][]string, deleted map[string]bool) { + if deleted[name] { + return + } + if children, hasChildren := childrenMap[name]; hasChildren { + for _, childName := range children { + s.deleteLocationTypeRecursive(ctx, childName, obsoleteLocations, childrenMap, deleted) + } + } + if locationType, exists := obsoleteLocations[name]; exists && locationType.Id != nil { + _ = s.locationSvc.Destroy(ctx, *locationType.Id) + deleted[name] = true + } +} + +// getParentID extracts the parent ID from a LocationType +func (s *LocationSync) getParentID(locationType nb.Location) string { + if locationType.Parent.IsSet() { + parent := locationType.Parent.Get() + if parent != nil && parent.Id != nil && parent.Id.String != nil { + return *parent.Id.String + } + } + return "" +} +func (s *LocationSync) buildStatusReference(ctx context.Context, name string) nb.ApprovalWorkflowStageResponseApprovalWorkflowStage { + locationType := s.statusSvc.GetByName(ctx, name) + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*locationType.Id) +} + +func (s *LocationSync) buildLocationTypeReference(ctx context.Context, name string) nb.ApprovalWorkflowStageResponseApprovalWorkflowStage { + locationType := s.locationTypes.GetByName(ctx, name) + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*locationType.Id) +} diff --git a/go/nautobotop/internal/nautobot/sync/rackGroup.go b/go/nautobotop/internal/nautobot/sync/rackGroup.go new file mode 100644 index 000000000..e7682ebca --- /dev/null +++ b/go/nautobotop/internal/nautobot/sync/rackGroup.go @@ -0,0 +1,192 @@ +package sync + +import ( + "context" + "fmt" + + "github.com/charmbracelet/log" + nb "github.com/nautobot/go-nautobot/v3" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/dcim" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/helpers" + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/models" + "github.com/samber/lo" + + "github.com/rackerlabs/understack/go/nautobotop/internal/nautobot/client" + "go.yaml.in/yaml/v3" +) + +type RackGroupSync struct { + client *client.NautobotClient + rackGroupSvc *dcim.RackGroupService + locationSvc *dcim.LocationService +} + +func NewRackGroupSync(nautobotClient *client.NautobotClient) *RackGroupSync { + return &RackGroupSync{ + client: nautobotClient.GetClient(), + rackGroupSvc: dcim.NewRackGroupService(nautobotClient), + locationSvc: dcim.NewLocationService(nautobotClient.GetClient()), + } +} + +func (s *RackGroupSync) SyncAll(ctx context.Context, data map[string]string) error { + var rackGroups models.RackGroups + for key, f := range data { + var yml []models.RackGroup + if err := yaml.Unmarshal([]byte(f), &yml); err != nil { + s.client.AddReport("yamlFailed", "file: "+key+" error: "+err.Error()) + return err + } + rackGroups.RackGroup = append(rackGroups.RackGroup, yml...) + } + + for _, l := range rackGroups.RackGroup { + if err := s.syncRackGroupRecursive(ctx, l, nil); err != nil { + return err + } + } + s.deleteObsoleteRackGroup(ctx, rackGroups) + + return nil +} + +// syncRackGroupRecursive processes a rackGroups and all its children recursively +func (s *RackGroupSync) syncRackGroupRecursive(ctx context.Context, rackGroup models.RackGroup, parentID *string) error { + currentID, err := s.syncSingleRackGroup(ctx, rackGroup, parentID) + if err != nil { + return err + } + + for _, child := range rackGroup.Children { + if err := s.syncRackGroupRecursive(ctx, child, currentID); err != nil { + return err + } + } + + return nil +} + +// syncSingleRackGroup handles the create/update logic for a single location +func (s *RackGroupSync) syncSingleRackGroup(ctx context.Context, rackGroup models.RackGroup, parentID *string) (*string, error) { + existingRackGroup := s.rackGroupSvc.GetByName(ctx, rackGroup.Name) + + rackGroupRequest := nb.RackGroupRequest{ + Name: rackGroup.Name, + Description: nb.PtrString(rackGroup.Description), + Parent: buildParentReference(parentID), + Location: s.buildLocationReference(ctx, rackGroup.Location), + } + + if existingRackGroup.Id == nil { + return s.createRackGroup(ctx, rackGroupRequest) + } + + if !helpers.CompareJSONFields(existingRackGroup, rackGroupRequest) { + return s.updateRackGroup(ctx, *existingRackGroup.Id, rackGroupRequest) + } + + log.Info("rackGroup is unchanged, skipping update", "name", rackGroupRequest.Name) + return rackGroupRequest.Id, nil +} + +// createRackGroup creates a new location in Nautobot +func (s *RackGroupSync) createRackGroup(ctx context.Context, request nb.RackGroupRequest) (*string, error) { + createdRackGroup, err := s.rackGroupSvc.Create(ctx, request) + if err != nil || createdRackGroup == nil { + return nil, fmt.Errorf("failed to create rackgroup %s: %w", request.Name, err) + } + log.Info("rackGroup created", "name", request.Name) + return createdRackGroup.Id, nil +} + +// updateRackGroup updates an existing location in Nautobot +func (s *RackGroupSync) updateRackGroup(ctx context.Context, id string, request nb.RackGroupRequest) (*string, error) { + updatedRackGroup, err := s.rackGroupSvc.Update(ctx, id, request) + if err != nil || updatedRackGroup == nil { + return nil, fmt.Errorf("failed to update rackGroup %s: %w", request.Name, err) + } + log.Info("rackGroup updated", "name", request.Name) + return updatedRackGroup.Id, nil +} + +// deleteObsoleteRackGroup removes rackGroup that are not defined in YAML +func (s *RackGroupSync) deleteObsoleteRackGroup(ctx context.Context, rackGroups models.RackGroups) { + desiredRackGroups := make(map[string]models.RackGroup) + for _, rackGroup := range rackGroups.RackGroup { + s.collectAllRackGroups(rackGroup, desiredRackGroups) + } + + existingRackGroup := s.rackGroupSvc.ListAll(ctx) + existingMap := make(map[string]nb.RackGroup, len(existingRackGroup)) + for _, rackGroup := range existingRackGroup { + existingMap[rackGroup.Name] = rackGroup + } + + obsoleteRackGroup := lo.OmitByKeys(existingMap, lo.Keys(desiredRackGroups)) + s.deleteRackGroupWithDependencies(ctx, obsoleteRackGroup) +} + +// collectAllRackGroups recursively collects all rackGroups including nested children +func (s *RackGroupSync) collectAllRackGroups(rackGroup models.RackGroup, result map[string]models.RackGroup) { + result[rackGroup.Name] = rackGroup + for _, child := range rackGroup.Children { + s.collectAllRackGroups(child, result) + } +} + +// deleteRackGroupWithDependencies deletes rackGroups in correct order +func (s *RackGroupSync) deleteRackGroupWithDependencies(ctx context.Context, obsoleteRackGroup map[string]nb.RackGroup) { + idToName := make(map[string]string) + for name, rackGroup := range obsoleteRackGroup { + if rackGroup.Id != nil { + idToName[*rackGroup.Id] = name + } + } + + childrenMap := make(map[string][]string) + for name, rackGroup := range obsoleteRackGroup { + parentID := s.getParentID(rackGroup) + if parentID != "" { + if parentName, exists := idToName[parentID]; exists { + childrenMap[parentName] = append(childrenMap[parentName], name) + } + } + } + + deleted := make(map[string]bool) + for name := range obsoleteRackGroup { + s.deleteRackGroupRecursive(ctx, name, obsoleteRackGroup, childrenMap, deleted) + } +} + +// deleteRackGroupRecursive deletes a rackGroup and all its children recursively +func (s *RackGroupSync) deleteRackGroupRecursive(ctx context.Context, name string, obsoleteRackGroup map[string]nb.RackGroup, childrenMap map[string][]string, deleted map[string]bool) { + if deleted[name] { + return + } + if children, hasChildren := childrenMap[name]; hasChildren { + for _, childName := range children { + s.deleteRackGroupRecursive(ctx, childName, obsoleteRackGroup, childrenMap, deleted) + } + } + if rackGroup, exists := obsoleteRackGroup[name]; exists && rackGroup.Id != nil { + _ = s.rackGroupSvc.Destroy(ctx, *rackGroup.Id) + deleted[name] = true + } +} + +// getParentID extracts the parent ID from a RackGroup +func (s *RackGroupSync) getParentID(rackGroup nb.RackGroup) string { + if rackGroup.Parent.IsSet() { + parent := rackGroup.Parent.Get() + if parent != nil && parent.Id != nil && parent.Id.String != nil { + return *parent.Id.String + } + } + return "" +} + +func (s *RackGroupSync) buildLocationReference(ctx context.Context, name string) nb.ApprovalWorkflowStageResponseApprovalWorkflowStage { + rackGroup := s.locationSvc.GetByName(ctx, name) + return helpers.BuildApprovalWorkflowStageResponseApprovalWorkflowStage(*rackGroup.Id) +} diff --git a/schema/locations.schema.json b/schema/locations.schema.json new file mode 100644 index 000000000..476b5000f --- /dev/null +++ b/schema/locations.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://rackerlabs.github.io/understack/schema/locations.schema.json", + "title": "UnderStack Locations", + "description": "Hierarchical location configuration schema for Nautobot location organization", + "type": "array", + "items": { + "$ref": "#/$defs/location" + }, + "minItems": 1, + "$defs": { + "location": { + "type": "object", + "description": "A location definition with optional nested children", + "properties": { + "name": { + "description": "Name of the location", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Human-readable description of the location", + "type": "string" + }, + "location_type": { + "description": "Type of location (must correspond to a defined location type)", + "type": "string", + "minLength": 1 + }, + "status": { + "description": "Operational status of the location", + "type": "string", + "enum": ["Active", "Decommissioning", "Planned", "Retired", "Staging"] + }, + "children": { + "description": "Child locations nested under this location", + "type": "array", + "items": { + "$ref": "#/$defs/location" + } + } + }, + "required": ["name", "description", "location_type", "status"], + "additionalProperties": false + } + } +} diff --git a/schema/rack-groups.schema.json b/schema/rack-groups.schema.json new file mode 100644 index 000000000..8ae9b2f6a --- /dev/null +++ b/schema/rack-groups.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://rackerlabs.github.io/understack/schema/rack-group.schema.json", + "title": "UnderStack Rack Groups", + "description": "Hierarchical rack group configuration schema for Nautobot rack organization", + "type": "array", + "items": { + "$ref": "#/$defs/rackGroup" + }, + "minItems": 1, + "$defs": { + "rackGroup": { + "type": "object", + "description": "A rack group definition with optional nested children", + "properties": { + "name": { + "description": "Name of the rack group", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "Human-readable description of the rack group", + "type": "string" + }, + "location": { + "description": "Location where the rack group is situated (must correspond to a defined location)", + "type": "string", + "minLength": 1 + }, + "children": { + "description": "Child rack groups nested under this rack group", + "type": "array", + "items": { + "$ref": "#/$defs/rackGroup" + } + } + }, + "required": ["name", "description", "location"], + "additionalProperties": false + } + } +}