mirror of
https://github.com/token2/snapd.git
synced 2026-03-13 11:15:47 -07:00
* o/assertstate, o/devicestate: add more general function for fetching validation set assertions * daemon, client: add API routes for creating/removing recovery system * daemon, o/snapstate: add .snap file extension to snaps from forms The seed writer will fail to consider files as snaps if their filenames do not end in .snap. * tests: test creating a recovery system * tests: add spread test for offline creation of recovery system * tests: update offline recovery system test to reboot into new system * tests/nested/manual/recovery-system-reboot: add variants for factory-reset and install modes * tests: replace usage of default-recovery-system with default-recovery * o/devicestate: enable offline creation of recovery system entirely from pre-installed snaps * daemon, client: test that offline API works without providing snaps or validation sets * tests/nested/manual/recovery-system-offline: test offline remodel with only pre-installed snaps * tests/nested/manual/recovery-system-reboot: modify test to create system with new set of essential snaps * tests: disable shellcheck printf check * daemon: rename functions for working with form values and add one for working with booleans * daemon: acquire state lock later in postSystemActionCreateOffline * daemon: cleanup form files if we fail to make change to create a recovery system * daemon: rename parseValidationSets to assertionsFromValidationSetStrings for clarity * client, daemon, tests: add "offline" field to create recovery system JSON api * daemon: convert TODO about comma-delimited list into explanation of why we use a comma delimited list * NEWS.md: add mention of create/remove recovery systems API * tests/nested/manual/recovery-system-offline: explicitly disable network from nested vm * tests/nested/manual/recovery-system-reboot: do not use new gadget in recovery system for now * tests/lib/nested.sh: add variable NESTED_FORCE_MS_KEYS to force using microsoft keys * tests/nested/manual/recovery-system-reboot: add back gadget snap swap to test * tests/nested/manual/recovery-system-reboot: retry POST to remove since there might be an auto-refresh happening
559 lines
16 KiB
Go
559 lines
16 KiB
Go
// -*- Mode: Go; indent-tabs-mode: t -*-
|
|
|
|
/*
|
|
* Copyright (C) 2020 Canonical Ltd
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License version 3 as
|
|
* published by the Free Software Foundation.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
*/
|
|
|
|
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/snapcore/snapd/asserts"
|
|
"github.com/snapcore/snapd/asserts/snapasserts"
|
|
"github.com/snapcore/snapd/client"
|
|
"github.com/snapcore/snapd/gadget"
|
|
"github.com/snapcore/snapd/overlord/assertstate"
|
|
"github.com/snapcore/snapd/overlord/auth"
|
|
"github.com/snapcore/snapd/overlord/devicestate"
|
|
"github.com/snapcore/snapd/overlord/install"
|
|
"github.com/snapcore/snapd/release"
|
|
"github.com/snapcore/snapd/snap"
|
|
)
|
|
|
|
var systemsCmd = &Command{
|
|
Path: "/v2/systems",
|
|
GET: getAllSystems,
|
|
ReadAccess: authenticatedAccess{},
|
|
// this is awkward, we want the postSystemsAction function to be used
|
|
// when the label is empty too, but the router will not handle the request
|
|
// for /v2/systems with the systemsActionCmd and instead handles it through
|
|
// this command, so we need to set the POST for this command to essentially
|
|
// forward to that one
|
|
POST: postSystemsAction,
|
|
WriteAccess: rootAccess{},
|
|
}
|
|
|
|
var systemsActionCmd = &Command{
|
|
Path: "/v2/systems/{label}",
|
|
GET: getSystemDetails,
|
|
ReadAccess: rootAccess{},
|
|
|
|
POST: postSystemsAction,
|
|
WriteAccess: rootAccess{},
|
|
}
|
|
|
|
type systemsResponse struct {
|
|
Systems []client.System `json:"systems,omitempty"`
|
|
}
|
|
|
|
func getAllSystems(c *Command, r *http.Request, user *auth.UserState) Response {
|
|
var rsp systemsResponse
|
|
|
|
seedSystems, err := c.d.overlord.DeviceManager().Systems()
|
|
if err != nil {
|
|
if err == devicestate.ErrNoSystems {
|
|
// no systems available
|
|
return SyncResponse(&rsp)
|
|
}
|
|
|
|
return InternalError(err.Error())
|
|
}
|
|
|
|
rsp.Systems = make([]client.System, 0, len(seedSystems))
|
|
|
|
for _, ss := range seedSystems {
|
|
// untangle the model
|
|
|
|
actions := make([]client.SystemAction, 0, len(ss.Actions))
|
|
for _, sa := range ss.Actions {
|
|
actions = append(actions, client.SystemAction{
|
|
Title: sa.Title,
|
|
Mode: sa.Mode,
|
|
})
|
|
}
|
|
|
|
rsp.Systems = append(rsp.Systems, client.System{
|
|
Current: ss.Current,
|
|
DefaultRecoverySystem: ss.DefaultRecoverySystem,
|
|
Label: ss.Label,
|
|
Model: client.SystemModelData{
|
|
Model: ss.Model.Model(),
|
|
BrandID: ss.Model.BrandID(),
|
|
DisplayName: ss.Model.DisplayName(),
|
|
},
|
|
Brand: snap.StoreAccount{
|
|
ID: ss.Brand.AccountID(),
|
|
Username: ss.Brand.Username(),
|
|
DisplayName: ss.Brand.DisplayName(),
|
|
Validation: ss.Brand.Validation(),
|
|
},
|
|
Actions: actions,
|
|
})
|
|
}
|
|
return SyncResponse(&rsp)
|
|
}
|
|
|
|
// wrapped for unit tests
|
|
var deviceManagerSystemAndGadgetAndEncryptionInfo = func(dm *devicestate.DeviceManager, systemLabel string) (*devicestate.System, *gadget.Info, *install.EncryptionSupportInfo, error) {
|
|
return dm.SystemAndGadgetAndEncryptionInfo(systemLabel)
|
|
}
|
|
|
|
func storageEncryption(encInfo *install.EncryptionSupportInfo) *client.StorageEncryption {
|
|
if encInfo.Disabled {
|
|
return &client.StorageEncryption{
|
|
Support: client.StorageEncryptionSupportDisabled,
|
|
}
|
|
}
|
|
storageEnc := &client.StorageEncryption{
|
|
StorageSafety: string(encInfo.StorageSafety),
|
|
Type: string(encInfo.Type),
|
|
}
|
|
required := (encInfo.StorageSafety == asserts.StorageSafetyEncrypted)
|
|
switch {
|
|
case encInfo.Available:
|
|
storageEnc.Support = client.StorageEncryptionSupportAvailable
|
|
case !encInfo.Available && required:
|
|
storageEnc.Support = client.StorageEncryptionSupportDefective
|
|
storageEnc.UnavailableReason = encInfo.UnavailableErr.Error()
|
|
case !encInfo.Available && !required:
|
|
storageEnc.Support = client.StorageEncryptionSupportUnavailable
|
|
storageEnc.UnavailableReason = encInfo.UnavailableWarning
|
|
}
|
|
|
|
return storageEnc
|
|
}
|
|
|
|
var (
|
|
devicestateInstallFinish = devicestate.InstallFinish
|
|
devicestateInstallSetupStorageEncryption = devicestate.InstallSetupStorageEncryption
|
|
devicestateCreateRecoverySystem = devicestate.CreateRecoverySystem
|
|
devicestateRemoveRecoverySystem = devicestate.RemoveRecoverySystem
|
|
)
|
|
|
|
func getSystemDetails(c *Command, r *http.Request, user *auth.UserState) Response {
|
|
wantedSystemLabel := muxVars(r)["label"]
|
|
|
|
deviceMgr := c.d.overlord.DeviceManager()
|
|
|
|
sys, gadgetInfo, encryptionInfo, err := deviceManagerSystemAndGadgetAndEncryptionInfo(deviceMgr, wantedSystemLabel)
|
|
if err != nil {
|
|
return InternalError(err.Error())
|
|
}
|
|
|
|
rsp := client.SystemDetails{
|
|
Current: sys.Current,
|
|
Label: sys.Label,
|
|
Brand: snap.StoreAccount{
|
|
ID: sys.Brand.AccountID(),
|
|
Username: sys.Brand.Username(),
|
|
DisplayName: sys.Brand.DisplayName(),
|
|
Validation: sys.Brand.Validation(),
|
|
},
|
|
// no body: we expect models to have empty bodies
|
|
Model: sys.Model.Headers(),
|
|
Volumes: gadgetInfo.Volumes,
|
|
StorageEncryption: storageEncryption(encryptionInfo),
|
|
}
|
|
for _, sa := range sys.Actions {
|
|
rsp.Actions = append(rsp.Actions, client.SystemAction{
|
|
Title: sa.Title,
|
|
Mode: sa.Mode,
|
|
})
|
|
}
|
|
|
|
return SyncResponse(rsp)
|
|
}
|
|
|
|
type systemActionRequest struct {
|
|
Action string `json:"action"`
|
|
|
|
client.SystemAction
|
|
client.InstallSystemOptions
|
|
client.CreateSystemOptions
|
|
}
|
|
|
|
func postSystemsAction(c *Command, r *http.Request, user *auth.UserState) Response {
|
|
contentType := r.Header.Get("Content-Type")
|
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
mediaType = "application/json"
|
|
}
|
|
|
|
switch mediaType {
|
|
case "application/json":
|
|
return postSystemsActionJSON(c, r)
|
|
case "multipart/form-data":
|
|
return postSystemsActionForm(c, r, params)
|
|
default:
|
|
return BadRequest("unexpected media type %q", mediaType)
|
|
}
|
|
}
|
|
|
|
func postSystemsActionForm(c *Command, r *http.Request, contentTypeParams map[string]string) (res Response) {
|
|
boundary := contentTypeParams["boundary"]
|
|
mpReader := multipart.NewReader(r.Body, boundary)
|
|
form, errRsp := readForm(mpReader)
|
|
if errRsp != nil {
|
|
return errRsp
|
|
}
|
|
|
|
action := form.Values["action"]
|
|
if len(action) != 1 {
|
|
return BadRequest("expected exactly one action in form")
|
|
}
|
|
|
|
defer func() {
|
|
// remove all files associated with the form if we're returning an error
|
|
if _, ok := res.(*apiError); ok {
|
|
form.RemoveAllExcept(nil)
|
|
}
|
|
}()
|
|
|
|
switch action[0] {
|
|
case "create":
|
|
return postSystemActionCreateOffline(c, form)
|
|
default:
|
|
return BadRequest("%s action is not supported for content type multipart/form-data", action[0])
|
|
}
|
|
}
|
|
|
|
func postSystemsActionJSON(c *Command, r *http.Request) Response {
|
|
var req systemActionRequest
|
|
systemLabel := muxVars(r)["label"]
|
|
|
|
decoder := json.NewDecoder(r.Body)
|
|
if err := decoder.Decode(&req); err != nil {
|
|
return BadRequest("cannot decode request body into system action: %v", err)
|
|
}
|
|
if decoder.More() {
|
|
return BadRequest("extra content found in request body")
|
|
}
|
|
switch req.Action {
|
|
case "do":
|
|
return postSystemActionDo(c, systemLabel, &req)
|
|
case "reboot":
|
|
return postSystemActionReboot(c, systemLabel, &req)
|
|
case "install":
|
|
return postSystemActionInstall(c, systemLabel, &req)
|
|
case "create":
|
|
if systemLabel != "" {
|
|
return BadRequest("label should not be provided in route when creating a system")
|
|
}
|
|
return postSystemActionCreate(c, &req)
|
|
case "remove":
|
|
return postSystemActionRemove(c, systemLabel)
|
|
default:
|
|
return BadRequest("unsupported action %q", req.Action)
|
|
}
|
|
}
|
|
|
|
// XXX: should deviceManager return more sensible errors here? e.g.:
|
|
// UnsupportedActionError{systemLabel, mode}, SystemDoesNotExistError{systemLabel}
|
|
func handleSystemActionErr(err error, systemLabel string) Response {
|
|
if os.IsNotExist(err) {
|
|
return NotFound("requested seed system %q does not exist", systemLabel)
|
|
}
|
|
if err == devicestate.ErrUnsupportedAction {
|
|
return BadRequest("requested action is not supported by system %q", systemLabel)
|
|
}
|
|
return InternalError(err.Error())
|
|
}
|
|
|
|
// wrapped for unit tests
|
|
var deviceManagerReboot = func(dm *devicestate.DeviceManager, systemLabel, mode string) error {
|
|
return dm.Reboot(systemLabel, mode)
|
|
}
|
|
|
|
func postSystemActionReboot(c *Command, systemLabel string, req *systemActionRequest) Response {
|
|
dm := c.d.overlord.DeviceManager()
|
|
if err := deviceManagerReboot(dm, systemLabel, req.Mode); err != nil {
|
|
return handleSystemActionErr(err, systemLabel)
|
|
}
|
|
return SyncResponse(nil)
|
|
}
|
|
|
|
func postSystemActionDo(c *Command, systemLabel string, req *systemActionRequest) Response {
|
|
if systemLabel == "" {
|
|
return BadRequest("system action requires the system label to be provided")
|
|
}
|
|
if req.Mode == "" {
|
|
return BadRequest("system action requires the mode to be provided")
|
|
}
|
|
|
|
sa := devicestate.SystemAction{
|
|
Title: req.Title,
|
|
Mode: req.Mode,
|
|
}
|
|
if err := c.d.overlord.DeviceManager().RequestSystemAction(systemLabel, sa); err != nil {
|
|
return handleSystemActionErr(err, systemLabel)
|
|
}
|
|
return SyncResponse(nil)
|
|
}
|
|
|
|
func postSystemActionInstall(c *Command, systemLabel string, req *systemActionRequest) Response {
|
|
st := c.d.overlord.State()
|
|
st.Lock()
|
|
defer st.Unlock()
|
|
|
|
switch req.Step {
|
|
case client.InstallStepSetupStorageEncryption:
|
|
chg, err := devicestateInstallSetupStorageEncryption(st, systemLabel, req.OnVolumes)
|
|
if err != nil {
|
|
return BadRequest("cannot setup storage encryption for install from %q: %v", systemLabel, err)
|
|
}
|
|
ensureStateSoon(st)
|
|
return AsyncResponse(nil, chg.ID())
|
|
case client.InstallStepFinish:
|
|
chg, err := devicestateInstallFinish(st, systemLabel, req.OnVolumes)
|
|
if err != nil {
|
|
return BadRequest("cannot finish install for %q: %v", systemLabel, err)
|
|
}
|
|
ensureStateSoon(st)
|
|
return AsyncResponse(nil, chg.ID())
|
|
default:
|
|
return BadRequest("unsupported install step %q", req.Step)
|
|
}
|
|
}
|
|
|
|
func assertionsFromValidationSetStrings(validationSets []string) ([]*asserts.AtSequence, error) {
|
|
sets := make([]*asserts.AtSequence, 0, len(validationSets))
|
|
for _, vs := range validationSets {
|
|
account, name, seq, err := snapasserts.ParseValidationSet(vs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
assertion := asserts.AtSequence{
|
|
Type: asserts.ValidationSetType,
|
|
SequenceKey: []string{release.Series, account, name},
|
|
Pinned: seq > 0,
|
|
Sequence: seq,
|
|
Revision: asserts.RevisionNotKnown,
|
|
}
|
|
|
|
sets = append(sets, &assertion)
|
|
}
|
|
|
|
return sets, nil
|
|
}
|
|
|
|
func readFormValue(form *Form, key string) (string, *apiError) {
|
|
values := form.Values[key]
|
|
if len(values) != 1 {
|
|
return "", BadRequest("expected exactly one %q value in form", key)
|
|
}
|
|
return values[0], nil
|
|
}
|
|
|
|
func readOptionalFormValue(form *Form, key string, defaultValue string) (string, *apiError) {
|
|
values := form.Values[key]
|
|
switch len(values) {
|
|
case 0:
|
|
return defaultValue, nil
|
|
case 1:
|
|
return values[0], nil
|
|
default:
|
|
return "", BadRequest("expected at most one %q value in form", key)
|
|
}
|
|
}
|
|
|
|
func readOptionalFormBoolean(form *Form, key string, defaultValue bool) (bool, *apiError) {
|
|
values := form.Values[key]
|
|
switch len(values) {
|
|
case 0:
|
|
return defaultValue, nil
|
|
case 1:
|
|
b, err := strconv.ParseBool(values[0])
|
|
if err != nil {
|
|
return false, BadRequest("cannot parse %q value as boolean: %s", key, values[0])
|
|
}
|
|
return b, nil
|
|
default:
|
|
return false, BadRequest("expected at most one %q value in form", key)
|
|
}
|
|
}
|
|
|
|
func postSystemActionCreateOffline(c *Command, form *Form) Response {
|
|
label, errRsp := readFormValue(form, "label")
|
|
if errRsp != nil {
|
|
return errRsp
|
|
}
|
|
|
|
testSystem, errRsp := readOptionalFormBoolean(form, "test-system", false)
|
|
if errRsp != nil {
|
|
return errRsp
|
|
}
|
|
|
|
markDefault, errRsp := readOptionalFormBoolean(form, "mark-default", false)
|
|
if errRsp != nil {
|
|
return errRsp
|
|
}
|
|
|
|
vsetsList, errRsp := readOptionalFormValue(form, "validation-sets", "")
|
|
if errRsp != nil {
|
|
return errRsp
|
|
}
|
|
|
|
var splitVSets []string
|
|
if vsetsList != "" {
|
|
splitVSets = strings.Split(vsetsList, ",")
|
|
}
|
|
|
|
// this could be multiple "validation-set" values, but that would make it so
|
|
// that the field names in the form and JSON APIs are different, since the
|
|
// JSON API uses "validation-sets" (plural). to keep the APIs consistent, we
|
|
// use a comma-delimeted list of validation sets strings.
|
|
sequences, err := assertionsFromValidationSetStrings(splitVSets)
|
|
if err != nil {
|
|
return BadRequest("cannot parse validation sets: %v", err)
|
|
}
|
|
|
|
var snapFiles []*uploadedSnap
|
|
if len(form.FileRefs["snap"]) > 0 {
|
|
snaps, errRsp := form.GetSnapFiles()
|
|
if errRsp != nil {
|
|
return errRsp
|
|
}
|
|
|
|
snapFiles = snaps
|
|
}
|
|
|
|
batch := asserts.NewBatch(nil)
|
|
for _, a := range form.Values["assertion"] {
|
|
if _, err := batch.AddStream(strings.NewReader(a)); err != nil {
|
|
return BadRequest("cannot decode assertion: %v", err)
|
|
}
|
|
}
|
|
|
|
st := c.d.overlord.State()
|
|
st.Lock()
|
|
defer st.Unlock()
|
|
|
|
if err := assertstate.AddBatch(st, batch, &asserts.CommitOptions{Precheck: true}); err != nil {
|
|
return BadRequest("error committing assertions: %v", err)
|
|
}
|
|
|
|
validationSets, err := assertstate.FetchValidationSets(st, sequences, assertstate.FetchValidationSetsOptions{
|
|
Offline: true,
|
|
}, nil)
|
|
if err != nil {
|
|
return BadRequest("cannot find validation sets in db: %v", err)
|
|
}
|
|
|
|
slInfo, apiErr := sideloadSnapsInfo(st, snapFiles, sideloadFlags{})
|
|
if apiErr != nil {
|
|
return apiErr
|
|
}
|
|
|
|
if len(slInfo.sideInfos) != len(slInfo.tmpPaths) {
|
|
return InternalError("mismatch between number of snap side infos and temporary paths")
|
|
}
|
|
|
|
localSnaps := make([]devicestate.LocalSnap, 0, len(slInfo.sideInfos))
|
|
for i := range slInfo.sideInfos {
|
|
localSnaps = append(localSnaps, devicestate.LocalSnap{
|
|
SideInfo: slInfo.sideInfos[i],
|
|
Path: slInfo.tmpPaths[i],
|
|
})
|
|
}
|
|
|
|
chg, err := devicestateCreateRecoverySystem(st, label, devicestate.CreateRecoverySystemOptions{
|
|
ValidationSets: validationSets.Sets(),
|
|
LocalSnaps: localSnaps,
|
|
TestSystem: testSystem,
|
|
MarkDefault: markDefault,
|
|
// using the form-based API implies that this should be an offline operation
|
|
Offline: true,
|
|
})
|
|
if err != nil {
|
|
return InternalError("cannot create recovery system %q: %v", label[0], err)
|
|
}
|
|
|
|
ensureStateSoon(st)
|
|
|
|
return AsyncResponse(nil, chg.ID())
|
|
}
|
|
|
|
func postSystemActionCreate(c *Command, req *systemActionRequest) Response {
|
|
st := c.d.overlord.State()
|
|
st.Lock()
|
|
defer st.Unlock()
|
|
|
|
if req.Label == "" {
|
|
return BadRequest("label must be provided in request body for action %q", req.Action)
|
|
}
|
|
|
|
sequences, err := assertionsFromValidationSetStrings(req.ValidationSets)
|
|
if err != nil {
|
|
return BadRequest("cannot parse validation sets: %v", err)
|
|
}
|
|
|
|
validationSets, err := assertstate.FetchValidationSets(c.d.state, sequences, assertstate.FetchValidationSetsOptions{
|
|
Offline: req.Offline,
|
|
}, nil)
|
|
if err != nil {
|
|
if errors.Is(err, &asserts.NotFoundError{}) {
|
|
return BadRequest("cannot fetch validation sets: %v", err)
|
|
}
|
|
return InternalError("cannot fetch validation sets: %v", err)
|
|
}
|
|
|
|
chg, err := devicestateCreateRecoverySystem(st, req.Label, devicestate.CreateRecoverySystemOptions{
|
|
ValidationSets: validationSets.Sets(),
|
|
TestSystem: req.TestSystem,
|
|
MarkDefault: req.MarkDefault,
|
|
Offline: req.Offline,
|
|
})
|
|
if err != nil {
|
|
return InternalError("cannot create recovery system %q: %v", req.Label, err)
|
|
}
|
|
|
|
ensureStateSoon(st)
|
|
|
|
return AsyncResponse(nil, chg.ID())
|
|
}
|
|
|
|
func postSystemActionRemove(c *Command, systemLabel string) Response {
|
|
if systemLabel == "" {
|
|
return BadRequest("system action requires the system label to be provided")
|
|
}
|
|
|
|
st := c.d.overlord.State()
|
|
st.Lock()
|
|
defer st.Unlock()
|
|
|
|
chg, err := devicestateRemoveRecoverySystem(st, systemLabel)
|
|
if err != nil {
|
|
if errors.Is(err, devicestate.ErrNoRecoverySystem) {
|
|
return NotFound(err.Error())
|
|
}
|
|
|
|
return InternalError("cannot remove recovery system %q: %v", systemLabel, err)
|
|
}
|
|
|
|
ensureStateSoon(st)
|
|
|
|
return AsyncResponse(nil, chg.ID())
|
|
}
|