From be72380cdbfff84c39b89696ae741295591fff0b Mon Sep 17 00:00:00 2001 From: Haider Bari Date: Thu, 23 Jan 2025 00:05:57 +0000 Subject: [PATCH] "force-unlock" support for the HTTP backend (#2381) Signed-off-by: Bari, Haider Co-authored-by: Bari, Haider --- internal/backend/remote-state/http/client.go | 20 ++- .../backend/remote-state/http/client_test.go | 124 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/internal/backend/remote-state/http/client.go b/internal/backend/remote-state/http/client.go index 17e017c111..fa2a549245 100644 --- a/internal/backend/remote-state/http/client.go +++ b/internal/backend/remote-state/http/client.go @@ -144,7 +144,25 @@ func (c *httpClient) Unlock(id string) error { return nil } - resp, err := c.httpRequest(c.UnlockMethod, c.UnlockURL, c.jsonLockInfo, "unlock") + var lockInfo statemgr.LockInfo + + // force unlock command does not instantiate statemgr.LockInfo + // which means that c.jsonLockInfo will be nil + if c.jsonLockInfo != nil { + if err := json.Unmarshal(c.jsonLockInfo, &lockInfo); err != nil { //nolint:musttag // for now add musttag until we fully adopt the linting rules + return fmt.Errorf("failed to unmarshal jsonLockInfo: %w", err) + } + if lockInfo.ID != id { + return &statemgr.LockError{ + Info: &lockInfo, + Err: fmt.Errorf("lock id %q does not match existing lock", id), + } + } + } + + lockInfo.ID = id + + resp, err := c.httpRequest(c.UnlockMethod, c.UnlockURL, lockInfo.Marshal(), "unlock") if err != nil { return err } diff --git a/internal/backend/remote-state/http/client_test.go b/internal/backend/remote-state/http/client_test.go index db34109981..5f50001718 100644 --- a/internal/backend/remote-state/http/client_test.go +++ b/internal/backend/remote-state/http/client_test.go @@ -254,6 +254,130 @@ func TestHttpClient_IsLockingEnabled(t *testing.T) { } } +// Tests the UnLock method for the HTTP client. +func TestHttpClient_Unlock(t *testing.T) { + stateLockInfoA := statemgr.LockInfo{ + ID: "bjarne-stroustrup-state-lock-id", + Who: "BjarneStroustrup", + Operation: "TestTypePlan", + Created: time.Date(2023, time.August, 21, 15, 9, 26, 0, time.UTC), + } + + stateLockInfoB := statemgr.LockInfo{ + ID: "edsger-dijkstra-state-lock-id", + } + + testCases := []struct { + name string + lockID string + jsonLockInfo []byte + lockResponseStatus int + lockResponseBody []byte + expectedErrorMsg error + expectedPayload []byte + }{ + { + // Successful unlocking HTTP remote state + name: "Successfully unlocked", + lockID: stateLockInfoA.ID, + jsonLockInfo: stateLockInfoA.Marshal(), + lockResponseStatus: http.StatusOK, + lockResponseBody: nil, + expectedErrorMsg: nil, + expectedPayload: stateLockInfoA.Marshal(), + }, + { + // Lock ID parameter does not match with LockInfo object Lock ID + name: "Lock ID's don't match", + lockID: stateLockInfoB.ID, + jsonLockInfo: stateLockInfoA.Marshal(), + lockResponseStatus: 0, + lockResponseBody: nil, + expectedErrorMsg: &statemgr.LockError{ + Info: &stateLockInfoA, + Err: fmt.Errorf("lock id %q does not match existing lock", stateLockInfoB.ID), + }, + expectedPayload: nil, + }, + { + // Failed unmarshal jsonLockInfo into LockInfo object + name: "Failed to unmarshal jsonLockInfo", + lockID: stateLockInfoA.ID, + jsonLockInfo: []byte("Simplicity is prerequisite for reliability."), + lockResponseStatus: 0, + lockResponseBody: nil, + expectedErrorMsg: fmt.Errorf("failed to unmarshal jsonLockInfo: invalid character 'S' looking for beginning of value"), + expectedPayload: nil, + }, + { + // Force unlock command being executed + name: "Successful force unlock", + lockID: stateLockInfoB.ID, + jsonLockInfo: nil, + lockResponseStatus: http.StatusOK, + lockResponseBody: nil, + expectedErrorMsg: nil, + expectedPayload: stateLockInfoB.Marshal(), + }, + { + // Force unlock command being executed + name: "Unsuccessful force unlock", + lockID: stateLockInfoB.ID, + jsonLockInfo: nil, + lockResponseStatus: http.StatusNotFound, + lockResponseBody: nil, + expectedErrorMsg: fmt.Errorf("Unexpected HTTP response code %d", http.StatusNotFound), + expectedPayload: stateLockInfoB.Marshal(), + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + var receivedPayload []byte + handler := func(w http.ResponseWriter, r *http.Request) { + receivedPayload, _ = io.ReadAll(r.Body) + w.WriteHeader(tt.lockResponseStatus) + _, err := w.Write(tt.lockResponseBody) + if err != nil { + t.Fatalf("Failed to write response body: %v", err) + } + } + + ts := httptest.NewServer(http.HandlerFunc(handler)) + defer ts.Close() + + unlockURL, err := url.Parse(ts.URL) + if err != nil { + t.Fatalf("Failed to parse lockURL: %v", err) + } + + client := &httpClient{ + UnlockURL: unlockURL, + LockMethod: "UNLOCK", + Client: retryablehttp.NewClient(), + jsonLockInfo: tt.jsonLockInfo, + } + + err = client.Unlock(tt.lockID) + if tt.expectedErrorMsg != nil && err == nil { + // no expected error + t.Errorf("UnLock() no expected error = %v", tt.expectedErrorMsg) + } + if tt.expectedErrorMsg == nil && err != nil { + // unexpected error + t.Errorf("UnLock() unexpected error = %v", err) + } + if tt.expectedErrorMsg != nil && err.Error() != tt.expectedErrorMsg.Error() { + // mismatched errors + t.Errorf("UnLock() error = %v, want %v", err, tt.expectedErrorMsg) + } + if !bytes.Equal(receivedPayload, tt.expectedPayload) { + t.Errorf("UnLock() payload = %v, want %v", receivedPayload, tt.expectedPayload) + } + }) + } +} + // Tests the Lock method for the HTTP client. // Test to see correct lock info is returned func TestHttpClient_lock(t *testing.T) {