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" diff --git a/drivers/resfssgcp_nfs/main_test.go b/drivers/resfssgcp_nfs/main_test.go index e926933bd..4fcdfcdf1 100644 --- a/drivers/resfssgcp_nfs/main_test.go +++ b/drivers/resfssgcp_nfs/main_test.go @@ -7,6 +7,9 @@ import ( "net/http/httptest" "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" @@ -14,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 { @@ -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/resipsgcp_dnsalias/main.go b/drivers/resipsgcp_dnsalias/main.go index 0cfb440d8..2f0fda5e1 100644 --- a/drivers/resipsgcp_dnsalias/main.go +++ b/drivers/resipsgcp_dnsalias/main.go @@ -2,25 +2,205 @@ package resipsgcp_dnsalias import ( "context" + "errors" + "fmt" + "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 - 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"` + + 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"` + Target string `json:"target,omitempty"` + FQDN string `json:"fqdn,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + } + + aliasListResponse struct { + Aliases []sgcp.Alias `json:"aliases"` + } + + 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) + } + + authInfoProvider interface { + GetAuthInfo(string) (*sgcp.AuthInfo, error) } ) -// New creates a new SGCP NFS filesystem resource driver func New() resource.Driver { return &T{} } +func (t *T) Configure() error { + 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() + } + + // 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") + } + if t.UUID == "" && t.Name == "" { + 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) + } + + 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) + + if t.api != nil { + mgr.api = t.api + } else { + mgr.api = sgcp.NewDNSAPI(cfg, httpClient, t.Log(), tokenFactory) + } + + t.mgr = mgr + return nil +} + +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) + } + return t.mgr.delete(ctx) +} + func (t *T) Status(ctx context.Context) status.T { - return status.NotApplicable + // 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) + } + + 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) Boot(ctx context.Context) error { + return t.Stop(ctx) } diff --git a/drivers/resipsgcp_dnsalias/main_test.go b/drivers/resipsgcp_dnsalias/main_test.go new file mode 100644 index 000000000..85067112b --- /dev/null +++ b/drivers/resipsgcp_dnsalias/main_test.go @@ -0,0 +1,586 @@ +package resipsgcp_dnsalias + +import ( + "context" + "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/sgcpdnstesthelper" + "github.com/opensvc/om3/v3/util/testsgcphelper" + + "github.com/opensvc/om3/v3/util/sgcp" +) + +// 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("") + } +} + +// 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 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", + }, + }, + }, + { + 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, + }, + } + + 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"}, + }, + }, + } + + 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 TestStart(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + defer setup(t)() + + 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", + }, + }, + }, + } + + 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) + }) + + 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") + }) + + 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") + }) +} + +func TestStop(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + defer setup(t)() + + 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", + }, + }, + }, + { + Name: "name-none", + UUID: "uuid-none", + ZoneID: "z1", + AliasL: []sgcp.Alias{ + { + UUID: "uuid-none", + Name: "name-none", + Target: "none.", + FQDN: "name2.z1", + ZoneID: "z1", + }, + }, + }, + } + + 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("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("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) + }) + + 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("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) + }) +} diff --git a/drivers/resipsgcp_dnsalias/mgr.go b/drivers/resipsgcp_dnsalias/mgr.go new file mode 100644 index 000000000..ef4a606ff --- /dev/null +++ b/drivers/resipsgcp_dnsalias/mgr.go @@ -0,0 +1,140 @@ +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) { + // 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 + } + 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 +} 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 +} 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 ( 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 new file mode 100644 index 000000000..1f89ccdfb --- /dev/null +++ b/util/sgcp/dns.go @@ -0,0 +1,150 @@ +package sgcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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) (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 + 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, 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) (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 + 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, 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) 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. +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.CName + 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 + aliasPath := a.config.DNS.Path.CName + 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 + aliasPath := a.config.DNS.Path.CName + return fmt.Sprintf("%s%s/%s%s/%s", base, zonePath, zoneID, aliasPath, aliasUUID) +} diff --git a/util/sgcpdnstesthelper/main.go b/util/sgcpdnstesthelper/main.go new file mode 100644 index 000000000..4b095b117 --- /dev/null +++ b/util/sgcpdnstesthelper/main.go @@ -0,0 +1,223 @@ +package sgcpdnstesthelper + +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 + + callCount Counters + } + + Counters struct { + Delete int + Update int + Search int + } + + 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) + a.rLock.RLock() + a.callCount.Search++ + a.rLock.RUnlock() + 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 + t.update(v) + return v.asAlias(), nil +} + +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 (db *DB) update(v *DBEntry) { + if v == nil { + return + } + nv := v.clone() + db.rLock.Lock() + defer db.rLock.Unlock() + if nv.UUID != "" { + db.byId[nv.UUID] = nv + } + if nv.Name != "" && nv.ZoneID != "" { + db.byZoneAndName[nv.Name+"@"+nv.ZoneID] = nv + } + if nv.UUID != "" && nv.Name != "" && nv.ZoneID != "" { + db.byIdZoneAndName[nv.Name+"@"+nv.ZoneID+"@"+nv.UUID] = nv + } + db.callCount.Update++ +} + +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 { + db.update(v.clone()) + } +} + +func (db *DB) CallCounts() Counters { + db.rLock.RLock() + calls := db.callCount + db.rLock.RUnlock() + return calls +} + +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 (db *DB) search(zoneID, name, uuid string) (v *DBEntry, ok bool) { + db.rLock.RLock() + defer db.rLock.RUnlock() + if zoneID != "" && name != "" && uuid != "" { + v, ok = db.byIdZoneAndName[name+"@"+zoneID+"@"+uuid] + } else if zoneID != "" && name != "" { + v, ok = db.byZoneAndName[name+"@"+zoneID] + } else if uuid != "" { + v, ok = db.byId[uuid] + } + v = v.clone() + 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 (db *DB) Search(zoneID, name, uuid string) (alias *sgcp.Alias, ok bool) { + v, ok := db.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 + } + 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 +} diff --git a/util/sgcpserverfortest/main.go b/util/sgcpserverfortest/main.go new file mode 100644 index 000000000..b463a5972 --- /dev/null +++ b/util/sgcpserverfortest/main.go @@ -0,0 +1,315 @@ +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" + + "github.com/opensvc/om3/v3/util/sgcp" + "github.com/opensvc/om3/v3/util/sgcpdnstesthelper" +) + +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 dnsGetAliasHandler(a *sgcpdnstesthelper.Api) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + zoneID := r.PathValue("zoneID") + query := r.URL.Query() + name := query.Get("name") + id := query.Get("id") + + 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) + 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") + 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}" + + // TODO: verify path + GetDnsAlias = "GET /dns/zone/{zoneID}/cname-entry" + + 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() + dnsDB := sgcpdnstesthelper.NewDB() + dnsApi := sgcpdnstesthelper.NewApi(dnsDB) + + 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: /dns/alias/{id} + mux.HandleFunc(GetDnsAlias, dnsGetAliasHandler(dnsApi)) + + // url: /auth/access_token + mux.HandleFunc(PostAuth, postAuthToken) + + log.Println("Listening on :8000") + log.Fatal(http.ListenAndServe(":8000", mux)) +} 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 96% rename from util/sgcp/testsgcphelper/text/config.yaml rename to util/testsgcphelper/text/config.yaml index 6aae22572..c02fe89a8 100644 --- a/util/sgcp/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