Skip to content

Commit 835fe31

Browse files
authored
Prevent cluster groups from being deleted when referenced by a projects' configuration (#15119)
Adds an API extension `clustering_groups_used_by` and adds a `UsedBy` field to `api.ClusterGroup`. On GET requests, the `UsedBy` URLs are projects that contain the cluster group in `restricted.cluster.groups`. DELETE requests will fail if the cluster group is used by a project. Closes #15118
2 parents 2952871 + 9c38fc5 commit 835fe31

File tree

8 files changed

+141
-52
lines changed

8 files changed

+141
-52
lines changed

doc/api-extensions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2625,3 +2625,8 @@ Adds flags --network and --storage. The --network flag adds a network device con
26252625
## `client_cert_presence`
26262626

26272627
Adds the field `client_certificate` to `GET /1.0` to indicate if the current request has a client certificate in it. This is for informational purposes only and does not affect the behavior of the API.
2628+
2629+
## `clustering_groups_used_by`
2630+
2631+
This API extension adds a `used_by` field to the API response for a {ref}`cluster group <cluster-groups>`.
2632+
Deletion of a cluster group is disallowed if the cluster group is referenced by project configuration (see {config:option}`project-restricted:restricted.cluster.groups`).

doc/rest-api.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,15 @@ definitions:
327327
example: group1
328328
type: string
329329
x-go-name: Name
330+
used_by:
331+
description: |-
332+
UsedBy is a list or LXD entity URLs that reference the cluster group.
333+
334+
API extension: clustering_groups_used_by
335+
items:
336+
type: string
337+
type: array
338+
x-go-name: UsedBy
330339
title: ClusterGroup represents a cluster group.
331340
type: object
332341
x-go-package: github.com/canonical/lxd/shared/api

lxd/api_cluster.go

Lines changed: 61 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"sync"
1717
"time"
1818

19+
dqlite "github.com/canonical/go-dqlite/v3/client"
1920
"github.com/gorilla/mux"
2021

2122
"github.com/canonical/lxd/client"
@@ -774,11 +775,15 @@ func clusterPutJoin(d *Daemon, r *http.Request, req api.ClusterPut) response.Res
774775
s.UpdateIdentityCache()
775776

