diff --git a/client/clientutil/snapinfo_test.go b/client/clientutil/snapinfo_test.go index 67c5469de13..96f43c11a43 100644 --- a/client/clientutil/snapinfo_test.go +++ b/client/clientutil/snapinfo_test.go @@ -121,6 +121,7 @@ func (*cmdSuite) TestClientSnapFromSnapInfo(c *C) { "Hold", "GatingHold", "RefreshInhibit", + "Components", } var checker func(string, reflect.Value) checker = func(pfx string, x reflect.Value) { diff --git a/client/components.go b/client/components.go new file mode 100644 index 00000000000..b5473046261 --- /dev/null +++ b/client/components.go @@ -0,0 +1,40 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 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 client + +import ( + "time" + + "github.com/snapcore/snapd/snap" +) + +// Component describes a component for API purposes +// TODO we will eventually add a "status" field when it becomes clear which +// states can a component have. +type Component struct { + Name string `json:"name"` + Type snap.ComponentType `json:"type"` + Version string `json:"version"` + Summary string `json:"summary"` + Description string `json:"description"` + Revision snap.Revision `json:"revision"` + InstalledSize int64 `json:"installed-size,omitempty"` + InstallDate *time.Time `json:"install-date,omitempty"` +} diff --git a/client/packages.go b/client/packages.go index 9a543ef60b2..d4a9c4cd0f0 100644 --- a/client/packages.go +++ b/client/packages.go @@ -91,6 +91,9 @@ type Snap struct { GatingHold *time.Time `json:"gating-hold,omitempty"` // if RefreshInhibit is nil, then there is no pending refresh. RefreshInhibit *SnapRefreshInhibit `json:"refresh-inhibit,omitempty"` + + // Components is a list of the snap components + Components []Component `json:"components,omitempty"` } type SnapHealth struct { diff --git a/daemon/api_sideload_n_try.go b/daemon/api_sideload_n_try.go index f85c3aa2d64..7d3d48ef0a5 100644 --- a/daemon/api_sideload_n_try.go +++ b/daemon/api_sideload_n_try.go @@ -325,18 +325,21 @@ func sideloadSnap(st *state.State, snapFile *uploadedSnap, flags sideloadFlags) var tset *state.TaskSet container := "snap" + message := fmt.Sprintf("%q snap", instanceName) if compInfo == nil { tset, _, err = snapstateInstallPath(st, sideInfo, snapFile.tmpPath, instanceName, "", flags.Flags, nil) } else { // It is a component container = "component" + message = fmt.Sprintf("%q component for %q snap", + compInfo.Component.ComponentName, instanceName) tset, err = snapstateInstallComponentPath(st, snap.NewComponentSideInfo(compInfo.Component, snap.Revision{}), snapInfo, snapFile.tmpPath, flags.Flags) } if err != nil { return nil, errToResponse(err, []string{sideInfo.RealName}, InternalError, "cannot install %s file: %v", container) } - msg := fmt.Sprintf(i18n.G("Install %q %s from file %q"), instanceName, container, snapFile.filename) + msg := fmt.Sprintf(i18n.G("Install %s from file %q"), message, snapFile.filename) chg := newChange(st, "install-"+container, msg, []*state.TaskSet{tset}, []string{instanceName}) apiData := map[string]interface{}{ "snap-name": instanceName, diff --git a/daemon/api_sideload_n_try_test.go b/daemon/api_sideload_n_try_test.go index 163eca1a02d..8847b9f9093 100644 --- a/daemon/api_sideload_n_try_test.go +++ b/daemon/api_sideload_n_try_test.go @@ -295,7 +295,7 @@ func (s *sideloadSuite) TestSideloadComponent(c *check.C) { csi := snap.NewComponentSideInfo(naming.NewComponentRef("local", "comp"), snap.Revision{}) chgSummary, systemRestartImmediate := s.sideloadComponentCheck(c, body, head, "local", flags, csi) - c.Check(chgSummary, check.Equals, `Install "local" component from file "a/b/local+localcomp.comp"`) + c.Check(chgSummary, check.Equals, `Install "comp" component for "local" snap from file "a/b/local+localcomp.comp"`) c.Check(systemRestartImmediate, check.Equals, false) } @@ -311,7 +311,7 @@ func (s *sideloadSuite) TestSideloadComponentInstanceName(c *check.C) { csi := snap.NewComponentSideInfo(naming.NewComponentRef("local", "comp"), snap.Revision{}) chgSummary, systemRestartImmediate := s.sideloadComponentCheck(c, body, head, "local_instance", flags, csi) - c.Check(chgSummary, check.Equals, `Install "local_instance" component from file "a/b/local+localcomp.comp"`) + c.Check(chgSummary, check.Equals, `Install "comp" component for "local_instance" snap from file "a/b/local+localcomp.comp"`) c.Check(systemRestartImmediate, check.Equals, false) } diff --git a/daemon/api_snaps_test.go b/daemon/api_snaps_test.go index c2c02ed9945..63f2b7c98f0 100644 --- a/daemon/api_snaps_test.go +++ b/daemon/api_snaps_test.go @@ -46,10 +46,13 @@ import ( "github.com/snapcore/snapd/overlord/auth" "github.com/snapcore/snapd/overlord/healthstate" "github.com/snapcore/snapd/overlord/snapstate" + "github.com/snapcore/snapd/overlord/snapstate/sequence" + "github.com/snapcore/snapd/overlord/snapstate/snapstatetest" "github.com/snapcore/snapd/overlord/state" "github.com/snapcore/snapd/sandbox" "github.com/snapcore/snapd/snap" "github.com/snapcore/snapd/snap/channel" + "github.com/snapcore/snapd/snap/naming" "github.com/snapcore/snapd/snap/snaptest" "github.com/snapcore/snapd/store" "github.com/snapcore/snapd/strutil" @@ -1605,6 +1608,207 @@ func (s *snapsSuite) TestMapLocalFields(c *check.C) { c.Check(daemon.MapLocal(about, nil), check.DeepEquals, expected) } +func (s *snapsSuite) TestMapLocalFieldsWithComponents(c *check.C) { + dirs.SetRootDir(c.MkDir()) + defer dirs.SetRootDir(dirs.GlobalRootDir) + + media := snap.MediaInfos{ + { + Type: "screenshot", + URL: "https://example.com/shot1.svg", + }, { + Type: "icon", + URL: "https://example.com/icon.png", + }, { + Type: "screenshot", + URL: "https://example.com/shot2.svg", + }, + } + + publisher := snap.StoreAccount{ + ID: "some-dev-id", + Username: "some-dev", + DisplayName: "Some Developer", + Validation: "poor", + } + info := &snap.Info{ + SideInfo: snap.SideInfo{ + SnapID: "some-snap-id", + RealName: "some-snap", + EditedTitle: "A Title", + EditedSummary: "a summary", + EditedDescription: "the\nlong\ndescription", + Channel: "bleeding/edge", + EditedLinks: map[string][]string{ + "contact": {"mailto:alice@example.com"}, + }, + LegacyEditedContact: "mailto:alice@example.com", + Revision: snap.R(7), + Private: true, + }, + SnapType: "app", + Base: "the-base", + Version: "v1.0", + License: "MIT", + Broken: "very", + Confinement: "very strict", + CommonIDs: []string{"foo", "bar"}, + Media: media, + DownloadInfo: snap.DownloadInfo{ + Size: 42, + Sha3_384: "some-sum", + }, + Publisher: publisher, + Components: map[string]*snap.Component{ + "comp-1": { + Name: "comp-1", + Type: "test", + }, + "comp-2": { + Name: "comp-2", + Type: "test", + Summary: "summary 2", + Description: "description 2", + }, + "comp-3": { + Name: "comp-3", + Type: "test", + Summary: "summary 3", + Description: "description 3", + }, + "comp-4": { + Name: "comp-4", + Type: "test", + }, + }, + } + + // make InstallDate work + c.Assert(os.MkdirAll(info.MountDir(), 0755), check.IsNil) + c.Assert(os.Symlink("7", filepath.Join(info.MountDir(), "..", "current")), check.IsNil) + + info.Apps = map[string]*snap.AppInfo{ + "foo": {Snap: info, Name: "foo", Command: "foo"}, + "bar": {Snap: info, Name: "bar", Command: "bar"}, + } + + const comp1yaml = ` +component: some-snap+comp-1 +type: test +version: 1.0 +` + const comp2yaml = ` +component: some-snap+comp-2 +type: test +version: 1.0 +summary: summary 2 +description: description 2 +` + // We need just enough info for components in snap.yaml + const snapYaml = ` +name: some-snap +version: 1 +components: + comp-1: + type: test + comp-2: + type: test +` + + // Mock snap.yaml/component.yaml files for installed components + ssi := &snap.SideInfo{RealName: "some-snap", Revision: snap.R(7), + SnapID: "some-snap-id"} + snaptest.MockSnap(c, snapYaml, ssi) + csi := snap.NewComponentSideInfo(naming.NewComponentRef("some-snap", "comp-1"), snap.R(33)) + snaptest.MockComponent(c, comp1yaml, info, *csi) + csi2 := snap.NewComponentSideInfo(naming.NewComponentRef("some-snap", "comp-2"), snap.R(34)) + snaptest.MockComponent(c, comp2yaml, info, *csi2) + comps := []*sequence.ComponentState{ + sequence.NewComponentState(csi, snap.TestComponent), + sequence.NewComponentState(csi2, snap.TestComponent), + } + + // make InstallDate/InstalledSize work for comp1 and comp2 + cpi := snap.MinimalComponentContainerPlaceInfo( + csi.Component.ComponentName, csi.Revision, "some-snap") + symLn := snap.ComponentLinkPath(cpi, snap.R(7)) + c.Assert(os.MkdirAll(cpi.MountDir(), 0755), check.IsNil) + os.WriteFile(cpi.MountFile(), []byte{0, 0}, 0644) + c.Assert(os.MkdirAll(filepath.Dir(symLn), 0755), check.IsNil) + c.Assert(os.Symlink(cpi.MountDir(), symLn), check.IsNil) + cpi2 := snap.MinimalComponentContainerPlaceInfo( + csi2.Component.ComponentName, csi2.Revision, "some-snap") + symLn2 := snap.ComponentLinkPath(cpi2, snap.R(7)) + c.Assert(os.MkdirAll(cpi2.MountDir(), 0755), check.IsNil) + os.WriteFile(cpi2.MountFile(), []byte{0, 0, 0}, 0644) + c.Assert(os.MkdirAll(filepath.Dir(symLn2), 0755), check.IsNil) + c.Assert(os.Symlink(cpi2.MountDir(), symLn2), check.IsNil) + + about := daemon.MakeAboutSnap(info, &snapstate.SnapState{ + Sequence: snapstatetest.NewSequenceFromRevisionSideInfos( + []*sequence.RevisionSideState{ + sequence.NewRevisionSideState(ssi, comps)}), + Active: true, + TrackingChannel: "flaky/beta", + Current: snap.R(7), + Flags: snapstate.Flags{ + IgnoreValidation: true, + DevMode: true, + JailMode: true, + }, + }, + ) + + expected := &client.Snap{ + ID: "some-snap-id", + Name: "some-snap", + Summary: "a summary", + Description: "the\nlong\ndescription", + Developer: "some-dev", + Publisher: &publisher, + Icon: "https://example.com/icon.png", + Type: "app", + Base: "the-base", + Version: "v1.0", + Revision: snap.R(7), + Channel: "bleeding/edge", + TrackingChannel: "flaky/beta", + InstallDate: info.InstallDate(), + InstalledSize: 42, + Status: "active", + Confinement: "very strict", + IgnoreValidation: true, + DevMode: true, + JailMode: true, + Private: true, + Broken: "very", + Links: map[string][]string{ + "contact": {"mailto:alice@example.com"}, + }, + Contact: "mailto:alice@example.com", + Title: "A Title", + License: "MIT", + CommonIDs: []string{"foo", "bar"}, + MountedFrom: filepath.Join(dirs.SnapBlobDir, "some-snap_7.snap"), + Media: media, + Apps: []client.AppInfo{ + {Snap: "some-snap", Name: "bar"}, + {Snap: "some-snap", Name: "foo"}, + }, + Components: []client.Component{ + {Name: "comp-1", Type: "test", Version: "1.0", Revision: snap.R(33), + InstallDate: snap.ComponentInstallDate(cpi, snap.R(7)), InstalledSize: 2}, + {Name: "comp-2", Type: "test", Version: "1.0", Revision: snap.R(34), + Summary: "summary 2", Description: "description 2", + InstallDate: snap.ComponentInstallDate(cpi, snap.R(7)), InstalledSize: 3}, + {Name: "comp-3", Type: "test", + Summary: "summary 3", Description: "description 3"}, + {Name: "comp-4", Type: "test"}, + }, + } + c.Check(daemon.MapLocal(about, nil), check.DeepEquals, expected) +} + func (s *snapsSuite) TestMapLocalOfTryResolvesSymlink(c *check.C) { c.Assert(os.MkdirAll(dirs.SnapBlobDir, 0755), check.IsNil) diff --git a/daemon/snap.go b/daemon/snap.go index 2ec6190934c..85fb08c5f90 100644 --- a/daemon/snap.go +++ b/daemon/snap.go @@ -272,9 +272,62 @@ func mapLocal(about aboutSnap, sd clientutil.StatusDecorator) *client.Snap { if !about.gatingHold.IsZero() { result.GatingHold = &about.gatingHold } + + if len(about.info.Components) > 0 { + result.Components = fillComponentInfo(about) + } + return result } +func fillComponentInfo(about aboutSnap) []client.Component { + localSnap, snapst := about.info, about.snapst + comps := make([]client.Component, 0, len(about.info.Components)) + + // First present installed components + currentComps, err := snapst.CurrentComponentInfos() + if err != nil { + logger.Noticef("cannot retrieve installed components: %v", err) + } + currentCompsSet := map[string]bool{} + for _, comp := range currentComps { + currentCompsSet[comp.Component.ComponentName] = true + csi := snapst.CurrentComponentSideInfo(comp.Component) + cpi := snap.MinimalComponentContainerPlaceInfo( + comp.Component.ComponentName, csi.Revision, localSnap.InstanceName()) + compSz, err := snap.ComponentSize(cpi) + if err != nil { + logger.Noticef("cannot get size of %s: %v", comp.Component, err) + compSz = 0 + } + comps = append(comps, client.Component{ + Name: comp.Component.ComponentName, + Type: comp.Type, + Version: comp.Version, + Summary: comp.Summary, + Description: comp.Description, + Revision: csi.Revision, + InstallDate: snap.ComponentInstallDate(cpi, localSnap.Revision), + InstalledSize: compSz, + }) + } + + // Then, non-installed components + for name, comp := range about.info.Components { + if _, ok := currentCompsSet[name]; ok { + continue + } + comps = append(comps, client.Component{ + Name: name, + Type: comp.Type, + Summary: comp.Summary, + Description: comp.Description, + }) + } + + return comps +} + // snapIcon tries to find the icon inside the snap func snapIcon(info snap.PlaceInfo) string { found, _ := filepath.Glob(filepath.Join(info.MountDir(), "meta", "gui", "icon.*")) diff --git a/overlord/snapstate/backend/link.go b/overlord/snapstate/backend/link.go index c3d32b2fcd8..8758de52e11 100644 --- a/overlord/snapstate/backend/link.go +++ b/overlord/snapstate/backend/link.go @@ -25,7 +25,6 @@ import ( "io/fs" "os" "path/filepath" - "strings" "github.com/snapcore/snapd/boot" "github.com/snapcore/snapd/cmd/snaplock/runinhibit" @@ -185,15 +184,9 @@ func (b Backend) LinkSnap(info *snap.Info, dev snap.Device, linkCtx LinkContext, return rebootInfo, nil } -func componentLinkPath(cpi snap.ContainerPlaceInfo, snapRev snap.Revision) string { - instanceName, compName, _ := strings.Cut(cpi.ContainerName(), "+") - compBase := snap.ComponentsBaseDir(instanceName) - return filepath.Join(compBase, snapRev.String(), compName) -} - func (b Backend) LinkComponent(cpi snap.ContainerPlaceInfo, snapRev snap.Revision) error { mountDir := cpi.MountDir() - linkPath := componentLinkPath(cpi, snapRev) + linkPath := snap.ComponentLinkPath(cpi, snapRev) // Create components directory compsDir := filepath.Dir(linkPath) @@ -395,7 +388,7 @@ func removeCurrentSymlinks(info snap.PlaceInfo) error { } func (b Backend) UnlinkComponent(cpi snap.ContainerPlaceInfo, snapRev snap.Revision) error { - linkPath := componentLinkPath(cpi, snapRev) + linkPath := snap.ComponentLinkPath(cpi, snapRev) err := os.Remove(linkPath) if err != nil { diff --git a/snap/component.go b/snap/component.go index a0570856c9b..40f892d2325 100644 --- a/snap/component.go +++ b/snap/component.go @@ -19,7 +19,10 @@ package snap import ( "fmt" + "os" "path/filepath" + "strings" + "time" "github.com/snapcore/snapd/dirs" "github.com/snapcore/snapd/logger" @@ -37,7 +40,7 @@ type ComponentInfo struct { ComponentProvenance string `yaml:"provenance,omitempty"` // Hooks contains information about implicit and explicit hooks that this - // component has. This information is derived from a combination of the + // component has. This information is derived from a combination on the // component itself and the snap.Info that represents the snap this // component is associated with. This field may be empty if the // ComponentInfo was not created with the help of a snap.Info. @@ -155,6 +158,40 @@ func (c *componentPlaceInfo) MountDescription() string { return fmt.Sprintf("Mount unit for %s, revision %s", c.ContainerName(), c.compRevision) } +// ComponentLinkPath returns the path for the symlink for a component for a +// given snap revision. +func ComponentLinkPath(cpi ContainerPlaceInfo, snapRev Revision) string { + instanceName, compName, _ := strings.Cut(cpi.ContainerName(), "+") + compBase := ComponentsBaseDir(instanceName) + return filepath.Join(compBase, snapRev.String(), compName) +} + +// ComponentInstallDate returns the "install date" of the component by checking +// when its symlink was created. We cannot use the mount directory as lstat +// returns the date of the root of the container instead of the date when the +// mount directory was created. +func ComponentInstallDate(cpi ContainerPlaceInfo, snapRev Revision) *time.Time { + symLn := ComponentLinkPath(cpi, snapRev) + if st, err := os.Lstat(symLn); err == nil { + modTime := st.ModTime() + return &modTime + } + return nil +} + +// ComponentSize returns the file size of a component. +func ComponentSize(cpi ContainerPlaceInfo) (int64, error) { + st, err := os.Lstat(cpi.MountFile()) + if err != nil { + return 0, fmt.Errorf("error while looking for component file %q: %v", + cpi.MountFile(), err) + } + if !st.Mode().IsRegular() { + return 0, fmt.Errorf("unexpected file type for component file %q", cpi.MountFile()) + } + return st.Size(), nil +} + // ReadComponentInfoFromContainer reads ComponentInfo from a snap component // container. If snapInfo is not nil, it is used to complete the ComponentInfo // information about the component's implicit and explicit hooks, and their diff --git a/snap/component_test.go b/snap/component_test.go index a10c3d38e03..69031404d07 100644 --- a/snap/component_test.go +++ b/snap/component_test.go @@ -19,8 +19,10 @@ package snap_test import ( "fmt" + "os" "path/filepath" "strings" + "syscall" . "gopkg.in/check.v1" @@ -555,3 +557,66 @@ version: 1 unscopedHooks := componentInfo.HooksForPlug(unscoped) c.Assert(unscopedHooks, testutil.DeepUnsortedMatches, []*snap.HookInfo{componentInfo.Hooks["install"], componentInfo.Hooks["pre-refresh"]}) } + +func (s *componentSuite) TestComponentLinkPath(c *C) { + for i, tc := range []struct { + cpi snap.ContainerPlaceInfo + snapRev snap.Revision + link string + }{ + {snap.MinimalComponentContainerPlaceInfo("test-info", snap.R(25), "mysnap"), + snap.R(11), "mysnap/components/11/test-info"}, + {snap.MinimalComponentContainerPlaceInfo("test-info", snap.R(33), "mysnap"), + snap.R(11), "mysnap/components/11/test-info"}, + {snap.MinimalComponentContainerPlaceInfo("comp-1", snap.R(25), "foo"), + snap.R(11), "foo/components/11/comp-1"}, + } { + c.Logf("case %d, expected link %q", i, tc.link) + c.Check(snap.ComponentLinkPath(tc.cpi, tc.snapRev), Equals, + filepath.Join(dirs.SnapMountDir, tc.link)) + } +} + +func (s *infoSuite) TestComponentInstallDate(c *C) { + cpi := snap.MinimalComponentContainerPlaceInfo("comp", snap.R(1), "snap") + + // not current -> Zero + c.Check(snap.ComponentInstallDate(cpi, snap.R(33)), IsNil) + + //time.Sleep(time.Second) + link := snap.ComponentLinkPath(cpi, snap.R(33)) + dir, _ := filepath.Split(link) + c.Assert(os.MkdirAll(dir, 0755), IsNil) + c.Assert(os.Symlink(dirs.GlobalRootDir, link), IsNil) + st, err := os.Lstat(link) + c.Assert(err, IsNil) + instTime := st.ModTime() + + installDate := snap.ComponentInstallDate(cpi, snap.R(33)) + c.Check(installDate.Equal(instTime), Equals, true) +} + +func (s *infoSuite) TestComponentSize(c *C) { + cpi := snap.MinimalComponentContainerPlaceInfo("comp", snap.R(1), "snap") + mntFile := cpi.MountFile() + dir, _ := filepath.Split(mntFile) + c.Assert(os.MkdirAll(dir, 0755), IsNil) + + // No file + compSz, err := snap.ComponentSize(cpi) + c.Check(compSz, Equals, int64(0)) + c.Check(err, ErrorMatches, `error while looking for component file .*snap\+comp_1\.comp: no such file or directory`) + + // File + c.Assert(os.WriteFile(mntFile, []byte{0, 0}, 0644), IsNil) + compSz, err = snap.ComponentSize(cpi) + c.Check(compSz, Equals, int64(2)) + c.Check(err, IsNil) + + // Special file + c.Assert(os.Remove(mntFile), IsNil) + c.Assert(syscall.Mkfifo(mntFile, 0666), IsNil) + compSz, err = snap.ComponentSize(cpi) + c.Check(compSz, Equals, int64(0)) + c.Check(err, ErrorMatches, `unexpected file type for component file .*snap\+comp_1\.comp"`) +}