From 9c11f29bf129dd5524476bd714776fdf9c31e4d0 Mon Sep 17 00:00:00 2001 From: Fred Lotter Date: Wed, 28 Jun 2023 11:48:57 +0200 Subject: [PATCH] Changes #1 following code reviews --- cmd/detect.go | 196 +++++++++++++++++++++++++++++++++ cmd/detect_test.go | 99 +++++++++++++++++ cmd/export_test.go | 11 +- cmd/version.go | 63 +---------- cmd/version_test.go | 115 ------------------- internals/overlord/overlord.go | 6 +- 6 files changed, 311 insertions(+), 179 deletions(-) create mode 100644 cmd/detect.go create mode 100644 cmd/detect_test.go delete mode 100644 cmd/version_test.go diff --git a/cmd/detect.go b/cmd/detect.go new file mode 100644 index 00000000..3a48cab1 --- /dev/null +++ b/cmd/detect.go @@ -0,0 +1,196 @@ +// Copyright (c) 2023 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 cmd + +import ( + "fmt" + "io/ioutil" + "os" + "strconv" + "strings" + "sync" +) + +var ( + selfPid = os.Getpid() + + pid2ProcPath = "/proc/2/status" + rockPath = "/.rock/metadata.yaml" + lxdPath = "/proc/1/environ" + dockerEnvPath = "/.dockerenv" + dockerInitPath = "/.dockerinit" + + // Confined may be initialised by a binary derived from + // from this repository. This provides an override mechanism + // to bypass (speed up) detection if it is not needed. + Confined *bool + + once sync.Once + failure error +) + +// IsInit returns true if the system manager is the first process +// started by the Linux kernel. +func IsInit() bool { + return selfPid == 1 +} + +// IsConfined works out if we are running inside a container. +// If the error is set, the detection result is meaningless. +func IsConfined() (bool, error) { + // This will not only force a single detection, but also block additional + // concurrent calls until the primary is complete. + once.Do(checks) + + if Confined == nil { + failure = fmt.Errorf("confined state was globally set to nil") + } + + if failure != nil { + return false, failure + } + + return *Confined, nil +} + +// checks is a curated list of checks for speedy detection of +// confined environments. The quickest most obvious checks should be +// performed first. The list is iterated until a confinement check returns +// true. If all the checks fail to detect a confined runtime, we can +// assume its a unconfined virtual/real machine. +// +// If the system manager is used as library for derived projects where +// its certain that no confinement exist, use the Confined global to +// bypass this check. +func checks() { + var res bool + var err error + + // Was Confined globally set already? + if Confined != nil { + return + } + + // If any check encounters an error, or if we reach a + // conclusion, we update the globals and return. + defer func() { + Confined = &res + failure = err + }() + + for _, c := range checkList { + res, err = c() + if err != nil || res { + return + } + } +} + +var checkList = []func() (bool, error){ + isRock, + isLxd, + isDocker, + noKernel, +} + +// isRock checks if it can access /.meta/metadata.yaml. +func isRock() (bool, error) { + _, err := os.Stat(rockPath) + if err == nil { + return true, nil + } else if os.IsNotExist(err) { + return false, nil + } else { + return false, fmt.Errorf("rock detection file stat returned an error") + } +} + +// isLxd checks if /proc/1/environ contains the "container=xxx" variable. This +// check should work for OCI compliant images in general. +func isLxd() (bool, error) { + _, err := os.Stat(lxdPath) + if err == nil { + s, err := ioutil.ReadFile(lxdPath) + if err != nil { + return false, err + } else { + lines := strings.Split(string(s), "\000") + for _, l := range lines { + kv := strings.Split(l, "=") + if kv[0] == "container" { + return true, nil + } + } + } + return false, nil + } else if os.IsNotExist(err) { + return false, nil + } else { + return false, fmt.Errorf("lxd/oci detection file stat returned an error") + } +} + +// isDocker checks for /.dockerenv or /.dockerinit +func isDocker() (bool, error) { + _, err1 := os.Stat(dockerInitPath) + _, err2 := os.Stat(dockerEnvPath) + if err1 == nil || err2 == nil { + return true, nil + } else if os.IsNotExist(err1) && os.IsNotExist(err2) { + return false, nil + } else { + return false, fmt.Errorf("docker detection file stat returned an error") + } +} + +// noKernel returns true if a kernel is not visible. The check will inspect +// the PPID of PID2 if it exists. If the PPID is zero its kernel owned, which +// strongly suggests we have complete PID visibility, and not confined. +// +// This check can be used to confirm the service manager is run inside of +// a container runtime. The following two known situations will result in +// invalid results: +// +// 1. If /proc is not mounted, it will return true +// 2. If docker passes through host pids, "docker run --pid host", it will +// detect the kernel, even though its inside a container. +// +// This is used as a last best effort test for container runtime cases not +// picked up by earlier tests. It is also very useful to verify that indeed +// the environment appears like a normal machine with unconfined access, as +// this is what the assumption will be. +func noKernel() (bool, error) { + // This path may not exist in a specific userspace, so we + // will not report any file not found errors. + s, err := ioutil.ReadFile(pid2ProcPath) + if err != nil && !os.IsNotExist(err) { + return false, err + } else if err == nil { + lines := strings.Split(string(s), "\n") + for _, l := range lines { + kv := strings.Split(l, "\t") + if len(kv) == 2 && kv[0] == "PPid:" { + ppid, err := strconv.Atoi(kv[1]) + if err != nil { + return false, err + } + if ppid == 0 { + return false, nil + } + } + } + } + return true, nil +} diff --git a/cmd/detect_test.go b/cmd/detect_test.go new file mode 100644 index 00000000..3330c567 --- /dev/null +++ b/cmd/detect_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2014-2023 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 cmd_test + +import ( + "io/fs" + "io/ioutil" + "path/filepath" + "testing" + + . "gopkg.in/check.v1" + + "github.com/canonical/pebble/cmd" +) + +// Hook up check.v1 into the "go test" runner +func Test(t *testing.T) { TestingT(t) } + +type cmdTestSuite struct{} + +var _ = Suite(&cmdTestSuite{}) + +// createProcPid2Status creates a /proc//status file. +func createProcPid2Status(c *C, data string, perm fs.FileMode) string { + path := filepath.Join(c.MkDir(), "status") + err := ioutil.WriteFile(path, []byte(data), perm) + c.Assert(err, IsNil) + return path +} + +func (s *cmdTestSuite) TestNoKernelPathNotFound(c *C) { + defer cmd.MockPid2ProcPath("/1/2/3/4/5")() + v, err := cmd.NoKernel() + // We expect true because we cannot "see" the kernel. + // As stated in the function description, this is one + // of the expected cases, because we want to support + // systems without /proc mounted. + c.Assert(v, Equals, true) + c.Assert(err, IsNil) +} + +func (s *cmdTestSuite) TestNoKernelPathError(c *C) { + path := createProcPid2Status(c, "", 0o000) + defer cmd.MockPid2ProcPath(path)() + v, err := cmd.NoKernel() + c.Assert(v, Equals, false) + c.Assert(err, ErrorMatches, "*permission denied") +} + +func (s *cmdTestSuite) TestNoKernelValidPath(c *C) { + + for _, d := range []struct { + status string + container bool + err string + }{ + // Note the /proc//status format is: + // :\t + // The delimiter is a tab, not spaces. + {` +Pid: 2 +PPid: 0 +Something: 32`, false, ""}, + {` +Pid: 2 +PPid: 1 +Something: 32`, true, ""}, + {` +Pid: 2 +PPid: str +Something: 32`, false, "*invalid syntax*"}, + {` +something +1 2 3 4`, true, ""}, + } { + cmd.ResetContainerInit() + path := createProcPid2Status(c, d.status, 0o644) + defer cmd.MockPid2ProcPath(path)() + v, err := cmd.NoKernel() + c.Assert(v, Equals, d.container) + if d.err == "" { + c.Assert(err, IsNil) + } else { + c.Assert(err, ErrorMatches, d.err) + } + } +} diff --git a/cmd/export_test.go b/cmd/export_test.go index eba7b956..526e8431 100644 --- a/cmd/export_test.go +++ b/cmd/export_test.go @@ -18,6 +18,13 @@ import ( "sync" ) +var ( + IsRock = isRock + IsDocker = isDocker + IsLxd = isLxd + NoKernel = noKernel +) + // MockPid2ProcPath assigns a temporary path to where the PID2 // status can be found. func MockPid2ProcPath(path string) (restore func()) { @@ -45,6 +52,6 @@ func MockVersion(version string) (restore func()) { // ResetContainerInit forces the container runtime check // to retry with globals reset func ResetContainerInit() { - containerOnce = sync.Once{} - containerRuntime = true + once = sync.Once{} + Confined = nil } diff --git a/cmd/version.go b/cmd/version.go index f3f59c38..c19e7136 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -16,64 +16,5 @@ package cmd //go:generate ./mkversion.sh -import ( - "io/ioutil" - "os" - "strconv" - "strings" - "sync" -) - -var ( - // Version will be overwritten at build-time via mkversion.sh - Version = "unknown" - - pid2ProcPath = "/proc/2/status" - selfPid = os.Getpid() - - containerOnce sync.Once - containerRuntime bool = true -) - -// Containerised returns true if we are running inside a container runtime -// such as lxd or Docker. The detection is only intended for Linux host -// environments as it looks for the Linux kernel kthreadd process, started -// by the Linux kernel after init. Kernel initialised processes do not have -// a parent as its not a child of init (PID1) and as a result has its ppid -// set to zero. We use this property to detect the absence of a container -// runtime. If /proc is not mounted, the detection will assume its a -// container, so make sure early mounts are completed before running this -// check on an unconfined host. -func Containerised() bool { - containerOnce.Do(func() { - if s, err := ioutil.ReadFile(pid2ProcPath); err == nil { - lines := strings.Split(string(s), "\n") - for _, l := range lines { - kv := strings.Split(l, "\t") - if zeroPPid(kv) { - containerRuntime = false - break - } - } - } - }) - - return containerRuntime -} - -// zeroPPid returns true if a PPid key with a zero value was found, -// otherwise false. -func zeroPPid(kv []string) (found bool) { - if kv[0] == "PPid:" { - if ppid, err := strconv.Atoi(kv[1]); err == nil && ppid == 0 { - found = true - } - } - return found -} - -// InitProcess returns true if the system manager is the first process -// started by the Linux kernel. -func InitProcess() bool { - return selfPid == 1 -} +// Version will be overwritten at build-time via mkversion.sh +var Version = "unknown" diff --git a/cmd/version_test.go b/cmd/version_test.go deleted file mode 100644 index 19a22385..00000000 --- a/cmd/version_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2014-2023 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 cmd_test - -import ( - "io/ioutil" - "path/filepath" - "testing" - - . "gopkg.in/check.v1" - - "github.com/canonical/pebble/cmd" -) - -// Hook up check.v1 into the "go test" runner -func Test(t *testing.T) { TestingT(t) } - -type cmdTestSuite struct{} - -var _ = Suite(&cmdTestSuite{}) - -// createProcPid2Status creates a /proc//status file. -func createProcPid2Status(c *C, data string) string { - path := filepath.Join(c.MkDir(), "status") - err := ioutil.WriteFile(path, []byte(data), 0o644) - c.Assert(err, IsNil) - return path -} - -func (s *cmdTestSuite) SetUpTest(c *C) { - // Allow each test to trigger a check - cmd.ResetContainerInit() -} - -func (s *cmdTestSuite) TestContainerisedInvalidPath(c *C) { - // This path is not valid so the test must therefore - // assume the PID2 process does not exist, and therefore - // we are inside a container. This may trigger a false - // positive if /proc is not mounted before this is called. - defer cmd.MockPid2ProcPath("/1/2/3/4/5")() - c.Assert(cmd.Containerised(), Equals, true) -} - -// TestContainerisedValidPath runs individual tests in the loop -// resetting before each test to prevent the sync.Once from -// loading a previously cached value. -func (s *cmdTestSuite) TestContainerisedValidPath(c *C) { - - for _, d := range []struct { - status string - container bool - }{ - // Note the /proc//status format is: - // :\t - // The delimiter is a tab, not spaces. - {` -Pid: 2 -PPid: 0 -Something: 32`, false}, - {` -Pid: 2 -PPid: 1 -Something: 32`, true}, - {` -something -1 2 3 4`, true}, - } { - cmd.ResetContainerInit() - path := createProcPid2Status(c, d.status) - defer cmd.MockPid2ProcPath(path)() - c.Assert(cmd.Containerised(), Equals, d.container) - } -} - -// TestContainerisedCaching ensures we do not redo detection as -// the container state could be used more than once in the codebase. -func (s *cmdTestSuite) TestContainerisedCaching(c *C) { - // Note the /proc//status format is: - // :\t - // The delimiter is a tab, not spaces. - path := createProcPid2Status(c, ` -Pid: 2 -PPid: 0 -Something: 32`) - defer cmd.MockPid2ProcPath(path)() - c.Assert(cmd.Containerised(), Equals, false) - - path = createProcPid2Status(c, ` -Pid: 2 -PPid: 1 -Something: 32`) - defer cmd.MockPid2ProcPath(path)() - // This occurrence should not read the file, and return the cached value - c.Assert(cmd.Containerised(), Equals, false) -} - -// TestInitProcess checks if the init detection is plumbed in correctly. -func (s *cmdTestSuite) TestInitProcess(c *C) { - defer cmd.MockPid(1234)() - c.Assert(cmd.InitProcess(), Equals, false) - defer cmd.MockPid(1)() - c.Assert(cmd.InitProcess(), Equals, true) -} diff --git a/internals/overlord/overlord.go b/internals/overlord/overlord.go index 5f17fc56..89778dac 100644 --- a/internals/overlord/overlord.go +++ b/internals/overlord/overlord.go @@ -149,7 +149,11 @@ func loadState(statePath string, restartHandler restart.Handler, backend state.B return nil, fmt.Errorf("fatal: cannot find current boot ID: %v", err) } - if cmd.Containerised() { + confined, err := cmd.IsConfined() + if err != nil { + return nil, fmt.Errorf("fatal: confinement detection returned an error: %v", err) + } + if confined { // We need a unique boot id to support failed reboot detection logic in the // overlord. This is not guaranteed for a container runtime because not // all implementations (e.g. Docker) updates the boot id on restart of the