incus-mirror/cmd/incusd/profiles.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

979 lines
25 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
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/cluster"
"github.com/lxc/incus/v7/internal/server/db"
dbCluster "github.com/lxc/incus/v7/internal/server/db/cluster"
deviceConfig "github.com/lxc/incus/v7/internal/server/device/config"
"github.com/lxc/incus/v7/internal/server/instance"
"github.com/lxc/incus/v7/internal/server/instance/instancetype"
"github.com/lxc/incus/v7/internal/server/lifecycle"
"github.com/lxc/incus/v7/internal/server/project"
"github.com/lxc/incus/v7/internal/server/request"
"github.com/lxc/incus/v7/internal/server/response"
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 profilesCmd = APIEndpoint{
Path: "profiles",
Get: APIEndpointAction{Handler: profilesGet, AccessHandler: allowAuthenticated},
Post: APIEndpointAction{Handler: profilesPost, AccessHandler: allowPermission(auth.ObjectTypeProject, auth.EntitlementCanCreateProfiles)},
}
var profileCmd = APIEndpoint{
Path: "profiles/{name}",
Delete: APIEndpointAction{Handler: profileDelete, AccessHandler: allowPermission(auth.ObjectTypeProfile, auth.EntitlementCanEdit, "name")},
Get: APIEndpointAction{Handler: profileGet, AccessHandler: allowPermission(auth.ObjectTypeProfile, auth.EntitlementCanView, "name")},
Patch: APIEndpointAction{Handler: profilePatch, AccessHandler: allowPermission(auth.ObjectTypeProfile, auth.EntitlementCanEdit, "name")},
Post: APIEndpointAction{Handler: profilePost, AccessHandler: allowPermission(auth.ObjectTypeProfile, auth.EntitlementCanEdit, "name")},
Put: APIEndpointAction{Handler: profilePut, AccessHandler: allowPermission(auth.ObjectTypeProfile, auth.EntitlementCanEdit, "name")},
}
// swagger:operation GET /1.0/profiles profiles profiles_get
//
// Get the profiles
//
// Returns a list of profiles (URLs).
//
// ---
// produces:
// - application/json
// parameters:
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// - in: query
// name: all-projects
// description: Retrieve profiles from all projects
// type: boolean
// example: true
// - 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/profiles/default",
// "/1.0/profiles/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/profiles?recursion=1 profiles profiles_get_recursion1
//
// Get the profiles
//
// Returns a list of profiles (structs).
//
// ---
// produces:
// - application/json
// parameters:
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// - in: query
// name: all-projects
// description: Retrieve profiles from all projects
// type: boolean
// example: true
// - 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 profiles
// items:
// $ref: "#/definitions/Profile"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func profilesGet(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
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))
}
mustLoadObjects := recursion || (clauses != nil && len(clauses.Clauses) > 0)
allProjects := util.IsTrue(request.QueryParam(r, "all-projects"))
userHasPermission, err := s.Authorizer.GetPermissionChecker(r.Context(), r, auth.EntitlementCanView, auth.ObjectTypeProfile)
if err != nil {
return response.InternalError(err)
}
fullResults := make([]api.Profile, 0)
linkResults := make([]string, 0)
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
var profiles []dbCluster.Profile
if !allProjects {
filter := dbCluster.ProfileFilter{
Project: &p.Name,
}
profiles, err = dbCluster.GetProfiles(ctx, tx.Tx(), filter)
if err != nil {
return err
}
} else {
profiles, err = dbCluster.GetProfiles(ctx, tx.Tx())
if err != nil {
return err
}
}
if mustLoadObjects {
profileConfigs, err := dbCluster.GetAllProfileConfigs(ctx, tx.Tx())
if err != nil {
return err
}
profileDevices, err := dbCluster.GetAllProfileDevices(ctx, tx.Tx())
if err != nil {
return err
}
for _, profile := range profiles {
if !userHasPermission(auth.ObjectProfile(p.Name, profile.Name)) {
continue
}
apiProfile, err := profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices)
if err != nil {
return err
}
apiProfile.UsedBy, err = profileUsedBy(ctx, tx, profile)
if err != nil {
return err
}
apiProfile.UsedBy = project.FilterUsedBy(s.Authorizer, r, apiProfile.UsedBy)
if clauses != nil && len(clauses.Clauses) > 0 {
match, err := filter.Match(*apiProfile, *clauses)
if err != nil {
return err
}
if !match {
continue
}
}
fullResults = append(fullResults, *apiProfile)
linkResults = append(linkResults, apiProfile.URL(version.APIVersion, profile.Project).String())
}
} else {
for _, profile := range profiles {
if !userHasPermission(auth.ObjectProfile(p.Name, profile.Name)) {
continue
}
apiProfile := api.Profile{Name: profile.Name}
linkResults = append(linkResults, apiProfile.URL(version.APIVersion, profile.Project).String())
}
}
return err
})
if err != nil {
return response.SmartError(err)
}
if recursion {
return response.SyncResponse(true, fullResults)
}
return response.SyncResponse(true, linkResults)
}
// profileUsedBy returns all the instance URLs that are using the given profile.
func profileUsedBy(ctx context.Context, tx *db.ClusterTx, profile dbCluster.Profile) ([]string, error) {
instances, err := dbCluster.GetProfileInstances(ctx, tx.Tx(), profile.ID)
if err != nil {
return nil, err
}
usedBy := make([]string, len(instances))
for i, inst := range instances {
apiInst := &api.Instance{Name: inst.Name}
usedBy[i] = apiInst.URL(version.APIVersion, inst.Project).String()
}
return usedBy, nil
}
// swagger:operation POST /1.0/profiles profiles profiles_post
//
// Add a profile
//
// Creates a new profile.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// - in: body
// name: profile
// description: Profile
// required: true
// schema:
// $ref: "#/definitions/ProfilesPost"
// 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 profilesPost(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
req := api.ProfilesPost{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
// Quick checks.
if req.Name == "" {
return response.BadRequest(errors.New("No name provided"))
}
err = validate.IsAPIName(req.Name, false)
if err != nil {
return response.BadRequest(fmt.Errorf("Invalid profile name: %w", err))
}
err = instance.ValidConfig(d.os, req.Config, false, instancetype.Any)
if err != nil {
return response.BadRequest(err)
}
// At this point we don't know the instance type, so just use instancetype.Any type for validation.
err = instance.ValidDevices(s, *p, instancetype.Any, deviceConfig.NewDevices(req.Devices), nil)
if err != nil {
return response.BadRequest(err)
}
// Update DB entry.
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
devices, err := dbCluster.APIToDevices(req.Devices)
if err != nil {
return err
}
current, _ := dbCluster.GetProfile(ctx, tx.Tx(), p.Name, req.Name)
if current != nil {
return errors.New("The profile already exists")
}
profile := dbCluster.Profile{
Project: p.Name,
Name: req.Name,
Description: req.Description,
}
id, err := dbCluster.CreateProfile(ctx, tx.Tx(), profile)
if err != nil {
return err
}
err = dbCluster.CreateProfileConfig(ctx, tx.Tx(), id, req.Config)
if err != nil {
return err
}
err = dbCluster.CreateProfileDevices(ctx, tx.Tx(), id, devices)
if err != nil {
return err
}
return err
})
if err != nil {
return response.SmartError(fmt.Errorf("Error inserting %q into database: %w", req.Name, err))
}
err = s.Authorizer.AddProfile(r.Context(), p.Name, req.Name)
if err != nil {
logger.Error("Failed to add profile to authorizer", logger.Ctx{"name": req.Name, "project": p.Name, "error": err})
}
requestor := request.CreateRequestor(r)
lc := lifecycle.ProfileCreated.Event(req.Name, p.Name, requestor, nil)
s.Events.SendLifecycle(p.Name, lc)
return response.SyncResponseLocation(true, nil, lc.Source)
}
// swagger:operation GET /1.0/profiles/{name} profiles profile_get
//
// Get the profile
//
// Gets a specific profile.
//
// ---
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Profile name
// type: string
// required: true
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// responses:
// "200":
// description: Profile
// 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/Profile"
// "400":
// $ref: "#/responses/BadRequest"
// "403":
// $ref: "#/responses/Forbidden"
// "404":
// $ref: "#/responses/NotFound"
// "409":
// $ref: "#/responses/Conflict"
// "500":
// $ref: "#/responses/InternalServerError"
func profileGet(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
var resp *api.Profile
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
profile, err := dbCluster.GetProfile(ctx, tx.Tx(), p.Name, name)
if err != nil {
return fmt.Errorf("Fetch profile: %w", err)
}
profileConfigs, err := dbCluster.GetAllProfileConfigs(ctx, tx.Tx())
if err != nil {
return err
}
profileDevices, err := dbCluster.GetAllProfileDevices(ctx, tx.Tx())
if err != nil {
return err
}
resp, err = profile.ToAPI(ctx, tx.Tx(), profileConfigs, profileDevices)
if err != nil {
return err
}
resp.UsedBy, err = profileUsedBy(ctx, tx, *profile)
if err != nil {
return err
}
resp.UsedBy = project.FilterUsedBy(s.Authorizer, r, resp.UsedBy)
return nil
})
if err != nil {
return response.SmartError(err)
}
etag := []any{resp.Config, resp.Description, resp.Devices}
return response.SyncResponseETag(true, resp, etag)
}
// swagger:operation PUT /1.0/profiles/{name} profiles profile_put
//
// Update the profile
//
// Updates the entire profile configuration.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Profile name
// type: string
// required: true
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// - in: body
// name: profile
// description: Profile configuration
// required: true
// schema:
// $ref: "#/definitions/ProfilePut"
// 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 profilePut(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
if isClusterNotification(r) {
// In this case the ProfilePut request payload contains information about the old profile, since
// the new one has already been saved in the database.
old := api.ProfilePut{}
err := json.NewDecoder(r.Body).Decode(&old)
if err != nil {
return response.BadRequest(err)
}
err = doProfileUpdateCluster(r.Context(), s, p.Name, name, old)
return response.SmartError(err)
}
var profile *api.Profile
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
current, err := dbCluster.GetProfile(ctx, tx.Tx(), p.Name, name)
if err != nil {
return fmt.Errorf("Failed to retrieve profile %q: %w", name, err)
}
profile, err = current.ToAPI(ctx, tx.Tx(), nil, nil)
if err != nil {
return err
}
return nil
})
if err != nil {
return response.SmartError(err)
}
// Validate the ETag.
etag := []any{profile.Config, profile.Description, profile.Devices}
err = localUtil.EtagCheck(r, etag)
if err != nil {
return response.PreconditionFailed(err)
}
req := api.ProfilePut{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
err = doProfileUpdate(r.Context(), s, *p, name, profile, req)
if err == nil && !isClusterNotification(r) {
// Notify all other nodes. If a node is down, it will be ignored.
notifier, err := cluster.NewNotifier(s, s.Endpoints.NetworkCert(), s.ServerCert(), cluster.NotifyAlive)
if err != nil {
return response.SmartError(err)
}
err = notifier(func(client incus.InstanceServer) error {
return client.UseProject(p.Name).UpdateProfile(name, profile.ProfilePut, "")
})
if err != nil {
return response.SmartError(err)
}
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(p.Name, lifecycle.ProfileUpdated.Event(name, p.Name, requestor, nil))
return response.SmartError(err)
}
// swagger:operation PATCH /1.0/profiles/{name} profiles profile_patch
//
// Partially update the profile
//
// Updates a subset of the profile configuration.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Profile name
// type: string
// required: true
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// - in: body
// name: profile
// description: Profile configuration
// required: true
// schema:
// $ref: "#/definitions/ProfilePut"
// 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 profilePatch(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
var profile *api.Profile
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
current, err := dbCluster.GetProfile(ctx, tx.Tx(), p.Name, name)
if err != nil {
return fmt.Errorf("Failed to retrieve profile=%q: %w", name, err)
}
profile, err = current.ToAPI(ctx, tx.Tx(), nil, nil)
if err != nil {
return err
}
return nil
})
if err != nil {
return response.SmartError(err)
}
// Validate the ETag.
etag := []any{profile.Config, profile.Description, profile.Devices}
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.ProfilePut{}
err = json.NewDecoder(rdr2).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
// Get Description.
_, err = reqRaw.GetString("description")
if err != nil {
req.Description = profile.Description
}
// Get Config.
if req.Config == nil {
req.Config = profile.Config
} else {
for k, v := range profile.Config {
_, ok := req.Config[k]
if !ok {
req.Config[k] = v
}
}
}
// Get Devices.
if req.Devices == nil {
req.Devices = profile.Devices
} else {
for k, v := range profile.Devices {
_, ok := req.Devices[k]
if !ok {
req.Devices[k] = v
}
}
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(p.Name, lifecycle.ProfileUpdated.Event(name, p.Name, requestor, nil))
return response.SmartError(doProfileUpdate(r.Context(), s, *p, name, profile, req))
}
// swagger:operation POST /1.0/profiles/{name} profiles profile_post
//
// Rename the profile
//
// Renames an existing profile.
//
// ---
// consumes:
// - application/json
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Profile name
// type: string
// required: true
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// - in: body
// name: profile
// description: Profile rename request
// required: true
// schema:
// $ref: "#/definitions/ProfilePost"
// 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 profilePost(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
if name == "default" {
return response.Forbidden(errors.New(`The "default" profile cannot be renamed`))
}
req := api.ProfilePost{}
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return response.BadRequest(err)
}
// Quick checks.
if req.Name == "" {
return response.BadRequest(errors.New("No name provided"))
}
err = validate.IsAPIName(req.Name, false)
if err != nil {
return response.BadRequest(fmt.Errorf("Invalid profile name: %w", err))
}
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
// Check that the profile exists.
_, err = dbCluster.GetProfile(ctx, tx.Tx(), p.Name, name)
if err != nil {
return fmt.Errorf("Profile %q doesn't exist", name)
}
// Check that the name isn't already in use.
_, err = dbCluster.GetProfile(ctx, tx.Tx(), p.Name, req.Name)
if err == nil {
return fmt.Errorf("Profile %q already exists", req.Name)
}
return dbCluster.RenameProfile(ctx, tx.Tx(), p.Name, name, req.Name)
})
if err != nil {
return response.SmartError(err)
}
err = s.Authorizer.RenameProfile(r.Context(), p.Name, name, req.Name)
if err != nil {
logger.Error("Failed to rename profile in authorizer", logger.Ctx{"old_name": name, "new_name": req.Name, "project": p.Name, "error": err})
}
requestor := request.CreateRequestor(r)
lc := lifecycle.ProfileRenamed.Event(req.Name, p.Name, requestor, logger.Ctx{"old_name": name})
s.Events.SendLifecycle(p.Name, lc)
return response.SyncResponseLocation(true, nil, lc.Source)
}
// swagger:operation DELETE /1.0/profiles/{name} profiles profile_delete
//
// Delete the profile
//
// Removes the profile.
//
// ---
// produces:
// - application/json
// parameters:
// - in: path
// name: name
// description: Profile name
// type: string
// required: true
// - in: query
// name: project
// description: Project name
// type: string
// example: default
// 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 profileDelete(d *Daemon, r *http.Request) response.Response {
s := d.State()
p, err := project.ProfileProject(s.DB.Cluster, request.ProjectParam(r))
if err != nil {
return response.SmartError(err)
}
name, err := pathVar(r, "name")
if err != nil {
return response.SmartError(err)
}
if name == "default" {
return response.Forbidden(errors.New(`The "default" profile cannot be deleted`))
}
err = s.DB.Cluster.Transaction(r.Context(), func(ctx context.Context, tx *db.ClusterTx) error {
profile, err := dbCluster.GetProfile(ctx, tx.Tx(), p.Name, name)
if err != nil {
return err
}
usedBy, err := profileUsedBy(ctx, tx, *profile)
if err != nil {
return err
}
if len(usedBy) > 0 {
return errors.New("Profile is currently in use")
}
return dbCluster.DeleteProfile(ctx, tx.Tx(), p.Name, name)
})
if err != nil {
return response.SmartError(err)
}
err = s.Authorizer.DeleteProfile(r.Context(), p.Name, name)
if err != nil {
logger.Error("Failed to remove profile from authorizer", logger.Ctx{"name": name, "project": p.Name, "error": err})
}
requestor := request.CreateRequestor(r)
s.Events.SendLifecycle(p.Name, lifecycle.ProfileDeleted.Event(name, p.Name, requestor, nil))
return response.EmptySyncResponse
}