diff --git a/drivers/resipsgcp_dnsalias/main.go b/drivers/resipsgcp_dnsalias/main.go index 0cfb440d8..f3a292987 100644 --- a/drivers/resipsgcp_dnsalias/main.go +++ b/drivers/resipsgcp_dnsalias/main.go @@ -2,25 +2,298 @@ package resipsgcp_dnsalias import ( "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" "github.com/opensvc/om3/v3/core/datarecv" "github.com/opensvc/om3/v3/core/resource" "github.com/opensvc/om3/v3/core/status" + "github.com/opensvc/om3/v3/drivers/sgcphelper" + "github.com/opensvc/om3/v3/util/hostname" + "github.com/opensvc/om3/v3/util/httpclientcache" + "github.com/opensvc/om3/v3/util/sgcp" ) +const noneTarget = "none." + type ( + alias struct { + UUID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Target string `json:"target,omitempty"` + FQDN string `json:"fqdn,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + } + + aliasListResponse struct { + Aliases []alias `json:"aliases"` + } + + aliasManager struct { + alias alias + api *sgcp.DNSAPI + } + T struct { resource.T resource.Restart datarecv.DataRecv + + UUID string `json:"uuid,omitempty"` + Name string `json:"name,omitempty"` + Target string `json:"target,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + Secret string `json:"secret,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + + api *sgcp.DNSAPI + configured bool } ) -// New creates a new SGCP NFS filesystem resource driver func New() resource.Driver { return &T{} } +func (t *T) Configure() error { + return t.configure(context.Background()) +} + +func (t *T) configure(ctx context.Context) error { + if t.configured && t.api != nil { + return nil + } + + cfg := sgcp.GetConfig() + if cfg == nil { + return fmt.Errorf("mandatory sgcp config file is required: %s", sgcp.DefaultConfigPath) + } + if t.Target == "" { + t.Target = hostname.Hostname() + } + if t.Secret == "" { + t.Secret = cfg.GetDefaultSecret() + } + if t.Secret == "" { + return errors.New("secret is required") + } + if t.Endpoint == "" { + t.Endpoint = cfg.DNS.BaseURL + } + if t.Endpoint == "" { + return errors.New("endpoint is required") + } + if t.ZoneID == "" { + return errors.New("zone_id is required") + } + if t.UUID == "" && t.Name == "" { + return errors.New("alias need define at least name or uuid") + } + + httpClient, err := httpclientcache.Client(httpclientcache.Options{Timeout: 30 * time.Second}) + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + + authInfoer := &sgcphelper.GetAuthInfoFromDatastorePather{} + authInfo, err := authInfoer.GetAuthInfo(t.Secret) + if err != nil { + return fmt.Errorf("get auth info: %w", err) + } + + tokenFactory := sgcp.NewTokenFactory(t.Log(), httpClient, &cfg.Auth, authInfo) + + config := cfg.Clone() + if t.Endpoint != "" { + config.DNS.BaseURL = t.Endpoint + } + t.api = sgcp.NewDNSAPI(config, httpClient, t.Log(), tokenFactory) + t.configured = true + return nil +} + +func (t *T) Start(ctx context.Context) error { + mgr := &aliasManager{alias: alias{UUID: t.UUID, Name: t.Name, Target: t.Target, ZoneID: t.ZoneID}, api: t.api} + return mgr.createOrUpdate(ctx, t.Target) +} + +func (t *T) Stop(ctx context.Context) error { + mgr := &aliasManager{alias: alias{UUID: t.UUID, Name: t.Name, Target: t.Target, ZoneID: t.ZoneID}, api: t.api} + if t.UUID != "" { + return mgr.createOrUpdate(ctx, noneTarget) + } + return mgr.delete(ctx) +} + func (t *T) Status(ctx context.Context) status.T { - return status.NotApplicable + if err := t.configure(ctx); err != nil { + t.StatusLog().Error("%s", err) + return status.Undef + } + + _, _, code, data, err := t.api.GetAliases(ctx, t.ZoneID, t.Name, t.UUID) + if err != nil { + t.StatusLog().Error("%s", err) + return status.Undef + } + if code != http.StatusOK { + t.StatusLog().Error("unexpected status code %d", code) + return status.Undef + } + + var resp aliasListResponse + if err := json.Unmarshal(data, &resp); err != nil { + t.StatusLog().Error("decode aliases: %s", err) + return status.Undef + } + aliases := resp.Aliases + + if len(aliases) == 0 { + t.StatusLog().Info("not found") + return status.Down + } + if len(aliases) > 1 { + t.StatusLog().Warn("found multiple aliases: %v", aliases) + return status.Warn + } + found := aliases[0] + if found.Target == noneTarget || found.Target == strings.TrimSuffix(noneTarget, ".") { + t.StatusLog().Info("alias target is disabled") + return status.Down + } + if t.Name != "" && found.Name != t.Name { + t.StatusLog().Warn("alias name mismatch: found %s instead of %s", found.Name, t.Name) + return status.Warn + } + if found.Target != t.Target { + t.StatusLog().Info("alias target %s != %s", found.Target, t.Target) + return status.Down + } + if t.UUID != "" && found.UUID != "" && found.UUID != t.UUID { + t.StatusLog().Warn("alias uuid mismatch: found %s instead of %s", found.UUID, t.UUID) + return status.Warn + } + t.StatusLog().Info("%s => %s", found.FQDN, found.Target) + return status.Up +} + +func (t *T) Label(context.Context) string { + if t.Name != "" { + return t.Name + } + if t.UUID != "" { + return t.UUID + } + return t.ZoneID +} + +func (t *T) CanInstall(context.Context) (bool, error) { + return true, nil +} + +func (t *T) Boot(ctx context.Context) error { + return t.Stop(ctx) +} + +func (m *aliasManager) createOrUpdate(ctx context.Context, target string) error { + _, _, code, data, err := m.api.GetAliases(ctx, m.alias.ZoneID, m.alias.Name, m.alias.UUID) + if err != nil { + return err + } + if code != http.StatusOK { + return fmt.Errorf("unexpected status code %d", code) + } + var resp aliasListResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("decode aliases: %w", err) + } + aliases := resp.Aliases + + if len(aliases) == 0 { + if m.alias.UUID != "" { + return fmt.Errorf("unable to update alias id %s, no such alias", m.alias.UUID) + } + _, _, code, data, err := m.api.CreateAlias(ctx, m.alias.ZoneID, m.alias.Name, target) + if err != nil { + return err + } + if code != http.StatusCreated { + return fmt.Errorf("unexpected status code %d", code) + } + var created alias + if err := json.Unmarshal(data, &created); err != nil { + return fmt.Errorf("decode created alias: %w", err) + } + m.alias = created + return nil + } + if len(aliases) > 1 { + return fmt.Errorf("multiple aliases found, can't update: %v", aliases) + } + found := aliases[0] + if m.alias.UUID != "" && found.UUID != "" && found.UUID != m.alias.UUID { + return fmt.Errorf("can not update alias %v, another conflicting alias already exists: %v", m.alias, found) + } + _, _, code, data, err = m.api.UpdateAlias(ctx, found.ZoneID, found.UUID, m.alias.Name, target) + if err != nil { + return err + } + if code != http.StatusOK { + return fmt.Errorf("unexpected status code %d", code) + } + var updated alias + if err := json.Unmarshal(data, &updated); err != nil { + return fmt.Errorf("decode updated alias: %w", err) + } + m.alias = updated + return nil +} + +func (m *aliasManager) delete(ctx context.Context) error { + _, _, code, data, err := m.api.GetAliases(ctx, m.alias.ZoneID, m.alias.Name, m.alias.UUID) + if err != nil { + return err + } + if code != http.StatusOK { + return fmt.Errorf("unexpected status code %d", code) + } + var resp aliasListResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("decode aliases: %w", err) + } + aliases := resp.Aliases + + if len(aliases) == 0 { + return nil + } + if len(aliases) > 1 { + return fmt.Errorf("multiple aliases found, can't delete: %v", aliases) + } + _, _, code, _, err = m.api.DeleteAlias(ctx, aliases[0].ZoneID, aliases[0].UUID) + if err != nil { + return err + } + if code != http.StatusNoContent { + return fmt.Errorf("unexpected status code %d", code) + } + return nil +} + +func (m *aliasManager) search(ctx context.Context) ([]alias, error) { + _, _, code, data, err := m.api.GetAliases(ctx, m.alias.ZoneID, m.alias.Name, m.alias.UUID) + if err != nil { + return nil, err + } + if code != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", code) + } + var resp aliasListResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode aliases: %w", err) + } + return resp.Aliases, nil } diff --git a/drivers/resipsgcp_dnsalias/main_test.go b/drivers/resipsgcp_dnsalias/main_test.go new file mode 100644 index 000000000..ae764b961 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/main_test.go @@ -0,0 +1,490 @@ +package resipsgcp_dnsalias + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/opensvc/om3/v3/core/status" + "github.com/opensvc/om3/v3/util/plog" + "github.com/opensvc/om3/v3/util/sgcp" +) + +type mockTokenGetter struct { + token string +} + +func (m *mockTokenGetter) Get(ctx context.Context, scope ...string) (string, error) { + return m.token, nil +} + +func newTestDNSAPI(t *testing.T, handler func(w http.ResponseWriter, r *http.Request) (int, interface{})) (*sgcp.DNSAPI, *httptest.Server) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + statusCode, resp := handler(w, r) + if resp != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Errorf("failed to encode response: %v", err) + } + } else { + w.WriteHeader(statusCode) + } + })) + + cfg := &sgcp.Config{ + DNS: sgcp.DNSConfig{ + BaseURL: server.URL, + Path: struct { + Alias string `yaml:"alias"` + Zone string `yaml:"zone"` + }{ + Alias: "/aliases", + Zone: "/zones", + }, + }, + Auth: sgcp.AuthConfig{ + Scopes: map[string][]string{ + "dns_read": {"dns:read_dns_records"}, + "dns_write": {"dns:admin_dns_records"}, + }, + }, + } + client := server.Client() + logger := plog.NewDefaultLogger() + tk := &mockTokenGetter{token: "fake-token"} + + api := sgcp.NewDNSAPI(cfg, client, logger, tk) + return api, server +} + +func TestManagerCreateOrUpdateCreatesAliasWhenMissing(t *testing.T) { + var requests int + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + requests++ + switch { + case r.Method == http.MethodGet && r.URL.Path == "/aliases": + return http.StatusOK, map[string]interface{}{"aliases": []interface{}{}} + case r.Method == http.MethodPost && r.URL.Path == "/zones/zone-1/aliases": + return http.StatusCreated, map[string]interface{}{ + "id": "alias-1", + "name": "svc1", + "target": "node1", + "fqdn": "svc1.example.org", + "zone_id": "zone-1", + } + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + } + }) + defer server.Close() + + mgr := &aliasManager{alias: alias{ZoneID: "zone-1", Name: "svc1", Target: "node1"}, api: api} + + if err := mgr.createOrUpdate(context.Background(), "node1"); err != nil { + t.Fatalf("createOrUpdate returned error: %v", err) + } + if requests != 2 { + t.Fatalf("expected 2 requests, got %d", requests) + } + if mgr.alias.UUID != "alias-1" { + t.Fatalf("expected alias uuid alias-1, got %s", mgr.alias.UUID) + } +} + +func TestManagerCreateOrUpdateUpdatesExistingAlias(t *testing.T) { + var requests int + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + requests++ + switch { + case r.Method == http.MethodGet && r.URL.Path == "/aliases": + return http.StatusOK, map[string]interface{}{ + "aliases": []interface{}{ + map[string]interface{}{ + "id": "alias-1", + "name": "svc1", + "target": "old-node", + "fqdn": "svc1.example.org", + "zone_id": "zone-1", + }, + }, + } + case r.Method == http.MethodPut && r.URL.Path == "/zones/zone-1/aliases/alias-1": + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("invalid payload: %v", err) + return http.StatusBadRequest, nil + } + if payload["name"] != "svc1" || payload["target"] != "node1" { + t.Errorf("unexpected payload: %v", payload) + return http.StatusBadRequest, nil + } + return http.StatusOK, map[string]interface{}{ + "id": "alias-1", + "name": "svc1", + "target": "node1", + "fqdn": "svc1.example.org", + "zone_id": "zone-1", + } + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + } + }) + defer server.Close() + + mgr := &aliasManager{alias: alias{ZoneID: "zone-1", Name: "svc1", Target: "node1"}, api: api} + + if err := mgr.createOrUpdate(context.Background(), "node1"); err != nil { + t.Fatalf("createOrUpdate returned error: %v", err) + } + if requests != 2 { + t.Fatalf("expected 2 requests, got %d", requests) + } + if mgr.alias.Target != "node1" { + t.Fatalf("expected target node1, got %s", mgr.alias.Target) + } +} + +func TestManagerCreateOrUpdateMultipleAliasesError(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + if r.Method == http.MethodGet && r.URL.Path == "/aliases" { + return http.StatusOK, map[string]interface{}{ + "aliases": []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "node1", "zone_id": "zone-1"}, + map[string]interface{}{"id": "a2", "name": "svc1", "target": "node2", "zone_id": "zone-1"}, + }, + } + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + }) + defer server.Close() + + mgr := &aliasManager{alias: alias{ZoneID: "zone-1", Name: "svc1"}, api: api} + + err := mgr.createOrUpdate(context.Background(), "node1") + if err == nil { + t.Fatal("expected error for multiple aliases, got nil") + } + if !strings.Contains(err.Error(), "multiple aliases") { + t.Errorf("expected error containing 'multiple aliases', got %v", err) + } +} + +func TestManagerCreateOrUpdateWithUUIDNotFoundError(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + if r.Method == http.MethodGet && r.URL.Path == "/aliases" { + return http.StatusOK, map[string]interface{}{"aliases": []interface{}{}} + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + }) + defer server.Close() + + mgr := &aliasManager{alias: alias{ZoneID: "zone-1", UUID: "uuid-1", Name: "svc1"}, api: api} + + err := mgr.createOrUpdate(context.Background(), "node1") + if err == nil { + t.Fatal("expected error when UUID provided but alias not found") + } + if !strings.Contains(err.Error(), "no such alias") { + t.Errorf("expected error containing 'no such alias', got %v", err) + } +} + +func TestManagerDeleteDeletesAlias(t *testing.T) { + var requests int + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + requests++ + switch { + case r.Method == http.MethodGet && r.URL.Path == "/aliases": + return http.StatusOK, map[string]interface{}{ + "aliases": []interface{}{ + map[string]interface{}{"id": "alias-1", "name": "svc1", "target": "node1", "zone_id": "zone-1"}, + }, + } + case r.Method == http.MethodDelete && r.URL.Path == "/zones/zone-1/aliases/alias-1": + return http.StatusNoContent, nil + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + } + }) + defer server.Close() + + mgr := &aliasManager{alias: alias{ZoneID: "zone-1", Name: "svc1"}, api: api} + + if err := mgr.delete(context.Background()); err != nil { + t.Fatalf("delete returned error: %v", err) + } + if requests != 2 { + t.Fatalf("expected 2 requests, got %d", requests) + } +} + +func TestManagerDeleteWhenAliasMissingDoesNothing(t *testing.T) { + var requests int + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + requests++ + if r.Method == http.MethodGet && r.URL.Path == "/aliases" { + return http.StatusOK, map[string]interface{}{"aliases": []interface{}{}} + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + }) + defer server.Close() + + mgr := &aliasManager{alias: alias{ZoneID: "zone-1", Name: "svc1"}, api: api} + + if err := mgr.delete(context.Background()); err != nil { + t.Fatalf("delete returned error: %v", err) + } + if requests != 1 { + t.Fatalf("expected 1 request (only GET), got %d", requests) + } +} + +func TestTStatus(t *testing.T) { + tests := []struct { + name string + aliasResp []interface{} + configUUID string + configName string + configTarget string + wantStatus status.T + }{ + { + name: "alias not found => Down", + aliasResp: []interface{}{}, + configName: "svc1", + configTarget: "node1", + wantStatus: status.Down, + }, + { + name: "alias found with target none. => Down", + aliasResp: []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "none.", "fqdn": "svc1.example.org", "zone_id": "z1"}, + }, + configName: "svc1", + configTarget: "node1", + wantStatus: status.Down, + }, + { + name: "alias target mismatch => Down", + aliasResp: []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "other", "fqdn": "svc1.example.org", "zone_id": "z1"}, + }, + configName: "svc1", + configTarget: "node1", + wantStatus: status.Down, + }, + { + name: "name mismatch => Warn", + aliasResp: []interface{}{ + map[string]interface{}{"id": "a1", "name": "wrong", "target": "node1", "fqdn": "svc1.example.org", "zone_id": "z1"}, + }, + configName: "svc1", + configTarget: "node1", + wantStatus: status.Warn, + }, + { + name: "UUID mismatch => Warn", + aliasResp: []interface{}{ + map[string]interface{}{"id": "wrong-uuid", "name": "svc1", "target": "node1", "fqdn": "svc1.example.org", "zone_id": "z1"}, + }, + configUUID: "expected-uuid", + configName: "svc1", + configTarget: "node1", + wantStatus: status.Warn, + }, + { + name: "multiple aliases => Warn", + aliasResp: []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "node1", "zone_id": "z1"}, + map[string]interface{}{"id": "a2", "name": "svc1", "target": "node1", "zone_id": "z1"}, + }, + configName: "svc1", + configTarget: "node1", + wantStatus: status.Warn, + }, + { + name: "perfect match => Up", + aliasResp: []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "node1", "fqdn": "svc1.example.org", "zone_id": "z1"}, + }, + configUUID: "a1", + configName: "svc1", + configTarget: "node1", + wantStatus: status.Up, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + if r.Method == http.MethodGet && r.URL.Path == "/aliases" { + return http.StatusOK, map[string]interface{}{"aliases": tt.aliasResp} + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + }) + defer server.Close() + + tRes := &T{ + UUID: tt.configUUID, + Name: tt.configName, + Target: tt.configTarget, + ZoneID: "z1", + Endpoint: server.URL, + api: api, + configured: true, + } + + got := tRes.Status(context.Background()) + if got != tt.wantStatus { + t.Errorf("Status() = %v, want %v", got, tt.wantStatus) + } + }) + } +} + +func TestTStartStop(t *testing.T) { + t.Run("Start creates alias", func(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/aliases": + return http.StatusOK, map[string]interface{}{"aliases": []interface{}{}} + case r.Method == http.MethodPost && r.URL.Path == "/zones/zone-1/aliases": + return http.StatusCreated, map[string]interface{}{ + "id": "a1", "name": "svc1", "target": "node1", "fqdn": "svc1.example.org", "zone_id": "zone-1", + } + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + } + }) + defer server.Close() + + tRes := &T{ + Name: "svc1", + Target: "node1", + ZoneID: "zone-1", + Endpoint: server.URL, + api: api, + configured: true, + } + + if err := tRes.Start(context.Background()); err != nil { + t.Fatalf("Start returned error: %v", err) + } + }) + + t.Run("Stop with UUID sets target to none.", func(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/aliases": + return http.StatusOK, map[string]interface{}{ + "aliases": []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "node1", "zone_id": "zone-1"}, + }, + } + case r.Method == http.MethodPut && r.URL.Path == "/zones/zone-1/aliases/a1": + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + if payload["target"] != "none." { + t.Errorf("expected target 'none.', got %v", payload["target"]) + } + return http.StatusOK, map[string]interface{}{ + "id": "a1", "name": "svc1", "target": "none.", "fqdn": "svc1.example.org", "zone_id": "zone-1", + } + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + } + }) + defer server.Close() + + tRes := &T{ + UUID: "a1", + Name: "svc1", + Target: "node1", + ZoneID: "zone-1", + Endpoint: server.URL, + api: api, + configured: true, + } + + if err := tRes.Stop(context.Background()); err != nil { + t.Fatalf("Stop returned error: %v", err) + } + }) + + t.Run("Stop without UUID deletes alias", func(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/aliases": + return http.StatusOK, map[string]interface{}{ + "aliases": []interface{}{ + map[string]interface{}{"id": "a1", "name": "svc1", "target": "node1", "zone_id": "zone-1"}, + }, + } + case r.Method == http.MethodDelete && r.URL.Path == "/zones/zone-1/aliases/a1": + return http.StatusNoContent, nil + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + return http.StatusNotFound, nil + } + }) + defer server.Close() + + tRes := &T{ + Name: "svc1", + Target: "node1", + ZoneID: "zone-1", + Endpoint: server.URL, + api: api, + configured: true, + } + + if err := tRes.Stop(context.Background()); err != nil { + t.Fatalf("Stop returned error: %v", err) + } + }) +} + +func TestAliasAPIHTTPErrors(t *testing.T) { + t.Run("GetAliases returns error on non-200", func(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + return http.StatusInternalServerError, nil + }) + defer server.Close() + + _, _, code, _, err := api.GetAliases(context.Background(), "z1", "", "") + if err == nil { + t.Fatal("expected error, got nil") + } + if code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", code) + } + }) + + t.Run("CreateAlias returns error on non-201", func(t *testing.T) { + api, server := newTestDNSAPI(t, func(w http.ResponseWriter, r *http.Request) (int, interface{}) { + return http.StatusBadRequest, map[string]string{"error": "bad request"} + }) + defer server.Close() + + _, _, code, _, err := api.CreateAlias(context.Background(), "z1", "svc1", "node1") + if err == nil { + t.Fatal("expected error, got nil") + } + if code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", code) + } + }) +} diff --git a/util/sgcp/dns.go b/util/sgcp/dns.go new file mode 100644 index 000000000..27fadd10e --- /dev/null +++ b/util/sgcp/dns.go @@ -0,0 +1,146 @@ +package sgcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/opensvc/om3/v3/util/plog" +) + +// DNSAPI provides methods for DNS alias management. +type DNSAPI struct { + Api + config *Config +} + +// Alias represents a DNS alias. +type Alias struct { + UUID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Target string `json:"target,omitempty"` + FQDN string `json:"fqdn,omitempty"` + ZoneID string `json:"zone_id,omitempty"` +} + +// aliasListResponse is the API response for listing aliases. +type aliasListResponse struct { + Aliases []Alias `json:"aliases"` +} + +// NewDNSAPI creates a new DNSAPI instance. +func NewDNSAPI(config *Config, client *http.Client, l *plog.Logger, tk tokenGetter) *DNSAPI { + return &DNSAPI{ + config: config, + Api: Api{ + client: client, + tk: tk, + log: l, + }, + } +} + +// GetAliases retrieves aliases matching the given criteria. +func (a *DNSAPI) GetAliases(ctx context.Context, zoneID, name, uuid string) (method, url string, code int, data []byte, err error) { + method = http.MethodGet + url = a.getAliasesURL(zoneID, name, uuid) + code, data, err = a.do(ctx, method, url, nil, a.GetScopes("dns_read")...) + return +} + +// CreateAlias creates a new DNS alias. +func (a *DNSAPI) CreateAlias(ctx context.Context, zoneID, name, target string) (method, url string, code int, data []byte, err error) { + method = http.MethodPost + url = a.getAliasesCreateURL(zoneID) + + payload := map[string]any{"name": name, "target": target, "ttl": 60} + var b []byte + b, err = json.Marshal(payload) + if err != nil { + err = fmt.Errorf("failed to marshal create payload: %w", err) + return + } + a.log.Infof("%s %s data=%s", method, url, string(b)) + code, data, err = a.do(ctx, method, url, bytes.NewReader(b), a.GetScopes("dns_write")...) + return +} + +// UpdateAlias updates an existing DNS alias. +func (a *DNSAPI) UpdateAlias(ctx context.Context, zoneID, aliasUUID, name, target string) (method, url string, code int, data []byte, err error) { + method = http.MethodPut + url = a.getAliasURL(zoneID, aliasUUID) + + payload := map[string]any{"name": name, "target": target, "ttl": 60} + var b []byte + b, err = json.Marshal(payload) + if err != nil { + err = fmt.Errorf("failed to marshal update payload: %w", err) + return + } + a.log.Infof("%s %s data=%s", method, url, string(b)) + code, data, err = a.do(ctx, method, url, bytes.NewReader(b), a.GetScopes("dns_write")...) + return +} + +// DeleteAlias deletes a DNS alias. +func (a *DNSAPI) DeleteAlias(ctx context.Context, zoneID, aliasUUID string) (method, url string, code int, data []byte, err error) { + method = http.MethodDelete + url = a.getAliasURL(zoneID, aliasUUID) + + a.log.Infof("%s %s", method, url) + code, data, err = a.do(ctx, method, url, nil, a.GetScopes("dns_write")...) + return +} + +// GetScopes returns the scopes for a given scope type. +func (a *DNSAPI) GetScopes(scopeType string) []string { + return a.config.GetScopes(scopeType) +} + +// getAliasesURL constructs the URL for listing aliases with query parameters. +func (a *DNSAPI) getAliasesURL(zoneID, name, uuid string) string { + values := url.Values{} + values.Set("zone_id", zoneID) + if name != "" { + values.Set("name", name) + } + if uuid != "" { + values.Set("id", uuid) + } + base := strings.TrimRight(a.config.DNS.BaseURL, "/") + path := a.config.DNS.Path.Alias + if path == "" { + path = "/aliases" // fallback + } + return base + path + "?" + values.Encode() +} + +// getAliasesCreateURL constructs the URL for creating an alias. +func (a *DNSAPI) getAliasesCreateURL(zoneID string) string { + base := strings.TrimRight(a.config.DNS.BaseURL, "/") + zonePath := a.config.DNS.Path.Zone + if zonePath == "" { + zonePath = "/zones" + } + return fmt.Sprintf("%s%s/%s/aliases", base, zonePath, zoneID) +} + +// getAliasURL constructs the URL for a specific alias. +func (a *DNSAPI) getAliasURL(zoneID, aliasUUID string) string { + base := strings.TrimRight(a.config.DNS.BaseURL, "/") + zonePath := a.config.DNS.Path.Zone + if zonePath == "" { + zonePath = "/zones" + } + return fmt.Sprintf("%s%s/%s/aliases/%s", base, zonePath, zoneID, aliasUUID) +} + +// do is the function that executes the HTTP request (redundant but kept for consistency) +func (a *DNSAPI) do(ctx context.Context, method, url string, body io.Reader, scopes ...string) (statusCode int, b []byte, err error) { + return a.Api.do(ctx, method, url, body, scopes...) +}