Skip to content

Refactor and enhance SGCP drivers and caching mechanisms - #1103

Open
cgalibern wants to merge 34 commits into
opensvc:mainfrom
cgalibern:sgcp
Open

Refactor and enhance SGCP drivers and caching mechanisms#1103
cgalibern wants to merge 34 commits into
opensvc:mainfrom
cgalibern:sgcp

Conversation

@cgalibern

Copy link
Copy Markdown
Contributor

Description of Changes

  • Refactor:

    • Utilized ageingcache for improved consistency group cache handling.
    • Revised error handling with ErrPrecondition for better clarity and control.
  • Fixes:

    • Addressed security findings in the resfssgcp_nfs_cg driver:
      • Enhanced Switchover with precondition handling for safer failover fallback.
      • Improved cache handling to avoid stale state.
      • Strengthened checks for geo-redundancy and availability zone.
    • Adjusted resume-replication mock in tests to preserve hook-set status.
    • Resolved vulnerabilities in DNS alias cache to improve integrity and efficiency.
  • Features:

    • Implemented a new resfssgcp_nfs_cg resource driver to manage NFS consistency groups with capabilities like Switchover, Sync-resume, and enhanced status reporting.
    • Added DNS and CG endpoints to the test server to facilitate better integration tests.
    • Integrated an ageing cache to DNS alias driver for optimized performance with configurable TTL.

PaulJouvanceau and others added 18 commits September 3, 2026 18:16
- Cache getAliases responses with a configurable TTL (default 30s).
- Use a sentinel value ("null") to cache 404 (no aliases) without
  poisoning the cache with nil.
- Invalidate the cache after every mutation (create, update, delete).
- Explicitly clear the cache before Start, Stop, and Status to ensure
  fresh data.
- Expose CacheTTL on the mgr struct so tests can disable the cache
  (set to 0) and avoid filesystem permission issues.

Changes:
- drivers/resipsgcp_dnsalias/main.go
- drivers/resipsgcp_dnsalias/mgr.go
- drivers/resipsgcp_dnsalias/main_test.go
- Stop clearing alias cache before every read; only invalidate after mutations (create, update, delete) to allow cache reuse and reduce API traffic.
- Include endpoint and secret (SHA-256 hashed) in cache signature to isolate cache entries between different SGCP authentication contexts.
- Extend util/sgcpserverfortest/main.go with handlers for the new
  SGCP DNS CNAME records API (list, create, get, update/patch, delete)
  and a basic CG endpoint.
- Add util/sgcpserverfortest/users.yaml for test credentials.
Add a new `resfssgcp_nfs_cg` resource driver to manage Scaleway NFS
consistency groups (CG). This driver provides:

- Switchover / failover of a CG to a target availability zone (AZ).
- Sync-resume to re-establish replication after an incident.
- Detailed status reporting including geo-redundancy and replication states.

**Changes in `util/sgcp/file.go`:**

- Add `GetConsistencyGroup` and `PatchConsistencyGroup` methods to the
  `FilesAPI` struct. These allow retrieving CG details and applying
  operations (switchover, failover, resume-replication) via the Scaleway
  API.

**Implementation of `drivers/resfssgcp_nfs_cg/main.go`:**

- Define data models (`CgInfo`, `GeoRedundancyInfo`, `ReplicationInfo`, etc.)
  that mirror the API response.
- Implement `cgMgr` to abstract API calls and handle retries.
- Implement `T` resource with configuration keywords: `uuid`, `az`, `secret`,
  `endpoint`, `timeout`, `failover`.
- Add `Start()`: performs switchover (or failover when `--force` is set),
  with fallback to failover on precondition failure (412) if conditions are
  met.
- Add `SyncResume()`: checks resumability via `checkResumable()` and calls
  the resume API, waiting for the CG to become ready.
- Add `Status()`: displays CG status, geo-redundancy targets, and replication
  targets with appropriate log levels.
- Implement caching (`cgInfoCache`) to reduce API calls during status
  polling.

**Testing (`drivers/resfssgcp_nfs_cg/main_test.go`):**

- Unit tests for helpers, `checkResumable`, `localRepStatus`, and
  `waitForFn` using JSON fixtures.
- Integration tests covering `Start` (success, 412 fallback, force,
  already up, operation in progress) and `SyncResume` (success, already
  resumed, in progress) using a mocked API.
- Add `util/sgcpcgtesthelper` package: an in-memory mock implementation of
  `cgAPI` with call counters and customisable callbacks (`PatchSwitchoverFunc`,
  `PatchFailoverFunc`, `PatchResumeFunc`). This allows precise control over
  API responses and state transitions.
