diff --git a/diode-server/netboxdiodeplugin/client.go b/diode-server/netboxdiodeplugin/client.go index 766281ad..7ecc4a04 100644 --- a/diode-server/netboxdiodeplugin/client.go +++ b/diode-server/netboxdiodeplugin/client.go @@ -1,6 +1,7 @@ package netboxdiodeplugin import ( + "bytes" "context" "encoding/json" "errors" @@ -203,6 +204,74 @@ func (c *Client) RetrieveDcimDeviceState(ctx context.Context, objectID int, quer }, nil } +// ApplyChangeSet applies a change set +func (c *Client) ApplyChangeSet(ctx context.Context, changeSet ChangeSetRequest) (*ChangeSetResponse, error) { + endpointURL, err := url.Parse(fmt.Sprintf("%s/apply-change-set", c.baseURL.String())) + if err != nil { + return nil, err + } + + reqBody, err := json.Marshal(changeSet) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL.String(), bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + c.logger.Warn("failed to close response body", "error", closeErr) + } + }() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body %w", err) + } + + var changeSetResponse ChangeSetResponse + if err = json.Unmarshal(respBytes, &changeSetResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response body %w", err) + } + + if resp.StatusCode != http.StatusOK { + c.logger.Debug(fmt.Sprintf("request POST %s failed", req.URL.String()), "statusCode", resp.StatusCode, "response", changeSetResponse) + return &changeSetResponse, fmt.Errorf("request POST %s failed - %q", req.URL.String(), resp.Status) + } + return &changeSetResponse, nil +} + +// ChangeSetRequest represents a apply change set request +type ChangeSetRequest struct { + ChangeSetID string `json:"change_set_id"` + ChangeSet []Change `json:"change_set"` +} + +// Change represents a change to apply +type Change struct { + ChangeID string `json:"change_id"` + ChangeType string `json:"change_type"` + ObjectType string `json:"object_type"` + ObjectID *int `json:"object_id,omitempty"` + ObjectVersion *int `json:"object_version,omitempty"` + Data any `json:"data"` +} + +// ChangeSetResponse represents an apply change set response +type ChangeSetResponse struct { + ChangeSetID string `json:"change_set_id"` + Result string `json:"result"` + Errors []string `json:"errors"` +} + // DcimDevice represents a DCIM device type DcimDevice struct { ID int `json:"id"` diff --git a/diode-server/netboxdiodeplugin/client_test.go b/diode-server/netboxdiodeplugin/client_test.go index 4c38453e..ed1bf8bd 100644 --- a/diode-server/netboxdiodeplugin/client_test.go +++ b/diode-server/netboxdiodeplugin/client_test.go @@ -133,7 +133,7 @@ func TestRetrieveDcimDeviceState(t *testing.T) { { name: "invalid server response", objectID: 100, - apiKey: "bardfoo", + apiKey: "barfoo", mockServerResponse: ``, shouldError: true, }, @@ -174,7 +174,115 @@ func TestRetrieveDcimDeviceState(t *testing.T) { } } +func TestApplyChangeSet(t *testing.T) { + tests := []struct { + name string + apiKey string + changeSetRequest netboxdiodeplugin.ChangeSetRequest + mockServerResponse string + mockStatusCode int + response any + shouldError bool + }{ + { + name: "valid apply change set response", + apiKey: "foobar", + changeSetRequest: netboxdiodeplugin.ChangeSetRequest{ + ChangeSetID: "00000000-0000-0000-0000-000000000000", + ChangeSet: []netboxdiodeplugin.Change{ + { + ChangeID: "00000000-0000-0000-0000-000000000001", + ChangeType: "create", + ObjectType: "dcim.device", + ObjectID: nil, + ObjectVersion: nil, + Data: &netboxdiodeplugin.DcimDevice{ + Name: "test", + }, + }, + { + ChangeID: "00000000-0000-0000-0000-000000000002", + ChangeType: "update", + ObjectType: "dcim.device", + ObjectID: ptrInt(1), + ObjectVersion: ptrInt(2), + Data: &netboxdiodeplugin.DcimDevice{ + Name: "test", + }, + }, + }, + }, + mockServerResponse: `{"change_set_id":"00000000-0000-0000-0000-000000000000","result":"success"}`, + mockStatusCode: http.StatusOK, + response: &netboxdiodeplugin.ChangeSetResponse{ + ChangeSetID: "00000000-0000-0000-0000-000000000000", + Result: "success", + }, + shouldError: false, + }, + { + name: "invalid request", + apiKey: "foobar", + changeSetRequest: netboxdiodeplugin.ChangeSetRequest{ + ChangeSetID: "00000000-0000-0000-0000-000000000000", + ChangeSet: []netboxdiodeplugin.Change{ + { + ChangeID: "00000000-0000-0000-0000-000000000001", + ChangeType: "create", + ObjectType: "", + ObjectID: nil, + ObjectVersion: nil, + Data: nil, + }, + }, + }, + mockServerResponse: `{"change_set_id":"00000000-0000-0000-0000-000000000000","result":"failure","errors":["invalid object type"]}`, + mockStatusCode: http.StatusBadRequest, + shouldError: true, + }, + } + + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanUpEnvVars() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost) + assert.Equal(t, r.URL.Path, "/api/diode/apply-change-set") + assert.Equal(t, r.Header.Get("Authorization"), fmt.Sprintf("Token %s", tt.apiKey)) + assert.Equal(t, r.Header.Get("User-Agent"), fmt.Sprintf("%s/%s", netboxdiodeplugin.SDKName, netboxdiodeplugin.SDKVersion)) + assert.Equal(t, r.Header.Get("Content-Type"), "application/json") + w.WriteHeader(tt.mockStatusCode) + _, _ = w.Write([]byte(tt.mockServerResponse)) + } + mux := http.NewServeMux() + mux.HandleFunc("/api/diode/apply-change-set", handler) + ts := httptest.NewServer(mux) + defer ts.Close() + + _ = os.Setenv(netboxdiodeplugin.BaseURLEnvVarName, fmt.Sprintf("%s/api/diode", ts.URL)) + + client, err := netboxdiodeplugin.NewClient(tt.apiKey, logger) + require.NoError(t, err) + resp, err := client.ApplyChangeSet(context.Background(), tt.changeSetRequest) + if tt.shouldError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.response, resp) + assert.Equal(t, tt.mockStatusCode, http.StatusOK) + }) + } +} + func cleanUpEnvVars() { _ = os.Unsetenv(netboxdiodeplugin.BaseURLEnvVarName) _ = os.Unsetenv(netboxdiodeplugin.TimeoutSecondsEnvVarName) } + +func ptrInt(i int) *int { + return &i +}