Skip to content

Commit c8802fc

Browse files
committed
feat(sync): enhance device synchronization with additional filtering options
- Added new command flags for filtering Netbox devices by query, site, role, status, status exclusion, tag, and tenant. - Refactored device fetching logic to utilize a filters struct for improved clarity and maintainability. - Updated resource upsertion to use common package for consistency.
1 parent 78a4ebb commit c8802fc

File tree

5 files changed

+374
-103
lines changed

5 files changed

+374
-103
lines changed

cmd/ctrlc/root/sync/netbox/devices/devices.go

Lines changed: 36 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,28 @@ package devices
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"os"
7-
"strconv"
88

99
"github.com/MakeNowJust/heredoc/v2"
1010
"github.com/charmbracelet/log"
1111
"github.com/ctrlplanedev/cli/internal/api"
12-
ctrlp "github.com/ctrlplanedev/cli/internal/common"
13-
netbox "github.com/netbox-community/go-netbox/v4"
12+
"github.com/ctrlplanedev/cli/internal/common"
1413
"github.com/spf13/cobra"
1514
)
1615

17-
const pageSize int32 = 100
18-
1916
func NewSyncDevicesCmd() *cobra.Command {
2017
var netboxURL string
2118
var netboxToken string
2219
var providerName string
20+
var query string
21+
var siteFilter []string
22+
var roleFilter []string
23+
var statusFilter []string
24+
var statusExcludeFilter []string
25+
var tagFilter []string
26+
var tenantFilter []string
2327

2428
cmd := &cobra.Command{
2529
Use: "devices",
@@ -31,9 +35,17 @@ func NewSyncDevicesCmd() *cobra.Command {
3135
ctx := context.Background()
3236
log.Info("Syncing Netbox devices into Ctrlplane")
3337

34-
client := netbox.NewAPIClientFor(netboxURL, netboxToken)
38+
filters := deviceFilters{
39+
Query: query,
40+
Site: siteFilter,
41+
Role: roleFilter,
42+
Status: statusFilter,
43+
StatusExclude: statusExcludeFilter,
44+
Tag: tagFilter,
45+
Tenant: tenantFilter,
46+
}
3547

36-
allDevices, err := fetchAllDevices(ctx, client)
48+
allDevices, err := fetchAllDevicesDirect(ctx, netboxURL, netboxToken, filters)
3749
if err != nil {
3850
return fmt.Errorf("failed to list Netbox devices: %w", err)
3951
}
@@ -49,111 +61,32 @@ func NewSyncDevicesCmd() *cobra.Command {
4961
providerName = "netbox-devices"
5062
}
5163

52-
return ctrlp.UpsertResources(ctx, resources, &providerName)
64+
for _, resource := range resources {
65+
b, err := json.MarshalIndent(resource, "", " ")
66+
if err != nil {
67+
fmt.Printf("error marshaling resource: %v\n", err)
68+
continue
69+
}
70+
fmt.Println(string(b))
71+
}
72+
73+
return common.UpsertResources(ctx, resources, &providerName)
5374
},
5475
}
5576

5677
cmd.Flags().StringVar(&netboxURL, "netbox-url", os.Getenv("NETBOX_URL"), "Netbox instance URL")
5778
cmd.Flags().StringVar(&netboxToken, "netbox-token", os.Getenv("NETBOX_TOKEN"), "Netbox API token")
5879
cmd.Flags().StringVarP(&providerName, "provider", "p", "", "Resource provider name (default: netbox-devices)")
80+
cmd.Flags().StringVar(&query, "q", "", "Search query for Netbox devices")
81+
cmd.Flags().StringSliceVar(&siteFilter, "site", nil, "Filter by Netbox site slug/name (repeatable)")
82+
cmd.Flags().StringSliceVar(&roleFilter, "role", nil, "Filter by Netbox device role slug/name (repeatable)")
83+
cmd.Flags().StringSliceVar(&statusFilter, "status", nil, "Filter by Netbox status (repeatable)")
84+
cmd.Flags().StringSliceVar(&statusExcludeFilter, "status-n", nil, "Exclude Netbox status values (repeatable)")
85+
cmd.Flags().StringSliceVar(&tagFilter, "tag", nil, "Filter by Netbox tag slug (repeatable)")
86+
cmd.Flags().StringSliceVar(&tenantFilter, "tenant", nil, "Filter by Netbox tenant slug/name (repeatable)")
5987

6088
cmd.MarkFlagRequired("netbox-url")
6189
cmd.MarkFlagRequired("netbox-token")
6290

6391
return cmd
6492
}
65-
66-
func fetchAllDevices(ctx context.Context, client *netbox.APIClient) ([]netbox.DeviceWithConfigContext, error) {
67-
var all []netbox.DeviceWithConfigContext
68-
var offset int32
69-
page := 1
70-
71-
for {
72-
log.Info("Fetching Netbox devices page", "page", page, "offset", offset, "limit", pageSize)
73-
res, _, err := client.DcimAPI.
74-
DcimDevicesList(ctx).
75-
Limit(pageSize).
76-
Offset(offset).
77-
Execute()
78-
if err != nil {
79-
log.Error(err, "Failed to fetch Netbox devices page", "page", page, "offset", offset)
80-
return nil, err
81-
}
82-
83-
log.Info("Fetched devices from Netbox page", "page", page, "count", len(res.Results), "total", res.Count)
84-
all = append(all, res.Results...)
85-
86-
if int32(len(all)) >= res.Count {
87-
log.Info("All Netbox devices fetched", "total_count", len(all))
88-
break
89-
}
90-
offset += pageSize
91-
page++
92-
}
93-
94-
return all, nil
95-
}
96-
97-
func mapDevice(device netbox.DeviceWithConfigContext) api.ResourceProviderResource {
98-
metadata := map[string]string{}
99-
100-
metadata["netbox/id"] = strconv.Itoa(int(device.Id))
101-
metadata["netbox/site"] = device.GetSite().Name
102-
103-
if device.Status != nil {
104-
metadata["netbox/status"] = string(device.Status.GetValue())
105-
}
106-
if rack, ok := device.GetRackOk(); ok && rack != nil {
107-
metadata["netbox/rack"] = rack.GetName()
108-
}
109-
if tenant, ok := device.GetTenantOk(); ok && tenant != nil {
110-
metadata["netbox/tenant"] = tenant.GetName()
111-
}
112-
if role := device.Role; role.Name != "" {
113-
metadata["netbox/role"] = role.Name
114-
}
115-
if platform, ok := device.GetPlatformOk(); ok && platform != nil {
116-
metadata["netbox/platform"] = platform.GetName()
117-
}
118-
119-
for _, tag := range device.Tags {
120-
metadata[fmt.Sprintf("netbox/tag/%s", tag.Slug)] = "true"
121-
}
122-
123-
site := device.GetSite()
124-
config := map[string]any{
125-
"id": device.Id,
126-
"name": device.GetName(),
127-
"device_type": device.DeviceType.GetDisplay(),
128-
"role": device.Role.GetName(),
129-
"site": map[string]any{
130-
"id": site.Id,
131-
"url": site.Url,
132-
"slug": site.Slug,
133-
"name": site.Name,
134-
},
135-
}
136-
if device.Serial != nil {
137-
config["serial"] = *device.Serial
138-
}
139-
if pip, ok := device.GetPrimaryIpOk(); ok && pip != nil {
140-
config["primaryIp"] = pip.GetAddress()
141-
}
142-
if platform, ok := device.GetPlatformOk(); ok && platform != nil {
143-
config["platform"] = platform.GetName()
144-
}
145-
146-
name := device.GetName()
147-
if name == "" {
148-
name = device.Display
149-
}
150-
151-
return api.ResourceProviderResource{
152-
Version: "netbox/device/v1",
153-
Kind: "Device",
154-
Name: name,
155-
Identifier: strconv.Itoa(int(device.Id)),
156-
Config: config,
157-
Metadata: metadata,
158-
}
159-
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package devices
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"time"
12+
13+
"github.com/charmbracelet/log"
14+
)
15+
16+
func fetchAllDevicesDirect(ctx context.Context, netboxURL, netboxToken string, filters deviceFilters) ([]netboxDevice, error) {
17+
var all []netboxDevice
18+
var offset int32
19+
page := 1
20+
base := strings.TrimRight(netboxURL, "/") + "/api/dcim/devices/"
21+
httpClient := &http.Client{Timeout: 45 * time.Second}
22+
23+
for {
24+
log.Info(
25+
"Fetching Netbox devices page",
26+
"page", page,
27+
"offset", offset,
28+
"limit", pageSize,
29+
"q", filters.Query,
30+
"site", filters.Site,
31+
"role", filters.Role,
32+
"status", filters.Status,
33+
"status_n", filters.StatusExclude,
34+
"tag", filters.Tag,
35+
"tenant", filters.Tenant,
36+
)
37+
38+
res, err := fetchDevicesPage(ctx, httpClient, base, netboxToken, filters.toQuery(offset))
39+
if err != nil {
40+
log.Error(err, "Failed to fetch Netbox devices page", "page", page, "offset", offset)
41+
return nil, err
42+
}
43+
44+
log.Info("Fetched devices from Netbox page", "page", page, "count", len(res.Results), "total", res.Count)
45+
all = append(all, res.Results...)
46+
47+
if res.Next == nil || *res.Next == "" || int32(len(all)) >= res.Count {
48+
log.Info("All Netbox devices fetched", "total_count", len(all))
49+
break
50+
}
51+
offset += pageSize
52+
page++
53+
}
54+
55+
return all, nil
56+
}
57+
58+
func fetchDevicesPage(
59+
ctx context.Context,
60+
httpClient *http.Client,
61+
baseURL string,
62+
netboxToken string,
63+
queryParams url.Values,
64+
) (netboxListResponse, error) {
65+
endpoint := baseURL + "?" + queryParams.Encode()
66+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
67+
if err != nil {
68+
return netboxListResponse{}, fmt.Errorf("failed to build Netbox request: %w", err)
69+
}
70+
req.Header.Set("Authorization", "Token "+netboxToken)
71+
req.Header.Set("Accept", "application/json")
72+
73+
resp, err := httpClient.Do(req)
74+
if err != nil {
75+
return netboxListResponse{}, err
76+
}
77+
defer resp.Body.Close()
78+
79+
if resp.StatusCode >= 400 {
80+
body, _ := io.ReadAll(resp.Body)
81+
return netboxListResponse{}, fmt.Errorf(
82+
"netbox /api/dcim/devices request failed (HTTP %d): %s",
83+
resp.StatusCode,
84+
strings.TrimSpace(string(body)),
85+
)
86+
}
87+
88+
var parsed netboxListResponse
89+
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
90+
return netboxListResponse{}, fmt.Errorf("failed to decode Netbox devices response: %w", err)
91+
}
92+
93+
return parsed, nil
94+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package devices
2+
3+
import (
4+
"net/url"
5+
"strconv"
6+
)
7+
8+
const pageSize int32 = 1_000
9+
10+
type deviceFilters struct {
11+
Query string
12+
Site []string
13+
Role []string
14+
Status []string
15+
StatusExclude []string
16+
Tag []string
17+
Tenant []string
18+
}
19+
20+
func (f deviceFilters) toQuery(offset int32) url.Values {
21+
q := url.Values{}
22+
q.Set("limit", strconv.Itoa(int(pageSize)))
23+
q.Set("offset", strconv.Itoa(int(offset)))
24+
25+
if f.Query != "" {
26+
q.Set("q", f.Query)
27+
}
28+
for _, v := range f.Site {
29+
q.Add("site", v)
30+
}
31+
for _, v := range f.Role {
32+
q.Add("role", v)
33+
}
34+
for _, v := range f.Status {
35+
q.Add("status", v)
36+
}
37+
for _, v := range f.StatusExclude {
38+
q.Add("status_n", v)
39+
}
40+
for _, v := range f.Tag {
41+
q.Add("tag", v)
42+
}
43+
for _, v := range f.Tenant {
44+
q.Add("tenant", v)
45+
}
46+
47+
return q
48+
}

0 commit comments

Comments
 (0)