- Handle 412 status before transport error in Switchover to allow failover fallback
- Use env.HasDaemonOrigin() for daemon origin check, keep legacy "daemon" compatibility
- Remove unbounded consistency group cache in Status to avoid stale state
- Add Resync method to wire sync-resume through the resource framework
- Verify expected availability zone after start operation completes
- Guard against empty georedundancy targets and evaluate all geo targets in checkResumable
- Reject failed/rollback states in mixed replication/georedundancy resume checks
The az keyword defaults to {node.labels.az}, and an unset node label
evaluates to an empty string instead of failing. The resource used to
configure fine with an empty az, and the start action then asked the
provider for a switchover to the "" availability zone.

Validate az in Configure, where the secret and the endpoint already are,
and say in the keyword doc that a node without the az label has to set
the keyword.
The driver package was never imported, so its init() never ran and
driver.Register was never called: fs#1.type=sgcp_nfs_cg was reported as
an unknown driver, and the capability scanner never announced
drv.fs.sgcp_nfs_cg.
The text had the two operations swapped: the start action calls failover
when switchover fails with status code 412, not the other way round. Say
which conditions actually enable that fallback, and what --force does.
The tests installed the sgcp configuration in a tempdir, but left the
agent root alone, so the ageing caches the drivers keep and the locks
they take went to the node /var/lib/opensvc: a run left 62 cache entries
behind, and needed the privileges to write them.

Make the tempdir the agent root for the duration of the test, and move
the disabled flag there too. That flag is an absolute path, stat'ed on
every action, and file.Exists() reports a permission error as an existing
file: an unprivileged run used to see the sgcp support as disabled, and
the resource start silently did nothing.
IsDisabled() went through file.Exists(), which reports every stat error
but "does not exist" as an existing file. A permission or io error on the
disabled flag path thus disabled the sgcp support: the consistency group
start returned success without switching anything over, and the instance
was declared up in the wrong availability zone.

Stat the flag here instead, and tell the three outcomes apart. The
callers now act on the undecidable one: the actions that have work to
skip report it, the status evaluations log it and stay n/a, and the
consistency group stop, which has nothing to undo, only warns.

Two more things fall out of the rewrite. The configuration is read
through GetConfig(), under the lock that guards its writes and without
dereferencing a nil config. And the dir argument, ignored so far, is now
what a relative disabled_flag path is resolved against, instead of the
current directory of whoever runs the command.
The two fs drivers gate their start, stop and status on the disabled
flag, this one never did. Creating the flag stopped the filesystem and
consistency group work on a node, and left the alias driver creating,
retargeting and deleting records through the api.

Gate the three actions the same way, and cover it: no api call at all
when the flag is set, the actions run when it is not, and an undecidable
flag is reported rather than taken for a disable.
createOrUpdate() cleared the cache after replacing m.alias with what the
api returned. The signature hashes the alias identity, so a resource
configured without the uuid keyword cached its aliases under the empty
uuid, then dropped the entry of the completed one, which nothing had ever
written. The stale aliases stayed readable for the whole ttl: a status
evaluated right after a successful start reported the target the alias
had before.

Capture the signature before the read that fills it, and drop that one.
The fallback also accepted a bare OSVC_ACTION_ORIGIN=daemon, a value no
daemon ever sets: imon, nmon, the api and the scheduler set daemon/monitor,
daemon/api and daemon/scheduler, which env.HasDaemonOrigin() already
matches. The only way to have that value was to export it by hand, which
is a way around a gate that exists to tell an orchestration apart from an
operator start, where the operator asks for a failover with --force.

The literal was there for the test. Have it set daemon/monitor instead,
and the test refusing the fallback set the user origin.
cache.enabled was read nowhere but in a test asserting it parses, and the
dns alias driver aged its entries on a 30s constant of its own while the
consistency group driver read cache.ttl_seconds. Two drivers, two
policies, and a setting that did nothing.

Drop cache.enabled, and have the alias driver take its ttl from the
configuration like the other one. A zero ttl_seconds now disables the
caching, which is what the removed flag was meant to do. A deployed
configuration still naming enabled keeps loading: the yaml decoding
ignores what the type does not declare.
syncResume() logged the reason checkResumable() gave, under a leftover
debug prefix, and returned a bare "still not resumed". The operator
running sync resync got the verdict without the explanation.

Wrap the reason in the returned error. A checkResumable() that accepts
the group again keeps the plain message: nothing went wrong there, the
resume just did not take.
GetCg() reads past the cache, and threw away what Clear() had to say. A
clear that fails leaves the read behind it serving the entry the caller
asked to go without, and the wait loops poll that same status every two
seconds until they time out. Return it instead.

