incus-mirror/cmd/incusd/api_project.go
Stéphane Graber 5a4709d663
incusd: Document baseline error codes for endpoints using SmartError
Signed-off-by: Stéphane Graber <stgraber@stgraber.org>
2026-07-28 18:32:51 -04:00

2132 lines
63 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"slices"
"strings"
incus "github.com/lxc/incus/v7/client"
"github.com/lxc/incus/v7/internal/filter"
"github.com/lxc/incus/v7/internal/jmap"
"github.com/lxc/incus/v7/internal/server/auth"
"github.com/lxc/incus/v7/internal/server/db"
"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/network"
"github.com/lxc/incus/v7/internal/server/operations"
projecthelpers "github.com/lxc/incus/v7/internal/server/project"
"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"
"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/util"
"github.com/lxc/incus/v7/shared/validate"
)
var projectsCmd = APIEndpoint{
Path: "projects",
Get: APIEndpointAction{Handler: projectsGet, AccessHandler: allowAuthenticated},
Post: APIEndpointAction{Handler: projectsPost, AccessHandler: allowPermission(auth.ObjectTypeServer, auth.EntitlementCanCreateProjects)},
}
var projectCmd = APIEndpoint{
Path: "projects/{name}",
Delete: APIEndpointAction{Handler: projectDelete, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanEdit, "name")},
Get: APIEndpointAction{Handler: projectGet, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanView, "name")},
Patch: APIEndpointAction{Handler: projectPatch, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanEdit, "name")},
Post: APIEndpointAction{Handler: projectPost, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanEdit, "name")},
Put: APIEndpointAction{Handler: projectPut, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanEdit, "name")},
}
var projectStateCmd = APIEndpoint{
Path: "projects/{name}/state",
Get: APIEndpointAction{Handler: projectStateGet, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanView, "name")},
}
var projectAccessCmd = APIEndpoint{
Path: "projects/{name}/access",
Get: APIEndpointAction{Handler: projectAccess, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanEdit, "name")},
}
// swagger:operation GET /1.0/projects projects projects_get
//
// Get the projects
//
// Returns a list of projects (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/projects/default",
// "/1.0/projects/foo"
// ]
// "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/projects?recursion=1 projects projects_get_recursion1
//
// Get the projects
//
// Returns a list of projects (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 projects
// items:
// $ref: "#/definitions/Project"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func projectsGet(d *Daemon, r *http.Request) response.Response {
s := d.State()
recursion := localUtil.IsRecursionRequest(r)
// 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))
}
userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), r, auth.EntitlementCanView, auth.ObjectTypeProject)
if err != nil {
return response.InternalError(err)
}
filtered := make([]api.Project, 0)
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
projects, err := cluster.GetProjects(ctx, tx.Tx())
if err != nil {
return err
}
for _, project := range projects {
if !userHasPermission(auth.ObjectProject(project.Name)) {
continue
}
apiProject, err := project.ToAPI(ctx, tx.Tx())
if err != nil {
return err
}
apiProject.UsedBy, err = projectUsedBy(ctx, tx, &project)
if err != nil {
return err
}
if clauses != nil && len(clauses.Clauses) > 0 {
match, err := filter.Match(*apiProject, *clauses)
if err != nil {
return err
}
if !match {
continue
}
}
filtered = append(filtered, *apiProject)
}
return nil
})
if err != nil {
return response.SmartError(err)
}
if recursion {
return response.SyncResponse(true, filtered)
}
urls := make([]string, len(filtered))
for i, p := range filtered {
urls[i] = p.URL(version.APIVersion).String()
}
return response.SyncResponse(true, urls)
}
// projectUsedBy returns a list of URLs for all instances, images, profiles,
// storage volumes, networks, and acls that use this project.
func projectUsedBy(ctx context.Context, tx *db.ClusterTx, project *cluster.Project) ([]string, error) {
usedBy := []string{}
instances, err := cluster.GetInstances(ctx, tx.Tx(), cluster.InstanceFilter{Project: &project.Name})
if err != nil {
return nil, err
}
for _, instance := range instances {
apiInstance := api.Instance{Name: instance.Name}
usedBy = append(usedBy, apiInstance.URL(version.APIVersion, project.Name).String())
}
images, err := cluster.GetImages(ctx, tx.Tx(), cluster.ImageFilter{Project: &project.Name})
if err != nil {
return nil, err
}
for _, image := range images {
apiImage := api.Image{Fingerprint: image.Fingerprint}
usedBy = append(usedBy, apiImage.URL(version.APIVersion, project.Name).String())
}
networks, err := tx.GetNetworkURIs(ctx, project.ID, project.Name)
if err != nil {
return nil, err
}
usedBy = append(usedBy, networks...)
acls, err := cluster.GetNetworkACLs(ctx, tx.Tx(), cluster.NetworkACLFilter{Project: &project.Name})
if err != nil {
return nil, fmt.Errorf("Unable to get URIs for network acl: %w", err)
}
for _, acl := range acls {
apiNetworkACL := api.NetworkACL{NetworkACLPost: api.NetworkACLPost{Name: acl.Name}}
usedBy = append(usedBy, apiNetworkACL.URL(version.APIVersion, project.Name).String())
}
var zones []cluster.NetworkZone
zones, err = cluster.GetNetworkZones(ctx, tx.Tx(), cluster.NetworkZoneFilter{Project: &project.Name})
if err != nil {
return nil, fmt.Errorf("Unable to get URIs for network zones: %w", err)
}
// Create URIs for each zone.
networkZones := make([]string, len(zones))
for i, zone := range zones {
networkZones[i] = api.NewURL().Path(version.APIVersion, "network-zones", zone.Name).Project(project.Name).String()
}
usedBy = append(usedBy, networkZones...)
profiles, err := cluster.GetProfiles(ctx, tx.Tx(), cluster.ProfileFilter{Project: &project.Name})
if err != nil {
return nil, err
}
for _, profile := range profiles {
apiProfile := api.Profile{Name: profile.Name}
usedBy = append(usedBy, apiProfile.URL(version.APIVersion, project.Name).String())
}
storageBuckets, err := tx.GetStorageBucketURIs(ctx, project.Name)
if err != nil {
return nil, err
}
usedBy = append(usedBy, storageBuckets...)
storageVolumes, err := tx.GetStorageVolumeURIs(ctx, project.Name)
if err != nil {
return nil, err
}
usedBy = append(usedBy, storageVolumes...)
return usedBy, nil
}
// swagger:operation POST /1.0/projects projects projects_post
//
// Add a project
//
// Creates a new project.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: body
// name: project
// description: Project
// required: true
// schema:
// $ref: "#/definitions/ProjectsPost"
// responses:
// "201":
// $ref: "#/responses/EmptySyncResponse"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func projectsPost(d *Daemon, r *http.Request) response.Response {
s := d.State()
// Parse the request.
project := api.ProjectsPost{}
// Set default features.
if project.Config == nil {
project.Config = map[string]string{}
}
for featureName, featureInfo := range cluster.ProjectFeatures {
_, ok := project.Config[featureName]
if !ok && featureInfo.DefaultEnabled {
project.Config[featureName] = "true"
}
}
err := json.NewDecoder(r.Body).Decode(&project)
if err != nil {
return response.BadRequest(err)
}
// Quick checks.
err = validate.IsAPIName(project.Name, false)
if err != nil {
return response.BadRequest(fmt.Errorf("Invalid project name: %w", err))
}
err = projectValidateName(project.Name)
if err != nil {
return response.BadRequest(err)
}
// Validate the configuration.
err = projectValidateConfig(s, project.Config)
if err != nil {
return response.BadRequest(err)
}
var id int64
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
id, err = cluster.CreateProject(ctx, tx.Tx(), cluster.Project{Description: project.Description, Name: project.Name})
if err != nil {
return fmt.Errorf("Failed adding database record: %w", err)
}
err = cluster.CreateProjectConfig(ctx, tx.Tx(), id, project.Config)
if err != nil {
return fmt.Errorf("Unable to create project config for project %q: %w", project.Name, err)
}
if util.IsTrue(project.Config["features.profiles"]) {
err = projectCreateDefaultProfile(ctx, tx, project.Name)
if err != nil {
return err
}
if project.Config["features.images"] == "false" {
err = cluster.InitProjectWithoutImages(ctx, tx.Tx(), project.Name)
if err != nil {
return err
}
}
}
return nil
})
if err != nil {
return response.SmartError(fmt.Errorf("Failed creating project %q: %w", project.Name, err))
}
err = s.Authorizer.AddProject(r.Context(), id, project.Name)
if err != nil {
logger.Error("Failed to add project to authorizer", logger.Ctx{"name": project.Name, "error": err})
}
requestor := request.CreateRequestor(r)
lc := lifecycle.ProjectCreated.Event(project.Name, requestor, nil)
s.Events.SendLifecycle(project.Name, lc)
return response.SyncResponseLocation(true, nil, lc.Source)
}
// Create the default profile of a project.
func projectCreateDefaultProfile(ctx context.Context, tx *db.ClusterTx, project string) error {
// Create a default profile
profile := cluster.Profile{}
profile.Project = project
profile.Name = api.ProjectDefaultName
profile.Description = fmt.Sprintf("Default Incus profile for project %s", project)
_, err := cluster.CreateProfile(ctx, tx.Tx(), profile)
if err != nil {
return fmt.Errorf("Add default profile to database: %w", err)
}
return nil
}
// swagger:operation GET /1.0/projects/{name} projects project_get
//
// Get the project
//
// Gets a specific project.
//
// ---
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// responses:
// "200":
// description: Project
// 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/Project"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func projectGet(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
// Get the database entry
var project *api.Project
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
dbProject, err := cluster.GetProject(ctx, tx.Tx(), name)
if err != nil {
return err
}
project, err = dbProject.ToAPI(ctx, tx.Tx())
if err != nil {
return err
}
project.UsedBy, err = projectUsedBy(ctx, tx, dbProject)
return err
})
if err != nil {
return response.SmartError(err)
}
etag := []any{
project.Description,
project.Config,
}
return response.SyncResponseETag(true, project, etag)
}
// swagger:operation PUT /1.0/projects/{name} projects project_put
//
// Update the project
//
// Updates the entire project configuration.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// - in: body
// name: project
// description: Project configuration
// required: true
// schema:
// $ref: "#/definitions/ProjectPut"
// 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 projectPut(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
// Get the current data
var project *api.Project
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
dbProject, err := cluster.GetProject(ctx, tx.Tx(), name)
if err != nil {
return err
}
project, err = dbProject.ToAPI(ctx, tx.Tx())
if err != nil {
return err
}
project.UsedBy, err = projectUsedBy(ctx, tx, dbProject)
if err != nil {
return err
}
return err
})
if err != nil {
return response.SmartError(err)
}
// Validate ETag
etag := []any{
project.Description,
project.Config,
}
err = localUtil.EtagCheck(r, etag)
if err != nil {
return response.PreconditionFailed(err)
}
// Parse the request
req := api.ProjectPut{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(project.Name, lifecycle.ProjectUpdated.Event(project.Name, requestor, nil))
return projectChange(r.Context(), s, project, req)
}
// swagger:operation PATCH /1.0/projects/{name} projects project_patch
//
// Partially update the project
//
// Updates a subset of the project configuration.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// - in: body
// name: project
// description: Project configuration
// required: true
// schema:
// $ref: "#/definitions/ProjectPut"
// 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 projectPatch(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
// Get the current data
var project *api.Project
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
dbProject, err := cluster.GetProject(ctx, tx.Tx(), name)
if err != nil {
return err
}
project, err = dbProject.ToAPI(ctx, tx.Tx())
if err != nil {
return err
}
project.UsedBy, err = projectUsedBy(ctx, tx, dbProject)
if err != nil {
return err
}
return err
})
if err != nil {
return response.SmartError(err)
}
// Validate ETag
etag := []any{
project.Description,
project.Config,
}
err = localUtil.EtagCheck(r, etag)
if err != nil {
return response.PreconditionFailed(err)
}
body, err := io.ReadAll(r.Body)
if err != nil {
return response.InternalError(err)
}
rdr1 := io.NopCloser(bytes.NewBuffer(body))
rdr2 := io.NopCloser(bytes.NewBuffer(body))
reqRaw := jmap.Map{}
err = json.NewDecoder(rdr1).Decode(&reqRaw)
if err != nil {
return response.BadRequest(err)
}
req := api.ProjectPut{}
err = json.NewDecoder(rdr2).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
// Check what was actually set in the query
_, err = reqRaw.GetString("description")
if err != nil {
req.Description = project.Description
}
config, err := reqRaw.GetMap("config")
if err != nil {
req.Config = project.Config
} else {
for k, v := range project.Config {
_, ok := config[k]
if !ok {
config[k] = v
}
}
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(project.Name, lifecycle.ProjectUpdated.Event(project.Name, requestor, nil))
return projectChange(r.Context(), s, project, req)
}
// Common logic between PUT and PATCH.
func projectChange(ctx context.Context, s *state.State, project *api.Project, req api.ProjectPut) response.Response {
// Make a list of config keys that have changed.
configChanged := []string{}
for key := range project.Config {
if req.Config[key] != project.Config[key] {
configChanged = append(configChanged, key)
}
}
for key := range req.Config {
_, ok := project.Config[key]
if !ok {
configChanged = append(configChanged, key)
}
}
// Record which features have been changed.
var featuresChanged []string
for _, configKeyChanged := range configChanged {
_, isFeature := cluster.ProjectFeatures[configKeyChanged]
if isFeature {
featuresChanged = append(featuresChanged, configKeyChanged)
}
}
// Quick checks.
if len(featuresChanged) > 0 {
if project.Name == api.ProjectDefaultName {
return response.BadRequest(errors.New("You can't change the features of the default project"))
}
// Consider the project empty if it is only used by the default profile.
usedByLen := len(project.UsedBy)
projectInUse := usedByLen > 1 || (usedByLen == 1 && !strings.Contains(project.UsedBy[0], "/profiles/default"))
if projectInUse {
// Check if feature is allowed to be changed.
for _, featureChanged := range featuresChanged {
// If feature is currently enabled, and it is being changed in the request, it
// must be being disabled. So prevent it on non-empty projects.
if util.IsTrue(project.Config[featureChanged]) {
return response.BadRequest(fmt.Errorf("Project feature %q cannot be disabled on non-empty projects", featureChanged))
}
// If feature is currently disabled, and it is being changed in the request, it
// must be being enabled. So check if feature can be enabled on non-empty projects.
if util.IsFalse(project.Config[featureChanged]) && !cluster.ProjectFeatures[featureChanged].CanEnableNonEmpty {
return response.BadRequest(fmt.Errorf("Project feature %q cannot be enabled on non-empty projects", featureChanged))
}
}
}
}
// Validate the configuration.
err := projectValidateConfig(s, req.Config)
if err != nil {
return response.BadRequest(err)
}
// Update the database entry.
err = s.DB.Cluster.Transaction(ctx, func(ctx context.Context, tx *db.ClusterTx) error {
err := projecthelpers.AllowProjectUpdate(tx, project.Name, req.Config, configChanged)
if err != nil {
return err
}
err = cluster.UpdateProject(ctx, tx.Tx(), project.Name, req)
if err != nil {
return fmt.Errorf("Persist profile changes: %w", err)
}
if slices.Contains(configChanged, "features.profiles") {
if util.IsTrue(req.Config["features.profiles"]) {
err = projectCreateDefaultProfile(ctx, tx, project.Name)
if err != nil {
return err
}
} else {
// Delete the project-specific default profile.
err = cluster.DeleteProfile(ctx, tx.Tx(), project.Name, api.ProjectDefaultName)
if err != nil {
return fmt.Errorf("Delete project default profile: %w", err)
}
}
}
if slices.Contains(configChanged, "features.images") && util.IsFalse(req.Config["features.images"]) && util.IsTrue(req.Config["features.profiles"]) {
err = cluster.InitProjectWithoutImages(ctx, tx.Tx(), project.Name)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return response.SmartError(err)
}
return response.EmptySyncResponse
}
// swagger:operation POST /1.0/projects/{name} projects project_post
//
// Rename the project
//
// Renames an existing project.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// - in: body
// name: project
// description: Project rename request
// required: true
// schema:
// $ref: "#/definitions/ProjectPost"
// 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 projectPost(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
// Parse the request.
req := api.ProjectPost{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
// Quick checks.
if name == api.ProjectDefaultName {
return response.Forbidden(errors.New("The 'default' project cannot be renamed"))
}
// Perform the rename.
run := func(op *operations.Operation) error {
var id int64
err := s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
project, err := cluster.GetProject(ctx, tx.Tx(), req.Name)
if err != nil && !response.IsNotFoundError(err) {
return fmt.Errorf("Failed checking if project %q exists: %w", req.Name, err)
}
if project != nil {
return fmt.Errorf("A project named %q already exists", req.Name)
}
project, err = cluster.GetProject(ctx, tx.Tx(), name)
if err != nil {
return fmt.Errorf("Failed loading project %q: %w", name, err)
}
empty, err := projectIsEmpty(ctx, project, tx)
if err != nil {
return err
}
if !empty {
return errors.New("Only empty projects can be renamed")
}
id, err = cluster.GetProjectID(ctx, tx.Tx(), name)
if err != nil {
return fmt.Errorf("Failed getting project ID for project %q: %w", name, err)
}
err = validate.IsAPIName(name, false)
if err != nil {
return fmt.Errorf("Invalid project name: %w", err)
}
err = projectValidateName(req.Name)
if err != nil {
return fmt.Errorf("Invalid project name: %w", err)
}
return cluster.RenameProject(ctx, tx.Tx(), name, req.Name)
})
if err != nil {
return err
}
err = s.Authorizer.RenameProject(s.ShutdownCtx, id, name, req.Name)
if err != nil {
logger.Error("Failed to rename project in authorizer", logger.Ctx{"name": name, "new_name": req.Name, "err": err})
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(req.Name, lifecycle.ProjectRenamed.Event(req.Name, requestor, logger.Ctx{"old_name": name}))
return nil
}
op, err := operations.OperationCreate(s, "", operations.OperationClassTask, operationtype.ProjectRename, nil, nil, run, nil, nil, r)
if err != nil {
return response.InternalError(err)
}
return operations.OperationResponse(op)
}
// swagger:operation DELETE /1.0/projects/{name} projects project_delete
//
// Delete the project
//
// Removes the project.
//
// ---
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// - in: query
// name: force
// description: Delete project and related artifacts
// type: boolean
// 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 projectDelete(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
force := util.IsTrue(r.FormValue("force"))
// Quick checks.
if name == api.ProjectDefaultName {
return response.Forbidden(errors.New("The 'default' project cannot be deleted"))
}
var id int64
var projectConfig map[string]string
var usedBy []string
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
project, err := cluster.GetProject(ctx, tx.Tx(), name)
if err != nil {
return fmt.Errorf("Fetch project %q: %w", name, err)
}
if !force {
empty, err := projectIsEmpty(ctx, project, tx)
if err != nil {
return err
}
if !empty {
return errors.New("Only empty projects can be removed.")
}
} else {
usedBy, err = projectUsedBy(ctx, tx, project)
if err != nil {
return err
}
}
id, err = cluster.GetProjectID(ctx, tx.Tx(), name)
if err != nil {
return fmt.Errorf("Fetch project id %q: %w", name, err)
}
projectConfig, err = cluster.GetProjectConfig(ctx, tx.Tx(), int(id))
if err != nil {
return fmt.Errorf("Fetch project config %q: %w", name, err)
}
return nil
})
if err != nil {
return response.SmartError(err)
}
// Handle requests to empty the project.
if force {
// Parse used by list.
defaultProfile := api.NewURL().Path(version.APIVersion, "profiles", api.ProjectDefaultName).Project(name).String()
entries := map[string][]string{}
var count int
for _, u := range usedBy {
// Skip the default profile.
if u == defaultProfile {
continue
}
// Parse the URL.
uri, err := url.Parse(u)
if err != nil {
return response.InternalError(err)
}
elements := strings.Split(uri.Path, "/")
if len(elements) < 4 {
return response.InternalError(fmt.Errorf("Bad usedBy entry: %s", u))
}
if elements[2] == "storage-pools" {
switch elements[4] {
case "buckets":
if entries["storage-buckets"] == nil {
entries["storage-buckets"] = []string{}
}
entry := fmt.Sprintf("%s/%s", elements[3], elements[5])
target := uri.Query().Get("target")
if target != "" {
entry = fmt.Sprintf("%s/%s", entry, target)
}
entries["storage-buckets"] = append(entries["storage-buckets"], entry)
case "volumes":
if entries["storage-volumes"] == nil {
entries["storage-volumes"] = []string{}
}
entry := fmt.Sprintf("%s/%s", elements[3], elements[6])
target := uri.Query().Get("target")
if target != "" {
entry = fmt.Sprintf("%s/%s", entry, target)
}
entries["storage-volumes"] = append(entries["storage-volumes"], entry)
}
} else {
if entries[elements[2]] == nil {
entries[elements[2]] = []string{}
}
entries[elements[2]] = append(entries[elements[2]], elements[3])
}
count++
}
// Connect to the local server.
target, err := incus.ConnectIncusUnix(s.OS.GetUnixSocket(), nil)
if err != nil {
return response.InternalError(err)
}
target = target.UseProject(name)
// Delete instances.
for _, instName := range entries["instances"] {
// Get current instance state.
instState, _, err := target.GetInstance(instName)
if err != nil {
return response.InternalError(err)
}
// If running, force stop it.
if instState.StatusCode != api.Stopped {
req := api.InstanceStatePut{
Action: "stop",
Timeout: -1,
Force: true,
}
op, err := target.UpdateInstanceState(instName, req, "")
if err != nil {
return response.InternalError(err)
}
err = op.Wait()
if err != nil {
return response.InternalError(err)
}
}
// Get the instance configuration.
inst, _, err := target.GetInstance(instName)
if err != nil {
return response.InternalError(err)
}
// Clear security.protection.delete if set.
if util.IsTrue(inst.ExpandedConfig["security.protection.delete"]) {
inst.Config["security.protection.delete"] = "false"
op, err := target.UpdateInstance(instName, inst.Writable(), "")
if err != nil {
return response.InternalError(err)
}
err = op.Wait()
if err != nil {
return response.InternalError(err)
}
}
// Delete the instance.
op, err := target.DeleteInstance(instName)
if err != nil {
return response.InternalError(err)
}
err = op.Wait()
if err != nil {
return response.InternalError(err)
}
// Done deleting the instance.
count--
}
// Delete profiles.
for _, profileName := range entries["profiles"] {
err := target.DeleteProfile(profileName)
if err != nil {
return response.InternalError(err)
}
// Done deleting the profile.
count--
}
// Empty the default profile, if the project owns one.
if util.IsTrue(projectConfig["features.profiles"]) {
err = target.UpdateProfile("default", api.ProfilePut{}, "")
if err != nil {
return response.InternalError(err)
}
}
// Delete images.
for _, imageFingerprint := range entries["images"] {
op, err := target.DeleteImage(imageFingerprint)
if err != nil {
return response.InternalError(err)
}
err = op.Wait()
if err != nil {
return response.InternalError(err)
}
// Done deleting the image.
count--
}
// Delete networks.
for _, networkName := range entries["networks"] {
err := target.DeleteNetwork(networkName)
if err != nil {
return response.InternalError(err)
}
// Done deleting the network.
count--
}
// Delete network ACLs.
for _, networkACLName := range entries["network-acls"] {
err := target.DeleteNetworkACL(networkACLName)
if err != nil {
return response.InternalError(err)
}
// Done deleting the network ACL.
count--
}
// Delete network address sets.
for _, networkAddressSetName := range entries["network-address-sets"] {
err := target.DeleteNetworkAddressSet(networkAddressSetName)
if err != nil {
return response.InternalError(err)
}
// Done deleting the network address set.
count--
}
// Delete network zones.
for _, networkZoneName := range entries["network-zones"] {
err := target.DeleteNetworkZone(networkZoneName)
if err != nil {
return response.InternalError(err)
}
// Done deleting the network zone.
count--
}
// Delete storage volumes.
for _, volume := range entries["storage-volumes"] {
fields := strings.Split(volume, "/")
if len(fields) == 3 {
target = target.UseTarget(fields[2])
}
err := target.DeleteStoragePoolVolume(fields[0], "custom", fields[1])
if err != nil {
return response.InternalError(err)
}
// Done deleting the storage volume.
count--
}
// Delete storage buckets.
for _, volume := range entries["storage-buckets"] {
fields := strings.Split(volume, "/")
if len(fields) == 3 {
target = target.UseTarget(fields[2])
}
err := target.DeleteStoragePoolBucket(fields[0], fields[1])
if err != nil {
return response.InternalError(err)
}
// Done deleting the storage volume.
count--
}
// Check if anything is left.
if count != 0 {
return response.BadRequest(errors.New("Project couldn't be automatically emptied"))
}
}
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
return cluster.DeleteProject(ctx, tx.Tx(), name)
})
if err != nil {
return response.SmartError(err)
}
err = s.Authorizer.DeleteProject(r.Context(), id, name)
if err != nil {
logger.Error("Failed to remove project from authorizer", logger.Ctx{"name": name, "err": err})
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(name, lifecycle.ProjectDeleted.Event(name, requestor, nil))
return response.EmptySyncResponse
}
// swagger:operation GET /1.0/projects/{name}/state projects project_state_get
//
// Get the project state
//
// Gets a specific project resource consumption information.
//
// ---
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// responses:
// "200":
// description: Project 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/ProjectState"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func projectStateGet(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
// Setup the state struct.
projectState := api.ProjectState{}
// Get current limits and usage.
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
result, err := projecthelpers.GetCurrentAllocations(ctx, tx, name)
if err != nil {
return err
}
projectState.Resources = result
return nil
})
if err != nil {
return response.SmartError(err)
}
return response.SyncResponse(true, &projectState)
}
// Check if a project is empty.
func projectIsEmpty(ctx context.Context, project *cluster.Project, tx *db.ClusterTx) (bool, error) {
usedBy, err := projectUsedBy(ctx, tx, project)
if err != nil {
return false, err
}
defaultProfile := api.NewURL().Path(version.APIVersion, "profiles", api.ProjectDefaultName).Project(project.Name).String()
for _, entry := range usedBy {
// Ignore the default profile.
if entry == defaultProfile {
continue
}
return false, nil
}
return true, nil
}
func isEitherAllowOrBlock(value string) error {
return validate.Optional(validate.IsOneOf("block", "allow"))(value)
}
func isEitherAllowOrBlockOrManaged(value string) error {
return validate.Optional(validate.IsOneOf("block", "allow", "managed"))(value)
}
func projectValidateConfig(s *state.State, config map[string]string) error {
// Validate the project configuration.
projectConfigKeys := map[string]func(value string) error{
// gendoc:generate(entity=project, group=specific, key=backups.compression_algorithm)
// Specify which compression algorithm to use for backups in this project.
// Possible values are `bzip2`, `gzip`, `lz4`, `lzma`, `xz`, `zstd` or `none`.
// ---
// type: string
// shortdesc: Compression algorithm to use for backups
"backups.compression_algorithm": validate.IsCompressionAlgorithm,
// gendoc:generate(entity=project, group=features, key=features.profiles)
//
// ---
// type: bool
// defaultdesc: `false`
// initialvaluedesc: `true`
// shortdesc: Whether to use a separate set of profiles for the project
"features.profiles": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=features, key=features.images)
// This setting applies to both images and image aliases.
// ---
// type: bool
// defaultdesc: `false`
// initialvaluedesc: `true`
// shortdesc: Whether to use a separate set of images for the project
"features.images": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=features, key=features.storage.volumes)
//
// ---
// type: bool
// defaultdesc: `false`
// initialvaluedesc: `true`
// shortdesc: Whether to use a separate set of storage volumes for the project
"features.storage.volumes": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=features, key=features.storage.buckets)
//
// ---
// type: bool
// defaultdesc: `false`
// initialvaluedesc: `true`
// shortdesc: Whether to use a separate set of storage buckets for the project
"features.storage.buckets": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=features, key=features.networks)
//
// ---
// type: bool
// defaultdesc: `false`
// initialvaluedesc: `false`
// shortdesc: Whether to use a separate set of networks for the project
"features.networks": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=features, key=features.networks.zones)
//
// ---
// type: bool
// defaultdesc: `false`
// initialvaluedesc: `false`
// shortdesc: Whether to use a separate set of network zones for the project
"features.networks.zones": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=specific, key=images.auto_update_cached)
//
// ---
// type: bool
// shortdesc: Whether to automatically update cached images in the project
"images.auto_update_cached": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=specific, key=images.auto_update_interval)
// Specify the interval in hours.
// To disable looking for updates to cached images, set this option to `0`.
// ---
// type: integer
// shortdesc: Interval at which to look for updates to cached images
"images.auto_update_interval": validate.Optional(validate.IsInt64),
// gendoc:generate(entity=project, group=specific, key=images.compression_algorithm)
// Possible values are `bzip2`, `gzip`, `lz4`, `lzma`, `xz`, `zstd` or `none`.
// ---
// type: string
// shortdesc: Compression algorithm to use for new images in the project
"images.compression_algorithm": validate.IsCompressionAlgorithm,
// gendoc:generate(entity=project, group=specific, key=images.default_architecture)
//
// ---
// type: string
// shortdesc: Default architecture to use in a mixed-architecture cluster
"images.default_architecture": validate.Optional(validate.IsArchitecture),
// gendoc:generate(entity=project, group=specific, key=images.remote_cache_expiry)
// Specify the number of days after which the unused cached image expires.
// ---
// type: integer
// shortdesc: When an unused cached remote image is flushed in the project
"images.remote_cache_expiry": validate.Optional(validate.IsInt64),
// gendoc:generate(entity=project, group=limits, key=limits.instances)
//
// ---
// type: integer
// shortdesc: Maximum number of instances that can be created in the project
"limits.instances": validate.Optional(validate.IsUint32),
// gendoc:generate(entity=project, group=limits, key=limits.containers)
//
// ---
// type: integer
// shortdesc: Maximum number of containers that can be created in the project
"limits.containers": validate.Optional(validate.IsUint32),
// gendoc:generate(entity=project, group=limits, key=limits.virtual-machines)
//
// ---
// type: integer
// shortdesc: Maximum number of VMs that can be created in the project
"limits.virtual-machines": validate.Optional(validate.IsUint32),
// gendoc:generate(entity=project, group=limits, key=limits.memory)
// The value is the maximum value for the sum of the individual {config:option}`instance-resource-limits:limits.memory` configurations set on the instances of the project.
// ---
// type: string
// shortdesc: Usage limit for the host's memory for the project
"limits.memory": validate.Optional(validate.IsSize),
// gendoc:generate(entity=project, group=limits, key=limits.processes)
// This value is the maximum value for the sum of the individual {config:option}`instance-resource-limits:limits.processes` configurations set on the instances of the project.
// ---
// type: integer
// shortdesc: Maximum number of processes within the project
"limits.processes": validate.Optional(validate.IsUint32),
// gendoc:generate(entity=project, group=limits, key=limits.cpu)
// This value is the maximum value for the sum of the individual {config:option}`instance-resource-limits:limits.cpu` configurations set on the instances of the project.
// ---
// type: integer
// shortdesc: Maximum number of CPUs to use in the project
"limits.cpu": validate.Optional(validate.IsUint32),
// gendoc:generate(entity=project, group=limits, key=limits.disk)
// This value is the maximum value of the aggregate disk space used by all instance volumes, custom volumes, and images of the project.
// ---
// type: string
// shortdesc: Maximum disk space used by the project
"limits.disk": validate.Optional(validate.IsSize),
// gendoc:generate(entity=project, group=limits, key=limits.networks)
//
// ---
// type: integer
// shortdesc: Maximum number of networks that the project can have
"limits.networks": validate.Optional(validate.IsUint32),
// gendoc:generate(entity=project, group=specific, key=network.hwaddr_pattern)
// Specify a MAC address template, e.g. `10:66:6a:xx:xx:xx`, to use within the cluster.
// Every `x` in the template will be replaced by a random character in `0``f`.
// Beware of the birthday paradox! A single `xx` block leads to a 10% collision probability with only 8 addresses; for a double `xx:xx` block, 118 addresses; for a triple `xx:xx:xx` block, 1881; for a quadruple `xx:xx:xx:xx` block, 30084. We provide absolutely no guardrail against that.
// ---
// type: string
// scope: global
// shortdesc: MAC address template
"network.hwaddr_pattern": validate.Optional(validate.IsMACPattern),
// gendoc:generate(entity=project, group=restricted, key=restricted)
// This option must be enabled to allow the `restricted.*` keys to take effect.
// To temporarily remove the restrictions, you can disable this option instead of clearing the related keys.
// ---
// type: bool
// defaultdesc: `false`
// shortdesc: Whether to block access to security-sensitive features
"restricted": validate.Optional(validate.IsBool),
// gendoc:generate(entity=project, group=restricted, key=restricted.backups)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent creating instance or volume backups
"restricted.backups": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.cluster.groups)
// If specified, this option prevents targeting cluster groups other than the provided ones.
// ---
// type: string
// shortdesc: Cluster groups that can be targeted
"restricted.cluster.groups": validate.Optional(func(value string) error {
// Basic format validation.
err := validate.IsListOf(validate.IsAny)(value)
if err != nil {
return err
}
// Get all valid groups.
groupNames := []string{}
err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
clusterGroups, err := cluster.GetClusterGroups(ctx, tx.Tx())
if err != nil {
return err
}
for _, group := range clusterGroups {
groupNames = append(groupNames, group.Name)
}
return nil
})
if err != nil {
return err
}
// Confirm that the group names exist.
for _, name := range util.SplitNTrimSpace(value, ",", -1, true) {
if !slices.Contains(groupNames, name) {
return fmt.Errorf("Cluster group %q doesn't exist", name)
}
}
return nil
}),
// gendoc:generate(entity=project, group=restricted, key=restricted.cluster.target)
// Possible values are `allow` or `block`.
// When set to `allow`, this option allows targeting of cluster members (either directly or via a group) when creating or moving instances.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent targeting of cluster members
"restricted.cluster.target": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.containers.interception)
// Possible values are `allow`, `block`, or `full`.
// When set to `allow`, interception options that are usually safe are allowed.
// File system mounting remains blocked.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using system call interception options
"restricted.containers.interception": validate.Optional(validate.IsOneOf("allow", "block", "full")),
// gendoc:generate(entity=project, group=restricted, key=restricted.containers.nesting)
// Possible values are `allow` or `block`.
// When set to `allow`, {config:option}`instance-security:security.nesting` can be set to `true` for an instance.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent running nested Incus
"restricted.containers.nesting": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.containers.lowlevel)
// Possible values are `allow` or `block`.
// When set to `allow`, low-level container options like {config:option}`instance-raw:raw.lxc`, {config:option}`instance-raw:raw.idmap`, `volatile.*`, etc. can be used.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using low-level container options
"restricted.containers.lowlevel": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.containers.privilege)
// Possible values are `unprivileged`, `isolated`, and `allow`.
//
// - When set to `unprivileged`, this option prevents setting {config:option}`instance-security:security.privileged` to `true`.
// - When set to `isolated`, this option prevents setting {config:option}`instance-security:security.privileged` to `true` and {config:option}`instance-security:security.idmap.isolated` to `false`.
// - When set to `allow`, there is no restriction.
// ---
// type: string
// defaultdesc: `unprivileged`
// shortdesc: Which settings for privileged containers to prevent
"restricted.containers.privilege": validate.Optional(validate.IsOneOf("allow", "unprivileged", "isolated")),
// gendoc:generate(entity=project, group=restricted, key=restricted.virtual-machines.lowlevel)
// Possible values are `allow` or `block`.
// When set to `allow`, low-level VM options like {config:option}`instance-raw:raw.qemu`, `volatile.*`, etc. can be used.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using low-level VM options
"restricted.virtual-machines.lowlevel": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.virtual-machines.nesting)
// Possible values are `allow` or `block`.
// When set to `block`, {config:option}`instance-security:security.nesting` must be set to `false` on all virtual machines, turning off nested virtualization.
// ---
// type: string
// defaultdesc: `allow`
// shortdesc: Whether to prevent using nested virtualization
"restricted.virtual-machines.nesting": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.unix-char)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `unix-char`
"restricted.devices.unix-char": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.unix-block)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `unix-block`
"restricted.devices.unix-block": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.unix-hotplug)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `unix-hotplug`
"restricted.devices.unix-hotplug": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.infiniband)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `infiniband`
"restricted.devices.infiniband": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.gpu)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `gpu`
"restricted.devices.gpu": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.usb)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `usb`
"restricted.devices.usb": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.pci)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `pci`
"restricted.devices.pci": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.proxy)
// Possible values are `allow` or `block`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent using devices of type `proxy`
"restricted.devices.proxy": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.nic)
// Possible values are `allow`, `block`, or `managed`.
//
// - When set to `block`, this option prevents using all network devices.
// - When set to `managed`, this option allows using network devices only if `network=` is set.
// - When set to `allow`, there is no restriction on which network devices can be used.
// ---
// type: string
// defaultdesc: `managed`
// shortdesc: Which network devices can be used
"restricted.devices.nic": isEitherAllowOrBlockOrManaged,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.disk)
// Possible values are `allow`, `block`, or `managed`.
//
// - When set to `block`, this option prevents using all disk devices except the root one.
// - When set to `managed`, this option allows using disk devices only if `pool=` is set.
// - When set to `allow`, there is no restriction on which disk devices can be used.
// ---
// type: string
// defaultdesc: `managed`
// shortdesc: Which disk devices can be used
"restricted.devices.disk": isEitherAllowOrBlockOrManaged,
// gendoc:generate(entity=project, group=restricted, key=restricted.devices.disk.paths)
// If {config:option}`project-restricted:restricted.devices.disk` is set to `allow`, this option controls which `source` can be used for `disk` devices.
// Specify a comma-separated list of path prefixes that restrict the `source` setting.
// If this option is left empty, all paths are allowed.
// ---
// type: string
// shortdesc: Which `source` can be used for `disk` devices
"restricted.devices.disk.paths": validate.Optional(validate.IsListOf(validate.IsAbsFilePath)),
// gendoc:generate(entity=project, group=restricted, key=restricted.idmap.uid)
// This option specifies the host UID ranges that are allowed in the instance's {config:option}`instance-raw:raw.idmap` setting.
// ---
// type: string
// shortdesc: Which host UID ranges are allowed in `raw.idmap`
"restricted.idmap.uid": validate.Optional(validate.IsListOf(validate.IsUint32Range)),
// gendoc:generate(entity=project, group=restricted, key=restricted.idmap.gid)
// This option specifies the host GID ranges that are allowed in the instance's {config:option}`instance-raw:raw.idmap` setting.
// ---
// type: string
// shortdesc: Which host GID ranges are allowed in `raw.idmap`
"restricted.idmap.gid": validate.Optional(validate.IsListOf(validate.IsUint32Range)),
// gendoc:generate(entity=project, group=restricted, key=restricted.images.servers)
// Specify a comma-delimited list of image servers domains that are allowed for use in this project.
// If this option is not set, all image servers are accessible.
// ---
// type: string
// shortdesc: Which image servers (HTTP host) are allowed for us in this project
"restricted.images.servers": validate.Optional(validate.IsListOf(validate.IsAny)),
// gendoc:generate(entity=project, group=restricted, key=restricted.networks.access)
// Specify a comma-delimited list of network names that are allowed for use in this project.
// If this option is not set, all networks are accessible.
//
// Note that this setting depends on the {config:option}`project-restricted:restricted.devices.nic` setting.
// ---
// type: string
// shortdesc: Which network names are allowed for use in this project
"restricted.networks.access": validate.Optional(validate.IsListOf(validate.IsAny)),
// gendoc:generate(entity=project, group=restricted, key=restricted.networks.integrations)
// Specify a comma-delimited list of network integrations that can be used by networks in this project.
// ---
// type: string
// shortdesc: Which network integrations can be used in this project
"restricted.networks.integrations": validate.IsListOf(validate.IsAny),
// gendoc:generate(entity=project, group=restricted, key=restricted.networks.uplinks)
// Specify a comma-delimited list of network names that can be used as uplink for networks in this project.
// ---
// type: string
// shortdesc: Which network names can be used as uplink in this project
"restricted.networks.uplinks": validate.Optional(validate.IsListOf(validate.IsAny)),
// gendoc:generate(entity=project, group=restricted, key=restricted.networks.subnets)
// Specify a comma-delimited list of network subnets from the uplink networks that are allocated for use in this project.
// Use the form `<uplink>:<subnet>`.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Which network subnets are allocated for use in this project
"restricted.networks.subnets": validate.Optional(func(value string) error {
return projectValidateRestrictedSubnets(s, value)
}),
// gendoc:generate(entity=project, group=restricted, key=restricted.networks.zones)
// Specify a comma-delimited list of network zones that can be used (or something under them) in this project.
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Which network zones can be used in this project
"restricted.networks.zones": validate.IsListOf(validate.IsAny),
// gendoc:generate(entity=project, group=restricted, key=restricted.snapshots)
//
// ---
// type: string
// defaultdesc: `block`
// shortdesc: Whether to prevent creating instance or volume snapshots
"restricted.snapshots": isEitherAllowOrBlock,
// gendoc:generate(entity=project, group=restricted, key=restricted.storage-pools.access)
// Specify a comma-delimited list of storage pool names that are allowed for use in this project.
// If this option is not set, all storage pools are accessible.
// ---
// type: string
// shortdesc: Which storage pool names are allowed for use in this project
"restricted.storage-pools.access": validate.Optional(validate.IsListOf(validate.IsAny)),
}
// Add the storage pool keys.
err := s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
var err error
// Load all the pools.
pools, err := tx.GetStoragePoolNames(ctx)
if err != nil {
return err
}
// Add the storage-pool specific config keys.
for _, poolName := range pools {
// gendoc:generate(entity=project, group=limits, key=limits.disk.pool.POOL_NAME)
// This value is the maximum value of the aggregate disk
// space used by all instance volumes, custom volumes, and images of the
// project on this specific storage pool.
// ---
// type: string
// shortdesc: Maximum disk space used by the project on this pool
projectConfigKeys[fmt.Sprintf("limits.disk.pool.%s", poolName)] = validate.Optional(validate.IsSize)
}
return nil
})
if err != nil && !api.StatusErrorCheck(err, http.StatusNotFound) {
return fmt.Errorf("Failed loading storage pool names: %w", err)
}
for k, v := range config {
key := k
// User keys are free for all.
// gendoc:generate(entity=project, group=specific, key=user.*)
//
// ---
// type: string
// shortdesc: User-provided free-form key/value pairs
if strings.HasPrefix(key, "user.") {
continue
}
// Then validate.
validator, ok := projectConfigKeys[key]
if !ok {
return fmt.Errorf("Invalid project configuration key %q", k)
}
err := validator(v)
if err != nil {
return fmt.Errorf("Invalid project configuration key %q value: %w", k, err)
}
}
// Ensure that restricted projects have their own profiles. Otherwise restrictions in this project could
// be bypassed by settings from the default project's profiles that are not checked against this project's
// restrictions when they are configured.
if util.IsTrue(config["restricted"]) && util.IsFalse(config["features.profiles"]) {
return errors.New("Projects without their own profiles cannot be restricted")
}
return nil
}
func projectValidateName(name string) error {
if strings.Contains(name, "_") {
return errors.New("Project names may not contain underscores")
}
if strings.Contains(name, "'") || strings.Contains(name, `"`) {
return errors.New("Project names may not contain quotes")
}
if name == "*" {
return errors.New("Reserved project name")
}
if slices.Contains([]string{".", ".."}, name) {
return fmt.Errorf("Invalid project name %q", name)
}
return nil
}
// projectValidateRestrictedSubnets checks that the project's restricted.networks.subnets are properly formatted
// and are within the specified uplink network's routes.
func projectValidateRestrictedSubnets(s *state.State, value string) error {
for _, subnetRaw := range util.SplitNTrimSpace(value, ",", -1, false) {
subnetParts := strings.SplitN(subnetRaw, ":", 2)
if len(subnetParts) != 2 {
return fmt.Errorf(`Subnet %q invalid, must be in the format of "<uplink network>:<subnet>"`, subnetRaw)
}
uplinkName := subnetParts[0]
subnetStr := subnetParts[1]
restrictedSubnetIP, restrictedSubnet, err := net.ParseCIDR(subnetStr)
if err != nil {
return err
}
if restrictedSubnetIP.String() != restrictedSubnet.IP.String() {
return fmt.Errorf("Not an IP network address %q", subnetStr)
}
var uplink *api.Network
err = s.DB.Cluster.Transaction(context.TODO(), func(ctx context.Context, tx *db.ClusterTx) error {
// Check uplink exists and load config to compare subnets.
_, uplink, _, err = tx.GetNetworkInAnyState(ctx, api.ProjectDefaultName, uplinkName)
return err
})
if err != nil {
return fmt.Errorf("Invalid uplink network %q: %w", uplinkName, err)
}
// Parse uplink route subnets.
var uplinkRoutes []*net.IPNet
for _, k := range []string{"ipv4.routes", "ipv6.routes"} {
if uplink.Config[k] == "" {
continue
}
uplinkRoutes, err = network.SubnetParseAppend(uplinkRoutes, util.SplitNTrimSpace(uplink.Config[k], ",", -1, false)...)
if err != nil {
return err
}
}
foundMatch := false
// Check that the restricted subnet is within one of the uplink's routes.
for _, uplinkRoute := range uplinkRoutes {
if network.SubnetContains(uplinkRoute, restrictedSubnet) {
foundMatch = true
break
}
}
if !foundMatch {
return fmt.Errorf("Uplink network %q doesn't contain %q in its routes", uplinkName, restrictedSubnet.String())
}
}
return nil
}
// swagger:operation GET /1.0/projects/{name}/access projects project_access
//
// Get who has access to a project
//
// Gets the access information for the project.
//
// ---
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Project name
// type: string
// required: true
// responses:
// "200":
// description: Access
// 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/Access"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func projectAccess(d *Daemon, r *http.Request) response.Response {
s := d.State()
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
// Quick checks.
err = validate.IsAPIName(name, false)
if err != nil {
return response.BadRequest(fmt.Errorf("Invalid project name: %w", err))
}
err = projectValidateName(name)
if err != nil {
return response.BadRequest(err)
}
// get the access struct
access, err := s.Authorizer.GetProjectAccess(context.TODO(), name)
if err != nil {
return response.InternalError(err)
}
return response.SyncResponse(true, access)
}