Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Per-project uplink IP quotas #14631

Merged
merged 10 commits into from
Jan 27, 2025
5 changes: 5 additions & 0 deletions doc/api-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2552,3 +2552,8 @@ This adds support for listing network zones across all projects using the `all-p
Adds support for instance root volumes to be attached to other instances as disk
devices. Introduces the `<type>/<volume>` syntax for the `source` property of
disk devices.

## `projects_limits_uplink_ips`

Introduces per-project uplink IP limits for each available uplink network, adding `limits.networks.uplink_ips.ipv4.NETWORK_NAME` and `limits.networks.uplink_ips.ipv6.NETWORK_NAME` configuration keys for projects with `features.networks` enabled.
These keys define the maximum value of IPs made available on a network named NETWORK_NAME to be assigned as uplink IPs for entities inside a certain project. These entities can be other networks, network forwards or load balancers.
85 changes: 82 additions & 3 deletions lxd/api_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"

"github.com/gorilla/mux"
Expand Down Expand Up @@ -294,7 +295,7 @@ func projectsPost(d *Daemon, r *http.Request) response.Response {
}

// Validate the configuration.
err = projectValidateConfig(s, project.Config)
err = projectValidateConfig(s, project.Config, project.Name)
if err != nil {
return response.BadRequest(err)
}
Expand Down Expand Up @@ -682,7 +683,7 @@ func projectChange(s *state.State, project *api.Project, req api.ProjectPut) res
}

// Validate the configuration.
err := projectValidateConfig(s, req.Config)
err := projectValidateConfig(s, req.Config, project.Name)
if err != nil {
return response.BadRequest(err)
}
Expand Down Expand Up @@ -952,6 +953,54 @@ func projectStateGet(d *Daemon, r *http.Request) response.Response {
return response.SyncResponse(true, &state)
}

// uplinkIPLimitValidator returns a validator function for uplink IP limits.
// The protocol argument specifies whether we should validate ipv4 or ipv6.
func uplinkIPLimitValidator(s *state.State, projectName string, networkName string, protocol string) func(string) error {
return func(value string) error {
// Perform cheaper checks on the value first.
providedIPQuota, err := strconv.Atoi(value)
if err != nil {
return err
}

if providedIPQuota < 0 {
return fmt.Errorf("Value must be non-negative")
}

// The results for the quota we are not interested in will be ignored in the end, so
// here -1 is used to prevent the quota that is not relevant from stopping UplinkAddressQuotasExceeded
// from returning early.
IPV4AddressQuota := -1
hamistao marked this conversation as resolved.
Show resolved Hide resolved
IPV6AddressQuota := -1

if protocol == "ipv6" {
IPV6AddressQuota = providedIPQuota
}

if protocol == "ipv4" {
IPV4AddressQuota = providedIPQuota
}

// Check if the provided value is equal or lower to the number of uplink addresses currently in use
// on the provided project and in the specified network.
// We are only interested on the result for the desired protocol, the other will always come out as true.
err = s.DB.Cluster.Transaction(s.ShutdownCtx, func(ctx context.Context, tx *db.ClusterTx) error {
invalidIPV4Quota, invalidIPV6Quota, err := limits.UplinkAddressQuotasExceeded(ctx, tx, projectName, networkName, IPV4AddressQuota, IPV6AddressQuota)
if err != nil {
return err
}

if protocol == "ipv4" && invalidIPV4Quota || protocol == "ipv6" && invalidIPV6Quota {
return fmt.Errorf("Uplink %s limit %q is below current number of used uplink addresses", protocol, value)
}

return nil
})

return err
}
}

// Check if a project is empty.
func projectIsEmpty(ctx context.Context, project *cluster.Project, tx *db.ClusterTx) (bool, error) {
instances, err := cluster.GetInstances(ctx, tx.Tx(), cluster.InstanceFilter{Project: &project.Name})
Expand Down Expand Up @@ -1024,7 +1073,7 @@ func isEitherAllowOrBlockOrManaged(value string) error {
return validate.Optional(validate.IsOneOf("block", "allow", "managed"))(value)
}

func projectValidateConfig(s *state.State, config map[string]string) error {
func projectValidateConfig(s *state.State, config map[string]string, projectName string) error {
// Validate the project configuration.
projectConfigKeys := map[string]func(value string) error{
// lxdmeta:generate(entities=project; group=specific; key=backups.compression_algorithm)
Expand Down Expand Up @@ -1414,6 +1463,36 @@ func projectValidateConfig(s *state.State, config map[string]string) error {
return fmt.Errorf("Failed loading storage pool names: %w", err)
}

// Per-network project limits for uplink IPs only make sense for projects with their own networks.
if shared.IsTrue(config["features.networks"]) {
// Get networks that are allowed to be used as uplinks by this project.
allowedUplinkNetworks, err := network.AllowedUplinkNetworks(s, config)
if err != nil {
return err
}

// Add network-specific config keys.
for _, networkName := range allowedUplinkNetworks {
// lxdmeta:generate(entities=project; group=limits; key=limits.networks.uplink_ips.ipv4.NETWORK_NAME)
// Maximum number of IPv4 addresses that this project can consume from the specified uplink network.
// This number of IPs can be consumed by networks, forwards and load balancers in this project.
//
// ---
// type: string
// shortdesc: Quota of IPv4 addresses from a specified uplink network that can be used by entities in this project
projectConfigKeys["limits.networks.uplink_ips.ipv4."+networkName] = validate.Optional(uplinkIPLimitValidator(s, projectName, networkName, "ipv4"))

// lxdmeta:generate(entities=project; group=limits; key=limits.networks.uplink_ips.ipv6.NETWORK_NAME)
// Maximum number of IPv6 addresses that this project can consume from the specified uplink network.
// This number of IPs can be consumed by networks, forwards and load balancers in this project.
//
// ---
// type: string
// shortdesc: Quota of IPv4 addresses from a specified uplink network that can be used by entities in this project
projectConfigKeys["limits.networks.uplink_ips.ipv6."+networkName] = validate.Optional(uplinkIPLimitValidator(s, projectName, networkName, "ipv6"))
Comment on lines +1491 to +1492
Copy link
Member

Choose a reason for hiding this comment

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

Caught while writing weekly news, there is a typo in short description: IPv4 -> IPv6

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching this, will fix in a minute

}
}

for k, v := range config {
key := k

Expand Down
97 changes: 97 additions & 0 deletions lxd/project/limits/permissions.go
hamistao marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -1661,3 +1661,100 @@ func CheckTarget(ctx context.Context, authorizer auth.Authorizer, r *http.Reques

return nil, "", nil
}

// uplinkIPLimits is a type used to help check uplink IP quota usage.
type uplinkIPLimits struct {
quotaIPV4 int
quotaIPV6 int
usedIPV4Addresses int
usedIPV6Addresses int
}

func (q *uplinkIPLimits) increment(incrementIPV4 bool, incrementIPV6 bool) {
if incrementIPV4 {
q.usedIPV4Addresses++
}

if incrementIPV6 {
q.usedIPV6Addresses++
}
}

func (q *uplinkIPLimits) hasExceeded() bool {
return q.usedIPV4Addresses > q.quotaIPV4 && q.usedIPV6Addresses > q.quotaIPV6
}

// UplinkAddressQuotasExceeded checks whether the number of current uplink addresses used in project
// projectName on network networkName is higher than their provided quota for each IP protocol.
// Uplink addresses can be consumed by load balancers, network forwards and networks.
// For simplicity, this function assumes both limits are provided and returns early if both provided
// quotas are exceeded. So if one of the limits is not of the caller's interest, -1 should be provided
// and the result for that protocol should be ignored.
func UplinkAddressQuotasExceeded(ctx context.Context, tx *db.ClusterTx, projectName string, networkName string, uplinkIPV4Quota int, uplinkIPV6Quota int) (V4QuotaExceeded bool, V6QuotaExceeded bool, err error) {
quotas := uplinkIPLimits{
quotaIPV4: uplinkIPV4Quota,
quotaIPV6: uplinkIPV6Quota,
}

// If both provided quotas are below 0, return right away.
if quotas.hasExceeded() {
return true, true, nil
}

// First count uplink addresses for other networks.
projectNetworks, err := tx.GetCreatedNetworksByProject(ctx, projectName)
if err != nil {
return false, false, nil
}

for _, network := range projectNetworks {
// Check if each network is using our target network as an uplink.
if network.Config["network"] == networkName {
_, hasIPV6 := network.Config["volatile.network.ipv6.address"]
_, hasIPV4 := network.Config["volatile.network.ipv4.address"]
quotas.increment(hasIPV4, hasIPV6)
if quotas.hasExceeded() {
return true, true, nil
}
}
}

// Count listen addresses for network forwards.
forwardListenAddressesMap, err := tx.GetProjectNetworkForwardListenAddressesByUplink(ctx, networkName, false)
if err != nil {
return false, false, err
}

// Iterate through each network on the provided project while counting the uplink addresses used by their
// network forwards.
for _, addresses := range forwardListenAddressesMap[projectName] {
for _, address := range addresses {
isIPV6 := validate.IsNetworkAddressV6(address) == nil
quotas.increment(!isIPV6, isIPV6)
if quotas.hasExceeded() {
return true, true, nil
}
}
}

// Count listen addresses for load balancers.
loadBalancerAddressesMap, err := tx.GetProjectNetworkLoadBalancerListenAddressesByUplink(ctx, networkName, false)
if err != nil {
return false, false, err
}

// Iterate through each network on the provided project while counting the uplink addresses used by their
// load balancers.
for _, addresses := range loadBalancerAddressesMap[projectName] {
for _, address := range addresses {
isIPV6 := validate.IsNetworkAddressV6(address) == nil
quotas.increment(!isIPV6, isIPV6)
if quotas.hasExceeded() {
return true, true, nil
}
}
}

// At least one of the quotas were not exceeded.
return quotas.usedIPV4Addresses > quotas.quotaIPV4, quotas.usedIPV6Addresses > quotas.quotaIPV6, err
}
1 change: 1 addition & 0 deletions shared/version/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ var APIExtensions = []string{
"network_get_target",
"network_zones_all_projects",
"instance_root_volume_attachment",
"projects_limits_uplink_ips",
}

// APIExtensionsCount returns the number of available API extensions.
Expand Down