Skip to content

Commit

Permalink
feat(daemon): Add support for PEBBLE_COPY_ONCE. (#352)
Browse files Browse the repository at this point in the history
`$PEBBLE_COPY_ONCE` environment variable allows `pebble run` to copy layers and state from another directory when seeding a new `$PEBBLE` directory.

The rationale for this change is to allow OCI images to specify existing layers in the default `$PEBBLE` location. Then at runtime, be able to run `pebble` with a different `$PEBBLE` directory. This allows `pebble` to be run as a non-root user or run on a read-only root filesystem.

Issue #351 outlines that `pebble run` has no unit or integration tests. A test should be performed manually for now.

Manual QA is as follows:
- Create a directory, with a layers subdirectory and valid testing layers (e.g. ~/dir/with-existing-state)
- Create another empty directory. (e.g. ~/dir/with-no-state)
- `PEBBLE_COPY_ONCE=~/dir/with-existing-state  PEBBLE=~/dir/with-no-state pebble run`
- Check all the layers are loaded.
- Check all the layers are copied.
- Modify a layer in `~/dir/with-no-state/layers`.
- Restart `pebble run` and check that it doesn't crash.
- Check that the modifications are preserved after the restart (PEBBLE_COPY_ONCE is only for an empty directory).
- Check `PEBBLE_COPY_ONCE` can be set to a non-existing directory with `pebble run` passed `--create-dirs`

[JU090](https://docs.google.com/document/d/1NWV4QsYq1NldS_V_YlafpJQyNAV-WS46VsIKGBrin_s/edit)
[JUJU-5435](https://warthogs.atlassian.net/browse/JUJU-5435)

[JUJU-5435]: https://warthogs.atlassian.net/browse/JUJU-5435?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
hpidcock authored Feb 23, 2024
1 parent ed3b601 commit ff7af10
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 0 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ pebble run
...
```

To initialise the `$PEBBLE` directory with the contents of another, in a one time copy, set the `PEBBLE_COPY_ONCE` environment
variable to the source directory. This will only copy the contents if the target directory, `$PEBBLE`, is empty.

### Viewing, starting, and stopping services

You can view the status of one or more services by using `pebble services`:
Expand Down
4 changes: 4 additions & 0 deletions internals/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,10 @@ func getEnvPaths() (pebbleDir string, socketPath string) {
return pebbleDir, socketPath
}

func getCopySource() string {
return os.Getenv("PEBBLE_COPY_ONCE")
}

type cliState struct {
NoticesLastListed time.Time `json:"notices-last-listed"`
NoticesLastOkayed time.Time `json:"notices-last-okayed"`
Expand Down
25 changes: 25 additions & 0 deletions internals/cli/cmd_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ func runDaemon(rcmd *cmdRun, ch chan os.Signal, ready chan<- func()) error {
return err
}
}
err := maybeCopyPebbleDir(pebbleDir, getCopySource())
if err != nil {
return err
}

dopts := daemon.Options{
Dir: pebbleDir,
SocketPath: socketPath,
Expand Down Expand Up @@ -275,3 +280,23 @@ func convertArgs(args [][]string) (map[string][]string, error) {

return mappedArgs, nil
}

func maybeCopyPebbleDir(destDir, srcDir string) error {
if srcDir == "" {
return nil
}
entries, err := os.ReadDir(destDir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
} else if len(entries) != 0 {
// Skip non-empty dir.
return nil
}
fsys := os.DirFS(srcDir)
// TODO: replace with os.CopyFS when we're using Go 1.23
err = copyFS(destDir, fsys)
if err != nil {
return fmt.Errorf("cannot copy %q to %q: %w", srcDir, destDir, err)
}
return nil
}
105 changes: 105 additions & 0 deletions internals/cli/cmd_run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// 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 <http://www.gnu.org/licenses/>.

package cli_test

import (
"io/fs"
"os"
"path"

. "gopkg.in/check.v1"

"github.com/canonical/pebble/internals/cli"
)

func (s *PebbleSuite) TestMaybeCopyPebbleDir(c *C) {
src := c.MkDir()
err := os.MkdirAll(path.Join(src, "a", "b", "c"), 0700)
c.Assert(err, IsNil)
err = os.WriteFile(path.Join(src, "a", "b", "c", "a.yaml"), []byte("# hi\n"), 0666)
c.Assert(err, IsNil)
err = os.WriteFile(path.Join(src, "a.yaml"), []byte("# bye\n"), 0666)
c.Assert(err, IsNil)

dst := c.MkDir()
err = cli.MaybeCopyPebbleDir(dst, src)
c.Assert(err, IsNil)

got := map[string]bool{}
dstFS := os.DirFS(dst)
err = fs.WalkDir(dstFS, ".", func(path string, d fs.DirEntry, err error) error {
switch path {
case ".", "a", "a/b", "a/b/c":
case "a.yaml":
c.Check(got[path], Equals, false)
data, err := fs.ReadFile(dstFS, path)
c.Check(err, IsNil)
c.Check(data, DeepEquals, []byte("# bye\n"))
got[path] = true
case "a/b/c/a.yaml":
c.Check(got[path], Equals, false)
data, err := fs.ReadFile(dstFS, path)
c.Check(err, IsNil)
c.Check(data, DeepEquals, []byte("# hi\n"))
got[path] = true
default:
c.Errorf("bad path %s", path)
}
return err
})
c.Assert(err, IsNil)
c.Assert(got, DeepEquals, map[string]bool{
"a.yaml": true,
"a/b/c/a.yaml": true,
})
}

func (s *PebbleSuite) TestMaybeCopyPebbleDirNoCopy(c *C) {
src := c.MkDir()
err := os.MkdirAll(path.Join(src, "a", "b", "c"), 0700)
c.Assert(err, IsNil)
err = os.WriteFile(path.Join(src, "a", "b", "c", "a.yaml"), []byte("# hi\n"), 0666)
c.Assert(err, IsNil)
err = os.WriteFile(path.Join(src, "a.yaml"), []byte("# bye\n"), 0666)
c.Assert(err, IsNil)

dst := c.MkDir()
err = os.WriteFile(path.Join(dst, "a.yaml"), []byte("# no\n"), 0666)
c.Assert(err, IsNil)

err = cli.MaybeCopyPebbleDir(dst, src)
c.Assert(err, IsNil)

got := map[string]bool{}
dstFS := os.DirFS(dst)
err = fs.WalkDir(dstFS, ".", func(path string, d fs.DirEntry, err error) error {
switch path {
case ".":
case "a.yaml":
c.Check(got[path], Equals, false)
data, err := fs.ReadFile(dstFS, path)
c.Check(err, IsNil)
c.Check(data, DeepEquals, []byte("# no\n"))
got[path] = true
default:
c.Errorf("bad path %s", path)
}
return err
})
c.Assert(err, IsNil)
c.Assert(got, DeepEquals, map[string]bool{
"a.yaml": true,
})
}
95 changes: 95 additions & 0 deletions internals/cli/copyfs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) 2009 The Go Authors. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// - Redistributions of source code must retain the above copyright
//
// notice, this list of conditions and the following disclaimer.
// - Redistributions in binary form must reproduce the above
//
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// - Neither the name of Google Inc. nor the names of its
//
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

package cli

import (
"io"
"io/fs"
"os"
"path/filepath"
)

// Implementation from https://go-review.googlesource.com/c/go/+/558995 until accepted into go.

// copyFS copies the file system fsys into the directory dir,
// creating dir if necessary.
//
// Newly created directories and files have their default modes
// where any bits from the file in fsys that are not part of the
// standard read, write, and execute permissions will be zeroed
// out, and standard read and write permissions are set for owner,
// group, and others while retaining any existing execute bits from
// the file in fsys.
//
// Symbolic links in fsys are not supported, a *PathError with Err set
// to ErrInvalid is returned on symlink.
//
// Copying stops at and returns the first error encountered.
func copyFS(dir string, fsys fs.FS) error {
return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

newPath := filepath.Join(dir, path)
if d.IsDir() {
return os.MkdirAll(newPath, 0777)
}

// TODO(panjf2000): handle symlinks with the help of fs.ReadLinkFS
// once https://go.dev/issue/49580 is done.
// we also need safefilepath.IsLocal from https://go.dev/cl/564295.
if !d.Type().IsRegular() {
return &fs.PathError{Op: "CopyFS", Path: path, Err: fs.ErrInvalid}
}

r, err := fsys.Open(path)
if err != nil {
return err
}
defer r.Close()
info, err := r.Stat()
if err != nil {
return err
}
w, err := os.OpenFile(newPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666|info.Mode()&0777)
if err != nil {
return err
}

if _, err := io.Copy(w, r); err != nil {
w.Close()
return &fs.PathError{Op: "Copy", Path: newPath, Err: err}
}
return w.Close()
})
}
2 changes: 2 additions & 0 deletions internals/cli/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ var (
MaybePresentWarnings = maybePresentWarnings

GetEnvPaths = getEnvPaths

MaybeCopyPebbleDir = maybeCopyPebbleDir
)

func FakeIsStdoutTTY(t bool) (restore func()) {
Expand Down

0 comments on commit ff7af10

Please sign in to comment.