Skip to content

Commit d8fcef5

Browse files
Lionel-Wilsoncursoragent
authored andcommitted
Create Azure adapter: NetworkNetworkWatcher (#4530)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary This PR adds a new Azure adapter for discovering **Network Watcher** resources (`NetworkNetworkWatcher` type). Network Watchers provide network monitoring, diagnostic, and analytics capabilities in Azure. ## Changes ### New Files - **`sources/azure/clients/network-watchers-client.go`** - Client interface wrapping `armnetwork.WatchersClient` - **`sources/azure/manual/network-network-watcher.go`** - Adapter implementation - **`sources/azure/manual/network-network-watcher_test.go`** - Unit tests - **`sources/azure/integration-tests/network-network-watcher_test.go`** - Integration test - **`sources/azure/shared/mocks/mock_network_watchers_client.go`** - Generated mock ### Modified Files - **`sources/azure/manual/adapters.go`** - Registered the new adapter ## Implementation Details - **ListableWrapper** adapter type for top-level resources - Health mapping from `ProvisioningState` to SDP health - Links to child `NetworkFlowLog` resources via SEARCH query - Requires `Microsoft.Network/networkWatchers/read` permission ## Self-Review Checklist - [x] Item type defined in `shared/item-types.go` - [x] Lookups properly defined - [x] Client interface with mockable methods - [x] Error handling with `azureshared.QueryError` - [x] Exhaustive `ProvisioningState` switch - [x] Unit tests pass - [x] Integration test handles Azure one-per-region limit - [x] Linting passes ## Related Closes ENG-3542 <!-- CURSOR_AGENT_PR_BODY_END --> Linear Issue: [ENG-3542](https://linear.app/overmind/issue/ENG-3542/create-azure-adapter-networknetworkwatcher) <div><a href="https://cursor.com/agents/bc-057597d0-12b0-4d84-91db-ef70f46a6bc4"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-057597d0-12b0-4d84-91db-ef70f46a6bc4"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Lionel Wilson <Lionel-Wilson@users.noreply.github.com> GitOrigin-RevId: 7e331567dfaacbc260feaef53acbb575f8cbcfa3
1 parent cbf0677 commit d8fcef5

6 files changed

Lines changed: 968 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package clients
2+
3+
import (
4+
"context"
5+
6+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9"
7+
)
8+
9+
//go:generate mockgen -destination=../shared/mocks/mock_network_watchers_client.go -package=mocks -source=network-watchers-client.go
10+
11+
// NetworkWatchersPager is a type alias for the generic Pager interface with network watchers response type.
12+
type NetworkWatchersPager = Pager[armnetwork.WatchersClientListResponse]
13+
14+
// NetworkWatchersClient is an interface for interacting with Azure Network Watchers
15+
type NetworkWatchersClient interface {
16+
NewListPager(resourceGroupName string, options *armnetwork.WatchersClientListOptions) NetworkWatchersPager
17+
Get(ctx context.Context, resourceGroupName string, networkWatcherName string, options *armnetwork.WatchersClientGetOptions) (armnetwork.WatchersClientGetResponse, error)
18+
}
19+
20+
type networkWatchersClient struct {
21+
client *armnetwork.WatchersClient
22+
}
23+
24+
func (c *networkWatchersClient) NewListPager(resourceGroupName string, options *armnetwork.WatchersClientListOptions) NetworkWatchersPager {
25+
return c.client.NewListPager(resourceGroupName, options)
26+
}
27+
28+
func (c *networkWatchersClient) Get(ctx context.Context, resourceGroupName string, networkWatcherName string, options *armnetwork.WatchersClientGetOptions) (armnetwork.WatchersClientGetResponse, error) {
29+
return c.client.Get(ctx, resourceGroupName, networkWatcherName, options)
30+
}
31+
32+
// NewNetworkWatchersClient creates a new NetworkWatchersClient from the Azure SDK client
33+
func NewNetworkWatchersClient(client *armnetwork.WatchersClient) NetworkWatchersClient {
34+
return &networkWatchersClient{client: client}
35+
}
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
package integrationtests
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
14+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v9"
15+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v2"
16+
log "github.com/sirupsen/logrus"
17+
18+
"github.com/overmindtech/cli/go/discovery"
19+
"github.com/overmindtech/cli/go/sdp-go"
20+
"github.com/overmindtech/cli/go/sdpcache"
21+
"github.com/overmindtech/cli/sources"
22+
"github.com/overmindtech/cli/sources/azure/clients"
23+
"github.com/overmindtech/cli/sources/azure/manual"
24+
azureshared "github.com/overmindtech/cli/sources/azure/shared"
25+
)
26+
27+
const (
28+
// Azure only allows one Network Watcher per region per subscription.
29+
// We create a test Network Watcher in our integration test resource group.
30+
integrationTestNetworkWatcherTestName = "ovm-integ-test-nw"
31+
)
32+
33+
func TestNetworkNetworkWatcherIntegration(t *testing.T) {
34+
subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID")
35+
if subscriptionID == "" {
36+
t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set")
37+
}
38+
39+
cred, err := azureshared.NewAzureCredential(t.Context())
40+
if err != nil {
41+
t.Fatalf("Failed to create Azure credential: %v", err)
42+
}
43+
44+
rgClient, err := armresources.NewResourceGroupsClient(subscriptionID, cred, nil)
45+
if err != nil {
46+
t.Fatalf("Failed to create Resource Groups client: %v", err)
47+
}
48+
49+
networkWatchersClient, err := armnetwork.NewWatchersClient(subscriptionID, cred, nil)
50+
if err != nil {
51+
t.Fatalf("Failed to create Network Watchers client: %v", err)
52+
}
53+
54+
setupCompleted := false
55+
56+
t.Run("Setup", func(t *testing.T) {
57+
ctx := t.Context()
58+
59+
// Create resource group if it doesn't exist
60+
err := createResourceGroup(ctx, rgClient, integrationTestResourceGroup, integrationTestLocation)
61+
if err != nil {
62+
t.Fatalf("Failed to create resource group: %v", err)
63+
}
64+
65+
// Create network watcher - Azure only allows one per region per subscription
66+
err = createNetworkWatcher(ctx, networkWatchersClient, integrationTestResourceGroup, integrationTestNetworkWatcherTestName, integrationTestLocation)
67+
if err != nil {
68+
// If we hit the limit, it means a Network Watcher already exists in another RG
69+
if strings.Contains(err.Error(), "NetworkWatcherCountLimitReached") {
70+
t.Skipf("Skipping: Azure allows only one Network Watcher per region. One already exists: %v", err)
71+
}
72+
t.Fatalf("Failed to create network watcher: %v", err)
73+
}
74+
75+
// Wait for network watcher to be available
76+
err = waitForNetworkWatcherAvailable(ctx, networkWatchersClient, integrationTestResourceGroup, integrationTestNetworkWatcherTestName)
77+
if err != nil {
78+
t.Fatalf("Failed waiting for network watcher: %v", err)
79+
}
80+
81+
setupCompleted = true
82+
})
83+
84+
t.Run("Run", func(t *testing.T) {
85+
if !setupCompleted {
86+
t.Skip("Skipping Run: Setup did not complete successfully")
87+
}
88+
89+
t.Run("GetNetworkWatcher", func(t *testing.T) {
90+
ctx := t.Context()
91+
92+
log.Printf("Retrieving network watcher %s in subscription %s, resource group %s",
93+
integrationTestNetworkWatcherTestName, subscriptionID, integrationTestResourceGroup)
94+
95+
wrapper := manual.NewNetworkNetworkWatcher(
96+
clients.NewNetworkWatchersClient(networkWatchersClient),
97+
[]azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)},
98+
)
99+
scope := wrapper.Scopes()[0]
100+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
101+
102+
sdpItem, qErr := adapter.Get(ctx, scope, integrationTestNetworkWatcherTestName, true)
103+
if qErr != nil {
104+
t.Fatalf("Expected no error, got: %v", qErr)
105+
}
106+
107+
if sdpItem == nil {
108+
t.Fatalf("Expected sdpItem to be non-nil")
109+
}
110+
111+
uniqueAttrKey := sdpItem.GetUniqueAttribute()
112+
uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey)
113+
if err != nil {
114+
t.Fatalf("Failed to get unique attribute: %v", err)
115+
}
116+
117+
if uniqueAttrValue != integrationTestNetworkWatcherTestName {
118+
t.Fatalf("Expected unique attribute value to be %s, got %s", integrationTestNetworkWatcherTestName, uniqueAttrValue)
119+
}
120+
121+
log.Printf("Successfully retrieved network watcher %s", integrationTestNetworkWatcherTestName)
122+
})
123+
124+
t.Run("ListNetworkWatchers", func(t *testing.T) {
125+
ctx := t.Context()
126+
127+
log.Printf("Listing network watchers in subscription %s, resource group %s",
128+
subscriptionID, integrationTestResourceGroup)
129+
130+
wrapper := manual.NewNetworkNetworkWatcher(
131+
clients.NewNetworkWatchersClient(networkWatchersClient),
132+
[]azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)},
133+
)
134+
scope := wrapper.Scopes()[0]
135+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
136+
137+
listable, ok := adapter.(discovery.ListableAdapter)
138+
if !ok {
139+
t.Fatalf("Adapter does not support List operation")
140+
}
141+
142+
sdpItems, err := listable.List(ctx, scope, true)
143+
if err != nil {
144+
t.Fatalf("Failed to list network watchers: %v", err)
145+
}
146+
147+
if len(sdpItems) < 1 {
148+
t.Fatalf("Expected at least one network watcher, got %d", len(sdpItems))
149+
}
150+
151+
var found bool
152+
for _, item := range sdpItems {
153+
uniqueAttrKey := item.GetUniqueAttribute()
154+
if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil && v == integrationTestNetworkWatcherTestName {
155+
found = true
156+
break
157+
}
158+
}
159+
160+
if !found {
161+
t.Fatalf("Expected to find network watcher %s in the list", integrationTestNetworkWatcherTestName)
162+
}
163+
164+
log.Printf("Found %d network watchers in resource group %s", len(sdpItems), integrationTestResourceGroup)
165+
})
166+
167+
t.Run("VerifyLinkedItems", func(t *testing.T) {
168+
ctx := t.Context()
169+
170+
log.Printf("Verifying linked items for network watcher %s", integrationTestNetworkWatcherTestName)
171+
172+
wrapper := manual.NewNetworkNetworkWatcher(
173+
clients.NewNetworkWatchersClient(networkWatchersClient),
174+
[]azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)},
175+
)
176+
scope := wrapper.Scopes()[0]
177+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
178+
179+
sdpItem, qErr := adapter.Get(ctx, scope, integrationTestNetworkWatcherTestName, true)
180+
if qErr != nil {
181+
t.Fatalf("Expected no error, got: %v", qErr)
182+
}
183+
184+
linkedQueries := sdpItem.GetLinkedItemQueries()
185+
186+
for _, query := range linkedQueries {
187+
q := query.GetQuery()
188+
if q == nil {
189+
t.Error("LinkedItemQuery has nil Query")
190+
continue
191+
}
192+
193+
if q.GetType() == "" {
194+
t.Error("LinkedItemQuery has empty Type")
195+
}
196+
197+
if q.GetMethod() != sdp.QueryMethod_GET && q.GetMethod() != sdp.QueryMethod_SEARCH {
198+
t.Errorf("LinkedItemQuery has invalid Method: %v", q.GetMethod())
199+
}
200+
201+
if q.GetQuery() == "" {
202+
t.Error("LinkedItemQuery has empty Query")
203+
}
204+
205+
if q.GetScope() == "" {
206+
t.Error("LinkedItemQuery has empty Scope")
207+
}
208+
}
209+
210+
log.Printf("Verified %d linked item queries for network watcher %s", len(linkedQueries), integrationTestNetworkWatcherTestName)
211+
})
212+
213+
t.Run("VerifyItemAttributes", func(t *testing.T) {
214+
ctx := t.Context()
215+
216+
log.Printf("Verifying item attributes for network watcher %s", integrationTestNetworkWatcherTestName)
217+
218+
wrapper := manual.NewNetworkNetworkWatcher(
219+
clients.NewNetworkWatchersClient(networkWatchersClient),
220+
[]azureshared.ResourceGroupScope{azureshared.NewResourceGroupScope(subscriptionID, integrationTestResourceGroup)},
221+
)
222+
scope := wrapper.Scopes()[0]
223+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
224+
225+
sdpItem, qErr := adapter.Get(ctx, scope, integrationTestNetworkWatcherTestName, true)
226+
if qErr != nil {
227+
t.Fatalf("Expected no error, got: %v", qErr)
228+
}
229+
230+
if sdpItem.GetType() != azureshared.NetworkNetworkWatcher.String() {
231+
t.Errorf("Expected item type %s, got %s", azureshared.NetworkNetworkWatcher, sdpItem.GetType())
232+
}
233+
234+
expectedScope := fmt.Sprintf("%s.%s", subscriptionID, integrationTestResourceGroup)
235+
if sdpItem.GetScope() != expectedScope {
236+
t.Errorf("Expected scope %s, got %s", expectedScope, sdpItem.GetScope())
237+
}
238+
239+
if sdpItem.GetUniqueAttribute() != "name" {
240+
t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute())
241+
}
242+
243+
if err := sdpItem.Validate(); err != nil {
244+
t.Fatalf("Item validation failed: %v", err)
245+
}
246+
247+
log.Printf("Verified item attributes for network watcher %s", integrationTestNetworkWatcherTestName)
248+
})
249+
})
250+
251+
t.Run("Teardown", func(t *testing.T) {
252+
ctx := t.Context()
253+
254+
// Delete the network watcher we created
255+
err := deleteNetworkWatcher(ctx, networkWatchersClient, integrationTestResourceGroup, integrationTestNetworkWatcherTestName)
256+
if err != nil {
257+
t.Logf("Warning: Failed to delete network watcher %s: %v", integrationTestNetworkWatcherTestName, err)
258+
}
259+
})
260+
}
261+
262+
func createNetworkWatcher(ctx context.Context, client *armnetwork.WatchersClient, resourceGroup, name, location string) error {
263+
_, err := client.Get(ctx, resourceGroup, name, nil)
264+
if err == nil {
265+
log.Printf("Network watcher %s already exists, skipping creation", name)
266+
return nil
267+
}
268+
269+
result, err := client.CreateOrUpdate(ctx, resourceGroup, name, armnetwork.Watcher{
270+
Location: &location,
271+
Tags: map[string]*string{
272+
"purpose": new("overmind-integration-tests"),
273+
},
274+
}, nil)
275+
if err != nil {
276+
var respErr *azcore.ResponseError
277+
if errors.As(err, &respErr) && respErr.StatusCode == http.StatusConflict {
278+
if _, getErr := client.Get(ctx, resourceGroup, name, nil); getErr == nil {
279+
log.Printf("Network watcher %s already exists (conflict), skipping", name)
280+
return nil
281+
}
282+
return fmt.Errorf("network watcher %s conflict but not retrievable: %w", name, err)
283+
}
284+
return fmt.Errorf("failed to create network watcher: %w", err)
285+
}
286+
287+
log.Printf("Network watcher %s created: %v", name, result.Watcher.Name)
288+
return nil
289+
}
290+
291+
func waitForNetworkWatcherAvailable(ctx context.Context, client *armnetwork.WatchersClient, resourceGroup, name string) error {
292+
maxAttempts := 20
293+
pollInterval := 5 * time.Second
294+
maxNotFoundAttempts := 5
295+
notFoundCount := 0
296+
297+
for attempt := 1; attempt <= maxAttempts; attempt++ {
298+
resp, err := client.Get(ctx, resourceGroup, name, nil)
299+
if err != nil {
300+
var respErr *azcore.ResponseError
301+
if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound {
302+
notFoundCount++
303+
if notFoundCount >= maxNotFoundAttempts {
304+
return fmt.Errorf("network watcher %s not found after %d attempts", name, notFoundCount)
305+
}
306+
time.Sleep(pollInterval)
307+
continue
308+
}
309+
return fmt.Errorf("error checking network watcher: %w", err)
310+
}
311+
notFoundCount = 0
312+
if resp.Properties != nil && resp.Properties.ProvisioningState != nil && *resp.Properties.ProvisioningState == armnetwork.ProvisioningStateSucceeded {
313+
log.Printf("Network watcher %s is available", name)
314+
return nil
315+
}
316+
time.Sleep(pollInterval)
317+
}
318+
return fmt.Errorf("timeout waiting for network watcher %s", name)
319+
}
320+
321+
func deleteNetworkWatcher(ctx context.Context, client *armnetwork.WatchersClient, resourceGroup, name string) error {
322+
poller, err := client.BeginDelete(ctx, resourceGroup, name, nil)
323+
if err != nil {
324+
var respErr *azcore.ResponseError
325+
if errors.As(err, &respErr) && respErr.StatusCode == http.StatusNotFound {
326+
log.Printf("Network watcher %s already deleted", name)
327+
return nil
328+
}
329+
return fmt.Errorf("failed to begin delete network watcher: %w", err)
330+
}
331+
332+
_, err = poller.PollUntilDone(ctx, nil)
333+
if err != nil {
334+
return fmt.Errorf("failed to delete network watcher: %w", err)
335+
}
336+
337+
log.Printf("Network watcher %s deleted successfully", name)
338+
return nil
339+
}

0 commit comments

Comments
 (0)