From 0460d27cb118f156c36351a533be769afe27abc2 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Wed, 8 Jul 2026 11:24:11 +0200 Subject: [PATCH 01/19] feat(ip): implement SGCP DNS alias resource driver Add the resipsgcp_dnsalias driver to manage DNS aliases via the SGCP API. This driver supports create, update, delete, and status operations, following the same pattern as the existing resfssgcp_nfs driver. - Add T struct with keywords: uuid, name, target, zone_id, secret, endpoint - Implement Configure, Start, Stop, Status, Label, CanInstall, Boot methods - Use sgcp.DNSAPI for API calls, with authentication via token factory - Add aliasManager to handle createOrUpdate and delete logic - Add unit tests covering creation, update, deletion, status, and error cases - Add manifest.go to register driver and keywords The driver integrates with the SGCP configuration (sgcp.yaml) and uses the common http client cache and token factory from the sgcp package. --- drivers/resipsgcp_dnsalias/main.go | 277 +++++++++++++- drivers/resipsgcp_dnsalias/main_test.go | 490 ++++++++++++++++++++++++ util/sgcp/dns.go | 146 +++++++ 3 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 drivers/resipsgcp_dnsalias/main_test.go create mode 100644 util/sgcp/dns.go 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...) +} From 5283ddaa6bb26f07c7707b4ad021c0723aa5b75d Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Fri, 10 Jul 2026 10:37:38 +0200 Subject: [PATCH 02/19] [resfssgcp_nfs] Extract `MockGetAuthInfoProvider` to reusable helper package for test simplification Signed-off-by: Cyril Galibern --- drivers/resfssgcp_nfs/main_test.go | 46 ++++++------------------------ drivers/sgcpauthtesthelper/main.go | 46 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 37 deletions(-) create mode 100644 drivers/sgcpauthtesthelper/main.go diff --git a/drivers/resfssgcp_nfs/main_test.go b/drivers/resfssgcp_nfs/main_test.go index e926933bd..18e81eb86 100644 --- a/drivers/resfssgcp_nfs/main_test.go +++ b/drivers/resfssgcp_nfs/main_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "testing" + "github.com/opensvc/om3/v3/drivers/sgcpauthtesthelper" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,40 +27,10 @@ func newDrvWithRid(s string) *T { return d } -type ( - tAuthInfo struct { - sgcp.AuthInfo - } -) - -func (a *tAuthInfo) GetAuthInfo(string) (*sgcp.AuthInfo, error) { - return &a.AuthInfo, nil -} - -func tCreateAuthInfo(s string) *tAuthInfo { - var a sgcp.AuthInfo - switch s { - case "id1": - a = sgcp.AuthInfo{ - AccountID: "account_1", - ClientID: "client_id_1", - ClientSecret: "client_secret_1", - Signature: "1", - } - default: - a = sgcp.AuthInfo{ - AccountID: "account_default", - ClientID: "client_id_efault", - ClientSecret: "client_secret_default", - Signature: "default", - } - } - return &tAuthInfo{AuthInfo: a} -} - // Setup initializes the test environment by configuring SGCP with a temporary configuration file. // It returns a cleanup function that resets the configuration to a null state when invoked. func Setup(t *testing.T) func() { + t.Helper() cfgFile := testsgcphelper.InstallConfig(t) sgcp.SetConfigForTest(cfgFile) require.NotNil(t, sgcp.GetConfig()) @@ -89,7 +61,7 @@ func TestConfigure(t *testing.T) { defer Setup(t)() drv := newDrvWithRid("test-rid") - drv.authInfoer = tCreateAuthInfo("id1") + drv.authInfoer = sgcpauthtesthelper.NewMockGetAuthInfoProvider("id1") // Set configuration drv.UUID = "test-uuid" drv.Host = "test-host" @@ -111,7 +83,7 @@ func TestConfigureWhenNoConfig(t *testing.T) { sgcp.SetConfigForTest("") drv := newDrvWithRid("test-rid") - drv.authInfoer = tCreateAuthInfo("id1") + drv.authInfoer = sgcpauthtesthelper.NewMockGetAuthInfoProvider("id1") // Set configuration drv.UUID = "test-uuid" drv.Host = "test-host" @@ -126,7 +98,7 @@ func TestConfigureWithMissingUUID(t *testing.T) { defer Setup(t)() drv := newDrvWithRid("test-rid") - drv.authInfoer = tCreateAuthInfo("id1") + drv.authInfoer = sgcpauthtesthelper.NewMockGetAuthInfoProvider("id1") // This should still work since UUID is set via the configuration drv.UUID = "test-uuid" @@ -139,7 +111,7 @@ func TestIsClientIgnored(t *testing.T) { defer Setup(t)() drv := newDrvWithRid("test-rid") - drv.authInfoer = tCreateAuthInfo("id1") + drv.authInfoer = sgcpauthtesthelper.NewMockGetAuthInfoProvider("id1") err := drv.Configure() assert.NoError(t, err) @@ -173,7 +145,7 @@ func TestGetNFSClients(t *testing.T) { defer Setup(t)() drv := newDrvWithRid("test-rid") - drv.authInfoer = tCreateAuthInfo("id1") + drv.authInfoer = sgcpauthtesthelper.NewMockGetAuthInfoProvider("id1") require.NoError(t, drv.Configure()) // Set up ignored hosts @@ -303,7 +275,7 @@ func TestLabel(t *testing.T) { defer Setup(t)() drv := newDrvWithRid("test-rid") - drv.authInfoer = tCreateAuthInfo("id1") + drv.authInfoer = sgcpauthtesthelper.NewMockGetAuthInfoProvider("id1") drv.UUID = "test-uuid" drv.MountPoint = "/tmp/foo" diff --git a/drivers/sgcpauthtesthelper/main.go b/drivers/sgcpauthtesthelper/main.go new file mode 100644 index 000000000..b623711fb --- /dev/null +++ b/drivers/sgcpauthtesthelper/main.go @@ -0,0 +1,46 @@ +package sgcpauthtesthelper + +import "github.com/opensvc/om3/v3/util/sgcp" + +type ( + // MockGetAuthInfoProvider is a mock implementation of the GetAuthInfoProvider interface for testing purposes. + MockGetAuthInfoProvider struct { + sgcp.AuthInfo + } + + getAuthInfoProvider interface { + GetAuthInfo(string) (*sgcp.AuthInfo, error) + } +) + +var ( + // _ ensures MockGetAuthInfoProvider statically implements the getAuthInfoProvider interface. + _ getAuthInfoProvider = &MockGetAuthInfoProvider{} +) + +// NewMockGetAuthInfoProvider creates a new mock instance of MockGetAuthInfoProvider populated with test AuthInfo data. +func NewMockGetAuthInfoProvider(s string) *MockGetAuthInfoProvider { + var a sgcp.AuthInfo + switch s { + case "id1": + a = sgcp.AuthInfo{ + AccountID: "account_1", + ClientID: "client_id_1", + ClientSecret: "client_secret_1", + Signature: "1", + } + default: + a = sgcp.AuthInfo{ + AccountID: "account_default", + ClientID: "client_id_efault", + ClientSecret: "client_secret_default", + Signature: "default", + } + } + return &MockGetAuthInfoProvider{AuthInfo: a} +} + +// GetAuthInfo retrieves the authentication details of the MockGetAuthInfoProvider as a pointer to sgcp.AuthInfo. +func (a *MockGetAuthInfoProvider) GetAuthInfo(string) (*sgcp.AuthInfo, error) { + return &a.AuthInfo, nil +} From 8ee70a8328ade884670cd7b512987f1a17996b9f Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 14:47:08 +0200 Subject: [PATCH 03/19] [testsgcphelper] Relocate package and update imports to reflect new path Signed-off-by: Cyril Galibern --- drivers/resfssgcp_nfs/main_test.go | 2 +- util/{sgcp => }/testsgcphelper/main.go | 0 util/{sgcp => }/testsgcphelper/text/config.yaml | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename util/{sgcp => }/testsgcphelper/main.go (100%) rename util/{sgcp => }/testsgcphelper/text/config.yaml (100%) diff --git a/drivers/resfssgcp_nfs/main_test.go b/drivers/resfssgcp_nfs/main_test.go index 18e81eb86..4fcdfcdf1 100644 --- a/drivers/resfssgcp_nfs/main_test.go +++ b/drivers/resfssgcp_nfs/main_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/opensvc/om3/v3/drivers/sgcpauthtesthelper" + "github.com/opensvc/om3/v3/util/testsgcphelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,7 +17,6 @@ import ( "github.com/opensvc/om3/v3/core/resource" "github.com/opensvc/om3/v3/core/status" "github.com/opensvc/om3/v3/util/sgcp" - "github.com/opensvc/om3/v3/util/sgcp/testsgcphelper" ) func newDrvWithRid(s string) *T { diff --git a/util/sgcp/testsgcphelper/main.go b/util/testsgcphelper/main.go similarity index 100% rename from util/sgcp/testsgcphelper/main.go rename to util/testsgcphelper/main.go diff --git a/util/sgcp/testsgcphelper/text/config.yaml b/util/testsgcphelper/text/config.yaml similarity index 100% rename from util/sgcp/testsgcphelper/text/config.yaml rename to util/testsgcphelper/text/config.yaml From 51f9ef3dca3b62309c6d4d44c4954e734241574d Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 14:47:39 +0200 Subject: [PATCH 04/19] [util/sgcpdnshelper] Introduce `sgcpdnshelper` package with API and in-memory DB for SGCP alias management Signed-off-by: Cyril Galibern --- util/sgcpdnshelper/main.go | 184 +++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 util/sgcpdnshelper/main.go diff --git a/util/sgcpdnshelper/main.go b/util/sgcpdnshelper/main.go new file mode 100644 index 000000000..83d324d57 --- /dev/null +++ b/util/sgcpdnshelper/main.go @@ -0,0 +1,184 @@ +package sgcpdnshelper + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + + "github.com/google/uuid" + + "github.com/opensvc/om3/v3/util/sgcp" +) + +type ( + Api struct { + sgcp.Api + *DB + } + + DB struct { + rLock sync.RWMutex + byId map[string]*DBEntry + byZoneAndName map[string]*DBEntry + byIdZoneAndName map[string]*DBEntry + } + + DBEntry struct { + Name string + UUID string + ZoneID string + AliasL []sgcp.Alias + StatusCode int + Err error + } +) + +func NewDB() *DB { + return &DB{ + byId: make(map[string]*DBEntry), + byZoneAndName: make(map[string]*DBEntry), + byIdZoneAndName: make(map[string]*DBEntry), + } +} + +func NewApi(db *DB) *Api { + return &Api{DB: db} +} + +func (a *Api) CreateAlias(_ context.Context, zoneID, name, target string) (*sgcp.Alias, error) { + aUUID := uuid.New().String() + alias := sgcp.Alias{ + UUID: aUUID, + Name: name, + Target: target, + FQDN: fmt.Sprintf("%s.%s", name, zoneID), + ZoneID: zoneID, + } + v := &DBEntry{ + Name: name, + UUID: aUUID, + ZoneID: zoneID, + AliasL: []sgcp.Alias{alias}, + } + a.update(v) + return &v.AliasL[0], nil +} + +func (a *Api) DeleteAlias(_ context.Context, zoneID, aliasUUID string) error { + v, ok := a.search(zoneID, "", aliasUUID) + if ok { + a.delete(v) + } + return nil +} + +func (a *Api) GetAliases(_ context.Context, zoneID, name, uuid string) (method, url string, code int, data []byte, err error) { + resp, ok := a.search(zoneID, name, uuid) + method = http.MethodGet + url = "/file/alias" + if ok { + code := resp.StatusCode + if code == 0 { + code = http.StatusOK + } + m := map[string][]sgcp.Alias{ + "aliases": resp.AliasL, + } + b, err := json.Marshal(m) + if err == nil { + err = resp.Err + } + return method, url, code, b, err + } + return method, url, http.StatusNotFound, nil, nil +} + +func (t *Api) UpdateAlias(_ context.Context, zoneID string, aliasUUID string, name string, target string) (*sgcp.Alias, error) { + v, ok := t.search(zoneID, name, aliasUUID) + if !ok { + return nil, fmt.Errorf("alias not found") + } + if len(v.AliasL) != 1 { + return nil, fmt.Errorf("can't update: len aliases=%d", len(v.AliasL)) + } + v.AliasL[0].Target = target + return v.asAlias(), nil +} + +func (a *DB) delete(v *DBEntry) { + a.rLock.Lock() + defer a.rLock.Unlock() + delete(a.byId, v.UUID) + delete(a.byZoneAndName, v.Name+"@"+v.ZoneID) + delete(a.byIdZoneAndName, v.Name+"@"+v.ZoneID+"@"+v.UUID) +} + +func (a *DB) update(v *DBEntry) { + if v == nil { + return + } + nv := v.clone() + a.rLock.Lock() + defer a.rLock.Unlock() + if nv.UUID != "" { + a.byId[nv.UUID] = nv + } + if nv.Name != "" && nv.ZoneID != "" { + a.byZoneAndName[nv.Name+"@"+nv.ZoneID] = nv + } + if nv.UUID != "" && nv.Name != "" && nv.ZoneID != "" { + a.byIdZoneAndName[nv.Name+"@"+nv.ZoneID+"@"+nv.UUID] = nv + } +} + +func (a *DB) Setup(l []DBEntry) { + a.byId = make(map[string]*DBEntry) + a.byZoneAndName = make(map[string]*DBEntry) + a.byIdZoneAndName = make(map[string]*DBEntry) + for _, v := range l { + a.update(v.clone()) + } +} + +func (a *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { + a.rLock.RLock() + defer a.rLock.RUnlock() + if zoneID != "" && name != "" && uuid != "" { + v, ok = a.byIdZoneAndName[name+"@"+zoneID+"@"+uuid] + } else if zoneID != "" && name != "" { + v, ok = a.byZoneAndName[name+"@"+zoneID] + } else if uuid != "" { + v, ok = a.byId[uuid] + } + v = v.clone() + return +} + +func (v *DBEntry) asAlias() *sgcp.Alias { + if len(v.AliasL) != 1 { + return nil + } + a := v.AliasL[0] + return &sgcp.Alias{ + UUID: a.UUID, + Name: a.Name, + Target: a.Target, + FQDN: a.FQDN, + ZoneID: a.ZoneID, + } +} + +func (v *DBEntry) clone() *DBEntry { + if v == nil { + return nil + } + n := *v + l := make([]sgcp.Alias, len(v.AliasL)) + for i, a := range v.AliasL { + l[i] = a + } + n.AliasL = l + return &n +} From dc58b28ebf4f65ed2d9bc5c18a3c5169e5fee21d Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Fri, 10 Jul 2026 14:51:54 +0200 Subject: [PATCH 05/19] [util/sgcpdns] Refactor alias management methods to improve clarity and error handling Signed-off-by: Cyril Galibern --- util/sgcp/dns.go | 76 +++++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/util/sgcp/dns.go b/util/sgcp/dns.go index 27fadd10e..67c17012f 100644 --- a/util/sgcp/dns.go +++ b/util/sgcp/dns.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "net/url" "strings" @@ -54,9 +53,10 @@ func (a *DNSAPI) GetAliases(ctx context.Context, zoneID, name, uuid string) (met } // 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) +func (a *DNSAPI) CreateAlias(ctx context.Context, zoneID, name, target string) (alias *Alias, err error) { + var result Alias + method := http.MethodPost + path := a.getAliasesCreateURL(zoneID) payload := map[string]any{"name": name, "target": target, "ttl": 60} var b []byte @@ -65,15 +65,21 @@ func (a *DNSAPI) CreateAlias(ctx context.Context, zoneID, name, target string) ( 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 + a.log.Infof("%s %s data=%s", method, path, string(b)) + if code, data, err := a.do(ctx, method, path, bytes.NewReader(b), a.GetScopes("dns_write")...); err != nil { + return nil, fmt.Errorf("%s %s: %w", method, path, err) + } else if err := a.CheckStatusCode(method, path, code, http.StatusCreated); err != nil { + return nil, err + } else if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("%s %s unmarshal created alias: %w", method, path, err) + } + return &result, nil } // 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) +func (a *DNSAPI) UpdateAlias(ctx context.Context, zoneID, aliasUUID, name, target string) (alias *Alias, err error) { + method := http.MethodPut + path := a.getAliasURL(zoneID, aliasUUID) payload := map[string]any{"name": name, "target": target, "ttl": 60} var b []byte @@ -82,19 +88,29 @@ func (a *DNSAPI) UpdateAlias(ctx context.Context, zoneID, aliasUUID, name, targe 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")...) + a.log.Infof("%s %s data=%s", method, path, string(b)) + if code, data, err := a.do(ctx, method, path, bytes.NewReader(b), a.GetScopes("dns_write")...); err != nil { + return nil, err + } else if err := a.CheckStatusCode(method, path, code, http.StatusOK); err != nil { + return nil, err + } else if err := json.Unmarshal(data, &alias); err != nil { + return nil, fmt.Errorf("failed to unmarshal alias: %w", err) + } 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 +func (a *DNSAPI) DeleteAlias(ctx context.Context, zoneID, aliasUUID string) error { + method := http.MethodDelete + path := a.getAliasURL(zoneID, aliasUUID) + + a.log.Infof("%s %s", method, path) + if code, _, err := a.do(ctx, method, path, nil, a.GetScopes("dns_write")...); err != nil { + return err + } else if err := a.CheckStatusCode(method, path, code, http.StatusNoContent); err != nil { + return err + } + return nil } // GetScopes returns the scopes for a given scope type. @@ -114,33 +130,21 @@ func (a *DNSAPI) getAliasesURL(zoneID, name, uuid string) string { } base := strings.TrimRight(a.config.DNS.BaseURL, "/") path := a.config.DNS.Path.Alias - if path == "" { - path = "/aliases" // fallback - } - return base + path + "?" + values.Encode() + return fmt.Sprintf("%s%s?%s", 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) + aliasPath := a.config.DNS.Path.Alias + return fmt.Sprintf("%s%s/%s%s", base, zonePath, zoneID, aliasPath) } // 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...) + aliasPath := a.config.DNS.Path.Alias + return fmt.Sprintf("%s%s/%s%s/%s", base, zonePath, zoneID, aliasPath, aliasUUID) } From 361ed71900ef48afe70d627a96e8bf8ab0ab1f68 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:22:55 +0200 Subject: [PATCH 06/19] [sgcphelper] Add `AuthInfoFromPath` helper function for retrieving authentication information --- drivers/sgcphelper/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/drivers/sgcphelper/main.go b/drivers/sgcphelper/main.go index 155ca486d..bdec5b753 100644 --- a/drivers/sgcphelper/main.go +++ b/drivers/sgcphelper/main.go @@ -12,6 +12,11 @@ type ( GetAuthInfoFromDatastorePather struct{} ) +func AuthInfoFromPath(s string) (*sgcp.AuthInfo, error) { + t := &GetAuthInfoFromDatastorePather{} + return t.GetAuthInfo(s) +} + // GetAuthInfo retrieves authentication information from the specified datastore path and returns an AuthInfo struct. func (g *GetAuthInfoFromDatastorePather) GetAuthInfo(datastorePath string) (*sgcp.AuthInfo, error) { var ( From 3d410d2d437e94180604fd7d3a591265d5326d02 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:24:56 +0200 Subject: [PATCH 07/19] [util/sgcpdnshelper] Fix alias update logic in `main.go` to update internal db --- util/sgcpdnshelper/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/util/sgcpdnshelper/main.go b/util/sgcpdnshelper/main.go index 83d324d57..ed1b7919e 100644 --- a/util/sgcpdnshelper/main.go +++ b/util/sgcpdnshelper/main.go @@ -104,7 +104,8 @@ func (t *Api) UpdateAlias(_ context.Context, zoneID string, aliasUUID string, na return nil, fmt.Errorf("can't update: len aliases=%d", len(v.AliasL)) } v.AliasL[0].Target = target - return v.asAlias(), nil + t.update(v) + return v.AsAlias(), nil } func (a *DB) delete(v *DBEntry) { From 880568bbf1cdcaff93d3aca0850192e11ad78e5b Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:31:17 +0200 Subject: [PATCH 08/19] [util/sgcpdnshelper] Add `Counters` struct to track API call statistics and reset functionality --- util/sgcpdnshelper/main.go | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/util/sgcpdnshelper/main.go b/util/sgcpdnshelper/main.go index ed1b7919e..6295fdf3c 100644 --- a/util/sgcpdnshelper/main.go +++ b/util/sgcpdnshelper/main.go @@ -23,6 +23,14 @@ type ( byId map[string]*DBEntry byZoneAndName map[string]*DBEntry byIdZoneAndName map[string]*DBEntry + + callCount Counters + } + + Counters struct { + Delete int + Update int + Search int } DBEntry struct { @@ -76,6 +84,9 @@ func (a *Api) DeleteAlias(_ context.Context, zoneID, aliasUUID string) error { func (a *Api) GetAliases(_ context.Context, zoneID, name, uuid string) (method, url string, code int, data []byte, err error) { resp, ok := a.search(zoneID, name, uuid) + a.rLock.RLock() + a.callCount.Search++ + a.rLock.RUnlock() method = http.MethodGet url = "/file/alias" if ok { @@ -105,7 +116,7 @@ func (t *Api) UpdateAlias(_ context.Context, zoneID string, aliasUUID string, na } v.AliasL[0].Target = target t.update(v) - return v.AsAlias(), nil + return v.asAlias(), nil } func (a *DB) delete(v *DBEntry) { @@ -114,6 +125,7 @@ func (a *DB) delete(v *DBEntry) { delete(a.byId, v.UUID) delete(a.byZoneAndName, v.Name+"@"+v.ZoneID) delete(a.byIdZoneAndName, v.Name+"@"+v.ZoneID+"@"+v.UUID) + a.callCount.Delete++ } func (a *DB) update(v *DBEntry) { @@ -132,6 +144,7 @@ func (a *DB) update(v *DBEntry) { if nv.UUID != "" && nv.Name != "" && nv.ZoneID != "" { a.byIdZoneAndName[nv.Name+"@"+nv.ZoneID+"@"+nv.UUID] = nv } + a.callCount.Update++ } func (a *DB) Setup(l []DBEntry) { @@ -143,6 +156,19 @@ func (a *DB) Setup(l []DBEntry) { } } +func (a *DB) CallCounts() Counters { + a.rLock.RLock() + calls := a.callCount + a.rLock.RUnlock() + return calls +} + +func (a *DB) ResetCalls() { + a.rLock.Lock() + defer a.rLock.Unlock() + a.callCount = Counters{} +} + func (a *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { a.rLock.RLock() defer a.rLock.RUnlock() From 08659eaa223e857c250eda73bef84b0c5451f988 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:32:16 +0200 Subject: [PATCH 09/19] [util/sgcpdnshelper] Add `Search` method to query database for aliases using zoneID, name, and uuid --- util/sgcpdnshelper/main.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/util/sgcpdnshelper/main.go b/util/sgcpdnshelper/main.go index 6295fdf3c..2b26e7bd8 100644 --- a/util/sgcpdnshelper/main.go +++ b/util/sgcpdnshelper/main.go @@ -169,6 +169,8 @@ func (a *DB) ResetCalls() { a.callCount = Counters{} } +// search looks up a DBEntry in the database matching the specified zoneID, name, and uuid. +// Returns the matched DBEntry and a boolean indicating success or failure. func (a *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { a.rLock.RLock() defer a.rLock.RUnlock() @@ -183,6 +185,16 @@ func (a *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { return } +// Search retrieves an sgcp.Alias from the database using zoneID, name, and uuid as search parameters. +// Returns the matched alias and a boolean indicating whether the alias was found. +func (a *DB) Search(zoneID, name, uuid string) (alias *sgcp.Alias, ok bool) { + v, ok := a.search(zoneID, name, uuid) + if !ok { + return nil, ok + } + return v.asAlias(), ok +} + func (v *DBEntry) asAlias() *sgcp.Alias { if len(v.AliasL) != 1 { return nil From 33b19ed11378de7d4203bcc10fc609543be04a7f Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:33:12 +0200 Subject: [PATCH 10/19] [util/sgcpdnshelper] Refactor `DB` methods to use consistent receiver name --- util/sgcpdnshelper/main.go | 70 +++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/util/sgcpdnshelper/main.go b/util/sgcpdnshelper/main.go index 2b26e7bd8..4a50000d5 100644 --- a/util/sgcpdnshelper/main.go +++ b/util/sgcpdnshelper/main.go @@ -119,67 +119,67 @@ func (t *Api) UpdateAlias(_ context.Context, zoneID string, aliasUUID string, na return v.asAlias(), nil } -func (a *DB) delete(v *DBEntry) { - a.rLock.Lock() - defer a.rLock.Unlock() - delete(a.byId, v.UUID) - delete(a.byZoneAndName, v.Name+"@"+v.ZoneID) - delete(a.byIdZoneAndName, v.Name+"@"+v.ZoneID+"@"+v.UUID) - a.callCount.Delete++ +func (db *DB) delete(v *DBEntry) { + db.rLock.Lock() + defer db.rLock.Unlock() + delete(db.byId, v.UUID) + delete(db.byZoneAndName, v.Name+"@"+v.ZoneID) + delete(db.byIdZoneAndName, v.Name+"@"+v.ZoneID+"@"+v.UUID) + db.callCount.Delete++ } -func (a *DB) update(v *DBEntry) { +func (db *DB) update(v *DBEntry) { if v == nil { return } nv := v.clone() - a.rLock.Lock() - defer a.rLock.Unlock() + db.rLock.Lock() + defer db.rLock.Unlock() if nv.UUID != "" { - a.byId[nv.UUID] = nv + db.byId[nv.UUID] = nv } if nv.Name != "" && nv.ZoneID != "" { - a.byZoneAndName[nv.Name+"@"+nv.ZoneID] = nv + db.byZoneAndName[nv.Name+"@"+nv.ZoneID] = nv } if nv.UUID != "" && nv.Name != "" && nv.ZoneID != "" { - a.byIdZoneAndName[nv.Name+"@"+nv.ZoneID+"@"+nv.UUID] = nv + db.byIdZoneAndName[nv.Name+"@"+nv.ZoneID+"@"+nv.UUID] = nv } - a.callCount.Update++ + db.callCount.Update++ } -func (a *DB) Setup(l []DBEntry) { - a.byId = make(map[string]*DBEntry) - a.byZoneAndName = make(map[string]*DBEntry) - a.byIdZoneAndName = make(map[string]*DBEntry) +func (db *DB) Setup(l []DBEntry) { + db.byId = make(map[string]*DBEntry) + db.byZoneAndName = make(map[string]*DBEntry) + db.byIdZoneAndName = make(map[string]*DBEntry) for _, v := range l { - a.update(v.clone()) + db.update(v.clone()) } } -func (a *DB) CallCounts() Counters { - a.rLock.RLock() - calls := a.callCount - a.rLock.RUnlock() +func (db *DB) CallCounts() Counters { + db.rLock.RLock() + calls := db.callCount + db.rLock.RUnlock() return calls } -func (a *DB) ResetCalls() { - a.rLock.Lock() - defer a.rLock.Unlock() - a.callCount = Counters{} +func (db *DB) ResetCalls() { + db.rLock.Lock() + defer db.rLock.Unlock() + db.callCount = Counters{} } // search looks up a DBEntry in the database matching the specified zoneID, name, and uuid. // Returns the matched DBEntry and a boolean indicating success or failure. -func (a *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { - a.rLock.RLock() - defer a.rLock.RUnlock() +func (db *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { + db.rLock.RLock() + defer db.rLock.RUnlock() if zoneID != "" && name != "" && uuid != "" { - v, ok = a.byIdZoneAndName[name+"@"+zoneID+"@"+uuid] + v, ok = db.byIdZoneAndName[name+"@"+zoneID+"@"+uuid] } else if zoneID != "" && name != "" { - v, ok = a.byZoneAndName[name+"@"+zoneID] + v, ok = db.byZoneAndName[name+"@"+zoneID] } else if uuid != "" { - v, ok = a.byId[uuid] + v, ok = db.byId[uuid] } v = v.clone() return @@ -187,8 +187,8 @@ func (a *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { // Search retrieves an sgcp.Alias from the database using zoneID, name, and uuid as search parameters. // Returns the matched alias and a boolean indicating whether the alias was found. -func (a *DB) Search(zoneID, name, uuid string) (alias *sgcp.Alias, ok bool) { - v, ok := a.search(zoneID, name, uuid) +func (db *DB) Search(zoneID, name, uuid string) (alias *sgcp.Alias, ok bool) { + v, ok := db.search(zoneID, name, uuid) if !ok { return nil, ok } From 6f1ed6acc6efdff004143b61fea3955fc40571f6 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:35:52 +0200 Subject: [PATCH 11/19] [util/sgcpdnshelper] Rename package to `sgcpdnstesthelper` to reflect updated functionality Signed-off-by: Cyril Galibern --- util/{sgcpdnshelper => sgcpdnstesthelper}/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename util/{sgcpdnshelper => sgcpdnstesthelper}/main.go (99%) diff --git a/util/sgcpdnshelper/main.go b/util/sgcpdnstesthelper/main.go similarity index 99% rename from util/sgcpdnshelper/main.go rename to util/sgcpdnstesthelper/main.go index 4a50000d5..4b095b117 100644 --- a/util/sgcpdnshelper/main.go +++ b/util/sgcpdnstesthelper/main.go @@ -1,4 +1,4 @@ -package sgcpdnshelper +package sgcpdnstesthelper import ( "context" From ef0dcd7cd8dfbbcd35c36fa1b60a2bc273a43649 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Fri, 10 Jul 2026 19:37:11 +0200 Subject: [PATCH 12/19] [resipsgcp_dnsalias] Refactor alias management with dedicated manager and API abstraction Signed-off-by: Cyril Galibern --- drivers/resipsgcp_dnsalias/main.go | 230 +++++++++-------------------- drivers/resipsgcp_dnsalias/mgr.go | 139 +++++++++++++++++ 2 files changed, 206 insertions(+), 163 deletions(-) create mode 100644 drivers/resipsgcp_dnsalias/mgr.go diff --git a/drivers/resipsgcp_dnsalias/main.go b/drivers/resipsgcp_dnsalias/main.go index f3a292987..2146d7200 100644 --- a/drivers/resipsgcp_dnsalias/main.go +++ b/drivers/resipsgcp_dnsalias/main.go @@ -2,25 +2,47 @@ 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/plog" "github.com/opensvc/om3/v3/util/sgcp" ) const noneTarget = "none." type ( + T struct { + resource.T + resource.Restart + + 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"` + + mgr *mgr + + // for tests + api apiProvider + } + + mgr struct { + alias alias + api apiProvider + log *plog.Logger + } + + // alias decoupled from sgcp.Alias to allow for future changes alias struct { UUID string `json:"id,omitempty"` Name string `json:"name,omitempty"` @@ -30,28 +52,19 @@ type ( } aliasListResponse struct { - Aliases []alias `json:"aliases"` + Aliases []sgcp.Alias `json:"aliases"` } - aliasManager struct { - alias alias - api *sgcp.DNSAPI + apiProvider interface { + CheckStatusCode(method string, url string, got int, wanted ...int) error + CreateAlias(ctx context.Context, zoneID, name, target string) (alias *sgcp.Alias, err error) + DeleteAlias(ctx context.Context, zoneID, aliasUUID string) (err error) + GetAliases(ctx context.Context, zoneID, name, uuid string) (method, url string, code int, data []byte, err error) + UpdateAlias(ctx context.Context, zoneID string, aliasUUID string, name string, target string) (alias *sgcp.Alias, err error) } - 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 + authInfoProvider interface { + GetAuthInfo(string) (*sgcp.AuthInfo, error) } ) @@ -60,14 +73,6 @@ func New() resource.Driver { } 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) @@ -75,18 +80,24 @@ func (t *T) configure(ctx context.Context) error { if t.Target == "" { t.Target = hostname.Hostname() } + + // secret is mandatory: define from keyword, fallback to cfg default, ensure not empty if t.Secret == "" { t.Secret = cfg.GetDefaultSecret() } if t.Secret == "" { return errors.New("secret is required") } + + // endpoint is mandatory: define from keyword, fallback to cfg default, ensure not empty if t.Endpoint == "" { t.Endpoint = cfg.DNS.BaseURL } if t.Endpoint == "" { return errors.New("endpoint is required") } + + // zoneid is mandatory if t.ZoneID == "" { return errors.New("zone_id is required") } @@ -94,63 +105,59 @@ func (t *T) configure(ctx context.Context) error { return errors.New("alias need define at least name or uuid") } + return t.configureMgr(cfg) +} + +func (t *T) configureMgr(cfg *sgcp.Config) error { + mgr := &mgr{ + alias: alias{UUID: t.UUID, Name: t.Name, Target: t.Target, ZoneID: t.ZoneID}, + log: t.Log(), + } + if t.api != nil { + // allow custom api for tests + mgr.api = t.api + t.mgr = mgr + return nil + } + 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) + authInfo, err := sgcphelper.AuthInfoFromPath(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 + if t.api != nil { + mgr.api = t.api + } else { + mgr.api = sgcp.NewDNSAPI(cfg, httpClient, t.Log(), tokenFactory) } - t.api = sgcp.NewDNSAPI(config, httpClient, t.Log(), tokenFactory) - t.configured = true + + t.mgr = mgr 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) + return t.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 t.mgr.createOrUpdate(ctx, noneTarget) } - return mgr.delete(ctx) + return t.mgr.delete(ctx) } func (t *T) Status(ctx context.Context) status.T { - 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) + aliases, err := t.mgr.getAliases(ctx) if err != nil { - t.StatusLog().Error("%s", err) - return status.Undef + t.StatusLog().Error("get alias failed: %s", err) } - 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") @@ -191,109 +198,6 @@ func (t *T) Label(context.Context) string { 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/mgr.go b/drivers/resipsgcp_dnsalias/mgr.go new file mode 100644 index 000000000..4a2cb4cf0 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/mgr.go @@ -0,0 +1,139 @@ +package resipsgcp_dnsalias + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/opensvc/om3/v3/util/sgcp" +) + +var ( + ErrUpdateNotFound = fmt.Errorf("update: alias not found") +) + +func (m *mgr) createOrUpdate(ctx context.Context, target string) error { + aliases, err := m.getAliases(ctx) + if err != nil { + return fmt.Errorf("get aliases: %w", err) + } + + if len(aliases) == 0 { + if m.alias.UUID != "" { + return fmt.Errorf("uuid %s: %w", m.alias.UUID, ErrUpdateNotFound) + } + if v, err := m.create(ctx, target); err != nil { + return fmt.Errorf("create alias: %w", err) + } else { + m.alias = *v + 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) + } + + expectedAlias := m.alias + expectedAlias.Target = target + if expectedAlias.Equal(toAlias(&found)) { + m.log.Debugf("alias already exists") + return nil + } + + if v, err := m.update(ctx, found.ZoneID, found.UUID, m.alias.Name, target); err != nil { + return fmt.Errorf("update alias: %w", err) + } else if v == nil { + return fmt.Errorf("update alias unexpected nil") + } else { + m.alias = *v + return nil + } +} + +func (m *mgr) delete(ctx context.Context) error { + aliases, err := m.getAliases(ctx) + if err != nil { + return fmt.Errorf("get aliases: %w", err) + } + + if len(aliases) == 0 { + return nil + } + if len(aliases) > 1 { + return fmt.Errorf("multiple aliases found, can't delete: %v", aliases) + } + + alias := aliases[0] + if err := m.api.DeleteAlias(ctx, alias.ZoneID, alias.UUID); err != nil { + return fmt.Errorf("delete alias: %w", err) + } + return nil +} + +// getAliases retrieves a list of aliases for the specified zone, name, and UUID or returns an error if unsuccessful. +func (m *mgr) getAliases(ctx context.Context) ([]sgcp.Alias, error) { + method, url, code, data, err := m.api.GetAliases(ctx, m.alias.ZoneID, m.alias.Name, m.alias.UUID) + if err != nil { + return nil, err + } + if err := m.api.CheckStatusCode(method, url, code, http.StatusOK, http.StatusNotFound); err != nil { + return nil, err + } + if code == http.StatusNotFound { + return nil, nil + } + var resp aliasListResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode aliases: %w", err) + } + return resp.Aliases, nil +} + +// create creates a new alias with the specified target and returns the created alias or an error if the operation fails. +func (m *mgr) create(ctx context.Context, target string) (*alias, error) { + v, err := m.api.CreateAlias(ctx, m.alias.ZoneID, m.alias.Name, target) + if err != nil { + return nil, err + } + return toAlias(v), nil +} + +// update modifies an existing alias with the specified parameters and returns the updated alias or an error if any occurs. +func (m *mgr) update(ctx context.Context, zoneID, aliasUUID, aliasName, target string) (*alias, error) { + v, err := m.api.UpdateAlias(ctx, zoneID, aliasUUID, aliasName, target) + if err != nil { + return nil, err + } + return toAlias(v), nil +} + +// toAlias converts a sgcp.Alias object to an alias object by mapping corresponding fields. +func toAlias(v *sgcp.Alias) *alias { + return &alias{ + UUID: v.UUID, + Name: v.Name, + Target: v.Target, + FQDN: v.FQDN, + ZoneID: v.ZoneID, + } +} + +// Equal compares two alias objects and returns true if they are equal, or false otherwise. +// It doesn't compare the FQDN field. +func (a *alias) Equal(b *alias) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.UUID == b.UUID && + a.Name == b.Name && + a.Target == b.Target && + a.ZoneID == b.ZoneID +} From 838b888ad6e9b60c3e92f85e219102c98c1e4112 Mon Sep 17 00:00:00 2001 From: Paul Jouvanceau Date: Fri, 10 Jul 2026 19:38:27 +0200 Subject: [PATCH 13/19] [resipsgcp_dnsalias] Rewrite test suite with streamlined helper functions and enhanced test cases Api test is not expected here Signed-off-by: Cyril Galibern --- drivers/resipsgcp_dnsalias/main_test.go | 964 +++++++++++++----------- 1 file changed, 530 insertions(+), 434 deletions(-) diff --git a/drivers/resipsgcp_dnsalias/main_test.go b/drivers/resipsgcp_dnsalias/main_test.go index ae764b961..85067112b 100644 --- a/drivers/resipsgcp_dnsalias/main_test.go +++ b/drivers/resipsgcp_dnsalias/main_test.go @@ -2,489 +2,585 @@ package resipsgcp_dnsalias import ( "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" + "fmt" "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/core/resource" "github.com/opensvc/om3/v3/core/status" - "github.com/opensvc/om3/v3/util/plog" + "github.com/opensvc/om3/v3/util/sgcpdnstesthelper" + "github.com/opensvc/om3/v3/util/testsgcphelper" + "github.com/opensvc/om3/v3/util/sgcp" ) -type mockTokenGetter struct { - token string +// setup initializes the test environment by configuring SGCP with a temporary configuration file. +// It returns a cleanup function that resets the configuration to a null state when invoked. +func setup(t *testing.T) func() { + t.Helper() + cfgFile := testsgcphelper.InstallConfig(t) + sgcp.SetConfigForTest(cfgFile) + require.NotNil(t, sgcp.GetConfig()) + + return func() { + sgcp.SetConfigForTest("") + } } -func (m *mockTokenGetter) Get(ctx context.Context, scope ...string) (string, error) { - return m.token, nil +// newDBAndDrv initializes a database and driver instance for testing with preset +// entries and configurations. +func newDBAndDrv(t *testing.T, s string, entries []sgcpdnstesthelper.DBEntry) (*sgcpdnstesthelper.DB, *T) { + t.Helper() + drv := New().(*T) + require.NoError(t, drv.SetRID(s)) + db := sgcpdnstesthelper.NewDB() + db.Setup(entries) + drv.api = sgcpdnstesthelper.NewApi(db) + return db, drv } -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", +func TestStatus(t *testing.T) { + cleanup := setup(t) + defer cleanup() + + dbEntries := []sgcpdnstesthelper.DBEntry{ + { + Name: "svc1", + UUID: "a1", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "a1", + Name: "svc1", + Target: "none.", + FQDN: "svc1.example.org", + ZoneID: "z1", + }, }, }, - Auth: sgcp.AuthConfig{ - Scopes: map[string][]string{ - "dns_read": {"dns:read_dns_records"}, - "dns_write": {"dns:admin_dns_records"}, + { + Name: "svc2", + UUID: "a2", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "a2", + Name: "svc2", + Target: "tgt2", + FQDN: "svc2.example.org", + ZoneID: "z1", + }, }, }, + { + Name: "svc3", + UUID: "a3", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "a3", + Name: "svc3bis", + Target: "node1", + FQDN: "svc1.example.org", + ZoneID: "z1", + }, + }, + }, + { + Name: "svc4", + UUID: "id4", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "svc4-bad-id", + Name: "svc4", + Target: "node1", + FQDN: "svc1.example.org", + ZoneID: "z1", + }, + }, + }, + { + Name: "multiple", + UUID: "multipleId", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "id1", + Name: "multiple1", + Target: "node1", + FQDN: "node1.example.org", + ZoneID: "z1", + }, + { + UUID: "id2", + Name: "multiple2", + Target: "node2", + FQDN: "node2.example.org", + ZoneID: "z1", + }, + }, + }, + { + Name: "bad500", + ZoneID: "z1", + StatusCode: 500, + }, } - 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) + cases := map[string]struct { + resUUID string + resName string + resZoneID string + resTarget string + expectedUID string + expectedName string + expectedZoneID string + expectedTarget string + + expectedStatus status.T + expectedStatusLog []resource.StatusLogEntry + }{ + "when alias is not found": { + resName: "svc1", + resZoneID: "z2", + resTarget: "node1", + expectedStatus: status.Down, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "info", Message: "not found"}, + }, + }, + "find alias from name": { + resName: "svc2", + resZoneID: "z1", + resTarget: "tgt2", + expectedStatus: status.Up, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "info", Message: "svc2.example.org => tgt2"}, + }, + }, + "found target is none": { + resName: "svc1", + resZoneID: "z1", + resTarget: "node1", + expectedStatus: status.Down, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "info", Message: "alias target is disabled"}, + }, + }, + "target mismatch": { + resName: "svc2", + resZoneID: "z1", + resTarget: "tgtx", + expectedStatus: status.Down, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "info", Message: "alias target tgt2 != tgtx"}, + }, + }, + "name mismatch": { + resName: "svc3", + resZoneID: "z1", + resTarget: "node1", + expectedStatus: status.Warn, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "warn", Message: "alias name mismatch: found svc3bis instead of svc3"}, + }, + }, + "uuid mismatch": { + resUUID: "id4", + resName: "svc4", + resZoneID: "z1", + resTarget: "node1", + expectedStatus: status.Warn, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "warn", Message: "alias uuid mismatch: found svc4-bad-id instead of id4"}, + }, + }, + "find multiple aliases": { + resName: "multiple", + resZoneID: "z1", + resTarget: "node1", + expectedStatus: status.Warn, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "warn", Message: "found multiple aliases: [{id1 multiple1 node1 node1.example.org z1} {id2 multiple2 node2 node2.example.org z1}]"}, + }, + }, + "perfect match": { + resUUID: "a2", + resName: "svc2", + resZoneID: "z1", + resTarget: "tgt2", + expectedStatus: status.Up, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "info", Message: "svc2.example.org => tgt2"}, + }, + }, + "api error 500": { + resName: "bad500", + resZoneID: "z1", + resTarget: "tgt2", + expectedStatus: status.Down, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "error", Message: "get alias failed: unexpected status code for GET /file/alias got 500 wanted [200 404]"}, + {Level: "info", Message: "not found"}, + }, + }, + "status up when found from only uuid": { + resUUID: "a2", + resZoneID: "z1", + resTarget: "tgt2", + expectedStatus: status.Up, + expectedStatusLog: []resource.StatusLogEntry{ + {Level: "info", Message: "svc2.example.org => tgt2"}, + }, + }, } -} - -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) + for name, tc := range cases { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + t.Run(fmt.Sprintf("%s expect status %s", name, tc.expectedStatus), func(t *testing.T) { + _, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.UUID = tc.resUUID + drv.Name = tc.resName + drv.Target = tc.resTarget + drv.ZoneID = tc.resZoneID + require.NoError(t, drv.Configure()) + + dStatus := drv.Status(ctx) + assert.Equalf(t, tc.expectedStatus, dStatus, "expected %s, got %s", tc.expectedStatus, dStatus) + statusLog := drv.StatusLog() + assert.Equal(t, tc.expectedStatusLog, statusLog.Entries()) + }) } } -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} +func TestStart(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + defer setup(t)() - 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) + dbEntries := []sgcpdnstesthelper.DBEntry{ + { + Name: "name1", + UUID: "uuid1", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "uuid1", + Name: "name1", + Target: "target1", + FQDN: "name1.z1", + ZoneID: "z1", + }, + }, + }, + { + Name: "name2", + UUID: "uuid2", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "uuid2", + Name: "name2", + Target: "target2", + FQDN: "name2.z1", + ZoneID: "z1", + }, + }, + }, } -} -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 + t.Run("Create missing alias when uuid is unset", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.Name = "foo" + drv.Target = "foo-target" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify alias doesn't exits") + alias, ok := db.Search("z1", "foo", "") + require.Falsef(t, ok, "unexpected found alias") + + db.ResetCalls() + t.Log("Start will create new alias") + require.NoError(t, drv.Start(ctx), "unexpected to Start error") + require.NotEmptyf(t, drv.mgr.alias.UUID, "mgr didn't retrieve alias UUID") + createdUUID := drv.mgr.alias.UUID + t.Logf("Created alias uuid: %s", createdUUID) + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 1, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") + + t.Log("verify alias has been created in db") + alias, ok = db.Search("z1", "foo", "") + require.True(t, ok) + assert.NotNil(t, alias) + assert.Equal(t, createdUUID, alias.UUID) + assert.Equal(t, "foo", alias.Name) + t.Logf("created alias: %v", alias) }) - 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 - } + t.Run("Don't update alias when all is correct", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.UUID = "uuid1" + drv.Name = "name1" + drv.Target = "target1" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify alias initially exits") + alias, ok := db.Search("z1", "name1", "uuid1") + require.Truef(t, ok, "didn't find alias") + t.Logf("initial alias: %v", alias) + require.NotNil(t, alias) + assert.Equal(t, "uuid1", alias.UUID, "unexpected initial alias UUID") + assert.Equal(t, "name1", alias.Name, "unexpected initial alias name") + + db.ResetCalls() + t.Log("Start don't have to update alias") + require.NoError(t, drv.Start(ctx), "unexpected to Start error") + require.Equalf(t, "uuid1", drv.mgr.alias.UUID, "unexpected alias UUID") + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 0, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") }) - 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 + t.Run("update alias target", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.UUID = "uuid2" + drv.Name = "name2" + drv.Target = "newTarget2" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify alias exits initially, with alternate target") + alias, ok := db.Search("z1", "name2", "uuid2") + require.Truef(t, ok, "didn't find alias") + require.NotNil(t, alias) + t.Logf("initial alias: %v", alias) + require.Equal(t, drv.UUID, alias.UUID) + require.Equal(t, drv.Name, alias.Name) + t.Logf("Existing target for %s is %s", alias.Name, alias.Target) + require.NotEqual(t, drv.Target, alias.Target) + + db.ResetCalls() + t.Log("Start must update alias target") + require.NoError(t, drv.Start(ctx), "unexpected to Start error") + require.Equalf(t, "newTarget2", drv.mgr.alias.Target, "unexpected alias target") + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 1, call.Update, "unexpected api update call") + require.Equal(t, 1, call.Search, "unexpected api search call") + + t.Log("verify alias has been created in db") + alias, ok = db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.True(t, ok) + require.NotNil(t, alias, "final alias not found") + t.Logf("final alias: %v", alias) + require.Equal(t, drv.Target, alias.Target, + "unexpected final alias target value") }) - 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, - }, +func TestStop(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + defer setup(t)() + + dbEntries := []sgcpdnstesthelper.DBEntry{ { - name: "UUID mismatch => Warn", - aliasResp: []interface{}{ - map[string]interface{}{"id": "wrong-uuid", "name": "svc1", "target": "node1", "fqdn": "svc1.example.org", "zone_id": "z1"}, + Name: "name1", + UUID: "uuid1", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "uuid1", + Name: "name1", + Target: "target1", + FQDN: "name1.z1", + ZoneID: "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"}, + Name: "name2", + UUID: "uuid2", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "uuid2", + Name: "name2", + Target: "target2", + FQDN: "name2.z1", + ZoneID: "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"}, + Name: "name-none", + UUID: "uuid-none", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "uuid-none", + Name: "name-none", + Target: "none.", + FQDN: "name2.z1", + ZoneID: "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("drv with kw uuid: returns error alias is not found", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + + drv.UUID = "uuid-no-present" + drv.Name = "no-present-name" + drv.Target = "target" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify alias doesn't exits") + _, ok := db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Falsef(t, ok, "unexpected found alias") + + db.ResetCalls() + t.Log("call Stop") + err := drv.Stop(ctx) + t.Logf("error: %v", err) + require.Error(t, err) + require.ErrorIsf(t, err, ErrUpdateNotFound, "unexpected to Stop error") + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 0, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") }) - 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("drv without kw: returns no error if alias is not found", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.Name = "no-present-name" + drv.Target = "target" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify alias doesn't exits") + _, ok := db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Falsef(t, ok, "unexpected found alias") + + db.ResetCalls() + t.Log("call Stop") + require.NoError(t, drv.Stop(ctx)) + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 0, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") }) - 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) - } + t.Run("drv with kw uuid must update alias target to none", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.Name = "name1" + drv.UUID = "uuid1" + drv.Target = "target1" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify initial exits") + alias, ok := db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Truef(t, ok, "initial found alias") + t.Logf("initial alias: %+v", alias) + require.Equal(t, drv.Target, alias.Target) + + db.ResetCalls() + t.Log("call Stop") + require.NoError(t, drv.Stop(ctx)) + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 1, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") + + t.Log("verify alias has been updated") + alias, ok = db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Truef(t, ok, "final found alias") + t.Logf("final alias: %+v", alias) + require.Equal(t, noneTarget, alias.Target) }) -} -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("drv with kw uuid and target already none is noop", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.Name = "name-none" + drv.UUID = "uuid-none" + drv.Target = "none." + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify initial exits with taget none") + alias, ok := db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Truef(t, ok, "initial found alias") + t.Logf("initial alias: %+v", alias) + require.Equal(t, noneTarget, alias.Target) + + db.ResetCalls() + t.Log("call Stop") + require.NoError(t, drv.Stop(ctx)) + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 0, call.Delete, "unexpected api delete call") + require.Equal(t, 0, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") + + t.Log("verify alias has been updated") + alias, ok = db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Truef(t, ok, "final found alias") + t.Logf("final alias: %+v", alias) + require.Equal(t, noneTarget, alias.Target) }) - 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) - } + t.Run("drv without kw uuid must delete alias", func(t *testing.T) { + db, drv := newDBAndDrv(t, "rid1", dbEntries) + drv.Name = "name1" + drv.Target = "target1" + drv.ZoneID = "z1" + require.NoError(t, drv.Configure()) + + t.Log("verify initial exits") + initial, ok := db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Truef(t, ok, "initial found alias") + t.Logf("initial alias: %+v", initial) + require.Equal(t, drv.Target, initial.Target) + + db.ResetCalls() + t.Log("call Stop") + require.NoError(t, drv.Stop(ctx)) + + call := db.CallCounts() + t.Logf("call counts: %+v", call) + require.Equal(t, 1, call.Delete, "unexpected api delete call") + require.Equal(t, 0, call.Update, "unexpected api update call") + assert.Equal(t, 1, call.Search, "unexpected api search call") + + t.Log("verify alias has been deleted") + final, ok := db.Search(drv.ZoneID, drv.Name, drv.UUID) + require.Falsef(t, ok, "found alias") + t.Logf("final alias: %+v", final) }) } From c0bee9ee985904b1ff6299184bcdd3187f42e93c Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:41:14 +0200 Subject: [PATCH 14/19] [resipsgcp_dnsalias] Add TODO comments for caching and cleanup tasks Signed-off-by: Cyril Galibern --- drivers/resipsgcp_dnsalias/main.go | 3 +++ drivers/resipsgcp_dnsalias/mgr.go | 1 + 2 files changed, 4 insertions(+) diff --git a/drivers/resipsgcp_dnsalias/main.go b/drivers/resipsgcp_dnsalias/main.go index 2146d7200..2f0fda5e1 100644 --- a/drivers/resipsgcp_dnsalias/main.go +++ b/drivers/resipsgcp_dnsalias/main.go @@ -143,10 +143,12 @@ func (t *T) configureMgr(cfg *sgcp.Config) error { } func (t *T) Start(ctx context.Context) error { + // TODO: implement cache cleanup return t.mgr.createOrUpdate(ctx, t.Target) } func (t *T) Stop(ctx context.Context) error { + // TODO: implement cache cleanup if t.UUID != "" { return t.mgr.createOrUpdate(ctx, noneTarget) } @@ -154,6 +156,7 @@ func (t *T) Stop(ctx context.Context) error { } func (t *T) Status(ctx context.Context) status.T { + // TODO: implement cache cleanup if command is not called from the scheduler aliases, err := t.mgr.getAliases(ctx) if err != nil { t.StatusLog().Error("get alias failed: %s", err) diff --git a/drivers/resipsgcp_dnsalias/mgr.go b/drivers/resipsgcp_dnsalias/mgr.go index 4a2cb4cf0..ef4a606ff 100644 --- a/drivers/resipsgcp_dnsalias/mgr.go +++ b/drivers/resipsgcp_dnsalias/mgr.go @@ -77,6 +77,7 @@ func (m *mgr) delete(ctx context.Context) error { // getAliases retrieves a list of aliases for the specified zone, name, and UUID or returns an error if unsuccessful. func (m *mgr) getAliases(ctx context.Context) ([]sgcp.Alias, error) { + // TODO: Use ageing cache method, url, code, data, err := m.api.GetAliases(ctx, m.alias.ZoneID, m.alias.Name, m.alias.UUID) if err != nil { return nil, err From 0f2a603140124b1bdb5259a0a1bfe1b18231cfba Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 19:44:34 +0200 Subject: [PATCH 15/19] [util/sgcpserverfortest] Introduce test server with file and client management REST API endpoints for SGCP scenarios Signed-off-by: Cyril Galibern --- util/sgcpserverfortest/main.go | 281 +++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 util/sgcpserverfortest/main.go diff --git a/util/sgcpserverfortest/main.go b/util/sgcpserverfortest/main.go new file mode 100644 index 000000000..491d8be62 --- /dev/null +++ b/util/sgcpserverfortest/main.go @@ -0,0 +1,281 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "slices" + "strings" + "sync/atomic" + + "gopkg.in/yaml.v3" + + "github.com/google/uuid" +) + +type ( + // NfsClient represents an NFS client configuration + NfsClient struct { + UUID string `json:"uuid"` + Host string `json:"host"` + Permission string `json:"permission"` + Protocol string `json:"protocol"` + ConsistencyGroupID string `json:"consistencyGroupId,omitempty"` + } + + // FilesystemInfo represents the filesystem information from the API + FilesystemInfo struct { + UUID string `json:"uuid"` + ConsistencyGroupID string `json:"consistencyGroupId"` + NFSClients []NfsClient `json:"nfsClients"` + Status string `json:"status"` + } + + Token struct { + AccessToken string `json:"access_token"` + } + + User struct { + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + Scopes []string `yaml:"scopes"` + } + + Users map[string]User +) + +var ( + users *Users + + files = map[string]FilesystemInfo{ + "1ab7d139-dd35-4f9c-ad82-cd6a93675cfd": FilesystemInfo{ + UUID: "1ab7d139-dd35-4f9c-ad82-cd6a93675cfd", + ConsistencyGroupID: "12", + NFSClients: nil, + Status: "online", + }, + } + + createdTokenCount atomic.Int64 +) + +func assertAuth(desc string, w http.ResponseWriter, r *http.Request, scope ...string) bool { + auth := r.Header.Get("Authorization") + for _, s := range scope { + if !strings.Contains(auth, s) { + slog.Warn(fmt.Sprintf("%s [%d]", desc, http.StatusForbidden), "missing_scope", s) + w.WriteHeader(http.StatusForbidden) + return false + } + } + return true +} + +func logStatusCode(desc string, code int) { + slog.Info(fmt.Sprintf("%s [%d]", desc, code), "code", code) +} + +func setHeader(w http.ResponseWriter, desc string, code int) { + logStatusCode(desc, code) + w.WriteHeader(code) +} + +func getFileAPI(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + slog.Info(GetFile, "id", id) + if !assertAuth(GetFile, w, r, "account1:sgcp:files:read") { + return + } + if v, ok := files[id]; ok { + setHeader(w, GetFile, http.StatusOK) + json.NewEncoder(w).Encode(v) + return + } + setHeader(w, GetFile, http.StatusNotFound) + return +} +func getFileClient(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + slog.Info(GetFileClient, "id", id) + if !assertAuth(GetFileClient, w, r, "account1:sgcp:files:read") { + return + } + + if v, ok := files[id]; ok { + setHeader(w, GetFileClient, http.StatusOK) + json.NewEncoder(w).Encode(v.NFSClients) + return + } + setHeader(w, GetFileClient, http.StatusNotFound) + + return +} +func postFileClient(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + slog.Info(PostFileClient, "id", id) + if !assertAuth(PostFileClient, w, r, "account1:sgcp:files:read", "account1:sgcp:files:write") { + return + } + + var body NfsClient + err := json.NewDecoder(r.Body).Decode(&body) + + if err != nil { + logStatusCode(PostFileClient, http.StatusBadRequest) + http.Error(w, fmt.Sprintf("invalid JSON: %s", err), http.StatusBadRequest) + return + } + + if v, ok := files[id]; ok { + body.UUID = uuid.New().String() + v.NFSClients = append(v.NFSClients, body) + files[id] = v + setHeader(w, PostFileClient, http.StatusCreated) + json.NewEncoder(w).Encode(body) + slog.Info("Created client", "id", id, "clientID", body.UUID) + return + } + + logStatusCode(PostFileClient, http.StatusNotFound) + http.Error(w, fmt.Sprintf("no such fs %s", err), http.StatusNotFound) + return +} +func deleteFileClient(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + clientID := r.PathValue("clientID") + slog.Info(DeleteFileClient, "id", id, "clientID", clientID) + if !assertAuth(DeleteFileClient, w, r, "account1:sgcp:files:read", "account1:sgcp:files:write") { + return + } + + if v, ok := files[id]; ok { + l := make([]NfsClient, 0) + for _, c := range v.NFSClients { + if c.UUID != clientID { + l = append(l, c) + } + } + v.NFSClients = l + files[id] = v + setHeader(w, DeleteFileClient, http.StatusNoContent) + return + } + + logStatusCode(DeleteFileClient, http.StatusNotFound) + http.Error(w, fmt.Sprintf("no such fs %s", id), http.StatusNotFound) + return +} + +func postAuthToken(w http.ResponseWriter, r *http.Request) { + slog.Info(PostAuth) + auth := r.Header.Get("Authorization") + authB, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")) + if err != nil { + http.Error(w, "invalid auth", http.StatusBadRequest) + return + } + l := strings.Split(string(authB), ":") + if len(l) != 2 { + http.Error(w, "invalid auth", http.StatusBadRequest) + return + } + clientID := l[0] + clientSecret := l[1] + userScopes, ok := users.ScopesForAuth(clientID, clientSecret) + if !ok { + slog.Info(PostAuth+" bad creadentials", "client_id", clientID) + setHeader(w, PostAuth, http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + formScope := r.FormValue("scope") + requestedScopes := strings.Fields(formScope) + for _, s := range requestedScopes { + if !slices.Contains(userScopes, s) { + slog.Warn(PostAuth, "refusedScope", s) + setHeader(w, PostAuth, http.StatusForbidden) + return + } + } + + count := createdTokenCount.Add(1) + body := Token{ + AccessToken: fmt.Sprintf("%v", requestedScopes), + } + setHeader(w, PostAuth, http.StatusOK) + slog.Info(PostAuth, "createdCount", count, "client_id", clientID, "createdToken", requestedScopes) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(body) +} + +func (u Users) ScopesForAuth(clientID, clientSecret string) ([]string, bool) { + if user, ok := u[clientID]; ok && user.ClientSecret == clientSecret { + return user.Scopes, true + } + return nil, false +} + +var ( + GetFile = "GET /file/fs/{id}" + PostFileClient = "POST /file/fs/{id}/client" + GetFileClient = "Get /file/fs/{id}/client" + DeleteFileClient = "DELETE /file/fs/{id}/client/{clientID}" + + PostAuth = "POST /auth/access_token" +) + +func loadUsers() (*Users, error) { + var u Users + configFile := "users.yaml" + data, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", configFile, err) + } + + if err := yaml.Unmarshal(data, &u); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", configFile, err) + } + + for userID, user := range u { + slog.Info("loaded user", "userID", userID, "clientSecret", user.ClientSecret, "scopes", user.Scopes) + } + + fmt.Printf("loaded users: %#v\n", u) + + return &u, nil +} + +func main() { + var err error + createdTokenCount.Store(0) + mux := http.NewServeMux() + + users, err = loadUsers() + if err != nil { + slog.Error("failed to load users", "err", err) + return + } + // url: /file/fs/{id} + mux.HandleFunc(GetFile, getFileAPI) + + // url: /file/fs/{id}/client + mux.HandleFunc(PostFileClient, postFileClient) + mux.HandleFunc(GetFileClient, getFileClient) + + // url: /file/fs/{id}/client/{clientID} + mux.HandleFunc(DeleteFileClient, deleteFileClient) + + // url: /auth/access_token + mux.HandleFunc(PostAuth, postAuthToken) + + log.Println("Listening on :8000") + log.Fatal(http.ListenAndServe(":8000", mux)) +} From 7d0029e2516e8a76b68c8c299a6b1e64bddc149d Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 20:11:05 +0200 Subject: [PATCH 16/19] [util/sgcpserverfortest] Bootstrap DNS alias handler with SGCP Signed-off-by: Cyril Galibern --- util/sgcpserverfortest/main.go | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/util/sgcpserverfortest/main.go b/util/sgcpserverfortest/main.go index 491d8be62..16ddbe4bd 100644 --- a/util/sgcpserverfortest/main.go +++ b/util/sgcpserverfortest/main.go @@ -15,6 +15,9 @@ import ( "gopkg.in/yaml.v3" "github.com/google/uuid" + + "github.com/opensvc/om3/v3/util/sgcp" + "github.com/opensvc/om3/v3/util/sgcpdnstesthelper" ) type ( @@ -170,6 +173,30 @@ func deleteFileClient(w http.ResponseWriter, r *http.Request) { return } +func dnsGetAliasHandler(a *sgcpdnstesthelper.Api) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + zoneID := r.PathValue("zone_id") + id := r.PathValue("id") + query := r.URL.Query() + qName := query.Get("name") + qUUID := query.Get("uuid") + + slog.Info(GetDnsAlias, "zoneID", zoneID, "id", id) + alias, ok := a.DB.Search(zoneID, qName, qUUID) + if !ok { + logStatusCode(GetDnsAlias, http.StatusNotFound) + http.Error(w, fmt.Sprintf("no such alias %s", id), http.StatusNotFound) + return + } + setHeader(w, GetDnsAlias, http.StatusOK) + // TODO: verify mapping + body := map[string]sgcp.Alias{ + "alias": *alias, + } + json.NewEncoder(w).Encode(body) + } +} + func postAuthToken(w http.ResponseWriter, r *http.Request) { slog.Info(PostAuth) auth := r.Header.Get("Authorization") @@ -229,6 +256,9 @@ var ( GetFileClient = "Get /file/fs/{id}/client" DeleteFileClient = "DELETE /file/fs/{id}/client/{clientID}" + // TODO: verify path + GetDnsAlias = "GET /dns/zone/{zone_id}/cname-entry/{id}" + PostAuth = "POST /auth/access_token" ) @@ -257,6 +287,8 @@ func main() { var err error createdTokenCount.Store(0) mux := http.NewServeMux() + dnsDB := sgcpdnstesthelper.NewDB() + dnsApi := sgcpdnstesthelper.NewApi(dnsDB) users, err = loadUsers() if err != nil { @@ -273,6 +305,9 @@ func main() { // url: /file/fs/{id}/client/{clientID} mux.HandleFunc(DeleteFileClient, deleteFileClient) + // url: /dns/alias/{id} + mux.HandleFunc(GetDnsAlias, dnsGetAliasHandler(dnsApi)) + // url: /auth/access_token mux.HandleFunc(PostAuth, postAuthToken) From 09da3dedbd18088da9ddeb67f46bd15a1a3775d6 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 20:25:31 +0200 Subject: [PATCH 17/19] [util/sgcp] Rename `Alias` to `CName` in DNS configuration and update references --- util/sgcp/config.go | 2 +- util/sgcp/config_test.go | 4 ++-- util/sgcp/dns.go | 6 +++--- util/testsgcphelper/text/config.yaml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/util/sgcp/config.go b/util/sgcp/config.go index 34700ec68..5458f98c4 100644 --- a/util/sgcp/config.go +++ b/util/sgcp/config.go @@ -34,7 +34,7 @@ type ( DNSConfig struct { BaseURL string `yaml:"base_url"` Path struct { - Alias string `yaml:"alias"` + CName string `yaml:"cname"` Zone string `yaml:"zone"` } `yaml:"path"` } diff --git a/util/sgcp/config_test.go b/util/sgcp/config_test.go index 457671382..95128ded6 100644 --- a/util/sgcp/config_test.go +++ b/util/sgcp/config_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/opensvc/om3/v3/util/sgcp/testsgcphelper" + "github.com/opensvc/om3/v3/util/testsgcphelper" ) func Setup(t *testing.T) func() { @@ -67,7 +67,7 @@ func TestLoadConfig(t *testing.T) { // Test DNS configuration assert.Equal(t, "https://127.0.0.1:1215/dns", cfg.DNS.BaseURL) - assert.Equal(t, "/alias", cfg.DNS.Path.Alias) + assert.Equal(t, "/cname-entry", cfg.DNS.Path.CName) assert.Equal(t, "/zone", cfg.DNS.Path.Zone) // Test auth configuration diff --git a/util/sgcp/dns.go b/util/sgcp/dns.go index 67c17012f..1f89ccdfb 100644 --- a/util/sgcp/dns.go +++ b/util/sgcp/dns.go @@ -129,7 +129,7 @@ func (a *DNSAPI) getAliasesURL(zoneID, name, uuid string) string { values.Set("id", uuid) } base := strings.TrimRight(a.config.DNS.BaseURL, "/") - path := a.config.DNS.Path.Alias + path := a.config.DNS.Path.CName return fmt.Sprintf("%s%s?%s", base, path, values.Encode()) } @@ -137,7 +137,7 @@ func (a *DNSAPI) getAliasesURL(zoneID, name, uuid string) string { func (a *DNSAPI) getAliasesCreateURL(zoneID string) string { base := strings.TrimRight(a.config.DNS.BaseURL, "/") zonePath := a.config.DNS.Path.Zone - aliasPath := a.config.DNS.Path.Alias + aliasPath := a.config.DNS.Path.CName return fmt.Sprintf("%s%s/%s%s", base, zonePath, zoneID, aliasPath) } @@ -145,6 +145,6 @@ func (a *DNSAPI) getAliasesCreateURL(zoneID string) string { func (a *DNSAPI) getAliasURL(zoneID, aliasUUID string) string { base := strings.TrimRight(a.config.DNS.BaseURL, "/") zonePath := a.config.DNS.Path.Zone - aliasPath := a.config.DNS.Path.Alias + aliasPath := a.config.DNS.Path.CName return fmt.Sprintf("%s%s/%s%s/%s", base, zonePath, zoneID, aliasPath, aliasUUID) } diff --git a/util/testsgcphelper/text/config.yaml b/util/testsgcphelper/text/config.yaml index 6aae22572..c02fe89a8 100644 --- a/util/testsgcphelper/text/config.yaml +++ b/util/testsgcphelper/text/config.yaml @@ -12,7 +12,7 @@ files: dns: base_url: "https://127.0.0.1:1215/dns" path: - alias: "/alias" + cname: "/cname-entry" zone: "/zone" # Authentication and caching settings From 66655ba92e7f45bf8bfc68572ee19ce5943b38a6 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 20:25:48 +0200 Subject: [PATCH 18/19] [util/sgcpserverfortest] Rename `Alias` to `CName` in DNS configuration and update references --- util/sgcpserverfortest/main.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/util/sgcpserverfortest/main.go b/util/sgcpserverfortest/main.go index 16ddbe4bd..b463a5972 100644 --- a/util/sgcpserverfortest/main.go +++ b/util/sgcpserverfortest/main.go @@ -175,14 +175,13 @@ func deleteFileClient(w http.ResponseWriter, r *http.Request) { func dnsGetAliasHandler(a *sgcpdnstesthelper.Api) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - zoneID := r.PathValue("zone_id") - id := r.PathValue("id") + zoneID := r.PathValue("zoneID") query := r.URL.Query() - qName := query.Get("name") - qUUID := query.Get("uuid") + name := query.Get("name") + id := query.Get("id") - slog.Info(GetDnsAlias, "zoneID", zoneID, "id", id) - alias, ok := a.DB.Search(zoneID, qName, qUUID) + slog.Info(GetDnsAlias, "zoneID", zoneID, "cnameID", id, "name", name) + alias, ok := a.DB.Search(zoneID, name, id) if !ok { logStatusCode(GetDnsAlias, http.StatusNotFound) http.Error(w, fmt.Sprintf("no such alias %s", id), http.StatusNotFound) @@ -257,7 +256,7 @@ var ( DeleteFileClient = "DELETE /file/fs/{id}/client/{clientID}" // TODO: verify path - GetDnsAlias = "GET /dns/zone/{zone_id}/cname-entry/{id}" + GetDnsAlias = "GET /dns/zone/{zoneID}/cname-entry" PostAuth = "POST /auth/access_token" ) From 8bc14332ee6c466353b0942f2f7cd3e20921258e Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Fri, 10 Jul 2026 20:35:10 +0200 Subject: [PATCH 19/19] [core/driverdb] Add support for `resipsgcp_dnsalias` driver --- core/driverdb/drivers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/core/driverdb/drivers.go b/core/driverdb/drivers.go index a67962248..25765f0a9 100644 --- a/core/driverdb/drivers.go +++ b/core/driverdb/drivers.go @@ -39,6 +39,7 @@ import ( _ "github.com/opensvc/om3/v3/drivers/resfszfs" _ "github.com/opensvc/om3/v3/drivers/resiphost" _ "github.com/opensvc/om3/v3/drivers/resiproute" + _ "github.com/opensvc/om3/v3/drivers/resipsgcp_dnsalias" _ "github.com/opensvc/om3/v3/drivers/ressharenfs" _ "github.com/opensvc/om3/v3/drivers/ressyncrsync" _ "github.com/opensvc/om3/v3/drivers/ressyncsymsnapvx"