From 523fe7c76d374b8c610952336ce6389d6ad636d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 17:55:08 +0100 Subject: [PATCH 1/8] microcluster/types: Add OS interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows keeping the state directory tooling internally but allow mocking the it for unit tests as it's exported as part of the state available for API handlers written by downstream consumers of Microcluster. Signed-off-by: Julian Pelizäus --- microcluster/types/os.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 microcluster/types/os.go diff --git a/microcluster/types/os.go b/microcluster/types/os.go new file mode 100644 index 00000000..8cedfe80 --- /dev/null +++ b/microcluster/types/os.go @@ -0,0 +1,21 @@ +package types + +import ( + "net/url" + + "github.com/canonical/lxd/shared" +) + +// OS represents Microcluster's state on disk. +type OS interface { + StateDir() string + DatabaseDir() string + TrustDir() string + CertificatesDir() string + DatabasePath() string + ControlSocket() *url.URL + ControlSocketPath() string + IsControlSocketPresent() (bool, error) + ServerCert() (*shared.CertInfo, error) + ClusterCert() (*shared.CertInfo, error) +} From d6e1345b5612523fd8cfb0b1b23bcc95d91996c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 17:55:53 +0100 Subject: [PATCH 2/8] internal/sys: Implement the OS interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- internal/sys/os.go | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/internal/sys/os.go b/internal/sys/os.go index 934c68ff..3d5bf715 100644 --- a/internal/sys/os.go +++ b/internal/sys/os.go @@ -3,6 +3,7 @@ package sys import ( "errors" "fmt" + "net/url" "os" "path/filepath" @@ -14,15 +15,15 @@ import ( // OS contains fields and methods for interacting with the state directory. type OS struct { - StateDir string - DatabaseDir string - TrustDir string - CertificatesDir string - LogFile string + stateDir string + databaseDir string + trustDir string + certificatesDir string + logFile string } // DefaultOS returns a fresh uninitialized OS instance with default values. -func DefaultOS(stateDir string, createDir bool) (*OS, error) { +func DefaultOS(stateDir string, createDir bool) (types.OS, error) { if stateDir == "" { stateDir = os.Getenv(StateDir) } @@ -30,11 +31,11 @@ func DefaultOS(stateDir string, createDir bool) (*OS, error) { // TODO: Configurable log file path. os := &OS{ - StateDir: stateDir, - DatabaseDir: filepath.Join(stateDir, "database"), - TrustDir: filepath.Join(stateDir, "truststore"), - CertificatesDir: filepath.Join(stateDir, "certificates"), - LogFile: "", + stateDir: stateDir, + databaseDir: filepath.Join(stateDir, "database"), + trustDir: filepath.Join(stateDir, "truststore"), + certificatesDir: filepath.Join(stateDir, "certificates"), + logFile: "", } err := os.init(createDir) @@ -50,10 +51,10 @@ func (s *OS) init(createDir bool) error { path string mode os.FileMode }{ - {s.StateDir, 0711}, - {s.DatabaseDir, 0700}, - {s.TrustDir, 0700}, - {s.CertificatesDir, 0700}, + {s.stateDir, 0711}, + {s.databaseDir, 0700}, + {s.trustDir, 0700}, + {s.certificatesDir, 0700}, } for _, dir := range dirs { @@ -101,23 +102,23 @@ func (s *OS) IsControlSocketPresent() (bool, error) { } // ControlSocket returns the full path to the control.socket file that this daemon is listening on. -func (s *OS) ControlSocket() api.URL { - return *api.NewURL().Scheme("http").Host(s.ControlSocketPath()) +func (s *OS) ControlSocket() *url.URL { + return &api.NewURL().Scheme("http").Host(s.ControlSocketPath()).URL } // ControlSocketPath returns the filesystem path to the control socket. func (s *OS) ControlSocketPath() string { - return filepath.Join(s.StateDir, "control.socket") + return filepath.Join(s.stateDir, "control.socket") } // DatabasePath returns the path of the database file managed by dqlite. func (s *OS) DatabasePath() string { - return filepath.Join(s.DatabaseDir, "db.bin") + return filepath.Join(s.databaseDir, "db.bin") } // ServerCert gets the local server certificate from the state directory. func (s *OS) ServerCert() (*shared.CertInfo, error) { - cert, err := shared.KeyPairAndCA(s.StateDir, string(types.ServerCertificateName), shared.CertServer, shared.CertOptions{}) + cert, err := shared.KeyPairAndCA(s.stateDir, string(types.ServerCertificateName), shared.CertServer, shared.CertOptions{}) if err != nil { return nil, fmt.Errorf("Failed to load TLS certificate: %w", err) } @@ -127,10 +128,30 @@ func (s *OS) ServerCert() (*shared.CertInfo, error) { // ClusterCert gets the local cluster certificate from the state directory. func (s *OS) ClusterCert() (*shared.CertInfo, error) { - cert, err := shared.KeyPairAndCA(s.StateDir, string(types.ClusterCertificateName), shared.CertServer, shared.CertOptions{}) + cert, err := shared.KeyPairAndCA(s.stateDir, string(types.ClusterCertificateName), shared.CertServer, shared.CertOptions{}) if err != nil { return nil, fmt.Errorf("Failed to load TLS certificate: %w", err) } return cert, nil } + +// StateDir gets the state's base directory. +func (s *OS) StateDir() string { + return s.stateDir +} + +// DatabaseDir gets the state's database directory. +func (s *OS) DatabaseDir() string { + return s.databaseDir +} + +// TrustDir gets the state's trust directory. +func (s *OS) TrustDir() string { + return s.trustDir +} + +// CertificatesDir gets the state's certificates directory. +func (s *OS) CertificatesDir() string { + return s.certificatesDir +} From d7f5c0163349acb0c38d46604ba42da38b72fde6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 17:57:18 +0100 Subject: [PATCH 3/8] internal/rest/resources: Consume the OS interface functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- internal/rest/resources/certificates.go | 6 +++--- internal/rest/resources/cluster.go | 13 ++++++++----- internal/rest/resources/control.go | 12 ++++++------ internal/rest/resources/heartbeat.go | 4 ++-- internal/rest/resources/truststore.go | 4 ++-- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/internal/rest/resources/certificates.go b/internal/rest/resources/certificates.go index e16ff2dd..0afaf3f6 100644 --- a/internal/rest/resources/certificates.go +++ b/internal/rest/resources/certificates.go @@ -87,14 +87,14 @@ func clusterCertificatesPut(s state.State, r *http.Request) response.Response { var certificateDir string if certificateName == string(types.ClusterCertificateName) { - certificateDir = s.FileSystem().StateDir + certificateDir = s.FileSystem().StateDir() } else if certificateName == string(types.ServerCertificateName) { - certificateDir = s.FileSystem().StateDir + certificateDir = s.FileSystem().StateDir() if s.Database().Status() != types.DatabaseNotReady { return response.SmartError(fmt.Errorf("Cannot replace server certificate after initialization")) } } else { - certificateDir = s.FileSystem().CertificatesDir + certificateDir = s.FileSystem().CertificatesDir() // Check if an additional listener exists for that name. // We cannot query the daemon's config of the additional listeners as diff --git a/internal/rest/resources/cluster.go b/internal/rest/resources/cluster.go index f6c2a4c1..dce31bb3 100644 --- a/internal/rest/resources/cluster.go +++ b/internal/rest/resources/cluster.go @@ -195,7 +195,7 @@ func clusterPost(s state.State, r *http.Request) response.Response { } // Add the cluster member to our local store for authentication. - err = s.Remotes().Add(s.FileSystem().TrustDir, newRemote) + err = s.Remotes().Add(s.FileSystem().TrustDir(), newRemote) if err != nil { return response.SmartError(err) } @@ -203,7 +203,7 @@ func clusterPost(s state.State, r *http.Request) response.Response { tokenResponse.ClusterAdditionalCerts = make(map[string]types.KeyPair) // Load the list of custom certificates from its state directory. - err = filepath.WalkDir(s.FileSystem().CertificatesDir, func(path string, d fs.DirEntry, err error) error { + err = filepath.WalkDir(s.FileSystem().CertificatesDir(), func(path string, d fs.DirEntry, err error) error { // Skip directories if d.IsDir() { return nil @@ -213,7 +213,7 @@ func clusterPost(s state.State, r *http.Request) response.Response { splittedPath := strings.Split(filepath.Base(path), ".") if len(splittedPath) == 2 && splittedPath[1] == "crt" { // Load the certificate - cert, err := shared.KeyPairAndCA(s.FileSystem().CertificatesDir, splittedPath[0], shared.CertServer, shared.CertOptions{}) + cert, err := shared.KeyPairAndCA(s.FileSystem().CertificatesDir(), splittedPath[0], shared.CertServer, shared.CertOptions{}) if err != nil { return fmt.Errorf("Failed to load certificate for additional server %q: %w", splittedPath[0], err) } @@ -383,7 +383,7 @@ func resetClusterMember(ctx context.Context, s state.State, force bool) (reExec logger.Error("Failed shutting down", slog.String("error", err.Error())) } - err = os.RemoveAll(s.FileSystem().StateDir) + err = os.RemoveAll(s.FileSystem().StateDir()) if err != nil && !force { logger.Error("Failed to remove the state directory", slog.String("error", err.Error())) } @@ -643,7 +643,10 @@ func clusterMemberDelete(s state.State, r *http.Request) response.Response { } } - localClient, err := internalClient.New(s.FileSystem().ControlSocket(), nil, nil, false) + url := api.NewURL() + url.URL = *s.FileSystem().ControlSocket() + + localClient, err := internalClient.New(*url, nil, nil, false) if err != nil { return response.SmartError(err) } diff --git a/internal/rest/resources/control.go b/internal/rest/resources/control.go index 981f5eec..108b4fe3 100644 --- a/internal/rest/resources/control.go +++ b/internal/rest/resources/control.go @@ -141,18 +141,18 @@ func controlPost(state state.State, r *http.Request) response.Response { // Replace the server keypair if the cluster member name has changed upon initialization. if !certNameMatches { - err := os.Remove(filepath.Join(state.FileSystem().StateDir, "server.crt")) + err := os.Remove(filepath.Join(state.FileSystem().StateDir(), "server.crt")) if err != nil { return response.SmartError(err) } - err = os.Remove(filepath.Join(state.FileSystem().StateDir, "server.key")) + err = os.Remove(filepath.Join(state.FileSystem().StateDir(), "server.key")) if err != nil { return response.SmartError(err) } // Generate a new keypair with the new subject name. - _, err = shared.KeyPairAndCA(state.FileSystem().StateDir, string(types.ServerCertificateName), shared.CertServer, shared.CertOptions{AddHosts: true, CommonName: req.Name}) + _, err = shared.KeyPairAndCA(state.FileSystem().StateDir(), string(types.ServerCertificateName), shared.CertServer, shared.CertOptions{AddHosts: true, CommonName: req.Name}) if err != nil { return response.SmartError(err) } @@ -292,7 +292,7 @@ func writeCert(dir, prefix string, cert, key, ca []byte) error { func setupLocalMember(state state.State, localClusterMember *trust.Remote, joinInfo *types.TokenResponse) ([]string, error) { // Set up cluster certificate. - err := writeCert(state.FileSystem().StateDir, string(types.ClusterCertificateName), []byte(joinInfo.ClusterCert.String()), []byte(joinInfo.ClusterKey), nil) + err := writeCert(state.FileSystem().StateDir(), string(types.ClusterCertificateName), []byte(joinInfo.ClusterCert.String()), []byte(joinInfo.ClusterKey), nil) if err != nil { return nil, err } @@ -305,7 +305,7 @@ func setupLocalMember(state state.State, localClusterMember *trust.Remote, joinI ca = []byte(cert.CA) } - err := writeCert(state.FileSystem().CertificatesDir, name, []byte(cert.Cert), []byte(cert.Key), ca) + err := writeCert(state.FileSystem().CertificatesDir(), name, []byte(cert.Cert), []byte(cert.Key), ca) if err != nil { return nil, err } @@ -324,7 +324,7 @@ func setupLocalMember(state state.State, localClusterMember *trust.Remote, joinI } clusterMembers = append(clusterMembers, *localClusterMember) - err = state.Remotes().Add(state.FileSystem().TrustDir, clusterMembers...) + err = state.Remotes().Add(state.FileSystem().TrustDir(), clusterMembers...) if err != nil { return nil, err } diff --git a/internal/rest/resources/heartbeat.go b/internal/rest/resources/heartbeat.go index 29c14964..7686904b 100644 --- a/internal/rest/resources/heartbeat.go +++ b/internal/rest/resources/heartbeat.go @@ -50,7 +50,7 @@ func heartbeatPost(s state.State, r *http.Request) response.Response { clusterMemberList = append(clusterMemberList, clusterMember) } - err = s.Remotes().Replace(s.FileSystem().TrustDir, clusterMemberList...) + err = s.Remotes().Replace(s.FileSystem().TrustDir(), clusterMemberList...) if err != nil { return response.SmartError(err) } @@ -167,7 +167,7 @@ func beginHeartbeat(ctx context.Context, s state.State, hbReq types.HeartbeatInf logger.Debug("Beginning new heartbeat round", slog.String("address", s.Address().URL.Host)) // Update local record of cluster members from the database, including any pending nodes for authentication. - err = s.Remotes().Replace(s.FileSystem().TrustDir, clusterMembers...) + err = s.Remotes().Replace(s.FileSystem().TrustDir(), clusterMembers...) if err != nil { return response.SmartError(err) } diff --git a/internal/rest/resources/truststore.go b/internal/rest/resources/truststore.go index 9bbec4ae..bb764e4b 100644 --- a/internal/rest/resources/truststore.go +++ b/internal/rest/resources/truststore.go @@ -107,7 +107,7 @@ func trustPost(s state.State, r *http.Request) response.Response { remotes := s.Remotes() _, ok := remotes.RemotesByName()[newRemote.Name] if !ok { - err = remotes.Add(s.FileSystem().TrustDir, newRemote) + err = remotes.Add(s.FileSystem().TrustDir(), newRemote) if err != nil { return response.SmartError(fmt.Errorf("Failed adding local record of newly joined node %q: %w", req.Name, err)) } @@ -167,7 +167,7 @@ func trustDelete(s state.State, r *http.Request) response.Response { newRemotes = append(newRemotes, newRemote) } - err = remotes.Replace(s.FileSystem().TrustDir, newRemotes...) + err = remotes.Replace(s.FileSystem().TrustDir(), newRemotes...) if err != nil { return response.SmartError(fmt.Errorf("Failed to remove truststore entry for node with name %q: %w", name, err)) } From fa9f71fae3d2d22f0a77e7a69afb9952b6ec25ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 18:01:50 +0100 Subject: [PATCH 4/8] internal/daemon: Consume the OS interface functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- internal/daemon/daemon.go | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 15fa61f3..f490ef38 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -81,7 +81,7 @@ type Daemon struct { config *internalConfig.DaemonConfig // Local daemon's configuration from daemon.yaml file. - os *sys.OS + os types.OS serverCert *shared.CertInfo clusterMu sync.RWMutex @@ -186,7 +186,7 @@ func (d *Daemon) Run(ctx context.Context, stateDir string, args Args) error { d.drainConnectionsTimeout = args.DrainConnectionsTimeout // Setup the deamon's internal config. - d.config = internalConfig.NewDaemonConfig(filepath.Join(d.os.StateDir, "daemon.yaml")) + d.config = internalConfig.NewDaemonConfig(filepath.Join(d.os.StateDir(), "daemon.yaml")) // Clean up the daemon state on an error during init. reverter := revert.New() @@ -446,12 +446,12 @@ func (d *Daemon) reload() error { func (d *Daemon) initStore() error { var err error - d.fsWatcher, err = sys.NewWatcher(d.shutdownCtx, d.os.StateDir) + d.fsWatcher, err = sys.NewWatcher(d.shutdownCtx, d.os.StateDir()) if err != nil { return err } - d.trustStore, err = trust.Init(d.fsWatcher, nil, d.os.TrustDir) + d.trustStore, err = trust.Init(d.fsWatcher, nil, d.os.TrustDir()) if err != nil { return err } @@ -541,7 +541,7 @@ func (d *Daemon) StartAPI(ctx context.Context, bootstrap bool, initConfig map[st } if bootstrap { - err = d.trustStore.Remotes().Add(d.os.TrustDir, localNode) + err = d.trustStore.Remotes().Add(d.os.TrustDir(), localNode) if err != nil { return fmt.Errorf("Failed to initialize local remote entry: %w", err) } @@ -838,7 +838,11 @@ func (d *Daemon) UpdateServers() error { // startUnixServer starts up the core unix listener with the given resources. func (d *Daemon) startUnixServer(serverEndpoints []rest.Resources, socketGroup string) error { ctlServer := d.initServer(serverEndpoints...) - ctl := endpoints.NewSocket(d.shutdownCtx, ctlServer, d.os.ControlSocket(), socketGroup, d.drainConnectionsTimeout) + + url := api.NewURL() + url.URL = *d.os.ControlSocket() + + ctl := endpoints.NewSocket(d.shutdownCtx, ctlServer, *url, socketGroup, d.drainConnectionsTimeout) d.endpoints = endpoints.NewEndpoints(d.shutdownCtx, map[string]endpoints.Endpoint{ endpoints.EndpointsUnix: ctl, }) @@ -920,7 +924,7 @@ func (d *Daemon) addExtensionServers(preInit bool, fallbackCert *shared.CertInfo continue } - customCertExists := shared.PathExists(filepath.Join(d.os.CertificatesDir, fmt.Sprintf("%s.crt", serverName))) + customCertExists := shared.PathExists(filepath.Join(d.os.CertificatesDir(), fmt.Sprintf("%s.crt", serverName))) var err error var cert *shared.CertInfo @@ -930,7 +934,7 @@ func (d *Daemon) addExtensionServers(preInit bool, fallbackCert *shared.CertInfo } else { // Generate a dedicated certificate or load the custom one if it exists. // When updating the additional listeners the dedicated certificate from before will be reused. - cert, err = shared.KeyPairAndCA(d.os.CertificatesDir, serverName, shared.CertServer, shared.CertOptions{AddHosts: true, CommonName: serverName}) + cert, err = shared.KeyPairAndCA(d.os.CertificatesDir(), serverName, shared.CertServer, shared.CertOptions{AddHosts: true, CommonName: serverName}) if err != nil { return fmt.Errorf("Failed to setup dedicated certificate for additional server %q: %w", serverName, err) } @@ -1000,9 +1004,9 @@ func (d *Daemon) ReloadCert(name types.CertificateName) error { var dir string if name == types.ClusterCertificateName || name == types.ServerCertificateName { - dir = d.os.StateDir + dir = d.os.StateDir() } else { - dir = d.os.CertificatesDir + dir = d.os.CertificatesDir() } cert, err := shared.KeyPairAndCA(dir, string(name), shared.CertServer, shared.CertOptions{AddHosts: true, CommonName: d.Name()}) @@ -1035,7 +1039,7 @@ func (d *Daemon) ReloadCert(name types.CertificateName) error { // - and cannot load a custom certificate which shares their name d.extensionServersMu.RLock() for name, server := range d.extensionServers { - certExists := shared.PathExists(filepath.Join(d.os.CertificatesDir, fmt.Sprintf("%s.crt", name))) + certExists := shared.PathExists(filepath.Join(d.os.CertificatesDir(), fmt.Sprintf("%s.crt", name))) if !server.CoreAPI && !server.DedicatedCertificate && !certExists { d.endpoints.UpdateTLSByName(name, cert) } @@ -1099,8 +1103,8 @@ func (d *Daemon) ExtensionServers() []string { } // FileSystem returns the filesystem structure for the daemon. -func (d *Daemon) FileSystem() *sys.OS { - copyOS := *d.os +func (d *Daemon) FileSystem() types.OS { + copyOS := *(d.os.(*sys.OS)) return ©OS } From bdcc3972c6ee921f6da92997dc659bddd9802b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 18:02:23 +0100 Subject: [PATCH 5/8] internal/db: Consume the OS interface functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- internal/db/db.go | 2 +- internal/db/dqlite.go | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index faf3ea6e..3804bff9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -145,7 +145,7 @@ func (db *DqliteDB) waitUpgrade(bootstrap bool, ext extensions.Extensions) error otherNodesBehind := false newSchema := db.Schema() - newSchema.File(path.Join(db.os.StateDir, "patch.global.sql")) + newSchema.File(path.Join(db.os.StateDir(), "patch.global.sql")) if !bootstrap { checkVersions := func(ctx context.Context, current int, tx *sql.Tx) error { diff --git a/internal/db/dqlite.go b/internal/db/dqlite.go index 44c8c832..231030f1 100644 --- a/internal/db/dqlite.go +++ b/internal/db/dqlite.go @@ -42,7 +42,7 @@ type DqliteDB struct { listenAddr api.URL // Listen address for this dqlite node. dbName string // This is db.bin. - os *sys.OS + os types.OS db *sql.DB dqlite *dqlite.App @@ -73,7 +73,7 @@ func (db *DqliteDB) Accept(conn net.Conn) { } // NewDB creates an empty db struct with no dqlite connection. -func NewDB(ctx context.Context, serverCert func() *shared.CertInfo, clusterCert func() *shared.CertInfo, memberName func() string, os *sys.OS, heartbeatInterval time.Duration) (*DqliteDB, error) { +func NewDB(ctx context.Context, serverCert func() *shared.CertInfo, clusterCert func() *shared.CertInfo, memberName func() string, os types.OS, heartbeatInterval time.Duration) (*DqliteDB, error) { shutdownCtx, shutdownCancel := context.WithCancel(ctx) if heartbeatInterval == 0 { @@ -133,7 +133,7 @@ func (db *DqliteDB) SchemaVersion() (versionInternal uint64, versionExternal uin // isInitialized determines whether the database has been bootstrapped or joined to a cluster. // This is an internal helper function; external callers should use Status() instead. func (db *DqliteDB) isInitialized() (bool, error) { - _, err := os.Stat(filepath.Join(db.os.DatabaseDir, "info.yaml")) + _, err := os.Stat(filepath.Join(db.os.DatabaseDir(), "info.yaml")) if err != nil { if os.IsNotExist(err) { return false, nil @@ -149,7 +149,7 @@ func (db *DqliteDB) isInitialized() (bool, error) { func (db *DqliteDB) Bootstrap(extensions extensions.Extensions, addr api.URL, clusterRecord cluster.CoreClusterMember) error { var err error db.listenAddr = addr - db.dqlite, err = dqlite.New(db.os.DatabaseDir, + db.dqlite, err = dqlite.New(db.os.DatabaseDir(), dqlite.WithAddress(db.listenAddr.URL.Host), dqlite.WithRolesAdjustmentFrequency(db.heartbeatInterval), dqlite.WithRolesAdjustmentHook(db.heartbeat), @@ -183,7 +183,7 @@ func (db *DqliteDB) Bootstrap(extensions extensions.Extensions, addr api.URL, cl func (db *DqliteDB) Join(extensions extensions.Extensions, addr api.URL, joinAddresses ...string) error { var err error db.listenAddr = addr - db.dqlite, err = dqlite.New(db.os.DatabaseDir, + db.dqlite, err = dqlite.New(db.os.DatabaseDir(), dqlite.WithCluster(joinAddresses), dqlite.WithRolesAdjustmentFrequency(db.heartbeatInterval), dqlite.WithRolesAdjustmentHook(db.heartbeat), @@ -357,7 +357,10 @@ func (db *DqliteDB) heartbeat(leaderInfo dqliteClient.NodeInfo, servers []dqlite return nil } - client, err := internalClient.New(db.os.ControlSocket(), nil, nil, false) + url := api.NewURL() + url.URL = *db.os.ControlSocket() + + client, err := internalClient.New(*url, nil, nil, false) if err != nil { db.log().Error("Failed to get local client", slog.String("address", db.listenAddr.String()), slog.String("error", err.Error())) return nil From 2c4dbddea9387e59885f7b0b02983b6c3ffa7470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 18:03:14 +0100 Subject: [PATCH 6/8] internal/recover: Consume the OS interface functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- internal/recover/recover.go | 61 ++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/internal/recover/recover.go b/internal/recover/recover.go index 3e7ba937..f97d6d6a 100644 --- a/internal/recover/recover.go +++ b/internal/recover/recover.go @@ -24,15 +24,14 @@ import ( "github.com/canonical/microcluster/v3/client" "github.com/canonical/microcluster/v3/internal/config" "github.com/canonical/microcluster/v3/internal/log" - "github.com/canonical/microcluster/v3/internal/sys" "github.com/canonical/microcluster/v3/internal/trust" "github.com/canonical/microcluster/v3/microcluster/types" ) // GetDqliteClusterMembers parses the trust store and // path.Join(filesystem.DatabaseDir, "cluster.yaml"). -func GetDqliteClusterMembers(filesystem *sys.OS) ([]types.DqliteMember, error) { - storePath := path.Join(filesystem.DatabaseDir, "cluster.yaml") +func GetDqliteClusterMembers(filesystem types.OS) ([]types.DqliteMember, error) { + storePath := path.Join(filesystem.DatabaseDir(), "cluster.yaml") var nodeInfo []dqlite.NodeInfo err := readYaml(storePath, &nodeInfo) @@ -40,7 +39,7 @@ func GetDqliteClusterMembers(filesystem *sys.OS) ([]types.DqliteMember, error) { return nil, err } - remotes, err := readTrustStore(filesystem.TrustDir) + remotes, err := readTrustStore(filesystem.TrustDir()) if err != nil { return nil, err } @@ -68,7 +67,7 @@ func GetDqliteClusterMembers(filesystem *sys.OS) ([]types.DqliteMember, error) { // files, modifies the daemon and trust store, and writes a recovery tarball. // It does not check members to ensure that the new configuration is valid; use // ValidateMemberChanges to ensure that the inputs to this function are correct. -func RecoverFromQuorumLoss(ctx context.Context, filesystem *sys.OS, members []types.DqliteMember) (string, error) { +func RecoverFromQuorumLoss(ctx context.Context, filesystem types.OS, members []types.DqliteMember) (string, error) { // Set up our new cluster configuration nodeInfo := make([]dqlite.NodeInfo, 0, len(members)) for _, member := range members { @@ -93,7 +92,7 @@ func RecoverFromQuorumLoss(ctx context.Context, filesystem *sys.OS, members []ty // Check each cluster member's /1.0 to ensure that they are unreachable. // This is a sanity check to ensure that we're not reconfiguring a cluster // that's still partially up. - remotes, err := readTrustStore(filesystem.TrustDir) + remotes, err := readTrustStore(filesystem.TrustDir()) if err != nil { return "", err } @@ -138,13 +137,13 @@ func RecoverFromQuorumLoss(ctx context.Context, filesystem *sys.OS, members []ty return "", err } - err = dqlite.ReconfigureMembershipExt(filesystem.DatabaseDir, nodeInfo) + err = dqlite.ReconfigureMembershipExt(filesystem.DatabaseDir(), nodeInfo) if err != nil { return "", fmt.Errorf("Dqlite recovery: %w", err) } // Update local info.yaml with our new address - localInfoYamlPath := path.Join(filesystem.DatabaseDir, "info.yaml") + localInfoYamlPath := path.Join(filesystem.DatabaseDir(), "info.yaml") var localInfo dqlite.NodeInfo err = readYaml(localInfoYamlPath, &localInfo) @@ -164,7 +163,7 @@ func RecoverFromQuorumLoss(ctx context.Context, filesystem *sys.OS, members []ty return "", err } - err = writeDqliteClusterYaml(path.Join(filesystem.DatabaseDir, "cluster.yaml"), members) + err = writeDqliteClusterYaml(path.Join(filesystem.DatabaseDir(), "cluster.yaml"), members) if err != nil { return "", err } @@ -180,7 +179,7 @@ func RecoverFromQuorumLoss(ctx context.Context, filesystem *sys.OS, members []ty return recoveryTarballPath, err } - err = updateTrustStore(filesystem.TrustDir, members) + err = updateTrustStore(filesystem.TrustDir(), members) if err != nil { return recoveryTarballPath, fmt.Errorf("Failed to update trust store: %w", err) } @@ -235,13 +234,13 @@ func writeDqliteClusterYaml(path string, members []types.DqliteMember) error { return writeYaml(path, &nodeInfo) } -func updateDaemonAddress(filesystem *sys.OS, address string) error { +func updateDaemonAddress(filesystem types.OS, address string) error { newAddress, err := types.ParseAddrPort(address) if err != nil { return fmt.Errorf("Failed to update daemon.yaml: %w", err) } - daemonConfig := config.NewDaemonConfig(path.Join(filesystem.StateDir, "daemon.yaml")) + daemonConfig := config.NewDaemonConfig(path.Join(filesystem.StateDir(), "daemon.yaml")) err = daemonConfig.Load() if err != nil { return fmt.Errorf("Failed to load daemon.yaml: %w", err) @@ -350,14 +349,14 @@ func ValidateMemberChanges(oldMembers []types.DqliteMember, newMembers []types.D return nil } -func writeGlobalMembersPatch(filesystem *sys.OS, members []types.DqliteMember) error { +func writeGlobalMembersPatch(filesystem types.OS, members []types.DqliteMember) error { sql := "" for _, member := range members { sql += fmt.Sprintf("UPDATE core_cluster_members SET address = %q WHERE name = %q;\n", member.Address, member.Name) } if len(sql) > 0 { - patchPath := path.Join(filesystem.StateDir, "patch.global.sql") + patchPath := path.Join(filesystem.StateDir(), "patch.global.sql") patchFile, err := os.OpenFile(patchPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err @@ -384,9 +383,9 @@ func writeGlobalMembersPatch(filesystem *sys.OS, members []types.DqliteMember) e // go-dqlite's info.yaml is excluded from the tarball. // The new cluster configuration is included as `recovery.yaml`. // This function returns the path to the tarball. -func createRecoveryTarball(ctx context.Context, filesystem *sys.OS, members []types.DqliteMember) (string, error) { - tarballPath := path.Join(filesystem.StateDir, "recovery_db.tar.gz") - recoveryYamlPath := path.Join(filesystem.DatabaseDir, "recovery.yaml") +func createRecoveryTarball(ctx context.Context, filesystem types.OS, members []types.DqliteMember) (string, error) { + tarballPath := path.Join(filesystem.StateDir(), "recovery_db.tar.gz") + recoveryYamlPath := path.Join(filesystem.DatabaseDir(), "recovery.yaml") err := writeYaml(recoveryYamlPath, members) if err != nil { @@ -396,7 +395,7 @@ func createRecoveryTarball(ctx context.Context, filesystem *sys.OS, members []ty // info.yaml is used by go-dqlite to keep track of the current cluster member's // ID and address. We shouldn't replicate the recovery member's info.yaml // to all other members, so exclude it from the tarball: - err = createTarball(ctx, tarballPath, filesystem.DatabaseDir, ".", []string{"info.yaml"}) + err = createTarball(ctx, tarballPath, filesystem.DatabaseDir(), ".", []string{"info.yaml"}) return tarballPath, err } @@ -405,9 +404,9 @@ func createRecoveryTarball(ctx context.Context, filesystem *sys.OS, members []ty // fiesystem.StateDir. If it exists, unpack it into a temporary directory, // ensure that it is a valid microcluster recovery tarball, and replace the // existing filesystem.DatabaseDir. -func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem *sys.OS) error { - tarballPath := path.Join(filesystem.StateDir, "recovery_db.tar.gz") - unpackDir := path.Join(filesystem.StateDir, "recovery_db") +func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem types.OS) error { + tarballPath := path.Join(filesystem.StateDir(), "recovery_db.tar.gz") + unpackDir := path.Join(filesystem.StateDir(), "recovery_db") recoveryYamlPath := path.Join(unpackDir, "recovery.yaml") // Determine if the recovery tarball exists @@ -430,7 +429,7 @@ func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem *sys.OS) error { // We need to set the local info.yaml address with the (possibly changed) // incoming address for this member. - localInfoYamlPath := path.Join(filesystem.DatabaseDir, "info.yaml") + localInfoYamlPath := path.Join(filesystem.DatabaseDir(), "info.yaml") recoveryInfoYamlPath := path.Join(unpackDir, "info.yaml") var localInfo dqlite.NodeInfo @@ -465,7 +464,7 @@ func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem *sys.OS) error { } // Update the local trust store with the incoming cluster configuration - err = updateTrustStore(filesystem.TrustDir, incomingMembers) + err = updateTrustStore(filesystem.TrustDir(), incomingMembers) if err != nil { return err } @@ -477,7 +476,7 @@ func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem *sys.OS) error { // Now that we're as sure as we can be that the recovery DB is valid, we can // replace the existing DB - err = os.RemoveAll(filesystem.DatabaseDir) + err = os.RemoveAll(filesystem.DatabaseDir()) if err != nil { return err } @@ -487,7 +486,7 @@ func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem *sys.OS) error { return err } - err = os.Rename(unpackDir, filesystem.DatabaseDir) + err = os.Rename(unpackDir, filesystem.DatabaseDir()) if err != nil { return err } @@ -510,13 +509,13 @@ func MaybeUnpackRecoveryTarball(ctx context.Context, filesystem *sys.OS) error { // CreateDatabaseBackup writes a tarball of filesystem.DatabaseDir to // filesystem.StateDir as db_backup.TIMESTAMP.tar.gz. It does not check to // to ensure that the database is stopped. -func CreateDatabaseBackup(ctx context.Context, filesystem *sys.OS) error { +func CreateDatabaseBackup(ctx context.Context, filesystem types.OS) error { // tar interprets `:` as a remote drive; ISO8601 allows a 'basic format' // with the colons omitted (as opposed to time.RFC3339) // https://en.wikipedia.org/wiki/ISO_8601 backupFileName := fmt.Sprintf("db_backup.%s.tar.gz", time.Now().Format("2006-01-02T150405Z0700")) - backupFilePath := path.Join(filesystem.StateDir, backupFileName) + backupFilePath := path.Join(filesystem.StateDir(), backupFileName) logger, err := log.LoggerFromContext(ctx) if err != nil { @@ -527,13 +526,13 @@ func CreateDatabaseBackup(ctx context.Context, filesystem *sys.OS) error { // For DB backups the tarball should contain the subdirs (usually `database/`) // so that the user can easily untar the backup from the state dir. - rootDir := filesystem.StateDir - walkDir, err := filepath.Rel(filesystem.StateDir, filesystem.DatabaseDir) + rootDir := filesystem.StateDir() + walkDir, err := filepath.Rel(filesystem.StateDir(), filesystem.DatabaseDir()) // Don't bother if DatabaseDir is not inside StateDir if err != nil { - logger.Warn("DB backup: DatabaseDir (%q) not in StateDir (%q)", slog.String("databaseDir", filesystem.DatabaseDir), slog.String("stateDir", filesystem.StateDir)) - rootDir = filesystem.DatabaseDir + logger.Warn("DB backup: DatabaseDir (%q) not in StateDir (%q)", slog.String("databaseDir", filesystem.DatabaseDir()), slog.String("stateDir", filesystem.StateDir())) + rootDir = filesystem.DatabaseDir() walkDir = "." } From c73a9a8b5d5d758006b6c64023adca22a7b9f7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 18:03:39 +0100 Subject: [PATCH 7/8] internal/state: Consume the OS interface functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- internal/state/state.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 35ac3a3b..8326c4d4 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -14,7 +14,6 @@ import ( "github.com/canonical/microcluster/v3/internal/endpoints" "github.com/canonical/microcluster/v3/internal/extensions" internalClient "github.com/canonical/microcluster/v3/internal/rest/client" - "github.com/canonical/microcluster/v3/internal/sys" "github.com/canonical/microcluster/v3/internal/trust" "github.com/canonical/microcluster/v3/microcluster/types" ) @@ -22,7 +21,7 @@ import ( // State exposes the internal daemon state for use with extended API handlers. type State interface { // FileSystem structure. - FileSystem() *sys.OS + FileSystem() types.OS // Listen Address. Address() *api.URL @@ -99,7 +98,7 @@ type InternalState struct { // Hooks contain external implementations that are triggered by specific cluster actions. Hooks *Hooks - InternalFileSystem func() *sys.OS + InternalFileSystem func() types.OS InternalAddress func() *api.URL InternalName func() string InternalVersion func() string @@ -111,7 +110,7 @@ type InternalState struct { } // FileSystem can be used to inspect the microcluster filesystem. -func (s *InternalState) FileSystem() *sys.OS { +func (s *InternalState) FileSystem() types.OS { return s.InternalFileSystem() } From 3beb13d5a035ac6f781fc2adc1fb3c0ac5e9a072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Tue, 2 Dec 2025 18:03:55 +0100 Subject: [PATCH 8/8] microcluster: Consume the OS interface functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- microcluster/app.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/microcluster/app.go b/microcluster/app.go index 4c831052..d652ef51 100644 --- a/microcluster/app.go +++ b/microcluster/app.go @@ -30,7 +30,7 @@ type DaemonArgs = daemon.Args // MicroCluster contains some basic filesystem information for interacting with the MicroCluster daemon. type MicroCluster struct { - FileSystem *sys.OS + FileSystem types.OS args Args } @@ -93,7 +93,7 @@ func (m *MicroCluster) Start(ctx context.Context, daemonArgs DaemonArgs) error { // Attach the logger to the parent context. ctx = context.WithValue(ctx, log.CtxLogger, logger) - err := d.Run(ctx, m.FileSystem.StateDir, daemonArgs) + err := d.Run(ctx, m.FileSystem.StateDir(), daemonArgs) if err != nil { return fmt.Errorf("Daemon stopped with error: %w", err) } @@ -307,7 +307,10 @@ func (m *MicroCluster) RevokeJoinToken(ctx context.Context, name string) error { func (m *MicroCluster) LocalClient() (*client.Client, error) { c := m.args.Client if c == nil { - internalClient, err := internalClient.New(m.FileSystem.ControlSocket(), nil, nil, false) + url := api.NewURL() + url.URL = *m.FileSystem.ControlSocket() + + internalClient, err := internalClient.New(*url, nil, nil, false) if err != nil { return nil, err }