mirror of
https://github.com/lxc/incus
synced 2026-08-02 05:26:46 +00:00
2132 lines
63 KiB
Go
2132 lines
63 KiB
Go
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)
|
||
}
|