776777
// Update local setup and possibly join the raft dqlite cluster.
777-
nodes := make([]db.RaftNode, len(info.RaftNodes))
778-
for i, node := range info.RaftNodes {
779-
nodes[i].ID = node.ID
780-
nodes[i].Address = node.Address
781-
nodes[i].Role = db.RaftRole(node.Role)
778+
nodes := make([]db.RaftNode, 0, len(info.RaftNodes))
779+
for _, node := range info.RaftNodes {
780+
nodes = append(nodes, db.RaftNode{
781+
NodeInfo: dqlite.NodeInfo{
782+
ID: node.ID,
783+
Address: node.Address,
784+
Role: db.RaftRole(node.Role),
785+
},
786+
})
782787
}
783788

784789
err = cluster.Join(s, d.gateway, networkCert, serverCert, req.ServerName, nodes)
@@ -1378,6 +1383,7 @@ func clusterNodesPost(d *Daemon, r *http.Request) response.Response {
13781383
}
13791384

13801385
// Filter to online members.
1386+
onlineNodeAddresses = make([]any, 0, len(members))
13811387
for _, member := range members {
13821388
if member.State == db.ClusterMemberStateEvacuated || member.IsOffline(s.GlobalConfig.OfflineThreshold()) {
13831389
continue
@@ -2481,14 +2487,16 @@ func internalClusterPostAccept(d *Daemon, r *http.Request) response.Response {
24812487
}
24822488

24832489
accepted := internalClusterPostAcceptResponse{
2484-
RaftNodes: make([]internalRaftNode, len(nodes)),
2490+
RaftNodes: make([]internalRaftNode, 0, len(nodes)),
24852491
PrivateKey: s.Endpoints.NetworkPrivateKey(),
24862492
}
24872493

2488-
for i, node := range nodes {
2489-
accepted.RaftNodes[i].ID = node.ID
2490-
accepted.RaftNodes[i].Address = node.Address
2491-
accepted.RaftNodes[i].Role = int(node.Role)
2494+
for _, node := range nodes {
2495+
accepted.RaftNodes = append(accepted.RaftNodes, internalRaftNode{
2496+
ID: node.ID,
2497+
Address: node.Address,
2498+
Role: int(node.Role),
2499+
})
24922500
}
24932501

24942502
return response.SyncResponse(true, accepted)
@@ -2718,12 +2726,16 @@ func internalClusterPostAssign(d *Daemon, r *http.Request) response.Response {
27182726
return response.BadRequest(fmt.Errorf("No raft members provided"))
27192727
}
27202728

2721-
nodes := make([]db.RaftNode, len(req.RaftNodes))
2722-
for i, node := range req.RaftNodes {
2723-
nodes[i].ID = node.ID
2724-
nodes[i].Address = node.Address
2725-
nodes[i].Role = db.RaftRole(node.Role)
2726-
nodes[i].Name = node.Name
2729+
nodes := make([]db.RaftNode, 0, len(req.RaftNodes))
2730+
for _, node := range req.RaftNodes {
2731+
nodes = append(nodes, db.RaftNode{
2732+
NodeInfo: dqlite.NodeInfo{
2733+
ID: node.ID,
2734+
Address: node.Address,
2735+
Role: db.RaftRole(node.Role),
2736+
},
2737+
Name: node.Name,
2738+
})
27272739
}
27282740

27292741
err = cluster.Assign(s, d.gateway, nodes)
@@ -3249,15 +3261,15 @@ func evacuateClusterMember(s *state.State, gateway *cluster.Gateway, r *http.Req
32493261
return response.SmartError(err)
32503262
}
32513263

3252-
instances := make([]instance.Instance, len(dbInstances))
3264+
instances := make([]instance.Instance, 0, len(dbInstances))
32533265

3254-
for i, dbInst := range dbInstances {
3266+
for _, dbInst := range dbInstances {
32553267
inst, err := instance.LoadByProjectAndName(s, dbInst.Project, dbInst.Name)
32563268
if err != nil {
32573269
return response.SmartError(fmt.Errorf("Failed to load instance: %w", err))
32583270
}
32593271

3260-
instances[i] = inst
3272+
instances = append(instances, inst)
32613273
}
32623274

32633275
run := func(op *operations.Operation) error {
@@ -3426,8 +3438,8 @@ func restoreClusterMember(d *Daemon, r *http.Request) response.Response {
34263438
return response.SmartError(err)
34273439
}
34283440

3429-
instances := make([]instance.Instance, 0)
3430-
localInstances := make([]instance.Instance, 0)
3441+
instances := make([]instance.Instance, 0, len(dbInstances))
3442+
localInstances := make([]instance.Instance, 0, len(dbInstances))
34313443

34323444
for _, dbInst := range dbInstances {
34333445
inst, err := instance.LoadByProjectAndName(s, dbInst.Project, dbInst.Name)
@@ -3817,26 +3829,24 @@ func clusterGroupsGet(d *Daemon, r *http.Request) response.Response {
38173829
return err
38183830
}
38193831

3820-
for i := range clusterGroups {
3821-
nodeClusterGroups, err := dbCluster.GetNodeClusterGroups(ctx, tx.Tx(), dbCluster.NodeClusterGroupFilter{GroupID: &clusterGroups[i].ID})
3832+
apiClusterGroups := make([]*api.ClusterGroup, 0, len(clusterGroups))
3833+
for _, clusterGroup := range clusterGroups {
3834+
nodeClusterGroups, err := dbCluster.GetNodeClusterGroups(ctx, tx.Tx(), dbCluster.NodeClusterGroupFilter{GroupID: &clusterGroup.ID})
38223835
if err != nil {
38233836
return err
38243837
}
38253838

3826-
clusterGroups[i].Nodes = make([]string, 0, len(nodeClusterGroups))
3839+
clusterGroup.Nodes = make([]string, 0, len(nodeClusterGroups))
38273840
for _, node := range nodeClusterGroups {
3828-
clusterGroups[i].Nodes = append(clusterGroups[i].Nodes, node.Node)
3841+
clusterGroup.Nodes = append(clusterGroup.Nodes, node.Node)
38293842
}
3830-
}
38313843

3832-
apiClusterGroups := make([]*api.ClusterGroup, len(clusterGroups))
3833-
for i, clusterGroup := range clusterGroups {
3834-
members, err := tx.GetClusterGroupNodes(ctx, clusterGroup.Name)
3844+
apiClusterGroup, err := clusterGroup.ToAPI(ctx, tx.Tx())
38353845
if err != nil {
38363846
return err
38373847
}
38383848

3839-
apiClusterGroups[i] = db.ClusterGroupToAPI(&clusterGroup, members)
3849+
apiClusterGroups = append(apiClusterGroups, apiClusterGroup)
38403850
}
38413851

38423852
result = apiClusterGroups
@@ -3900,7 +3910,7 @@ func clusterGroupGet(d *Daemon, r *http.Request) response.Response {
39003910
}
39013911

39023912
var group *dbCluster.ClusterGroup
3903-
3913+
var apiGroup *api.ClusterGroup
39043914
err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
39053915
// Get the cluster group.
39063916
group, err = dbCluster.GetClusterGroup(ctx, tx.Tx(), name)
@@ -3918,13 +3928,17 @@ func clusterGroupGet(d *Daemon, r *http.Request) response.Response {
39183928
group.Nodes = append(group.Nodes, node.Node)
39193929
}
39203930

3931+
apiGroup, err = group.ToAPI(ctx, tx.Tx())
3932+
if err != nil {
3933+
return err
3934+
}
3935+
39213936
return nil
39223937
})
39233938
if err != nil {
39243939
return response.SmartError(err)
39253940
}
39263941

3927-
apiGroup, err := group.ToAPI()
39283942
if err != nil {
39293943
return response.InternalError(err)
39303944
}
@@ -4193,17 +4207,17 @@ func clusterGroupPatch(d *Daemon, r *http.Request) response.Response {
41934207
dbClusterGroup.Nodes = append(dbClusterGroup.Nodes, node.Node)
41944208
}
41954209

4210+
clusterGroup, err = dbClusterGroup.ToAPI(ctx, tx.Tx())
4211+
if err != nil {
4212+
return err
4213+
}
4214+
41964215
return nil
41974216
})
41984217
if err != nil {
41994218
return response.SmartError(err)
42004219
}
42014220

4202-
clusterGroup, err = dbClusterGroup.ToAPI()
4203-
if err != nil {
4204-
return response.SmartError(err)
4205-
}
4206-
42074221
req := clusterGroup.Writable()
42084222

42094223
// Validate the ETag.
@@ -4345,7 +4359,16 @@ func clusterGroupDelete(d *Daemon, r *http.Request) response.Response {
43454359
}
43464360

43474361
if len(members) > 0 {
4348-
return fmt.Errorf("Only empty cluster groups can be removed")
4362+
return api.StatusErrorf(http.StatusBadRequest, "Only empty cluster groups can be removed")
4363+
}
4364+
4365+
usedBy, err := dbCluster.GetClusterGroupUsedBy(ctx, tx.Tx(), name)
4366+
if err != nil {
4367+
return err
4368+
}
4369+
4370+
if len(usedBy) > 0 {
4371+
return api.StatusErrorf(http.StatusBadRequest, "Cluster group is currently in use")
43494372
}
43504373

43514374
return dbCluster.DeleteClusterGroup(ctx, tx.Tx(), name)

lxd/db/cluster.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,6 @@ import (
1212
"github.com/canonical/lxd/shared/version"
1313
)
1414

15-
// ClusterGroupToAPI is a convenience to convert a ClusterGroup db struct into
16-
// an API cluster group struct.
17-
func ClusterGroupToAPI(clusterGroup *cluster.ClusterGroup, nodes []string) *api.ClusterGroup {
18-
c := &api.ClusterGroup{
19-
Name: clusterGroup.Name,
20-
Description: clusterGroup.Description,
21-
Members: nodes,
22-
}
23-
24-
return c
25-
}
26-
2715
// GetClusterGroupNodes returns a list of nodes of the given cluster group.
2816
func (c *ClusterTx) GetClusterGroupNodes(ctx context.Context, groupName string) ([]string, error) {
2917
q := `SELECT nodes.name FROM nodes_cluster_groups

lxd/db/cluster/cluster_groups.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package cluster
22

33
import (
4+
"context"
5+
"database/sql"
6+
7+
"github.com/canonical/lxd/lxd/db/query"
8+
"github.com/canonical/lxd/shared"
49
"github.com/canonical/lxd/shared/api"
10+
"github.com/canonical/lxd/shared/entity"
511
)
612

713
//go:generate -command mapper lxd-generate db mapper -t cluster_groups.mapper.go
@@ -42,12 +48,49 @@ type ClusterGroupFilter struct {
4248
}
4349

4450
// ToAPI returns a LXD API entry.
45-
func (c *ClusterGroup) ToAPI() (*api.ClusterGroup, error) {
51+
func (c *ClusterGroup) ToAPI(ctx context.Context, tx *sql.Tx) (*api.ClusterGroup, error) {
52+
usedBy, err := GetClusterGroupUsedBy(ctx, tx, c.Name)
53+
if err != nil {
54+
return nil, err
55+
}
56+
4657
result := api.ClusterGroup{
4758
Name: c.Name,
4859
Description: c.Description,
4960
Members: c.Nodes,
61+
UsedBy: usedBy,
5062
}
5163

5264
return &result, nil
5365
}
66+
67+
// GetClusterGroupUsedBy collates references to the cluster group with the given name.
68+
// This currently only returns the URLs of projects whose `restricted.cluster.groups` configuration
69+
// contains the cluster group.
70+
func GetClusterGroupUsedBy(ctx context.Context, tx *sql.Tx, groupName string) ([]string, error) {
71+
q := `
72+
SELECT projects.name, projects_config.value FROM projects
73+
JOIN projects_config ON projects.id = projects_config.project_id
74+
WHERE projects_config.key = 'restricted.cluster.groups'`
75+
76+
var projectURLs []string
77+
err := query.Scan(ctx, tx, q, func(scan func(dest ...any) error) error {
78+
var projectName string
79+
var configValue string
80+
err := scan(&projectName, &configValue)
81+
if err != nil {
82+
return err
83+
}
84+
85+
if shared.ValueInSlice(groupName, shared.SplitNTrimSpace(configValue, ",", -1, false)) {
86+
projectURLs = append(projectURLs, entity.ProjectURL(projectName).String())
87+
}
88+
89+
return nil
90+
})
91+
if err != nil {
92+
return nil, err
93+
}
94+
95+
return projectURLs, nil
96+
}

shared/api/cluster.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,11 @@ type ClusterGroup struct {
321321
// List of members in this group
322322
// Example: ["node1", "node3"]
323323
Members []string `json:"members" yaml:"members"`
324+
325+
// UsedBy is a list or LXD entity URLs that reference the cluster group.
326+
//
327+
// API extension: clustering_groups_used_by
328+
UsedBy []string `json:"used_by" yaml:"used_by"`
324329
}
325330

326331
// ClusterGroupPost represents the fields required to rename a cluster group.

shared/version/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ var APIExtensions = []string{
441441
"oidc_scopes",
442442
"project_default_network_and_storage",
443443
"client_cert_presence",
444+
"clustering_groups_used_by",
444445
}
445446

446447
// APIExtensionsCount returns the number of available API extensions.

test/suites/clustering.sh

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3810,7 +3810,22 @@ EOF
38103810
# Clean up
38113811
lxc rm -f c1 c2 c3 c4 c5
38123812

3813-
# Restricted project tests
3813+
## Restricted project tests
3814+
3815+
# Create an empty cluster group and reference it from project config
3816+
lxc cluster group create cluster:fizz
3817+
lxc project create cluster:buzz -c restricted=true -c restricted.cluster.groups=fizz
3818+
3819+
# Cannot launch an instance because fizz has no members
3820+
! lxc init testimage cluster:c1 --project buzz || false
3821+
3822+
# Group fizz has no members, but it cannot be deleted because it is referenced by project buzz.
3823+
! lxc cluster group delete cluster:fizz || false
3824+
3825+
# Clean up.
3826+
lxc project delete cluster:buzz
3827+
lxc cluster group delete cluster:fizz
3828+
38143829
lxc project create foo -c features.images=false -c restricted=true -c restricted.cluster.groups=blah
38153830
lxc profile show default | lxc profile edit default --project foo
38163831

0 commit comments

Comments
 (0)