diff --git a/CLAUDE.md b/CLAUDE.md index 0dcfff0..4463adc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`core/go-infra` provides infrastructure provider API clients (Hetzner Cloud, Hetzner Robot, CloudNS) and a YAML-based infrastructure configuration parser. Zero framework dependencies — stdlib + yaml only. +`core/go-infra` provides infrastructure provider API clients (Hetzner Cloud, Hetzner Robot, CloudNS) and a YAML-based infrastructure configuration parser. Dependencies: `go-log` (error handling), `go-io` (file I/O), `yaml.v3`, and `testify` (tests). ## Build & Test @@ -35,6 +35,8 @@ These are subcommands for the parent `core` CLI, registered via `cli.RegisterCom ## Coding Standards - UK English in comments and strings +- **Error handling**: Use `coreerr.E()` from `go-log`, never `fmt.Errorf` or `errors.New` +- **File I/O**: Use `coreio.Local.Read()` from `go-io`, never `os.ReadFile` - Tests use `testify` (`assert` + `require`) - Test naming: `TestType_Method_Good`, `TestType_Method_Bad`, `TestType_Method_Ugly` suffixes (Good = happy path, Bad = expected errors, Ugly = edge cases) - Tests use `httptest.NewServer` for HTTP mocking — no mock libraries diff --git a/cloudns_test.go b/cloudns_test.go index 7fbf2b1..3df7593 100644 --- a/cloudns_test.go +++ b/cloudns_test.go @@ -517,6 +517,172 @@ func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) { assert.Empty(t, records) } +// --- UpdateRecord round-trip --- + +func TestCloudNSClient_UpdateRecord_Good_ViaDoRaw(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "example.com", r.URL.Query().Get("domain-name")) + assert.Equal(t, "42", r.URL.Query().Get("record-id")) + assert.Equal(t, "www", r.URL.Query().Get("host")) + assert.Equal(t, "A", r.URL.Query().Get("record-type")) + assert.Equal(t, "5.6.7.8", r.URL.Query().Get("record")) + assert.Equal(t, "3600", r.URL.Query().Get("ttl")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was updated successfully."}`)) + })) + defer ts.Close() + + client := NewCloudNSClient("12345", "secret") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("cloudns API"), + WithRetry(RetryConfig{}), + ) + + err := client.UpdateRecord(context.Background(), "example.com", "42", "www", "A", "5.6.7.8", 3600) + require.NoError(t, err) +} + +func TestCloudNSClient_UpdateRecord_Bad_FailedStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Record not found."}`)) + })) + defer ts.Close() + + client := NewCloudNSClient("12345", "secret") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("cloudns API"), + WithRetry(RetryConfig{}), + ) + + err := client.UpdateRecord(context.Background(), "example.com", "999", "www", "A", "5.6.7.8", 3600) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Record not found") +} + +// --- EnsureRecord round-trip --- + +func TestCloudNSClient_EnsureRecord_Good_AlreadyCorrect(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}}`)) + })) + defer ts.Close() + + client := NewCloudNSClient("12345", "secret") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("cloudns API"), + WithRetry(RetryConfig{}), + ) + + changed, err := client.EnsureRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600) + require.NoError(t, err) + assert.False(t, changed, "should not change when record already correct") +} + +func TestCloudNSClient_EnsureRecord_Good_NeedsUpdate(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + // ListRecords — returns existing record with old value + _, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}}`)) + } else { + // UpdateRecord + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "5.6.7.8", r.URL.Query().Get("record")) + _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was updated successfully."}`)) + } + })) + defer ts.Close() + + client := NewCloudNSClient("12345", "secret") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("cloudns API"), + WithRetry(RetryConfig{}), + ) + + changed, err := client.EnsureRecord(context.Background(), "example.com", "www", "A", "5.6.7.8", 3600) + require.NoError(t, err) + assert.True(t, changed, "should change when record needs update") +} + +func TestCloudNSClient_EnsureRecord_Good_NeedsCreate(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + // ListRecords — no matching record + _, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"other","record":"1.2.3.4","ttl":"3600","status":1}}`)) + } else { + // CreateRecord + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "www", r.URL.Query().Get("host")) + _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":99}}`)) + } + })) + defer ts.Close() + + client := NewCloudNSClient("12345", "secret") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("cloudns API"), + WithRetry(RetryConfig{}), + ) + + changed, err := client.EnsureRecord(context.Background(), "example.com", "www", "A", "1.2.3.4", 3600) + require.NoError(t, err) + assert.True(t, changed, "should change when record needs to be created") +} + +// --- ClearACMEChallenge round-trip --- + +func TestCloudNSClient_ClearACMEChallenge_Good_ViaDoRaw(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + if callCount == 1 { + // ListRecords — returns ACME challenge records + _, _ = w.Write([]byte(`{ + "1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}, + "2":{"id":"2","type":"TXT","host":"_acme-challenge","record":"token1","ttl":"60","status":1} + }`)) + } else { + // DeleteRecord + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "2", r.URL.Query().Get("record-id")) + _, _ = w.Write([]byte(`{"status":"Success","statusDescription":"The record was deleted successfully."}`)) + } + })) + defer ts.Close() + + client := NewCloudNSClient("12345", "secret") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("cloudns API"), + WithRetry(RetryConfig{}), + ) + + err := client.ClearACMEChallenge(context.Background(), "example.com") + require.NoError(t, err) + assert.GreaterOrEqual(t, callCount, 2, "should have called list + delete") +} + func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "49500", r.URL.Query().Get("auth-id")) diff --git a/config_test.go b/config_test.go index 1ec8b59..b10955f 100644 --- a/config_test.go +++ b/config_test.go @@ -79,6 +79,55 @@ func TestLoad_Ugly(t *testing.T) { } } +func TestConfig_HostsByRole_Good(t *testing.T) { + cfg := &Config{ + Hosts: map[string]*Host{ + "de": {FQDN: "de.example.com", Role: "app"}, + "de2": {FQDN: "de2.example.com", Role: "app"}, + "noc": {FQDN: "noc.example.com", Role: "bastion"}, + "build": {FQDN: "build.example.com", Role: "builder"}, + }, + } + + apps := cfg.HostsByRole("app") + if len(apps) != 2 { + t.Errorf("HostsByRole(app) = %d, want 2", len(apps)) + } + if _, ok := apps["de"]; !ok { + t.Error("expected de in app hosts") + } + if _, ok := apps["de2"]; !ok { + t.Error("expected de2 in app hosts") + } + + bastions := cfg.HostsByRole("bastion") + if len(bastions) != 1 { + t.Errorf("HostsByRole(bastion) = %d, want 1", len(bastions)) + } + + empty := cfg.HostsByRole("nonexistent") + if len(empty) != 0 { + t.Errorf("HostsByRole(nonexistent) = %d, want 0", len(empty)) + } +} + +func TestConfig_AppServers_Good(t *testing.T) { + cfg := &Config{ + Hosts: map[string]*Host{ + "de": {FQDN: "de.example.com", Role: "app"}, + "noc": {FQDN: "noc.example.com", Role: "bastion"}, + }, + } + + apps := cfg.AppServers() + if len(apps) != 1 { + t.Errorf("AppServers() = %d, want 1", len(apps)) + } + if _, ok := apps["de"]; !ok { + t.Error("expected de in AppServers()") + } +} + func TestExpandPath(t *testing.T) { home, _ := os.UserHomeDir() diff --git a/hetzner_test.go b/hetzner_test.go index 42fb892..cb2ad46 100644 --- a/hetzner_test.go +++ b/hetzner_test.go @@ -221,6 +221,169 @@ func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) { assert.Contains(t, err.Error(), "hrobot API: HTTP 401") } +func TestHCloudClient_ListLoadBalancers_Good(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/load_balancers", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"load_balancers":[{"id":789,"name":"hermes","public_net":{"enabled":true,"ipv4":{"ip":"5.6.7.8"}}}]}`)) + })) + defer ts.Close() + + client := NewHCloudClient("test-token") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("hcloud API"), + WithRetry(RetryConfig{}), + ) + + lbs, err := client.ListLoadBalancers(context.Background()) + require.NoError(t, err) + require.Len(t, lbs, 1) + assert.Equal(t, "hermes", lbs[0].Name) + assert.Equal(t, 789, lbs[0].ID) +} + +func TestHCloudClient_GetLoadBalancer_Good(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/load_balancers/789", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"load_balancer":{"id":789,"name":"hermes","public_net":{"enabled":true,"ipv4":{"ip":"5.6.7.8"}}}}`)) + })) + defer ts.Close() + + client := NewHCloudClient("test-token") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("hcloud API"), + WithRetry(RetryConfig{}), + ) + + lb, err := client.GetLoadBalancer(context.Background(), 789) + require.NoError(t, err) + assert.Equal(t, "hermes", lb.Name) +} + +func TestHCloudClient_CreateLoadBalancer_Good(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/load_balancers", r.URL.Path) + + var body HCloudLBCreateRequest + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "hermes", body.Name) + assert.Equal(t, "lb11", body.LoadBalancerType) + assert.Equal(t, "round_robin", body.Algorithm.Type) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"load_balancer":{"id":789,"name":"hermes","public_net":{"enabled":true,"ipv4":{"ip":"5.6.7.8"}}}}`)) + })) + defer ts.Close() + + client := NewHCloudClient("test-token") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithAuth(func(req *http.Request) { + req.Header.Set("Authorization", "Bearer test-token") + }), + WithPrefix("hcloud API"), + WithRetry(RetryConfig{}), + ) + + lb, err := client.CreateLoadBalancer(context.Background(), HCloudLBCreateRequest{ + Name: "hermes", + LoadBalancerType: "lb11", + Location: "fsn1", + Algorithm: HCloudLBAlgorithm{Type: "round_robin"}, + }) + require.NoError(t, err) + assert.Equal(t, "hermes", lb.Name) + assert.Equal(t, 789, lb.ID) +} + +func TestHCloudClient_DeleteLoadBalancer_Good(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/load_balancers/789", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + client := NewHCloudClient("test-token") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithPrefix("hcloud API"), + WithRetry(RetryConfig{}), + ) + + err := client.DeleteLoadBalancer(context.Background(), 789) + assert.NoError(t, err) +} + +func TestHCloudClient_CreateSnapshot_Good(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/servers/123/actions/create_image", r.URL.Path) + + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "daily backup", body["description"]) + assert.Equal(t, "snapshot", body["type"]) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"image":{"id":456}}`)) + })) + defer ts.Close() + + client := NewHCloudClient("test-token") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithAuth(func(req *http.Request) { + req.Header.Set("Authorization", "Bearer test-token") + }), + WithPrefix("hcloud API"), + WithRetry(RetryConfig{}), + ) + + err := client.CreateSnapshot(context.Background(), 123, "daily backup") + assert.NoError(t, err) +} + +func TestHRobotClient_GetServer_Good(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/server/1.2.3.4", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"server":{"server_ip":"1.2.3.4","server_name":"noc","product":"EX44","dc":"FSN1","status":"ready","cancelled":false}}`)) + })) + defer ts.Close() + + client := NewHRobotClient("testuser", "testpass") + client.baseURL = ts.URL + client.api = NewAPIClient( + WithHTTPClient(ts.Client()), + WithAuth(func(req *http.Request) { + req.SetBasicAuth("testuser", "testpass") + }), + WithPrefix("hrobot API"), + WithRetry(RetryConfig{}), + ) + + server, err := client.GetServer(context.Background(), "1.2.3.4") + require.NoError(t, err) + assert.Equal(t, "noc", server.ServerName) + assert.Equal(t, "EX44", server.Product) +} + // --- Type serialisation --- func TestHCloudServer_JSON_Good(t *testing.T) {