From 9054790466f6c042613c4af924c00a949198c69e Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 29 Jun 2026 15:12:05 +0200 Subject: [PATCH 01/33] [resfssgcp_nfs] Add driver implementation and keyword definitions - Implemented the `resfssgcp_nfs` driver with manifest and capabilities registration. - Added keyword definitions for UUID, host, permission, exclusive, protocol, secret, and endpoint. - Embedded text descriptions for keywords to facilitate configuration and usage. --- drivers/resfssgcp_nfs/caps.go | 15 ++++ drivers/resfssgcp_nfs/manifest.go | 90 ++++++++++++++++++++++++ drivers/resfssgcp_nfs/text/kw/endpoint | 1 + drivers/resfssgcp_nfs/text/kw/exclusive | 1 + drivers/resfssgcp_nfs/text/kw/host | 1 + drivers/resfssgcp_nfs/text/kw/permission | 1 + drivers/resfssgcp_nfs/text/kw/protocol | 1 + drivers/resfssgcp_nfs/text/kw/secret | 1 + drivers/resfssgcp_nfs/text/kw/uuid | 1 + 9 files changed, 112 insertions(+) create mode 100644 drivers/resfssgcp_nfs/caps.go create mode 100644 drivers/resfssgcp_nfs/manifest.go create mode 100644 drivers/resfssgcp_nfs/text/kw/endpoint create mode 100644 drivers/resfssgcp_nfs/text/kw/exclusive create mode 100644 drivers/resfssgcp_nfs/text/kw/host create mode 100644 drivers/resfssgcp_nfs/text/kw/permission create mode 100644 drivers/resfssgcp_nfs/text/kw/protocol create mode 100644 drivers/resfssgcp_nfs/text/kw/secret create mode 100644 drivers/resfssgcp_nfs/text/kw/uuid diff --git a/drivers/resfssgcp_nfs/caps.go b/drivers/resfssgcp_nfs/caps.go new file mode 100644 index 000000000..ef7d03b74 --- /dev/null +++ b/drivers/resfssgcp_nfs/caps.go @@ -0,0 +1,15 @@ +package resfssgcp_nfs + +import ( + "context" + + "github.com/opensvc/om3/v3/util/capabilities" +) + +func init() { + capabilities.Register(capabilitiesScanner) +} + +func capabilitiesScanner(ctx context.Context) ([]string, error) { + return []string{drvID.Cap()}, nil +} \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/manifest.go b/drivers/resfssgcp_nfs/manifest.go new file mode 100644 index 000000000..67faafe7a --- /dev/null +++ b/drivers/resfssgcp_nfs/manifest.go @@ -0,0 +1,90 @@ +package resfssgcp_nfs + +import ( + "embed" + + "github.com/opensvc/om3/v3/core/datarecv" + "github.com/opensvc/om3/v3/core/driver" + "github.com/opensvc/om3/v3/core/keywords" + "github.com/opensvc/om3/v3/core/manifest" + "github.com/opensvc/om3/v3/core/naming" +) + +var ( + //go:embed text + fs embed.FS + + drvID = driver.NewID(driver.GroupFS, "sgcp_nfs") + + kws = []*keywords.Keyword{ + { + Attr: "UUID", + Option: "uuid", + Required: true, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/uuid"), + }, + { + Attr: "Host", + Option: "host", + Required: true, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/host"), + }, + { + Attr: "Permission", + Option: "permission", + Candidates: []string{"read-only", "read-write"}, + Default: DefaultPermission, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/permission"), + }, + { + Attr: "Exclusive", + Option: "exclusive", + Converter: "boolean", + Default: "false", // TODO: move to config + Scopable: true, + Text: keywords.NewText(fs, "text/kw/exclusive"), + }, + { + Attr: "Protocol", + Option: "protocol", + Candidates: []string{"nfs4.1"}, + Default: DefaultProtocol, // TODO: move to config + Scopable: true, + Text: keywords.NewText(fs, "text/kw/protocol"), + }, + { + Attr: "Secret", + Option: "secret", + Default: SecretDefaultName, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/secret"), + }, + { + Attr: "Endpoint", + Option: "endpoint", + Default: "https://localhost/v1", // TODO: move to config + Scopable: true, + Text: keywords.NewText(fs, "text/kw/endpoint"), + }, + } +) + +func init() { + driver.Register(drvID, New) +} + +func (t *T) DriverID() driver.ID { + return drvID +} + +// Manifest exposes to the core the input expected by the driver. +func (t *T) Manifest() *manifest.T { + m := manifest.New(drvID, t) + m.Kinds.Or(naming.KindSvc, naming.KindVol) + m.AddKeywords(kws...) + m.AddKeywords(datarecv.Keywords("DataRecv.")...) + return m +} diff --git a/drivers/resfssgcp_nfs/text/kw/endpoint b/drivers/resfssgcp_nfs/text/kw/endpoint new file mode 100644 index 000000000..2ee02e1f9 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/endpoint @@ -0,0 +1 @@ +The api url to use for filesystem management. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/text/kw/exclusive b/drivers/resfssgcp_nfs/text/kw/exclusive new file mode 100644 index 000000000..2dd6ed1a3 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/exclusive @@ -0,0 +1 @@ +If exclusive, on start, drop all grants for other hosts on the filesystem. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/text/kw/host b/drivers/resfssgcp_nfs/text/kw/host new file mode 100644 index 000000000..f26b0eed5 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/host @@ -0,0 +1 @@ +The client ip address or resolvable name granted access to the filesystem on start. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/text/kw/permission b/drivers/resfssgcp_nfs/text/kw/permission new file mode 100644 index 000000000..2898a7949 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/permission @@ -0,0 +1 @@ +The access permission on the filesystem granted to the host. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/text/kw/protocol b/drivers/resfssgcp_nfs/text/kw/protocol new file mode 100644 index 000000000..cc1f7a22a --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/protocol @@ -0,0 +1 @@ +The protocol used to access the filesystem. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/text/kw/secret b/drivers/resfssgcp_nfs/text/kw/secret new file mode 100644 index 000000000..1c8940a99 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/secret @@ -0,0 +1 @@ +The name of the secret object in the same namespace containing the api access credentials. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs/text/kw/uuid b/drivers/resfssgcp_nfs/text/kw/uuid new file mode 100644 index 000000000..ba314e953 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/uuid @@ -0,0 +1 @@ +The unique identifier of the filesystem object on the provider. \ No newline at end of file From 223fde4b5f7011204dc518f4346d10191f964470 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 29 Jun 2026 15:32:05 +0200 Subject: [PATCH 02/33] [resfssgcp_nfs_cg] Add driver implementation with manifest and keywords - Introduced `resfssgcp_nfs_cg` driver with capabilities and manifest registration. - Defined keywords: `uuid`, `az`, `secret`, `endpoint`, `timeout`, and `failover`. - Embedded text descriptions for keyword configurations. --- drivers/resfssgcp_nfs_cg/caps.go | 15 +++++ drivers/resfssgcp_nfs_cg/manifest.go | 80 +++++++++++++++++++++++ drivers/resfssgcp_nfs_cg/text/kw/az | 1 + drivers/resfssgcp_nfs_cg/text/kw/endpoint | 1 + drivers/resfssgcp_nfs_cg/text/kw/failover | 2 + drivers/resfssgcp_nfs_cg/text/kw/secret | 1 + drivers/resfssgcp_nfs_cg/text/kw/timeout | 1 + drivers/resfssgcp_nfs_cg/text/kw/uuid | 1 + 8 files changed, 102 insertions(+) create mode 100644 drivers/resfssgcp_nfs_cg/caps.go create mode 100644 drivers/resfssgcp_nfs_cg/manifest.go create mode 100644 drivers/resfssgcp_nfs_cg/text/kw/az create mode 100644 drivers/resfssgcp_nfs_cg/text/kw/endpoint create mode 100644 drivers/resfssgcp_nfs_cg/text/kw/failover create mode 100644 drivers/resfssgcp_nfs_cg/text/kw/secret create mode 100644 drivers/resfssgcp_nfs_cg/text/kw/timeout create mode 100644 drivers/resfssgcp_nfs_cg/text/kw/uuid diff --git a/drivers/resfssgcp_nfs_cg/caps.go b/drivers/resfssgcp_nfs_cg/caps.go new file mode 100644 index 000000000..98149b432 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/caps.go @@ -0,0 +1,15 @@ +package resfssgcp_nfs_cg + +import ( + "context" + + "github.com/opensvc/om3/v3/util/capabilities" +) + +func init() { + capabilities.Register(capabilitiesScanner) +} + +func capabilitiesScanner(ctx context.Context) ([]string, error) { + return []string{drvID.Cap()}, nil +} \ No newline at end of file diff --git a/drivers/resfssgcp_nfs_cg/manifest.go b/drivers/resfssgcp_nfs_cg/manifest.go new file mode 100644 index 000000000..cf11ffe57 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/manifest.go @@ -0,0 +1,80 @@ +package resfssgcp_nfs_cg + +import ( + "embed" + + "github.com/opensvc/om3/v3/core/driver" + "github.com/opensvc/om3/v3/core/keywords" + "github.com/opensvc/om3/v3/core/manifest" + "github.com/opensvc/om3/v3/core/naming" +) + +var ( + //go:embed text + fs embed.FS + + drvID = driver.NewID(driver.GroupFS, "sgcp_nfs_cg") + + kws = []*keywords.Keyword{ + { + Attr: "UUID", + Option: "uuid", + Required: true, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/uuid"), + }, + { + Attr: "AZ", + Option: "az", + Default: "{node.labels.az}", + Scopable: true, + Text: keywords.NewText(fs, "text/kw/az"), + }, + { + Attr: "Secret", + Option: "secret", + Default: SecretDefaultName, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/secret"), + }, + { + Attr: "Endpoint", + Option: "endpoint", + Default: "https://localhost/v1", // TODO: move to config + Scopable: true, + Text: keywords.NewText(fs, "text/kw/endpoint"), + }, + { + Attr: "Timeout", + Option: "timeout", + Converter: "duration", + Default: "300s", // TODO: move to config + Scopable: true, + Text: keywords.NewText(fs, "text/kw/timeout"), + }, + { + Attr: "Failover", + Option: "failover", + Converter: "boolean", + Default: "true", + Scopable: true, + Text: keywords.NewText(fs, "text/kw/failover"), + }, + } +) + +func init() { + driver.Register(drvID, New) +} + +func (t *T) DriverID() driver.ID { + return drvID +} + +// Manifest exposes to the core the input expected by the driver. +func (t *T) Manifest() *manifest.T { + m := manifest.New(drvID, t) + m.Kinds.Or(naming.KindSvc, naming.KindVol) + m.AddKeywords(kws...) + return m +} diff --git a/drivers/resfssgcp_nfs_cg/text/kw/az b/drivers/resfssgcp_nfs_cg/text/kw/az new file mode 100644 index 000000000..494d5e5af --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/text/kw/az @@ -0,0 +1 @@ +The availability zone of the local node. Use a reference to a node label or a scoped value. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs_cg/text/kw/endpoint b/drivers/resfssgcp_nfs_cg/text/kw/endpoint new file mode 100644 index 000000000..45927f0cf --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/text/kw/endpoint @@ -0,0 +1 @@ +The fileaas api url. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs_cg/text/kw/failover b/drivers/resfssgcp_nfs_cg/text/kw/failover new file mode 100644 index 000000000..f14fedda9 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/text/kw/failover @@ -0,0 +1,2 @@ +Allows daemon resource start to call operation 'switchover' when the 'failover' operation fails with status code 412. +When '--force' option is used, the setting has no effect because operation during start is always 'failover'. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs_cg/text/kw/secret b/drivers/resfssgcp_nfs_cg/text/kw/secret new file mode 100644 index 000000000..1c8940a99 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/text/kw/secret @@ -0,0 +1 @@ +The name of the secret object in the same namespace containing the api access credentials. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs_cg/text/kw/timeout b/drivers/resfssgcp_nfs_cg/text/kw/timeout new file mode 100644 index 000000000..dc7375f78 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/text/kw/timeout @@ -0,0 +1 @@ +The maximum time to wait for a consistency group switchover. \ No newline at end of file diff --git a/drivers/resfssgcp_nfs_cg/text/kw/uuid b/drivers/resfssgcp_nfs_cg/text/kw/uuid new file mode 100644 index 000000000..c05ce13c4 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/text/kw/uuid @@ -0,0 +1 @@ +The fileaas consistency group id. \ No newline at end of file From e8d876ff14369e30c70ead4f5fd73bb7004ab435 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 29 Jun 2026 15:48:12 +0200 Subject: [PATCH 03/33] [resipsgcp_dnsalias] Add driver implementation with manifest and keyword definitions - Introduced `resipsgcp_dnsalias` driver with capabilities and manifest registration. - Defined keywords: `uuid`, `name`, `target`, `zone_id`, `secret`, and `endpoint`. - Added embedded text descriptions for keyword configurations to support DNS alias management. --- drivers/resipsgcp_dnsalias/caps.go | 15 ++++ drivers/resipsgcp_dnsalias/manifest.go | 79 +++++++++++++++++++ drivers/resipsgcp_dnsalias/text/kw/endpoint | 1 + drivers/resipsgcp_dnsalias/text/kw/name | 1 + drivers/resipsgcp_dnsalias/text/kw/secret | 1 + drivers/resipsgcp_dnsalias/text/kw/target | 1 + .../resipsgcp_dnsalias/text/kw/target.default | 1 + drivers/resipsgcp_dnsalias/text/kw/uuid | 3 + drivers/resipsgcp_dnsalias/text/kw/zone_id | 1 + 9 files changed, 103 insertions(+) create mode 100644 drivers/resipsgcp_dnsalias/caps.go create mode 100644 drivers/resipsgcp_dnsalias/manifest.go create mode 100644 drivers/resipsgcp_dnsalias/text/kw/endpoint create mode 100644 drivers/resipsgcp_dnsalias/text/kw/name create mode 100644 drivers/resipsgcp_dnsalias/text/kw/secret create mode 100644 drivers/resipsgcp_dnsalias/text/kw/target create mode 100644 drivers/resipsgcp_dnsalias/text/kw/target.default create mode 100644 drivers/resipsgcp_dnsalias/text/kw/uuid create mode 100644 drivers/resipsgcp_dnsalias/text/kw/zone_id diff --git a/drivers/resipsgcp_dnsalias/caps.go b/drivers/resipsgcp_dnsalias/caps.go new file mode 100644 index 000000000..453484f14 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/caps.go @@ -0,0 +1,15 @@ +package resipsgcp_dnsalias + +import ( + "context" + + "github.com/opensvc/om3/v3/util/capabilities" +) + +func init() { + capabilities.Register(capabilitiesScanner) +} + +func capabilitiesScanner(ctx context.Context) ([]string, error) { + return []string{drvID.Cap()}, nil +} \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/manifest.go b/drivers/resipsgcp_dnsalias/manifest.go new file mode 100644 index 000000000..06d44c599 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/manifest.go @@ -0,0 +1,79 @@ +package resipsgcp_dnsalias + +import ( + "embed" + + "github.com/opensvc/om3/v3/core/driver" + "github.com/opensvc/om3/v3/core/keywords" + "github.com/opensvc/om3/v3/core/manifest" + "github.com/opensvc/om3/v3/core/naming" +) + +var ( + //go:embed text + fs embed.FS + + drvID = driver.NewID(driver.GroupIP, "sgcp_dnsalias") + + kws = []*keywords.Keyword{ + { + Attr: "UUID", + Option: "uuid", + Required: false, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/uuid"), + }, + { + Attr: "Name", + Option: "name", + Required: false, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/name"), + }, + { + Attr: "Target", + Option: "target", + Required: false, + Scopable: true, + DefaultText: keywords.NewText(fs, "text/kw/target_default"), + Text: keywords.NewText(fs, "text/kw/target"), + }, + { + Attr: "ZoneID", + Option: "zone_id", + Required: true, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/zone_id"), + }, + { + Attr: "Secret", + Option: "secret", + Default: SecretDefaultName, + Scopable: true, + Text: keywords.NewText(fs, "text/kw/secret"), + }, + { + Attr: "Endpoint", + Option: "endpoint", + Default: "https://localhost/v1", // TODO: move to config + Scopable: true, + Text: keywords.NewText(fs, "text/kw/endpoint"), + }, + } +) + +func init() { + driver.Register(drvID, New) +} + +func (t *T) DriverID() driver.ID { + return drvID +} + +// Manifest exposes to the core the input expected by the driver. +func (t *T) Manifest() *manifest.T { + m := manifest.New(drvID, t) + m.Kinds.Or(naming.KindSvc, naming.KindVol) + m.AddKeywords(kws...) + return m +} diff --git a/drivers/resipsgcp_dnsalias/text/kw/endpoint b/drivers/resipsgcp_dnsalias/text/kw/endpoint new file mode 100644 index 000000000..9e15b5c2e --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/endpoint @@ -0,0 +1 @@ +The api url to use for DNS alias management. \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/text/kw/name b/drivers/resipsgcp_dnsalias/text/kw/name new file mode 100644 index 000000000..ce123c3f3 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/name @@ -0,0 +1 @@ +The DNS alias name to control. If set, ensure alias name is correct during start, or check. \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/text/kw/secret b/drivers/resipsgcp_dnsalias/text/kw/secret new file mode 100644 index 000000000..1c8940a99 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/secret @@ -0,0 +1 @@ +The name of the secret object in the same namespace containing the api access credentials. \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/text/kw/target b/drivers/resipsgcp_dnsalias/text/kw/target new file mode 100644 index 000000000..8fb4d375f --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/target @@ -0,0 +1 @@ +The DNS record name to point the alias to. \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/text/kw/target.default b/drivers/resipsgcp_dnsalias/text/kw/target.default new file mode 100644 index 000000000..484508391 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/target.default @@ -0,0 +1 @@ +The node hostname \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/text/kw/uuid b/drivers/resipsgcp_dnsalias/text/kw/uuid new file mode 100644 index 000000000..bb7836e31 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/uuid @@ -0,0 +1,3 @@ +The DNS alias uuid to control. If set, the stop does not delete the DNS API object, +but sets 'none.' as the target instead. +If not set, the start tries to detect uuid to update, or create new one, the stop delete alias with name. \ No newline at end of file diff --git a/drivers/resipsgcp_dnsalias/text/kw/zone_id b/drivers/resipsgcp_dnsalias/text/kw/zone_id new file mode 100644 index 000000000..20af4f47b --- /dev/null +++ b/drivers/resipsgcp_dnsalias/text/kw/zone_id @@ -0,0 +1 @@ +The DNS zone identifier. \ No newline at end of file From 31d03ecf52e26ea21ad771655c572edefc1ec67c Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 29 Jun 2026 18:39:35 +0200 Subject: [PATCH 04/33] [resfshost] Remove unused keyword group variables to simplify manifest configuration --- drivers/resfshost/manifest.go | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/drivers/resfshost/manifest.go b/drivers/resfshost/manifest.go index 6d005bf8a..05f89f9c8 100644 --- a/drivers/resfshost/manifest.go +++ b/drivers/resfshost/manifest.go @@ -74,38 +74,16 @@ var ( Text: keywords.NewText(fs, "text/kw/check_read"), } - KeywordsVirtual = []*keywords.Keyword{ - &KeywordMountPoint, - &KeywordMountOptions, - &KeywordDevice, - &KeywordStatTimeout, - &KeywordZone, - &KeywordCheckRead, - } - KeywordsBase = []*keywords.Keyword{ &KeywordMountPoint, &KeywordDevice, &KeywordMountOptions, &KeywordStatTimeout, - &manifest.KWSCSIPersistentReservationKey, - &manifest.KWSCSIPersistentReservationEnabled, - &manifest.KWSCSIPersistentReservationNoPreemptAbort, &KeywordPromoteRW, &KeywordMKFSOptions, &KeywordZone, &KeywordCheckRead, } - - KeywordsPooling = []*keywords.Keyword{ - &KeywordMountPoint, - &KeywordDevice, - &KeywordMountOptions, - &KeywordStatTimeout, - &KeywordMKFSOptions, - &KeywordZone, - &KeywordCheckRead, - } ) func init() { From 62a1687ce0705ba3c609d3c59622e6ea9a929fa1 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Sat, 4 Jul 2026 12:09:47 +0200 Subject: [PATCH 05/33] [core/object] Add `DecodeKeys` method to support batch key decoding --- core/object/datastore.go | 1 + core/object/datastore_key_decode.go | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/core/object/datastore.go b/core/object/datastore.go index f391a57c1..61a895b54 100644 --- a/core/object/datastore.go +++ b/core/object/datastore.go @@ -30,6 +30,7 @@ type ( AddKey(name string, b []byte) error ChangeKey(name string, b []byte) error DecodeKey(name string) ([]byte, error) + DecodeKeys(name ...string) ([][]byte, error) EditKey(name string) error InstallKey(name string) error InstallKeyTo(KVInstall) error diff --git a/core/object/datastore_key_decode.go b/core/object/datastore_key_decode.go index 53bd60849..d7672c8c6 100644 --- a/core/object/datastore_key_decode.go +++ b/core/object/datastore_key_decode.go @@ -27,3 +27,16 @@ func (t *dataStore) decode(keyname string) ([]byte, error) { func (t *dataStore) DecodeKey(keyname string) ([]byte, error) { return t.decode(keyname) } + +// DecodeKeys returns the decoded bytes of the key value +func (t *dataStore) DecodeKeys(keynames ...string) ([][]byte, error) { + var l [][]byte + for _, keyname := range keynames { + if b, err := t.decode(keyname); err != nil { + return nil, fmt.Errorf("decode key %s: %w", keyname, err) + } else { + l = append(l, b) + } + } + return l, nil +} From afd82fe5927bd6006e39df730d0f3bce1247411b Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Sat, 4 Jul 2026 12:14:50 +0200 Subject: [PATCH 06/33] [sgcp] Add AuthInfo struct and GetAuthInfo utility function - Defined `AuthInfo` struct to encapsulate authentication details (Account ID, Client ID, Client Secret, Signature). - Implemented `GetAuthInfo` function to retrieve and decode auth details from a datastore path. --- drivers/sgcphelper/main.go | 39 ++++++++++++++++++++++++++++++++++++++ util/sgcp/auth.go | 13 +++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 drivers/sgcphelper/main.go create mode 100644 util/sgcp/auth.go diff --git a/drivers/sgcphelper/main.go b/drivers/sgcphelper/main.go new file mode 100644 index 000000000..e2066f0d5 --- /dev/null +++ b/drivers/sgcphelper/main.go @@ -0,0 +1,39 @@ +package sgcphelper + +import ( + "fmt" + + "github.com/opensvc/om3/v3/core/naming" + "github.com/opensvc/om3/v3/core/object" + "github.com/opensvc/om3/v3/util/sgcp" +) + +// GetAuthInfo retrieves authentication information from the specified datastore path and returns an AuthInfo struct. +func GetAuthInfo(datastorePath string) (*sgcp.AuthInfo, error) { + var ( + ds object.DataStore + dsValues [][]byte + ) + secPath, err := naming.ParsePath(datastorePath) + if err != nil { + return nil, fmt.Errorf("parse secret path: %w", err) + } + + ds, err = object.NewSec(secPath, object.WithVolatile(true)) + if err != nil { + return nil, fmt.Errorf("get secret %s: %w", secPath, err) + } + + dsValues, err = ds.DecodeKeys("account_id", "client_id", "client_secret") + if err != nil { + return nil, fmt.Errorf("decode auth keys from %s: %w", secPath, err) + } + authInfo := &sgcp.AuthInfo{ + AccountID: string(dsValues[0]), + ClientID: string(dsValues[1]), + ClientSecret: string(dsValues[2]), + + Signature: fmt.Sprintf("sgcp-authinfo-%s-%s", secPath.Namespace, secPath.Name), + } + return authInfo, nil +} diff --git a/util/sgcp/auth.go b/util/sgcp/auth.go new file mode 100644 index 000000000..b349430b1 --- /dev/null +++ b/util/sgcp/auth.go @@ -0,0 +1,13 @@ +package sgcp + +type ( + // AuthInfo represents authentication details required for API access. + AuthInfo struct { + AccountID string + ClientID string + ClientSecret string + + // Signature identifies auth info + Signature string + } +) From 9695713ba2eda2e0859fec26f412e16818a66170 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Sat, 4 Jul 2026 12:18:57 +0200 Subject: [PATCH 07/33] [ageingcache] Add Outputter struct for managing output functions - Introduced `Outputter` struct to encapsulate output function logic. - Added `NewOutputter` constructor and `Output` method for output generation. --- util/ageingcache/main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/util/ageingcache/main.go b/util/ageingcache/main.go index 88df2d3c5..6174b8bdd 100644 --- a/util/ageingcache/main.go +++ b/util/ageingcache/main.go @@ -7,12 +7,27 @@ import ( "github.com/opensvc/fcache" "github.com/opensvc/fcntllock" "github.com/opensvc/flock" + "github.com/opensvc/om3/v3/core/rawconfig" "github.com/opensvc/om3/v3/util/xsession" ) var maxLockDuration = 30 * time.Second +type ( + Outputter struct { + f func() ([]byte, error) + } +) + +func NewOutputter(f func() ([]byte, error)) *Outputter { + return &Outputter{f: f} +} + +func (t *Outputter) Output() ([]byte, error) { + return t.f() +} + // Output manage output session function cache func Output(o fcache.Outputter, sig string, maxAge time.Duration) (out []byte, err error) { cacheDir := cacheDir() From d66b7227d4964dbb7e296c1ce4b680f07f6827f5 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Sat, 4 Jul 2026 12:20:18 +0200 Subject: [PATCH 08/33] [file] Fix type conversion for unix.Major/Minor function calls (darwin) - Updated `unix.Major` and `unix.Minor` to explicitly cast `stat1.Rdev` and `stat2.Rdev` to `uint64`. --- util/file/main.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/util/file/main.go b/util/file/main.go index bdb6c45f9..2967272eb 100644 --- a/util/file/main.go +++ b/util/file/main.go @@ -10,8 +10,9 @@ import ( "syscall" "time" - "github.com/opensvc/om3/v3/util/command" "golang.org/x/sys/unix" + + "github.com/opensvc/om3/v3/util/command" ) var ( @@ -342,16 +343,16 @@ func VerifySameMajorMinorAndInode(p1, p2 string) error { return err } - maj1 := unix.Major(stat1.Rdev) - maj2 := unix.Major(stat2.Rdev) + maj1 := unix.Major(uint64(stat1.Rdev)) + maj2 := unix.Major(uint64(stat2.Rdev)) if maj1 != maj2 { return fmt.Errorf("%w: major of %s is %d, %s is %d", ErrNotSame, p1, maj1, p2, maj2, ) } - min1 := unix.Minor(stat1.Rdev) - min2 := unix.Minor(stat2.Rdev) + min1 := unix.Minor(uint64(stat1.Rdev)) + min2 := unix.Minor(uint64(stat2.Rdev)) if min1 != min2 { return fmt.Errorf("%w: minor of %s is %d, %s is %d", ErrNotSame, p1, min1, p2, min2, From ebd1a2ddfb315479c464f3d9e0e32a88cb5ae197 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Sat, 4 Jul 2026 12:20:38 +0200 Subject: [PATCH 09/33] [pg] Update `ApplyProc` to return additional success status (darwin) - Modified `ApplyProc` function to include a boolean return value for success indication alongside the error. --- util/pg/dummy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/pg/dummy.go b/util/pg/dummy.go index e73526f3b..9c2fb5d03 100644 --- a/util/pg/dummy.go +++ b/util/pg/dummy.go @@ -3,8 +3,8 @@ package pg // ApplyProc creates the cgroup, set caps, and add the specified process -func (c Config) ApplyProc(pid int) error { - return nil +func (c Config) ApplyProc(pid int) (bool, error) { + return false, nil } func (c Config) Delete() (bool, error) { From 719dedcd0e29d3b315a20ad6f2072a4681587f81 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 15:10:36 +0200 Subject: [PATCH 10/33] [resfshost] Split `KeywordCheckRead` into `Enabled` and `Disabled` variants --- drivers/resfshost/manifest.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/drivers/resfshost/manifest.go b/drivers/resfshost/manifest.go index 05f89f9c8..106d489e3 100644 --- a/drivers/resfshost/manifest.go +++ b/drivers/resfshost/manifest.go @@ -66,14 +66,24 @@ var ( Scopable: true, Text: keywords.NewText(fs, "text/kw/zone"), } - KeywordCheckRead = keywords.Keyword{ + KeywordCheckReadDisabled = keywords.Keyword{ Attr: "CheckRead", Converter: "bool", Option: "check_read", + Default: "false", Scopable: true, Text: keywords.NewText(fs, "text/kw/check_read"), } + KeywordCheckReadEnabled = keywords.Keyword{ + Attr: "CheckRead", + Converter: "bool", + Option: "check_read", + Scopable: true, + Default: "true", + Text: keywords.NewText(fs, "text/kw/check_read"), + } + KeywordsBase = []*keywords.Keyword{ &KeywordMountPoint, &KeywordDevice, @@ -82,7 +92,7 @@ var ( &KeywordPromoteRW, &KeywordMKFSOptions, &KeywordZone, - &KeywordCheckRead, + &KeywordCheckReadDisabled, } ) From b27238851aeca0ae15837b30f8fc94758755d0be Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 15:14:50 +0200 Subject: [PATCH 11/33] [sgcp] Add `Config` struct and utility functions for SGCP configuration management - Introduced `Config` struct to centralize SGCP API configuration (files, DNS, auth, cache). - Added utility methods for configuration loading, cloning, and access (e.g., `GetConfig`, `LoadConfig`). - Implemented tests for configuration behavior and validation. - Included embedded configuration example in YAML format for reference and testing. --- util/sgcp/config.go | 143 +++++++++++++++++++++++++++++++++++++ util/sgcp/config_test.go | 108 ++++++++++++++++++++++++++++ util/sgcp/text/config.yaml | 33 +++++++++ 3 files changed, 284 insertions(+) create mode 100644 util/sgcp/config.go create mode 100644 util/sgcp/config_test.go create mode 100644 util/sgcp/text/config.yaml diff --git a/util/sgcp/config.go b/util/sgcp/config.go new file mode 100644 index 000000000..08413205c --- /dev/null +++ b/util/sgcp/config.go @@ -0,0 +1,143 @@ +package sgcp + +import ( + "fmt" + "os" + "sync" + + "gopkg.in/yaml.v3" + + "github.com/opensvc/om3/v3/util/plog" +) + +type ( + // Config represents the SGCP configuration structure + Config struct { + Files FilesConfig `yaml:"files"` + DNS DNSConfig `yaml:"dns"` + Auth AuthConfig `yaml:"auth"` + Cache CacheConfig `yaml:"cache"` + } + + // FilesConfig contains file-related API configuration + FilesConfig struct { + BaseURL string `yaml:"base_url"` + Path struct { + FS string `yaml:"fs"` + Client string `yaml:"client"` + CG string `yaml:"cg"` + } `yaml:"path"` + } + + // DNSConfig contains DNS-related API configuration + DNSConfig struct { + BaseURL string `yaml:"base_url"` + Path struct { + Alias string `yaml:"alias"` + Zone string `yaml:"zone"` + } `yaml:"path"` + } + + // AuthConfig contains authentication configuration + AuthConfig struct { + BaseURL string `yaml:"base_url" json:"base_url"` + DefaultSecret string `yaml:"default_secret"` + Scopes map[string][]string `yaml:"scopes"` + TTLSeconds int `yaml:"ttl_seconds"` + Timeout int `yaml:"timeout"` + } + + // CacheConfig contains caching configuration + CacheConfig struct { + TTLSeconds int `yaml:"ttl_seconds"` + Enabled bool `yaml:"enabled"` + } +) + +// SGCPConfig is the global configuration instance +var ( + config *Config + configOnce sync.Once + logger = plog.NewDefaultLogger() + + DefaultConfigPath = "/etc/om3/sgcp.yaml" +) + +const ( + DefaultBaseURL = "https://localhost/file/v1" +) + +// LoadConfig loads the SGCP configuration +func LoadConfig() (*Config, error) { + data, err := os.ReadFile(DefaultConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", DefaultConfigPath, err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", DefaultConfigPath, err) + } + + return &config, nil +} + +// GetConfig returns the global SGCP configuration, loading it once +func GetConfig() *Config { + configOnce.Do(func() { + var err error + config, err = LoadConfig() + if err != nil { + logger.Debugf("Failed to load SGCP config: %v", err) + } + }) + + return config.Clone() +} + +func (c *Config) Clone() *Config { + if c == nil { + return nil + } + n := *c + scopes := make(map[string][]string) + for k, v := range c.Auth.Scopes { + scopes[k] = append([]string{}, v...) + } + n.Auth.Scopes = scopes + return &n +} + +func (c *Config) WithAuthSecret(s string) *Config { + c.Auth.DefaultSecret = s + return c +} + +func (c *Config) WithFileURL(s string) *Config { + c.Files.BaseURL = s + return c +} + +func (c *Config) WithDNSBaseURL(s string) *Config { + c.DNS.BaseURL = s + return c +} + +// GetScopes returns the auth scopes for a given scope type +func (c *Config) GetScopes(scopeType string) []string { + if c == nil { + return []string{} + } + if scopes, ok := c.Auth.Scopes[scopeType]; ok { + return scopes + } + return []string{} +} + +// GetDefaultSecret returns the default secret name +func (c *Config) GetDefaultSecret() string { + if c == nil { + return "" + } + return c.Auth.DefaultSecret +} diff --git a/util/sgcp/config_test.go b/util/sgcp/config_test.go new file mode 100644 index 000000000..248718f80 --- /dev/null +++ b/util/sgcp/config_test.go @@ -0,0 +1,108 @@ +package sgcp + +import ( + "embed" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + //go:embed text + fs embed.FS +) + +func Setup(t *testing.T) (cleanup func()) { + t.Helper() + tmpDir := t.TempDir() + OrigDefaultConfigPath := DefaultConfigPath + DefaultConfigPath = filepath.Join(tmpDir, "sgcp.yaml") + cleanup = func() { + DefaultConfigPath = OrigDefaultConfigPath + } + return cleanup +} + +func InstallConfig(t *testing.T) { + t.Helper() + b, err := fs.ReadFile("text/config.yaml") + require.NoError(t, err) + require.NoError(t, os.WriteFile(DefaultConfigPath, b, 0755)) +} + +// TestGetScopes tests the GetScopes function +func TestGetScopes(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + cfg, err := LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + scopes := cfg.GetScopes("custom_admin") + assert.Equal(t, []string{"custom:read", "custom:write"}, scopes) + + // Test unknown scope + scopes = cfg.GetScopes("unknown") + assert.Equal(t, []string{}, scopes) +} + +// TestGetDefaultSecret tests the GetDefaultSecret function +func TestGetDefaultSecret(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + + config, err := LoadConfig() + require.NoError(t, err) + + secret := config.GetDefaultSecret() + assert.Equal(t, "the-secret", secret) +} + +// TestDefaultConfigPath tests the default config path value +func TestDefaultConfigPath(t *testing.T) { + assert.Equal(t, "/etc/om3/sgcp.yaml", DefaultConfigPath) +} + +// TestGetConfig tests the global config getter when no config exists +func TestGetConfigWhenNotPresnt(t *testing.T) { + defer Setup(t)() + cfg, err := LoadConfig() + assert.NotNil(t, err) + assert.Nil(t, cfg) +} + +// TestLoadConfig tests loading the configuration from a file +func TestLoadConfig(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + + cfg, err := LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + // Test files configuration + assert.Equal(t, "https://127.0.0.1:1215/file", cfg.Files.BaseURL) + assert.Equal(t, "/fs", cfg.Files.Path.FS) + assert.Equal(t, "/client", cfg.Files.Path.Client) + assert.Equal(t, "/cg", cfg.Files.Path.CG) + + // 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, "/zone", cfg.DNS.Path.Zone) + + // Test auth configuration + assert.Equal(t, "https://127.0.0.1:1215/auth", cfg.Auth.BaseURL) + assert.Equal(t, "the-secret", cfg.Auth.DefaultSecret) + assert.Equal(t, []string{"files:read"}, cfg.Auth.Scopes["files_read"]) + assert.Equal(t, []string{"files:write"}, cfg.Auth.Scopes["files_write"]) + assert.Equal(t, 10, cfg.Auth.Timeout) + assert.Equal(t, 1140, cfg.Auth.TTLSeconds) + + // Test cache configuration + assert.Equal(t, 14400, cfg.Cache.TTLSeconds) + assert.True(t, cfg.Cache.Enabled) +} diff --git a/util/sgcp/text/config.yaml b/util/sgcp/text/config.yaml new file mode 100644 index 000000000..19eaf0a6f --- /dev/null +++ b/util/sgcp/text/config.yaml @@ -0,0 +1,33 @@ +# SGCP API Configuration +# This file contains all API paths for SGCP services +# Do not hardcode API paths in the code - use this config instead + +files: + base_url: "https://127.0.0.1:1215/file" + path: + fs: "/fs" + client: "/client" + cg: "/cg" + +dns: + base_url: "https://127.0.0.1:1215/dns" + path: + alias: "/alias" + zone: "/zone" + +# Authentication and caching settings +auth: + base_url: "https://127.0.0.1:1215/auth" + default_secret: "the-secret" + scopes: + files_read: ["files:read"] + files_write: ["files:write"] + dns_read: ["dns:read"] + dns_write: ["dns:write"] + custom_admin: ["custom:read", "custom:write"] + timeout: 10 + ttl_seconds: 1140 # 19 minutes + +cache: + ttl_seconds: 14400 # 4 hours + enabled: true From 0dfe445735b77dacad2af67f53fe86867cd58664 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 15:18:11 +0200 Subject: [PATCH 12/33] [sgcp] Add `Api` struct and HTTP utility methods with tests - Introduced `Api` struct to centralize HTTP client logic for SGCP. - Implemented `do` and `CheckStatusCode` methods for making authenticated requests and handling response codes. - Added comprehensive tests to validate the new API functionality. --- util/sgcp/api.go | 67 ++++++++++++++++++++++++++++ util/sgcp/api_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 util/sgcp/api.go create mode 100644 util/sgcp/api_test.go diff --git a/util/sgcp/api.go b/util/sgcp/api.go new file mode 100644 index 000000000..c9dbcc4e3 --- /dev/null +++ b/util/sgcp/api.go @@ -0,0 +1,67 @@ +package sgcp + +import ( + "context" + "fmt" + "io" + "net/http" + "slices" + + "github.com/opensvc/om3/v3/util/plog" +) + +type ( + Api struct { + client *http.Client + tk tokenGetter + log *plog.Logger + } + + tokenGetter interface { + Get(ctx context.Context, scope ...string) (string, error) + } +) + +func (a *Api) CheckStatusCode(method, url string, got int, wanted ...int) error { + a.log.Debugf("%s %s status code: %d", method, url, got) + if slices.Contains(wanted, got) { + return nil + } + return fmt.Errorf("unexpected status code for %s %s got %d wanted %v", method, url, got, wanted) +} + +func (a *Api) do(ctx context.Context, method, url string, body io.Reader, scopes ...string) (statusCode int, b []byte, err error) { + var req *http.Request + var resp *http.Response + + bearer, err := a.tk.Get(ctx, scopes...) + if err != nil { + return 0, nil, err + } + + req, err = http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return 0, nil, err + } + + // Add headers for authentication + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearer)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + a.log.Debugf("request: %s %s", method, url) + resp, err = a.client.Do(req) + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + a.log.Debugf("request: %s %s status code: %d", method, url, resp.StatusCode) + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + b, err = io.ReadAll(resp.Body) + + return resp.StatusCode, b, err +} diff --git a/util/sgcp/api_test.go b/util/sgcp/api_test.go new file mode 100644 index 000000000..eb4fda7ac --- /dev/null +++ b/util/sgcp/api_test.go @@ -0,0 +1,100 @@ +package sgcp + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/util/plog" +) + +type ( + TTkBuilder struct{} +) + +func (ttk *TTkBuilder) Get(ctx context.Context, scope ...string) (string, error) { + return "tk_scopes=" + strings.Join(scope, ","), nil +} + +func TestDo(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + + cfg, err := LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + // Create a test server + var calls []string + + validAuth := "Bearer tk_scopes=scope1,scope2" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/foo/bar" && r.Method == "GET" { + w.Header().Set("Content-Type", "application/json") + auth := r.Header.Get("Authorization") + if auth != validAuth { + t.Logf("invalid auth %s instead of %s", auth, validAuth) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + t.Logf("%s %s auth %s", r.Method, r.URL.Path, auth) + w.WriteHeader(http.StatusOK) + calls = append(calls, "test") + enc := json.NewEncoder(w) + enc.Encode(map[string]interface{}{"test": calls}) + return + } + }) + + ts := httptest.NewUnstartedServer(handler) + + ln, err := net.Listen("tcp", "127.0.0.1:1215") + if err != nil { + t.Fatal(err) + } + + ts.EnableHTTP2 = true + ts.Listener = ln + ts.StartTLS() + defer ts.Close() + + client := ts.Client() + log := plog.NewDefaultLogger() + + api := Api{ + client: client, + log: log, + tk: &TTkBuilder{}, + } + + ctx := context.Background() + + // First request should hit the server + code, data1, err := api.do(ctx, "GET", "https://127.0.0.1:1215/foo/bar", nil, "scope1", "scope2") + require.Equal(t, 200, code) + require.NoError(t, err) + t.Logf("First request result: %s", string(data1)) + assert.Equal(t, string([]byte(`{"test":["test"]}`)), strings.TrimSuffix(string(data1), "\n")) +} + +// TestCheckStatusCode tests the status code checking +func TestCheckStatusCode(t *testing.T) { + a := &Api{ + log: plog.NewDefaultLogger(), + } + + err := a.CheckStatusCode(http.MethodGet, "https://localhost:1215/foo", 403, 200, 201) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected status code for GET https://localhost:1215/foo got 403 wanted [200 201]") + + err = a.CheckStatusCode(http.MethodGet, "https://localhost:1215/foo", 201, 200, 201) + assert.Nil(t, err) +} From 2faa7cd2b51d42ac9441e8f1ce549adf9ab73f46 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 16:40:52 +0200 Subject: [PATCH 13/33] [sgcp] Add `TokenFactory` for token management with caching and tests - Introduced `TokenFactory` to handle token generation and caching for SGCP API authentication. - Implemented `Get` and `Clear` methods for token retrieval and cache invalidation. - Added tests to validate token generation, caching behavior, and edge cases. --- util/sgcp/auth.go | 152 +++++++++++++++++++++++++++++++++++++++++ util/sgcp/auth_test.go | 129 ++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 util/sgcp/auth_test.go diff --git a/util/sgcp/auth.go b/util/sgcp/auth.go index b349430b1..e0e4ed666 100644 --- a/util/sgcp/auth.go +++ b/util/sgcp/auth.go @@ -1,5 +1,19 @@ package sgcp +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/opensvc/om3/v3/util/ageingcache" + "github.com/opensvc/om3/v3/util/plog" +) + type ( // AuthInfo represents authentication details required for API access. AuthInfo struct { @@ -10,4 +24,142 @@ type ( // Signature identifies auth info Signature string } + + // TokenFactory is responsible for generating and caching authentication tokens for API access. + TokenFactory struct { + authInfo *AuthInfo + authConfig *AuthConfig + log *plog.Logger + client *http.Client + } + + // Token represents an authentication token used for API access. + Token struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + } ) + +// NewTokenFactory initializes a new instance of TokenFactory with the provided logger, authentication config, and auth info. +func NewTokenFactory(log *plog.Logger, client *http.Client, authCfg *AuthConfig, authInfo *AuthInfo) *TokenFactory { + return &TokenFactory{ + client: client, + log: log, + authConfig: authCfg, + authInfo: authInfo, + } +} + +// Get retrieves an authentication token for the given context and scopes, utilizing caching for performance optimization. +// Returns the token as a string or an error if retrieval fails. +func (t *TokenFactory) Get(ctx context.Context, scope ...string) (string, error) { + cacheMaxAge := time.Duration(t.authConfig.TTLSeconds) * time.Second + sig := t.signature(scope...) + o := ageingcache.NewOutputter(func() ([]byte, error) { + token, err := t.newToken(ctx, scope...) + if err != nil { + t.log.Debugf("get token failed on missing cache sig %s.out, ttl: %s", sig, cacheMaxAge) + return nil, fmt.Errorf("get token failed: %w", err) + } + return []byte(token.AccessToken), nil + }) + data, err := ageingcache.Output(o, sig, cacheMaxAge) + if err != nil { + return "", err + } + return string(data), nil +} + +// Clear removes the cached authentication token associated with the provided scope(s). +// Returns an error if the operation fails. +func (t *TokenFactory) Clear(scope ...string) error { + + return ageingcache.Clear(t.signature(scope...)) +} + +// newToken generates a new authentication token for the provided context and scope(s) using client credentials flow. +func (t *TokenFactory) newToken(ctx context.Context, scope ...string) (*Token, error) { + if t.authConfig.BaseURL == "" { + return nil, fmt.Errorf("empty auth config BaseURL") + } + if t.authInfo.ClientID == "" || t.authInfo.ClientSecret == "" { + return nil, fmt.Errorf("client_id and client_secret are required") + } + if len(scope) == 0 { + return nil, fmt.Errorf("scope is required") + } + + requestURL := strings.TrimRight(t.authConfig.BaseURL, "/") + "/access_token" + values := url.Values{} + values.Set("grant_type", "client_credentials") + scopes := t.formatScopeRequest(scope...) + values.Set("scope", scopes) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(t.authInfo.ClientID, t.authInfo.ClientSecret) + + //client := &http.Client{Timeout: time.Duration(max(1, t.authConfig.Timeout)) * time.Second} + //t.client + client := *t.client + client.Timeout = time.Duration(max(1, t.authConfig.Timeout)) * time.Second + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("iam token request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var token Token + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return nil, err + } + if token.AccessToken == "" { + return nil, fmt.Errorf("iam token response missing access_token") + } + return &token, nil +} + +// signature returns a sanitized signature for the scopes. +func (t *TokenFactory) signature(scope ...string) string { + sanitize := func(a ...string) string { + var l []string + for _, s := range a { + sanitized := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '-' + }, s) + l = append(l, sanitized) + } + return strings.Join(l, "-") + } + + l := append([]string{}, t.authInfo.Signature) + l = append(l, scope...) + + return fmt.Sprintf("sgcp-token-%s", sanitize(l...)) +} + +// formatScopeRequest constructs a formatted scope string that can be used to request new token, +// by prepending the account ID if it's not empty and processing each scope. +func (t *TokenFactory) formatScopeRequest(scopes ...string) string { + if t.authInfo.AccountID == "" { + return strings.Join(scopes, " ") + } + out := make([]string, 0, len(scopes)) + for _, scope := range scopes { + out = append(out, t.authInfo.AccountID+":"+scope) + } + return strings.Join(out, " ") +} diff --git a/util/sgcp/auth_test.go b/util/sgcp/auth_test.go new file mode 100644 index 000000000..404755ee1 --- /dev/null +++ b/util/sgcp/auth_test.go @@ -0,0 +1,129 @@ +package sgcp + +import ( + "context" + "encoding/base64" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/util/plog" +) + +func TestTokenFactory(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + + cfg, err := LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + // Create a test server + var authCalls []string + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + type bodyStruct struct { + Scope string + } + + if r.URL.Path == "/auth/access_token" && r.Method == "POST" { + w.Header().Set("Content-Type", "application/json") + auth := r.Header.Get("Authorization") + basicAuth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")) + require.NoError(t, err) + if string(basicAuth) != "clientid1:clientSecret1" { + w.WriteHeader(http.StatusBadRequest) + return + } + l := strings.Split(string(basicAuth), ":") + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + scope := r.FormValue("scope") + + t.Logf("Read %s bytes from request body", string(scope)) + w.WriteHeader(http.StatusOK) + t.Logf("Post new token for for client id: %s", l[0]) + authCalls = append(authCalls, l[0]+":"+scope) + enc := json.NewEncoder(w) + enc.Encode(map[string]interface{}{ + "access_token": l[0] + ":" + scope, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + ts := httptest.NewUnstartedServer(handler) + + ln, err := net.Listen("tcp", "127.0.0.1:1215") + if err != nil { + t.Fatal(err) + } + + ts.EnableHTTP2 = true + ts.Listener = ln + ts.StartTLS() + defer ts.Close() + + authInfo := &AuthInfo{ + AccountID: "account1", + ClientID: "clientid1", + ClientSecret: "clientSecret1", + Signature: "the-secret", + } + client := ts.Client() + log := plog.NewDefaultLogger() + tk := NewTokenFactory(log, client, &cfg.Auth, authInfo) + ctx := context.Background() + + require.NoError(t, tk.Clear("scope1")) + require.NoError(t, tk.Clear("scope2", "scope3")) + + callSignScope1 := "clientid1:account1:scope1" + callSignScope2and3 := "clientid1:account1:scope2 account1:scope3" + + t.Logf("get token for scope1") + bearer, err := tk.Get(ctx, "scope1") + require.NoError(t, err) + require.Equal(t, "clientid1:account1:scope1", bearer) + expectedCalls := []string{callSignScope1} + require.Equal(t, expectedCalls, authCalls) + + t.Logf("get again token for scope1, must hit cache") + bearer, err = tk.Get(ctx, "scope1") + require.NoError(t, err) + require.Equal(t, "clientid1:account1:scope1", bearer) + expectedCalls = []string{callSignScope1} + require.Equalf(t, expectedCalls, authCalls, "The second call didn't hit cache") + + t.Logf("get again token for scope2 and scope3") + bearer, err = tk.Get(ctx, "scope2", "scope3") + require.NoError(t, err) + require.Equal(t, "clientid1:account1:scope2 account1:scope3", bearer) + expectedCalls = []string{callSignScope1, callSignScope2and3} + require.Equalf(t, expectedCalls, authCalls, "wanted calls %v, got %v", expectedCalls, authCalls) + + t.Logf("get again token for scope1, must hit cache") + bearer, err = tk.Get(ctx, "scope1") + require.NoError(t, err) + require.Equal(t, "clientid1:account1:scope1", bearer) + expectedCalls = []string{callSignScope1, callSignScope2and3} + require.Equalf(t, expectedCalls, authCalls, "wanted calls %v, got %v", expectedCalls, authCalls) + + t.Logf("clear cache scope1") + require.NoError(t, tk.Clear("scope1")) + t.Logf("get again token for scope1, must create new token") + bearer, err = tk.Get(ctx, "scope1") + require.NoError(t, err) + require.Equal(t, "clientid1:account1:scope1", bearer) + expectedCalls = []string{callSignScope1, callSignScope2and3, callSignScope1} + require.Equalf(t, expectedCalls, authCalls, "wanted calls %v, got %v", expectedCalls, authCalls) +} From c5002304ce1169e5afcfaa0f0f1b94d751b2909e Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 16:54:43 +0200 Subject: [PATCH 14/33] [sgcp] Add `FilesAPI` for managing file operations with tests - Introduced `FilesAPI` for handling SGCP file-related operations, including NFS client management. - Added methods and utilities for constructing URLs, executing HTTP requests, and managing scopes. - Implemented corresponding tests to validate API logic and HTTP interactions. --- util/sgcp/file.go | 113 +++++++++++++++++++++++++++++++++++++++++ util/sgcp/file_test.go | 109 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 util/sgcp/file.go create mode 100644 util/sgcp/file_test.go diff --git a/util/sgcp/file.go b/util/sgcp/file.go new file mode 100644 index 000000000..4166c068b --- /dev/null +++ b/util/sgcp/file.go @@ -0,0 +1,113 @@ +package sgcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/opensvc/om3/v3/util/plog" +) + +type ( + // FilesAPI provides methods for file-related API operations + FilesAPI struct { + Api + config *Config + } + + // NfsClient represents an NFS client with associated metadata and permissions for a specific filesystem. + NfsClient struct { + UUID string `json:"uuid,omitempty"` + Host string `json:"host"` + Permission string `json:"permission"` + Protocol string `json:"protocol"` + ConsistencyGroupID string `json:"consistencyGroupId,omitempty"` + } +) + +// NewFilesAPI creates a new FilesAPI instance +func NewFilesAPI(config *Config, client *http.Client, l *plog.Logger, tk tokenGetter) *FilesAPI { + return &FilesAPI{ + config: config, + Api: Api{ + client: client, + tk: tk, + log: l, + }, + } +} + +func (a *FilesAPI) GetFilesystem(ctx context.Context, uuid string) (method, url string, code int, data []byte, err error) { + method = http.MethodGet + url = a.getFilesystemURL(uuid) + code, data, err = a.do(ctx, method, url, nil, a.GetScopes("files_read")...) + return +} + +func (a *FilesAPI) PostNFSClients(ctx context.Context, uuid string, client *NfsClient) (method, url string, code int, data []byte, err error) { + var b []byte + method = http.MethodPost + url = a.getNFSClientsURL(uuid) + + b, err = json.Marshal(client) + if err != nil { + err = fmt.Errorf("failed to marshal NFS client: %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("files_write")...) + return +} + +func (a *FilesAPI) DeleteNFSClients(ctx context.Context, fsUUID, clientUUID string) (method, url string, code int, data []byte, err error) { + method = http.MethodDelete + url = a.getNFSClientURL(fsUUID, clientUUID) + + a.log.Infof("%s %s", method, url) + code, data, err = a.do(ctx, method, url, nil, a.GetScopes("files_write")...) + return +} + +func (a *FilesAPI) GetScopes(scopeType string) []string { + return a.config.GetScopes(scopeType) +} + +// getFilesystemURL constructs the URL for a specific filesystem +func (a *FilesAPI) getFilesystemURL(uuid string) string { + return fmt.Sprintf("%s%s/%s", a.config.Files.BaseURL, a.config.Files.Path.FS, uuid) +} + +// getNFSClientsURL constructs the URL for NFS clients of a filesystem +func (a *FilesAPI) getNFSClientsURL(uuid string) string { + return fmt.Sprintf("%s%s/%s%s", + a.config.Files.BaseURL, + a.config.Files.Path.FS, + uuid, + a.config.Files.Path.Client) +} + +// getNFSClientURL constructs the URL for a specific NFS client +func (a *FilesAPI) getNFSClientURL(uuid, clientUUID string) string { + return fmt.Sprintf("%s%s/%s%s/%s", + a.config.Files.BaseURL, + a.config.Files.Path.FS, + uuid, + a.config.Files.Path.Client, + clientUUID) +} + +// do is the function that executes the HTTP request +func (a *FilesAPI) 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...) +} + +// GetConsistencyGroupURL constructs the URL for a specific consistency group +func (a *FilesAPI) GetConsistencyGroupURL(uuid string) string { + return fmt.Sprintf("%s%s/%s", + a.config.Files.BaseURL, + a.config.Files.Path.CG, + uuid) +} diff --git a/util/sgcp/file_test.go b/util/sgcp/file_test.go new file mode 100644 index 000000000..8159378cd --- /dev/null +++ b/util/sgcp/file_test.go @@ -0,0 +1,109 @@ +package sgcp + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/util/plog" +) + +// TestFilesAPIURLMethods tests URL construction for FilesAPI +func TestFilesAPIURLMethods(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + cfg, err := LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + api := NewFilesAPI(cfg, nil, nil, nil) + + // Test filesystem URL + assert.Equal(t, "https://127.0.0.1:1215/file/fs/test-uuid", + api.getFilesystemURL("test-uuid")) + + // Test NFS clients URL + assert.Equal(t, "https://127.0.0.1:1215/file/fs/test-uuid/client", + api.getNFSClientsURL("test-uuid")) + + // Test NFS client URL + assert.Equal(t, "https://127.0.0.1:1215/file/fs/test-uuid/client/client-uuid", + api.getNFSClientURL("test-uuid", "client-uuid")) + + // Test consistency group URL + assert.Equal(t, "https://127.0.0.1:1215/file/cg/cg-uuid", + api.GetConsistencyGroupURL("cg-uuid")) +} + +func TestGetFilesystem(t *testing.T) { + defer Setup(t)() + InstallConfig(t) + + cfg, err := LoadConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + // Create a test server + var calls []string + var apiCallCount int + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/file/fs/id1" && r.Method == "GET" { + w.Header().Set("Content-Type", "application/json") + auth := r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + t.Logf("GET /file/fs/id1 for auth %s", auth) + w.WriteHeader(http.StatusOK) + calls = append(calls, r.URL.Path) + enc := json.NewEncoder(w) + enc.Encode(map[string]interface{}{"test": calls}) + apiCallCount++ + return + } + w.WriteHeader(http.StatusNotFound) + }) + + ts := httptest.NewUnstartedServer(handler) + + ln, err := net.Listen("tcp", "127.0.0.1:1215") + if err != nil { + t.Fatal(err) + } + + ts.EnableHTTP2 = true + ts.Listener = ln + ts.StartTLS() + defer ts.Close() + + client := ts.Client() + log := plog.NewDefaultLogger() + tk := &TTkBuilder{} + api := NewFilesAPI(cfg, client, log, tk) + + ctx := context.Background() + + t.Log("expecting all calls hits the api") + method, url, code, data1, err := api.GetFilesystem(ctx, "id1") + require.Contains(t, url, "/fs/id1") + require.Equal(t, "GET", method) + require.Equal(t, 200, code) + require.NoError(t, err) + t.Logf("First request result: %s", string(data1)) + t.Logf("api calls count: %d %v", apiCallCount, calls) + assert.Equal(t, 1, apiCallCount) + + method, url, code, data1, err = api.GetFilesystem(ctx, "id1") + require.Contains(t, url, "/fs/id1") + require.Equal(t, "GET", method) + require.Equal(t, 200, code) + require.NoError(t, err) + t.Logf("Second request result: %s", string(data1)) + t.Logf("api calls count: %d %v", apiCallCount, calls) + assert.Equal(t, 2, apiCallCount) +} From 32535663bd1863516ddd0c263abb351d880e8541 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:04:29 +0200 Subject: [PATCH 15/33] [sgcp] Add `DisabledFlag` support to `Config` and `IsDisabled` utility function - Added `DisabledFlag` field to the `Config` struct for managing SGCP service disablement via a file path. - Introduced `IsDisabled` function to check the presence of the disable flag using the configured path. - Updated configuration example to include the new `disabled_flag` field. --- util/sgcp/config.go | 9 +++++---- util/sgcp/main.go | 14 ++++++++++++++ util/sgcp/text/config.yaml | 2 ++ 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 util/sgcp/main.go diff --git a/util/sgcp/config.go b/util/sgcp/config.go index 08413205c..5daf04c7f 100644 --- a/util/sgcp/config.go +++ b/util/sgcp/config.go @@ -13,10 +13,11 @@ import ( type ( // Config represents the SGCP configuration structure Config struct { - Files FilesConfig `yaml:"files"` - DNS DNSConfig `yaml:"dns"` - Auth AuthConfig `yaml:"auth"` - Cache CacheConfig `yaml:"cache"` + Files FilesConfig `yaml:"files"` + DNS DNSConfig `yaml:"dns"` + Auth AuthConfig `yaml:"auth"` + Cache CacheConfig `yaml:"cache"` + DisabledFlag string `yaml:"disabled_flag"` } // FilesConfig contains file-related API configuration diff --git a/util/sgcp/main.go b/util/sgcp/main.go new file mode 100644 index 000000000..c2ef5c594 --- /dev/null +++ b/util/sgcp/main.go @@ -0,0 +1,14 @@ +// Package sgcp provides utility functions for SGCP API interactions. +// It uses a YAML configuration file to avoid hardcoding API paths in the code. +package sgcp + +import ( + "github.com/opensvc/om3/v3/util/file" +) + +func IsDisabled(s string) bool { + if config.DisabledFlag == "" { + return false + } + return file.Exists(config.DisabledFlag) +} diff --git a/util/sgcp/text/config.yaml b/util/sgcp/text/config.yaml index 19eaf0a6f..6aae22572 100644 --- a/util/sgcp/text/config.yaml +++ b/util/sgcp/text/config.yaml @@ -31,3 +31,5 @@ auth: cache: ttl_seconds: 14400 # 4 hours enabled: true + +disabled_flag: /var/lib/opensvc/sgcp_disabled From 89c80b382f91320835254625af4f4077e28fcf01 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:10:50 +0200 Subject: [PATCH 16/33] [sgcp] Refactor `GetAuthInfo` into struct method for improved encapsulation - Introduced `GetAuthInfoFromDatastorePather` struct to encapsulate the `GetAuthInfo` logic as a method. --- drivers/sgcphelper/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/drivers/sgcphelper/main.go b/drivers/sgcphelper/main.go index e2066f0d5..155ca486d 100644 --- a/drivers/sgcphelper/main.go +++ b/drivers/sgcphelper/main.go @@ -8,8 +8,12 @@ import ( "github.com/opensvc/om3/v3/util/sgcp" ) +type ( + GetAuthInfoFromDatastorePather struct{} +) + // GetAuthInfo retrieves authentication information from the specified datastore path and returns an AuthInfo struct. -func GetAuthInfo(datastorePath string) (*sgcp.AuthInfo, error) { +func (g *GetAuthInfoFromDatastorePather) GetAuthInfo(datastorePath string) (*sgcp.AuthInfo, error) { var ( ds object.DataStore dsValues [][]byte From e2337eb8e85bb7654bf4b2ec4a4c0d351507f62d Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:16:57 +0200 Subject: [PATCH 17/33] [resfssgcp_nfs] Add SGCP NFS filesystem driver with client management and tests - Introduced `resfssgcp_nfs` package to provide SGCP NFS filesystem resource driver. - Implemented NFS client management with support for exclusive mode, API integration, and caching. - Added comprehensive test coverage, including configuration validation and resource lifecycle scenarios. - Updated `manifest.go` to include keywords for underlying filesystem resource. --- drivers/resfssgcp_nfs/api.go | 261 +++++++++++++++++++ drivers/resfssgcp_nfs/main.go | 404 +++++++++++++++++++++++++++++ drivers/resfssgcp_nfs/main_test.go | 338 ++++++++++++++++++++++++ drivers/resfssgcp_nfs/manifest.go | 21 +- 4 files changed, 1021 insertions(+), 3 deletions(-) create mode 100644 drivers/resfssgcp_nfs/api.go create mode 100644 drivers/resfssgcp_nfs/main.go create mode 100644 drivers/resfssgcp_nfs/main_test.go diff --git a/drivers/resfssgcp_nfs/api.go b/drivers/resfssgcp_nfs/api.go new file mode 100644 index 000000000..2af12f7de --- /dev/null +++ b/drivers/resfssgcp_nfs/api.go @@ -0,0 +1,261 @@ +package resfssgcp_nfs + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "time" + + "github.com/opensvc/om3/v3/util/ageingcache" + "github.com/opensvc/om3/v3/util/plog" + "github.com/opensvc/om3/v3/util/sgcp" +) + +type ( + // nfsClientMgr handles NFS client management for the resource + nfsClientMgr struct { + uuid string + host string + permission string + protocol string + nfsIgnored []string + + api *sgcp.FilesAPI + cacheConfig *sgcp.CacheConfig + log *plog.Logger + } +) + +// Start adds the NFS client to the filesystem +func (mgr *nfsClientMgr) Start(ctx context.Context, exclusive bool) error { + if exclusive { + return mgr.startExclusive(ctx) + } + return mgr.start(ctx) +} + +func (mgr *nfsClientMgr) start(ctx context.Context) error { + // Verify if the client already exists + fileInfo, err := mgr.getFileInfo(ctx) + if err != nil { + return err + } + + if fileInfo != nil { + for _, client := range fileInfo.NFSClients { + if client.Host == mgr.host { + if client.Permission == mgr.permission { + mgr.log.Infof("%s already exists", client) + return nil + } + // Wrong permission, delete and recreate + if err := mgr.deleteNFSClient(ctx, client); err != nil { + // Don't stop here, next addNFSClient will retry delete wrong permission + mgr.log.Debugf("continue (will be retried) on failed deletion of NFS client with wrong permission: %s", err) + } + break + } + } + } + + // Add the client + _, err = mgr.addNFSClient(ctx, mgr.host) + return err +} + +// startExclusive starts with exclusive access (removes other clients first) +func (mgr *nfsClientMgr) startExclusive(ctx context.Context) error { + // Delete all clients except our host + fileInfo, err := mgr.getFileInfo(ctx) + if err != nil { + return err + } + + if fileInfo != nil { + for _, client := range fileInfo.NFSClients { + if client.Host != mgr.host && !mgr.isClientIgnored(client.Host) { + if err := mgr.deleteNFSClient(ctx, client); err != nil { + return err + } + } + } + } + + // Add our client + _, err = mgr.addNFSClient(ctx, mgr.host) + return err +} + +func (mgr *nfsClientMgr) cacheSig(name string) string { + return fmt.Sprintf("%s:%s", name, mgr.uuid) +} + +func (mgr *nfsClientMgr) cacheClear(name string) error { + cacheSig := mgr.cacheSig(name) + return ageingcache.Clear(cacheSig) +} + +func (mgr *nfsClientMgr) getFileInfo(ctx context.Context) (*FilesystemInfo, error) { + var fileInfo FilesystemInfo + + cacheSig := mgr.cacheSig("getFileInfo") + ttl := time.Duration(mgr.cacheConfig.TTLSeconds) * time.Second + o := ageingcache.NewOutputter(mgr.getFileInfoFactory(ctx)) + data, err := ageingcache.Output(o, cacheSig, ttl) + if err != nil { + mgr.log.Debugf("getFileInfo failed on missing cache sig %s.out, ttl: %s: %s", cacheSig, ttl, err) + return nil, fmt.Errorf("getFileInfo failed: %w", err) + } + + if err := json.Unmarshal(data, &fileInfo); err != nil { + return nil, err + } + + return &fileInfo, err +} + +func (mgr *nfsClientMgr) getFileInfoFactory(ctx context.Context) func() ([]byte, error) { + return func() ([]byte, error) { + method, url, statusCode, data, err := mgr.api.GetFilesystem(ctx, mgr.uuid) + if err != nil { + return nil, err + } + + if err := mgr.api.CheckStatusCode(method, url, statusCode, http.StatusNotFound, http.StatusOK); err != nil { + return nil, err + } + switch statusCode { + case http.StatusOK: + case http.StatusNotFound: + return nil, nil + default: + // paranoid, should never happen + return nil, fmt.Errorf("%s %s got unexpected status code %d", method, url, statusCode) + } + return data, nil + } +} + +// Stop removes the NFS client for this host +func (mgr *nfsClientMgr) Stop(ctx context.Context) error { + fileInfo, err := mgr.getFileInfo(ctx) + if err != nil { + return err + } + + if fileInfo == nil { + return nil + } + + for _, client := range fileInfo.NFSClients { + if client.Host == mgr.host { + return mgr.deleteNFSClient(ctx, client) + } + } + + return nil +} + +// addNFSClient adds a new NFS client to the filesystem +func (mgr *nfsClientMgr) addNFSClient(ctx context.Context, host string) (*NfsClient, error) { + newClient := &NfsClient{ + Host: host, + Permission: mgr.permission, + Protocol: mgr.protocol, + } + + mgr.log.Debugf("Verify existence of %s", newClient) + + fileInfo, err := mgr.getFileInfo(ctx) + if err != nil { + return nil, err + } + + if fileInfo != nil { + for _, client := range fileInfo.NFSClients { + if client.Host == host { + if client.Permission == mgr.permission { + mgr.log.Infof("%s already exists", client) + return &client, nil + } + mgr.log.Infof("%s already exists, with wrong permission", client) + if err := mgr.deleteNFSClient(ctx, client); err != nil { + mgr.log.Warnf("delete existing %s with wrong permissions: %s", client, err) + } + break + } + } + } + payload := &sgcp.NfsClient{ + Host: host, + Permission: mgr.permission, + Protocol: mgr.protocol, + } + mgr.log.Infof("grant permission %s for host %s on filesystem %s ...", mgr.permission, host, mgr.uuid) + method, url, statusCode, data, err := mgr.api.PostNFSClients(ctx, mgr.uuid, payload) + + if err != nil { + return nil, err + } + if err := mgr.api.CheckStatusCode(method, url, statusCode, http.StatusCreated); err != nil { + return nil, err + } + if statusCode != http.StatusCreated { + return nil, fmt.Errorf("unexpected status code %s %s: %d", method, url, statusCode) + } + var createdClient NfsClient + if err := json.Unmarshal(data, &createdClient); err != nil { + return nil, err + } + + mgr.log.Infof("created %s on filesystem %s", createdClient, mgr.uuid) + return &createdClient, nil +} + +// deleteNFSClient deletes an NFS client from the filesystem +func (mgr *nfsClientMgr) deleteNFSClient(ctx context.Context, client NfsClient) error { + cgMsg := "" + if client.ConsistencyGroupID != "" { + cgMsg = fmt.Sprintf(" in consistency group %s", client.ConsistencyGroupID) + } + + mgr.log.Infof("drop permission %s for host %s on filesystem %s%s ...", client.Permission, client.Host, mgr.uuid, cgMsg) + method, url, statusCode, _, err := mgr.api.DeleteNFSClients(ctx, mgr.uuid, client.UUID) + if err != nil { + return err + } + if err := mgr.api.CheckStatusCode(method, url, statusCode, http.StatusNoContent, http.StatusPreconditionFailed); err != nil { + return err + } + switch statusCode { + case http.StatusNoContent: + case http.StatusPreconditionFailed: + return fmt.Errorf("consistency group is not in ready (status_code %d)", statusCode) + default: + // paranoid, should never happen + return fmt.Errorf("unexpected status code %d", statusCode) + } + + mgr.log.Infof("deleted %s on filesystem %s", client, mgr.uuid) + return nil +} + +func (mgr *nfsClientMgr) checkStatusCode(method, url string, got int, wanted ...int) error { + mgr.log.Debugf("%s %s status code: %d", method, url, got) + if slices.Contains(wanted, got) { + return nil + } + return fmt.Errorf("unexpected status code for %s %s got %d wanted %v", method, url, got, wanted) +} + +// isClientIgnored checks if a client host should be ignored +func (mgr *nfsClientMgr) isClientIgnored(host string) bool { + for _, ignored := range mgr.nfsIgnored { + if host == ignored { + return true + } + } + return false +} diff --git a/drivers/resfssgcp_nfs/main.go b/drivers/resfssgcp_nfs/main.go new file mode 100644 index 000000000..4bc21acd0 --- /dev/null +++ b/drivers/resfssgcp_nfs/main.go @@ -0,0 +1,404 @@ +// Package resfssgcp_nfs provides the SGCP NFS filesystem driver +package resfssgcp_nfs + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/opensvc/om3/v3/core/datarecv" + "github.com/opensvc/om3/v3/core/rawconfig" + "github.com/opensvc/om3/v3/core/resource" + "github.com/opensvc/om3/v3/core/status" + "github.com/opensvc/om3/v3/drivers/resfshost" + "github.com/opensvc/om3/v3/drivers/sgcphelper" + "github.com/opensvc/om3/v3/util/httpclientcache" + "github.com/opensvc/om3/v3/util/sgcp" +) + +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"` + } + + // T represents the SGCP NFS filesystem resource + T struct { + resource.T + resource.Restart + datarecv.DataRecv + + // Configuration parameters + UUID string `json:"uuid"` + Host string `json:"host,omitempty"` + Permission string `json:"permission,omitempty"` + Exclusive bool `json:"exclusive,omitempty"` + Protocol string `json:"protocol,omitempty"` + Secret string `json:"secret,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Path string `json:"path,omitempty"` + + // Configuration parameters for the underlying filesystem resource + MountPoint string `json:"mnt"` + Device string `json:"dev"` + Type string `json:"type"` + MountOptions string `json:"mnt_opt"` + StatTimeout *time.Duration `json:"stat_timeout"` + Zone string `json:"zone"` + CheckRead bool `json:"check_read"` + + // Internal state + resFs fsDriver + fileInfoCache *FilesystemInfo + mgr *nfsClientMgr + authInfoer GetAuthInfoer + } + + GetAuthInfoer interface { + GetAuthInfo(string) (*sgcp.AuthInfo, error) + } + + fsDriver interface { + Start(context.Context) error + Stop(context.Context) error + Status(context.Context) status.T + StatusLog() resource.StatusLogger + Label(context.Context) string + Head() string + CanInstall(context.Context) (bool, error) + } +) + +// NfsClientIgnored is a list of NFS client hosts to ignore +var NfsClientIgnored = []string{} + +// New creates a new SGCP NFS filesystem resource driver +func New() resource.Driver { + return &T{} +} + +// Configure sets up the resource +func (t *T) Configure() error { + cfg := sgcp.GetConfig() + + // set defaults when kw are not set + if t.Secret == "" { + t.Secret = cfg.Auth.DefaultSecret + } + if t.Endpoint == "" { + t.Endpoint = cfg.Files.BaseURL + } + if t.Permission == "" { + t.Permission = DefaultPermission + } + if t.Protocol == "" { + t.Protocol = DefaultProtocol + } + + if t.Endpoint != "" { + cfg = cfg.WithFileURL(t.Endpoint) + } + if t.Secret != "" { + cfg = cfg.WithAuthSecret(t.Secret) + } + + if err := t.configureMgr(cfg); err != nil { + return fmt.Errorf("configure mgr: %w", err) + } + + if err := t.configureUnderlyingFilesystem("nfs"); err != nil { + return fmt.Errorf("configure underlying filesystem: %w", err) + } + + return nil +} + +func (t *T) configureMgr(cfg *sgcp.Config) error { + // Initialize SGCP API client + httpClient, err := httpclientcache.Client(httpclientcache.Options{ + Timeout: 30 * time.Second, + }) + if err != nil { + return fmt.Errorf("failed to create HTTP client: %w", err) + } + + if t.authInfoer == nil { + t.authInfoer = &sgcphelper.GetAuthInfoFromDatastorePather{} + } + + authInfo, err := t.authInfoer.GetAuthInfo(t.Secret) + if err != nil { + return fmt.Errorf("get auth info: %w", err) + } + tk := sgcp.NewTokenFactory(t.Log(), httpClient, &cfg.Auth, authInfo) + + t.mgr = &nfsClientMgr{ + uuid: t.UUID, + host: t.Host, + permission: t.Permission, + protocol: t.Protocol, + log: t.Log(), + nfsIgnored: NfsClientIgnored, + api: sgcp.NewFilesAPI(cfg, httpClient, t.Log(), tk), + cacheConfig: &cfg.Cache, + } + return nil +} + +func (t *T) configureUnderlyingFilesystem(resType string) error { + // Prepare the underlying filesystem resource + r := resfshost.New().(*resfshost.T) + if err := r.SetRID(t.RID()); err != nil { + return fmt.Errorf("set underlying fs rid: %w", err) + } + if o := t.GetObject(); o != nil { + r.SetObject(t.GetObject()) + } + r.Type = resType + r.Device = t.Device + r.MountPoint = t.MountPoint + r.MountOptions = t.MountOptions + r.StatTimeout = t.StatTimeout + r.DataRecv = t.DataRecv + r.Perm = t.Perm + if err := r.Configure(); err != nil { + return fmt.Errorf("configure underlying fs: %w", err) + } + t.resFs = r + + t.DataRecv.SetReceiver(r) + return nil +} + +// Start starts the SGCP NFS filesystem resource +func (t *T) Start(ctx context.Context) error { + // Start the XaaS (SGCP API) part + if err := t.fileStart(ctx); err != nil { + return err + } + + // Start underlying fs, this includes the data receive step + if err := t.resFs.Start(ctx); err != nil { + return err + } + + return nil +} + +// Stop stops the SGCP NFS filesystem resource +func (t *T) Stop(ctx context.Context) error { + // stop the underlying filesystem + if err := t.resFs.Stop(ctx); err != nil { + return err + } + + // Stop the XaaS part + return t.fileStop(ctx) +} + +// Status returns the combined status of the file and fs +func (t *T) Status(ctx context.Context) status.T { + fileStatus := t.fileStatus(ctx) + + // Get underlying filesystem status + fsStatus := t.resFs.Status(ctx) + t.StatusLog().Merge(t.resFs.StatusLog()) + + fileStatus.Add(fsStatus) + return fileStatus +} + +// Head returns the head path for the data receiver +func (t *T) Head() string { + return t.resFs.Head() +} + +// Label returns a label for the resource +func (t *T) Label(ctx context.Context) string { + return t.resFs.Label(ctx) +} + +// CanInstall checks if the resource can be installed +func (t *T) CanInstall(ctx context.Context) (bool, error) { + return t.resFs.CanInstall(ctx) +} + +// Boot stops the resource (for daemon bootstrap) +func (t *T) Boot(ctx context.Context) error { + return t.Stop(ctx) +} + +// fileStart handles the SGCP API part of starting the filesystem +func (t *T) fileStart(ctx context.Context) error { + if sgcp.IsDisabled(rawconfig.NodeVarDir()) { + t.Log().Infof("skipping file start %s: SGCP API disabled", t.UUID) + return nil + } + + t.Log().Infof("starting file %s", t.UUID) + + // Clear cache to get fresh data + if err := t.clearFileStatusCache(); err != nil { + return fmt.Errorf("clear cache: %w", err) + } + + // Check current status + if t.fileStatus(ctx) == status.Up { + t.Log().Infof("permission %s from host %s already created", t.Permission, t.Host) + return nil + } + + // Start the NFS client + return t.mgr.Start(ctx, t.Exclusive) +} + +// fileStop handles the SGCP API part of stopping the filesystem +func (t *T) fileStop(ctx context.Context) error { + if sgcp.IsDisabled(rawconfig.NodeVarDir()) { + t.Log().Infof("skipping file stop %s: SGCP API disabled", t.UUID) + return nil + } + t.Log().Infof("stopping file %s", t.UUID) + + // Clear cache to get fresh data + if err := t.clearFileStatusCache(); err != nil { + return fmt.Errorf("clear cache: %w", err) + } + + // Check current status + if t.fileStatus(ctx) == status.Down { + t.Log().Infof("permission %s from host %s already deleted", t.Permission, t.Host) + return nil + } + + return t.mgr.Stop(ctx) +} + +// fileStatus returns the status of the filesystem from the SGCP API +func (t *T) fileStatus(ctx context.Context) status.T { + // Check if XaaS status is disabled + if sgcp.IsDisabled(rawconfig.NodeVarDir()) { + t.Log().Debugf("skipping file status %s: SGCP API disabled", t.UUID) + return status.NotApplicable + } + + fileInfo, err := t.getFileInfo(ctx) + if err != nil { + t.Log().Debugf("file object not visible in api: %s", err) + t.StatusLog().Info("file object not visible in api") + return status.Down + } + + if fileInfo == nil { + t.StatusLog().Info("file object not visible in api") + return status.Down + } + + clients := t.getNFSClients(fileInfo) + n := len(clients) + + if t.Exclusive { + if n > 1 { + t.StatusLog().Warn(fmt.Sprintf("too many grants (%d)", n)) + } + } + + if n == 0 { + t.StatusLog().Info("no access granted") + return status.Down + } + + // Filter clients to only those matching our host and permission + var matchingClients []NfsClient + for _, client := range clients { + if client.Host == t.Host && client.Permission == t.Permission { + matchingClients = append(matchingClients, client) + } + } + + if len(matchingClients) == 0 { + t.StatusLog().Info("access not in current grants") + return status.Down + } + + if fileInfo.Status == "passive" { + t.StatusLog().Info("status passive, access granted") + return status.Down + } + + return status.Up +} + +// getFileInfo retrieves filesystem information from the API +func (t *T) getFileInfo(ctx context.Context) (*FilesystemInfo, error) { + // Use cached value if available + if t.fileInfoCache != nil { + return t.fileInfoCache, nil + } + + fileInfo, err := t.mgr.getFileInfo(ctx) + if err == nil { + // Cache the result + t.fileInfoCache = fileInfo + } + + return fileInfo, err +} + +// getNFSClients returns the NFS clients for the filesystem, filtered by ignored hosts +func (t *T) getNFSClients(fileInfo *FilesystemInfo) []NfsClient { + if fileInfo == nil { + return []NfsClient{} + } + + var filteredClients []NfsClient + for _, client := range fileInfo.NFSClients { + if !t.isClientIgnored(client.Host) { + filteredClients = append(filteredClients, client) + } + } + + return filteredClients +} + +// isClientIgnored checks if a client host should be ignored +func (t *T) isClientIgnored(host string) bool { + for _, ignored := range NfsClientIgnored { + if host == ignored { + return true + } + } + return false +} + +// clearFileStatusCache clears the filesystem info cache +func (t *T) clearFileStatusCache() error { + var errs error + t.fileInfoCache = nil + for _, s := range []string{"getFileInfo"} { + errs = errors.Join(errs, t.mgr.cacheClear(s)) + } + return errs +} + +// String returns a string representation of an NfsClient +func (c *NfsClient) String() string { + if c == nil { + return "NfsClient " + } + return fmt.Sprintf("NfsClient uuid:%s, host:%s, protocol:%s, permission:%s", + c.UUID, c.Host, c.Protocol, c.Permission) +} diff --git a/drivers/resfssgcp_nfs/main_test.go b/drivers/resfssgcp_nfs/main_test.go new file mode 100644 index 000000000..a40c80a26 --- /dev/null +++ b/drivers/resfssgcp_nfs/main_test.go @@ -0,0 +1,338 @@ +package resfssgcp_nfs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opensvc/om3/v3/core/driver" + "github.com/opensvc/om3/v3/core/resource" + "github.com/opensvc/om3/v3/core/status" + "github.com/opensvc/om3/v3/util/sgcp" +) + +func newDrvWithRid(s string) *T { + d := New().(*T) + if err := d.SetRID(s); err != nil { + panic(err) + } + 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} +} + +// TestDriverID tests that the driver has the correct ID +func TestDriverID(t *testing.T) { + drv := New() + assert.Equal(t, driver.NewID(driver.GroupFS, "sgcp_nfs"), drv.DriverID()) +} + +// TestNew creates a new driver instance +func TestNew(t *testing.T) { + drv := New() + require.NotNil(t, drv) + + // Check that it implements the resource.Driver interface + _, ok := drv.(resource.Driver) + assert.True(t, ok) +} + +// TestConfigure tests driver configuration +func TestConfigure(t *testing.T) { + drv := newDrvWithRid("test-rid") + drv.authInfoer = tCreateAuthInfo("id1") + // Set configuration + drv.UUID = "test-uuid" + drv.Host = "test-host" + drv.Permission = "read-write" + drv.Protocol = "nfs4.1" + // Configure should not fail with valid config + err := drv.Configure() + assert.NoError(t, err) + + // Check defaults + assert.Equal(t, "read-write", drv.Permission) + assert.Equal(t, "nfs4.1", drv.Protocol) + assert.Equal(t, "iam", drv.Secret) + assert.Equal(t, sgcp.DefaultBaseURL, drv.Endpoint) +} + +// TestConfigureWithMissingUUID tests configuration validation +func TestConfigureWithMissingUUID(t *testing.T) { + drv := newDrvWithRid("test-rid") + drv.authInfoer = tCreateAuthInfo("id1") + + // This should still work since UUID is set via the configuration + drv.UUID = "test-uuid" + err := drv.Configure() + assert.NoError(t, err) +} + +// TestIsClientIgnored tests the client ignored functionality +func TestIsClientIgnored(t *testing.T) { + drv := newDrvWithRid("test-rid") + drv.authInfoer = tCreateAuthInfo("id1") + + err := drv.Configure() + assert.NoError(t, err) + + // Add some ignored hosts + NfsClientIgnored = []string{"ignored1", "ignored2"} + + assert.True(t, drv.isClientIgnored("ignored1")) + assert.True(t, drv.isClientIgnored("ignored2")) + assert.False(t, drv.isClientIgnored("valid-host")) +} + +// TestClientString tests the client string representation +func TestClientString(t *testing.T) { + client := &NfsClient{ + UUID: "client-uuid", + Host: "client-host", + Permission: "read-write", + Protocol: "nfs4.1", + } + + str := client.String() + assert.Contains(t, str, "client-uuid") + assert.Contains(t, str, "client-host") + assert.Contains(t, str, "read-write") + assert.Contains(t, str, "nfs4.1") +} + +// TestGetNFSClients tests filtering of NFS clients +func TestGetNFSClients(t *testing.T) { + drv := newDrvWithRid("test-rid") + drv.authInfoer = tCreateAuthInfo("id1") + require.NoError(t, drv.Configure()) + + // Set up ignored hosts + NfsClientIgnored = []string{"ignored-host"} + + fileInfo := &FilesystemInfo{ + UUID: "fs-uuid", + NFSClients: []NfsClient{ + {UUID: "client1", Host: "host1", Permission: "read-write", Protocol: "nfs4.1"}, + {UUID: "client2", Host: "ignored-host", Permission: "read-write", Protocol: "nfs4.1"}, + {UUID: "client3", Host: "host2", Permission: "read-only", Protocol: "nfs4.1"}, + }, + } + + clients := drv.getNFSClients(fileInfo) + + // Should have 2 clients (ignored-host should be filtered out) + assert.Len(t, clients, 2) + + // Check that the ignored host is not in the result + hosts := []string{} + for _, client := range clients { + hosts = append(hosts, client.Host) + } + assert.NotContains(t, hosts, "ignored-host") +} + +// TestFileStatusWithNoClients tests status when no clients are available +func TestFileStatusWithNoClients(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uuid": "test-uuid", "nfsClients": [], "status": "online"}`)) + })) + defer server.Close() + + // Create a mock HTTP client that uses our test server + client := server.Client() + _ = client + + // This test is more of a unit test for the logic + // In a real scenario, you'd mock the HTTP client properly + drv := New().(*T) + drv.UUID = "test-uuid" + drv.Host = "test-host" + drv.Permission = "read-write" + drv.Endpoint = server.URL + + // Mock the filesAPI + // This is simplified - in a real test you'd use dependency injection + + // Test with no clients + testNoClients := &FilesystemInfo{ + UUID: "test-uuid", + NFSClients: []NfsClient{}, + Status: "online", + } + + // Test the status logic + assert.Equal(t, status.Down, drv.fileStatusFromInfo(testNoClients)) +} + +// TestFileStatusWithClients tests status when clients are available +func TestFileStatusWithClients(t *testing.T) { + testWithClients := &FilesystemInfo{ + UUID: "test-uuid", + NFSClients: []NfsClient{ + {UUID: "client1", Host: "test-host", Permission: "read-write", Protocol: "nfs4.1"}, + }, + Status: "online", + } + + drv := New().(*T) + drv.UUID = "test-uuid" + drv.Host = "test-host" + drv.Permission = "read-write" + + // Test the status logic + assert.Equal(t, status.Up, drv.fileStatusFromInfo(testWithClients)) +} + +// fileStatusFromInfo is a test helper that simulates fileStatus with a given FilesystemInfo +func (t *T) fileStatusFromInfo(fileInfo *FilesystemInfo) status.T { + if fileInfo == nil { + t.StatusLog().Info("file object not visible in api") + return status.Down + } + + clients := t.getNFSClients(fileInfo) + n := len(clients) + + if t.Exclusive { + if n > 1 { + t.StatusLog().Warn("too many grants (%d)", n) + } + } + + if n == 0 { + t.StatusLog().Info("no access granted") + return status.Down + } + + // Filter clients to only those matching our host and permission + var matchingClients []NfsClient + for _, client := range clients { + if client.Host == t.Host && client.Permission == t.Permission { + matchingClients = append(matchingClients, client) + } + } + + if len(matchingClients) == 0 { + t.StatusLog().Info("access not in current grants") + return status.Down + } + + if fileInfo.Status == "passive" { + t.StatusLog().Info("status passive, access granted") + return status.Down + } + + return status.Up +} + +// TestLabel tests the label functionality +func TestLabel(t *testing.T) { + drv := newDrvWithRid("test-rid") + drv.authInfoer = tCreateAuthInfo("id1") + + drv.UUID = "test-uuid" + drv.MountPoint = "/tmp/foo" + drv.Device = "/dev/false-nfs-for-test" + require.NoError(t, drv.Configure()) + + ctx := context.Background() + label := drv.Label(ctx) + + // Without an underlying filesystem driver, should return UUID + assert.Equal(t, "/dev/false-nfs-for-test@/tmp/foo", label) +} + +// TestManifest tests the manifest functionality +func TestManifest(t *testing.T) { + drv := New().(*T) + + manifest := drv.Manifest() + require.NotNil(t, manifest) + + // Should have the correct driver ID + assert.Equal(t, drvID, manifest.DriverID) + + // Should have keywords + assert.NotEmpty(t, manifest.Keywords) +} + +// TestFilesystemInfoUnmarshal tests JSON unmarshaling of FilesystemInfo +func TestFilesystemInfoUnmarshal(t *testing.T) { + jsonData := `{ + "uuid": "test-uuid", + "consistencyGroupId": "cg-uuid", + "nfsClients": [ + {"uuid": "client1", "host": "host1", "permission": "read-write", "protocol": "nfs4.1"} + ], + "status": "online" + }` + + var fileInfo FilesystemInfo + err := json.Unmarshal([]byte(jsonData), &fileInfo) + require.NoError(t, err) + + assert.Equal(t, "test-uuid", fileInfo.UUID) + assert.Equal(t, "cg-uuid", fileInfo.ConsistencyGroupID) + assert.Len(t, fileInfo.NFSClients, 1) + assert.Equal(t, "host1", fileInfo.NFSClients[0].Host) + assert.Equal(t, "read-write", fileInfo.NFSClients[0].Permission) +} + +// TestNfsClientMarshal tests JSON marshaling of NfsClient +func TestNfsClientMarshal(t *testing.T) { + client := NfsClient{ + UUID: "client-uuid", + Host: "client-host", + Permission: "read-write", + Protocol: "nfs4.1", + } + + jsonData, err := json.Marshal(client) + require.NoError(t, err) + + var unmarshaled NfsClient + err = json.Unmarshal(jsonData, &unmarshaled) + require.NoError(t, err) + + assert.Equal(t, client.UUID, unmarshaled.UUID) + assert.Equal(t, client.Host, unmarshaled.Host) + assert.Equal(t, client.Permission, unmarshaled.Permission) + assert.Equal(t, client.Protocol, unmarshaled.Protocol) +} diff --git a/drivers/resfssgcp_nfs/manifest.go b/drivers/resfssgcp_nfs/manifest.go index 67faafe7a..8870bc8d7 100644 --- a/drivers/resfssgcp_nfs/manifest.go +++ b/drivers/resfssgcp_nfs/manifest.go @@ -8,6 +8,14 @@ import ( "github.com/opensvc/om3/v3/core/keywords" "github.com/opensvc/om3/v3/core/manifest" "github.com/opensvc/om3/v3/core/naming" + "github.com/opensvc/om3/v3/drivers/resfshost" +) + +const ( + DefaultPermission = "read-write" + DefaultProtocol = "nfs4.1" + DefaultExclusive = "false" + SecretDefaultName = "iam" ) var ( @@ -42,8 +50,8 @@ var ( { Attr: "Exclusive", Option: "exclusive", - Converter: "boolean", - Default: "false", // TODO: move to config + Converter: "bool", + Default: DefaultExclusive, Scopable: true, Text: keywords.NewText(fs, "text/kw/exclusive"), }, @@ -65,7 +73,6 @@ var ( { Attr: "Endpoint", Option: "endpoint", - Default: "https://localhost/v1", // TODO: move to config Scopable: true, Text: keywords.NewText(fs, "text/kw/endpoint"), }, @@ -85,6 +92,14 @@ func (t *T) Manifest() *manifest.T { m := manifest.New(drvID, t) m.Kinds.Or(naming.KindSvc, naming.KindVol) m.AddKeywords(kws...) + m.AddKeywords( + &resfshost.KeywordDevice, + &resfshost.KeywordMountPoint, + &resfshost.KeywordMountOptions, + &resfshost.KeywordStatTimeout, + &resfshost.KeywordZone, + &resfshost.KeywordCheckReadEnabled, + ) m.AddKeywords(datarecv.Keywords("DataRecv.")...) return m } From cf0ba254d45e1a1a6dc9ed1084462423ab4262d3 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:24:59 +0200 Subject: [PATCH 18/33] [resfssgcp_nfs] Validate `secret` and `endpoint` fields with fallback in configuration - Added validation for `secret` and `endpoint` fields, ensuring they are specified either in the task or configuration file. - Adjusted configuration logic to set default values or return appropriate error messages if missing. --- drivers/resfssgcp_nfs/main.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/drivers/resfssgcp_nfs/main.go b/drivers/resfssgcp_nfs/main.go index 4bc21acd0..0c8f7cb3c 100644 --- a/drivers/resfssgcp_nfs/main.go +++ b/drivers/resfssgcp_nfs/main.go @@ -98,9 +98,21 @@ func (t *T) Configure() error { if t.Secret == "" { t.Secret = cfg.Auth.DefaultSecret } + if t.Secret == "" { + return fmt.Errorf("secret is required (neither defined into secret keyword nor config file %s", sgcp.DefaultConfigPath) + } else { + cfg = cfg.WithAuthSecret(t.Secret) + } + if t.Endpoint == "" { t.Endpoint = cfg.Files.BaseURL } + if t.Endpoint == "" { + return fmt.Errorf("file endpoint is required (neither defined into endpoint keyword nor config file %s", sgcp.DefaultConfigPath) + } else { + cfg = cfg.WithFileURL(t.Endpoint) + } + if t.Permission == "" { t.Permission = DefaultPermission } @@ -108,13 +120,6 @@ func (t *T) Configure() error { t.Protocol = DefaultProtocol } - if t.Endpoint != "" { - cfg = cfg.WithFileURL(t.Endpoint) - } - if t.Secret != "" { - cfg = cfg.WithAuthSecret(t.Secret) - } - if err := t.configureMgr(cfg); err != nil { return fmt.Errorf("configure mgr: %w", err) } From 44dc326e618a00de5fb54616f03b0971b21f0276 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:26:58 +0200 Subject: [PATCH 19/33] [resipsgcp_dnsalias] Remove unused default values for `secret` and `endpoint` fields in manifest --- drivers/resipsgcp_dnsalias/manifest.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/drivers/resipsgcp_dnsalias/manifest.go b/drivers/resipsgcp_dnsalias/manifest.go index 06d44c599..55e715b08 100644 --- a/drivers/resipsgcp_dnsalias/manifest.go +++ b/drivers/resipsgcp_dnsalias/manifest.go @@ -48,14 +48,12 @@ var ( { Attr: "Secret", Option: "secret", - Default: SecretDefaultName, Scopable: true, Text: keywords.NewText(fs, "text/kw/secret"), }, { Attr: "Endpoint", Option: "endpoint", - Default: "https://localhost/v1", // TODO: move to config Scopable: true, Text: keywords.NewText(fs, "text/kw/endpoint"), }, From e187732fdf22feefb402a78a04cb66378ce7f2d3 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:27:08 +0200 Subject: [PATCH 20/33] Add `resfssgcp_nfs` driver to driver database --- core/driverdb/drivers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/core/driverdb/drivers.go b/core/driverdb/drivers.go index 5e190eb61..a67962248 100644 --- a/core/driverdb/drivers.go +++ b/core/driverdb/drivers.go @@ -35,6 +35,7 @@ import ( _ "github.com/opensvc/om3/v3/drivers/resfsdir" _ "github.com/opensvc/om3/v3/drivers/resfsflag" _ "github.com/opensvc/om3/v3/drivers/resfshost" + _ "github.com/opensvc/om3/v3/drivers/resfssgcp_nfs" _ "github.com/opensvc/om3/v3/drivers/resfszfs" _ "github.com/opensvc/om3/v3/drivers/resiphost" _ "github.com/opensvc/om3/v3/drivers/resiproute" From ed65950ef4dcc2bec8c3896ead544d2134ba8f81 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 17:33:55 +0200 Subject: [PATCH 21/33] [resfssgcp_nfs] Conditionally register driver based on SGCP config file presence --- drivers/resfssgcp_nfs/manifest.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/drivers/resfssgcp_nfs/manifest.go b/drivers/resfssgcp_nfs/manifest.go index 8870bc8d7..a4abdf924 100644 --- a/drivers/resfssgcp_nfs/manifest.go +++ b/drivers/resfssgcp_nfs/manifest.go @@ -9,6 +9,8 @@ import ( "github.com/opensvc/om3/v3/core/manifest" "github.com/opensvc/om3/v3/core/naming" "github.com/opensvc/om3/v3/drivers/resfshost" + "github.com/opensvc/om3/v3/util/file" + "github.com/opensvc/om3/v3/util/sgcp" ) const ( @@ -80,7 +82,9 @@ var ( ) func init() { - driver.Register(drvID, New) + if file.Exists(sgcp.DefaultConfigPath) { + driver.Register(drvID, New) + } } func (t *T) DriverID() driver.ID { From afdefeda5c0fac76324e4e6b82e98040e65fc54e Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 20:05:49 +0200 Subject: [PATCH 22/33] Refactor SGCP configuration loading and testing: - Moved test configuration logic to a new `testsgcphelper` package. - Replaced `LoadConfig` with a thread-safe `GetConfig` function. - Introduced `SetConfigForTest` for managing test configurations. - Updated test cases to use the refactored configuration logic. --- util/sgcp/api_test.go | 5 -- util/sgcp/auth_test.go | 4 +- util/sgcp/config.go | 58 ++++++++++++------- util/sgcp/config_test.go | 56 +++++------------- util/sgcp/file_test.go | 9 +-- util/sgcp/testsgcphelper/main.go | 24 ++++++++ .../{ => testsgcphelper}/text/config.yaml | 0 7 files changed, 82 insertions(+), 74 deletions(-) create mode 100644 util/sgcp/testsgcphelper/main.go rename util/sgcp/{ => testsgcphelper}/text/config.yaml (100%) diff --git a/util/sgcp/api_test.go b/util/sgcp/api_test.go index eb4fda7ac..e545b007f 100644 --- a/util/sgcp/api_test.go +++ b/util/sgcp/api_test.go @@ -25,11 +25,6 @@ func (ttk *TTkBuilder) Get(ctx context.Context, scope ...string) (string, error) func TestDo(t *testing.T) { defer Setup(t)() - InstallConfig(t) - - cfg, err := LoadConfig() - require.NoError(t, err) - require.NotNil(t, cfg) // Create a test server var calls []string diff --git a/util/sgcp/auth_test.go b/util/sgcp/auth_test.go index 404755ee1..9218af6ff 100644 --- a/util/sgcp/auth_test.go +++ b/util/sgcp/auth_test.go @@ -17,10 +17,8 @@ import ( func TestTokenFactory(t *testing.T) { defer Setup(t)() - InstallConfig(t) - cfg, err := LoadConfig() - require.NoError(t, err) + cfg := GetConfig() require.NotNil(t, cfg) // Create a test server diff --git a/util/sgcp/config.go b/util/sgcp/config.go index 5daf04c7f..34700ec68 100644 --- a/util/sgcp/config.go +++ b/util/sgcp/config.go @@ -58,36 +58,20 @@ type ( // SGCPConfig is the global configuration instance var ( config *Config + configLck sync.RWMutex configOnce sync.Once logger = plog.NewDefaultLogger() DefaultConfigPath = "/etc/om3/sgcp.yaml" ) -const ( - DefaultBaseURL = "https://localhost/file/v1" -) - -// LoadConfig loads the SGCP configuration -func LoadConfig() (*Config, error) { - data, err := os.ReadFile(DefaultConfigPath) - if err != nil { - return nil, fmt.Errorf("failed to read config file %s: %w", DefaultConfigPath, err) - } - - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse config file %s: %w", DefaultConfigPath, err) - } - - return &config, nil -} - // GetConfig returns the global SGCP configuration, loading it once func GetConfig() *Config { + configLck.Lock() + defer configLck.Unlock() configOnce.Do(func() { var err error - config, err = LoadConfig() + config, err = loadConfig(DefaultConfigPath) if err != nil { logger.Debugf("Failed to load SGCP config: %v", err) } @@ -96,6 +80,25 @@ func GetConfig() *Config { return config.Clone() } +// SetConfigForTest sets the global configuration for testing, +// loading it from the specified file, or resetting it if null configFile is passed. +func SetConfigForTest(configFile string) { + // Need to call GetConfig to ensure configOnce is initialized + _ = GetConfig() + + configLck.Lock() + defer configLck.Unlock() + if configFile == "" { + config = nil + return + } + var err error + config, err = loadConfig(configFile) + if err != nil { + logger.Debugf("Failed to load SGCP config: %v", err) + } +} + func (c *Config) Clone() *Config { if c == nil { return nil @@ -142,3 +145,18 @@ func (c *Config) GetDefaultSecret() string { } return c.Auth.DefaultSecret } + +// loadConfig loads the SGCP configuration +func loadConfig(s string) (*Config, error) { + data, err := os.ReadFile(s) + if err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", s, err) + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", s, err) + } + + return &config, nil +} diff --git a/util/sgcp/config_test.go b/util/sgcp/config_test.go index 248718f80..457671382 100644 --- a/util/sgcp/config_test.go +++ b/util/sgcp/config_test.go @@ -1,44 +1,29 @@ package sgcp import ( - "embed" - "os" - "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" -) -var ( - //go:embed text - fs embed.FS + "github.com/opensvc/om3/v3/util/sgcp/testsgcphelper" ) -func Setup(t *testing.T) (cleanup func()) { +func Setup(t *testing.T) func() { t.Helper() - tmpDir := t.TempDir() - OrigDefaultConfigPath := DefaultConfigPath - DefaultConfigPath = filepath.Join(tmpDir, "sgcp.yaml") - cleanup = func() { - DefaultConfigPath = OrigDefaultConfigPath - } - return cleanup -} + cfgFile := testsgcphelper.InstallConfig(t) + SetConfigForTest(cfgFile) -func InstallConfig(t *testing.T) { - t.Helper() - b, err := fs.ReadFile("text/config.yaml") - require.NoError(t, err) - require.NoError(t, os.WriteFile(DefaultConfigPath, b, 0755)) + return func() { + SetConfigForTest("") + } } // TestGetScopes tests the GetScopes function func TestGetScopes(t *testing.T) { defer Setup(t)() - InstallConfig(t) - cfg, err := LoadConfig() - require.NoError(t, err) + + cfg := GetConfig() require.NotNil(t, cfg) scopes := cfg.GetScopes("custom_admin") @@ -52,35 +37,26 @@ func TestGetScopes(t *testing.T) { // TestGetDefaultSecret tests the GetDefaultSecret function func TestGetDefaultSecret(t *testing.T) { defer Setup(t)() - InstallConfig(t) - config, err := LoadConfig() - require.NoError(t, err) + cfg := GetConfig() + require.NotNil(t, cfg) - secret := config.GetDefaultSecret() + secret := cfg.GetDefaultSecret() assert.Equal(t, "the-secret", secret) } -// TestDefaultConfigPath tests the default config path value -func TestDefaultConfigPath(t *testing.T) { - assert.Equal(t, "/etc/om3/sgcp.yaml", DefaultConfigPath) -} - // TestGetConfig tests the global config getter when no config exists -func TestGetConfigWhenNotPresnt(t *testing.T) { - defer Setup(t)() - cfg, err := LoadConfig() - assert.NotNil(t, err) +func TestGetConfigWhenNotPresent(t *testing.T) { + SetConfigForTest("") + cfg := GetConfig() assert.Nil(t, cfg) } // TestLoadConfig tests loading the configuration from a file func TestLoadConfig(t *testing.T) { defer Setup(t)() - InstallConfig(t) - cfg, err := LoadConfig() - require.NoError(t, err) + cfg := GetConfig() require.NotNil(t, cfg) // Test files configuration diff --git a/util/sgcp/file_test.go b/util/sgcp/file_test.go index 8159378cd..b419b3b2f 100644 --- a/util/sgcp/file_test.go +++ b/util/sgcp/file_test.go @@ -17,9 +17,8 @@ import ( // TestFilesAPIURLMethods tests URL construction for FilesAPI func TestFilesAPIURLMethods(t *testing.T) { defer Setup(t)() - InstallConfig(t) - cfg, err := LoadConfig() - require.NoError(t, err) + + cfg := GetConfig() require.NotNil(t, cfg) api := NewFilesAPI(cfg, nil, nil, nil) @@ -43,10 +42,8 @@ func TestFilesAPIURLMethods(t *testing.T) { func TestGetFilesystem(t *testing.T) { defer Setup(t)() - InstallConfig(t) - cfg, err := LoadConfig() - require.NoError(t, err) + cfg := GetConfig() require.NotNil(t, cfg) // Create a test server diff --git a/util/sgcp/testsgcphelper/main.go b/util/sgcp/testsgcphelper/main.go new file mode 100644 index 000000000..9c5c69ce8 --- /dev/null +++ b/util/sgcp/testsgcphelper/main.go @@ -0,0 +1,24 @@ +package testsgcphelper + +import ( + "embed" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + //go:embed text + fs embed.FS +) + +func InstallConfig(t *testing.T) string { + t.Helper() + tmpCfgFile := filepath.Join(t.TempDir(), "sgcp.yaml") + b, err := fs.ReadFile("text/config.yaml") + require.NoError(t, err) + require.NoError(t, os.WriteFile(tmpCfgFile, b, 0755)) + return tmpCfgFile +} diff --git a/util/sgcp/text/config.yaml b/util/sgcp/testsgcphelper/text/config.yaml similarity index 100% rename from util/sgcp/text/config.yaml rename to util/sgcp/testsgcphelper/text/config.yaml From 01b50d9bc9e37c8d464ef6733f9e3df0fd3d3f7e Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Mon, 6 Jul 2026 20:06:39 +0200 Subject: [PATCH 23/33] [resfssgcp_nfs] Add mandatory config file validation and enhance test setup - Added validation for mandatory SGCP config file in `Configure` method. - Introduced `Setup` helper for managing test configuration state. - Enhanced test cases to validate error handling and configuration prerequisites. --- drivers/resfssgcp_nfs/main.go | 4 +++ drivers/resfssgcp_nfs/main_test.go | 42 ++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/drivers/resfssgcp_nfs/main.go b/drivers/resfssgcp_nfs/main.go index 0c8f7cb3c..548641079 100644 --- a/drivers/resfssgcp_nfs/main.go +++ b/drivers/resfssgcp_nfs/main.go @@ -94,6 +94,10 @@ func New() resource.Driver { func (t *T) Configure() error { cfg := sgcp.GetConfig() + if cfg == nil { + return fmt.Errorf("mandatory config file is required: %s", sgcp.DefaultConfigPath) + } + // set defaults when kw are not set if t.Secret == "" { t.Secret = cfg.Auth.DefaultSecret diff --git a/drivers/resfssgcp_nfs/main_test.go b/drivers/resfssgcp_nfs/main_test.go index a40c80a26..e926933bd 100644 --- a/drivers/resfssgcp_nfs/main_test.go +++ b/drivers/resfssgcp_nfs/main_test.go @@ -14,6 +14,7 @@ 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 { @@ -55,6 +56,18 @@ func tCreateAuthInfo(s string) *tAuthInfo { 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() { + cfgFile := testsgcphelper.InstallConfig(t) + sgcp.SetConfigForTest(cfgFile) + require.NotNil(t, sgcp.GetConfig()) + + return func() { + sgcp.SetConfigForTest("") + } +} + // TestDriverID tests that the driver has the correct ID func TestDriverID(t *testing.T) { drv := New() @@ -73,6 +86,8 @@ func TestNew(t *testing.T) { // TestConfigure tests driver configuration func TestConfigure(t *testing.T) { + defer Setup(t)() + drv := newDrvWithRid("test-rid") drv.authInfoer = tCreateAuthInfo("id1") // Set configuration @@ -87,12 +102,29 @@ func TestConfigure(t *testing.T) { // Check defaults assert.Equal(t, "read-write", drv.Permission) assert.Equal(t, "nfs4.1", drv.Protocol) - assert.Equal(t, "iam", drv.Secret) - assert.Equal(t, sgcp.DefaultBaseURL, drv.Endpoint) + assert.Equal(t, "the-secret", drv.Secret) + assert.Equal(t, "https://127.0.0.1:1215/file", drv.Endpoint) +} + +// TestConfigure tests driver configuration +func TestConfigureWhenNoConfig(t *testing.T) { + sgcp.SetConfigForTest("") + + drv := newDrvWithRid("test-rid") + drv.authInfoer = tCreateAuthInfo("id1") + // Set configuration + drv.UUID = "test-uuid" + drv.Host = "test-host" + drv.Permission = "read-write" + drv.Protocol = "nfs4.1" + // Configure should not fail with valid config + require.Error(t, drv.Configure(), "mandatory config file is required: /etc/om3/sgcp.yaml") } // TestConfigureWithMissingUUID tests configuration validation func TestConfigureWithMissingUUID(t *testing.T) { + defer Setup(t)() + drv := newDrvWithRid("test-rid") drv.authInfoer = tCreateAuthInfo("id1") @@ -104,6 +136,8 @@ func TestConfigureWithMissingUUID(t *testing.T) { // TestIsClientIgnored tests the client ignored functionality func TestIsClientIgnored(t *testing.T) { + defer Setup(t)() + drv := newDrvWithRid("test-rid") drv.authInfoer = tCreateAuthInfo("id1") @@ -136,6 +170,8 @@ func TestClientString(t *testing.T) { // TestGetNFSClients tests filtering of NFS clients func TestGetNFSClients(t *testing.T) { + defer Setup(t)() + drv := newDrvWithRid("test-rid") drv.authInfoer = tCreateAuthInfo("id1") require.NoError(t, drv.Configure()) @@ -264,6 +300,8 @@ func (t *T) fileStatusFromInfo(fileInfo *FilesystemInfo) status.T { // TestLabel tests the label functionality func TestLabel(t *testing.T) { + defer Setup(t)() + drv := newDrvWithRid("test-rid") drv.authInfoer = tCreateAuthInfo("id1") From 7e7b23b0edc3c91af36961ec2e0cebefa08059b8 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:01:06 +0200 Subject: [PATCH 24/33] [sgcp] Add singleflight for token cache fetch to prevent duplicate requests --- util/sgcp/auth.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/util/sgcp/auth.go b/util/sgcp/auth.go index e0e4ed666..04dc87a5f 100644 --- a/util/sgcp/auth.go +++ b/util/sgcp/auth.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "golang.org/x/sync/singleflight" + "github.com/opensvc/om3/v3/util/ageingcache" "github.com/opensvc/om3/v3/util/plog" ) @@ -42,6 +44,10 @@ type ( } ) +var ( + singleFlightGrp singleflight.Group +) + // NewTokenFactory initializes a new instance of TokenFactory with the provided logger, authentication config, and auth info. func NewTokenFactory(log *plog.Logger, client *http.Client, authCfg *AuthConfig, authInfo *AuthInfo) *TokenFactory { return &TokenFactory{ @@ -65,11 +71,17 @@ func (t *TokenFactory) Get(ctx context.Context, scope ...string) (string, error) } return []byte(token.AccessToken), nil }) - data, err := ageingcache.Output(o, sig, cacheMaxAge) + i, err, _ := singleFlightGrp.Do(sig, func() (interface{}, error) { + return ageingcache.Output(o, sig, cacheMaxAge) + }) + if err != nil { return "", err } - return string(data), nil + if b, ok := i.([]byte); ok { + return string(b), nil + } + return "", fmt.Errorf("unexpected returned token type") } // Clear removes the cached authentication token associated with the provided scope(s). From 3fde4c8813f0ae27841284ed711be76b6e22959b Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:02:20 +0200 Subject: [PATCH 25/33] [resfssgcp_nfs] Clear file status cache on start/stop operations --- drivers/resfssgcp_nfs/main.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/drivers/resfssgcp_nfs/main.go b/drivers/resfssgcp_nfs/main.go index 548641079..3d0d48423 100644 --- a/drivers/resfssgcp_nfs/main.go +++ b/drivers/resfssgcp_nfs/main.go @@ -252,6 +252,9 @@ func (t *T) Boot(ctx context.Context) error { // fileStart handles the SGCP API part of starting the filesystem func (t *T) fileStart(ctx context.Context) error { + defer func() { + _ = t.clearFileStatusCache() + }() if sgcp.IsDisabled(rawconfig.NodeVarDir()) { t.Log().Infof("skipping file start %s: SGCP API disabled", t.UUID) return nil @@ -276,6 +279,9 @@ func (t *T) fileStart(ctx context.Context) error { // fileStop handles the SGCP API part of stopping the filesystem func (t *T) fileStop(ctx context.Context) error { + defer func() { + _ = t.clearFileStatusCache() + }() if sgcp.IsDisabled(rawconfig.NodeVarDir()) { t.Log().Infof("skipping file stop %s: SGCP API disabled", t.UUID) return nil From 77c98f660034aeca394f16657f9be2c4d62ade57 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:25:10 +0200 Subject: [PATCH 26/33] [resfssgcp_nfs] Simplify driver registration and adjust capabilities scanning --- drivers/resfssgcp_nfs/caps.go | 7 ++++++- drivers/resfssgcp_nfs/manifest.go | 6 +----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/drivers/resfssgcp_nfs/caps.go b/drivers/resfssgcp_nfs/caps.go index ef7d03b74..2739131ee 100644 --- a/drivers/resfssgcp_nfs/caps.go +++ b/drivers/resfssgcp_nfs/caps.go @@ -4,6 +4,8 @@ import ( "context" "github.com/opensvc/om3/v3/util/capabilities" + "github.com/opensvc/om3/v3/util/file" + "github.com/opensvc/om3/v3/util/sgcp" ) func init() { @@ -11,5 +13,8 @@ func init() { } func capabilitiesScanner(ctx context.Context) ([]string, error) { + if !file.Exists(sgcp.DefaultConfigPath) { + return nil, nil + } return []string{drvID.Cap()}, nil -} \ No newline at end of file +} diff --git a/drivers/resfssgcp_nfs/manifest.go b/drivers/resfssgcp_nfs/manifest.go index a4abdf924..8870bc8d7 100644 --- a/drivers/resfssgcp_nfs/manifest.go +++ b/drivers/resfssgcp_nfs/manifest.go @@ -9,8 +9,6 @@ import ( "github.com/opensvc/om3/v3/core/manifest" "github.com/opensvc/om3/v3/core/naming" "github.com/opensvc/om3/v3/drivers/resfshost" - "github.com/opensvc/om3/v3/util/file" - "github.com/opensvc/om3/v3/util/sgcp" ) const ( @@ -82,9 +80,7 @@ var ( ) func init() { - if file.Exists(sgcp.DefaultConfigPath) { - driver.Register(drvID, New) - } + driver.Register(drvID, New) } func (t *T) DriverID() driver.ID { From 87b8fb932a997557c675e443365bff4e490bf858 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:26:41 +0200 Subject: [PATCH 27/33] [resfssgcp_nfs] Skip capability scanning if the SGCP config file is missing --- drivers/resfssgcp_nfs_cg/caps.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/drivers/resfssgcp_nfs_cg/caps.go b/drivers/resfssgcp_nfs_cg/caps.go index 98149b432..9defb9224 100644 --- a/drivers/resfssgcp_nfs_cg/caps.go +++ b/drivers/resfssgcp_nfs_cg/caps.go @@ -4,6 +4,8 @@ import ( "context" "github.com/opensvc/om3/v3/util/capabilities" + "github.com/opensvc/om3/v3/util/file" + "github.com/opensvc/om3/v3/util/sgcp" ) func init() { @@ -11,5 +13,8 @@ func init() { } func capabilitiesScanner(ctx context.Context) ([]string, error) { + if !file.Exists(sgcp.DefaultConfigPath) { + return nil, nil + } return []string{drvID.Cap()}, nil -} \ No newline at end of file +} From e8cc70e1ee165309583c7892e6c771951e2cfec2 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:27:08 +0200 Subject: [PATCH 28/33] [resipsgcp_dnsalias] Skip capability scanning if SGCP config file is missing --- drivers/resipsgcp_dnsalias/caps.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/drivers/resipsgcp_dnsalias/caps.go b/drivers/resipsgcp_dnsalias/caps.go index 453484f14..188c31241 100644 --- a/drivers/resipsgcp_dnsalias/caps.go +++ b/drivers/resipsgcp_dnsalias/caps.go @@ -4,6 +4,8 @@ import ( "context" "github.com/opensvc/om3/v3/util/capabilities" + "github.com/opensvc/om3/v3/util/file" + "github.com/opensvc/om3/v3/util/sgcp" ) func init() { @@ -11,5 +13,8 @@ func init() { } func capabilitiesScanner(ctx context.Context) ([]string, error) { + if !file.Exists(sgcp.DefaultConfigPath) { + return nil, nil + } return []string{drvID.Cap()}, nil -} \ No newline at end of file +} From 0a52896652e17074d15d3a3bfb9d299b003b8f24 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:27:49 +0200 Subject: [PATCH 29/33] [resfssgcp_nfs_cg] Remove unused default values for `secret` and `endpoint` fields in manifest --- drivers/resfssgcp_nfs_cg/manifest.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/drivers/resfssgcp_nfs_cg/manifest.go b/drivers/resfssgcp_nfs_cg/manifest.go index cf11ffe57..6d1e6a8ef 100644 --- a/drivers/resfssgcp_nfs_cg/manifest.go +++ b/drivers/resfssgcp_nfs_cg/manifest.go @@ -33,14 +33,12 @@ var ( { Attr: "Secret", Option: "secret", - Default: SecretDefaultName, Scopable: true, Text: keywords.NewText(fs, "text/kw/secret"), }, { Attr: "Endpoint", Option: "endpoint", - Default: "https://localhost/v1", // TODO: move to config Scopable: true, Text: keywords.NewText(fs, "text/kw/endpoint"), }, From b10f81ef46e8a6603f7fceb936ccc3a6acb74b05 Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 01:28:27 +0200 Subject: [PATCH 30/33] Add `resfssgcp_nfs_cg` and `resipsgcp_dnsalias` resource drivers with basic implementation --- drivers/resfssgcp_nfs_cg/main.go | 26 ++++++++++++++++++++++++++ drivers/resipsgcp_dnsalias/main.go | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 drivers/resfssgcp_nfs_cg/main.go create mode 100644 drivers/resipsgcp_dnsalias/main.go diff --git a/drivers/resfssgcp_nfs_cg/main.go b/drivers/resfssgcp_nfs_cg/main.go new file mode 100644 index 000000000..62593f241 --- /dev/null +++ b/drivers/resfssgcp_nfs_cg/main.go @@ -0,0 +1,26 @@ +package resfssgcp_nfs_cg + +import ( + "context" + + "github.com/opensvc/om3/v3/core/datarecv" + "github.com/opensvc/om3/v3/core/resource" + "github.com/opensvc/om3/v3/core/status" +) + +type ( + T struct { + resource.T + resource.Restart + datarecv.DataRecv + } +) + +// New creates a new SGCP NFS filesystem resource driver +func New() resource.Driver { + return &T{} +} + +func (t *T) Status(ctx context.Context) status.T { + return status.NotApplicable +} diff --git a/drivers/resipsgcp_dnsalias/main.go b/drivers/resipsgcp_dnsalias/main.go new file mode 100644 index 000000000..0cfb440d8 --- /dev/null +++ b/drivers/resipsgcp_dnsalias/main.go @@ -0,0 +1,26 @@ +package resipsgcp_dnsalias + +import ( + "context" + + "github.com/opensvc/om3/v3/core/datarecv" + "github.com/opensvc/om3/v3/core/resource" + "github.com/opensvc/om3/v3/core/status" +) + +type ( + T struct { + resource.T + resource.Restart + datarecv.DataRecv + } +) + +// New creates a new SGCP NFS filesystem resource driver +func New() resource.Driver { + return &T{} +} + +func (t *T) Status(ctx context.Context) status.T { + return status.NotApplicable +} From 3f4b23657d7a4d9738a0559ea9e8a294575e2cbe Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 11:04:56 +0200 Subject: [PATCH 31/33] [resfssgcp_nfs] Update filesystem configuration to use NFSv4 --- drivers/resfssgcp_nfs/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/resfssgcp_nfs/main.go b/drivers/resfssgcp_nfs/main.go index 3d0d48423..58c794f39 100644 --- a/drivers/resfssgcp_nfs/main.go +++ b/drivers/resfssgcp_nfs/main.go @@ -128,7 +128,7 @@ func (t *T) Configure() error { return fmt.Errorf("configure mgr: %w", err) } - if err := t.configureUnderlyingFilesystem("nfs"); err != nil { + if err := t.configureUnderlyingFilesystem("nfs4"); err != nil { return fmt.Errorf("configure underlying filesystem: %w", err) } From f6436315926b319ae39809321179df442a9c8abc Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 11:43:22 +0200 Subject: [PATCH 32/33] [resfssgcp_nfs] Add `StartTimeout` attribute to configure start action timeout --- drivers/resfssgcp_nfs/main.go | 11 +++++++++++ drivers/resfssgcp_nfs/manifest.go | 8 ++++++++ drivers/resfssgcp_nfs/text/kw/start_timeout | 3 +++ 3 files changed, 22 insertions(+) create mode 100644 drivers/resfssgcp_nfs/text/kw/start_timeout diff --git a/drivers/resfssgcp_nfs/main.go b/drivers/resfssgcp_nfs/main.go index 58c794f39..6971202b2 100644 --- a/drivers/resfssgcp_nfs/main.go +++ b/drivers/resfssgcp_nfs/main.go @@ -57,6 +57,7 @@ type ( Type string `json:"type"` MountOptions string `json:"mnt_opt"` StatTimeout *time.Duration `json:"stat_timeout"` + StartTimeout *time.Duration `json:"start_timeout"` Zone string `json:"zone"` CheckRead bool `json:"check_read"` @@ -194,6 +195,16 @@ func (t *T) configureUnderlyingFilesystem(resType string) error { // Start starts the SGCP NFS filesystem resource func (t *T) Start(ctx context.Context) error { + if t.StartTimeout != nil && *t.StartTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, *t.StartTimeout) + defer cancel() + } + deadline, ok := ctx.Deadline() + if ok { + t.Log().Tracef("action context deadline: %v", deadline) + } + // Start the XaaS (SGCP API) part if err := t.fileStart(ctx); err != nil { return err diff --git a/drivers/resfssgcp_nfs/manifest.go b/drivers/resfssgcp_nfs/manifest.go index 8870bc8d7..6570b2c0e 100644 --- a/drivers/resfssgcp_nfs/manifest.go +++ b/drivers/resfssgcp_nfs/manifest.go @@ -76,6 +76,14 @@ var ( Scopable: true, Text: keywords.NewText(fs, "text/kw/endpoint"), }, + { + Attr: "StartTimeout", + Converter: "duration", + Example: "1m5s", + Option: "start_timeout", + Scopable: true, + Text: keywords.NewText(fs, "text/kw/start_timeout"), + }, } ) diff --git a/drivers/resfssgcp_nfs/text/kw/start_timeout b/drivers/resfssgcp_nfs/text/kw/start_timeout new file mode 100644 index 000000000..6ffef3178 --- /dev/null +++ b/drivers/resfssgcp_nfs/text/kw/start_timeout @@ -0,0 +1,3 @@ +Wait for `` before declaring the start action a failure. + +If undefined, the object start_timeout will be used. From e7fa7e0937c2e85545f57192a0091ae60882a75c Mon Sep 17 00:00:00 2001 From: Cyril Galibern Date: Tue, 7 Jul 2026 12:18:54 +0200 Subject: [PATCH 33/33] [resfssgcp_nfs] Remove unused default value for `Secret` attribute in manifest --- drivers/resfssgcp_nfs/manifest.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/drivers/resfssgcp_nfs/manifest.go b/drivers/resfssgcp_nfs/manifest.go index 6570b2c0e..a8ae6005e 100644 --- a/drivers/resfssgcp_nfs/manifest.go +++ b/drivers/resfssgcp_nfs/manifest.go @@ -15,7 +15,6 @@ const ( DefaultPermission = "read-write" DefaultProtocol = "nfs4.1" DefaultExclusive = "false" - SecretDefaultName = "iam" ) var ( @@ -66,7 +65,6 @@ var ( { Attr: "Secret", Option: "secret", - Default: SecretDefaultName, Scopable: true, Text: keywords.NewText(fs, "text/kw/secret"), },