mirror of
https://github.com/lxc/incus
synced 2026-08-02 05:26:46 +00:00
3148 lines
88 KiB
Go
3148 lines
88 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
incus "github.com/lxc/incus/v7/client"
|
|
"github.com/lxc/incus/v7/internal/filter"
|
|
internalInstance "github.com/lxc/incus/v7/internal/instance"
|
|
"github.com/lxc/incus/v7/internal/server/auth"
|
|
"github.com/lxc/incus/v7/internal/server/certificate"
|
|
"github.com/lxc/incus/v7/internal/server/cluster"
|
|
clusterConfig "github.com/lxc/incus/v7/internal/server/cluster/config"
|
|
clusterRequest "github.com/lxc/incus/v7/internal/server/cluster/request"
|
|
"github.com/lxc/incus/v7/internal/server/db"
|
|
dbCluster "github.com/lxc/incus/v7/internal/server/db/cluster"
|
|
"github.com/lxc/incus/v7/internal/server/db/operationtype"
|
|
"github.com/lxc/incus/v7/internal/server/lifecycle"
|
|
"github.com/lxc/incus/v7/internal/server/node"
|
|
"github.com/lxc/incus/v7/internal/server/operations"
|
|
"github.com/lxc/incus/v7/internal/server/request"
|
|
"github.com/lxc/incus/v7/internal/server/response"
|
|
"github.com/lxc/incus/v7/internal/server/state"
|
|
localUtil "github.com/lxc/incus/v7/internal/server/util"
|
|
internalUtil "github.com/lxc/incus/v7/internal/util"
|
|
"github.com/lxc/incus/v7/internal/version"
|
|
"github.com/lxc/incus/v7/shared/api"
|
|
"github.com/lxc/incus/v7/shared/logger"
|
|
"github.com/lxc/incus/v7/shared/osarch"
|
|
"github.com/lxc/incus/v7/shared/revert"
|
|
localtls "github.com/lxc/incus/v7/shared/tls"
|
|
"github.com/lxc/incus/v7/shared/util"
|
|
"github.com/lxc/incus/v7/shared/validate"
|
|
)
|
|
|
|
var clusterCmd = APIEndpoint{
|
|
Path: "cluster",
|
|
|
|
Get: APIEndpointAction{Handler: clusterGet, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanView)},
|
|
Put: APIEndpointAction{Handler: clusterPut, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
}
|
|
|
|
var clusterNodesCmd = APIEndpoint{
|
|
Path: "cluster/members",
|
|
|
|
Get: APIEndpointAction{Handler: clusterNodesGet, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanView)},
|
|
Post: APIEndpointAction{Handler: clusterNodesPost, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
}
|
|
|
|
var clusterNodeCmd = APIEndpoint{
|
|
Path: "cluster/members/{name}",
|
|
|
|
Delete: APIEndpointAction{Handler: clusterNodeDelete, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
Get: APIEndpointAction{Handler: clusterNodeGet, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanView)},
|
|
Patch: APIEndpointAction{Handler: clusterNodePatch, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
Put: APIEndpointAction{Handler: clusterNodePut, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
Post: APIEndpointAction{Handler: clusterNodePost, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
}
|
|
|
|
var clusterNodeStateCmd = APIEndpoint{
|
|
Path: "cluster/members/{name}/state",
|
|
|
|
Get: APIEndpointAction{Handler: clusterNodeStateGet, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanView)},
|
|
Post: APIEndpointAction{Handler: clusterNodeStatePost, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanEdit)},
|
|
}
|
|
|
|
// swagger:operation GET /1.0/cluster cluster cluster_get
|
|
//
|
|
// Get the cluster configuration
|
|
//
|
|
// Gets the current cluster configuration.
|
|
//
|
|
// ---
|
|
// produces:
|
|
// - application/json
|
|
// responses:
|
|
// "200":
|
|
// description: Cluster configuration
|
|
// schema:
|
|
// type: object
|
|
// description: Sync response
|
|
// properties:
|
|
// type:
|
|
// type: string
|
|
// description: Response type
|
|
// example: sync
|
|
// status:
|
|
// type: string
|
|
// description: Status description
|
|
// example: Success
|
|
// status_code:
|
|
// type: integer
|
|
// description: Status code
|
|
// example: 200
|
|
// metadata:
|
|
// $ref: "#/definitions/Cluster"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterGet(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
serverName := s.ServerName
|
|
|
|
// If the name is set to the hard-coded default node name, then
|
|
// clustering is not enabled.
|
|
if serverName == "none" {
|
|
serverName = ""
|
|
}
|
|
|
|
memberConfig, err := clusterGetMemberConfig(r.Context(), s.DB.Cluster)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Sort the member config.
|
|
sort.Slice(memberConfig, func(i, j int) bool {
|
|
left := memberConfig[i]
|
|
right := memberConfig[j]
|
|
|
|
if left.Entity != right.Entity {
|
|
return left.Entity < right.Entity
|
|
}
|
|
|
|
if left.Name != right.Name {
|
|
return left.Name < right.Name
|
|
}
|
|
|
|
if left.Key != right.Key {
|
|
return left.Key < right.Key
|
|
}
|
|
|
|
return left.Description < right.Description
|
|
})
|
|
|
|
clusterInfo := api.Cluster{
|
|
ServerName: serverName,
|
|
Enabled: serverName != "",
|
|
MemberConfig: memberConfig,
|
|
}
|
|
|
|
return response.SyncResponseETag(true, clusterInfo, clusterInfo)
|
|
}
|
|
|
|
// Fetch information about all node-specific configuration keys set on the
|
|
// storage pools and networks of this cluster.
|
|
func clusterGetMemberConfig(ctx context.Context, clusterDB *db.Cluster) ([]api.ClusterMemberConfigKey, error) {
|
|
var pools map[string]map[string]string
|
|
var networks map[string]map[string]string
|
|
|
|
keys := []api.ClusterMemberConfigKey{}
|
|
|
|
err := clusterDB.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
|
|
var err error
|
|
|
|
pools, err = tx.GetStoragePoolsLocalConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to fetch storage pools configuration: %w", err)
|
|
}
|
|
|
|
networks, err = tx.GetNetworksLocalConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to fetch networks configuration: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for pool, config := range pools {
|
|
for key := range config {
|
|
if strings.HasPrefix(key, internalInstance.ConfigVolatilePrefix) {
|
|
continue
|
|
}
|
|
|
|
key := api.ClusterMemberConfigKey{
|
|
Entity: "storage-pool",
|
|
Name: pool,
|
|
Key: key,
|
|
Description: fmt.Sprintf("\"%s\" property for storage pool \"%s\"", key, pool),
|
|
}
|
|
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
|
|
for network, config := range networks {
|
|
for key := range config {
|
|
if strings.HasPrefix(key, internalInstance.ConfigVolatilePrefix) {
|
|
continue
|
|
}
|
|
|
|
key := api.ClusterMemberConfigKey{
|
|
Entity: "network",
|
|
Name: network,
|
|
Key: key,
|
|
Description: fmt.Sprintf("\"%s\" property for network \"%s\"", key, network),
|
|
}
|
|
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// Depending on the parameters passed and on local state this endpoint will
|
|
// either:
|
|
//
|
|
// - bootstrap a new cluster (if this node is not clustered yet)
|
|
// - request to join an existing cluster
|
|
// - disable clustering on a node
|
|
//
|
|
// The client is required to be trusted.
|
|
|
|
// swagger:operation PUT /1.0/cluster cluster cluster_put
|
|
//
|
|
// Update the cluster configuration
|
|
//
|
|
// Updates the entire cluster configuration.
|
|
//
|
|
// ---
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: body
|
|
// name: cluster
|
|
// description: Cluster configuration
|
|
// required: true
|
|
// schema:
|
|
// $ref: "#/definitions/ClusterPut"
|
|
// responses:
|
|
// "200":
|
|
// $ref: "#/responses/EmptySyncResponse"
|
|
// "202":
|
|
// $ref: "#/responses/Operation"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "412":
|
|
// $ref: "#/responses/PreconditionFailed"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterPut(d *Daemon, r *http.Request) response.Response {
|
|
req := api.ClusterPut{}
|
|
|
|
// Parse the request
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Quick checks.
|
|
if req.ServerName == "" && req.Enabled {
|
|
return response.BadRequest(errors.New("ServerName is required when enabling clustering"))
|
|
}
|
|
|
|
if req.ServerName != "" && !req.Enabled {
|
|
return response.BadRequest(errors.New("ServerName must be empty when disabling clustering"))
|
|
}
|
|
|
|
if req.ServerName != "" && strings.HasPrefix(req.ServerName, targetGroupPrefix) {
|
|
return response.BadRequest(fmt.Errorf("ServerName may not start with %q", targetGroupPrefix))
|
|
}
|
|
|
|
// Disable clustering.
|
|
if !req.Enabled {
|
|
return clusterPutDisable(d, r, req)
|
|
}
|
|
|
|
// Depending on the provided parameters we either bootstrap a brand new
|
|
// cluster with this node as first node, or perform a request to join a
|
|
// given cluster.
|
|
if req.ClusterAddress == "" {
|
|
return clusterPutBootstrap(d, r, req)
|
|
}
|
|
|
|
return clusterPutJoin(d, r, req)
|
|
}
|
|
|
|
func clusterPutBootstrap(d *Daemon, r *http.Request, req api.ClusterPut) response.Response {
|
|
s := d.State()
|
|
|
|
logger.Info("Bootstrapping cluster", logger.Ctx{"serverName": req.ServerName})
|
|
|
|
run := func(op *operations.Operation) error {
|
|
// Update server name.
|
|
d.globalConfigMu.Lock()
|
|
d.serverName = req.ServerName
|
|
d.serverClustered = true
|
|
d.globalConfigMu.Unlock()
|
|
|
|
d.events.SetLocalLocation(d.serverName)
|
|
|
|
// Refresh the state.
|
|
s = d.State()
|
|
|
|
// Start clustering tasks
|
|
d.startClusterTasks()
|
|
|
|
err := cluster.Bootstrap(s, d.gateway, req.ServerName)
|
|
if err != nil {
|
|
d.stopClusterTasks()
|
|
return err
|
|
}
|
|
|
|
// Restart the networks.
|
|
err = networkStartup(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Return the new server certificate to the client.
|
|
clusterCertPath := internalUtil.VarPath("cluster.crt")
|
|
if util.PathExists(clusterCertPath) {
|
|
cert, err := os.ReadFile(clusterCertPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = op.UpdateMetadata(map[string]any{"certificate": string(cert)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterEnabled.Event(req.ServerName, op.Requestor(), nil))
|
|
|
|
return nil
|
|
}
|
|
|
|
resources := map[string][]api.URL{}
|
|
resources["cluster"] = []api.URL{}
|
|
|
|
// If there's no cluster.https_address set, but core.https_address is,
|
|
// let's default to it.
|
|
var err error
|
|
var config *node.Config
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
config, err = node.ConfigLoad(ctx, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to fetch member configuration: %w", err)
|
|
}
|
|
|
|
localClusterAddress := config.ClusterAddress()
|
|
if localClusterAddress != "" {
|
|
return nil
|
|
}
|
|
|
|
localHTTPSAddress := config.HTTPSAddress()
|
|
|
|
if internalUtil.IsWildCardAddress(localHTTPSAddress) {
|
|
return fmt.Errorf("Cannot use wildcard core.https_address %q for cluster.https_address. Please specify a new cluster.https_address or core.https_address", localClusterAddress)
|
|
}
|
|
|
|
_, err = config.Patch(map[string]string{
|
|
"cluster.https_address": localHTTPSAddress,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("Copy core.https_address to cluster.https_address: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Update local config cache.
|
|
d.globalConfigMu.Lock()
|
|
d.localConfig = config
|
|
d.globalConfigMu.Unlock()
|
|
|
|
op, err := operations.OperationCreate(s, "", operations.OperationClassTask, operationtype.ClusterBootstrap, resources, nil, run, nil, nil, r)
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
// Add the cluster flag from the agent
|
|
version.UserAgentFeatures([]string{"cluster"})
|
|
|
|
return operations.OperationResponse(op)
|
|
}
|
|
|
|
func clusterPutJoin(d *Daemon, r *http.Request, req api.ClusterPut) response.Response {
|
|
s := d.State()
|
|
|
|
logger.Info("Joining cluster", logger.Ctx{"serverName": req.ServerName})
|
|
|
|
// Make sure basic pre-conditions are met.
|
|
if len(req.ClusterCertificate) == 0 {
|
|
return response.BadRequest(errors.New("No target cluster member certificate provided"))
|
|
}
|
|
|
|
if s.ServerClustered {
|
|
return response.BadRequest(errors.New("This server is already clustered"))
|
|
}
|
|
|
|
// Validate server address.
|
|
if req.ServerAddress == "" {
|
|
return response.BadRequest(errors.New("No server address provided for this member"))
|
|
}
|
|
|
|
// Check that the provided address is an IP address or DNS, not wildcard and isn't required to specify a port.
|
|
err := validate.IsListenAddress(true, false, false)(req.ServerAddress)
|
|
if err != nil {
|
|
return response.BadRequest(fmt.Errorf("Invalid server address %q: %w", req.ServerAddress, err))
|
|
}
|
|
|
|
// Verify provided address against cluster.https_address if set.
|
|
localHTTPSAddress := s.LocalConfig.ClusterAddress()
|
|
if localHTTPSAddress != "" {
|
|
if !internalUtil.IsAddressCovered(req.ServerAddress, localHTTPSAddress) {
|
|
return response.BadRequest(fmt.Errorf(`Server address %q is not covered by %q from "cluster.https_address"`, req.ServerAddress, localHTTPSAddress))
|
|
}
|
|
} else {
|
|
// If cluster.https_address is not set, check against core.https_address
|
|
localHTTPSAddress = s.LocalConfig.HTTPSAddress()
|
|
|
|
var config *node.Config
|
|
|
|
if localHTTPSAddress == "" {
|
|
// As the user always provides a server address, but no networking
|
|
// was setup on this node, let's do the job and open the
|
|
// port. We'll use the same address both for the REST API and
|
|
// for clustering.
|
|
|
|
// First try to listen to the provided address. If we fail, we
|
|
// won't actually update the database config.
|
|
err := s.Endpoints.NetworkUpdateAddress(req.ServerAddress)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
config, err = node.ConfigLoad(ctx, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to load cluster config: %w", err)
|
|
}
|
|
|
|
_, err = config.Patch(map[string]string{
|
|
"core.https_address": req.ServerAddress,
|
|
"cluster.https_address": req.ServerAddress,
|
|
})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
} else {
|
|
// The user has previously set core.https_address and
|
|
// is now providing a cluster address as well. If they
|
|
// differ we need to listen to it.
|
|
if !internalUtil.IsAddressCovered(req.ServerAddress, localHTTPSAddress) {
|
|
err := s.Endpoints.ClusterUpdateAddress(req.ServerAddress)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
}
|
|
|
|
// Update the cluster.https_address config key.
|
|
err := s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
var err error
|
|
|
|
config, err = node.ConfigLoad(ctx, tx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to load cluster config: %w", err)
|
|
}
|
|
|
|
_, err = config.Patch(map[string]string{
|
|
"cluster.https_address": req.ServerAddress,
|
|
})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
}
|
|
|
|
// Update local config cache.
|
|
d.globalConfigMu.Lock()
|
|
d.localConfig = config
|
|
d.globalConfigMu.Unlock()
|
|
}
|
|
|
|
// Client parameters to connect to the target cluster node.
|
|
serverCert := s.ServerCert()
|
|
args := &incus.ConnectionArgs{
|
|
TLSClientCert: string(serverCert.PublicKey()),
|
|
TLSClientKey: string(serverCert.PrivateKey()),
|
|
TLSServerCert: string(req.ClusterCertificate),
|
|
UserAgent: version.UserAgent,
|
|
}
|
|
|
|
// Always set a proxy function to have cluster traffic bypass any configured HTTP proxy.
|
|
proxy := func(req *http.Request) (*url.URL, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
args.Proxy = proxy
|
|
|
|
// Asynchronously join the cluster.
|
|
run := func(op *operations.Operation) error {
|
|
logger.Debug("Running cluster join operation")
|
|
|
|
// If the user has provided a join token, setup the trust
|
|
// relationship by adding our own certificate to the cluster.
|
|
if req.ClusterToken != "" {
|
|
err := cluster.SetupTrust(serverCert, req.ServerName, req.ClusterAddress, req.ClusterCertificate, req.ClusterToken)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to setup cluster trust: %w", err)
|
|
}
|
|
}
|
|
|
|
// Now we are in the remote trust store, ensure our name and type are correct to allow the cluster
|
|
// to associate our member name to the server certificate.
|
|
err := cluster.UpdateTrust(serverCert, req.ServerName, req.ClusterAddress, req.ClusterCertificate)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to update cluster trust: %w", err)
|
|
}
|
|
|
|
// Connect to the target cluster node.
|
|
client, err := incus.ConnectIncus(fmt.Sprintf("https://%s", req.ClusterAddress), args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the cluster members
|
|
members, err := client.GetClusterMembers()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify if a node with the same name already exists in the cluster.
|
|
for _, member := range members {
|
|
if member.ServerName == req.ServerName {
|
|
return fmt.Errorf("The cluster already has a member with name: %s", req.ServerName)
|
|
}
|
|
}
|
|
|
|
// As ServerAddress field is required to be set it means that we're using the new join API
|
|
// introduced with the 'clustering_join' extension.
|
|
// Connect to ourselves to initialize storage pools and networks using the API.
|
|
localClient, err := incus.ConnectIncusUnix(d.os.GetUnixSocket(), &incus.ConnectionArgs{UserAgent: clusterRequest.UserAgentJoiner})
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to connect to local server: %w", err)
|
|
}
|
|
|
|
reverter := revert.New()
|
|
defer reverter.Fail()
|
|
|
|
// Update server name.
|
|
oldServerName := d.serverName
|
|
d.globalConfigMu.Lock()
|
|
d.serverName = req.ServerName
|
|
d.serverClustered = true
|
|
d.globalConfigMu.Unlock()
|
|
reverter.Add(func() {
|
|
d.globalConfigMu.Lock()
|
|
d.serverName = oldServerName
|
|
d.serverClustered = false
|
|
d.globalConfigMu.Unlock()
|
|
|
|
d.events.SetLocalLocation(d.serverName)
|
|
})
|
|
|
|
d.events.SetLocalLocation(d.serverName)
|
|
|
|
// Create all storage pools and networks.
|
|
err = clusterInitMember(localClient, client, req.MemberConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to initialize member: %w", err)
|
|
}
|
|
|
|
// Get all defined storage pools and networks, so they can be compared to the ones in the cluster.
|
|
pools := []api.StoragePool{}
|
|
networks := []api.InitNetworksProjectPost{}
|
|
|
|
err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
poolNames, err := tx.GetStoragePoolNames(ctx)
|
|
if err != nil && !response.IsNotFoundError(err) {
|
|
return err
|
|
}
|
|
|
|
for _, name := range poolNames {
|
|
_, pool, _, err := tx.GetStoragePoolInAnyState(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pools = append(pools, *pool)
|
|
}
|
|
|
|
// Get a list of projects for networks.
|
|
var projects []dbCluster.Project
|
|
|
|
projects, err = dbCluster.GetProjects(ctx, tx.Tx())
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to load projects for networks: %w", err)
|
|
}
|
|
|
|
for _, p := range projects {
|
|
networkNames, err := tx.GetNetworks(ctx, p.Name)
|
|
if err != nil && !response.IsNotFoundError(err) {
|
|
return err
|
|
}
|
|
|
|
for _, name := range networkNames {
|
|
_, network, _, err := tx.GetNetworkInAnyState(ctx, p.Name, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
internalNetwork := api.InitNetworksProjectPost{
|
|
NetworksPost: api.NetworksPost{
|
|
NetworkPut: network.NetworkPut,
|
|
Name: network.Name,
|
|
Type: network.Type,
|
|
},
|
|
Project: p.Name,
|
|
}
|
|
|
|
networks = append(networks, internalNetwork)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
reverter.Add(func() {
|
|
err = client.DeletePendingClusterMember(req.ServerName, true)
|
|
if err != nil {
|
|
logger.Errorf("Failed request to delete cluster member: %v", err)
|
|
}
|
|
})
|
|
|
|
// Now request for this node to be added to the list of cluster nodes.
|
|
info, err := clusterAcceptMember(client, req.ServerName, req.ServerAddress, cluster.SchemaVersion, version.APIExtensionsCount(), pools, networks)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed request to add member: %w", err)
|
|
}
|
|
|
|
// Update our TLS configuration using the returned cluster certificate.
|
|
err = internalUtil.WriteCert(s.OS.VarDir, "cluster", info.PublicKey, info.PrivateKey, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to save cluster certificate: %w", err)
|
|
}
|
|
|
|
networkCert, err := internalUtil.LoadClusterCert(s.OS.VarDir)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to parse cluster certificate: %w", err)
|
|
}
|
|
|
|
s.Endpoints.NetworkUpdateCert(networkCert)
|
|
|
|
// Add trusted certificates of other members to local trust store.
|
|
trustedCerts, err := client.GetCertificates()
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to get trusted certificates: %w", err)
|
|
}
|
|
|
|
for _, trustedCert := range trustedCerts {
|
|
if trustedCert.Type == api.CertificateTypeServer {
|
|
dbType, err := certificate.FromAPIType(trustedCert.Type)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Store the certificate in the local database.
|
|
dbCert := dbCluster.Certificate{
|
|
Fingerprint: trustedCert.Fingerprint,
|
|
Type: dbType,
|
|
Name: trustedCert.Name,
|
|
Certificate: trustedCert.Certificate,
|
|
Restricted: trustedCert.Restricted,
|
|
}
|
|
|
|
logger.Debugf("Adding certificate %q (%s) to local trust store", trustedCert.Name, trustedCert.Fingerprint)
|
|
|
|
err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
id, err := dbCluster.CreateCertificate(ctx, tx.Tx(), dbCert)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = dbCluster.UpdateCertificateProjects(ctx, tx.Tx(), int(id), trustedCert.Projects)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil && !api.StatusErrorCheck(err, http.StatusConflict) {
|
|
return fmt.Errorf("Failed adding local trusted certificate %q (%s): %w", trustedCert.Name, trustedCert.Fingerprint, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update cached trusted certificates (this adds the server certificates we collected above) so that we are able to join.
|
|
// Client and metric type certificates from the cluster we are joining will not be added until later.
|
|
s.UpdateCertificateCache()
|
|
|
|
// Update local setup and possibly join the raft cowsql cluster.
|
|
nodes := make([]db.RaftNode, len(info.RaftNodes))
|
|
for i, raftNode := range info.RaftNodes {
|
|
nodes[i].ID = raftNode.ID
|
|
nodes[i].Address = raftNode.Address
|
|
nodes[i].Role = db.RaftRole(raftNode.Role)
|
|
}
|
|
|
|
err = cluster.Join(s, d.gateway, networkCert, serverCert, req.ServerName, nodes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add the new node to the default cluster group.
|
|
err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
err := tx.AddNodeToClusterGroup(ctx, "default", req.ServerName)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to add new member to the default cluster group: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start clustering tasks.
|
|
d.startClusterTasks()
|
|
reverter.Add(func() { d.stopClusterTasks() })
|
|
|
|
// Load the configuration.
|
|
var nodeConfig *node.Config
|
|
err = s.DB.Node.Transaction(context.TODO(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
var err error
|
|
nodeConfig, err = node.ConfigLoad(ctx, tx)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the current (updated) config.
|
|
var currentClusterConfig *clusterConfig.Config
|
|
err = d.db.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
currentClusterConfig, err = clusterConfig.Load(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.globalConfigMu.Lock()
|
|
d.localConfig = nodeConfig
|
|
d.globalConfig = currentClusterConfig
|
|
d.globalConfigMu.Unlock()
|
|
|
|
changes := util.CloneMap(currentClusterConfig.Dump())
|
|
|
|
// OVN/OVS setup is handled explicitly below with error tolerance, since
|
|
// the joining node may not have OVS/OVN available locally.
|
|
delete(changes, "network.ovn.northbound_connection")
|
|
delete(changes, "network.ovn.ca_cert")
|
|
delete(changes, "network.ovn.client_cert")
|
|
delete(changes, "network.ovn.client_key")
|
|
|
|
// Apply remaining configuration changes.
|
|
err = doAPI10UpdateTriggers(d, nil, changes, nodeConfig, currentClusterConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Refresh the state.
|
|
s = d.State()
|
|
|
|
// Re-connect OVS if needed.
|
|
_ = d.setupOVS()
|
|
|
|
// Re-connect OVN if needed.
|
|
_ = d.setupOVN()
|
|
|
|
// Start up networks so any post-join changes can be applied now that we have a Node ID.
|
|
logger.Debug("Starting networks after cluster join")
|
|
err = networkStartup(s)
|
|
if err != nil {
|
|
logger.Errorf("Failed starting networks: %v", err)
|
|
}
|
|
|
|
client, err = cluster.Connect(req.ClusterAddress, s.Endpoints.NetworkCert(), serverCert, r, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add the cluster flag from the agent
|
|
version.UserAgentFeatures([]string{"cluster"})
|
|
|
|
// Notify the leader of successful join, possibly triggering
|
|
// role changes.
|
|
_, _, err = client.RawQuery("POST", "/internal/cluster/rebalance", nil, "")
|
|
if err != nil {
|
|
logger.Warnf("Failed to trigger cluster rebalance: %v", err)
|
|
}
|
|
|
|
// Ensure all images are available after this node has joined.
|
|
err = autoSyncImages(s.ShutdownCtx, s)
|
|
if err != nil {
|
|
logger.Warn("Failed to sync images")
|
|
}
|
|
|
|
// Update the cert cache again to add client and metric certs to the cache.
|
|
s.UpdateCertificateCache()
|
|
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterMemberAdded.Event(req.ServerName, op.Requestor(), nil))
|
|
|
|
reverter.Success()
|
|
return nil
|
|
}
|
|
|
|
resources := map[string][]api.URL{}
|
|
resources["cluster"] = []api.URL{}
|
|
|
|
op, err := operations.OperationCreate(s, "", operations.OperationClassTask, operationtype.ClusterJoin, resources, nil, run, nil, nil, r)
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
return operations.OperationResponse(op)
|
|
}
|
|
|
|
// clusterPutDisableMu is used to prevent the daemon from being replaced/stopped during removal from the
|
|
// cluster until such time as the request that initiated the removal has finished. This allows for self removal
|
|
// from the cluster when not the leader.
|
|
var clusterPutDisableMu sync.Mutex
|
|
|
|
// Disable clustering on a node.
|
|
func clusterPutDisable(d *Daemon, r *http.Request, req api.ClusterPut) response.Response {
|
|
s := d.State()
|
|
|
|
logger.Info("Disabling clustering", logger.Ctx{"serverName": req.ServerName})
|
|
|
|
// Close the cluster database
|
|
err := s.DB.Cluster.Close()
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Update our TLS configuration using our original certificate.
|
|
for _, suffix := range []string{"crt", "key", "ca"} {
|
|
path := filepath.Join(s.OS.VarDir, "cluster."+suffix)
|
|
if !util.PathExists(path) {
|
|
continue
|
|
}
|
|
|
|
err := os.Remove(path)
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
}
|
|
|
|
networkCert, err := internalUtil.LoadCert(s.OS.VarDir)
|
|
if err != nil {
|
|
return response.InternalError(fmt.Errorf("Failed to parse member certificate: %w", err))
|
|
}
|
|
|
|
// Reset the cluster database and make it local to this node.
|
|
err = d.gateway.Reset(networkCert)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
requestor := request.CreateRequestor(r)
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterDisabled.Event(req.ServerName, requestor, nil))
|
|
|
|
// Stop database cluster connection.
|
|
d.gateway.Kill()
|
|
|
|
go func() {
|
|
<-r.Context().Done() // Wait until request has finished.
|
|
|
|
// Wait until we can acquire the lock. This way if another request is holding the lock we won't
|
|
// replace/stop the daemon until that request has finished.
|
|
clusterPutDisableMu.Lock()
|
|
defer clusterPutDisableMu.Unlock()
|
|
|
|
if d.systemdSocketActivated {
|
|
logger.Info("Exiting daemon following removal from cluster")
|
|
os.Exit(0) // nolint:revive
|
|
} else {
|
|
logger.Info("Restarting daemon following removal from cluster")
|
|
err = localUtil.ReplaceDaemon()
|
|
if err != nil {
|
|
logger.Error("Failed restarting daemon", logger.Ctx{"err": err})
|
|
}
|
|
}
|
|
}()
|
|
|
|
return response.ManualResponse(func(w http.ResponseWriter) error {
|
|
err := response.EmptySyncResponse.Render(w)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send the response before replacing the daemon process.
|
|
f, ok := w.(http.Flusher)
|
|
if !ok {
|
|
return errors.New("http.ResponseWriter is not type http.Flusher")
|
|
}
|
|
|
|
f.Flush()
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// clusterInitMember initializes storage pools and networks on this member. We pass two client instances, one
|
|
// connected to ourselves (the joining member) and one connected to the target cluster member to join.
|
|
func clusterInitMember(d incus.InstanceServer, client incus.InstanceServer, memberConfig []api.ClusterMemberConfigKey) error {
|
|
data := api.InitLocalPreseed{}
|
|
|
|
// Fetch all pools currently defined in the cluster.
|
|
pools, err := client.GetStoragePools()
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to fetch information about cluster storage pools: %w", err)
|
|
}
|
|
|
|
// Merge the returned storage pools configs with the node-specific
|
|
// configs provided by the user.
|
|
for _, pool := range pools {
|
|
// Skip pending pools.
|
|
if pool.Status == "Pending" {
|
|
continue
|
|
}
|
|
|
|
logger.Debugf("Populating init data for storage pool %q", pool.Name)
|
|
|
|
post := api.StoragePoolsPost{
|
|
StoragePoolPut: pool.StoragePoolPut,
|
|
Driver: pool.Driver,
|
|
Name: pool.Name,
|
|
}
|
|
|
|
// Delete config keys that are automatically populated by the daemon.
|
|
delete(post.Config, "volatile.initial_source")
|
|
delete(post.Config, "zfs.pool_name")
|
|
|
|
// Apply the node-specific config supplied by the user.
|
|
nodeSpecificConfig := db.NodeSpecificStorageConfig(pool.Driver)
|
|
for _, config := range memberConfig {
|
|
if config.Entity != "storage-pool" {
|
|
continue
|
|
}
|
|
|
|
if config.Name != pool.Name {
|
|
continue
|
|
}
|
|
|
|
if !slices.Contains(nodeSpecificConfig, config.Key) {
|
|
logger.Warnf("Ignoring config key %q for storage pool %q", config.Key, config.Name)
|
|
continue
|
|
}
|
|
|
|
post.Config[config.Key] = config.Value
|
|
}
|
|
|
|
data.StoragePools = append(data.StoragePools, post)
|
|
}
|
|
|
|
projects, err := client.GetProjects()
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to fetch project information about cluster networks: %w", err)
|
|
}
|
|
|
|
for _, p := range projects {
|
|
if util.IsFalseOrEmpty(p.Config["features.networks"]) && p.Name != api.ProjectDefaultName {
|
|
// Skip non-default projects that can't have their own networks so we don't try
|
|
// and add the same default project networks twice.
|
|
continue
|
|
}
|
|
|
|
// We only care about project features at this stage, leave the restrictions and limits for later.
|
|
features := map[string]string{}
|
|
for k, v := range p.Config {
|
|
if strings.HasPrefix(k, "features.") {
|
|
features[k] = v
|
|
}
|
|
}
|
|
|
|
// Request that the project be created first before the project specific networks.
|
|
data.Projects = append(data.Projects, api.ProjectsPost{
|
|
Name: p.Name,
|
|
ProjectPut: api.ProjectPut{
|
|
Description: p.Description,
|
|
Config: features,
|
|
},
|
|
})
|
|
|
|
// Fetch all project specific networks currently defined in the cluster for the project.
|
|
networks, err := client.UseProject(p.Name).GetNetworks()
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to fetch network information about cluster networks in project %q: %w", p.Name, err)
|
|
}
|
|
|
|
// Merge the returned networks configs with the node-specific configs provided by the user.
|
|
for _, network := range networks {
|
|
// Skip unmanaged or pending networks.
|
|
if !network.Managed || network.Status != api.NetworkStatusCreated {
|
|
continue
|
|
}
|
|
|
|
// OVN networks don't need local creation.
|
|
if network.Type == "ovn" {
|
|
continue
|
|
}
|
|
|
|
post := api.InitNetworksProjectPost{
|
|
NetworksPost: api.NetworksPost{
|
|
NetworkPut: network.NetworkPut,
|
|
Name: network.Name,
|
|
Type: network.Type,
|
|
},
|
|
Project: p.Name,
|
|
}
|
|
|
|
// Apply the node-specific config supplied by the user for networks in the default project.
|
|
// At this time project specific networks don't have node specific config options.
|
|
if p.Name == api.ProjectDefaultName {
|
|
for _, config := range memberConfig {
|
|
if config.Entity != "network" {
|
|
continue
|
|
}
|
|
|
|
if config.Name != network.Name {
|
|
continue
|
|
}
|
|
|
|
if !db.IsNodeSpecificNetworkConfig(config.Key) {
|
|
logger.Warnf("Ignoring config key %q for network %q in project %q", config.Key, config.Name, p.Name)
|
|
continue
|
|
}
|
|
|
|
post.Config[config.Key] = config.Value
|
|
}
|
|
}
|
|
|
|
data.Networks = append(data.Networks, post)
|
|
}
|
|
}
|
|
|
|
err = d.ApplyServerPreseed(api.InitPreseed{InitLocalPreseed: data})
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to initialize storage pools and networks: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Perform a request to the /internal/cluster/accept endpoint to check if a new
|
|
// node can be accepted into the cluster and obtain joining information such as
|
|
// the cluster private certificate.
|
|
func clusterAcceptMember(client incus.InstanceServer, name string, address string, schema int, apiExt int, pools []api.StoragePool, networks []api.InitNetworksProjectPost) (*internalClusterPostAcceptResponse, error) {
|
|
architecture, err := osarch.ArchitectureGetLocalID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req := internalClusterPostAcceptRequest{
|
|
Name: name,
|
|
Address: address,
|
|
Schema: schema,
|
|
API: apiExt,
|
|
StoragePools: pools,
|
|
Networks: networks,
|
|
Architecture: architecture,
|
|
}
|
|
|
|
info := &internalClusterPostAcceptResponse{}
|
|
resp, _, err := client.RawQuery("POST", "/internal/cluster/accept", req, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = resp.MetadataAsStruct(&info)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// swagger:operation GET /1.0/cluster/members cluster cluster_members_get
|
|
//
|
|
// Get the cluster members
|
|
//
|
|
// Returns a list of cluster members (URLs).
|
|
//
|
|
// ---
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: query
|
|
// name: filter
|
|
// description: Collection filter
|
|
// type: string
|
|
// example: default
|
|
// responses:
|
|
// "200":
|
|
// description: API endpoints
|
|
// schema:
|
|
// type: object
|
|
// description: Sync response
|
|
// properties:
|
|
// type:
|
|
// type: string
|
|
// description: Response type
|
|
// example: sync
|
|
// status:
|
|
// type: string
|
|
// description: Status description
|
|
// example: Success
|
|
// status_code:
|
|
// type: integer
|
|
// description: Status code
|
|
// example: 200
|
|
// metadata:
|
|
// type: array
|
|
// description: List of endpoints
|
|
// items:
|
|
// type: string
|
|
// example: |-
|
|
// [
|
|
// "/1.0/cluster/members/server01",
|
|
// "/1.0/cluster/members/server02"
|
|
// ]
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
|
|
// swagger:operation GET /1.0/cluster/members?recursion=1 cluster cluster_members_get_recursion1
|
|
//
|
|
// Get the cluster members
|
|
//
|
|
// Returns a list of cluster members (structs).
|
|
//
|
|
// ---
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: query
|
|
// name: filter
|
|
// description: Collection filter
|
|
// type: string
|
|
// example: default
|
|
// responses:
|
|
// "200":
|
|
// description: API endpoints
|
|
// schema:
|
|
// type: object
|
|
// description: Sync response
|
|
// properties:
|
|
// type:
|
|
// type: string
|
|
// description: Response type
|
|
// example: sync
|
|
// status:
|
|
// type: string
|
|
// description: Status description
|
|
// example: Success
|
|
// status_code:
|
|
// type: integer
|
|
// description: Status code
|
|
// example: 200
|
|
// metadata:
|
|
// type: array
|
|
// description: List of cluster members
|
|
// items:
|
|
// $ref: "#/definitions/ClusterMember"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodesGet(d *Daemon, r *http.Request) response.Response {
|
|
recursion := localUtil.IsRecursionRequest(r)
|
|
s := d.State()
|
|
|
|
// Parse filter value.
|
|
filterStr := r.FormValue("filter")
|
|
clauses, err := filter.Parse(filterStr, filter.QueryOperatorSet())
|
|
if err != nil {
|
|
return response.BadRequest(fmt.Errorf("Invalid filter: %w", err))
|
|
}
|
|
|
|
leaderAddress, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
var raftNodes []db.RaftNode
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
raftNodes, err = tx.GetRaftNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading RAFT nodes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
var members []api.ClusterMember
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
failureDomains, err := tx.GetFailureDomainsNames(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading failure domains names: %w", err)
|
|
}
|
|
|
|
memberFailureDomains, err := tx.GetNodesFailureDomains(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading member failure domains: %w", err)
|
|
}
|
|
|
|
nodes, err := tx.GetNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting cluster members: %w", err)
|
|
}
|
|
|
|
maxVersion, err := tx.GetNodeMaxVersion(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting max member version: %w", err)
|
|
}
|
|
|
|
args := db.NodeInfoArgs{
|
|
LeaderAddress: leaderAddress,
|
|
FailureDomains: failureDomains,
|
|
MemberFailureDomains: memberFailureDomains,
|
|
OfflineThreshold: s.GlobalConfig.OfflineThreshold(),
|
|
MaxMemberVersion: maxVersion,
|
|
RaftNodes: raftNodes,
|
|
}
|
|
|
|
members = make([]api.ClusterMember, 0, len(nodes))
|
|
for i := range nodes {
|
|
member, err := nodes[i].ToAPI(ctx, tx, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
members = append(members, *member)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Apply filters.
|
|
filtered := make([]api.ClusterMember, 0)
|
|
for _, member := range members {
|
|
if clauses != nil && len(clauses.Clauses) > 0 {
|
|
match, err := filter.Match(member, *clauses)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
if !match {
|
|
continue
|
|
}
|
|
}
|
|
|
|
filtered = append(filtered, member)
|
|
}
|
|
|
|
// Return full responses.
|
|
if recursion {
|
|
return response.SyncResponse(true, filtered)
|
|
}
|
|
|
|
// Return URLs only.
|
|
urls := make([]string, 0, len(members))
|
|
for _, member := range members {
|
|
u := api.NewURL().Path(version.APIVersion, "cluster", "members", member.ServerName)
|
|
urls = append(urls, u.String())
|
|
}
|
|
|
|
return response.SyncResponse(true, urls)
|
|
}
|
|
|
|
var clusterNodesPostMu sync.Mutex // Used to prevent races when creating cluster join tokens.
|
|
|
|
// swagger:operation POST /1.0/cluster/members cluster cluster_members_post
|
|
//
|
|
// Request a join token
|
|
//
|
|
// Requests a join token to add a cluster member.
|
|
//
|
|
// ---
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: body
|
|
// name: cluster
|
|
// description: Cluster member add request
|
|
// required: true
|
|
// schema:
|
|
// $ref: "#/definitions/ClusterMembersPost"
|
|
// responses:
|
|
// "202":
|
|
// $ref: "#/responses/Operation"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodesPost(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
req := api.ClusterMembersPost{}
|
|
|
|
// Parse the request.
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Quick checks.
|
|
err = validate.IsAPIName(req.ServerName, false)
|
|
if err != nil {
|
|
return response.BadRequest(fmt.Errorf("Invalid cluster member name: %w", err))
|
|
}
|
|
|
|
if !s.ServerClustered {
|
|
return response.BadRequest(errors.New("This server is not clustered"))
|
|
}
|
|
|
|
expiry, err := internalInstance.GetExpiry(time.Now(), s.GlobalConfig.ClusterJoinTokenExpiry())
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Get target addresses for existing online members, so that it can be encoded into the join token so that
|
|
// the joining member will not have to specify a joining address during the join process.
|
|
// Use anonymous interface type to align with how the API response will be returned for consistency when
|
|
// retrieving remote operations.
|
|
onlineNodeAddresses := make([]any, 0)
|
|
|
|
// Get cluster database state.
|
|
leaderAddress, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
var raftNodes []db.RaftNode
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
raftNodes, err = tx.GetRaftNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading RAFT nodes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
// Get global cluster state.
|
|
failureDomains, err := tx.GetFailureDomainsNames(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading failure domains names: %w", err)
|
|
}
|
|
|
|
memberFailureDomains, err := tx.GetNodesFailureDomains(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading member failure domains: %w", err)
|
|
}
|
|
|
|
maxVersion, err := tx.GetNodeMaxVersion(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting max member version: %w", err)
|
|
}
|
|
|
|
// Get the nodes.
|
|
members, err := tx.GetNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting cluster members: %w", err)
|
|
}
|
|
|
|
args := db.NodeInfoArgs{
|
|
LeaderAddress: leaderAddress,
|
|
FailureDomains: failureDomains,
|
|
MemberFailureDomains: memberFailureDomains,
|
|
OfflineThreshold: s.GlobalConfig.OfflineThreshold(),
|
|
MaxMemberVersion: maxVersion,
|
|
RaftNodes: raftNodes,
|
|
}
|
|
|
|
// Filter to online members.
|
|
for _, member := range members {
|
|
memberInfo, err := member.ToAPI(ctx, tx, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify if a node with the same name already exists in the cluster.
|
|
if member.Name == req.ServerName {
|
|
return fmt.Errorf("The cluster already has a member with name: %s", req.ServerName)
|
|
}
|
|
|
|
// Skip servers that are offline.
|
|
if slices.Contains([]int{db.ClusterMemberStateEvacuated, db.ClusterMemberStateEvacuating, db.ClusterMemberStateRestoring}, member.State) || member.IsOffline(s.GlobalConfig.OfflineThreshold()) {
|
|
continue
|
|
}
|
|
|
|
// Only include servers that have a one of the database roles.
|
|
if !slices.Contains(memberInfo.Roles, "database") && !slices.Contains(memberInfo.Roles, "database-standby") {
|
|
continue
|
|
}
|
|
|
|
onlineNodeAddresses = append(onlineNodeAddresses, member.Address)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
if len(onlineNodeAddresses) < 1 {
|
|
return response.InternalError(errors.New("There are no online cluster members"))
|
|
}
|
|
|
|
// Lock to prevent concurrent requests racing the operationsGetByType function and creating duplicates.
|
|
// We have to do this because collecting all of the operations from existing cluster members can take time.
|
|
clusterNodesPostMu.Lock()
|
|
defer clusterNodesPostMu.Unlock()
|
|
|
|
// Remove any existing join tokens for the requested cluster member, this way we only ever have one active
|
|
// join token for each potential new member, and it has the most recent active members list for joining.
|
|
// This also ensures any historically unused (but potentially published) join tokens are removed.
|
|
ops, err := operationsGetByType(s, r, api.ProjectDefaultName, operationtype.ClusterJoinToken)
|
|
if err != nil {
|
|
return response.InternalError(fmt.Errorf("Failed getting cluster join token operations: %w", err))
|
|
}
|
|
|
|
for _, op := range ops {
|
|
if op.StatusCode != api.Running {
|
|
continue // Tokens are single use, so if cancelled but not deleted yet its not available.
|
|
}
|
|
|
|
opServerName, ok := op.Metadata["serverName"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if opServerName == req.ServerName {
|
|
// Join token operation matches requested server name, so lets cancel it.
|
|
logger.Warn("Cancelling duplicate join token operation", logger.Ctx{"operation": op.ID, "serverName": opServerName})
|
|
err = operationCancel(s, r, api.ProjectDefaultName, op)
|
|
if err != nil {
|
|
return response.InternalError(fmt.Errorf("Failed to cancel operation %q: %w", op.ID, err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate join secret for new member. This will be stored inside the join token operation and will be
|
|
// supplied by the joining member (encoded inside the join token) which will allow us to lookup the correct
|
|
// operation in order to validate the requested joining server name is correct and authorised.
|
|
joinSecret, err := internalUtil.RandomHexString(32)
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
// Generate fingerprint of network certificate so joining member can automatically trust the correct
|
|
// certificate when it is presented during the join process.
|
|
fingerprint, err := localtls.CertFingerprintStr(string(s.Endpoints.NetworkPublicKey()))
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
meta := map[string]any{
|
|
"serverName": req.ServerName, // Add server name to allow validation of name during join process.
|
|
"secret": joinSecret,
|
|
"fingerprint": fingerprint,
|
|
"addresses": onlineNodeAddresses,
|
|
"expiresAt": expiry,
|
|
}
|
|
|
|
resources := map[string][]api.URL{}
|
|
resources["cluster"] = []api.URL{}
|
|
|
|
op, err := operations.OperationCreate(s, api.ProjectDefaultName, operations.OperationClassToken, operationtype.ClusterJoinToken, resources, meta, nil, nil, nil, r)
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterTokenCreated.Event("members", op.Requestor(), nil))
|
|
|
|
return operations.OperationResponse(op)
|
|
}
|
|
|
|
// swagger:operation GET /1.0/cluster/members/{name} cluster cluster_member_get
|
|
//
|
|
// Get the cluster member
|
|
//
|
|
// Gets a specific cluster member.
|
|
//
|
|
// ---
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// responses:
|
|
// "200":
|
|
// description: Cluster member
|
|
// schema:
|
|
// type: object
|
|
// description: Sync response
|
|
// properties:
|
|
// type:
|
|
// type: string
|
|
// description: Response type
|
|
// example: sync
|
|
// status:
|
|
// type: string
|
|
// description: Status description
|
|
// example: Success
|
|
// status_code:
|
|
// type: integer
|
|
// description: Status code
|
|
// example: 200
|
|
// metadata:
|
|
// $ref: "#/definitions/ClusterMember"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodeGet(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
name, err := pathVar(r, "name")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
leaderAddress, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
var raftNodes []db.RaftNode
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
raftNodes, err = tx.GetRaftNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading RAFT nodes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
var memberInfo *api.ClusterMember
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
failureDomains, err := tx.GetFailureDomainsNames(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading failure domains names: %w", err)
|
|
}
|
|
|
|
memberFailureDomains, err := tx.GetNodesFailureDomains(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading member failure domains: %w", err)
|
|
}
|
|
|
|
member, err := tx.GetNodeByName(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
maxVersion, err := tx.GetNodeMaxVersion(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting max member version: %w", err)
|
|
}
|
|
|
|
args := db.NodeInfoArgs{
|
|
LeaderAddress: leaderAddress,
|
|
FailureDomains: failureDomains,
|
|
MemberFailureDomains: memberFailureDomains,
|
|
OfflineThreshold: s.GlobalConfig.OfflineThreshold(),
|
|
MaxMemberVersion: maxVersion,
|
|
RaftNodes: raftNodes,
|
|
}
|
|
|
|
memberInfo, err = member.ToAPI(ctx, tx, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
return response.SyncResponseETag(true, memberInfo, memberInfo.ClusterMemberPut)
|
|
}
|
|
|
|
// swagger:operation PATCH /1.0/cluster/members/{name} cluster cluster_member_patch
|
|
//
|
|
// Partially update the cluster member
|
|
//
|
|
// Updates a subset of the cluster member configuration.
|
|
//
|
|
// ---
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// - in: body
|
|
// name: cluster
|
|
// description: Cluster member configuration
|
|
// required: true
|
|
// schema:
|
|
// $ref: "#/definitions/ClusterMemberPut"
|
|
// responses:
|
|
// "200":
|
|
// $ref: "#/responses/EmptySyncResponse"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "412":
|
|
// $ref: "#/responses/PreconditionFailed"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodePatch(d *Daemon, r *http.Request) response.Response {
|
|
return updateClusterNode(d.State(), d.gateway, r, true)
|
|
}
|
|
|
|
// swagger:operation PUT /1.0/cluster/members/{name} cluster cluster_member_put
|
|
//
|
|
// Update the cluster member
|
|
//
|
|
// Updates the entire cluster member configuration.
|
|
//
|
|
// ---
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// - in: body
|
|
// name: cluster
|
|
// description: Cluster member configuration
|
|
// required: true
|
|
// schema:
|
|
// $ref: "#/definitions/ClusterMemberPut"
|
|
// responses:
|
|
// "200":
|
|
// $ref: "#/responses/EmptySyncResponse"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "412":
|
|
// $ref: "#/responses/PreconditionFailed"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodePut(d *Daemon, r *http.Request) response.Response {
|
|
return updateClusterNode(d.State(), d.gateway, r, false)
|
|
}
|
|
|
|
// updateClusterNode is shared between clusterNodePut and clusterNodePatch.
|
|
func updateClusterNode(s *state.State, gateway *cluster.Gateway, r *http.Request, isPatch bool) response.Response {
|
|
name, err := pathVar(r, "name")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
leaderAddress, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
var raftNodes []db.RaftNode
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
raftNodes, err = tx.GetRaftNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading RAFT nodes: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
var member db.NodeInfo
|
|
var memberInfo *api.ClusterMember
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
failureDomains, err := tx.GetFailureDomainsNames(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading failure domains names: %w", err)
|
|
}
|
|
|
|
memberFailureDomains, err := tx.GetNodesFailureDomains(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading member failure domains: %w", err)
|
|
}
|
|
|
|
member, err = tx.GetNodeByName(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
maxVersion, err := tx.GetNodeMaxVersion(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting max member version: %w", err)
|
|
}
|
|
|
|
args := db.NodeInfoArgs{
|
|
LeaderAddress: leaderAddress,
|
|
FailureDomains: failureDomains,
|
|
MemberFailureDomains: memberFailureDomains,
|
|
OfflineThreshold: s.GlobalConfig.OfflineThreshold(),
|
|
MaxMemberVersion: maxVersion,
|
|
RaftNodes: raftNodes,
|
|
}
|
|
|
|
memberInfo, err = member.ToAPI(ctx, tx, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Validate the request is fine
|
|
err = localUtil.EtagCheck(r, memberInfo.ClusterMemberPut)
|
|
if err != nil {
|
|
return response.PreconditionFailed(err)
|
|
}
|
|
|
|
// Parse the request
|
|
req := api.ClusterMemberPut{}
|
|
err = json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Validate the request
|
|
if slices.Contains(memberInfo.Roles, string(db.ClusterRoleDatabase)) && !slices.Contains(req.Roles, string(db.ClusterRoleDatabase)) {
|
|
return response.BadRequest(fmt.Errorf("The %q role cannot be dropped at this time", db.ClusterRoleDatabase))
|
|
}
|
|
|
|
if !slices.Contains(memberInfo.Roles, string(db.ClusterRoleDatabase)) && slices.Contains(req.Roles, string(db.ClusterRoleDatabase)) {
|
|
return response.BadRequest(fmt.Errorf("The %q role cannot be added at this time", db.ClusterRoleDatabase))
|
|
}
|
|
|
|
// Nodes must belong to at least one group.
|
|
if len(req.Groups) == 0 {
|
|
return response.BadRequest(errors.New("Cluster members need to belong to at least one group"))
|
|
}
|
|
|
|
// Prevent assigning all nodes the 'database-client' role.
|
|
if slices.Contains(req.Roles, string(db.ClusterRoleDatabaseClient)) {
|
|
clientNodes := 1
|
|
nodesCount := 0
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
nodesCount, err = tx.GetNodesCount(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading nodes count: %w", err)
|
|
}
|
|
|
|
nodes, err := tx.GetNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading nodes: %w", err)
|
|
}
|
|
|
|
for _, n := range nodes {
|
|
// Ignore the node currently being updated.
|
|
if n.Name == member.Name {
|
|
continue
|
|
}
|
|
|
|
if slices.Contains(n.Roles, db.ClusterRoleDatabaseClient) {
|
|
clientNodes++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
if clientNodes >= nodesCount {
|
|
return response.BadRequest(errors.New("Assigning the 'database-client' role to all nodes is not allowed"))
|
|
}
|
|
}
|
|
|
|
// Convert the roles.
|
|
newRoles := make([]db.ClusterRole, 0, len(req.Roles))
|
|
for _, role := range req.Roles {
|
|
newRoles = append(newRoles, db.ClusterRole(role))
|
|
}
|
|
|
|
// Update the database
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
nodeInfo, err := tx.GetNodeByName(ctx, name)
|
|
if err != nil {
|
|
return fmt.Errorf("Loading node information: %w", err)
|
|
}
|
|
|
|
err = clusterValidateConfig(req.Config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if isPatch {
|
|
// Populate request config with current values.
|
|
if req.Config == nil {
|
|
req.Config = nodeInfo.Config
|
|
} else {
|
|
for k, v := range nodeInfo.Config {
|
|
_, ok := req.Config[k]
|
|
if !ok {
|
|
req.Config[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update node config.
|
|
err = tx.UpdateNodeConfig(ctx, nodeInfo.ID, req.Config)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to update cluster member config: %w", err)
|
|
}
|
|
|
|
// Update the description.
|
|
if req.Description != memberInfo.Description {
|
|
err = tx.SetDescription(nodeInfo.ID, req.Description)
|
|
if err != nil {
|
|
return fmt.Errorf("Update description: %w", err)
|
|
}
|
|
}
|
|
|
|
// Update the roles.
|
|
err = tx.UpdateNodeRoles(nodeInfo.ID, newRoles)
|
|
if err != nil {
|
|
return fmt.Errorf("Update roles: %w", err)
|
|
}
|
|
|
|
err = tx.UpdateNodeFailureDomain(ctx, nodeInfo.ID, req.FailureDomain)
|
|
if err != nil {
|
|
return fmt.Errorf("Update failure domain: %w", err)
|
|
}
|
|
|
|
// Update the cluster groups.
|
|
err = tx.UpdateNodeClusterGroups(ctx, nodeInfo.ID, req.Groups)
|
|
if err != nil {
|
|
return fmt.Errorf("Update cluster groups: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// If cluster roles changed, then distribute the info to all members.
|
|
if s.Endpoints != nil && clusterRolesChanged(member.Roles, newRoles) {
|
|
cluster.NotifyHeartbeat(s, gateway)
|
|
}
|
|
|
|
requestor := request.CreateRequestor(r)
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterMemberUpdated.Event(name, requestor, nil))
|
|
|
|
return response.EmptySyncResponse
|
|
}
|
|
|
|
// clusterRolesChanged checks whether the non-internal roles have changed between oldRoles and newRoles.
|
|
func clusterRolesChanged(oldRoles []db.ClusterRole, newRoles []db.ClusterRole) bool {
|
|
// Build list of external-only roles from the newRoles list (excludes internal roles added by raft).
|
|
newExternalRoles := make([]db.ClusterRole, 0, len(newRoles))
|
|
for _, r := range newRoles {
|
|
// Check list of known external roles.
|
|
for _, externalRole := range db.ClusterRoles {
|
|
if r == externalRole {
|
|
newExternalRoles = append(newExternalRoles, r) // Found external role.
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, r := range oldRoles {
|
|
if !cluster.RoleInSlice(r, newExternalRoles) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
for _, r := range newExternalRoles {
|
|
if !cluster.RoleInSlice(r, oldRoles) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// clusterValidateConfig validates the configuration keys/values for cluster members.
|
|
func clusterValidateConfig(config map[string]string) error {
|
|
clusterConfigKeys := map[string]func(value string) error{
|
|
// gendoc:generate(entity=cluster, group=cluster, key=scheduler.instance)
|
|
// Possible values are `all`, `manual`, and `group`. See
|
|
// {ref}`clustering-instance-placement` for more information.
|
|
// ---
|
|
// type: string
|
|
// defaultdesc: `all`
|
|
// shortdesc: Controls how instances are scheduled to run on this member
|
|
"scheduler.instance": validate.Optional(validate.IsOneOf("all", "group", "manual")),
|
|
}
|
|
|
|
for k, v := range config {
|
|
// User keys are free for all.
|
|
|
|
// gendoc:generate(entity=cluster, group=cluster, key=user.*)
|
|
// User keys can be used in search.
|
|
// ---
|
|
// type: string
|
|
// shortdesc: Free form user key/value storage
|
|
if strings.HasPrefix(k, "user.") {
|
|
continue
|
|
}
|
|
|
|
validator, ok := clusterConfigKeys[k]
|
|
if !ok {
|
|
return fmt.Errorf("Invalid cluster configuration key %q", k)
|
|
}
|
|
|
|
err := validator(v)
|
|
if err != nil {
|
|
return fmt.Errorf("Invalid cluster configuration key %q value", k)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// swagger:operation POST /1.0/cluster/members/{name} cluster cluster_member_post
|
|
//
|
|
// Rename the cluster member
|
|
//
|
|
// Renames an existing cluster member.
|
|
//
|
|
// ---
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// - in: body
|
|
// name: cluster
|
|
// description: Cluster member rename request
|
|
// required: true
|
|
// schema:
|
|
// $ref: "#/definitions/ClusterMemberPost"
|
|
// responses:
|
|
// "200":
|
|
// $ref: "#/responses/EmptySyncResponse"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodePost(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
memberName, err := pathVar(r, "name")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Forward request.
|
|
resp := forwardedResponseToNode(s, r, memberName)
|
|
if resp != nil {
|
|
return resp
|
|
}
|
|
|
|
req := api.ClusterMemberPost{}
|
|
|
|
// Parse the request
|
|
err = json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Quick checks.
|
|
err = validate.IsAPIName(req.ServerName, false)
|
|
if err != nil {
|
|
return response.BadRequest(fmt.Errorf("Invalid cluster member name: %w", err))
|
|
}
|
|
|
|
// Perform the rename.
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
return tx.RenameNode(ctx, memberName, req.ServerName)
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Update local server name.
|
|
d.globalConfigMu.Lock()
|
|
d.serverName = req.ServerName
|
|
d.globalConfigMu.Unlock()
|
|
|
|
d.events.SetLocalLocation(d.serverName)
|
|
|
|
requestor := request.CreateRequestor(r)
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterMemberRenamed.Event(req.ServerName, requestor, logger.Ctx{"old_name": memberName}))
|
|
|
|
return response.EmptySyncResponse
|
|
}
|
|
|
|
// swagger:operation DELETE /1.0/cluster/members/{name} cluster cluster_member_delete
|
|
//
|
|
// Delete the cluster member
|
|
//
|
|
// Removes the member from the cluster.
|
|
//
|
|
// ---
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// responses:
|
|
// "200":
|
|
// $ref: "#/responses/EmptySyncResponse"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodeDelete(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
force, err := strconv.Atoi(r.FormValue("force"))
|
|
if err != nil {
|
|
force = 0
|
|
}
|
|
|
|
pending, err := strconv.Atoi(r.FormValue("pending"))
|
|
if err != nil {
|
|
pending = 0
|
|
}
|
|
|
|
name, err := pathVar(r, "name")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Redirect all requests to the leader, which is the one with
|
|
// knowing what nodes are part of the raft cluster.
|
|
localClusterAddress := s.LocalConfig.ClusterAddress()
|
|
|
|
leader, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
var localInfo, leaderInfo db.NodeInfo
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
localInfo, err = tx.GetNodeByAddress(ctx, localClusterAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading local member info %q: %w", localClusterAddress, err)
|
|
}
|
|
|
|
leaderInfo, err = tx.GetNodeByAddress(ctx, leader)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed loading leader member info %q: %w", leader, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Get information about the cluster.
|
|
var nodes []db.RaftNode
|
|
err = s.DB.Node.Transaction(r.Context(), func(ctx context.Context, tx *db.NodeTx) error {
|
|
var err error
|
|
nodes, err = tx.GetRaftNodes(ctx)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(fmt.Errorf("Unable to get raft nodes: %w", err))
|
|
}
|
|
|
|
if localClusterAddress != leader {
|
|
if localInfo.Name == name {
|
|
// If the member being removed is ourselves and we are not the leader, then lock the
|
|
// clusterPutDisableMu before we forward the request to the leader, so that when the leader
|
|
// goes on to request clusterPutDisable back to ourselves it won't be actioned until we
|
|
// have returned this request back to the original client.
|
|
clusterPutDisableMu.Lock()
|
|
logger.Info("Acquired cluster self removal lock", logger.Ctx{"member": localInfo.Name})
|
|
|
|
go func() {
|
|
<-r.Context().Done() // Wait until request is finished.
|
|
|
|
logger.Info("Releasing cluster self removal lock", logger.Ctx{"member": localInfo.Name})
|
|
clusterPutDisableMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
logger.Debugf("Redirect member delete request to %s", leader)
|
|
client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), r, false)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
if pending == 0 {
|
|
err = client.DeleteClusterMember(name, force == 1)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
} else {
|
|
err = client.DeletePendingClusterMember(name, force == 1)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
}
|
|
|
|
// If we are the only remaining node, wait until promotion to leader,
|
|
// then update cluster certs.
|
|
if name == leaderInfo.Name && len(nodes) == 2 {
|
|
err = d.gateway.WaitLeadership()
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
s.UpdateCertificateCache()
|
|
}
|
|
|
|
return response.ManualResponse(func(w http.ResponseWriter) error {
|
|
err := response.EmptySyncResponse.Render(w)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send the response before replacing the daemon process.
|
|
f, ok := w.(http.Flusher)
|
|
if !ok {
|
|
return errors.New("http.ResponseWriter is not type http.Flusher")
|
|
}
|
|
|
|
f.Flush()
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Get lock now we are on leader.
|
|
d.clusterMembershipMutex.Lock()
|
|
defer d.clusterMembershipMutex.Unlock()
|
|
|
|
// If we are removing the leader of a 2 node cluster, ensure the other node can be a leader.
|
|
if name == leaderInfo.Name && len(nodes) == 2 {
|
|
for i := range nodes {
|
|
if nodes[i].Address != leader && nodes[i].Role != db.RaftVoter {
|
|
// Promote the remaining node.
|
|
nodes[i].Role = db.RaftVoter
|
|
err := changeMemberRole(s, r, nodes[i].Address, nodes)
|
|
if err != nil {
|
|
return response.SmartError(fmt.Errorf("Unable to promote remaining cluster member to leader: %w", err))
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Info("Deleting member from cluster", logger.Ctx{"name": name, "force": force})
|
|
|
|
err = autoSyncImages(s.ShutdownCtx, s)
|
|
if err != nil {
|
|
if force == 0 {
|
|
return response.SmartError(fmt.Errorf("Failed to sync images: %w", err))
|
|
}
|
|
|
|
// If force is set, only show a warning instead of returning an error.
|
|
logger.Warn("Failed to sync images")
|
|
}
|
|
|
|
// First check that the node is clear from containers and images and
|
|
// make it leave the database cluster, if it's part of it.
|
|
address, err := cluster.Leave(s, d.gateway, name, force == 1, pending == 1)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
if force != 1 {
|
|
// Try to gracefully delete all networks and storage pools on it.
|
|
// Delete all networks on this node
|
|
client, err := cluster.Connect(address, s.Endpoints.NetworkCert(), s.ServerCert(), r, true)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Get a list of projects for networks.
|
|
var networkProjectNames []string
|
|
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
networkProjectNames, err = dbCluster.GetProjectNames(ctx, tx.Tx())
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(fmt.Errorf("Failed to load projects for networks: %w", err))
|
|
}
|
|
|
|
for _, networkProjectName := range networkProjectNames {
|
|
var networks []string
|
|
|
|
err := s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
networks, err = tx.GetNetworks(ctx, networkProjectName)
|
|
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
for _, name := range networks {
|
|
err := client.UseProject(networkProjectName).DeleteNetwork(name)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var pools []string
|
|
|
|
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
// Delete all the pools on this node
|
|
pools, err = tx.GetStoragePoolNames(ctx)
|
|
|
|
return err
|
|
})
|
|
if err != nil && !response.IsNotFoundError(err) {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
for _, name := range pools {
|
|
err := client.DeleteStoragePool(name)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove node from the database
|
|
err = cluster.Purge(s.DB.Cluster, name, pending == 1)
|
|
if err != nil {
|
|
return response.SmartError(fmt.Errorf("Failed to remove member from database: %w", err))
|
|
}
|
|
|
|
err = rebalanceMemberRoles(s, d.gateway, r, nil)
|
|
if err != nil {
|
|
logger.Warnf("Failed to rebalance cowsql nodes: %v", err)
|
|
}
|
|
|
|
// If this leader node removed itself, just disable clustering.
|
|
if address == localClusterAddress {
|
|
return clusterPutDisable(d, r, api.ClusterPut{})
|
|
} else if force != 1 {
|
|
// Try to gracefully reset the database on the node.
|
|
client, err := cluster.Connect(address, s.Endpoints.NetworkCert(), s.ServerCert(), r, true)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
put := api.ClusterPut{}
|
|
put.Enabled = false
|
|
_, err = client.UpdateCluster(put, "")
|
|
if err != nil {
|
|
return response.SmartError(fmt.Errorf("Failed to cleanup the member: %w", err))
|
|
}
|
|
}
|
|
|
|
// Refresh the trusted certificate cache now that the member certificate has been removed.
|
|
// We do not need to notify the other members here because the next heartbeat will trigger member change
|
|
// detection and updateCertificateCache is called as part of that.
|
|
s.UpdateCertificateCache()
|
|
|
|
// Ensure all images are available after this node has been deleted.
|
|
err = autoSyncImages(s.ShutdownCtx, s)
|
|
if err != nil {
|
|
logger.Warn("Failed to sync images")
|
|
}
|
|
|
|
requestor := request.CreateRequestor(r)
|
|
s.Events.SendLifecycle(request.ProjectParam(r), lifecycle.ClusterMemberRemoved.Event(name, requestor, nil))
|
|
|
|
return response.EmptySyncResponse
|
|
}
|
|
|
|
func internalClusterPostAccept(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
req := internalClusterPostAcceptRequest{}
|
|
|
|
// Parse the request
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Quick checks.
|
|
if req.Name == "" {
|
|
return response.BadRequest(errors.New("No name provided"))
|
|
}
|
|
|
|
// Redirect all requests to the leader, which is the one
|
|
// knowing what nodes are part of the raft cluster.
|
|
localClusterAddress := s.LocalConfig.ClusterAddress()
|
|
|
|
leader, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
if localClusterAddress != leader {
|
|
logger.Debugf("Redirect member accept request to %s", leader)
|
|
|
|
if leader == "" {
|
|
return response.SmartError(errors.New("Unable to find leader address"))
|
|
}
|
|
|
|
redirectURL := &url.URL{
|
|
Scheme: "https",
|
|
Path: "/internal/cluster/accept",
|
|
Host: leader,
|
|
}
|
|
|
|
return response.SyncResponseRedirect(redirectURL.String())
|
|
}
|
|
|
|
// Get lock now we are on leader.
|
|
d.clusterMembershipMutex.Lock()
|
|
defer d.clusterMembershipMutex.Unlock()
|
|
|
|
// Make sure we have all the expected storage pools.
|
|
err = clusterCheckStoragePoolsMatch(r.Context(), s.DB.Cluster, req.StoragePools)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Make sure we have all the expected networks.
|
|
err = clusterCheckNetworksMatch(r.Context(), s.DB.Cluster, req.Networks)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
nodes, err := cluster.Accept(s, d.gateway, req.Name, req.Address, req.Schema, req.API, req.Architecture)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
accepted := internalClusterPostAcceptResponse{
|
|
RaftNodes: make([]internalRaftNode, len(nodes)),
|
|
PublicKey: s.Endpoints.NetworkPublicKey(),
|
|
PrivateKey: s.Endpoints.NetworkPrivateKey(),
|
|
}
|
|
|
|
for i, raftNode := range nodes {
|
|
accepted.RaftNodes[i].ID = raftNode.ID
|
|
accepted.RaftNodes[i].Address = raftNode.Address
|
|
accepted.RaftNodes[i].Role = int(raftNode.Role)
|
|
}
|
|
|
|
return response.SyncResponse(true, accepted)
|
|
}
|
|
|
|
// A request for the /internal/cluster/accept endpoint.
|
|
type internalClusterPostAcceptRequest struct {
|
|
Name string `json:"name" yaml:"name"`
|
|
Address string `json:"address" yaml:"address"`
|
|
Schema int `json:"schema" yaml:"schema"`
|
|
API int `json:"api" yaml:"api"`
|
|
StoragePools []api.StoragePool `json:"storage_pools" yaml:"storage_pools"`
|
|
Networks []api.InitNetworksProjectPost `json:"networks" yaml:"networks"`
|
|
Architecture int `json:"architecture" yaml:"architecture"`
|
|
}
|
|
|
|
// A Response for the /internal/cluster/accept endpoint.
|
|
type internalClusterPostAcceptResponse struct {
|
|
RaftNodes []internalRaftNode `json:"raft_nodes" yaml:"raft_nodes"`
|
|
PublicKey []byte `json:"public_key" yaml:"public_key"`
|
|
PrivateKey []byte `json:"private_key" yaml:"private_key"`
|
|
}
|
|
|
|
// Represent a node that is part of the cowsql raft cluster.
|
|
type internalRaftNode struct {
|
|
ID uint64 `json:"id" yaml:"id"`
|
|
Address string `json:"address" yaml:"address"`
|
|
Role int `json:"role" yaml:"role"`
|
|
Name string `json:"name" yaml:"name"`
|
|
}
|
|
|
|
// Used to update the cluster after a database node has been removed, and
|
|
// possibly promote another one as database node.
|
|
func internalClusterPostRebalance(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
// Redirect all requests to the leader, which is the one with with
|
|
// up-to-date knowledge of what nodes are part of the raft cluster.
|
|
localClusterAddress := s.LocalConfig.ClusterAddress()
|
|
|
|
leader, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
if localClusterAddress != leader {
|
|
logger.Debugf("Redirect cluster rebalance request to %s", leader)
|
|
redirectURL := &url.URL{
|
|
Scheme: "https",
|
|
Path: "/internal/cluster/rebalance",
|
|
Host: leader,
|
|
}
|
|
|
|
return response.SyncResponseRedirect(redirectURL.String())
|
|
}
|
|
|
|
// Get lock now we are on leader.
|
|
d.clusterMembershipMutex.Lock()
|
|
defer d.clusterMembershipMutex.Unlock()
|
|
|
|
err = rebalanceMemberRoles(s, d.gateway, r, nil)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
return response.SyncResponse(true, nil)
|
|
}
|
|
|
|
// Check if there's a cowsql node whose role should be changed, and post a
|
|
// change role request if so.
|
|
func rebalanceMemberRoles(s *state.State, gateway *cluster.Gateway, r *http.Request, unavailableMembers []string) error {
|
|
if s.ShutdownCtx.Err() != nil {
|
|
return nil
|
|
}
|
|
|
|
again:
|
|
address, nodes, err := cluster.Rebalance(s, gateway, unavailableMembers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if address == "" {
|
|
// Nothing to do.
|
|
return nil
|
|
}
|
|
|
|
// Process demotions of offline nodes immediately.
|
|
for _, member := range nodes {
|
|
if member.Address != address {
|
|
continue
|
|
}
|
|
|
|
reachable := cluster.HasConnectivity(s.Endpoints.NetworkCert(), s.ServerCert(), address, true)
|
|
|
|
if member.Role != db.RaftSpare {
|
|
if !reachable {
|
|
// The server isn't ready to be promoted yet, try again next time.
|
|
return nil
|
|
}
|
|
|
|
logger.Info("Promoting cluster member", logger.Ctx{"name": member.Name, "role": member.Role})
|
|
break
|
|
}
|
|
|
|
if reachable {
|
|
// Don't demote reachable servers.
|
|
break
|
|
}
|
|
|
|
logger.Info("Demoting cluster member", logger.Ctx{"name": member.Name, "role": member.Role})
|
|
|
|
err := gateway.DemoteOfflineNode(member.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to demote cluster member %q: %w", member.Name, err)
|
|
}
|
|
|
|
goto again
|
|
}
|
|
|
|
// Then handle the promotions.
|
|
err = changeMemberRole(s, r, address, nodes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
goto again
|
|
}
|
|
|
|
// Check if there are nodes not part of the raft configuration and add them in
|
|
// case.
|
|
func upgradeNodesWithoutRaftRole(s *state.State, gateway *cluster.Gateway) error {
|
|
if s.ShutdownCtx.Err() != nil {
|
|
return nil
|
|
}
|
|
|
|
var members []db.NodeInfo
|
|
err := s.DB.Cluster.Transaction(context.Background(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
var err error
|
|
members, err = tx.GetNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting cluster members: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return cluster.UpgradeMembersWithoutRole(gateway, members)
|
|
}
|
|
|
|
// Post a change role request to the member with the given address. The nodes
|
|
// slice contains details about all members, including the one being changed.
|
|
func changeMemberRole(s *state.State, r *http.Request, address string, nodes []db.RaftNode) error {
|
|
post := &internalClusterPostAssignRequest{}
|
|
for _, raftNode := range nodes {
|
|
post.RaftNodes = append(post.RaftNodes, internalRaftNode{
|
|
ID: raftNode.ID,
|
|
Address: raftNode.Address,
|
|
Role: int(raftNode.Role),
|
|
Name: raftNode.Name,
|
|
})
|
|
}
|
|
|
|
client, err := cluster.Connect(address, s.Endpoints.NetworkCert(), s.ServerCert(), r, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _, err = client.RawQuery("POST", "/internal/cluster/assign", post, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Try to handover the role of this member to another one.
|
|
func handoverMemberRole(s *state.State, gateway *cluster.Gateway) error {
|
|
// If we aren't clustered, there's nothing to do.
|
|
if !s.ServerClustered {
|
|
return nil
|
|
}
|
|
|
|
// Figure out our own cluster address.
|
|
localClusterAddress := s.LocalConfig.ClusterAddress()
|
|
|
|
post := &internalClusterPostHandoverRequest{
|
|
Address: localClusterAddress,
|
|
}
|
|
|
|
logCtx := logger.Ctx{"address": localClusterAddress}
|
|
|
|
// Find the cluster leader.
|
|
findLeader:
|
|
leader, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if leader == "" {
|
|
return errors.New("No leader address found")
|
|
}
|
|
|
|
if leader == localClusterAddress {
|
|
logger.Info("Transferring leadership", logCtx)
|
|
err := gateway.TransferLeadership()
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to transfer leadership: %w", err)
|
|
}
|
|
|
|
goto findLeader
|
|
}
|
|
|
|
logger.Info("Handing over cluster member role", logCtx)
|
|
client, err := cluster.Connect(leader, s.Endpoints.NetworkCert(), s.ServerCert(), nil, true)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed handing over cluster member role: %w", err)
|
|
}
|
|
|
|
_, _, err = client.RawQuery("POST", "/internal/cluster/handover", post, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Used to assign a new role to a the local cowsql node.
|
|
func internalClusterPostAssign(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
req := internalClusterPostAssignRequest{}
|
|
|
|
// Parse the request
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Quick checks.
|
|
if len(req.RaftNodes) == 0 {
|
|
return response.BadRequest(errors.New("No raft members provided"))
|
|
}
|
|
|
|
nodes := make([]db.RaftNode, len(req.RaftNodes))
|
|
for i, raftNode := range req.RaftNodes {
|
|
nodes[i].ID = raftNode.ID
|
|
nodes[i].Address = raftNode.Address
|
|
nodes[i].Role = db.RaftRole(raftNode.Role)
|
|
nodes[i].Name = raftNode.Name
|
|
}
|
|
|
|
err = cluster.Assign(s, d.gateway, nodes)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
return response.SyncResponse(true, nil)
|
|
}
|
|
|
|
// A request for the /internal/cluster/assign endpoint.
|
|
type internalClusterPostAssignRequest struct {
|
|
RaftNodes []internalRaftNode `json:"raft_nodes" yaml:"raft_nodes"`
|
|
}
|
|
|
|
// Used to to transfer the responsibilities of a member to another one.
|
|
func internalClusterPostHandover(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
req := internalClusterPostHandoverRequest{}
|
|
|
|
// Parse the request
|
|
err := json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Quick checks.
|
|
if req.Address == "" {
|
|
return response.BadRequest(errors.New("No id provided"))
|
|
}
|
|
|
|
// Redirect all requests to the leader, which is the one with
|
|
// authoritative knowledge of the current raft configuration.
|
|
localClusterAddress := s.LocalConfig.ClusterAddress()
|
|
|
|
leader, err := s.Cluster.LeaderAddress()
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
if leader == "" {
|
|
return response.SmartError(errors.New("No leader address found"))
|
|
}
|
|
|
|
if localClusterAddress != leader {
|
|
logger.Debugf("Redirect handover request to %s", leader)
|
|
redirectURL := &url.URL{
|
|
Scheme: "https",
|
|
Path: "/internal/cluster/handover",
|
|
Host: leader,
|
|
}
|
|
|
|
return response.SyncResponseRedirect(redirectURL.String())
|
|
}
|
|
|
|
// Get lock now we are on leader.
|
|
d.clusterMembershipMutex.Lock()
|
|
defer d.clusterMembershipMutex.Unlock()
|
|
|
|
target, nodes, err := cluster.Handover(s, d.gateway, req.Address)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// If there's no other member we can promote, there's nothing we can
|
|
// do, just return.
|
|
if target == "" {
|
|
goto out
|
|
}
|
|
|
|
logger.Info("Promoting member during handover", logger.Ctx{"address": localClusterAddress, "losingAddress": req.Address, "candidateAddress": target})
|
|
err = changeMemberRole(s, r, target, nodes)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
// Demote the member that is handing over.
|
|
for i, raftNode := range nodes {
|
|
if raftNode.Address == req.Address {
|
|
nodes[i].Role = db.RaftSpare
|
|
}
|
|
}
|
|
|
|
logger.Info("Demoting member during handover", logger.Ctx{"address": localClusterAddress, "losingAddress": req.Address})
|
|
err = changeMemberRole(s, r, req.Address, nodes)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
out:
|
|
return response.SyncResponse(true, nil)
|
|
}
|
|
|
|
// A request for the /internal/cluster/handover endpoint.
|
|
type internalClusterPostHandoverRequest struct {
|
|
// Address of the server whose role should be transferred.
|
|
Address string `json:"address" yaml:"address"`
|
|
}
|
|
|
|
func clusterCheckStoragePoolsMatch(ctx context.Context, clusterDB *db.Cluster, reqPools []api.StoragePool) error {
|
|
return clusterDB.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
|
|
poolNames, err := tx.GetCreatedStoragePoolNames(ctx)
|
|
if err != nil && !response.IsNotFoundError(err) {
|
|
return err
|
|
}
|
|
|
|
for _, name := range poolNames {
|
|
found := false
|
|
for _, reqPool := range reqPools {
|
|
if reqPool.Name != name {
|
|
continue
|
|
}
|
|
|
|
found = true
|
|
|
|
var pool *api.StoragePool
|
|
|
|
_, pool, _, err = tx.GetStoragePoolInAnyState(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if pool.Driver != reqPool.Driver {
|
|
return fmt.Errorf("Mismatching driver for storage pool %s", name)
|
|
}
|
|
// Exclude the keys which are node-specific.
|
|
exclude := db.NodeSpecificStorageConfig(pool.Driver)
|
|
err = localUtil.CompareConfigs(pool.Config, reqPool.Config, exclude)
|
|
if err != nil {
|
|
return fmt.Errorf("Mismatching config for storage pool %s: %w", name, err)
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
if !found {
|
|
return fmt.Errorf("Missing storage pool %s", name)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func clusterCheckNetworksMatch(ctx context.Context, clusterDB *db.Cluster, reqNetworks []api.InitNetworksProjectPost) error {
|
|
return clusterDB.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
|
|
// Get a list of projects for networks.
|
|
networkProjectNames, err := dbCluster.GetProjectNames(ctx, tx.Tx())
|
|
if err != nil {
|
|
return fmt.Errorf("Failed to load projects for networks: %w", err)
|
|
}
|
|
|
|
for _, networkProjectName := range networkProjectNames {
|
|
networkNames, err := tx.GetCreatedNetworkNamesByProject(ctx, networkProjectName)
|
|
if err != nil && !response.IsNotFoundError(err) {
|
|
return err
|
|
}
|
|
|
|
for _, networkName := range networkNames {
|
|
_, network, _, err := tx.GetNetworkInAnyState(ctx, networkProjectName, networkName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// OVN networks don't need local creation.
|
|
if network.Type == "ovn" {
|
|
continue
|
|
}
|
|
|
|
// Check that the network is present locally.
|
|
found := false
|
|
for _, reqNetwork := range reqNetworks {
|
|
if reqNetwork.Name != networkName || reqNetwork.Project != networkProjectName {
|
|
continue
|
|
}
|
|
|
|
found = true
|
|
|
|
if reqNetwork.Type != network.Type {
|
|
return fmt.Errorf("Mismatching type for network %q in project %q", networkName, networkProjectName)
|
|
}
|
|
|
|
// Exclude the keys which are node-specific.
|
|
networkConfigWithoutNodeSpecific := db.StripNodeSpecificNetworkConfig(network.Config)
|
|
reqNetworkConfigwithoutNodeSpecific := db.StripNodeSpecificNetworkConfig(reqNetwork.Config)
|
|
err = localUtil.CompareConfigs(networkConfigWithoutNodeSpecific, reqNetworkConfigwithoutNodeSpecific, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("Mismatching config for network %q in project %q: %w", network.Name, networkProjectName, err)
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
if !found {
|
|
return fmt.Errorf("Missing network %q in project %q", networkName, networkProjectName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Used as low-level recovering helper.
|
|
func internalClusterRaftNodeDelete(d *Daemon, r *http.Request) response.Response {
|
|
s := d.State()
|
|
|
|
address, err := pathVar(r, "address")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
err = cluster.RemoveRaftNode(d.gateway, address)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
err = rebalanceMemberRoles(s, d.gateway, r, nil)
|
|
if err != nil && !errors.Is(err, cluster.ErrNotLeader) {
|
|
logger.Warn("Could not rebalance cluster member roles after raft member removal", logger.Ctx{"err": err})
|
|
}
|
|
|
|
return response.SyncResponse(true, nil)
|
|
}
|
|
|
|
// swagger:operation GET /1.0/cluster/members/{name}/state cluster cluster_member_state_get
|
|
//
|
|
// Get state of the cluster member
|
|
//
|
|
// Gets state of a specific cluster member.
|
|
//
|
|
// ---
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// responses:
|
|
// "200":
|
|
// description: Cluster member state
|
|
// schema:
|
|
// type: object
|
|
// description: Sync response
|
|
// properties:
|
|
// type:
|
|
// type: string
|
|
// description: Response type
|
|
// example: sync
|
|
// status:
|
|
// type: string
|
|
// description: Status description
|
|
// example: Success
|
|
// status_code:
|
|
// type: integer
|
|
// description: Status code
|
|
// example: 200
|
|
// metadata:
|
|
// $ref: "#/definitions/ClusterMemberState"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodeStateGet(d *Daemon, r *http.Request) response.Response {
|
|
memberName, err := pathVar(r, "name")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
s := d.State()
|
|
|
|
// Forward request.
|
|
resp := forwardedResponseToNode(s, r, memberName)
|
|
if resp != nil {
|
|
return resp
|
|
}
|
|
|
|
memberState, err := cluster.MemberState(r.Context(), s, memberName)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
return response.SyncResponse(true, memberState)
|
|
}
|
|
|
|
// swagger:operation POST /1.0/cluster/members/{name}/state cluster cluster_member_state_post
|
|
//
|
|
// Evacuate or restore a cluster member
|
|
//
|
|
// Evacuates or restores a cluster member.
|
|
//
|
|
// ---
|
|
// consumes:
|
|
// - application/json
|
|
// produces:
|
|
// - application/json
|
|
// parameters:
|
|
// - in: path
|
|
// name: name
|
|
// description: Cluster member name
|
|
// type: string
|
|
// required: true
|
|
// - in: body
|
|
// name: cluster
|
|
// description: Cluster member state
|
|
// required: true
|
|
// schema:
|
|
// $ref: "#/definitions/ClusterMemberStatePost"
|
|
// responses:
|
|
// "202":
|
|
// $ref: "#/responses/Operation"
|
|
// "400":
|
|
// $ref: "#/responses/BadRequest"
|
|
// "403":
|
|
// $ref: "#/responses/Forbidden"
|
|
// "404":
|
|
// $ref: "#/responses/NotFound"
|
|
// "409":
|
|
// $ref: "#/responses/Conflict"
|
|
// "500":
|
|
// $ref: "#/responses/InternalServerError"
|
|
func clusterNodeStatePost(d *Daemon, r *http.Request) response.Response {
|
|
name, err := pathVar(r, "name")
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
s := d.State()
|
|
|
|
// Forward request.
|
|
resp := forwardedResponseToNode(s, r, name)
|
|
if resp != nil {
|
|
return resp
|
|
}
|
|
|
|
// Parse the request.
|
|
req := api.ClusterMemberStatePost{}
|
|
err = json.NewDecoder(r.Body).Decode(&req)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
|
|
// Handling of evacuation mode.
|
|
if req.Action == "evacuate" {
|
|
// Validate the mode if provided.
|
|
if req.Mode != "" {
|
|
validator := internalInstance.InstanceConfigKeysAny["cluster.evacuate"]
|
|
err = validator(req.Mode)
|
|
if err != nil {
|
|
return response.BadRequest(err)
|
|
}
|
|
}
|
|
|
|
// Get a count of the cluster members.
|
|
var serverCount int
|
|
|
|
err := s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
|
|
nodes, err := tx.GetNodes(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("Failed getting cluster members: %w", err)
|
|
}
|
|
|
|
serverCount = len(nodes)
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return response.InternalError(err)
|
|
}
|
|
|
|
// Handle single node clusters.
|
|
if serverCount == 1 {
|
|
if req.Mode == "" || req.Mode == "auto" {
|
|
req.Mode = "stop"
|
|
} else if req.Mode != "stop" {
|
|
return response.BadRequest(fmt.Errorf("Can't perform %q evacuation on a single node cluster", req.Mode))
|
|
}
|
|
}
|
|
}
|
|
|
|
switch req.Action {
|
|
case "evacuate":
|
|
run := func(op *operations.Operation) error {
|
|
return evacuateClusterMember(context.Background(), s, op, name, req.Mode, evacuateStopInstance, evacuateMigrateInstance(r))
|
|
}
|
|
|
|
op, err := operations.OperationCreate(s, "", operations.OperationClassTask, operationtype.ClusterMemberEvacuate, nil, nil, run, nil, nil, r)
|
|
if err != nil {
|
|
return response.SmartError(err)
|
|
}
|
|
|
|
return operations.OperationResponse(op)
|
|
case "restore":
|
|
if req.Mode != "" && req.Mode != "skip" {
|
|
return response.BadRequest(fmt.Errorf("Invalid restore mode %q", req.Mode))
|
|
}
|
|
|
|
return restoreClusterMember(d, r, req.Mode == "skip")
|
|
}
|
|
|
|
return response.BadRequest(fmt.Errorf("Unknown action %q", req.Action))
|
|
}
|