Skip to content

Commit 70edb43

Browse files
committed
feat: enhance resource synchronization with variable support
- Introduced a new resourceInput struct to handle resource variables. - Updated parseResources and validateResources functions to work with resourceInput. - Implemented syncResourceVariables function to manage variable updates with retry logic. - Adjusted NewSyncPipeCmd to accommodate new resource structure and variable handling.
1 parent 5f7d8a9 commit 70edb43

3 files changed

Lines changed: 220 additions & 9 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ func mapDevice(device netbox.DeviceWithConfigContext) api.ResourceProviderResour
126126
"name": device.GetName(),
127127
"device_type": device.DeviceType.GetDisplay(),
128128
"role": device.Role.GetName(),
129-
"site": map[string]any{
129+
"site": map[string]any{
130130
"id": site.Id,
131-
"url": site.Url,
131+
"url": site.Url,
132132
"slug": site.Slug,
133133
"name": site.Name,
134134
},

cmd/ctrlc/root/sync/pipe/pipe.go

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"io"
88
"os"
99
"strings"
10+
"time"
1011

1112
"github.com/MakeNowJust/heredoc/v2"
13+
"github.com/avast/retry-go"
1214
"github.com/charmbracelet/log"
1315
"github.com/ctrlplanedev/cli/internal/api"
1416
"github.com/ctrlplanedev/cli/internal/cliutil"
@@ -35,6 +37,10 @@ func NewSyncPipeCmd() *cobra.Command {
3537
$ echo '{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server"}' \
3638
| ctrlc sync pipe --provider "my-servers"
3739
40+
# Resource with variables
41+
$ echo '[{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server","variables":{"env":"prod","tier":"frontend"}}]' \
42+
| ctrlc sync pipe --provider "my-servers"
43+
3844
# From curl with jq transformation
3945
$ curl -s https://cmdb.internal/api/servers \
4046
| jq '[.[] | {name, identifier: .id, version: "cmdb/v1", kind: "Server", config: ., metadata: {}}]' \
@@ -60,16 +66,17 @@ func NewSyncPipeCmd() *cobra.Command {
6066
}
6167

6268
// Parse JSON -- try array first, then single object
63-
resources, err := parseResources(data)
69+
resourceInputs, err := parseResources(data)
6470
if err != nil {
6571
return err
6672
}
6773

6874
// Validate required fields
69-
if err := validateResources(resources); err != nil {
75+
if err := validateResources(resourceInputs); err != nil {
7076
return err
7177
}
7278

79+
resources := toAPIResources(resourceInputs)
7380
log.Info("Syncing resources from stdin", "count", len(resources), "provider", providerName)
7481

7582
// Create API client
@@ -94,6 +101,11 @@ func NewSyncPipeCmd() *cobra.Command {
94101
return fmt.Errorf("failed to upsert resources: %w", err)
95102
}
96103

104+
workspaceID := ctrlplaneClient.GetWorkspaceID(ctx, workspace).String()
105+
if err := syncResourceVariables(ctx, ctrlplaneClient, workspaceID, resourceInputs); err != nil {
106+
return err
107+
}
108+
97109
log.Info("Response from upserting resources", "status", upsertResp.Status)
98110

99111
return cliutil.HandleResponseOutput(cmd, upsertResp)
@@ -106,20 +118,79 @@ func NewSyncPipeCmd() *cobra.Command {
106118
return cmd
107119
}
108120

121+
type resourceInput struct {
122+
Name string `json:"name"`
123+
Identifier string `json:"identifier"`
124+
Version string `json:"version"`
125+
Kind string `json:"kind"`
126+
Config map[string]any `json:"config"`
127+
Metadata map[string]string `json:"metadata"`
128+
Variables map[string]any `json:"-"`
129+
130+
hasVariables bool `json:"-"`
131+
}
132+
133+
func (r *resourceInput) UnmarshalJSON(data []byte) error {
134+
type resourceInputAlias struct {
135+
Name string `json:"name"`
136+
Identifier string `json:"identifier"`
137+
Version string `json:"version"`
138+
Kind string `json:"kind"`
139+
Config map[string]interface{} `json:"config"`
140+
Metadata map[string]string `json:"metadata"`
141+
}
142+
143+
var alias resourceInputAlias
144+
if err := json.Unmarshal(data, &alias); err != nil {
145+
return err
146+
}
147+
148+
var raw map[string]json.RawMessage
149+
if err := json.Unmarshal(data, &raw); err != nil {
150+
return err
151+
}
152+
153+
r.Name = alias.Name
154+
r.Identifier = alias.Identifier
155+
r.Version = alias.Version
156+
r.Kind = alias.Kind
157+
r.Config = alias.Config
158+
r.Metadata = alias.Metadata
159+
160+
varRaw, ok := raw["variables"]
161+
if !ok {
162+
r.hasVariables = false
163+
return nil
164+
}
165+
166+
r.hasVariables = true
167+
if string(varRaw) == "null" {
168+
r.Variables = map[string]any{}
169+
return nil
170+
}
171+
172+
var vars map[string]any
173+
if err := json.Unmarshal(varRaw, &vars); err != nil {
174+
return fmt.Errorf("invalid variables field: must be a JSON object")
175+
}
176+
r.Variables = vars
177+
return nil
178+
}
179+
109180
// parseResources attempts to parse the raw JSON data as either an array of
110181
// resources or a single resource object. A single object is normalized to a
111182
// one-element array.
112-
func parseResources(data []byte) ([]api.ResourceProviderResource, error) {
183+
func parseResources(data []byte) ([]resourceInput, error) {
113184
// Try array first
114-
var resources []api.ResourceProviderResource
185+
var resources []resourceInput
115186
if err := json.Unmarshal(data, &resources); err == nil {
116187
return resources, nil
117188
}
118189

119190
// Try single object
120-
var single api.ResourceProviderResource
191+
var single resourceInput
121192
if err := json.Unmarshal(data, &single); err == nil {
122-
return []api.ResourceProviderResource{single}, nil
193+
return []resourceInput{single}, nil
123194
}
124195

125196
// Show a snippet of the input for debugging
@@ -130,9 +201,24 @@ func parseResources(data []byte) ([]api.ResourceProviderResource, error) {
130201
return nil, fmt.Errorf("invalid JSON input: %s", snippet)
131202
}
132203

204+
func toAPIResources(resources []resourceInput) []api.ResourceProviderResource {
205+
out := make([]api.ResourceProviderResource, 0, len(resources))
206+
for _, r := range resources {
207+
out = append(out, api.ResourceProviderResource{
208+
Name: r.Name,
209+
Identifier: r.Identifier,
210+
Version: r.Version,
211+
Kind: r.Kind,
212+
Config: r.Config,
213+
Metadata: r.Metadata,
214+
})
215+
}
216+
return out
217+
}
218+
133219
// validateResources checks that each resource has the required fields:
134220
// Name, Identifier, Version, Kind.
135-
func validateResources(resources []api.ResourceProviderResource) error {
221+
func validateResources(resources []resourceInput) error {
136222
var errs []string
137223
for i, r := range resources {
138224
var missing []string
@@ -157,3 +243,56 @@ func validateResources(resources []api.ResourceProviderResource) error {
157243
}
158244
return nil
159245
}
246+
247+
func syncResourceVariables(
248+
ctx context.Context,
249+
client *api.ClientWithResponses,
250+
workspaceID string,
251+
resources []resourceInput,
252+
) error {
253+
for _, resource := range resources {
254+
if !resource.hasVariables {
255+
continue
256+
}
257+
258+
vars := resource.Variables
259+
if vars == nil {
260+
vars = map[string]any{}
261+
}
262+
263+
err := retry.Do(
264+
func() error {
265+
varsResp, err := client.RequestResourceVariablesUpdateWithResponse(
266+
ctx,
267+
workspaceID,
268+
resource.Identifier,
269+
api.RequestResourceVariablesUpdateJSONRequestBody(vars),
270+
)
271+
if err != nil {
272+
return retry.Unrecoverable(fmt.Errorf("failed to update resource variables for '%s': %w", resource.Identifier, err))
273+
}
274+
if varsResp == nil {
275+
return retry.Unrecoverable(fmt.Errorf("failed to update resource variables for '%s': empty response", resource.Identifier))
276+
}
277+
if varsResp.StatusCode() == 404 {
278+
return fmt.Errorf("resource '%s' not found yet, retrying", resource.Identifier)
279+
}
280+
if varsResp.StatusCode() != 204 {
281+
return retry.Unrecoverable(
282+
fmt.Errorf("failed to update resource variables for '%s': %s", resource.Identifier, string(varsResp.Body)),
283+
)
284+
}
285+
return nil
286+
},
287+
retry.Attempts(10),
288+
retry.Delay(100*time.Millisecond),
289+
retry.MaxDelay(15*time.Second),
290+
retry.DelayType(retry.BackOffDelay),
291+
)
292+
if err != nil {
293+
return err
294+
}
295+
}
296+
297+
return nil
298+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package pipe
2+
3+
import "testing"
4+
5+
func TestParseResources_ArrayWithVariables(t *testing.T) {
6+
input := []byte(`[
7+
{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server","variables":{"env":"prod"}},
8+
{"name":"web-2","identifier":"web-2-prod","version":"custom/v1","kind":"Server"}
9+
]`)
10+
11+
resources, err := parseResources(input)
12+
if err != nil {
13+
t.Fatalf("parseResources returned error: %v", err)
14+
}
15+
if len(resources) != 2 {
16+
t.Fatalf("expected 2 resources, got %d", len(resources))
17+
}
18+
19+
if !resources[0].hasVariables {
20+
t.Fatalf("expected resource[0] to have variables")
21+
}
22+
if resources[0].Variables["env"] != "prod" {
23+
t.Fatalf("expected resource[0].variables.env to be prod, got %#v", resources[0].Variables["env"])
24+
}
25+
26+
if resources[1].hasVariables {
27+
t.Fatalf("expected resource[1] to not have variables")
28+
}
29+
}
30+
31+
func TestParseResources_SingleObject(t *testing.T) {
32+
input := []byte(`{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server","variables":null}`)
33+
34+
resources, err := parseResources(input)
35+
if err != nil {
36+
t.Fatalf("parseResources returned error: %v", err)
37+
}
38+
if len(resources) != 1 {
39+
t.Fatalf("expected 1 resource, got %d", len(resources))
40+
}
41+
if !resources[0].hasVariables {
42+
t.Fatalf("expected resource to have variables present")
43+
}
44+
if resources[0].Variables == nil {
45+
t.Fatalf("expected null variables to normalize to empty map")
46+
}
47+
}
48+
49+
func TestParseResources_InvalidVariablesType(t *testing.T) {
50+
input := []byte(`{"name":"web-1","identifier":"web-1-prod","version":"custom/v1","kind":"Server","variables":"bad"}`)
51+
52+
_, err := parseResources(input)
53+
if err == nil {
54+
t.Fatalf("expected parseResources to fail for invalid variables type")
55+
}
56+
}
57+
58+
func TestValidateResources_UnchangedRequiredFields(t *testing.T) {
59+
resources := []resourceInput{
60+
{
61+
Name: "",
62+
Identifier: "id-1",
63+
Version: "",
64+
Kind: "Server",
65+
},
66+
}
67+
68+
err := validateResources(resources)
69+
if err == nil {
70+
t.Fatalf("expected validation to fail")
71+
}
72+
}

0 commit comments

Comments
 (0)