diff --git a/README.md b/README.md index c169cd79..5b9cc8b7 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/internals/cli/cli.go b/internals/cli/cli.go index 86c46e82..f7ad0e7d 100644 --- a/internals/cli/cli.go +++ b/internals/cli/cli.go @@ -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"` diff --git a/internals/cli/cmd_run.go b/internals/cli/cmd_run.go index 10096551..9158cd75 100644 --- a/internals/cli/cmd_run.go +++ b/internals/cli/cmd_run.go @@ -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, @@ -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 +} diff --git a/internals/cli/cmd_run_test.go b/internals/cli/cmd_run_test.go new file mode 100644 index 00000000..f4fcba9b --- /dev/null +++ b/internals/cli/cmd_run_test.go @@ -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 . + +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, + }) +} diff --git a/internals/cli/copyfs.go b/internals/cli/copyfs.go new file mode 100644 index 00000000..111af1fb --- /dev/null +++ b/internals/cli/copyfs.go @@ -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() + }) +} diff --git a/internals/cli/export_test.go b/internals/cli/export_test.go index e85d6845..e71e4e0a 100644 --- a/internals/cli/export_test.go +++ b/internals/cli/export_test.go @@ -43,6 +43,8 @@ var ( MaybePresentWarnings = maybePresentWarnings GetEnvPaths = getEnvPaths + + MaybeCopyPebbleDir = maybeCopyPebbleDir ) func FakeIsStdoutTTY(t bool) (restore func()) {