diff --git a/.github/workflows/spread.yaml b/.github/workflows/spread.yaml index 675ddb0e7..09f56d8f9 100644 --- a/.github/workflows/spread.yaml +++ b/.github/workflows/spread.yaml @@ -150,10 +150,23 @@ jobs: env: SUITE_PATHS: ${{ matrix.suite.paths }} LXD_CHANNEL: ${{ inputs.lxd_channel || '6/stable' }} + ARTIFACTS: ${{ github.workspace }}.cover run: | - mkdir ${{ github.workspace }}.cover + mkdir "$ARTIFACTS" read -r -a suite_paths <<< "$SUITE_PATHS" - spread -artifacts=${{ github.workspace }}.cover "${suite_paths[@]}" + spread -artifacts="$ARTIFACTS" "${suite_paths[@]}" + + # Workaround https://github.com/actions/upload-artifact/issues/546 + cd "$ARTIFACTS/lxd:ubuntu-24.04:tests" || exit 0 + tasks=(*/*/) + for task in "${tasks[@]}"; do + suite=${task%%/*} + task=${task#*/} + task=${task%/} + safe=${task//:/.} + [ "$task" = "$safe" ] || (cd "$suite" && mv "$task" "$safe") + done + shell: bash - name: Upload Coverage uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/client/workshop.go b/client/workshop.go index 3a7fc71e9..2e6971802 100644 --- a/client/workshop.go +++ b/client/workshop.go @@ -71,13 +71,14 @@ type Workshops struct { } type WorkshopInfo struct { - ProjectId string `json:"project-id"` - Name string `json:"name"` - Base string `json:"base"` - Status string `json:"status"` - Sdks []*Sdk `json:"sdks,omitempty"` - Hostname string `json:"hostname,omitempty"` - Notes []string `json:"notes,omitempty"` + ProjectId string `json:"project-id"` + Name string `json:"name"` + Base string `json:"base"` + Confinement string `json:"confinement"` + Status string `json:"status"` + Sdks []*Sdk `json:"sdks,omitempty"` + Hostname string `json:"hostname,omitempty"` + Notes []string `json:"notes,omitempty"` } type WorkshopFile struct { diff --git a/cmd/workshop/info.go b/cmd/workshop/info.go index bfab11ea3..993c3ee01 100644 --- a/cmd/workshop/info.go +++ b/cmd/workshop/info.go @@ -154,6 +154,7 @@ func (c *CmdInfo) Run(cmd *cobra.Command, av []string) error { fmt.Fprintf(w, "hostname:\t%s\n", workshop.Hostname) } fmt.Fprintf(w, "status:\t%s\n", strings.ToLower(workshop.Status)) + fmt.Fprintf(w, "confinement:\t%s\n", workshop.Confinement) // get the workshop notes notes := workshop.Notes diff --git a/cmd/workshop/info_test.go b/cmd/workshop/info_test.go index e690e84cd..f46a9ca22 100644 --- a/cmd/workshop/info_test.go +++ b/cmd/workshop/info_test.go @@ -41,6 +41,7 @@ func (m *workshopInfo) SetUpTest(c *check.C) { var mockWorkshopWithSdks = `{"type":"sync","status-code":200,"status":"OK","result":{ "name":"ws", "base":"ubuntu@22.04", + "confinement":"container", "project-id":"42424242", "status":"Error", "hostname":"ws.sdkcraft.wp", @@ -94,12 +95,13 @@ func (m *workshopInfo) TestWorkshopInfo(c *check.C) { err = cmd.Run(cmd.Command(), nil) c.Assert(err, check.IsNil) - c.Assert(m.stdout.String(), check.Matches, fmt.Sprintf(`name: ws -base: ubuntu@22.04 -project: %s -hostname: ws\.sdkcraft\.wp -status: error -notes: missing-project + c.Assert(m.stdout.String(), check.Matches, fmt.Sprintf(`name: ws +base: ubuntu@22.04 +project: %s +hostname: ws\.sdkcraft\.wp +status: error +confinement: container +notes: missing-project sdks: go: tracking: latest/edge @@ -114,6 +116,7 @@ sdks: var mockWorkshopWithHealth = `{"type":"sync","status-code":200,"status":"OK","result":{ "name":"ws", "base":"ubuntu@22.04", + "confinement":"container", "project-id":"42424242", "status":"Pending", "notes":["workshop-note"], @@ -152,11 +155,12 @@ func (m *workshopInfo) TestWorkshopInfoWithSdkHealthReport(c *check.C) { err := cmd.Run(cmd.Command(), []string{workshop}) c.Assert(err, check.IsNil) - c.Assert(m.stdout.String(), check.Matches, fmt.Sprintf(`name: ws -base: ubuntu@22.04 -project: %s -status: pending -notes: workshop-note,try-later + c.Assert(m.stdout.String(), check.Matches, fmt.Sprintf(`name: ws +base: ubuntu@22.04 +project: %s +status: pending +confinement: container +notes: workshop-note,try-later sdks: go: tracking: latest/edge @@ -169,6 +173,7 @@ sdks: var mockWorkshopWithMounts = `{"type":"sync","status-code":200,"status":"OK","result":{ "name":"ws", "base":"ubuntu@22.04", + "confinement":"container", "project-id":"42424242", "status":"Ready", "sdks":[{ @@ -200,11 +205,12 @@ var mockWorkshopWithMounts = `{"type":"sync","status-code":200,"status":"OK","re }] }}` -var mockWorkshopWithMountsOutput = `name: ws -base: ubuntu@22.04 -project: %s -status: ready -notes: %s +var mockWorkshopWithMountsOutput = `name: ws +base: ubuntu@22.04 +project: %s +status: ready +confinement: container +notes: %s sdks: go: tracking: latest/edge @@ -307,6 +313,7 @@ var mockWorkshopWithTunnels = `{ "result": { "name": "ws", "base": "ubuntu@22.04", + "confinement": "container", "project-id": "42424242", "status": "Ready", "sdks": [ @@ -392,11 +399,12 @@ func (m *workshopInfo) TestWorkshopInfoWithSdkTunnels(c *check.C) { err = cmd.Run(cmd.Command(), []string{workshop}) c.Assert(err, check.IsNil) - c.Assert(m.stdout.String(), check.Matches, fmt.Sprintf(`name: ws -base: ubuntu@22.04 -project: %s -status: ready -notes: -- + c.Assert(m.stdout.String(), check.Matches, fmt.Sprintf(`name: ws +base: ubuntu@22.04 +project: %s +status: ready +confinement: container +notes: -- sdks: system: installed: \(1\) diff --git a/cmd/workshop/init.go b/cmd/workshop/init.go index d824c25c6..6fc65207e 100644 --- a/cmd/workshop/init.go +++ b/cmd/workshop/init.go @@ -19,6 +19,7 @@ type CmdInit struct { root *CmdRoot sdks []string base string + vm bool } func (c *CmdInit) Command() *cobra.Command { @@ -53,6 +54,9 @@ $ workshop init dev --base ubuntu@22.04 --sdks go`, cmd.Flags().StringSliceVar(&c.sdks, "sdks", nil, `Comma-separated list of SDKs (e.g., "go,uv/latest/stable").`) cmd.Flags().StringVar(&c.base, "base", defaultBase, "Base image for the workshop.") + cmd.Flags().BoolVar(&c.vm, "vm", false, "Use a virtual machine instead of a container.") + + cmd.MarkFlagsMutuallyExclusive("sdks", "vm") return cmd } @@ -66,6 +70,11 @@ func (c *CmdInit) Run(cmd *cobra.Command, args []string) error { return err } + confinement := workshop.ConfinementContainer + if c.vm { + confinement = workshop.ConfinementVirtualMachine + } + wfile := &workshop.File{ Name: name, Base: c.base, @@ -76,6 +85,10 @@ func (c *CmdInit) Run(cmd *cobra.Command, args []string) error { return err } + // TODO: include confinement in the validation. We skip validation here + // because we might not have the experimental flag in the environment. + wfile.Confinement = confinement + if err := ensureCanCreate(projectDir, name); err != nil { return err } diff --git a/cmd/workshop/init_test.go b/cmd/workshop/init_test.go index 3345df03a..c45df21af 100644 --- a/cmd/workshop/init_test.go +++ b/cmd/workshop/init_test.go @@ -85,6 +85,21 @@ base: ubuntu@24.04 `) } +func (s *workshopInit) TestInitVM(c *check.C) { + projectDir := c.MkDir() + cmd := s.makeCmd(projectDir) + cmd.vm = true + + err := s.run(cmd, "dev") + c.Assert(err, check.IsNil) + + path := workshop.Filepath(projectDir, "dev") + c.Check(path, testutil.FileEquals, `name: dev +base: ubuntu@24.04 +confinement: virtual-machine +`) +} + func (s *workshopInit) TestInitWithSdkChannel(c *check.C) { projectDir := c.MkDir() cmd := s.makeCmd(projectDir) diff --git a/cmd/workshopctl/main.go b/cmd/workshopctl/main.go index 242ace426..701da9623 100644 --- a/cmd/workshopctl/main.go +++ b/cmd/workshopctl/main.go @@ -27,6 +27,7 @@ import ( "github.com/canonical/workshop/client" "github.com/canonical/workshop/internal/dirs" + "github.com/canonical/workshop/internal/fsfreeze" "github.com/canonical/workshop/internal/waitready" ) @@ -45,6 +46,14 @@ func main() { return } + if fsfreeze.IsFsfreezeInvocation() { + if err := fsfreeze.FreezeLocalFilesystems(os.Stdin, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + os.Exit(1) + } + return + } + // Set the user and group IDs to the workshop user uid := uint32(1000) // Change this to the workshop UID diff --git a/docs/reference/definition-files/schema.json b/docs/reference/definition-files/schema.json index 8b677bf8b..0f5abab9c 100644 --- a/docs/reference/definition-files/schema.json +++ b/docs/reference/definition-files/schema.json @@ -23,6 +23,15 @@ ], "errorMessage": "The base must be one of the supported values: ubuntu@20.04, ubuntu@22.04, ubuntu@24.04, ubuntu@26.04." }, + "confinement": { + "type": "string", + "description": "Type of sandboxing to use to run the workshop.", + "enum": [ + "container", + "virtual-machine" + ], + "errorMessage": "The confinement must be one of the supported values: container, virtual-machine." + }, "sdks": { "type": "array", "description": "Ordered list of SDKs to install on top of the base. Each entry references an existing SDK; names must be unique within the list. The system SDK is installed first implicitly and need not be listed.", diff --git a/internal/daemon/api_connections.go b/internal/daemon/api_connections.go index 85fadc4d9..92d7b871a 100644 --- a/internal/daemon/api_connections.go +++ b/internal/daemon/api_connections.go @@ -256,7 +256,11 @@ func v1GetConnections(c *Command, r *http.Request, _ *userState) Response { onlyConnected := qselect == "" if workshop != "" { - if err := checkWorkshopExists(r.Context(), c.d.overlord.WorkshopManager(), projectId, workshop); err != nil { + st := c.d.overlord.State() + st.Lock() + err := checkWorkshopExists(r.Context(), c.d.overlord.WorkshopManager(), projectId, workshop) + st.Unlock() + if err != nil { return statusNotFound("cannot access %q workshop: %w", workshop, err) } } diff --git a/internal/daemon/api_connections_test.go b/internal/daemon/api_connections_test.go index 272ca7b0f..5d063f6c9 100644 --- a/internal/daemon/api_connections_test.go +++ b/internal/daemon/api_connections_test.go @@ -91,7 +91,7 @@ func (s *apiSuite) workshopFile(ws string, sdks []*sdk.Info) *workshop.File { func (s *apiSuite) mockInstalledSDK(c *check.C, yaml string, w string) *workshop.Workshop { info := sdk.MockInfo(c, yaml, s.project.ProjectId, w) wf := s.workshopFile(w, []*sdk.Info{info}) - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil) wp, err := s.b.Workshop(s.ctx, w) @@ -128,7 +128,7 @@ func (s *apiSuite) mockInstalledSDKBoundPlug(c *check.C, yaml string, w string, Name: to} c.Assert(s.d.overlord.InterfaceManager().Repository().AddSdk(info), check.IsNil) wf := s.workshopFile(w, []*sdk.Info{info}) - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil) wp, err := s.b.Workshop(s.ctx, w) c.Check(err, check.IsNil) diff --git a/internal/daemon/api_exec_test.go b/internal/daemon/api_exec_test.go index aad22f031..488e0cbf0 100644 --- a/internal/daemon/api_exec_test.go +++ b/internal/daemon/api_exec_test.go @@ -41,7 +41,7 @@ func (s *apiSuite) setupExec(c *check.C) *Command { s.createWFile(c, "ws", wsYaml) wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04", Actions: map[string]workshop.Action{"lint": "\n\n\ngolangci-lint run\n"}} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Assert(err, check.IsNil) diff --git a/internal/daemon/api_sdks_test.go b/internal/daemon/api_sdks_test.go index a022dea5e..26cc3f56e 100644 --- a/internal/daemon/api_sdks_test.go +++ b/internal/daemon/api_sdks_test.go @@ -439,11 +439,11 @@ func (s *apiSuite) TestSdkInfoGetOk(c *check.C) { s.createWFile(c, "lerobot", "name: lerobot\nbase: ubuntu@20.04\n") wf := &workshop.File{Name: "nav2", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil) wf = &workshop.File{Name: "lerobot", Base: "ubuntu@20.04"} - snapshot = workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot = workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil) // Add SDK setups with channels so the endpoint can report channels. @@ -667,7 +667,7 @@ func (s *apiSuite) TestSdkInfoLocalOnly(c *check.C) { s.createWFile(c, "nav2", "name: nav2\nbase: ubuntu@20.04\n") wf := &workshop.File{Name: "nav2", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil) // Add SDK setup with channels so the endpoint can report channels. @@ -798,7 +798,7 @@ func (s *apiSuite) TestSdkInfoGetInvalidLocalMetadata(c *check.C) { s.createWFile(c, "ws", "name: ws\nbase: ubuntu@20.04\n") wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") c.Assert(s.b.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot), check.IsNil) meta := sdk.Meta{ diff --git a/internal/daemon/api_workshops.go b/internal/daemon/api_workshops.go index ca8fe1aea..4b219a44c 100644 --- a/internal/daemon/api_workshops.go +++ b/internal/daemon/api_workshops.go @@ -106,13 +106,14 @@ type Workshops struct { } type WorkshopInfo struct { - ProjectId string `json:"project-id"` - Name string `json:"name"` - Base string `json:"base"` - Status string `json:"status"` - Sdks []*SdkInfo `json:"sdks,omitempty"` - Hostname string `json:"hostname,omitempty"` - Notes []string `json:"notes,omitempty"` + ProjectId string `json:"project-id"` + Name string `json:"name"` + Base string `json:"base"` + Confinement string `json:"confinement"` + Status string `json:"status"` + Sdks []*SdkInfo `json:"sdks,omitempty"` + Hostname string `json:"hostname,omitempty"` + Notes []string `json:"notes,omitempty"` } type WorkshopFileInfo struct { @@ -202,6 +203,12 @@ func workshopToInfo(username string, w *workshop.Workshop, health healthstate.He info.ProjectId = w.Project.ProjectId info.Base = w.File.Base + confinement, err := w.File.Confinement.MarshalText() + if err != nil { + return nil, err + } + info.Confinement = string(confinement) + sdkSetups := w.SdksByInstallOrder() usr, env, err := osutil.UserAndEnv(username) @@ -255,6 +262,12 @@ func workshopToInfoFull(ctx context.Context, username string, w *workshop.Worksh info.ProjectId = w.Project.ProjectId info.Base = w.File.Base + confinement, err := w.File.Confinement.MarshalText() + if err != nil { + return nil, err + } + info.Confinement = string(confinement) + sdks, err := w.SdkInfosByInstallOrder(ctx) if err != nil { return nil, err diff --git a/internal/daemon/api_workshops_test.go b/internal/daemon/api_workshops_test.go index 4954d9905..2a231dad8 100644 --- a/internal/daemon/api_workshops_test.go +++ b/internal/daemon/api_workshops_test.go @@ -551,10 +551,11 @@ func (s *apiSuite) TestGetWorkshops(c *check.C) { info := rsp.Result.(Workshops) c.Check(info.Workshops, testutil.DeepUnsortedMatches, []*WorkshopInfo{{ - Name: "manysdks", - Base: "ubuntu@24.04", - ProjectId: s.project.ProjectId, - Status: "Ready", + Name: "manysdks", + Base: "ubuntu@24.04", + Confinement: "container", + ProjectId: s.project.ProjectId, + Status: "Ready", Sdks: []*SdkInfo{ { Name: "system", @@ -569,10 +570,11 @@ func (s *apiSuite) TestGetWorkshops(c *check.C) { }, }, }, { - Name: "basic", - Base: "ubuntu@22.04", - ProjectId: s.project.ProjectId, - Status: "Ready", + Name: "basic", + Base: "ubuntu@22.04", + Confinement: "container", + ProjectId: s.project.ProjectId, + Status: "Ready", Sdks: []*SdkInfo{{ Name: "system", Revision: system.SystemSdkRevision.String(), @@ -672,11 +674,12 @@ func (s *apiSuite) TestGetWorkshopInfo(c *check.C) { c.Assert(err, check.IsNil) c.Check(result, check.DeepEquals, Workshop{ WorkshopInfo: WorkshopInfo{ - Name: "tunnels", - Base: "ubuntu@22.04", - ProjectId: s.project.ProjectId, - Status: "Ready", - Notes: nil, + Name: "tunnels", + Base: "ubuntu@22.04", + Confinement: "container", + ProjectId: s.project.ProjectId, + Status: "Ready", + Notes: nil, Sdks: []*SdkInfo{ { Name: "system", @@ -824,11 +827,12 @@ func (s *apiSuite) TestGetWorkshopInfoSomePlugsBound(c *check.C) { } c.Check(result, check.DeepEquals, Workshop{ WorkshopInfo: WorkshopInfo{ - Name: "somebound", - Base: "ubuntu@22.04", - ProjectId: s.project.ProjectId, - Status: "Ready", - Notes: nil, + Name: "somebound", + Base: "ubuntu@22.04", + Confinement: "container", + ProjectId: s.project.ProjectId, + Status: "Ready", + Notes: nil, Sdks: []*SdkInfo{ { Name: "mount-conflict", @@ -3790,8 +3794,8 @@ func (s *apiSuite) TestRefreshBaseUpdate(c *check.C) { defer s.store.SetDownloadCallback(storeDownload(c))() oldGetBase := s.b.GetBaseCallback - s.b.GetBaseCallback = func(ctx context.Context, base string) (workshop.BaseImage, error) { - return workshop.BaseImage{Name: base, Fingerprint: "oldimage123"}, nil + s.b.GetBaseCallback = func(ctx context.Context, base string, confinement workshop.Confinement) (workshop.BaseImage, error) { + return workshop.BaseImage{Name: base, Confinement: confinement, Fingerprint: "oldimage123"}, nil } defer func() { s.b.GetBaseCallback = oldGetBase }() @@ -3810,7 +3814,7 @@ func (s *apiSuite) TestRefreshBaseUpdate(c *check.C) { wp, err := s.b.Workshop(s.ctx, "manysdks") c.Assert(err, check.IsNil) - c.Check(wp.Image, check.Equals, workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "oldimage123"}) + c.Check(wp.Image, check.Equals, workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "oldimage123"}) requests = []*bytes.Buffer{ bytes.NewBufferString(`{"names":["manysdks"],"action":"refresh"}`), @@ -3824,15 +3828,15 @@ func (s *apiSuite) TestRefreshBaseUpdate(c *check.C) { }, } - s.b.GetBaseCallback = func(ctx context.Context, base string) (workshop.BaseImage, error) { - return workshop.BaseImage{Name: base, Fingerprint: "newimage321"}, nil + s.b.GetBaseCallback = func(ctx context.Context, base string, confinement workshop.Confinement) (workshop.BaseImage, error) { + return workshop.BaseImage{Name: base, Confinement: confinement, Fingerprint: "newimage321"}, nil } s.runActionTest(c, requests, expected) wp, err = s.b.Workshop(s.ctx, "manysdks") c.Assert(err, check.IsNil) - c.Check(wp.Image, check.Equals, workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "newimage321"}) + c.Check(wp.Image, check.Equals, workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "newimage321"}) want := []expectedWorkshop{{ name: "manysdks", diff --git a/internal/daemon/snapshot-ingredients.yaml b/internal/daemon/snapshot-ingredients.yaml index f1c1e99a0..4e8bd149b 100644 --- a/internal/daemon/snapshot-ingredients.yaml +++ b/internal/daemon/snapshot-ingredients.yaml @@ -7,6 +7,7 @@ File: Name: string Base: string + Confinement: workshop.Confinement Sdks: '[]workshop.SdkRecord' Actions: Connections: @@ -29,6 +30,7 @@ Workshop: Hostname: BaseImage: Name: string + Confinement: workshop.Confinement Fingerprint: string SdkInstallation: Setup: sdk.Setup diff --git a/internal/dirs/dirs.go b/internal/dirs/dirs.go index d15f6490f..a394e7703 100644 --- a/internal/dirs/dirs.go +++ b/internal/dirs/dirs.go @@ -59,6 +59,9 @@ var ( // Cache directory for deb packages AptCacheDir = "/var/cache/apt/archives" + + // Symlink to workshopctl that freezes VM filesystems. + FsFreezePath = "/usr/local/lib/workshop/fsfreeze" ) // Variables for workshopd (host paths) diff --git a/internal/fsfreeze/export_test.go b/internal/fsfreeze/export_test.go new file mode 100644 index 000000000..548e1966a --- /dev/null +++ b/internal/fsfreeze/export_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 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 . + +package fsfreeze + +import ( + "os" + "time" + + "github.com/canonical/workshop/internal/osutil" + "github.com/canonical/workshop/internal/testutil" +) + +func MockFilesystems(path string) func() { + return testutil.FakeFunc(path, &filesystemsPath) +} + +func MockMountinfo(path string) func() { + return testutil.FakeFunc(path, &mountinfoPath) +} + +func MockTimeout(t time.Duration) func() { + return testutil.FakeFunc(t, &timeout) +} + +func MockFsFreeze(f func(*os.File) error) func() { + return testutil.FakeFunc(f, &fsFreeze) +} + +func MockFsThaw(f func(*os.File) error) func() { + return testutil.FakeFunc(f, &fsThaw) +} + +func LocalMounts() ([]*osutil.MountInfoEntry, string, error) { + mounts, rootFS, err := localMounts(mountinfoPath, filesystemsPath) + if err != nil { + return nil, "", err + } + return mounts, rootFS.String(), nil +} diff --git a/internal/fsfreeze/fsfreeze.go b/internal/fsfreeze/fsfreeze.go new file mode 100644 index 000000000..64300e3ca --- /dev/null +++ b/internal/fsfreeze/fsfreeze.go @@ -0,0 +1,283 @@ +// Copyright (c) 2026 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 . + +package fsfreeze + +import ( + "bufio" + "bytes" + "cmp" + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "slices" + "strings" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" + + "github.com/canonical/workshop/internal/fsfreeze/sys" + "github.com/canonical/workshop/internal/osutil" + "github.com/canonical/workshop/internal/revert" +) + +var ( + timeout = 2 * time.Minute + + mountinfoPath = "/proc/self/mountinfo" + filesystemsPath = "/proc/filesystems" + + fsFreeze = sys.FsFreeze + fsThaw = sys.FsThaw +) + +type superblock struct { + major int + minor int +} + +func (d superblock) String() string { + return fmt.Sprintf("%d:%d", d.major, d.minor) +} + +// IsFsfreezeInvocation reports whether the process was invoked via a symlink +// named fsfreeze. This allows multiple logically unrelated commands to be +// embedded in a single multi-call binary (even in tests). +func IsFsfreezeInvocation() bool { + return len(os.Args) > 0 && filepath.Base(os.Args[0]) == "fsfreeze" +} + +// FreezeLocalFilesystems freezes every filesystem associated with a block +// device. It uses the sd_notify protocol to signal readiness on stdout. When +// stdin is closed, the filesystems are thawed. It also thaws them on error, +// which can be a signal (HUP, INT, PIPE, or TERM) or a timeout. In this case, +// stdin should still be closed in order to unblock stdin.Read(). +func FreezeLocalFilesystems(stdin io.Reader, stdout io.Writer) (err error) { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGPIPE, syscall.SIGTERM) + defer stop() + + mounts, root, err := localMounts(mountinfoPath, filesystemsPath) + if err != nil { + return err + } + + frozen, err := freeze(ctx, mounts, root) + if err != nil { + return err + } + defer func() { + err = cmp.Or(err, thaw(frozen)) + }() + + if _, err := fmt.Fprintln(stdout, "READY=1"); err != nil { + return fmt.Errorf("cannot report readiness: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ctx, cancelCause := context.WithCancelCause(ctx) + defer cancelCause(nil) + go func() { + defer cancelCause(io.EOF) + if _, err1 := io.Copy(io.Discard, stdin); err1 != nil { + cancelCause(err1) + } + }() + + <-ctx.Done() + if err := context.Cause(ctx); errors.Is(err, io.EOF) { + return nil + } else { + return err + } +} + +// localMounts lists mounts involving block devices. The list is in reverse +// mount order, to guarantee that loop mounts are frozen before the filesystem +// holding the backing file. Only one representative of each superblock is +// listed, to avoid freezing a frozen filesystem. +// +// It also returns the device ID of the rootfs superblock. +func localMounts(mountinfo, filesystems string) ([]*osutil.MountInfoEntry, superblock, error) { + local, err := localFilesystems(filesystems) + if err != nil { + return nil, superblock{}, err + } + + mounts, err := osutil.LoadMountInfo(mountinfo) + if err != nil { + return nil, superblock{}, fmt.Errorf("cannot parse %q: %w", mountinfo, err) + } + slices.Reverse(mounts) + + idx := slices.IndexFunc(mounts, func(m *osutil.MountInfoEntry) bool { + return m.MountDir == "/" + }) + if idx < 0 { + return nil, superblock{}, errors.New("root filesystem not mounted") + } + root := superblock{major: mounts[idx].DevMajor, minor: mounts[idx].DevMinor} + + last := make(map[superblock]*osutil.MountInfoEntry, len(mounts)) + for _, m := range mounts { + if isLocal(m, local) { + last[superblock{major: m.DevMajor, minor: m.DevMinor}] = m + } else if m.MountDir == "/" { + return nil, superblock{}, fmt.Errorf("root filesystem %s (%s) is not freezable", root, m.FsType) + } + } + mounts = slices.DeleteFunc(mounts, func(m *osutil.MountInfoEntry) bool { + return last[superblock{major: m.DevMajor, minor: m.DevMinor}] != m + }) + + return mounts, root, nil +} + +// localFilesystems returns a map of filesystems that require block devices. +func localFilesystems(filesystems string) (map[string]bool, error) { + file, err := os.Open(filesystems) + if err != nil { + return nil, err + } + defer file.Close() + + types := map[string]bool{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + flag, name, ok := strings.Cut(scanner.Text(), "\t") + if !ok { + return nil, fmt.Errorf("cannot parse %q: too few fields", filesystems) + } + // Currently the first column is either "" or "nodev". + if !strings.Contains(flag, "nodev") { + types[name] = true + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("cannot parse %q: %w", filesystems, err) + } + return types, nil +} + +// isLocal attempts to determine if the given mount is backed by a block +// device. This is intended as an optimisation: most network-based filesystems +// don't support FIFREEZE and will return EOPNOTSUPP immediately. However, +// opening the file descriptor that we pass to FIFREEZE can potentially block +// for a long time if the network is unstable. +// +// There are a few caveats: CIFS does support FIFREEZE, and loop mounts may +// appear to be local even if the loop file is remote. The implementation is +// based on the QEMU guest agent, which ignores these edge cases. Typically +// mounts with major number 0 are remote and others are local. QEMU carves out +// an exception for btrfs; we treat any filesystem which doesn't specify +// "nodev" in /proc/filesystems as local, which should be more future-proof. +// For example bcachefs is a similar exception to btrfs. +// +// Another exception is ZFS, which uses major number 0 and specifies "nodev," +// but doesn't support FIFREEZE. If in future VMs use ZFS for the rootfs, we +// need to at least call fsync, or preferably the native ZFS equivalent of +// FIFREEZE, to handle this case properly. +func isLocal(m *osutil.MountInfoEntry, local map[string]bool) bool { + // Some filesystems have subtypes, like fuse.sshfs. Only the main type is + // listed in /proc/filesystems. + name, _, _ := strings.Cut(m.FsType, ".") + + return m.DevMajor != 0 || local[name] +} + +func freeze(ctx context.Context, mounts []*osutil.MountInfoEntry, root superblock) ([]*os.File, error) { + rev := revert.New() + defer rev.Fail() + + var frozen []*os.File + rev.Add(func() { + for _, f := range slices.Backward(frozen) { + _ = fsThaw(f) + f.Close() + } + }) + + for _, m := range mounts { + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + default: + } + + file, err := os.Open(m.MountDir) + if err != nil { + return nil, err + } + + if err := fsFreeze(file); err != nil { + file.Close() + if (superblock{major: m.DevMajor, minor: m.DevMinor} != root) && errors.Is(err, unix.EOPNOTSUPP) { + continue + } + return nil, err + } + + frozen = append(frozen, file) + } + + rev.Success() + return frozen, nil +} + +func thaw(frozen []*os.File) error { + var errs []error + for _, file := range slices.Backward(frozen) { + errs = append(errs, fsThaw(file)) + file.Close() + } + return errors.Join(errs...) +} + +type ReadyWriter struct { + once sync.Once + ready chan struct{} + buf bytes.Buffer +} + +func NewReadyWriter() *ReadyWriter { + return &ReadyWriter{ready: make(chan struct{})} +} + +func (w *ReadyWriter) Ready() <-chan struct{} { + return w.ready +} + +func (w *ReadyWriter) Write(data []byte) (int, error) { + n, err := w.buf.Write(data) + for { + line, _, found := bytes.Cut(w.buf.Bytes(), []byte{'\n'}) + if !found { + break + } + if bytes.Equal(line, []byte("READY=1")) { + w.once.Do(func() { + close(w.ready) + }) + } + w.buf.Next(len(line) + 1) + } + return n, err +} diff --git a/internal/fsfreeze/fsfreeze_test.go b/internal/fsfreeze/fsfreeze_test.go new file mode 100644 index 000000000..2d0cef83c --- /dev/null +++ b/internal/fsfreeze/fsfreeze_test.go @@ -0,0 +1,387 @@ +// Copyright (c) 2026 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 . + +package fsfreeze_test + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/sys/unix" + "gopkg.in/check.v1" + + "github.com/canonical/workshop/internal/fsfreeze" + "github.com/canonical/workshop/internal/osutil" + "github.com/canonical/workshop/internal/revert" + "github.com/canonical/workshop/internal/testutil" +) + +func Test(t *testing.T) { check.TestingT(t) } + +type fsfreezeSuite struct { + scratch string + + frozen map[string]error + canThaw chan struct{} + hangup bool + + restoreFilesystems func() + restoreMountinfo func() + restoreTimeout func() + restoreFsFreeze func() + restoreFsThaw func() +} + +var _ = check.Suite(&fsfreezeSuite{}) + +const fakeFilesystems = ` +nodev sysfs +nodev tmpfs +nodev proc + ext4 + vfat + fuseblk +nodev fuse + btrfs +nodev virtiofs +nodev 9p +nodev nfs4 +nodev zfs +` + +func (s *fsfreezeSuite) SetUpSuite(c *check.C) { + s.scratch = c.MkDir() + + err := os.WriteFile(filepath.Join(s.scratch, "filesystems"), []byte(fakeFilesystems[1:]), 0644) + c.Assert(err, check.IsNil) + s.restoreFilesystems = fsfreeze.MockFilesystems(filepath.Join(s.scratch, "filesystems")) + + s.restoreMountinfo = fsfreeze.MockMountinfo(filepath.Join(s.scratch, "mountinfo")) + + s.restoreTimeout = fsfreeze.MockTimeout(50 * time.Millisecond) + + s.restoreFsFreeze = fsfreeze.MockFsFreeze(s.fsFreeze) + s.restoreFsThaw = fsfreeze.MockFsThaw(s.fsThaw) +} + +func (s *fsfreezeSuite) SetUpTest(c *check.C) { + s.frozen = map[string]error{} + s.canThaw = make(chan struct{}) + s.hangup = false +} + +func (s *fsfreezeSuite) TearDownSuite(c *check.C) { + s.restoreFsThaw() + s.restoreFsFreeze() + s.restoreMountinfo() + s.restoreFilesystems() +} + +func (s *fsfreezeSuite) fsFreeze(file *os.File) error { + if err := s.frozen[file.Name()]; err != nil { + return err + } + s.frozen[file.Name()] = unix.EBUSY + return nil +} + +func (s *fsfreezeSuite) fsThaw(file *os.File) error { + // Ensure the tests have finished checking s.frozen before we touch it + // again. If the freeze times out, the test is very likely to be past that + // point, but `go test -race` can detect the lack of synchronization. + <-s.canThaw + + if err := s.frozen[file.Name()]; !errors.Is(err, unix.EBUSY) { + return err + } + delete(s.frozen, file.Name()) + return nil +} + +func (s *fsfreezeSuite) TestFreezeLocalFilesystemsOK(c *check.C) { + dir := c.MkDir() + mountinfo := fmt.Sprintf(` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / %s rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:], dir) + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + thaw, result, err := s.freezeLocalFilesystems() + c.Assert(err, check.IsNil) + + c.Check(s.frozen, check.HasLen, 2) + c.Check(s.frozen["/"], testutil.ErrorIs, unix.EBUSY) + c.Check(s.frozen[dir], testutil.ErrorIs, unix.EBUSY) + close(s.canThaw) + + thaw.Close() + c.Assert(<-result, check.IsNil) + + c.Check(s.frozen, check.HasLen, 0) +} + +func (s *fsfreezeSuite) TestFreezeLocalFilesystemsUnsupported(c *check.C) { + dir := c.MkDir() + mountinfo := fmt.Sprintf(` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / %s rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:], dir) + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + s.frozen[dir] = unix.EOPNOTSUPP + + thaw, result, err := s.freezeLocalFilesystems() + c.Assert(err, check.IsNil) + + c.Check(s.frozen, check.HasLen, 2) + c.Check(s.frozen["/"], testutil.ErrorIs, unix.EBUSY) + c.Check(s.frozen[dir], testutil.ErrorIs, unix.EOPNOTSUPP) + close(s.canThaw) + + thaw.Close() + c.Assert(<-result, check.IsNil) + + c.Check(s.frozen, check.HasLen, 1) + c.Check(s.frozen[dir], testutil.ErrorIs, unix.EOPNOTSUPP) +} + +func (s *fsfreezeSuite) TestFreezeLocalFilesystemsRootUnsupported(c *check.C) { + dir := c.MkDir() + mountinfo := fmt.Sprintf(` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / %s rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:], dir) + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + s.frozen["/"] = unix.EOPNOTSUPP + close(s.canThaw) + + thaw, result, err := s.freezeLocalFilesystems() + c.Check(err, check.ErrorMatches, "operation not supported") + + if err == nil { + thaw.Close() + c.Assert(<-result, check.ErrorMatches, "operation not supported") + } + + c.Check(s.frozen, check.HasLen, 1) + c.Check(s.frozen["/"], testutil.ErrorIs, unix.EOPNOTSUPP) +} + +func (s *fsfreezeSuite) TestFreezeLocalFilesystemsError(c *check.C) { + dir := c.MkDir() + mountinfo := fmt.Sprintf(` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / %s rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:], dir) + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + s.frozen[dir] = errors.New("cannot freeze") + close(s.canThaw) + + thaw, result, err := s.freezeLocalFilesystems() + c.Check(err, check.ErrorMatches, "cannot freeze") + + if err == nil { + thaw.Close() + c.Assert(<-result, check.ErrorMatches, "cannot freeze") + } + + c.Check(s.frozen, check.HasLen, 1) + c.Check(s.frozen[dir], check.ErrorMatches, "cannot freeze") +} + +func (s *fsfreezeSuite) TestFreezeLocalFilesystemsHangup(c *check.C) { + dir := c.MkDir() + mountinfo := fmt.Sprintf(` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / %s rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:], dir) + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + s.hangup = true + close(s.canThaw) + + thaw, result, err := s.freezeLocalFilesystems() + c.Check(err, check.ErrorMatches, "cannot report readiness: broken pipe") + + if err == nil { + thaw.Close() + c.Assert(<-result, check.ErrorMatches, "cannot report readiness: broken pipe") + } + + c.Check(s.frozen, check.HasLen, 0) +} + +func (s *fsfreezeSuite) TestFreezeLocalFilesystemsTimeout(c *check.C) { + dir := c.MkDir() + mountinfo := fmt.Sprintf(` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / %s rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:], dir) + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + thaw, result, err := s.freezeLocalFilesystems() + c.Assert(err, check.IsNil) + + c.Check(s.frozen, check.HasLen, 2) + c.Check(s.frozen["/"], testutil.ErrorIs, unix.EBUSY) + c.Check(s.frozen[dir], testutil.ErrorIs, unix.EBUSY) + close(s.canThaw) + + c.Check(<-result, check.ErrorMatches, "context deadline exceeded") + thaw.Close() + + c.Check(s.frozen, check.HasLen, 0) +} + +// freezeLocalFilesystems simulates calling fsfreeze.FreezeLocalFilesystems as +// a subprocess, using pipes to receive the ready signal and return. On +// success, returns an io.Closer that signals the "subprocess" to thaw the +// filesystems, and a channel to receive the error it reports (if any). If the +// ready signal isn't received promptly, we wait for the "subprocess" to +// timeout, close the remaining pipes and return an error. +func (s *fsfreezeSuite) freezeLocalFilesystems() (io.Closer, <-chan error, error) { + rev := revert.New() + defer rev.Fail() + + stdin, thaw := io.Pipe() + rev.Add(func() { + thaw.Close() + }) + + var ready <-chan struct{} + var stdout io.Writer + if s.hangup { + stdout = closedWriter{} + } else { + w := fsfreeze.NewReadyWriter() + stdout = w + ready = w.Ready() + } + + result := make(chan error, 1) + go func() { + defer stdin.Close() + result <- fsfreeze.FreezeLocalFilesystems(stdin, stdout) + close(result) + }() + + select { + case <-ready: + rev.Success() + return thaw, result, nil + case err := <-result: + return nil, nil, err + } +} + +type closedWriter struct{} + +func (closedWriter) Write([]byte) (int, error) { + return 0, unix.EPIPE +} + +func (s *fsfreezeSuite) TestLocalMountsOK(c *check.C) { + mountinfo := ` +23 28 0:22 / /proc rw,nosuid,nodev,noexec,relatime shared:12 - proc proc rw +24 28 0:23 / /sys rw,nosuid,nodev,noexec,relatime shared:2 - sysfs sysfs rw +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw,discard +41 28 0:37 / /tmp rw,nosuid,nodev shared:20 - tmpfs tmpfs rw +44 28 252:16 / /boot rw,relatime shared:22 - ext4 /dev/vda16 rw +46 44 252:15 / /boot/efi rw,relatime shared:24 - vfat /dev/vda15 rw +55 28 0:46 / /usr/local/lib/workshop/guest ro,relatime shared:30 - virtiofs workshop.bin ro +62 28 0:51 / /mnt/share rw,relatime shared:40 - nfs4 server:/export rw +70 28 252:32 / /mnt/backup ro,relatime shared:50 - ext4 /dev/vdc ro +75 28 0:70 /@home /home rw,relatime shared:22 - btrfs /dev/vdd1 rw,subvol=/@home +`[1:] + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + mounts, root, err := fsfreeze.LocalMounts() + c.Assert(err, check.IsNil) + c.Check(mountDirs(mounts), check.DeepEquals, []string{"/home", "/mnt/backup", "/boot/efi", "/boot", "/"}) + c.Check(root, check.Equals, "252:1") +} + +func (s *fsfreezeSuite) TestLocalMountsCompactsBySuperblock(c *check.C) { + mountinfo := ` +28 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw +44 28 252:16 / /srv rw,relatime shared:22 - ext4 /dev/vda16 rw +45 44 252:1 /project /srv/project rw,relatime shared:1 - ext4 /dev/vda1 rw +`[1:] + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + mounts, root, err := fsfreeze.LocalMounts() + c.Assert(err, check.IsNil) + // It's important that / is chosen over /project, because /srv could be + // backed by a loop file on /, and freezing /project also freezes /. + c.Check(mountDirs(mounts), check.DeepEquals, []string{"/srv", "/"}) + c.Check(root, check.Equals, "252:1") +} + +func mountDirs(mounts []*osutil.MountInfoEntry) []string { + dirs := make([]string, 0, len(mounts)) + for _, m := range mounts { + dirs = append(dirs, m.MountDir) + } + return dirs +} + +func (s *fsfreezeSuite) TestLocalMountsRequiresRootFS(c *check.C) { + mountinfo := ` +23 28 0:22 / /proc rw,relatime shared:12 - proc proc rw +`[1:] + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + _, _, err = fsfreeze.LocalMounts() + c.Check(err, check.ErrorMatches, "root filesystem not mounted") +} + +func (s *fsfreezeSuite) TestLocalMountsRequiresLocalRootFS(c *check.C) { + mountinfo := ` +28 1 0:51 / / rw,relatime shared:1 - nfs4 server:/export rw +44 28 252:16 / /boot rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:] + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + _, _, err = fsfreeze.LocalMounts() + c.Check(err, check.ErrorMatches, `root filesystem 0:51 \(nfs4\) is not freezable`) +} + +func (s *fsfreezeSuite) TestLocalMountsSupportForZFS(c *check.C) { + mountinfo := ` +28 1 0:60 / / rw,relatime shared:1 - zfs rpool/ROOT/ubuntu rw,xattr +44 28 252:16 / /boot rw,relatime shared:22 - ext4 /dev/vda16 rw +`[1:] + err := os.WriteFile(filepath.Join(s.scratch, "mountinfo"), []byte(mountinfo), 0644) + c.Assert(err, check.IsNil) + + _, _, err = fsfreeze.LocalMounts() + c.Check(err, check.ErrorMatches, `root filesystem 0:60 \(zfs\) is not freezable`) +} diff --git a/internal/fsfreeze/sys/generate.sh b/internal/fsfreeze/sys/generate.sh new file mode 100755 index 000000000..bc1482b9d --- /dev/null +++ b/internal/fsfreeze/sys/generate.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -eu +cd -- "$(dirname -- "$0")" + +temp=$(mktemp --directory --tmpdir="$(pwd)") +trap 'rm -rf -- "$temp"' EXIT + +cd "$temp" + +# Limit to supported architectures for safety. Most architectures share the +# _IOWR macro in include/uapi/asm-generic/ioctl.h, but others have a different +# one in arch//include/uapi/asm/ioctl.h. Currently I think FIFREEZE and +# related constants end up the same on all architectures regardless, but it's +# worth checking that when adding support for a new architecture. +cat <zsysnum_linux.go +//go:build amd64 || arm64 || riscv64 + +EOF + +ln -s ../sysnum_linux.go . +go tool cgo -godefs sysnum_linux.go >>zsysnum_linux.go + +gofmt -w zsysnum_linux.go + +mv zsysnum_linux.go .. diff --git a/internal/fsfreeze/sys/syscall_linux.go b/internal/fsfreeze/sys/syscall_linux.go new file mode 100644 index 000000000..eedc2a0b6 --- /dev/null +++ b/internal/fsfreeze/sys/syscall_linux.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 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 . + +//go:generate ./generate.sh + +package sys + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func FsFreeze(file *os.File) error { + if err := unix.IoctlSetInt(int(file.Fd()), FIFREEZE, 0); err != nil { + return &os.PathError{Op: "fsfreeze", Path: file.Name(), Err: err} + } + return nil +} + +func FsThaw(file *os.File) error { + if err := unix.IoctlSetInt(int(file.Fd()), FITHAW, 0); err != nil { + return &os.PathError{Op: "fsthaw", Path: file.Name(), Err: err} + } + return nil +} diff --git a/internal/fsfreeze/sys/sysnum_linux.go b/internal/fsfreeze/sys/sysnum_linux.go new file mode 100644 index 000000000..f6733fefc --- /dev/null +++ b/internal/fsfreeze/sys/sysnum_linux.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 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 . + +//go:build ignore + +// This file is used as input to cgo -godefs to define the below constants +// without relying on cgo. +package sys + +/* +#include +*/ +import "C" + +const ( + FIFREEZE = C.FIFREEZE + FITHAW = C.FITHAW +) diff --git a/internal/fsfreeze/sys/zsysnum_linux.go b/internal/fsfreeze/sys/zsysnum_linux.go new file mode 100644 index 000000000..89eaf2d48 --- /dev/null +++ b/internal/fsfreeze/sys/zsysnum_linux.go @@ -0,0 +1,11 @@ +//go:build amd64 || arm64 || riscv64 + +// Code generated by cmd/cgo -godefs; DO NOT EDIT. +// cgo -godefs sysnum_linux.go + +package sys + +const ( + FIFREEZE = 0xc0045877 + FITHAW = 0xc0045878 +) diff --git a/internal/idmap/idmapset_linux.go b/internal/idmap/idmapset_linux.go new file mode 100644 index 000000000..97cb9aeec --- /dev/null +++ b/internal/idmap/idmapset_linux.go @@ -0,0 +1,496 @@ +package idmap + +import ( + "bufio" + "errors" + "fmt" + "os" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/canonical/lxd/shared" +) + +var ( + // ErrHostIdIsSubId is returned when an attempt is made to add an idmap entry + // that intersects with an existing entry's host IDs. + ErrHostIdIsSubId = errors.New("host id is in the range of subids") //nolint:revive +) + +type IdRange struct { //nolint:revive + Isuid bool + Isgid bool + Startid int64 + Endid int64 +} + +// Contains checks if the given id is within the range defined by Startid and Endid. +func (i *IdRange) Contains(id int64) bool { + return id >= i.Startid && id <= i.Endid +} + +// ToLxcString returns the idmap entry in a format suitable for lxc.idmap. +func (e *IdmapEntry) ToLxcString() []string { + digits := fmt.Sprintf("%d %d %d", e.Nsid, e.Hostid, e.Maprange) + + if e.Isuid && e.Isgid { + return []string{ + "u " + digits, + "g " + digits, + } + } + + if e.Isuid { + return []string{"u " + digits} + } + + return []string{"g " + digits} +} + +// isBetween returns true if x is in the range [low, high). +func isBetween(x, low, high int64) bool { + return x >= low && x < high +} + +// HostidsIntersect checks if the host IDs of two idmap entries intersect. +func (e *IdmapEntry) HostidsIntersect(i IdmapEntry) bool { + if (e.Isuid && i.Isuid) || (e.Isgid && i.Isgid) { + switch { + case isBetween(e.Hostid, i.Hostid, i.Hostid+i.Maprange): + return true + case isBetween(i.Hostid, e.Hostid, e.Hostid+e.Maprange): + return true + case isBetween(e.Hostid+e.Maprange, i.Hostid, i.Hostid+i.Maprange): + return true + case isBetween(i.Hostid+i.Maprange, e.Hostid, e.Hostid+e.Maprange): + return true + } + } + + return false +} + +// Intersects checks if two idmap entries intersect. +func (e *IdmapEntry) Intersects(i IdmapEntry) bool { + if (e.Isuid && i.Isuid) || (e.Isgid && i.Isgid) { + switch { + case isBetween(e.Hostid, i.Hostid, i.Hostid+i.Maprange-1): + return true + case isBetween(i.Hostid, e.Hostid, e.Hostid+e.Maprange-1): + return true + case isBetween(e.Hostid+e.Maprange-1, i.Hostid, i.Hostid+i.Maprange-1): + return true + case isBetween(i.Hostid+i.Maprange-1, e.Hostid, e.Hostid+e.Maprange-1): + return true + case isBetween(e.Nsid, i.Nsid, i.Nsid+i.Maprange-1): + return true + case isBetween(i.Nsid, e.Nsid, e.Nsid+e.Maprange-1): + return true + case isBetween(e.Nsid+e.Maprange-1, i.Nsid, i.Nsid+i.Maprange-1): + return true + case isBetween(i.Nsid+i.Maprange-1, e.Nsid, e.Nsid+e.Maprange-1): + return true + } + } + return false +} + +// Usable returns whether or not the idmap entry is usable in the current user namespace. +func (e *IdmapEntry) Usable() error { + kernelIdmap, err := CurrentIdmapSet() + if err != nil { + return err + } + + kernelRanges, err := kernelIdmap.ValidRanges() + if err != nil { + return err + } + + // Validate the uid map + if e.Isuid { + valid := false + for _, kernelRange := range kernelRanges { + if !kernelRange.Isuid { + continue + } + + if kernelRange.Contains(e.Hostid) && kernelRange.Contains(e.Hostid+e.Maprange-1) { + valid = true + break + } + } + + if !valid { + return fmt.Errorf("the %q map cannot work in the current user namespace", e.ToLxcString()) + } + } + + // Validate the gid map + if e.Isgid { + valid := false + for _, kernelRange := range kernelRanges { + if !kernelRange.Isgid { + continue + } + + if kernelRange.Contains(e.Hostid) && kernelRange.Contains(e.Hostid+e.Maprange-1) { + valid = true + break + } + } + + if !valid { + return fmt.Errorf("the %q map cannot work in the current user namespace", e.ToLxcString()) + } + } + + return nil +} + +// Len returns the length of the IdmapSet. +func (m IdmapSet) Len() int { + return len(m.Idmap) +} + +// Less compares the elements with indexes i and j. +func (m IdmapSet) Less(i, j int) bool { + if m.Idmap[i].Isuid != m.Idmap[j].Isuid { + return m.Idmap[i].Isuid + } + + if m.Idmap[i].Isgid != m.Idmap[j].Isgid { + return m.Idmap[i].Isgid + } + + return m.Idmap[i].Nsid < m.Idmap[j].Nsid +} + +// Swap swaps the elements with indexes i and j. +func (m IdmapSet) Swap(i, j int) { + m.Idmap[i], m.Idmap[j] = m.Idmap[j], m.Idmap[i] +} + +// Usable checks if all entries in the IdmapSet are usable in the current user namespace. +func (m IdmapSet) Usable() error { + for _, e := range m.Idmap { + err := e.Usable() + if err != nil { + return err + } + } + + return nil +} + +// ValidRanges returns a list of valid ID ranges from the IdmapSet. +func (m IdmapSet) ValidRanges() ([]*IdRange, error) { + ranges := []*IdRange{} + + // Sort the map + idmap := IdmapSet{} + err := shared.DeepCopy(&m, &idmap) + if err != nil { + return nil, err + } + + sort.Sort(idmap) + + for _, mapEntry := range idmap.Idmap { + var entry *IdRange + for _, idEntry := range ranges { + if mapEntry.Isuid != idEntry.Isuid || mapEntry.Isgid != idEntry.Isgid { + continue + } + + if idEntry.Endid+1 == mapEntry.Nsid { + entry = idEntry + break + } + } + + if entry != nil { + entry.Endid = entry.Endid + mapEntry.Maprange + continue + } + + ranges = append(ranges, &IdRange{ + Isuid: mapEntry.Isuid, + Isgid: mapEntry.Isgid, + Startid: mapEntry.Nsid, + Endid: mapEntry.Nsid + mapEntry.Maprange - 1, + }) + } + + return ranges, nil +} + +// AddSafe adds an entry to the idmap set, breaking apart any ranges that the +// new idmap intersects with in the process. +func (m *IdmapSet) AddSafe(i IdmapEntry) error { + // doAddSafe() can't properly handle mappings that + // both UID and GID, because in this case the "i" idmapping + // will be inserted twice which may result to a further bugs and issues. + // Simplest solution is to split a "both" mapping into two separate ones + // one for UIDs and another one for GIDs. + newUidIdmapEntry := i //nolint:revive + newUidIdmapEntry.Isgid = false + err := m.doAddSafe(newUidIdmapEntry) + if err != nil { + return err + } + + newGidIdmapEntry := i + newGidIdmapEntry.Isuid = false + err = m.doAddSafe(newGidIdmapEntry) + if err != nil { + return err + } + + return nil +} + +func (m *IdmapSet) doAddSafe(i IdmapEntry) error { + result := []IdmapEntry{} + added := false + + if !i.Isuid && !i.Isgid { + return nil + } + + for _, e := range m.Idmap { + if !e.Intersects(i) { + result = append(result, e) + continue + } + + if e.HostidsIntersect(i) { + return ErrHostIdIsSubId + } + + added = true + + lower := IdmapEntry{ + Isuid: e.Isuid, + Isgid: e.Isgid, + Hostid: e.Hostid, + Nsid: e.Nsid, + Maprange: i.Nsid - e.Nsid, + } + + upper := IdmapEntry{ + Isuid: e.Isuid, + Isgid: e.Isgid, + Hostid: e.Hostid + lower.Maprange + i.Maprange, + Nsid: i.Nsid + i.Maprange, + Maprange: e.Maprange - i.Maprange - lower.Maprange, + } + + if lower.Maprange > 0 { + result = append(result, lower) + } + + result = append(result, i) + if upper.Maprange > 0 { + result = append(result, upper) + } + } + + if !added { + result = append(result, i) + } + + m.Idmap = result + return nil +} + +// getFromProc gets a uid or gid mapping from /proc/self/{g,u}id_map. +func getFromProc(fname string) ([][]int64, error) { + entries := [][]int64{} + + f, err := os.Open(fname) + if err != nil { + return nil, err + } + + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + // Validate format + s := strings.Fields(scanner.Text()) + if len(s) < 3 { + return nil, fmt.Errorf("unexpected values in %q: %q", fname, s) + } + + // Get range start + entryStart, err := strconv.ParseUint(s[0], 10, 32) + if err != nil { + continue + } + + // Get range size + entryHost, err := strconv.ParseUint(s[1], 10, 32) + if err != nil { + continue + } + + // Get range size + entrySize, err := strconv.ParseUint(s[2], 10, 32) + if err != nil { + continue + } + + entries = append(entries, []int64{int64(entryStart), int64(entryHost), int64(entrySize)}) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, errors.New("namespace does not have any map set") + } + + return entries, nil +} + +func KernelDefaultMap() (*IdmapSet, error) { + idmapset := new(IdmapSet) + + kernelMap, err := CurrentIdmapSet() + if err != nil { + // Hardcoded fallback map + e := IdmapEntry{Isuid: true, Isgid: false, Nsid: 0, Hostid: 1000000, Maprange: 1000000000} + idmapset.Idmap = append(idmapset.Idmap, e) + + e = IdmapEntry{Isuid: false, Isgid: true, Nsid: 0, Hostid: 1000000, Maprange: 1000000000} + idmapset.Idmap = append(idmapset.Idmap, e) + return idmapset, nil //nolint:nilerr + } + + // Look for mapped ranges + kernelRanges, err := kernelMap.ValidRanges() + if err != nil { + return nil, err + } + + // Special case for when we have the full kernel range + fullKernelRanges := []*IdRange{ + {true, false, int64(0), int64(4294967294)}, + {false, true, int64(0), int64(4294967294)}} + + if reflect.DeepEqual(kernelRanges, fullKernelRanges) { + // Hardcoded fallback map + e := IdmapEntry{Isuid: true, Isgid: false, Nsid: 0, Hostid: 1000000, Maprange: 1000000000} + idmapset.Idmap = append(idmapset.Idmap, e) + + e = IdmapEntry{Isuid: false, Isgid: true, Nsid: 0, Hostid: 1000000, Maprange: 1000000000} + idmapset.Idmap = append(idmapset.Idmap, e) + return idmapset, nil + } + + // Find a suitable uid range + for _, entry := range kernelRanges { + // We only care about uids right now + if !entry.Isuid { + continue + } + + // We want a map that's separate from the system's own POSIX allocation + if entry.Endid < 100000 { + continue + } + + // Don't use the first 100000 ids + if entry.Startid < 100000 { + entry.Startid = 100000 + } + + // Check if we have enough ids + if entry.Endid-entry.Startid < 65536 { + continue + } + + // Add the map + e := IdmapEntry{Isuid: true, Isgid: false, Nsid: 0, Hostid: entry.Startid, Maprange: entry.Endid - entry.Startid + 1} + idmapset.Idmap = append(idmapset.Idmap, e) + + // NOTE: Remove once LXD can deal with multiple shadow maps + break + } + + // Find a suitable gid range + for _, entry := range kernelRanges { + // We only care about gids right now + if !entry.Isgid { + continue + } + + // We want a map that's separate from the system's own POSIX allocation + if entry.Endid < 100000 { + continue + } + + // Don't use the first 65536 ids + if entry.Startid < 100000 { + entry.Startid = 100000 + } + + // Check if we have enough ids + if entry.Endid-entry.Startid < 65536 { + continue + } + + // Add the map + e := IdmapEntry{Isuid: false, Isgid: true, Nsid: 0, Hostid: entry.Startid, Maprange: entry.Endid - entry.Startid + 1} + idmapset.Idmap = append(idmapset.Idmap, e) + + // NOTE: Remove once LXD can deal with multiple shadow maps + break + } + + return idmapset, nil +} + +// CurrentIdmapSet creates an idmap of the current allocation. +func CurrentIdmapSet() (*IdmapSet, error) { + idmapset := new(IdmapSet) + + // Parse the uidmap + entries, err := getFromProc("/proc/self/uid_map") + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + // Fallback map + e := IdmapEntry{Isuid: true, Nsid: 0, Hostid: 0, Maprange: 0} + idmapset.Idmap = append(idmapset.Idmap, e) + } else { + for _, entry := range entries { + e := IdmapEntry{Isuid: true, Nsid: entry[0], Hostid: entry[1], Maprange: entry[2]} + idmapset.Idmap = append(idmapset.Idmap, e) + } + } + + // Parse the gidmap + entries, err = getFromProc("/proc/self/gid_map") + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + // Fallback map + e := IdmapEntry{Isgid: true, Nsid: 0, Hostid: 0, Maprange: 0} + idmapset.Idmap = append(idmapset.Idmap, e) + } else { + for _, entry := range entries { + e := IdmapEntry{Isgid: true, Nsid: entry[0], Hostid: entry[1], Maprange: entry[2]} + idmapset.Idmap = append(idmapset.Idmap, e) + } + } + + return idmapset, nil +} diff --git a/internal/idmap/structs.go b/internal/idmap/structs.go new file mode 100644 index 000000000..e29637247 --- /dev/null +++ b/internal/idmap/structs.go @@ -0,0 +1,15 @@ +package idmap + +// IdmapEntry is a single idmap entry (line). +type IdmapEntry struct { + Isuid bool `json:"Isuid"` + Isgid bool `json:"Isgid"` + Hostid int64 `json:"Hostid"` // id as seen on the host - i.e. 100000 + Nsid int64 `json:"Nsid"` // id as seen in the ns - i.e. 0 + Maprange int64 `json:"Maprange"` +} + +// IdmapSet is a list of IdmapEntry with some functions on it. +type IdmapSet struct { + Idmap []IdmapEntry `json:"Idmap"` +} diff --git a/internal/interfaces/lxd_device/tests/integration/backend_test.go b/internal/interfaces/lxd_device/tests/integration/backend_test.go index 62835b962..738ba65d6 100644 --- a/internal/interfaces/lxd_device/tests/integration/backend_test.go +++ b/internal/interfaces/lxd_device/tests/integration/backend_test.go @@ -41,6 +41,10 @@ import ( "github.com/canonical/workshop/internal/workshop/lxd/tests/helper" ) +func TestMain(m *testing.M) { + os.Exit(helper.RunTestsOrWorkshopCtl(m)) +} + type backendDeviceSuite struct { ctx context.Context be *lxdbackend.Backend @@ -81,17 +85,6 @@ func (f *backendDeviceSuite) readWorkshopFile(c *check.C, fname string) string { return string(buf) } -func defaultTestDevices(pid, w string) ([]workshop.Mount, []workshop.ProxyEntry) { - cwd, _ := os.Getwd() - mounts := []workshop.Mount{{ - Name: workshop.ConfigProjectPathDevice, - Type: workshop.HostWorkshop, - What: cwd, - Where: workshop.WorkshopProjectPath, - }} - return mounts, nil -} - func (f *backendDeviceSuite) SetUpTest(c *check.C) { dirs.SetRootDir(c.MkDir()) c.Assert(dirs.CreateDirs(), check.IsNil) @@ -125,8 +118,20 @@ func (f *backendDeviceSuite) SetUpTest(c *check.C) { f.setupRepo(c) - defer workshop.FakeDefaultDevices(defaultTestDevices)() - helper.LaunchTestWorkshop(c, f.ctx, f.be, c.MkDir()) + defer workshop.FakeDefaultDevices(helper.TestDevices)() + project := c.MkDir() + helper.LaunchTestWorkshop(c, f.ctx, f.be, project) + + // Required because WorkshopWorkshop mounts use + // x-systemd-requires=/project. + prjMount := workshop.Mount{ + Name: workshop.ConfigProjectPathDevice, + Type: workshop.HostWorkshop, + What: project, + Where: workshop.WorkshopProjectPath, + } + err = f.be.AddWorkshopMount(f.ctx, "test", prjMount) + c.Assert(err, check.IsNil) } func (f *backendDeviceSuite) TearDownTest(c *check.C) { diff --git a/internal/overlord/healthstate/healthstate_test.go b/internal/overlord/healthstate/healthstate_test.go index 90211ff49..967769b5c 100644 --- a/internal/overlord/healthstate/healthstate_test.go +++ b/internal/overlord/healthstate/healthstate_test.go @@ -112,7 +112,7 @@ var ( func (s *healthSuite) launchWorkshopWithSDKs(c *check.C, sdks []workshop.SdkRecord, hooks map[string]map[string]string) *workshop.Workshop { wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04", Sdks: sdks} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) ws, err := s.backend.WorkshopFs(s.ctx, "ws") @@ -252,7 +252,7 @@ func (s *healthSuite) TestWorkshopHealthOperationInProgress(c *check.C) { chg := s.state.NewChange("launch", "test") chg.Set("project-id", s.project.ProjectId) chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) task := s.state.NewTask("create-workshop", "test task") task.Set("workshop-file", "name: ws\nbase: ubuntu@20.04\n") @@ -280,7 +280,7 @@ func (s *healthSuite) TestWorkshopHealthOperationWaitingWithNotes(c *check.C) { chg := s.state.NewChange("refresh", "test") chg.Set("project-id", s.project.ProjectId) chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) chg.SetStatus(state.WaitStatus) task := s.state.NewTask("create-workshop", "test task") @@ -350,7 +350,7 @@ func (s *healthSuite) TestCheckStatusPending(c *check.C) { chg := s.state.NewChange("refresh", "test") chg.Set("project-id", s.project.ProjectId) chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) chg.SetStatus(state.DoingStatus) task := s.state.NewTask("create-workshop", "test task") @@ -384,7 +384,7 @@ func (s *healthSuite) TestCheckStatusWaiting(c *check.C) { chg := s.state.NewChange("refresh", "test") chg.Set("project-id", s.project.ProjectId) chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) chg.SetStatus(state.WaitStatus) task := s.state.NewTask("create-workshop", "test task") diff --git a/internal/overlord/hookstate/handlers_test.go b/internal/overlord/hookstate/handlers_test.go index 358e277c8..1cbfe9e16 100644 --- a/internal/overlord/hookstate/handlers_test.go +++ b/internal/overlord/hookstate/handlers_test.go @@ -148,7 +148,7 @@ func (s *hookSuite) TestExecHookDoesNotExist(c *check.C) { // Launch a workshop provinding no hooks wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) @@ -172,7 +172,7 @@ func (s *hookSuite) TestExecHookSkipsStrayHooksFile(c *check.C) { chg.AddTask(t1) wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) @@ -206,7 +206,7 @@ func (s *hookSuite) TestExecHookStatError(c *check.C) { chg.AddTask(t1) wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) @@ -667,7 +667,7 @@ func (s *hookSuite) TestHookWithMultipleHandlersIsError(c *check.C) { func (s *hookSuite) launchWorkshop(c *check.C, newsdk string) { wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04", Sdks: []workshop.SdkRecord{{Name: "one", Channel: "latest/stable"}}} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) ws, err := s.backend.WorkshopFs(s.ctx, "ws") diff --git a/internal/overlord/ifacestate/ifacemgr_test.go b/internal/overlord/ifacestate/ifacemgr_test.go index b4416e81f..a1930b8ab 100644 --- a/internal/overlord/ifacestate/ifacemgr_test.go +++ b/internal/overlord/ifacestate/ifacemgr_test.go @@ -145,7 +145,7 @@ func (s *interfaceManagerSuite) launchWorkshop(c *check.C, ws string, sdks []sdk err = yaml.Unmarshal(workshopFile.Bytes(), &wf) c.Assert(err, check.IsNil) - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err = s.wsbackend.LaunchOrRebuildWorkshop(ctx, &wf, snapshot) c.Assert(err, check.IsNil) diff --git a/internal/overlord/sdkstate/handlers_test.go b/internal/overlord/sdkstate/handlers_test.go index c59d5c7cd..9c2a93b24 100644 --- a/internal/overlord/sdkstate/handlers_test.go +++ b/internal/overlord/sdkstate/handlers_test.go @@ -195,7 +195,7 @@ func (s *sdkStateSuite) SetUpTest(c *check.C) { {Name: "test", Channel: "latest/stable"}, {Name: "test-broken", Channel: "latest/stable"}, }} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err = s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Assert(err, check.IsNil) @@ -456,7 +456,7 @@ func (s *sdkStateSuite) TestRetrieveSystemSdkSuccess(c *check.C) { setWorkshopProject("ws", s.project, t) chg.Set("user", "testuser") chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseOnly(sdk.R(1), "ubuntu@22.04", "fakeimage123")) + chg.Set("ws_new_base", workshop.BaseOnly(sdk.R(1), "ubuntu@22.04", workshop.ConfinementContainer, "fakeimage123")) chg.Set("ws_new_sdks", []sdk.Setup{newSdk}) chg.AddTask(t) @@ -506,7 +506,7 @@ func (s *sdkStateSuite) TestSnapshotSdkTwice(c *check.C) { s.state.Lock() defer s.state.Unlock() - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"} sdks := []sdk.Setup{{ Name: "test", Channel: "latest/stable", @@ -601,7 +601,7 @@ func (s *sdkStateSuite) TestSnapshotSkippedPostResume(c *check.C) { chg.Set("user", "testuser") chg.Set("project-id", s.project.ProjectId) chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", sdks) chg.Set("wait-setup", conflict.ChangeSetup{Mode: conflict.ChangeWaitOnError.String()}) chg.AddTask(t1) diff --git a/internal/overlord/workshopstate/handlers_test.go b/internal/overlord/workshopstate/handlers_test.go index 321a87c93..c3cda8d5f 100644 --- a/internal/overlord/workshopstate/handlers_test.go +++ b/internal/overlord/workshopstate/handlers_test.go @@ -156,7 +156,7 @@ func (s *workshopHandlers) TestStopPeriodicProgressUpdate(c *check.C) { defer s.state.Unlock() s.createWFile(c, "ws", wsFocal) wf := &workshop.File{Name: "ws", Base: "ubuntu@20.04"} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) @@ -199,7 +199,7 @@ func (s *workshopHandlers) TestUndoStash(c *check.C) { {Name: "test2", Channel: "latest/stable"}, }} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) @@ -244,7 +244,7 @@ func (s *workshopHandlers) TestRemoveWorkshop(c *check.C) { userDataDir := workshop.UserDataRootDir(s.user.HomeDir, nil) for _, wf := range wFiles { - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Check(err, check.IsNil) @@ -324,7 +324,7 @@ func (s *workshopHandlers) TestCreateWorkshopNoWorkshopDefinitionFound(c *check. setWorkshopProject("ws", s.project, t1) chg.Set("user", "testuser") chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) chg.AddTask(t1) @@ -350,7 +350,7 @@ func (s *workshopHandlers) TestCreateWorkshopWithSystemSdk(c *check.C) { setWorkshopProject("ws", s.project, t1) chg.Set("user", "testuser") chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) chg.AddTask(t1) @@ -380,7 +380,7 @@ func (s *workshopHandlers) TestCreateWorkshopCleanup(c *check.C) { setWorkshopProject("ws", s.project, t1) chg.Set("user", "testuser") chg.Set("ws_new_format", sdk.R(1)) - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) chg.Set("ws_new_sdks", []sdk.Setup{}) chg.AddTask(t1) @@ -412,7 +412,7 @@ func (s *workshopHandlers) TestRebuildWorkshopNoCleanup(c *check.C) { t1.Set("workshop-file", wsJammy) setWorkshopProject("ws", s.project, t1) chg.Set("user", "testuser") - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"} chg.Set("ws_new_format", sdk.R(1)) chg.Set("ws_new_base", image) chg.Set("ws_new_sdks", []sdk.Setup{}) @@ -452,7 +452,7 @@ func (s *workshopHandlers) TestDownloadBase(c *check.C) { t1 := s.state.NewTask("download-base", "...") setWorkshopProject("ws", s.project, t1) chg.Set("user", "testuser") - chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage1234"}) + chg.Set("ws_new_base", workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage1234"}) chg.AddTask(t1) s.state.Unlock() @@ -660,7 +660,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterRemove(c *check.C) { manifest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk, testSdk2}, } snapshot1 := workshop.SdkSnapshot(manifest.Format, manifest.Image, manifest.Sdks[:1]) @@ -712,7 +712,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterFailedLaunch(c *check.C) { manifest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk, testSdk2}, } snapshot1 := workshop.SdkSnapshot(manifest.Format, manifest.Image, manifest.Sdks[:1]) @@ -771,7 +771,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterRefresh(c *check.C) { current := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot1 := workshop.SdkSnapshot(current.Format, current.Image, current.Sdks) @@ -779,7 +779,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterRefresh(c *check.C) { latest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk2}, } snapshot2 := workshop.SdkSnapshot(latest.Format, latest.Image, latest.Sdks) @@ -845,7 +845,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterFailedRefresh(c *check.C) { current := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot1 := workshop.SdkSnapshot(current.Format, current.Image, current.Sdks) @@ -853,7 +853,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterFailedRefresh(c *check.C) { latest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk2}, } snapshot2 := workshop.SdkSnapshot(latest.Format, latest.Image, latest.Sdks) @@ -920,7 +920,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterRemoveMidRefresh(c *check.C) current := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot1 := workshop.SdkSnapshot(current.Format, current.Image, current.Sdks) @@ -928,7 +928,7 @@ func (s *workshopHandlers) TestSnapshotRemovedAfterRemoveMidRefresh(c *check.C) latest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk2}, } snapshot2 := workshop.SdkSnapshot(latest.Format, latest.Image, latest.Sdks) @@ -1049,7 +1049,7 @@ func (s *workshopHandlers) TestSnapshotExitCleanupAfterSuccessfulLaunch(c *check manifest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot := workshop.SdkSnapshot(manifest.Format, manifest.Image, manifest.Sdks) @@ -1093,7 +1093,7 @@ func (s *workshopHandlers) TestSnapshotNotRemovedBeforeCooldown(c *check.C) { manifest := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot := workshop.SdkSnapshot(manifest.Format, manifest.Image, manifest.Sdks) @@ -1137,7 +1137,7 @@ func (s *workshopHandlers) TestSnapshotExitCleanupIfUsedAgain(c *check.C) { manifest1 := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot1 := workshop.SdkSnapshot(manifest1.Format, manifest1.Image, manifest1.Sdks) @@ -1145,7 +1145,7 @@ func (s *workshopHandlers) TestSnapshotExitCleanupIfUsedAgain(c *check.C) { manifest2 := workshopstate.Manifest{ File: &workshop.File{Name: "ws2", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } @@ -1203,7 +1203,7 @@ func (s *workshopHandlers) TestSnapshotRetriesCleanupIfBlockingChangesArePresent manifest1 := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot1 := workshop.SdkSnapshot(manifest1.Format, manifest1.Image, manifest1.Sdks) @@ -1211,7 +1211,7 @@ func (s *workshopHandlers) TestSnapshotRetriesCleanupIfBlockingChangesArePresent manifest2 := workshopstate.Manifest{ File: &workshop.File{Name: "ws2", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } @@ -1289,7 +1289,7 @@ func (s *workshopHandlers) TestSnapshotCleanupPerformedByLatestUser(c *check.C) manifest1 := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot1 := workshop.SdkSnapshot(manifest1.Format, manifest1.Image, manifest1.Sdks) @@ -1297,7 +1297,7 @@ func (s *workshopHandlers) TestSnapshotCleanupPerformedByLatestUser(c *check.C) manifest2 := workshopstate.Manifest{ File: &workshop.File{Name: "ws2", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } @@ -1351,7 +1351,7 @@ func (s *workshopHandlers) TestSnapshotCleanupWaitsForDependentSnapshots(c *chec manifest1 := workshopstate.Manifest{ File: &workshop.File{Name: "ws", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk, testSdk2}, } snapshot1 := workshop.SdkSnapshot(manifest1.Format, manifest1.Image, manifest1.Sdks) @@ -1359,7 +1359,7 @@ func (s *workshopHandlers) TestSnapshotCleanupWaitsForDependentSnapshots(c *chec manifest2 := workshopstate.Manifest{ File: &workshop.File{Name: "ws2", Base: "ubuntu@22.04"}, Format: sdk.R(1), - Image: workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{testSdk}, } snapshot2 := workshop.SdkSnapshot(manifest2.Format, manifest2.Image, manifest2.Sdks) diff --git a/internal/overlord/workshopstate/manager.go b/internal/overlord/workshopstate/manager.go index 73a32a111..45e359761 100644 --- a/internal/overlord/workshopstate/manager.go +++ b/internal/overlord/workshopstate/manager.go @@ -123,6 +123,10 @@ func (w *WorkshopManager) Workshop(ctx context.Context, name, pId string) (*work return nil, err } + if w.FormatRevision().N >= 11 && workshop.Format.N < 11 { + w.state.Warnf("%q workshop needs to be refreshed; a future Workshop release will drop support for starting it", name) + } + return workshop, nil } @@ -176,6 +180,12 @@ func (w *WorkshopManager) Workshops(ctx context.Context, pId string) ([]*worksho return nil, err } + for _, workshop := range workshops { + if w.FormatRevision().N >= 11 && workshop.Format.N < 11 { + w.state.Warnf("%q workshop needs to be refreshed; a future Workshop release will drop support for starting it", workshop.Name) + } + } + return workshops, nil } diff --git a/internal/overlord/workshopstate/manifest.go b/internal/overlord/workshopstate/manifest.go index 581c56e9d..0c6914746 100644 --- a/internal/overlord/workshopstate/manifest.go +++ b/internal/overlord/workshopstate/manifest.go @@ -256,7 +256,7 @@ func (a *artifactFinder) launchOrRefreshManifests(ctx context.Context, names []s } files = append(files, file) - image, err := a.backend.GetBase(ctx, file.Base) + image, err := a.backend.GetBase(ctx, file.Base, file.Confinement) if err != nil { return nil, nil, fmt.Errorf("cannot %s %q: %w", action, name, err) } @@ -266,7 +266,15 @@ func (a *artifactFinder) launchOrRefreshManifests(ctx context.Context, names []s if err != nil { return nil, nil, fmt.Errorf("cannot %s %q: %w", action, name, err) } - sdks = slices.Insert(sdks, 0, systemMeta.Setup) + if file.Confinement == workshop.ConfinementContainer { + sdks = slices.Insert(sdks, 0, systemMeta.Setup) + } else if !refresh && !osutil.GetenvBool("WORKSHOP_EXPERIMENTAL_VMS") { + confinement, err := file.Confinement.MarshalText() + if err == nil { + err = fmt.Errorf("confinement %q is experimental\nTo opt in: %q", confinement, "snap set workshop workshop.experimental-vms=1") + } + return nil, nil, fmt.Errorf("cannot %s %q: %w", action, name, err) + } storeSdks = append(storeSdks, sdks) } @@ -285,6 +293,16 @@ func (a *artifactFinder) launchOrRefreshManifests(ctx context.Context, names []s if err != nil { return nil, nil, fmt.Errorf("cannot %s %q: %w", action, name, err) } + + if cur.File.Confinement != files[i].Confinement { + c1, err1 := cur.File.Confinement.MarshalText() + c2, err2 := files[i].Confinement.MarshalText() + if err := cmp.Or(err1, err2); err != nil { + return nil, nil, fmt.Errorf("cannot %s %q: %w", action, name, err) + } + return nil, nil, fmt.Errorf("cannot %s %q: confinement changed from %q to %q", action, name, c1, c2) + } + current = append(current, *cur) } else if err := a.checkNotLaunched(ctx, a.project.ProjectId, name); err != nil { return nil, nil, fmt.Errorf("cannot %s %q: %w", action, name, err) @@ -298,6 +316,9 @@ func (a *artifactFinder) launchOrRefreshManifests(ctx context.Context, names []s format := a.backend.FormatRevision() installOrder := sdkInstallOrder(files[i]) sdks := ordered(installOrder, storeSdks[i], localSdks) + if files[i].Confinement != workshop.ConfinementContainer && len(sdks) > 0 { + return nil, nil, fmt.Errorf("cannot %s %q: SDKs are currently unavailable for virtual machines", action, name) + } latest = append(latest, Manifest{File: files[i], Format: format, Image: images[i], Sdks: sdks}) } diff --git a/internal/overlord/workshopstate/manifest_test.go b/internal/overlord/workshopstate/manifest_test.go index 5222201d0..f0f16a41b 100644 --- a/internal/overlord/workshopstate/manifest_test.go +++ b/internal/overlord/workshopstate/manifest_test.go @@ -217,7 +217,7 @@ func (s *manifestSuite) createWFile(c *check.C, ws, base string, sdks []workshop func (s *manifestSuite) launchWorkshopWithSDKs(c *check.C, ws, base string, sdks []workshop.SdkRecord) *workshop.Workshop { wf := s.createWFile(c, ws, base, sdks) - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err := s.backend.LaunchOrRebuildWorkshop(s.ctx, wf, snapshot) c.Assert(err, check.IsNil) @@ -250,7 +250,7 @@ func (s *manifestSuite) TestLaunchOK(c *check.C) { Sdks: sdks, }) - c.Check(manifests[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + c.Check(manifests[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) c.Check(manifests[1].Image, check.Equals, manifests[0].Image) systemSdk, err := system.SystemSdkMeta() @@ -308,7 +308,7 @@ func (s *manifestSuite) TestRefreshOK(c *check.C) { c.Check(latest[0].File, check.DeepEquals, current[0].File) c.Check(current[0].Format, check.Equals, sdk.R(1)) c.Check(latest[0].Format, check.Equals, sdk.R(2)) - c.Check(current[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + c.Check(current[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) c.Check(latest[0].Image, check.Equals, current[0].Image) // Check base was updated for test-2. @@ -325,7 +325,7 @@ func (s *manifestSuite) TestRefreshOK(c *check.C) { c.Check(current[1].Format, check.Equals, sdk.R(1)) c.Check(latest[1].Format, check.Equals, sdk.R(2)) c.Check(current[1].Image, check.Equals, current[0].Image) - c.Check(latest[1].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"}) + c.Check(latest[1].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) // Check current SDKs are loaded from running workshop. c.Check(current[0].Sdks, check.DeepEquals, []sdk.Setup{oldSdk.Setup}) @@ -376,7 +376,7 @@ func (s *manifestSuite) TestRefreshRestoreOK(c *check.C) { Base: "ubuntu@20.04", Sdks: sdks, }) - c.Check(current[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + c.Check(current[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) c.Check(current[0].Sdks, check.DeepEquals, []sdk.Setup{oldSdk.Setup}) } @@ -442,6 +442,21 @@ func (s *manifestSuite) TestRefreshRequiresStatusReady(c *check.C) { c.Assert(err, check.ErrorMatches, `cannot refresh "test-2": not running`) } +func (s *manifestSuite) TestRefreshRequiresContainer(c *check.C) { + s.state.Lock() + defer s.state.Unlock() + + s.launchWorkshopWithSDKs(c, "test", "ubuntu@20.04", nil) + f, err := os.OpenFile(workshop.Filepath(s.project.Path, "test"), os.O_APPEND|os.O_WRONLY, 0644) + c.Assert(err, check.IsNil) + _, err = f.WriteString("confinement: virtual-machine\n") + c.Assert(f.Close(), check.IsNil) + c.Assert(err, check.IsNil) + + _, _, err = s.manager.RefreshManifests(s.ctx, s.project, []string{"test"}, conflict.RefreshUpdate) + c.Check(err, check.ErrorMatches, `cannot refresh "test": confinement changed from "container" to "virtual-machine"`) +} + func (s *manifestSuite) TestRestoreRequiresCurrentFormat(c *check.C) { s.state.Lock() defer s.state.Unlock() @@ -504,7 +519,7 @@ func (s *manifestSuite) TestLaunchRequiresBase(c *check.C) { s.launchWorkshopWithSDKs(c, "test-1", "ubuntu@20.04", nil) - restoreBase := testutil.FakeFunc(func(ctx context.Context, base string) (workshop.BaseImage, error) { + restoreBase := testutil.FakeFunc(func(ctx context.Context, base string, confinement workshop.Confinement) (workshop.BaseImage, error) { return workshop.BaseImage{}, errors.New("contrived error") }, &s.backend.GetBaseCallback) defer restoreBase() @@ -564,7 +579,7 @@ func (s *manifestSuite) TestLaunchValidRequest(c *check.C) { Base: "ubuntu@20.04", Sdks: sdks, }) - c.Check(manifests[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: "fakeimage123"}) + c.Check(manifests[0].Image, check.Equals, workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}) systemSdk, err := system.SystemSdkMeta() c.Assert(err, check.IsNil) @@ -1102,3 +1117,25 @@ func (s *manifestSuite) TestRefreshSortsSdks(c *check.C) { } c.Check(sorted, check.DeepEquals, expected) } + +func (s *manifestSuite) TestLaunchRejectsVMsWithSDKs(c *check.C) { + s.state.Lock() + defer s.state.Unlock() + + sdks := []workshop.SdkRecord{{Name: "test", Channel: "latest/edge"}} + s.createWFile(c, "test", "ubuntu@20.04", sdks) + f, err := os.OpenFile(workshop.Filepath(s.project.Path, "test"), os.O_APPEND|os.O_WRONLY, 0644) + c.Assert(err, check.IsNil) + _, err = f.WriteString("confinement: virtual-machine\n") + c.Assert(f.Close(), check.IsNil) + c.Assert(err, check.IsNil) + + os.Setenv("WORKSHOP_EXPERIMENTAL_VMS", "1") + _, err = s.manager.LaunchManifests(s.ctx, s.project, []string{"test"}) + c.Check(err, check.ErrorMatches, `cannot launch "test": SDKs are currently unavailable for virtual machines`) + + os.Unsetenv("WORKSHOP_EXPERIMENTAL_VMS") + _, err = s.manager.LaunchManifests(s.ctx, s.project, []string{"test"}) + c.Check(err, check.ErrorMatches, `cannot launch "test": confinement "virtual-machine" is experimental +To opt in: "snap set workshop workshop.experimental-vms=1"`) +} diff --git a/internal/overlord/workshopstate/request.go b/internal/overlord/workshopstate/request.go index c649dac40..875833ee1 100644 --- a/internal/overlord/workshopstate/request.go +++ b/internal/overlord/workshopstate/request.go @@ -400,7 +400,9 @@ func refresh(st *state.State, project workshop.Project, current, latest Manifest addTaskSet(state.NewTaskSet(discard)) stop := st.NewTask("stop-workshop", fmt.Sprintf("Stop %q workshop", latest.File.Name)) - stop.Set("force", true) + // Using force is fine for containers, but for VMs it can lead to (usually + // repairable) filesystem integrity issues, which are copied to the stash. + stop.Set("force", current.File.Confinement == workshop.ConfinementContainer) addTaskSet(state.NewTaskSet(stop)) // Unmount SDKs and remove plugs and slots from interfaces repository. @@ -661,8 +663,7 @@ func (w *WorkshopManager) Exec(ctx context.Context, name, projectId string, args return nil, err } - ctx = context.WithValue(ctx, workshop.ContextProjectId, project.ProjectId) - wp, err := w.backend.Workshop(ctx, name) + wp, err := w.Workshop(ctx, name, projectId) if err != nil { return nil, err } @@ -671,6 +672,7 @@ func (w *WorkshopManager) Exec(ctx context.Context, name, projectId string, args return nil, err } + ctx = context.WithValue(ctx, workshop.ContextProjectId, project.ProjectId) wrkspc, err := w.backend.WorkshopFs(ctx, name) if err != nil { return nil, err diff --git a/internal/overlord/workshopstate/request_test.go b/internal/overlord/workshopstate/request_test.go index 7d2e81a7d..724e8caae 100644 --- a/internal/overlord/workshopstate/request_test.go +++ b/internal/overlord/workshopstate/request_test.go @@ -133,7 +133,7 @@ func (s *requestSuite) launchWorkshopWithSDKs(c *check.C, ws string, sdks []work c.Assert(err, check.IsNil) wf := workshop.File{Name: ws, Base: "ubuntu@20.04", Sdks: sdks} - snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, "fakeimage123") + snapshot := workshop.BaseOnly(sdk.R(1), wf.Base, workshop.ConfinementContainer, "fakeimage123") err = s.backend.LaunchOrRebuildWorkshop(s.ctx, &wf, snapshot) c.Assert(err, check.IsNil) @@ -198,7 +198,7 @@ connections: current := []workshopstate.Manifest{{ File: &oldf, Format: sdk.R(1), - Image: workshop.BaseImage{Name: newf.Base, Fingerprint: "fakeimage123"}, + Image: workshop.BaseImage{Name: newf.Base, Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"}, Sdks: []sdk.Setup{{ Name: "system", Source: sdk.SystemSource, @@ -365,7 +365,7 @@ sdks: err := yaml.Unmarshal([]byte(file), &wf) c.Assert(err, check.IsNil) - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"} uv := sdk.Setup{ Name: "uv", @@ -523,7 +523,7 @@ sdks: err := yaml.Unmarshal([]byte(file), &wf) c.Assert(err, check.IsNil) - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "fakeimage123"} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "fakeimage123"} uv := sdk.Setup{ Name: "uv", diff --git a/internal/waitready/waitready.go b/internal/waitready/waitready.go index a3a4782dc..cf5d5380a 100644 --- a/internal/waitready/waitready.go +++ b/internal/waitready/waitready.go @@ -29,7 +29,7 @@ import ( "github.com/canonical/workshop/internal/systemd" ) -const Timeout = 5 * time.Minute +var Timeout = 5 * time.Minute // IsWaitreadyInvocation reports whether the process was invoked via a symlink // named waitready. This allows multiple logically unrelated commands to be diff --git a/internal/workshop/backend.go b/internal/workshop/backend.go index f085782a0..b2beb782c 100644 --- a/internal/workshop/backend.go +++ b/internal/workshop/backend.go @@ -100,13 +100,15 @@ type SdkVolume struct { type BaseImage struct { // Base name (e.g. ubuntu@24.04). Name string `json:"name"` + // Type of sandbox used to run the image (e.g. container). + Confinement Confinement `json:"confinement"` // Base image identifier, typically a hash. Fingerprint string `json:"fingerprint"` } type BaseImageManager interface { // Lookup the latest image for the given base. - GetBase(ctx context.Context, base string) (BaseImage, error) + GetBase(ctx context.Context, base string, confinement Confinement) (BaseImage, error) // Download the given base image. DownloadBase(ctx context.Context, image BaseImage, report *progress.Reporter) error } @@ -137,8 +139,8 @@ func (s Snapshot) IsBasedOn(other Snapshot) bool { } // BaseOnly identifies a "snapshot" which consists of a base image only. -func BaseOnly(format sdk.Revision, name, fingerprint string) Snapshot { - return Snapshot{Format: format, Image: BaseImage{Name: name, Fingerprint: fingerprint}} +func BaseOnly(format sdk.Revision, name string, confinement Confinement, fingerprint string) Snapshot { + return Snapshot{Format: format, Image: BaseImage{Name: name, Confinement: confinement, Fingerprint: fingerprint}} } // SdkSnapshot identifies a snapshot consisting of a base image and a sequence diff --git a/internal/workshop/fakebackend/backend.go b/internal/workshop/fakebackend/backend.go index e17d76449..b93bc2d34 100644 --- a/internal/workshop/fakebackend/backend.go +++ b/internal/workshop/fakebackend/backend.go @@ -121,7 +121,7 @@ type FakeWorkshopBackend struct { WorkshopFsCalls []*FsCall baseLock sync.Mutex - GetBaseCallback func(ctx context.Context, base string) (workshop.BaseImage, error) + GetBaseCallback func(ctx context.Context, base string, confinement workshop.Confinement) (workshop.BaseImage, error) DownloadBaseCallback func(ctx context.Context, image workshop.BaseImage, report *progress.Reporter) error DownloadBaseCalls []*DownloadCall @@ -624,14 +624,14 @@ func (s *FakeWorkshopBackend) userProject(ctx context.Context) (string, string, return userName, projectId, nil } -func (b *FakeWorkshopBackend) GetBase(ctx context.Context, base string) (workshop.BaseImage, error) { +func (b *FakeWorkshopBackend) GetBase(ctx context.Context, base string, confinement workshop.Confinement) (workshop.BaseImage, error) { b.baseLock.Lock() defer b.baseLock.Unlock() if b.GetBaseCallback != nil { - return b.GetBaseCallback(ctx, base) + return b.GetBaseCallback(ctx, base, confinement) } - return workshop.BaseImage{Name: base, Fingerprint: "fakeimage123"}, nil + return workshop.BaseImage{Name: base, Confinement: confinement, Fingerprint: "fakeimage123"}, nil } func (b *FakeWorkshopBackend) DownloadBase(ctx context.Context, image workshop.BaseImage, report *progress.Reporter) error { diff --git a/internal/workshop/lxd/lxd_backend.go b/internal/workshop/lxd/lxd_backend.go index bf8a148d8..0d243d7fe 100644 --- a/internal/workshop/lxd/lxd_backend.go +++ b/internal/workshop/lxd/lxd_backend.go @@ -18,7 +18,7 @@ import ( "cmp" "context" "embed" - _ "embed" + "encoding/json" "errors" "fmt" "io" @@ -42,11 +42,13 @@ import ( "github.com/canonical/workshop/internal/dirs" "github.com/canonical/workshop/internal/fsutil" + "github.com/canonical/workshop/internal/idmap" "github.com/canonical/workshop/internal/logger" "github.com/canonical/workshop/internal/osutil" "github.com/canonical/workshop/internal/revert" "github.com/canonical/workshop/internal/sdk" "github.com/canonical/workshop/internal/syscheck" + "github.com/canonical/workshop/internal/waitready" "github.com/canonical/workshop/internal/workshop" ) @@ -68,15 +70,11 @@ const ( ) var ( - startCommandTimeout = 1 * time.Minute - storagePoolDriver = "zfs" + storagePoolDriver = "zfs" workshopFormatsChecked = false ) -//go:embed start_command.sh -var startCommand string - func init() { if osutil.IsWSL() { storagePoolDriver = "btrfs" @@ -414,10 +412,10 @@ func (s *Backend) LaunchOrRebuildWorkshop(ctx context.Context, file *workshop.Fi req := api.InstancesPost{ InstancePut: api.InstancePut{ Config: config, - Devices: defaultDevices(usr, projectId, file.Name), + Devices: defaultDevices(usr, projectId, file.Name, file.Confinement), }, Name: InstanceName(file.Name, projectId), - Type: api.InstanceTypeContainer, + Type: instanceType(file.Confinement), } if !snapshot.IsBase() { @@ -432,7 +430,14 @@ func (s *Backend) LaunchOrRebuildWorkshop(ctx context.Context, file *workshop.Fi return err } - return s.adjustInstanceTemplates(conn, req.Name) + return s.adjustInstanceTemplates(conn, req.Name, file.Confinement) +} + +func instanceType(confinement workshop.Confinement) api.InstanceType { + if confinement == workshop.ConfinementVirtualMachine { + return api.InstanceTypeVM + } + return api.InstanceTypeContainer } func (s *Backend) launchOrRebuildFromImage(conn lxd.InstanceServer, usr *user.User, req api.InstancesPost) error { @@ -517,7 +522,7 @@ var instanceTemplates embed.FS // from an image (although the instance-id is different for 22.04 and up), but // when rebuilding a workshop from a snapshot, it results in both the hostname // and instance-id being taken from the snapshot. -func (s *Backend) adjustInstanceTemplates(conn lxd.InstanceServer, name string) error { +func (s *Backend) adjustInstanceTemplates(conn lxd.InstanceServer, name string, confinement workshop.Confinement) error { fromImage := []string{"create"} fromSnapshot := []string{"create", "copy"} @@ -530,10 +535,6 @@ func (s *Backend) adjustInstanceTemplates(conn lxd.InstanceServer, name string) When: fromSnapshot, Template: "hostname.tpl", }, - "/etc/machine-id": { - When: fromSnapshot, - Template: "machine-id.tpl", - }, "/etc/ssh/ssh_host_ed25519_key": { When: fromSnapshot, Template: "ssh_host_ed25519_key.tpl", @@ -555,11 +556,17 @@ func (s *Backend) adjustInstanceTemplates(conn lxd.InstanceServer, name string) Template: "eth0.network.tpl", Properties: map[string]string{"domain": networkDomain}, }, - dirs.WorkshopSocketPath + ".untrusted": { + } + if confinement == workshop.ConfinementContainer { + templates["/etc/machine-id"] = &api.ImageMetadataTemplate{ + When: fromSnapshot, + Template: "machine-id.tpl", + } + templates[dirs.WorkshopSocketPath+".untrusted"] = &api.ImageMetadataTemplate{ When: fromImage, CreateOnly: true, Template: "workshop.socket.untrusted.tpl", - }, + } } metadata, etag, err := conn.GetInstanceMetadata(name) @@ -586,12 +593,8 @@ func (s *Backend) adjustInstanceTemplates(conn lxd.InstanceServer, name string) } maps.Copy(metadata.Templates, templates) - files, err := instanceTemplates.ReadDir("templates") - if err != nil { - return err - } - for _, entry := range files { - if err := createInstanceTemplateFile(conn, name, entry.Name()); err != nil { + for _, template := range templates { + if err := createInstanceTemplateFile(conn, name, template.Template); err != nil { return err } } @@ -670,19 +673,8 @@ func (s *Backend) startWorkshop(conn lxd.InstanceServer, ctx context.Context, na rev := revert.New() defer rev.Fail() - // Enable autostart first so it doesn't race with workshop-waitready.service. - // See https://github.com/canonical/lxd/issues/18833. - if err := s.setAutoStart(conn, ctx, name, true); err != nil { - return err - } - cleanupCtx := context.WithoutCancel(ctx) rev.Add(func() { - // TODO: if this becomes a long-term thing, consider adding a timeout. - if e := s.setAutoStart(conn, cleanupCtx, name, false); e != nil { - logger.Noticef("On StartWorkshop: cannot reset %q workshop boot.autostart: %v", name, e) - } - // Stop workshop's timeout is handled by LXD API, so no need to have // a context with a timeout. if e := s.stopWorkshop(conn, cleanupCtx, name, true); e != nil { @@ -694,44 +686,160 @@ func (s *Backend) startWorkshop(conn lxd.InstanceServer, ctx context.Context, na return err } + waited, err := s.legacyWaitready(conn, ctx, name) + if err != nil { + return err + } + if !waited { + if err := s.awaitReadyEvent(conn, ctx, name); err != nil { + return err + } + } + + // Workshop started, enable autostart. + if err := s.setAutoStart(conn, ctx, name, true); err != nil { + return err + } + + if err := s.addWorkshopCNAMEs(conn, ctx, name); err != nil { + logger.Noticef("On StartWorkshop: failed to add %q workshop CNAME records: %v", name, err) + } + + rev.Success() + return nil +} + +func (s *Backend) legacyWaitready(conn lxd.InstanceServer, ctx context.Context, name string) (bool, error) { + projectId, ok := ctx.Value(workshop.ContextProjectId).(string) + if !ok { + return false, fmt.Errorf("context key project-id not found") + } + + inst, _, err := conn.GetInstance(InstanceName(name, projectId)) + if err != nil { + return false, err + } + + if inst.Config["user.workshop.format-revision"] != "" { + format, err := sdk.ParseRevision(inst.Config["user.workshop.format-revision"]) + if err != nil { + return false, err + } + if format.N >= 11 { + return false, nil + } + } + + var stdout strings.Builder var stderr strings.Builder args := workshop.Execution{ ExecArgs: workshop.ExecArgs{ - UserId: 0, - GroupId: 0, - Command: []string{ - "bash", "-euc", startCommand, - }, + Command: []string{"systemctl", "is-system-running", "--wait"}, WorkDir: "/", - Timeout: startCommandTimeout, + Timeout: waitready.Timeout, }, ExecControls: workshop.ExecControls{ + Stdout: &stdout, Stderr: &stderr, }, } exectx, err := s.execCommand(conn, ctx, name, &args) + if err != nil { + return false, err + } + + if err := exectx.WaitExecution(ctx); err != nil { + if _, ok := errors.AsType[*workshop.ErrExec](err); ok { + if strings.TrimSpace(stdout.String()) == "degraded" { + return true, nil + } + message := strings.TrimSpace(stderr.String()) + if message == "" { + return false, err + } + return false, errors.New(message) + } + return false, err + } + return true, nil +} + +func (s *Backend) awaitReadyEvent(conn lxd.InstanceServer, ctx context.Context, name string) error { + projectId, ok := ctx.Value(workshop.ContextProjectId).(string) + if !ok { + return fmt.Errorf("context key project-id not found") + } + instance := InstanceName(name, projectId) + + ctx, cancel := context.WithTimeout(ctx, waitready.Timeout) + defer cancel() + + listener, err := conn.GetEvents() if err != nil { return err } + defer listener.Disconnect() - var errExec *workshop.ErrExec - if err := exectx.WaitExecution(ctx); errors.As(err, &errExec) { - message := strings.TrimSpace(stderr.String()) - if message == "" { + events := make(chan api.Event, 1) + defer close(events) + + target, err := listener.AddHandler([]string{"lifecycle"}, func(event api.Event) { + defer func() { _ = recover() }() + events <- event + }) + if err != nil { + return err + } + defer func() { _ = listener.RemoveHandler(target) }() + + ready, err := s.isInstanceReady(conn, instance) + for { + if err != nil { return err } - return errors.New(message) - } else if err != nil { - return err + if ready { + return nil + } + select { + case event := <-events: + ready, err = s.isReadyEvent(event, instance) + case <-ctx.Done(): + ready, err := s.isInstanceReady(conn, instance) + if err == nil && ready { + return nil + } + return ctx.Err() + } } +} - if err := s.addWorkshopCNAMEs(conn, ctx, name); err != nil { - logger.Noticef("On StartWorkshop: failed to add %q workshop CNAME records: %v", name, err) +func (s *Backend) isInstanceReady(conn lxd.InstanceServer, instance string) (bool, error) { + state, _, err := conn.GetInstanceState(instance) + if err != nil { + return false, err } + return state.StatusCode == api.Ready, nil +} - rev.Success() - return nil +func (s *Backend) isReadyEvent(event api.Event, instance string) (bool, error) { + var lifecycle api.EventLifecycle + if err := json.Unmarshal(event.Metadata, &lifecycle); err != nil { + return false, err + } + + if lifecycle.Name != instance { + return false, nil + } + + switch lifecycle.Action { + case api.EventLifecycleInstanceReady: + return true, nil + case api.EventLifecycleInstanceShutdown, api.EventLifecycleInstanceStopped: + return false, fmt.Errorf("received %q event", lifecycle.Action) + default: + return false, nil + } } func (s *Backend) StopWorkshop(ctx context.Context, name string, force bool) error { @@ -1035,6 +1143,7 @@ func (b *Backend) loadWorkshop(conn lxd.InstanceServer, inst *api.Instance, p wo image := workshop.BaseImage{ Name: f.Base, + Confinement: f.Confinement, Fingerprint: inst.Config[workshop.ConfigWorkshopBaseFingerprint], } @@ -1131,7 +1240,7 @@ func (s *Backend) ProjectWorkshops(ctx context.Context) ([]*workshop.Workshop, e // Get all the running workshops for this project. args := lxd.GetInstancesArgs{ - InstanceType: api.InstanceTypeContainer, + InstanceType: api.InstanceTypeAny, Filters: []string{"config.user.workshop.project-id=" + p.ProjectId}, } instances, err := conn.GetInstances(args) @@ -1225,7 +1334,7 @@ func (s *Backend) LxdClient(ctx context.Context) (lxd.InstanceServer, error) { return ConnectLxd(ctx) } -func defaultDevices(usr *user.User, pid, w string) map[string]map[string]string { +func defaultDevices(usr *user.User, pid, w string, confinement workshop.Confinement) map[string]map[string]string { devices := map[string]map[string]string{ "root": {"type": "disk", "pool": storagePool, "path": "/"}, "workshop.network": {"type": "nic", "network": networkName, "name": "eth0"}, @@ -1236,8 +1345,11 @@ func defaultDevices(usr *user.User, pid, w string) map[string]map[string]string devices[mount.Name] = mountToLxdDisk(mount) } - for _, proxy := range proxies { - devices[proxy.Name] = proxyToLxdDevice(usr, proxy) + // LXD VMs have only limited support for proxy devices. + if confinement == workshop.ConfinementContainer { + for _, proxy := range proxies { + devices[proxy.Name] = proxyToLxdDevice(usr, proxy) + } } return devices @@ -1339,9 +1451,29 @@ write_files: [Service] Type=notify ExecStart=/usr/local/lib/workshop/waitready + Restart=on-failure + RestartSec=2s [Install] WantedBy=multi-user.target +{{- if .HasGRUB}} + - path: /etc/grub.d/70_workshop + permissions: '0755' + content: | + #!/bin/sh + exec tail --lines=+4 "$0" + + # Extract SMBIOS UUID and store it in a GRUB variable. We use it to set + # the systemd.machine_id kernel parameter to the LXD UUID, which forces + # systemd to use it. By default it prefers reading the machine ID from + # /etc/machine-id, which may be stale when restoring from a snapshot. + insmod smbios + smbios --type 1 --get-uuid 8 --set workshop_machine_id + export workshop_machine_id + - path: /etc/default/grub.d/70-workshop.cfg + content: | + GRUB_CMDLINE_LINUX="${GRUB_CMDLINE_LINUX:+$GRUB_CMDLINE_LINUX }"'systemd.machine_id=${workshop_machine_id}' +{{- end}} runcmd: # Project directory is required for 'workshop exec'. - install --directory --mode=755 /project /usr/local/bin /usr/local/lib/workshop {{shquote .WorkshopStateDir}} @@ -1352,17 +1484,35 @@ runcmd: # Put workshopctl on the PATH. - ln -sf {{shquote .WorkshopCtlPath}} /usr/local/bin/workshopctl - ln -sf ../../bin/workshopctl /usr/local/lib/workshop/waitready +{{- if ne .FsFreezePath ""}} + - ln -sf ../../bin/workshopctl {{shquote .FsFreezePath}} +{{- end}} - systemctl enable --now workshop-waitready.service + # Linger starts the user manager for the specified user on boot, which then creates /run/user/$UID, + # sets $XDG_RUNTIME_DIR and more. Interfaces such as desktop rely on both of these to be present. + # This does not introduce any additional modification beyond what a login session would normally create. + - loginctl enable-linger workshop +{{- if .HasGRUB}} + - update-grub +{{- end}} `[1:] var cloudConfig strings.Builder funcs := map[string]any{ "shquote": shlex.Quote, } + var fsFreezePath string + if file.Confinement != workshop.ConfinementContainer { + fsFreezePath = dirs.FsFreezePath + } dot := struct { + FsFreezePath string + HasGRUB bool WorkshopCtlPath string WorkshopStateDir string }{ + FsFreezePath: fsFreezePath, + HasGRUB: file.Confinement == workshop.ConfinementVirtualMachine, WorkshopCtlPath: filepath.Join(dirs.WorkshopGuestBinDir, filepath.Base(dirs.WorkshopCtlPath)), WorkshopStateDir: dirs.WorkshopStateDir, } @@ -1373,36 +1523,118 @@ runcmd: f, err := yaml.Marshal(file) if err != nil { - return map[string]string{}, err + return nil, err + } + + idmapSet, err := workshopIdmap(file.Confinement, userid, groupid) + if err != nil { + return nil, err } // Include all options we might change, even those with default values, // so that workshops can be rebuilt. cfg := map[string]string{ "boot.autostart": "false", - "raw.idmap": fmt.Sprintf("uid %s %s\ngid %s %s", userid, workshop.User.Uid, groupid, workshop.User.Gid), - "security.nesting": "true", "cloud-init.user-data": cloudConfig.String(), + "raw.idmap": formatIdmap(idmapSet), "user.workshop.format-revision": format.String(), "user.workshop.project-id": projectId, "user.workshop.name": file.Name, "user.workshop.file": string(f), "user.workshop.base-fingerprint": baseFingerprint, + } + + if file.Confinement == workshop.ConfinementContainer { + cfg["security.nesting"] = "true" // LXC appears to have a race condition wherein a proxy device mounted in // a dynamically created directory has the potential to be 'masked' by this // directory. We create an explicit mount for /tmp here (one such dynamic // directory) to allow us to mount X11 sockets reliably. // See: https://github.com/lxc/lxc/issues/434 - "raw.lxc": "lxc.mount.entry = tmpfs tmp tmpfs defaults", + cfg["raw.lxc"] = "lxc.mount.entry = tmpfs tmp tmpfs defaults" + } else { + // Ensure the NIC is named "eth0" so we can configure it. + cfg["agent.nic_config"] = "true" } return cfg, nil } -func FakeStartCommand(script string) func() { - old := startCommand - startCommand = script - return func() { - startCommand = old +func workshopIdmap(confinement workshop.Confinement, userid, groupid string) (*idmap.IdmapSet, error) { + hostUid, err1 := strconv.ParseInt(userid, 10, 64) + nsUid, err2 := strconv.ParseInt(workshop.User.Uid, 10, 64) + hostGid, err3 := strconv.ParseInt(groupid, 10, 64) + nsGid, err4 := strconv.ParseInt(workshop.User.Gid, 10, 64) + if err := cmp.Or(err1, err2, err3, err4); err != nil { + return nil, fmt.Errorf("invalid user or group ID: %w", err) + } + entries := []idmap.IdmapEntry{ + {Isuid: true, Hostid: hostUid, Nsid: nsUid, Maprange: 1}, + {Isgid: true, Hostid: hostGid, Nsid: nsGid, Maprange: 1}, + } + + idmapSet := &idmap.IdmapSet{} + if confinement != workshop.ConfinementContainer { + // TODO: query LXD for the default idmap somehow. The current + // implementation only works because the LXD snap runs in a mount + // namespace where /etc/ is a tmpfs, so it effectively ignores + // /etc/subuid and /etc/subgid. It would be more correct to call + // DefaultIdmapSet("/proc//root", "root"), but traversing + // /proc//root is a privileged operation. + var err error + idmapSet, err = idmap.KernelDefaultMap() + if err != nil { + return nil, err + } + if idmapSet.Len() == 0 { + return nil, errors.New("no available uid/gid map could be found") + } + if err := idmapSet.Usable(); err != nil { + return nil, err + } + } + + for _, entry := range entries { + if err := idmapSet.AddSafe(entry); err != nil { + singleton := &idmap.IdmapSet{Idmap: []idmap.IdmapEntry{entry}} + return nil, fmt.Errorf("raw.idmap %q: %w", strings.TrimSpace(formatIdmap(singleton)), err) + } + } + + return idmapSet, nil +} + +func formatIdmap(idmapSet *idmap.IdmapSet) string { + var entries strings.Builder + for _, ent := range idmapSet.Idmap { + switch { + case ent.Maprange <= 0, !ent.Isuid && !ent.Isgid: + continue + case ent.Isuid && !ent.Isgid: + entries.WriteString("uid") + case !ent.Isuid && ent.Isgid: + entries.WriteString("gid") + case ent.Isuid && ent.Isgid: + entries.WriteString("both") + } + + entries.WriteByte(' ') + + entries.WriteString(strconv.FormatInt(ent.Hostid, 10)) + if ent.Maprange > 1 { + entries.WriteByte('-') + entries.WriteString(strconv.FormatInt(ent.Hostid+ent.Maprange-1, 10)) + } + + entries.WriteByte(' ') + + entries.WriteString(strconv.FormatInt(ent.Nsid, 10)) + if ent.Maprange > 1 { + entries.WriteByte('-') + entries.WriteString(strconv.FormatInt(ent.Nsid+ent.Maprange-1, 10)) + } + + entries.WriteByte('\n') } + return entries.String() } diff --git a/internal/workshop/lxd/lxd_backend_project.go b/internal/workshop/lxd/lxd_backend_project.go index 110eeba5b..612d57330 100644 --- a/internal/workshop/lxd/lxd_backend_project.go +++ b/internal/workshop/lxd/lxd_backend_project.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "regexp" "slices" @@ -289,7 +290,7 @@ func (s *Backend) pruneProjects(client lxd.InstanceServer, ctx context.Context, // list of projects that we track (only if there are no remaining // workshops for this project) args := lxd.GetInstancesArgs{ - InstanceType: api.InstanceTypeContainer, + InstanceType: api.InstanceTypeAny, Filters: []string{"config.user.workshop.project-id=" + prj.ProjectId}, } workshops, err := client.GetInstances(args) @@ -313,7 +314,7 @@ func (s *Backend) pruneProjects(client lxd.InstanceServer, ctx context.Context, func (s *Backend) projectFsRoot(conn lxd.InstanceServer, ctx context.Context, projectId string) (path string, err error) { args := lxd.GetInstancesArgs{ - InstanceType: api.InstanceTypeContainer, + InstanceType: api.InstanceTypeAny, Filters: []string{"config.user.workshop.project-id=" + projectId}, } workshops, err := conn.GetInstances(args) @@ -352,7 +353,13 @@ func (s *Backend) projectFsRoot(conn lxd.InstanceServer, ctx context.Context, pr continue } if err = meta.WaitExecution(ctx); err != nil { - logger.Debugf("cannot check %q bind-mounts: %v, findmnt output: %s", i.Name, err, errbuf.String()) + // It's unsafe to access errbuf before the DataDone channel is closed. + var details string + if _, ok := errors.AsType[*workshop.ErrExec](err); ok { + details = ", findmnt output: " + errbuf.String() + } + + logger.Debugf("cannot check %q bind-mounts: %v%s", i.Name, err, details) continue } @@ -403,7 +410,7 @@ func (s *Backend) updateProjectMounts(conn lxd.InstanceServer, ctx context.Conte projectCtx := context.WithValue(ctx, workshop.ContextProjectId, project.ProjectId) args := lxd.GetInstancesArgs{ - InstanceType: api.InstanceTypeContainer, + InstanceType: api.InstanceTypeAny, Filters: []string{"config.user.workshop.project-id=" + project.ProjectId}, } workshops, err := conn.GetInstances(args) diff --git a/internal/workshop/lxd/lxd_backend_snapshots.go b/internal/workshop/lxd/lxd_backend_snapshots.go index c9abfbd06..a75312314 100644 --- a/internal/workshop/lxd/lxd_backend_snapshots.go +++ b/internal/workshop/lxd/lxd_backend_snapshots.go @@ -21,6 +21,7 @@ import ( "encoding/hex" "errors" "fmt" + "io" "maps" "net/http" "net/url" @@ -28,17 +29,25 @@ import ( "slices" "strings" "sync" + "time" lxd "github.com/canonical/lxd/client" "github.com/canonical/lxd/shared/api" "github.com/canonical/lxd/shared/entity" + "github.com/canonical/workshop/internal/dirs" + "github.com/canonical/workshop/internal/fsfreeze" "github.com/canonical/workshop/internal/logger" "github.com/canonical/workshop/internal/revert" "github.com/canonical/workshop/internal/sdk" "github.com/canonical/workshop/internal/workshop" ) +const ( + freezeTimeout = 5 * time.Minute + thawTimeout = 2 * time.Minute +) + var ( snapshotGuardsLock sync.Mutex snapshotGuards = map[string]*snapshotGuard{} @@ -171,7 +180,7 @@ func (s *Backend) Snapshot(ctx context.Context, snapshot workshop.Snapshot) (*wo workshops := map[string][]string{} usedBy, err := conn.GetInstances(lxd.GetInstancesArgs{ - InstanceType: api.InstanceTypeContainer, + InstanceType: instanceType(snapshot.Image.Confinement), Filters: []string{fmt.Sprintf("config.user.workshop.snapshot-%v=%s", len(snapshot.Sdks), name)}, }) if err != nil { @@ -185,7 +194,7 @@ func (s *Backend) Snapshot(ctx context.Context, snapshot workshop.Snapshot) (*wo // Check for stashed workshops as well. usedBy, err = snapshotConn.GetInstances(lxd.GetInstancesArgs{ - InstanceType: api.InstanceTypeContainer, + InstanceType: instanceType(snapshot.Image.Confinement), Filters: []string{fmt.Sprintf("config.user.workshop.snapshot-%v=%s", len(snapshot.Sdks), name)}, }) if err != nil { @@ -219,6 +228,11 @@ func identifySnapshot(inst *api.Instance) (*workshop.Snapshot, error) { return nil, err } + confinement := workshop.ConfinementContainer + if inst.Type == string(api.InstanceTypeVM) { + confinement = workshop.ConfinementVirtualMachine + } + sdks := make([]sdk.ContentID, len(inst.Devices)) length := 0 maxInstallOrder := 0 @@ -250,6 +264,7 @@ func identifySnapshot(inst *api.Instance) (*workshop.Snapshot, error) { Format: format, Image: workshop.BaseImage{ Name: inst.Config[workshop.ConfigWorkshopBase], + Confinement: confinement, Fingerprint: inst.Config[workshop.ConfigWorkshopBaseFingerprint], }, Sdks: sdks[:length], @@ -264,6 +279,14 @@ func compareSnapshots(name string, actual, expected workshop.Snapshot) error { if actual.Image.Name != expected.Image.Name { return fmt.Errorf("%q snapshot has %q base; required: %q", name, actual.Image.Name, expected.Image.Name) } + if actual.Image.Confinement != expected.Image.Confinement { + c1, err1 := actual.Image.Confinement.MarshalText() + c2, err2 := expected.Image.Confinement.MarshalText() + if err := cmp.Or(err1, err2); err != nil { + return fmt.Errorf("%q snapshot: %w", name, err) + } + return fmt.Errorf("%q snapshot has %q confinement; required: %q", name, c1, c2) + } if actual.Image.Fingerprint != expected.Image.Fingerprint { return fmt.Errorf("%q snapshot has %q base fingerprint; required: %q", name, actual.Image.Fingerprint, expected.Image.Fingerprint) } @@ -303,7 +326,8 @@ func (s *Backend) TakeSnapshot(ctx context.Context, name string, snapshot worksh snapshotName := sdkSnapshotName(snapshot, digest) // Disable cancellation, because the LXD operation will plow on regardless, - // and the lock is supposed to prevent concurrent import operations. + // and the lock is supposed to prevent concurrent import operations. We + // also want to avoid killing the fsfreeze process for VMs. lockedCtx := context.WithoutCancel(ctx) conn, snapshotConn, err := s.snapshotClients(lockedCtx) if err != nil { @@ -368,6 +392,24 @@ func (s *Backend) TakeSnapshot(ctx context.Context, name string, snapshot worksh } defer unlockSnapshot(snapshotName) + thawer := revert.New() + defer thawer.Fail() + var thaw io.Closer + var result <-chan error + + running := inst.StatusCode == api.Running || inst.StatusCode == api.Ready + if running && inst.Type != string(api.InstanceTypeContainer) { + thaw, result, err = s.freezeFilesystems(conn, ctx, name) + if err != nil { + return err + } + thawer.Add(func() { + if err1 := thawFilesystems(thaw, result); err1 != nil { + logger.Noticef("On TakeSnapshot: %v", err1) + } + }) + } + rev := revert.New() defer rev.Fail() @@ -398,6 +440,13 @@ func (s *Backend) TakeSnapshot(ctx context.Context, name string, snapshot worksh } }) + thawer.Success() + if thaw != nil { + if err := thawFilesystems(thaw, result); err != nil { + return err + } + } + if err := s.commitPartialSnapshot(snapshotConn, snapshotName); err != nil { return err } @@ -406,6 +455,84 @@ func (s *Backend) TakeSnapshot(ctx context.Context, name string, snapshot worksh return nil } +// freezeFilesystems runs the fsfreeze facet of workshopctl inside the given +// workshop. See fsfreeze.FreezeLocalFilesystems for details. +func (s *Backend) freezeFilesystems(conn lxd.InstanceServer, ctx context.Context, name string) (io.Closer, <-chan error, error) { + rev := revert.New() + defer rev.Fail() + + stdin, thaw := io.Pipe() + rev.Add(func() { + thaw.Close() + }) + + stdout := fsfreeze.NewReadyWriter() + var stderr strings.Builder + result := make(chan error, 1) + + args := &workshop.Execution{ + ExecArgs: workshop.ExecArgs{ + Command: []string{dirs.FsFreezePath}, + WorkDir: "/", + }, + ExecControls: workshop.ExecControls{ + Stdin: stdin, + Stdout: stdout, + Stderr: &stderr, + }, + } + exectx, err := s.execCommand(conn, ctx, name, args) + if err != nil { + stdin.Close() + return nil, nil, err + } + + // The fsfreeze process has its own timeout. If we interrupt it we risk + // leaving filesystems frozen, but cancelling WaitExecution doesn't + // cancel the execution itself. + waitCtx, cancel := context.WithTimeout(context.Background(), freezeTimeout) + go func() { + defer stdin.Close() + defer cancel() + result <- exectx.WaitExecution(waitCtx) + close(result) + }() + + select { + case <-stdout.Ready(): + rev.Success() + return thaw, result, nil + case err := <-result: + // It's unsafe to access errbuf before the DataDone channel is closed. + if _, ok := errors.AsType[*workshop.ErrExec](err); ok { + if s := stderr.String(); s != "" { + logger.Noticef("On freezeFilesystems: fsfreeze reported %s", s) + } else { + logger.Noticef("On freezeFilesystems: fsfreeze exited unexpectedly") + } + } + + if err == nil { + err = errors.New("fsfreeze never reported ready") + } + return nil, nil, err + } +} + +func thawFilesystems(thaw io.Closer, result <-chan error) error { + thaw.Close() + + waitCtx, cancel := context.WithTimeout(context.Background(), thawTimeout) + defer cancel() + + select { + case err := <-result: + return err + case <-waitCtx.Done(): + return waitCtx.Err() + } +} + func IsInstanceConflict(err error, name string) bool { if err == nil { return false @@ -828,7 +955,7 @@ func (s *Backend) snapshotClients(ctx context.Context) (lxd.InstanceServer, lxd. // replay some of the install-sdk and setup-base tasks. These can be handled in // the same way as in-progress launches and refreshes. func (s *Backend) FormatRevision() sdk.Revision { - return sdk.R(11) + return sdk.R(13) } func (s *Backend) HashSnapshot(snapshot workshop.Snapshot) (string, error) { @@ -837,8 +964,13 @@ func (s *Backend) HashSnapshot(snapshot workshop.Snapshot) (string, error) { return "", err } + confinement, err := snapshot.Image.Confinement.MarshalText() + if err != nil { + return "", err + } + hash := sha3.New384() - if _, err := fmt.Fprintf(hash, "%s %s\x00%s", snapshot.Format, snapshot.Image.Name, digest); err != nil { + if _, err := fmt.Fprintf(hash, "%s %s %s\x00%s", snapshot.Format, snapshot.Image.Name, confinement, digest); err != nil { return "", err } diff --git a/internal/workshop/lxd/lxd_backend_test.go b/internal/workshop/lxd/lxd_backend_test.go index fdd365948..18f7e15e4 100644 --- a/internal/workshop/lxd/lxd_backend_test.go +++ b/internal/workshop/lxd/lxd_backend_test.go @@ -15,17 +15,23 @@ package lxdbackend_test import ( + "crypto/sha3" + "encoding/hex" + "path/filepath" "testing" "gopkg.in/check.v1" + "gopkg.in/yaml.v3" + "github.com/canonical/workshop/internal/dirs" "github.com/canonical/workshop/internal/testutil" "github.com/canonical/workshop/internal/workshop" lxdbackend "github.com/canonical/workshop/internal/workshop/lxd" ) type LxdBeTests struct { - project workshop.Project + project workshop.Project + workshopCtlPath string } var _ = check.Suite(&LxdBeTests{}) @@ -35,6 +41,15 @@ func TestLxdBackendSuite(t *testing.T) { check.TestingT(t) } func (s *LxdBeTests) SetUpTest(c *check.C) { dir := c.MkDir() s.project = workshop.Project{ProjectId: "42ws42ws", Path: dir} + + // These tests don't require workshopctl, but the snapshot tests do, and + // the basename of the test binary appears in cloud-init.user-data. + s.workshopCtlPath = dirs.WorkshopCtlPath + dirs.WorkshopCtlPath = filepath.Join(dir, "integration.test") +} + +func (s *LxdBeTests) TearDownTest(c *check.C) { + dirs.WorkshopCtlPath = s.workshopCtlPath } func (f *LxdBeTests) TestReadProjectsSuccess(c *check.C) { @@ -64,7 +79,7 @@ func (f *LxdBeTests) TestReadProjectsSuccess(c *check.C) { c.Assert(projects, check.HasLen, 0) } -var marshalledWorkshop = `name: test +var containerFile = `name: test base: ubuntu@22.04 sdks: - name: one @@ -78,7 +93,7 @@ sdks: channel: latest/edge ` -func (f *LxdBeTests) TestDefaultWorkshopConfig(c *check.C) { +func (f *LxdBeTests) TestDefaultContainerConfig(c *check.C) { // Setup b := &lxdbackend.Backend{} file := &workshop.File{ @@ -98,12 +113,62 @@ func (f *LxdBeTests) TestDefaultWorkshopConfig(c *check.C) { // Validate c.Assert(err, check.IsNil) - c.Assert(cfg["raw.idmap"], check.Equals, "uid 1001 1000\ngid 1001 1000") + c.Assert(cfg["cloud-init.user-data"], check.Not(testutil.Contains), "GRUB_CMDLINE_LINUX") + c.Assert(cfg["raw.idmap"], check.Equals, "uid 1001 1000\ngid 1001 1000\n") + c.Assert(cfg["raw.lxc"], check.Equals, "lxc.mount.entry = tmpfs tmp tmpfs defaults") c.Assert(cfg["security.nesting"], check.Equals, "true") c.Assert(cfg["user.workshop.project-id"], check.Equals, f.project.ProjectId) - c.Assert(cfg["user.workshop.file"], check.Equals, marshalledWorkshop) + c.Assert(cfg["user.workshop.file"], check.Equals, containerFile) + c.Assert(cfg["user.workshop.format-revision"], check.Equals, b.FormatRevision().String()) + c.Assert(cfg["user.workshop.base-fingerprint"], check.Equals, "fakeimage12345") + + // Check hash here so it's easier to update snapshot-format.yaml. + digest := sha3.Sum384([]byte(cfg["cloud-init.user-data"])) + c.Check(hex.EncodeToString(digest[:]), check.Equals, "8cb63e0464ae87dca0a4b43e73fa6420b8b81e758a313b166732c886113af229acbaf83af34ce8b5fc1006772aae84f4") + // Check for syntax errors (e.g. whitespace). + var config map[string]any + err = yaml.Unmarshal([]byte(cfg["cloud-init.user-data"]), &config) + c.Assert(err, check.IsNil) +} + +var vmFile = `name: test +base: ubuntu@22.04 +confinement: virtual-machine +` + +func (f *LxdBeTests) TestDefaultVMConfig(c *check.C) { + // Setup + b := &lxdbackend.Backend{} + file := &workshop.File{ + Name: "test", + Base: "ubuntu@22.04", + Confinement: workshop.ConfinementVirtualMachine, + } + + // Execute + cfg, err := lxdbackend.DefaultConfig(b, f.project.ProjectId, "1002", "1002", file, b.FormatRevision(), "fakeimage12345") + + // Validate + c.Assert(err, check.IsNil) + c.Assert(cfg["cloud-init.user-data"], testutil.Contains, "GRUB_CMDLINE_LINUX") + c.Assert(cfg["raw.idmap"], testutil.Contains, "uid 1002 1000\n") + c.Assert(cfg["raw.idmap"], testutil.Contains, "gid 1002 1000\n") + _, ok := cfg["raw.lxc"] + c.Assert(ok, check.Equals, false) + _, ok = cfg["security.nesting"] + c.Assert(ok, check.Equals, false) + c.Assert(cfg["user.workshop.project-id"], check.Equals, f.project.ProjectId) + c.Assert(cfg["user.workshop.file"], check.Equals, vmFile) c.Assert(cfg["user.workshop.format-revision"], check.Equals, b.FormatRevision().String()) c.Assert(cfg["user.workshop.base-fingerprint"], check.Equals, "fakeimage12345") + + // Check hash here so it's easier to update snapshot-format.yaml. + digest := sha3.Sum384([]byte(cfg["cloud-init.user-data"])) + c.Check(hex.EncodeToString(digest[:]), check.Equals, "b2197fbcdb2a08fbb712f3f2a525dac06c50e025793b652ea7f4bc5d8456775cc69bcbc73a12d91e07d18baa5cba0c12") + // Check for syntax errors (e.g. whitespace). + var config map[string]any + err = yaml.Unmarshal([]byte(cfg["cloud-init.user-data"]), &config) + c.Assert(err, check.IsNil) } func (f *LxdBeTests) TestCheckLxdVersion(c *check.C) { diff --git a/internal/workshop/lxd/lxd_base_manager.go b/internal/workshop/lxd/lxd_base_manager.go index e39f3cf27..dfb5ba4bc 100644 --- a/internal/workshop/lxd/lxd_base_manager.go +++ b/internal/workshop/lxd/lxd_base_manager.go @@ -46,8 +46,8 @@ var ( currentDownloads = map[string]*downloadOp{} ) -func (b *Backend) GetBase(ctx context.Context, base string) (workshop.BaseImage, error) { - source, err := baseImageSource(base) +func (b *Backend) GetBase(ctx context.Context, base string, confinement workshop.Confinement) (workshop.BaseImage, error) { + source, err := baseImageSource(base, confinement) if err != nil { return workshop.BaseImage{}, err } @@ -63,7 +63,7 @@ func (b *Backend) GetBase(ctx context.Context, base string) (workshop.BaseImage, return workshop.BaseImage{}, fmt.Errorf("base %q not found: %w", base, err) } - return workshop.BaseImage{Name: base, Fingerprint: alias.Target}, nil + return workshop.BaseImage{Name: base, Confinement: confinement, Fingerprint: alias.Target}, nil } func (b *Backend) DownloadBase(ctx context.Context, image workshop.BaseImage, report *progress.Reporter) error { @@ -109,7 +109,7 @@ func (b *Backend) tryDownloadBase(ctx context.Context, op *downloadOp, image wor } func (b *Backend) downloadBase(ctx context.Context, op *downloadOp, image workshop.BaseImage) error { - source, err := baseImageSource(image.Name) + source, err := baseImageSource(image.Name, image.Confinement) if err != nil { return err } @@ -193,7 +193,7 @@ func (b *Backend) downloadBase(ctx context.Context, op *downloadOp, image worksh return nil } -func baseImageSource(base string) (*api.ImageSource, error) { +func baseImageSource(base string, confinement workshop.Confinement) (*api.ImageSource, error) { parts := strings.FieldsFunc(base, func(r rune) bool { return r == '@' }) if len(parts) != 2 { return nil, fmt.Errorf("invalid base %q (expected @)", base) @@ -218,7 +218,7 @@ func baseImageSource(base string) (*api.ImageSource, error) { // variants, but it should be OK for x86_64, aarch64 and riscv64. source := api.ImageSource{ Alias: parts[1], - ImageType: string(api.InstanceTypeContainer), + ImageType: string(instanceType(confinement)), Protocol: protocol, Server: url, } diff --git a/internal/workshop/lxd/start_command.sh b/internal/workshop/lxd/start_command.sh deleted file mode 100644 index 7997dafef..000000000 --- a/internal/workshop/lxd/start_command.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# Wait until system is up an running before returning -# see: https://blog.simos.info/how-to-know-when-a-lxd-container-has-finished-starting-up/ -while [ "$(systemctl is-system-running 2>/dev/null)" != running ] \ -&& [ "$(systemctl is-system-running 2>/dev/null)" != degraded ] -do - : -done -# Linger starts the user manager for the specified user on boot, which then creates /run/user/$UID, -# sets $XDG_RUNTIME_DIR and more. Interfaces such as desktop rely on both of these to be present. -# This does not introduce any additional modification beyond what a login session would normally create. -loginctl enable-linger workshop diff --git a/internal/workshop/lxd/tests/helper/helper.go b/internal/workshop/lxd/tests/helper/helper.go index ddb51ca10..313fd5c15 100644 --- a/internal/workshop/lxd/tests/helper/helper.go +++ b/internal/workshop/lxd/tests/helper/helper.go @@ -22,13 +22,18 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" + "testing" lxd "github.com/canonical/lxd/client" "github.com/canonical/lxd/shared/api" "gopkg.in/check.v1" + "github.com/canonical/workshop/internal/dirs" + "github.com/canonical/workshop/internal/fsfreeze" "github.com/canonical/workshop/internal/sdk" + "github.com/canonical/workshop/internal/waitready" "github.com/canonical/workshop/internal/workshop" ) @@ -43,8 +48,43 @@ actions: var MinimalImageServer = "simplestreams:https://cloud-images.ubuntu.com/minimal/releases" -func DefaultTestDevices(pid, w string) ([]workshop.Mount, []workshop.ProxyEntry) { - return nil, nil +var defaultDevices = workshop.DefaultDevices + +// RunTestsOrWorkshopCtl is intended to be called from TestMain; it allows test +// binaries to mount themselves into workshops in place of workshopctl. When +// run via a symlink with the right name, they behave the same as workshopctl. +func RunTestsOrWorkshopCtl(m *testing.M) int { + if waitready.IsWaitreadyInvocation() { + if err := waitready.WaitReady(); err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + return 1 + } + return 0 + } + + if fsfreeze.IsFsfreezeInvocation() { + if err := fsfreeze.FreezeLocalFilesystems(os.Stdin, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + return 1 + } + return 0 + } + + executable, err := os.Executable() + if err != nil { + panic(fmt.Errorf("cannot get executable path: %w", err)) + } + dirs.WorkshopCtlPath = executable + + return m.Run() +} + +func TestDevices(pid, w string) ([]workshop.Mount, []workshop.ProxyEntry) { + mounts, _ := defaultDevices(pid, w) + mounts = slices.DeleteFunc(mounts, func(m workshop.Mount) bool { + return m.Name != "workshop.bin" + }) + return mounts, nil } func CleanupLxdProject(c *check.C, client lxd.InstanceServer, project string) { @@ -58,7 +98,7 @@ func CleanupLxdProject(c *check.C, client lxd.InstanceServer, project string) { } } - args := lxd.GetInstancesArgs{InstanceType: api.InstanceTypeContainer} + args := lxd.GetInstancesArgs{InstanceType: api.InstanceTypeAny} instances, err := cli.GetInstances(args) c.Check(err, check.IsNil) for _, i := range instances { @@ -92,7 +132,7 @@ func CreateTestContext(username, projectId string) context.Context { } func LaunchTestWorkshop(c *check.C, ctx context.Context, bd workshop.Backend, dir string) { - image, err := bd.GetBase(ctx, "ubuntu@24.04") + image, err := bd.GetBase(ctx, "ubuntu@24.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) err = bd.DownloadBase(ctx, image, nil) c.Assert(err, check.IsNil) @@ -121,7 +161,7 @@ printf '%s\n' "$@" _, _, err = bd.CreateOrLoadProject(ctx, dir) c.Assert(err, check.IsNil) - snapshot := workshop.BaseOnly(bd.FormatRevision(), image.Name, image.Fingerprint) + snapshot := workshop.BaseOnly(bd.FormatRevision(), image.Name, workshop.ConfinementContainer, image.Fingerprint) err = bd.LaunchOrRebuildWorkshop(ctx, wf, snapshot) c.Assert(err, check.IsNil) @@ -149,7 +189,7 @@ func ExecOutput(ctx context.Context, bd workshop.Backend, name string, args work return "", err } if err := exectx.WaitExecution(ctx); err != nil { - return "", fmt.Errorf("%w\n%s", err, stderr.String()) + return stdout.String(), fmt.Errorf("%w\n%s", err, stderr.String()) } return stdout.String(), err } diff --git a/internal/workshop/lxd/tests/integration/project_test.go b/internal/workshop/lxd/tests/integration/project_test.go index 995b9887c..1eb989381 100644 --- a/internal/workshop/lxd/tests/integration/project_test.go +++ b/internal/workshop/lxd/tests/integration/project_test.go @@ -36,6 +36,10 @@ import ( "github.com/canonical/workshop/internal/workshop/lxd/tests/helper" ) +func TestMain(m *testing.M) { + os.Exit(helper.RunTestsOrWorkshopCtl(m)) +} + type wsProject struct { ctx context.Context client lxd.InstanceServer diff --git a/internal/workshop/lxd/tests/integration/snapshot-format.yaml b/internal/workshop/lxd/tests/integration/snapshot-format.yaml index 0ae5474e8..0bdf9dcb1 100644 --- a/internal/workshop/lxd/tests/integration/snapshot-format.yaml +++ b/internal/workshop/lxd/tests/integration/snapshot-format.yaml @@ -10,12 +10,12 @@ launched: architecture: '' config: boot.autostart: 'false' - raw.idmap: |- + raw.idmap: | uid 1000 1000 gid 1000 1000 raw.lxc: lxc.mount.entry = tmpfs tmp tmpfs defaults security.nesting: 'true' - cloud-init.user-data: '17fd6f33c4c2802b0142eefe3d41d4f98b1c0ce338ce06da2b3e6d06776ac3c2204ed2cc7c6c367d40cdfc6e11d82e6a' + cloud-init.user-data: '8cb63e0464ae87dca0a4b43e73fa6420b8b81e758a313b166732c886113af229acbaf83af34ce8b5fc1006772aae84f4' user.workshop.name: test user.workshop.project-id: '42424242' description: '' @@ -50,12 +50,12 @@ started: architecture: '' config: boot.autostart: 'true' - raw.idmap: |- + raw.idmap: | uid 1000 1000 gid 1000 1000 raw.lxc: lxc.mount.entry = tmpfs tmp tmpfs defaults security.nesting: 'true' - cloud-init.user-data: '17fd6f33c4c2802b0142eefe3d41d4f98b1c0ce338ce06da2b3e6d06776ac3c2204ed2cc7c6c367d40cdfc6e11d82e6a' + cloud-init.user-data: '8cb63e0464ae87dca0a4b43e73fa6420b8b81e758a313b166732c886113af229acbaf83af34ce8b5fc1006772aae84f4' user.workshop.name: test user.workshop.project-id: '42424242' description: '' @@ -90,12 +90,12 @@ sdk-attached: architecture: '' config: boot.autostart: 'true' - raw.idmap: |- + raw.idmap: | uid 1000 1000 gid 1000 1000 raw.lxc: lxc.mount.entry = tmpfs tmp tmpfs defaults security.nesting: 'true' - cloud-init.user-data: '17fd6f33c4c2802b0142eefe3d41d4f98b1c0ce338ce06da2b3e6d06776ac3c2204ed2cc7c6c367d40cdfc6e11d82e6a' + cloud-init.user-data: '8cb63e0464ae87dca0a4b43e73fa6420b8b81e758a313b166732c886113af229acbaf83af34ce8b5fc1006772aae84f4' user.workshop.name: test user.workshop.project-id: '42424242' description: '' @@ -142,12 +142,12 @@ sdk-mounted: architecture: '' config: boot.autostart: 'true' - raw.idmap: |- + raw.idmap: | uid 1000 1000 gid 1000 1000 raw.lxc: lxc.mount.entry = tmpfs tmp tmpfs defaults security.nesting: 'true' - cloud-init.user-data: '17fd6f33c4c2802b0142eefe3d41d4f98b1c0ce338ce06da2b3e6d06776ac3c2204ed2cc7c6c367d40cdfc6e11d82e6a' + cloud-init.user-data: '8cb63e0464ae87dca0a4b43e73fa6420b8b81e758a313b166732c886113af229acbaf83af34ce8b5fc1006772aae84f4' user.workshop.name: test user.workshop.project-id: '42424242' description: '' diff --git a/internal/workshop/lxd/tests/integration/snapshot_test.go b/internal/workshop/lxd/tests/integration/snapshot_test.go index aac04a3aa..0770cfdc5 100644 --- a/internal/workshop/lxd/tests/integration/snapshot_test.go +++ b/internal/workshop/lxd/tests/integration/snapshot_test.go @@ -26,19 +26,19 @@ import ( "errors" "fmt" "os" - "os/exec" "os/user" "path/filepath" "strings" - "syscall" "time" "github.com/canonical/lxd/shared/api" + "golang.org/x/sys/unix" "gopkg.in/check.v1" "gopkg.in/yaml.v3" "github.com/canonical/workshop/internal/dirs" "github.com/canonical/workshop/internal/osutil" + "github.com/canonical/workshop/internal/revert" "github.com/canonical/workshop/internal/sdk" "github.com/canonical/workshop/internal/testutil" "github.com/canonical/workshop/internal/workshop" @@ -51,9 +51,8 @@ type snapshotSuite struct { project workshop.Project ctx context.Context - restoreLookupUsr func() - restoreUserEnv func() - restoreImageServer func() + restoreLookupUsr func() + restoreUserEnv func() bd *lxdbackend.Backend } @@ -74,10 +73,8 @@ func (s *snapshotSuite) SetUpSuite(c *check.C) { s.restoreUserEnv = osutil.FakeUserEnvironment(func(user *user.User) (map[string]string, error) { return nil, nil }) - s.restoreImageServer = lxdbackend.FakeImageServer(helper.MinimalImageServer) dirs.SetRootDir(c.MkDir()) - dirs.WorkshopCtlPath = filepath.Join(c.MkDir(), "workshopctl") dirs.SocketPath = filepath.Join(dirs.DataDir, "workshop.socket") c.Assert(dirs.CreateDirs(), check.IsNil) @@ -100,7 +97,6 @@ func (s *snapshotSuite) TearDownSuite(c *check.C) { s.restoreLookupUsr() s.restoreUserEnv() - s.restoreImageServer() } // This suite deliberately doesn't override the default devices, so the test @@ -135,7 +131,7 @@ func (s *snapshotSuite) TestLxdBackendSnapshotFormat(c *check.C) { c.Assert(err, check.IsNil) // Launch workshop. - image, err := s.bd.GetBase(s.ctx, "ubuntu@24.04") + image, err := s.bd.GetBase(s.ctx, "ubuntu@24.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) err = s.bd.DownloadBase(s.ctx, image, nil) c.Assert(err, check.IsNil) @@ -147,7 +143,7 @@ func (s *snapshotSuite) TestLxdBackendSnapshotFormat(c *check.C) { {Name: "local-sdk", Source: sdk.ProjectSource}, }, } - snapshot := workshop.BaseOnly(s.bd.FormatRevision(), image.Name, image.Fingerprint) + snapshot := workshop.BaseOnly(s.bd.FormatRevision(), image.Name, workshop.ConfinementContainer, image.Fingerprint) remove := s.launchWorkshop(c, wf, snapshot) defer remove() @@ -314,221 +310,400 @@ func (s *snapshotSuite) snapshotFormat(c *check.C, snapshot workshop.Snapshot) a return inst } +func (s *snapshotSuite) TestLxdBackendSnapshotDiffContainer20(c *check.C) { + s.snapshotDiff(c, "ubuntu@20.04", workshop.ConfinementContainer) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffContainer22(c *check.C) { + s.snapshotDiff(c, "ubuntu@22.04", workshop.ConfinementContainer) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffContainer24(c *check.C) { + s.snapshotDiff(c, "ubuntu@24.04", workshop.ConfinementContainer) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffContainer26(c *check.C) { + s.snapshotDiff(c, "ubuntu@26.04", workshop.ConfinementContainer) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffVM20(c *check.C) { + s.snapshotDiff(c, "ubuntu@20.04", workshop.ConfinementVirtualMachine) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffVM22(c *check.C) { + s.snapshotDiff(c, "ubuntu@22.04", workshop.ConfinementVirtualMachine) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffVM24(c *check.C) { + s.snapshotDiff(c, "ubuntu@24.04", workshop.ConfinementVirtualMachine) +} +func (s *snapshotSuite) TestLxdBackendSnapshotDiffVM26(c *check.C) { + s.snapshotDiff(c, "ubuntu@26.04", workshop.ConfinementVirtualMachine) +} + // Launches 2 workshops from scratch and another from a snapshot of the first, // then checks that the third workshop is indistinguishable from the other two. -func (s *snapshotSuite) TestLxdBackendSnapshotDiff(c *check.C) { +func (s *snapshotSuite) snapshotDiff(c *check.C, base string, confinement workshop.Confinement) { if os.Geteuid() != 0 { c.Skip("requires root to mount and compare workshop filesystems") } - for _, base := range workshop.SupportedBases { - c.Logf("Testing snapshot integrity for base %q", base) - s.snapshotDiff(c, base) - } -} - -func (s *snapshotSuite) snapshotDiff(c *check.C, base string) { // Download base image. - image, err := s.bd.GetBase(s.ctx, base) + image, err := s.bd.GetBase(s.ctx, base, confinement) c.Assert(err, check.IsNil) err = s.bd.DownloadBase(s.ctx, image, nil) c.Assert(err, check.IsNil) - // Launch first workshop. - wf1 := &workshop.File{ - Name: "test1", - Base: base, + // Launch original workshop. + originFile := &workshop.File{ + Name: "origin", + Base: base, + Confinement: confinement, } - baseOnly := workshop.BaseOnly(s.bd.FormatRevision(), image.Name, image.Fingerprint) - remove := s.launchWorkshop(c, wf1, baseOnly) + baseOnly := workshop.BaseOnly(s.bd.FormatRevision(), image.Name, confinement, image.Fingerprint) + remove := s.launchWorkshop(c, originFile, baseOnly) defer remove() - // Start first workshop to take snapshot. - err = s.bd.StartWorkshop(s.ctx, "test1") + // Start original workshop and take a snapshot. + err = s.bd.StartWorkshop(s.ctx, "origin") c.Assert(err, check.IsNil) - snapshot := baseOnly - snapshot.Sdks = []sdk.ContentID{{ + originSnapshot := baseOnly + originSnapshot.Sdks = []sdk.ContentID{{ Name: "system", Sha3_384: "6b499970ebf370d4dbc4e9a005c042dee003c19a9420a78944bcbf32653d257f80f7c56bad55b4c967dca68a1ea92be7", IsVolume: true, }} - err1 := s.bd.TakeSnapshot(s.ctx, "test1", snapshot) - mount1, err2 := s.rootfsMount("test1") - err3 := s.bd.StopWorkshop(s.ctx, "test1", true) + err1 := s.bd.TakeSnapshot(s.ctx, "origin", originSnapshot) + originRootFS, err2 := s.workshopRootFS("origin", confinement) + err3 := s.bd.StopWorkshop(s.ctx, "origin", true) c.Assert(cmp.Or(err1, err2, err3), check.IsNil) - // Launch second workshop. - wf2 := &workshop.File{ - Name: "test2", - Base: base, + // Launch a completely independent workshop. + siblingFile := &workshop.File{ + Name: "sibling", + Base: base, + Confinement: confinement, } - remove = s.launchWorkshop(c, wf2, baseOnly) + remove = s.launchWorkshop(c, siblingFile, baseOnly) defer remove() - // Start second workshop to run cloud-init. - err = s.bd.StartWorkshop(s.ctx, "test2") + // Start independent workshop to run cloud-init. + err = s.bd.StartWorkshop(s.ctx, "sibling") c.Assert(err, check.IsNil) - mount2, err1 := s.rootfsMount("test2") - err2 = s.bd.StopWorkshop(s.ctx, "test2", true) + siblingRootFS, err1 := s.workshopRootFS("sibling", confinement) + err2 = s.bd.StopWorkshop(s.ctx, "sibling", true) c.Assert(cmp.Or(err1, err2), check.IsNil) - // Launch third workshop from snapshot. - wf3 := &workshop.File{ - Name: "test3", - Base: base, + // Launch clone of the first workshop. + cloneFile := &workshop.File{ + Name: "clone", + Base: base, + Confinement: confinement, } - remove = s.launchWorkshop(c, wf3, snapshot) + remove = s.launchWorkshop(c, cloneFile, originSnapshot) defer remove() - // Start third workshop to run cloud-init (again). - err = s.bd.StartWorkshop(s.ctx, "test3") + // Start cloned workshop and take another snapshot. + err = s.bd.StartWorkshop(s.ctx, "clone") c.Assert(err, check.IsNil) - snapshot2 := snapshot - snapshot2.Sdks = append(snapshot2.Sdks, sdk.ContentID{ + cloneSnapshot := originSnapshot + cloneSnapshot.Sdks = append(cloneSnapshot.Sdks, sdk.ContentID{ Name: "test-sdk", Sha3_384: "d024fbe91c6b99d0064306d52006c17a5d0406822ff253fbbe6a934ca9be50d3ff9a6ec3bac3be8396006029a1ff453a", IsVolume: false, }) - err1 = s.bd.TakeSnapshot(s.ctx, "test3", snapshot2) - mount3, err2 := s.rootfsMount("test3") - err3 = s.bd.StopWorkshop(s.ctx, "test3", true) + err1 = s.bd.TakeSnapshot(s.ctx, "clone", cloneSnapshot) + cloneRootFS, err2 := s.workshopRootFS("clone", confinement) + err3 = s.bd.StopWorkshop(s.ctx, "clone", true) c.Assert(cmp.Or(err1, err2, err3), check.IsNil) - // Mount each rootfs for comparison - workdir := c.MkDir() - for i := range 3 { - err := os.Mkdir(filepath.Join(workdir, fmt.Sprint(i)), os.ModePerm) - c.Assert(err, check.IsNil) + // Launch another independent workshop. + wf := &workshop.File{ + Name: "test", + Base: base, + Confinement: confinement, } - var roots []string - var files []uniqueFiles - for i, m := range []mount{mount1, mount2, mount3} { - if m.Fstype != "zfs" { - c.Skip("workshop storage pool is not using ZFS") - } - target := filepath.Join(workdir, fmt.Sprint(i)) - err := syscall.Mount(m.Source, target, m.Fstype, 0, "") + remove = s.launchWorkshop(c, wf, baseOnly) + defer remove() + err = s.bd.StartWorkshop(s.ctx, "test") + c.Assert(err, check.IsNil) + defer func() { + err1 := s.bd.StopWorkshop(s.ctx, "test", true) + c.Check(err1, check.IsNil) + }() + + // Mount the other workshops inside the new one. + unmountOrigin := s.mountRootFS(c, originRootFS, "test", "/mnt/origin") + defer unmountOrigin.Fail() + unmountSibling := s.mountRootFS(c, siblingRootFS, "test", "/mnt/sibling") + defer unmountSibling.Fail() + unmountClone := s.mountRootFS(c, cloneRootFS, "test", "/mnt/clone") + defer unmountClone.Fail() + + // Ensure ID files are unique, while most others are identical. + originFiles := s.extractUniqueFiles(c, "test", "/mnt/origin") + siblingFiles := s.extractUniqueFiles(c, "test", "/mnt/sibling") + cloneFiles := s.extractUniqueFiles(c, "test", "/mnt/clone") + + c.Check(originFiles.hostname, check.Not(check.Equals), siblingFiles.hostname) + c.Check(originFiles.machineID, check.Not(check.Equals), siblingFiles.machineID) + c.Check(originFiles.networkCfg, check.Not(check.Equals), siblingFiles.networkCfg) + c.Check(originFiles.sshKey, check.Not(check.Equals), siblingFiles.sshKey) + + c.Check(originFiles.hostname, check.Not(check.Equals), cloneFiles.hostname) + c.Check(originFiles.machineID, check.Not(check.Equals), cloneFiles.machineID) + c.Check(originFiles.networkCfg, check.Not(check.Equals), cloneFiles.networkCfg) + c.Check(originFiles.sshKey, check.Not(check.Equals), cloneFiles.sshKey) + + s.execDiff(c, "test", "/mnt/origin", "/mnt/sibling") + s.execDiff(c, "test", "/mnt/origin", "/mnt/clone") + + names := []string{"origin", "clone"} + for i, snapshot := range []workshop.Snapshot{originSnapshot, cloneSnapshot} { + c.Logf("Restoring snapshot of %q workshop", names[i]) + + // Restore original workshop from snapshot. + clone := unmountOrigin.Clone() + unmountOrigin.Success() + clone.Fail() + _ = s.launchWorkshop(c, originFile, snapshot) + + // Restart it to give services a chance to run. + err = s.bd.StartWorkshop(s.ctx, "origin") c.Assert(err, check.IsNil) - defer func() { - err1 := syscall.Unmount(target, 0) - c.Check(err1, check.IsNil) - }() + originRootFS, err1 := s.workshopRootFS("origin", confinement) + err2 = s.bd.StopWorkshop(s.ctx, "origin", true) + c.Assert(cmp.Or(err1, err2), check.IsNil) + + // Remount the rootfs into the "test" workshop. + unmountOrigin = s.mountRootFS(c, originRootFS, "test", "/mnt/origin") + defer unmountOrigin.Fail() + + // Check ID files, and most others, are preserved. + restoredFiles := s.extractUniqueFiles(c, "test", "/mnt/origin") + + c.Check(restoredFiles.hostname, check.Equals, originFiles.hostname) + c.Check(restoredFiles.machineID, check.Equals, originFiles.machineID) + c.Check(restoredFiles.networkCfg, check.Equals, originFiles.networkCfg) + c.Check(restoredFiles.sshKey, check.Equals, originFiles.sshKey) + + s.execDiff(c, "test", "/mnt/sibling", "/mnt/origin") + } +} + +type filesystem struct { + name string + confinement workshop.Confinement + + Fstype string `json:"fstype"` + Source string `json:"source"` + Fsroot string `json:"fsroot"` +} + +// workshopRootFS performs operations while the workshop is running to make it +// easy to mount its rootfs elsewhere once it has stopped. For containers, it +// returns the source ZFS dataset, and the subdirectory of the rootfs within +// that. For VMs, it relabels the filesystem to make it easier to locate when +// mounting the parent block device in another VM. +func (s *snapshotSuite) workshopRootFS(name string, confinement workshop.Confinement) (filesystem, error) { + args := workshop.ExecArgs{ + Command: []string{"findmnt", "--json", "--mountpoint=/", "--nofsroot", "--output=fsroot,fstype,source"}, + WorkDir: "/", + Timeout: time.Second, + } + output, err := helper.ExecOutput(s.ctx, s.bd, name, args) + if err != nil { + return filesystem{}, err + } + + var filesystems struct { + Filesystems []filesystem `json:"filesystems"` + } + if err := json.Unmarshal([]byte(output), &filesystems); err != nil { + return filesystem{}, err + } + if len(filesystems.Filesystems) != 1 { + return filesystem{}, fmt.Errorf("expected 1 filesystem, found:\n%s", output) + } + + rootfs := filesystems.Filesystems[0] + rootfs.name = name + rootfs.confinement = confinement + if confinement == workshop.ConfinementContainer { + return rootfs, nil + } + + if rootfs.Fstype != "ext4" { + return filesystem{}, fmt.Errorf("unexpected rootfs type %q", rootfs.Fstype) + } + args.Command = []string{"e2label", "/dev/disk/by-label/cloudimg-rootfs", "workshop-" + name} + _, err = helper.ExecOutput(s.ctx, s.bd, name, args) + if err != nil { + return filesystem{}, err + } + + rootfs.Source = "/dev/disk/by-label/workshop-" + name + return rootfs, nil +} - root := filepath.Join(target, m.Fsroot) - roots = append(roots, root) - files = append(files, extractUniqueFiles(c, root)) +func (s *snapshotSuite) mountRootFS(c *check.C, source filesystem, name, path string) *revert.Reverter { + if source.confinement == workshop.ConfinementContainer { + return s.mountContainerRootFS(c, source, name, path) } + return s.mountVMRootFS(c, source, name, path) +} - // Ensure certain files are unique. - c.Check(files[0].hostname, check.Not(check.Equals), files[1].hostname) - c.Check(files[0].machineID, check.Not(check.Equals), files[1].machineID) - c.Check(files[0].networkCfg, check.Not(check.Equals), files[1].networkCfg) - c.Check(files[0].sshKey, check.Not(check.Equals), files[1].sshKey) +// mountContainerRootFS mounts the rootfs of one container inside another +// container. LXD doesn't support this directly, so we first mount the rootfs +// on the host and then bind-mount the mountpoint into the other workshop. The +// host mount is ID-mapped so the container can modify the files. +func (s *snapshotSuite) mountContainerRootFS(c *check.C, source filesystem, name, path string) *revert.Reverter { + c.Assert(source.Fstype, check.Equals, "zfs") - c.Check(files[0].hostname, check.Not(check.Equals), files[2].hostname) - c.Check(files[0].machineID, check.Not(check.Equals), files[2].machineID) - c.Check(files[0].networkCfg, check.Not(check.Equals), files[2].networkCfg) - c.Check(files[0].sshKey, check.Not(check.Equals), files[2].sshKey) + userns := s.workshopUserNS(c, name) + defer userns.Close() - // Check for unexpected differences. - output, err := exec.Command("diff", "--brief", "--no-dereference", "--recursive", roots[0], roots[1]).CombinedOutput() - c.Check(err, check.IsNil, check.Commentf("%s", output)) + rev := revert.New() + defer rev.Fail() - output, err = exec.Command("diff", "--brief", "--no-dereference", "--recursive", roots[0], roots[2]).CombinedOutput() - c.Check(err, check.IsNil, check.Commentf("%s", output)) + mountpoint := c.MkDir() + s.idmappedMount(c, source, mountpoint, userns) + rev.Add(func() { + err1 := unix.Unmount(mountpoint, 0) + c.Check(err1, check.IsNil) + }) - // Restore first workshop from its own snapshot. - err = syscall.Unmount(filepath.Join(workdir, "0"), 0) + mount := workshop.Mount{ + Name: "root_" + source.name, + Type: workshop.HostWorkshop, + What: filepath.Join(mountpoint, source.Fsroot), + Where: path, + MakeWhere: true, + } + err := s.bd.AddWorkshopMount(s.ctx, name, mount) c.Assert(err, check.IsNil) - _ = s.launchWorkshop(c, wf1, snapshot) + rev.Add(func() { + err1 := s.bd.RemoveWorkshopMount(s.ctx, name, mount.Name) + c.Check(err1, check.IsNil) + }) + + clone := rev.Clone() + rev.Success() + return clone +} - // Restart it to give cloud-init a chance to run. - err = s.bd.StartWorkshop(s.ctx, "test1") +func (s *snapshotSuite) workshopUserNS(c *check.C, name string) *os.File { + conn, err := s.bd.LxdClient(s.ctx) c.Assert(err, check.IsNil) - mount1, err1 = s.rootfsMount("test1") - err2 = s.bd.StopWorkshop(s.ctx, "test1", true) - c.Assert(cmp.Or(err1, err2), check.IsNil) + defer conn.Disconnect() + + state, _, err := conn.GetInstanceState(lxdbackend.InstanceName(name, s.project.ProjectId)) + c.Assert(err, check.IsNil) + c.Assert(state.Pid > 0, check.Equals, true) + + userns, err := os.Open(filepath.Join("/proc", fmt.Sprint(state.Pid), "ns", "user")) + c.Assert(err, check.IsNil) + return userns +} + +func (s *snapshotSuite) idmappedMount(c *check.C, source filesystem, target string, userns *os.File) { + fsfd, err := unix.Fsopen(source.Fstype, unix.FSOPEN_CLOEXEC) + c.Assert(err, check.IsNil) + defer unix.Close(fsfd) + + err = unix.FsconfigSetString(fsfd, "source", source.Source) + c.Assert(err, check.IsNil) + err = unix.FsconfigCreate(fsfd) + c.Assert(err, check.IsNil) + + tree, err := unix.Fsmount(fsfd, unix.FSMOUNT_CLOEXEC, 0) + c.Assert(err, check.IsNil) + defer unix.Close(tree) - // Remount the rootfs. - if mount1.Fstype != "zfs" { - c.Skip("workshop storage pool is not using ZFS") + attr := &unix.MountAttr{ + Attr_set: unix.MOUNT_ATTR_IDMAP, + Userns_fd: uint64(userns.Fd()), } - err = syscall.Mount(mount1.Source, filepath.Join(workdir, "0"), mount1.Fstype, 0, "") + err = unix.MountSetattr(tree, "", unix.AT_EMPTY_PATH, attr) c.Assert(err, check.IsNil) - restored := extractUniqueFiles(c, roots[0]) - // Check that only Workshop-managed attributes are preserved. - c.Check(files[0].hostname, check.Equals, restored.hostname) - c.Check(files[0].machineID, check.Equals, restored.machineID) - c.Check(files[0].networkCfg, check.Equals, restored.networkCfg) - c.Check(files[0].sshKey, check.Equals, restored.sshKey) + err = unix.MoveMount(tree, "", unix.AT_FDCWD, target, unix.MOVE_MOUNT_F_EMPTY_PATH) + c.Assert(err, check.IsNil) +} - // Check for unexpected differences. - output, err = exec.Command("diff", "--brief", "--no-dereference", "--recursive", roots[1], roots[0]).CombinedOutput() - c.Check(err, check.IsNil, check.Commentf("%s", output)) +// mountVMRootFS mounts the rootfs of one VM inside another VM. LXD supports +// this directly, but only creates the block device without mounting it. +// Luckily we already relabeled the rootfs, so we can use that to mount it +// after udev has created the appropriate symlinks. +func (s *snapshotSuite) mountVMRootFS(c *check.C, source filesystem, name, path string) *revert.Reverter { + conn, err := s.bd.LxdClient(s.ctx) + c.Assert(err, check.IsNil) + defer conn.Disconnect() - // Refresh first workshop from a snapshot of third workshop. - err = syscall.Unmount(filepath.Join(workdir, "0"), 0) + inst, etag1, err := conn.GetInstance(lxdbackend.InstanceName(name, s.project.ProjectId)) c.Assert(err, check.IsNil) - _ = s.launchWorkshop(c, wf1, snapshot2) - // Restart first workshop to run cloud-init. - err = s.bd.StartWorkshop(s.ctx, "test1") + vol, etag2, err := conn.GetStoragePoolVolume(inst.Devices["root"]["pool"], inst.Type, lxdbackend.InstanceName(source.name, s.project.ProjectId)) c.Assert(err, check.IsNil) - mount1, err1 = s.rootfsMount("test1") + vol.Config["security.shared"] = "true" + op, err := conn.UpdateStoragePoolVolume(vol.Pool, vol.Type, vol.Name, vol.Writable(), etag2) + c.Assert(err, check.IsNil) + c.Assert(op.WaitContext(s.ctx), check.IsNil) - err2 = s.bd.StopWorkshop(s.ctx, "test1", true) - c.Assert(cmp.Or(err1, err2), check.IsNil) + rev := revert.New() + defer rev.Fail() - // Remount the rootfs. - if mount1.Fstype != "zfs" { - c.Skip("workshop storage pool is not using ZFS") + inst.Devices["root_"+source.name] = map[string]string{ + "type": "disk", + "pool": vol.Pool, + "source": vol.Name, + "source.type": vol.Type, } - err = syscall.Mount(mount1.Source, filepath.Join(workdir, "0"), mount1.Fstype, 0, "") + op, err = conn.UpdateInstance(inst.Name, inst.Writable(), etag1) c.Assert(err, check.IsNil) - refreshed := extractUniqueFiles(c, roots[0]) + c.Assert(op.WaitContext(s.ctx), check.IsNil) - // Check that only Workshop-managed attributes are preserved. - c.Check(files[0].hostname, check.Equals, refreshed.hostname) - c.Check(files[0].machineID, check.Equals, refreshed.machineID) - c.Check(files[0].networkCfg, check.Equals, refreshed.networkCfg) - c.Check(files[0].sshKey, check.Equals, refreshed.sshKey) + rev.Add(func() { + inst1, etag3, err1 := conn.GetInstance(inst.Name) + if c.Check(err1, check.IsNil) { + delete(inst1.Devices, "root_"+source.name) - // Check for unexpected differences. - output, err = exec.Command("diff", "--brief", "--no-dereference", "--recursive", roots[2], roots[0]).CombinedOutput() - c.Check(err, check.IsNil, check.Commentf("%s", output)) -} - -type mount struct { - Fstype string `json:"fstype"` - Source string `json:"source"` - Fsroot string `json:"fsroot"` -} + op1, err1 := conn.UpdateInstance(inst1.Name, inst1.Writable(), etag3) + if c.Check(err1, check.IsNil) { + c.Check(op1.WaitContext(s.ctx), check.IsNil) + } + } + }) -// rootfsMount returns the source ZFS dataset, and subdirectory within that, of -// the given container's rootfs. -func (s *snapshotSuite) rootfsMount(name string) (mount, error) { args := workshop.ExecArgs{ - Command: []string{"findmnt", "--json", "--mountpoint=/", "--nofsroot", "--output=fsroot,fstype,source"}, + Command: []string{"udevadm", "settle"}, WorkDir: "/", Timeout: time.Second, } - output, err := helper.ExecOutput(s.ctx, s.bd, name, args) - if err != nil { - return mount{}, err - } + _, err = helper.ExecOutput(s.ctx, s.bd, name, args) + c.Assert(err, check.IsNil) - var result struct { - Filesystems []mount `json:"filesystems"` - } - if err := json.Unmarshal([]byte(output), &result); err != nil { - return mount{}, err - } - if len(result.Filesystems) != 1 { - return mount{}, fmt.Errorf("expected 1 filesystem, found:\n%s", output) - } + // Check filesystem integrity; nonzero exit codes can indicate the rootfs + // was repaired successfully, but could be a sign that the filesystem + // wasn't properly frozen or the workshop wasn't stopped cleanly. + args.Command = []string{"fsck.ext4", "-fy", source.Source} + out, err := helper.ExecOutput(s.ctx, s.bd, name, args) + c.Check(err, check.IsNil, check.Commentf("%s", out)) - return result.Filesystems[0], nil + args.Command = []string{"mkdir", "-p", path} + _, err = helper.ExecOutput(s.ctx, s.bd, name, args) + c.Assert(err, check.IsNil) + + args.Command = []string{"mount", source.Source, path} + _, err = helper.ExecOutput(s.ctx, s.bd, name, args) + c.Assert(err, check.IsNil) + rev.Add(func() { + args1 := workshop.ExecArgs{ + Command: []string{"umount", path}, + WorkDir: "/", + Timeout: time.Second, + } + _, err1 := helper.ExecOutput(s.ctx, s.bd, name, args1) + c.Check(err1, check.IsNil) + }) + + clone := rev.Clone() + rev.Success() + return clone } type uniqueFiles struct { @@ -541,14 +716,18 @@ type uniqueFiles struct { // extractUniqueFiles prepares a rootfs for diff comparison. It removes files // that are likely be different (most of which are inconsequential) and returns // the contents of the files that really ought to be different. -func extractUniqueFiles(c *check.C, path string) uniqueFiles { - hostname, err := os.ReadFile(filepath.Join(path, "etc", "hostname")) +func (s *snapshotSuite) extractUniqueFiles(c *check.C, name, path string) uniqueFiles { + fs, err := s.bd.WorkshopFs(s.ctx, name) + c.Assert(err, check.IsNil) + defer fs.Close() + + hostname, err := fs.ReadFile(filepath.Join(path, "etc", "hostname")) c.Assert(err, check.IsNil) - machineID, err := os.ReadFile(filepath.Join(path, "etc", "machine-id")) + machineID, err := fs.ReadFile(filepath.Join(path, "etc", "machine-id")) c.Assert(err, check.IsNil) - networkCfg, err := os.ReadFile(filepath.Join(path, "etc", "systemd", "network", "10-cloud-init-eth0.network.d", "workshop.conf")) + networkCfg, err := fs.ReadFile(filepath.Join(path, "etc", "systemd", "network", "10-cloud-init-eth0.network.d", "workshop.conf")) c.Assert(err, check.IsNil) - sshKey, err := os.ReadFile(filepath.Join(path, "etc", "ssh", "ssh_host_ed25519_key.pub")) + sshKey, err := fs.ReadFile(filepath.Join(path, "etc", "ssh", "ssh_host_ed25519_key.pub")) c.Assert(err, check.IsNil) files := []string{ @@ -560,34 +739,34 @@ func extractUniqueFiles(c *check.C, path string) uniqueFiles { "etc/sudoers.d/90-cloud-init-users", "etc/systemd/network/10-cloud-init-eth0.network.d/workshop.conf", "var/cache/ldconfig/aux-cache", + "var/lib/systemd/random-seed", "var/lib/workshop/run/workshop.socket.untrusted", - "var/log/cloud-init.log", - "var/log/cloud-init-output.log", - "var/log/unattended-upgrades/unattended-upgrades-shutdown.log", - "var/log/wtmp", } for _, file := range files { local, err := filepath.Localize(file) c.Assert(err, check.IsNil) - err = os.Remove(filepath.Join(path, local)) + err = fs.Remove(filepath.Join(path, local)) if !errors.Is(err, os.ErrNotExist) { c.Assert(err, check.IsNil) } } dirs := []string{ + "tmp", + "var/cache/apparmor", "var/cache/snapd", "var/lib/cloud", "var/lib/snapd", - "var/log/journal", + "var/log", + "var/snap/lxd/common", "var/tmp", } for _, dir := range dirs { local, err := filepath.Localize(dir) c.Assert(err, check.IsNil) - err = os.RemoveAll(filepath.Join(path, local)) + err = fs.RemoveAll(filepath.Join(path, local)) c.Assert(err, check.IsNil) } @@ -598,3 +777,13 @@ func extractUniqueFiles(c *check.C, path string) uniqueFiles { sshKey: string(sshKey), } } + +func (s *snapshotSuite) execDiff(c *check.C, name, a, b string) { + args := workshop.ExecArgs{ + Command: []string{"diff", "--brief", "--no-dereference", "--recursive", a, b}, + WorkDir: "/", + Timeout: time.Second, + } + out, err := helper.ExecOutput(s.ctx, s.bd, name, args) + c.Check(err, check.IsNil, check.Commentf("diff %s %s:\n%s", a, b, out)) +} diff --git a/internal/workshop/lxd/tests/integration/workshop_exec_test.go b/internal/workshop/lxd/tests/integration/workshop_exec_test.go index 5eacaaf38..de9b9b1bf 100644 --- a/internal/workshop/lxd/tests/integration/workshop_exec_test.go +++ b/internal/workshop/lxd/tests/integration/workshop_exec_test.go @@ -56,18 +56,6 @@ type wsExec struct { var _ = check.Suite(&wsExec{}) -func execTestDevices(projectDir string) func(pid, w string) ([]workshop.Mount, []workshop.ProxyEntry) { - mounts := []workshop.Mount{{ - Name: workshop.ConfigProjectPathDevice, - Type: workshop.HostWorkshop, - What: projectDir, - Where: workshop.WorkshopProjectPath, - }} - return func(pid, w string) ([]workshop.Mount, []workshop.ProxyEntry) { - return mounts, nil - } -} - func (f *wsExec) SetUpSuite(c *check.C) { dirs.SetRootDir(c.MkDir()) c.Assert(dirs.CreateDirs(), check.IsNil) @@ -120,7 +108,7 @@ func (f *wsExec) SetUpSuite(c *check.C) { f.lxdClient, err = f.be.(*lxdbackend.Backend).LxdClient(f.ctx) c.Check(err, check.IsNil) - f.restoreDevices = workshop.FakeDefaultDevices(execTestDevices(c.MkDir())) + f.restoreDevices = workshop.FakeDefaultDevices(helper.TestDevices) f.newProjectidRestore = testutil.FakeFunc(func() (string, error) { return f.project.ProjectId, nil diff --git a/internal/workshop/lxd/tests/integration/workshop_test.go b/internal/workshop/lxd/tests/integration/workshop_test.go index 23b0be473..3cc2ee51d 100644 --- a/internal/workshop/lxd/tests/integration/workshop_test.go +++ b/internal/workshop/lxd/tests/integration/workshop_test.go @@ -42,6 +42,7 @@ import ( "github.com/canonical/workshop/internal/sdk" "github.com/canonical/workshop/internal/syscheck" "github.com/canonical/workshop/internal/testutil" + "github.com/canonical/workshop/internal/waitready" "github.com/canonical/workshop/internal/workshop" lxdbackend "github.com/canonical/workshop/internal/workshop/lxd" "github.com/canonical/workshop/internal/workshop/lxd/tests/helper" @@ -77,7 +78,7 @@ func (f *wsOps) SetUpSuite(c *check.C) { c.Assert(os.Mkdir(f.project.Path, os.ModePerm), check.IsNil) f.ctx = helper.CreateTestContext(f.usr.Username, "42424242") - f.restoreDevices = workshop.FakeDefaultDevices(helper.DefaultTestDevices) + f.restoreDevices = workshop.FakeDefaultDevices(helper.TestDevices) f.restoreImageServer = lxdbackend.FakeImageServer(helper.MinimalImageServer) f.restoreUserLookup = osutil.FakeUserLookup(func(name string) (*user.User, error) { return f.usr, nil @@ -154,11 +155,11 @@ func (f *wsOps) TestLxdBackendWorkshopStashUnstash(c *check.C) { Name: "test", Base: "ubuntu@22.04", } - image, err := f.bd.GetBase(f.ctx, wf.Base) + image, err := f.bd.GetBase(f.ctx, wf.Base, workshop.ConfinementContainer) c.Assert(err, check.IsNil) err = f.bd.DownloadBase(f.ctx, image, nil) c.Assert(err, check.IsNil) - snapshot := workshop.BaseOnly(f.bd.FormatRevision(), image.Name, image.Fingerprint) + snapshot := workshop.BaseOnly(f.bd.FormatRevision(), image.Name, workshop.ConfinementContainer, image.Fingerprint) err = f.bd.LaunchOrRebuildWorkshop(f.ctx, wf, snapshot) c.Assert(err, check.IsNil) @@ -531,9 +532,10 @@ func (f *wsOps) TestLxdBackendDownloadBase(c *check.C) { // ensure there is no image in LXD storage f.deleteImages(c, "ubuntu@22.04") - image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04") + image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) c.Check(image.Name, check.Equals, "ubuntu@22.04") + c.Check(image.Confinement, check.Equals, workshop.ConfinementContainer) c.Assert(image.Fingerprint, check.Not(check.Equals), "") var wg sync.WaitGroup @@ -561,30 +563,30 @@ func (f *wsOps) TestLxdBackendDownloadBase(c *check.C) { } func (f *wsOps) TestLxdBackendGetOrDownloadMalformedBase(c *check.C) { - image := workshop.BaseImage{Name: "ubuntu:24.04", Fingerprint: ""} - _, err := f.bd.GetBase(f.ctx, image.Name) + image := workshop.BaseImage{Name: "ubuntu:24.04", Confinement: workshop.ConfinementContainer, Fingerprint: ""} + _, err := f.bd.GetBase(f.ctx, image.Name, workshop.ConfinementContainer) c.Check(err, check.ErrorMatches, `invalid base "ubuntu:24.04" \(expected @\)`) err = f.bd.DownloadBase(f.ctx, image, nil) c.Check(err, check.ErrorMatches, `invalid base "ubuntu:24.04" \(expected @\)`) image.Name = "ubuntu@" - _, err = f.bd.GetBase(f.ctx, image.Name) + _, err = f.bd.GetBase(f.ctx, image.Name, workshop.ConfinementContainer) c.Check(err, check.ErrorMatches, `invalid base "ubuntu@" \(expected @\)`) err = f.bd.DownloadBase(f.ctx, image, nil) c.Check(err, check.ErrorMatches, `invalid base "ubuntu@" \(expected @\)`) image.Name = "canonical@ubuntu@24.04" - _, err = f.bd.GetBase(f.ctx, image.Name) + _, err = f.bd.GetBase(f.ctx, image.Name, workshop.ConfinementContainer) c.Check(err, check.ErrorMatches, `invalid base "canonical@ubuntu@24.04" \(expected @\)`) err = f.bd.DownloadBase(f.ctx, image, nil) c.Check(err, check.ErrorMatches, `invalid base "canonical@ubuntu@24.04" \(expected @\)`) } func (f *wsOps) TestLxdBackendDownloadBaseImageNotFound(c *check.C) { - _, err := f.bd.GetBase(f.ctx, "ubuntu@1.01") + _, err := f.bd.GetBase(f.ctx, "ubuntu@1.01", workshop.ConfinementContainer) c.Check(err, check.ErrorMatches, `base "ubuntu@1.01" not found.*`) - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "##################"} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "##################"} err = f.bd.DownloadBase(f.ctx, image, nil) c.Check(err, check.ErrorMatches, `"ubuntu@22.04" download failed.*`) } @@ -592,15 +594,15 @@ func (f *wsOps) TestLxdBackendDownloadBaseImageNotFound(c *check.C) { func (f *wsOps) TestLxdBackendDownloadProtocolNotSupported(c *check.C) { defer lxdbackend.FakeImageServer("https://cloud-images.ubuntu.com/minimal/releases")() - image := workshop.BaseImage{Name: "ubuntu@20.04", Fingerprint: ""} - _, err := f.bd.GetBase(f.ctx, image.Name) + image := workshop.BaseImage{Name: "ubuntu@20.04", Confinement: workshop.ConfinementContainer, Fingerprint: ""} + _, err := f.bd.GetBase(f.ctx, image.Name, workshop.ConfinementContainer) c.Check(err, check.ErrorMatches, `unknown image server URL prefix \(supported: simplestreams, lxd\)`) err = f.bd.DownloadBase(f.ctx, image, nil) c.Check(err, check.ErrorMatches, `unknown image server URL prefix \(supported: simplestreams, lxd\)`) } func (f *wsOps) TestLxdBackendDownloadConcurrentErrors(c *check.C) { - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: "##################"} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: "##################"} var wg sync.WaitGroup for range 5 { @@ -616,9 +618,10 @@ func (f *wsOps) TestLxdBackendDownloadBaseResumeAfterCancellation(c *check.C) { // ensure there is no image in LXD storage f.deleteImages(c, "ubuntu@22.04") - image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04") + image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) c.Check(image.Name, check.Equals, "ubuntu@22.04") + c.Check(image.Confinement, check.Equals, workshop.ConfinementContainer) c.Assert(image.Fingerprint, check.Not(check.Equals), "") wcancel, cancel := context.WithCancel(f.ctx) @@ -663,9 +666,10 @@ func (f *wsOps) TestLxdBackendDownloadMultipleBasesConcurrently(c *check.C) { var wg sync.WaitGroup for i, b := range workshop.SupportedBases { wg.Go(func() { - image, err := f.bd.GetBase(f.ctx, b) + image, err := f.bd.GetBase(f.ctx, b, workshop.ConfinementContainer) c.Assert(err, check.IsNil) c.Check(image.Name, check.Equals, b) + c.Check(image.Confinement, check.Equals, workshop.ConfinementContainer) c.Assert(image.Fingerprint, check.Not(check.Equals), "") fingerprints[i] = image.Fingerprint @@ -703,9 +707,10 @@ func (f *wsOps) TestLxdBackendReuseDownloadedBase(c *check.C) { images := f.listAllImages(c, "ubuntu@22.04") c.Assert(images, check.HasLen, 0) - image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04") + image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) c.Check(image.Name, check.Equals, "ubuntu@22.04") + c.Check(image.Confinement, check.Equals, workshop.ConfinementContainer) c.Assert(image.Fingerprint, check.Not(check.Equals), "") err = f.bd.DownloadBase(f.ctx, image, nil) c.Assert(err, check.IsNil) @@ -766,7 +771,7 @@ func (f *wsOps) TestLxdBackendReuseCachedBase(c *check.C) { c.Check(ok, check.Equals, false) c.Check(imageCached.UpdateSource, check.NotNil) - image := workshop.BaseImage{Name: "ubuntu@22.04", Fingerprint: imageCached.Fingerprint} + image := workshop.BaseImage{Name: "ubuntu@22.04", Confinement: workshop.ConfinementContainer, Fingerprint: imageCached.Fingerprint} err := f.bd.DownloadBase(f.ctx, image, nil) c.Assert(err, check.IsNil) @@ -791,13 +796,18 @@ func (f *wsOps) TestLxdBackendWorkshopStartFailed(c *check.C) { helper.LaunchTestWorkshop(c, f.ctx, f.bd, f.project.Path) defer helper.RemoveTestWorkshop(c, f.ctx, f.bd) - err := f.bd.StopWorkshop(f.ctx, "test", true) + // Disable the waitready service, so start will time out. + fs, err := f.bd.WorkshopFs(f.ctx, "test") + c.Assert(err, check.IsNil) + err = fs.Remove("/etc/systemd/system/multi-user.target.wants/workshop-waitready.service") + c.Assert(fs.Close(), check.IsNil) + c.Assert(err, check.IsNil) + + err = f.bd.StopWorkshop(f.ctx, "test", true) c.Check(err, check.IsNil) - // Leaves the workshop instance in a started state with a failed start - // command. The StartWorkshop API must clean up its previous progress, i.e. - // set the workshop to the Stopped state. - defer lxdbackend.FakeStartCommand("exit 1")() + // Speed up the test. + defer testutil.FakeFunc(time.Second, &waitready.Timeout)() err = f.bd.StartWorkshop(f.ctx, "test") c.Check(err, check.NotNil) @@ -890,13 +900,13 @@ func (f *wsOps) lsMnt(c *check.C) []os.FileInfo { } func (f *wsOps) TestLxdBackendWorkshopLaunch(c *check.C) { - image, err := f.bd.GetBase(f.ctx, "ubuntu@24.04") + image, err := f.bd.GetBase(f.ctx, "ubuntu@24.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) err = f.bd.DownloadBase(f.ctx, image, nil) c.Assert(err, check.IsNil) wf := &workshop.File{Name: "test", Base: "ubuntu@24.04"} - snapshot := workshop.BaseOnly(f.bd.FormatRevision(), image.Name, image.Fingerprint) + snapshot := workshop.BaseOnly(f.bd.FormatRevision(), image.Name, workshop.ConfinementContainer, image.Fingerprint) err = f.bd.LaunchOrRebuildWorkshop(f.ctx, wf, snapshot) c.Assert(err, check.IsNil) defer helper.RemoveTestWorkshop(c, f.ctx, f.bd) @@ -943,11 +953,11 @@ func (f *wsOps) TestLxdBackendWorkshopRebuild(c *check.C) { Name: "test", Base: "ubuntu@22.04", } - image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04") + image, err := f.bd.GetBase(f.ctx, "ubuntu@22.04", workshop.ConfinementContainer) c.Assert(err, check.IsNil) err = f.bd.DownloadBase(f.ctx, image, nil) c.Assert(err, check.IsNil) - snapshot := workshop.BaseOnly(f.bd.FormatRevision(), image.Name, image.Fingerprint) + snapshot := workshop.BaseOnly(f.bd.FormatRevision(), image.Name, workshop.ConfinementContainer, image.Fingerprint) err = f.bd.LaunchOrRebuildWorkshop(f.ctx, wf, snapshot) c.Assert(err, check.IsNil) @@ -1144,6 +1154,7 @@ func (f *wsOps) TestLxdBackendSnapshotOK(c *check.C) { Format: f.bd.FormatRevision(), Image: workshop.BaseImage{ Name: "ubuntu@24.04", + Confinement: workshop.ConfinementContainer, Fingerprint: "0b9429c9855cb158b90159bb818e6f98eab9b5b1260ace11b30ddb936e4f78979abc7cdc5e4e9fad51e3e290a2190ac2", }, Sdks: []sdk.ContentID{{ @@ -1198,6 +1209,7 @@ func (f *wsOps) TestLxdBackendSnapshotConflict(c *check.C) { Format: f.bd.FormatRevision(), Image: workshop.BaseImage{ Name: "ubuntu@24.04", + Confinement: workshop.ConfinementContainer, Fingerprint: "0b9429c9855cb158b90159bb818e6f98eab9b5b1260ace11b30ddb936e4f78979abc7cdc5e4e9fad51e3e290a2190ac2", }, Sdks: []sdk.ContentID{{ @@ -1280,6 +1292,7 @@ func (f *wsOps) TestLxdBackendSnapshotInterrupted(c *check.C) { Format: f.bd.FormatRevision(), Image: workshop.BaseImage{ Name: "ubuntu@24.04", + Confinement: workshop.ConfinementContainer, Fingerprint: "0b9429c9855cb158b90159bb818e6f98eab9b5b1260ace11b30ddb936e4f78979abc7cdc5e4e9fad51e3e290a2190ac2", }, Sdks: []sdk.ContentID{{ @@ -1328,6 +1341,7 @@ func (f *wsOps) TestLxdBackendSnapshotHashCollision(c *check.C) { Format: f.bd.FormatRevision(), Image: workshop.BaseImage{ Name: "ubuntu@24.04", + Confinement: workshop.ConfinementContainer, Fingerprint: "0b9429c9855cb158b90159bb818e6f98eab9b5b1260ace11b30ddb936e4f78979abc7cdc5e4e9fad51e3e290a2190ac2", }, Sdks: []sdk.ContentID{{ diff --git a/internal/workshop/workshop_file.go b/internal/workshop/workshop_file.go index 465a33c6e..cb77df72b 100644 --- a/internal/workshop/workshop_file.go +++ b/internal/workshop/workshop_file.go @@ -209,9 +209,40 @@ type Connection struct { type Action string +type Confinement int + +const ( + ConfinementContainer Confinement = iota + ConfinementVirtualMachine +) + +func (c Confinement) MarshalText() ([]byte, error) { + switch c { + case ConfinementContainer: + return []byte("container"), nil + case ConfinementVirtualMachine: + return []byte("virtual-machine"), nil + default: + return nil, fmt.Errorf("invalid confinement: %v", int(c)) + } +} + +func (c *Confinement) UnmarshalText(text []byte) error { + switch string(text) { + case "container": + *c = ConfinementContainer + case "virtual-machine": + *c = ConfinementVirtualMachine + default: + return fmt.Errorf("invalid confinement: %q", string(text)) + } + return nil +} + type File struct { Name string `yaml:"name"` Base string `yaml:"base"` + Confinement Confinement `yaml:"confinement,omitempty"` Sdks []SdkRecord `yaml:"sdks,omitempty"` Connections []Connection `yaml:"connections,omitempty"` Actions map[string]Action `yaml:"actions,omitempty"` diff --git a/internal/workshop/workshop_file_test.go b/internal/workshop/workshop_file_test.go index 9720b3586..5f0910a9e 100644 --- a/internal/workshop/workshop_file_test.go +++ b/internal/workshop/workshop_file_test.go @@ -95,6 +95,7 @@ actions: c.Assert(err, check.Equals, nil) c.Assert(file.Name, check.Equals, "xbert-gpu") c.Assert(file.Base, check.Equals, "ubuntu@20.04") + c.Assert(file.Confinement, check.Equals, workshop.ConfinementContainer) c.Assert(file.Sdks[0], check.DeepEquals, workshop.SdkRecord{Name: "system", Source: sdk.SystemSource}) c.Assert(file.Sdks[1], check.DeepEquals, workshop.SdkRecord{Name: "huggingface"}) c.Assert(file.Sdks[2], check.DeepEquals, workshop.SdkRecord{Name: "cuda", Channel: "latest/edge"}) @@ -186,6 +187,34 @@ func (f *workshopFile) TestSingleWorkshopFileError(c *check.C) { c.Assert(err, check.ErrorMatches, ".*is a directory") } +func (f *workshopFile) TestConfinement(c *check.C) { + yaml := `name: xbert-gpu +base: ubuntu@20.04 +confinement: container +` + f.createSingleWFile(c, "workshop.yaml", yaml) + file, err := f.project.Workshop("xbert-gpu") + c.Assert(err, check.IsNil) + c.Check(file.Confinement, check.Equals, workshop.ConfinementContainer) + + yaml = strings.Replace(yaml, "container", "virtual-machine", 1) + f.createSingleWFile(c, "workshop.yaml", yaml) + file, err = f.project.Workshop("xbert-gpu") + c.Assert(err, check.IsNil) + c.Check(file.Confinement, check.Equals, workshop.ConfinementVirtualMachine) +} + +func (f *workshopFile) TestConfinementError(c *check.C) { + yaml := `name: xbert-gpu +base: ubuntu@20.04 +confinement: classic +` + f.createSingleWFile(c, "workshop.yaml", yaml) + file, err := f.project.Workshop("xbert-gpu") + c.Check(file, check.IsNil) + c.Check(err, check.ErrorMatches, `invalid file ".*": invalid confinement: "classic"`) +} + func (f *workshopFile) TestWorkshopFileDuplicate(c *check.C) { yaml := `name: xbert-gpu base: ubuntu@22.04 diff --git a/snap/local/commands/run_daemon b/snap/local/commands/run_daemon index cf7018d70..d82625527 100755 --- a/snap/local/commands/run_daemon +++ b/snap/local/commands/run_daemon @@ -9,6 +9,9 @@ export WORKSHOP_DEBUG WORKSHOP_IMAGE_SERVER=$(snapctl get workshop.image.server.url) export WORKSHOP_IMAGE_SERVER +WORKSHOP_EXPERIMENTAL_VMS=$(snapctl get workshop.experimental-vms) +export WORKSHOP_EXPERIMENTAL_VMS + read -r sbpid _ < /proc/self/stat export LISTEN_PID="${sbpid}" diff --git a/tests/integration/workshop-snapshots/task.yaml b/tests/integration/workshop-snapshots/task.yaml index 0c9d02c57..0bba65d72 100644 --- a/tests/integration/workshop-snapshots/task.yaml +++ b/tests/integration/workshop-snapshots/task.yaml @@ -1,10 +1,18 @@ summary: Run LXD integration tests for workshop snapshots +environment: + METHOD/format: TestLxdBackendSnapshotFormat + # TODO: enable VM variants + METHOD/ctr20: TestLxdBackendSnapshotDiffContainer20 + METHOD/ctr22: TestLxdBackendSnapshotDiffContainer22 + METHOD/ctr24: TestLxdBackendSnapshotDiffContainer24 + METHOD/ctr26: TestLxdBackendSnapshotDiffContainer26 + execute: | rm -rf cover mkdir cover - go test -cover "$SPREAD_PATH"/internal/workshop/lxd/tests/integration -timeout=30m -tags=integration -check.v -check.f snapshotSuite -coverpkg=github.com/canonical/workshop/internal/workshop/lxd -args -test.gocoverdir="$PWD"/cover + go test -cover "$SPREAD_PATH"/internal/workshop/lxd/tests/integration -timeout=30m -tags=integration -check.v -check.f "snapshotSuite.$METHOD" -coverpkg=github.com/canonical/workshop/internal/workshop/lxd -args -test.gocoverdir="$PWD"/cover artifacts: - cover diff --git a/tests/lib/utils.sh b/tests/lib/utils.sh index c3977f819..52f386ed8 100644 --- a/tests/lib/utils.sh +++ b/tests/lib/utils.sh @@ -88,6 +88,7 @@ function setup_workshop() { snap install --dangerous --classic /workshop/tests/*.snap snap set workshop workshop.debug=1 + snap set workshop workshop.experimental-vms=1 snap set workshop workshop.image.server.url="$IMAGE_SERVER" snap alias workshop.sdk sdk snap restart workshop diff --git a/tests/main/launch-multiple/task.yaml b/tests/main/launch-multiple/task.yaml index ee8b2cf37..efba10a47 100644 --- a/tests/main/launch-multiple/task.yaml +++ b/tests/main/launch-multiple/task.yaml @@ -1,4 +1,7 @@ summary: Ensure that launch for multiple workshops works correctly +restore: | + . "$TESTSLIB"/utils.sh + workshop_exec remove ws-one ws-two ws-three execute: | . "$TESTSLIB"/utils.sh @@ -8,4 +11,7 @@ execute: | workshop_exec list | MATCH "ws-two[[:space:]]*Ready[[:space:]]*-" workshop_exec list | MATCH "ws-three[[:space:]]*Ready[[:space:]]*-" - workshop_exec remove ws-one ws-two ws-three + # ensure workshops can resolve each other by name + mkdir -p srv/qwertyuiop + workshop_exec exec ws-one -- systemd-run --user --working-directory=/project/srv -- python3 -m http.server 8877 + workshop_exec exec ws-two -- curl http://ws-one:8877 | MATCH qwertyuiop diff --git a/tests/main/launch/.workshop/ws-22.yaml b/tests/main/launch/.workshop/ws-22.yaml deleted file mode 100644 index 475fe1fa0..000000000 --- a/tests/main/launch/.workshop/ws-22.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: ws-22 -base: ubuntu@22.04 -actions: - check-apt: | - dry_run() { - sudo apt-get -o 'Debug::NoLocking=true' -o 'APT::Get::Assume-Yes=0' --assume-no "$@" install gnome - } - - [ -f /etc/apt/apt.conf.d/94cloud-init-config ] - sudo apt-get update - diff <(dry_run --no-install-recommends --no-install-suggests) <(dry_run) diff --git a/tests/main/launch/.workshop/ws-24.yaml b/tests/main/launch/.workshop/ws-24.yaml deleted file mode 100644 index 5279443ff..000000000 --- a/tests/main/launch/.workshop/ws-24.yaml +++ /dev/null @@ -1,11 +0,0 @@ -name: ws-24 -base: ubuntu@24.04 -actions: - check-apt: | - dry_run() { - sudo apt-get -o 'Debug::NoLocking=true' -o 'APT::Get::Assume-Yes=0' --assume-no "$@" install gnome - } - - [ -f /etc/apt/apt.conf.d/94cloud-init-config ] - sudo apt-get update - diff <(dry_run --no-install-recommends --no-install-suggests) <(dry_run) diff --git a/tests/main/launch/task.yaml b/tests/main/launch/task.yaml index ab56021d4..9bb4e78d4 100644 --- a/tests/main/launch/task.yaml +++ b/tests/main/launch/task.yaml @@ -1,51 +1,64 @@ summary: Test launch for the supported workshop bases +environment: + # TODO: enable VM variants + BASE/ctr20: ubuntu@20.04 + BASE/ctr22: ubuntu@22.04 + BASE/ctr24: ubuntu@24.04 + BASE/ctr26: ubuntu@26.04 + CONFINEMENT: container prepare: | timedatectl set-timezone Pacific/Auckland restore: | . "$TESTSLIB"/utils.sh timedatectl set-timezone Etc/UTC - workshop_exec remove ws-20 ws-22 ws-24 + workshop_exec remove || true execute: | . "$TESTSLIB"/utils.sh - echo "Test launch for all the supported bases" + echo "Test launch for $BASE ${CONFINEMENT}s" + envsubst '${BASE}${CONFINEMENT}' < workshop.yaml.in > workshop.yaml + chown ubuntu:ubuntu workshop.yaml + workshop_exec launch ws + workshop_exec list | MATCH 'ws[[:space:]]*Ready[[:space:]]*-' + # ensure user idmap is applied + rm -f build-artifact + workshop_exec exec ws touch build-artifact + stat --format='%U %G' build-artifact | MATCH '^ubuntu ubuntu$' + workshop_exec exec ws sudo chown 0:0 build-artifact + stat --format='%u %g' build-artifact | MATCH '^1000000 1000000$' + workshop_exec exec ws sudo chown 1001:1001 build-artifact + stat --format='%u %g' build-artifact | MATCH '^1001001 1001001$' + + # ensure the workshop has a socket for workshopctl + if [ "$CONFINEMENT" = container ]; then + workshop_exec exec ws test -S /var/lib/workshop/run/workshop.socket.untrusted + fi + + # ensure the apt cache is mounted + workshop_exec exec ws mountpoint /var/cache/apt/archives + workshop_exec exec ws stat --format='%A %U %G' /var/cache/apt/archives | MATCH '^drwxr-xr-x workshop workshop$' + + # ensure apt is configured to exclude suggested and recommended packages + workshop_exec run ws check-apt + + # ensure systemd-resolved is configured for the workshopbr0 network + resolvectl domain workshopbr0 | MATCH '^Link [0-9]+ \(workshopbr0\): ~wp$' + resolvectl query "ws-$(< .workshop.lock).wp" | MATCH 'link: workshopbr0' + resolvectl query --type=CNAME "ws.$(< .workshop.lock).wp" | MATCH "IN CNAME ws-$(< .workshop.lock).wp" + resolvectl query --type=CNAME 'ws.launch.wp' | MATCH "IN CNAME ws-$(< .workshop.lock).wp" + + # ensure systemd-networkd is configured not to release DHCP on stop + # just skip if `networkctl cat` is missing, since old bases have been manually tested function extract_section() { awk --assign title="[$1]" '/\[/ {p=0} $0 == title {p=1; next} p' } + sd_ver="$(workshop_exec exec ws networkctl --version | awk 'FNR == 1 {print $2}')" + if [ "$sd_ver" -ge 254 ]; then + workshop_exec exec ws sudo networkctl cat @eth0 > eth0.log + extract_section DHCPv4 < eth0.log | MATCH '^SendRelease=false$' + extract_section DHCPv6 < eth0.log | MATCH '^SendRelease=false$' + fi - function launch_and_validate() { - workshop_exec launch "$1" - workshop_exec list | MATCH "$1[[:space:]]*Ready[[:space:]]*-" - # ensure the workshop has a socket for workshopctl - workshop_exec exec "$1" test -S /var/lib/workshop/run/workshop.socket.untrusted - # ensure the apt cache is mounted - workshop_exec exec "$1" mountpoint /var/cache/apt/archives - workshop_exec exec "$1" stat --format='%A %U %G' /var/cache/apt/archives | MATCH '^drwxr-xr-x workshop workshop$' - # ensure apt is configured to exclude suggested and recommended packages - workshop_exec run "$1" check-apt - # ensure systemd-resolved is configured for the workshopbr0 network - resolvectl domain workshopbr0 | MATCH '^Link [0-9]+ \(workshopbr0\): ~wp$' - resolvectl query "$1-$(< .workshop.lock).wp" | MATCH 'link: workshopbr0' - resolvectl query --type=CNAME "${1}.$(< .workshop.lock).wp" | MATCH "IN CNAME $1-$(< .workshop.lock).wp" - resolvectl query --type=CNAME "${1}.launch.wp" | MATCH "IN CNAME $1-$(< .workshop.lock).wp" - # ensure systemd-networkd is configured not to release DHCP on stop - # just skip if `networkctl cat` is missing, since old bases have been manually tested - sd_ver="$(workshop_exec exec "$1" networkctl --version | awk 'FNR == 1 {print $2}')" - if [ "$sd_ver" -ge 254 ]; then - workshop_exec exec "$1" sudo networkctl cat @eth0 > eth0.log - extract_section DHCPv4 < eth0.log | MATCH '^SendRelease=false$' - extract_section DHCPv6 < eth0.log | MATCH '^SendRelease=false$' - fi - # ensure timezone is inherited from host - workshop_exec exec "$1" timedatectl show | MATCH '^Timezone=Pacific/Auckland$' - } - - launch_and_validate ws-20 - launch_and_validate ws-22 - launch_and_validate ws-24 - - # ensure workshops can resolve each other by name - mkdir -p srv/qwertyuiop - workshop_exec exec ws-22 -- systemd-run --user --working-directory=/project/srv -- python3 -m http.server 8877 - workshop_exec exec ws-24 -- curl http://ws-22:8877 | MATCH qwertyuiop + # ensure timezone is inherited from host + workshop_exec exec ws timedatectl show | MATCH '^Timezone=Pacific/Auckland$' diff --git a/tests/main/launch/.workshop/ws-20.yaml b/tests/main/launch/workshop.yaml.in similarity index 85% rename from tests/main/launch/.workshop/ws-20.yaml rename to tests/main/launch/workshop.yaml.in index 072331f9f..8a727edae 100644 --- a/tests/main/launch/.workshop/ws-20.yaml +++ b/tests/main/launch/workshop.yaml.in @@ -1,5 +1,6 @@ -name: ws-20 -base: ubuntu@20.04 +name: ws +base: ${BASE} +confinement: ${CONFINEMENT} actions: check-apt: | dry_run() { diff --git a/tests/main/start/task.yaml b/tests/main/start/task.yaml index d85a2f58a..131da03a7 100644 --- a/tests/main/start/task.yaml +++ b/tests/main/start/task.yaml @@ -14,28 +14,6 @@ execute: | workshop_exec exec -- test -d /run/user/1000 } - # TODO: remove this when `workshop start` begins to rely on LXD events. - function check_lxd_ready() { - echo "Check LXD itself reports the instance as Ready" - filter="name=ws-start-$(< .workshop.lock)" - - for _ in {1..50}; do - if lxc list --format csv --columns s --project workshop.ubuntu "$filter" | grep -Fqx READY; then - return 0 - fi - sleep 0.1 - done - - echo "Timed out waiting for LXD to report instance as READY" >&2 - workshop_exec exec -- systemctl status workshop-waitready.service || true - workshop_exec exec -- journalctl -u workshop-waitready.service || true - workshop_exec exec -- systemctl || true - workshop_exec exec -- systemctl status || true - workshop_exec exec -- journalctl -b || true - lxc info --show-log --project workshop.ubuntu "ws-start-$(< .workshop.lock)" - exit 1 - } - check_linger workshop_exec stop @@ -48,7 +26,6 @@ execute: | echo "Check the workshop is in Ready state" workshop_exec list | MATCH "^ws-start[[:space:]]+Ready[[:space:]]+-$" - check_lxd_ready check_linger echo "Check the workshop also becomes Ready when systemd boots degraded" @@ -57,5 +34,4 @@ execute: | workshop_exec stop workshop_exec start workshop_exec list | MATCH "^ws-start[[:space:]]+Ready[[:space:]]+-$" - check_lxd_ready workshop_exec exec -- systemctl is-system-running | MATCH '^degraded$'