A cold cache is not an error: fcache Clear() returns nil for an entry
that is not there. Also drop cacheClear(), which nothing called.
The method took an az and discarded it right away. The resume payload
carries no operationParameters: unlike the switchover and the failover,
the operation resumes the group toward the targets it is already
configured with. Say so, and stop asking the caller for a zone.
The wait loops logged their message with Warnf(msg), and the status
loggers were called with the result of an inner Sprintf. Both hand a
computed string over where a format is expected, and every one of these
messages interpolates text the provider chose: a status or an
availability zone carrying a percent sign came out mangled.

Pass the arguments instead. go vet says nothing here, its printf analysis
not knowing these loggers.
Stop moves nothing: the group stays in the availability zone the last
start switched it to, and the start of the instance elsewhere is what
moves it. Passing silently left an operator watching a stop wondering
whether the action did anything, and a reader wondering whether the empty
body was a hole.

Log the three states the action can be in, and write down why it has
nothing to undo.
TestStatus asserts six evaluations share one cache entry, and the test
driver aged its entries after a second: the assertion held only as long
as the six calls fit in that second. A 1.1s pause where a loaded runner
would stall is enough to fail it.

Give the test driver a ttl no run can outlive. The test that wants a
fresh read already drops the entry, which is what the driver does. Also
loop over the cached evaluations, asserting each, instead of assigning a
variable five times and reading none of them.
The resume branch applied its outcome before asking PatchResumeFunc, so a
test injecting a failure still got a resumed group, and could not tell
what the driver does with a provider that refuses.

Ask the hook first and return on its error. The order was not arbitrary:
Update() replaces the map entry, leaving the handler pointer stale, so
mutating after the hook would silently discard what the hook wrote. The
hook now replaces the outcome instead of gating it, which is what the
three tests using it already expressed. Write that contract down, the
switchover and failover hooks being failure injectors instead.
loadUsers() logged every client secret, then printed the whole map with
%#v, which printed them again. The users.yaml committed here holds made
up credentials, but nothing stops an operator from pointing this at an
account of their own. Log the user and client ids and the scopes, which
is what tells the file was read.

Also have the consistency group stub answer "ready" instead of "online",
a state the driver knows: it used to warn about the status it got, and
poll it until the action timed out.
Callers that want to tell the scheduler apart from the rest of the daemon
had to compare Origin() themselves. Give the scheduler the predicate its
two siblings already have.

The doc comments of those siblings named values the daemon never sets,
"daemon" for HasDaemonOrigin and "daemon/imon" for the monitor one. Name
the constants instead, and cover the three predicates with a table that
pins the bare "daemon" and "scheduler" strings as origins of no one.
A status asked for by anyone else was answered from the ageing cache the
scheduler fills, so an operator could be shown a consistency group in the
availability zone it left hours ago. The caches exist to keep the
scheduler's repeated evaluations off the provider api, not to answer the
question an operator is asking.

Drop the entry at the top of the three Status(), where the drivers already
have the invalidation their actions use, under a sgcphelper.NeedsCacheClear()
the three share. That rule lives with the drivers rather than in util/sgcp,
which reads no core package.
Configure() read the endpoint from the keyword, fell back to the
configuration and refused an empty value, then never applied it. Every
request kept going to dns.base_url, and the only thing the keyword
reached was the cache signature. WithDNSBaseURL() has been there since
the driver was written, called from nowhere. The two fs drivers call
their WithFileURL() equivalent in the same place.

Covered end to end, because a mocked api provider builds no url and is
how the bug survived until now: the alias is served by a test server the
endpoint keyword names, while the configuration points at a port nothing
listens on. That needs the real api, hence the credentials seam the
resfssgcp_nfs driver already has.
The api reports every status over 400 as an error, and getFileInfo()
tested the error first, so the not-found branch below it never ran, nor
the four "fileInfo == nil" guards it fed. The branch would not have
worked either: it returned no bytes, which the cache stored and the
unmarshal then choked on.

Read the status code first, cache the absence as a null document the way
the dns alias driver does, and turn it back into no filesystem at all on
the way out. Same order in deleteNFSClient(), whose "consistency group is
not in ready" was unreachable for the same reason: a refused drop showed
up as a bare HTTP 412.

The tests drive the real api against a test server, a fake being unable
to reproduce what this is about. Drop the unreachable switches, and a
private copy of CheckStatusCode() that no one called.
t.StatusLog().Info("xaas status disabled")
return status.NotApplicable
}
if sgcphelper.NeedsCacheClear() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle the cache clear error here instead of only logging it?

If the cache cannot be cleared, GetCachedCg() may return stale CG information. GetCg() already treats a cache clear failure as an error for this reason, to avoid polling on stale data until it times out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 you are right, we should stop here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants