From 0399712dfe8d4478661403d597b10d78f97a4a5d Mon Sep 17 00:00:00 2001 From: "Franklin \"Snaipe\" Mathieu" Date: Sat, 12 Nov 2022 18:38:05 +0100 Subject: [PATCH] config,storage: support populating directories from archives Tarballs are ubiquitous as a binary release format, and without any builtin ability to fetch, validate, and extract them, we are left with half-baked hacks to do all of this from the confines of a oneshot systemd service, or worse, extracting and providing the entirety of the archive contents as files and directory entries in the ignition config, resulting in very large json documents. Let's not do this. Instead, this commit formalizes the use of archives via adding a new (optional) "contents" key under a directory entry. This new contents key is identical in function as its eponymous version in the "files" entries, except that it incorporates a new "archive" subkey to specify the archive format rather than guessing with heuristics. Today, only "archive": "tar" is supported, though this commit is structured to allow the addition of other archive types if needed. --- config/shared/errors/errors.go | 3 + config/v3_4_experimental/schema/ignition.json | 18 ++ config/v3_4_experimental/types/directory.go | 15 ++ config/v3_4_experimental/types/resource.go | 21 ++ config/v3_4_experimental/types/schema.go | 12 +- docs/configuration-v3_4_experimental.md | 9 + docs/release-notes.md | 1 + .../exec/stages/files/filesystemEntries.go | 30 ++- internal/exec/util/archive.go | 250 ++++++++++++++++++ internal/exec/util/file.go | 41 ++- tests/negative/files/directory.go | 131 +++++++++ tests/positive/files/directory.go | 221 ++++++++++++++++ 12 files changed, 743 insertions(+), 9 deletions(-) create mode 100644 internal/exec/util/archive.go create mode 100644 tests/negative/files/directory.go diff --git a/config/shared/errors/errors.go b/config/shared/errors/errors.go index 9058e0b80b..e9142f9180 100644 --- a/config/shared/errors/errors.go +++ b/config/shared/errors/errors.go @@ -84,6 +84,9 @@ var ( ErrInvalidProxy = errors.New("proxies must be http(s)") ErrInsecureProxy = errors.New("insecure plaintext HTTP proxy specified for HTTPS resources") ErrPathConflictsSystemd = errors.New("path conflicts with systemd unit or dropin") + ErrUnsupportedArchiveType = errors.New("unsupported archive type") + ErrArchiveTypeRequired = errors.New("archive type is required") + ErrOverwriteMustBeTrue = errors.New("overwrite must be true when specifying directory contents") // Systemd section errors ErrInvalidSystemdExt = errors.New("invalid systemd unit extension") diff --git a/config/v3_4_experimental/schema/ignition.json b/config/v3_4_experimental/schema/ignition.json index d93ce6c2d0..7da9117804 100644 --- a/config/v3_4_experimental/schema/ignition.json +++ b/config/v3_4_experimental/schema/ignition.json @@ -40,6 +40,21 @@ } } }, + "archiveResource": { + "allOf": [ + { + "$ref": "#/definitions/resource" + }, + { + "type": "object", + "properties": { + "archive": { + "type": ["string", "null"] + } + } + } + ] + }, "verification": { "type": "object", "properties": { @@ -394,6 +409,9 @@ "properties": { "mode": { "type": ["integer", "null"] + }, + "contents": { + "$ref": "#/definitions/archiveResource" } } } diff --git a/config/v3_4_experimental/types/directory.go b/config/v3_4_experimental/types/directory.go index f6f0684557..0b5f82598c 100644 --- a/config/v3_4_experimental/types/directory.go +++ b/config/v3_4_experimental/types/directory.go @@ -15,6 +15,9 @@ package types import ( + "github.com/coreos/ignition/v2/config/shared/errors" + "github.com/coreos/ignition/v2/config/util" + "github.com/coreos/vcontext/path" "github.com/coreos/vcontext/report" ) @@ -22,5 +25,17 @@ import ( func (d Directory) Validate(c path.ContextPath) (r report.Report) { r.Merge(d.Node.Validate(c)) r.AddOnError(c.Append("mode"), validateMode(d.Mode)) + if !util.NilOrEmpty(d.Contents.Archive) && (d.Overwrite == nil || !*d.Overwrite) { + r.AddOnError(c.Append("overwrite"), errors.ErrOverwriteMustBeTrue) + } return } + +func (d Directory) KeyPrefix() string { + if util.NilOrEmpty(d.Contents.Archive) { + return "" + } + // If a directory is populated by an archive, all other file/directory entries + // in the config must conflict with any files under said directory. + return d.Path +} diff --git a/config/v3_4_experimental/types/resource.go b/config/v3_4_experimental/types/resource.go index 68da6c7b78..ee3aada6ce 100644 --- a/config/v3_4_experimental/types/resource.go +++ b/config/v3_4_experimental/types/resource.go @@ -89,3 +89,24 @@ func (res Resource) validateRequiredSource() error { } return validateURL(*res.Source) } + +func (res ArchiveResource) Validate(c path.ContextPath) (r report.Report) { + r.Merge(res.Resource.Validate(c)) + r.AddOnError(c.Append("archive"), res.validateArchive()) + return +} + +func (res ArchiveResource) validateArchive() error { + if util.NilOrEmpty(res.Source) { + // archive can be omitted iff the contents are omitted + return nil + } + if util.NilOrEmpty(res.Archive) { + return errors.ErrArchiveTypeRequired + } + switch *res.Archive { + case "tar": + return nil + } + return errors.ErrUnsupportedArchiveType +} diff --git a/config/v3_4_experimental/types/schema.go b/config/v3_4_experimental/types/schema.go index ca25b99ea7..d5b86e17da 100644 --- a/config/v3_4_experimental/types/schema.go +++ b/config/v3_4_experimental/types/schema.go @@ -2,6 +2,15 @@ package types // generated by "schematyper --package=types config/v3_4_experimental/schema/ignition.json -o config/v3_4_experimental/types/schema.go --root-type=Config" -- DO NOT EDIT +type ArchiveResource struct { + Resource + ArchiveResourceEmbedded1 +} + +type ArchiveResourceEmbedded1 struct { + Archive *string `json:"archive,omitempty"` +} + type Clevis struct { Custom ClevisCustom `json:"custom,omitempty"` Tang []Tang `json:"tang,omitempty"` @@ -31,7 +40,8 @@ type Directory struct { } type DirectoryEmbedded1 struct { - Mode *int `json:"mode,omitempty"` + Contents ArchiveResource `json:"contents,omitempty"` + Mode *int `json:"mode,omitempty"` } type Disk struct { diff --git a/docs/configuration-v3_4_experimental.md b/docs/configuration-v3_4_experimental.md index 01e835ce3e..68a7d9be27 100644 --- a/docs/configuration-v3_4_experimental.md +++ b/docs/configuration-v3_4_experimental.md @@ -111,6 +111,15 @@ The Ignition configuration is a JSON document conforming to the following specif * **_group_** (object): specifies the directory's group. * **_id_** (integer): the group ID of the group. * **_name_** (string): the group name of the group. + * **_contents_** (object): options related to the contents of the directory. If specified, `overwrite` must be `true`. Directories populated from an archive own all files under it. This means that specifying files, directories, and links under the path of this directory always result in a conflict error during config validation. + * **archive** (string): format of the archive to extract into the directory. Must be `tar`. If `tar` is specified, the source must be a USTAR, PAX, or GNU tarball. Only regular files, directories, and links (both hard links and symlinks) are extracted, other file types are ignored and emit a warning. Note that for `tar` archives, sparse files are not supported and processing an archive with one will result in an error. + * **_compression_** (string): the type of compression used on the archive (null or gzip). Compression cannot be used with S3. + * **_source_** (string): the URL of the archive to extract. Supported schemes are `http`, `https`, `tftp`, `s3`, `arn`, `gs`, and [`data`][rfc2397]. When using `http`, it is advisable to use the verification option to ensure the contents haven't been modified. If source is omitted and a directory already exists at the path, Ignition will do nothing. If source is omitted and no directory exists, an empty directory will be created. + * **_httpHeaders_** (list of objects): a list of HTTP headers to be added to the request. Available for `http` and `https` source schemes only. + * **name** (string): the header name. + * **_value_** (string): the header contents. + * **_verification_** (object): options related to the verification of the archive file. + * **_hash_** (string): the hash of the archive file, in the form `-` where type is either `sha512` or `sha256`. * **_links_** (list of objects): the list of links to be created. Every file, directory, and link must have a unique `path`. * **path** (string): the absolute path to the link * **_overwrite_** (boolean): whether to delete preexisting nodes at the path. If overwrite is false and a matching link exists at the path, Ignition will only set the owner and group. Defaults to false. diff --git a/docs/release-notes.md b/docs/release-notes.md index 21a439592b..5a592e38d3 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -12,6 +12,7 @@ nav_order: 9 ### Features - Ship aarch64 macOS ignition-validate binary in GitHub release artifacts +- Add the ability to populate directory from tar archives. ### Changes diff --git a/internal/exec/stages/files/filesystemEntries.go b/internal/exec/stages/files/filesystemEntries.go index 18e00778ae..b1a1e2cf33 100644 --- a/internal/exec/stages/files/filesystemEntries.go +++ b/internal/exec/stages/files/filesystemEntries.go @@ -17,6 +17,7 @@ package files import ( "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -285,7 +286,7 @@ func (tmp fileEntry) create(l *log.Logger, u util.Util) error { for _, op := range fetchOps { msg := "writing file %q" - if op.Append { + if op.Mode == util.FetchAppend { msg = "appending to file %q" } if err := l.LogOp( @@ -323,6 +324,33 @@ func (tmp dirEntry) create(l *log.Logger, u util.Util) error { return fmt.Errorf("error creating directory %s: A non-directory already exists and overwrite is false", d.Path) } + if d.Contents.Archive != nil { + dirf, err := os.Open(d.Path) + if err != nil { + return fmt.Errorf("open() failed on %s: %v", d.Path, err) + } + switch _, err := dirf.Readdirnames(1); { + case err == nil: + return fmt.Errorf("refusing to populate directory %s: directory is not empty and overwrite is false", d.Path) + case err != io.EOF: + return fmt.Errorf("readdirnames() failed on %s: %v", d.Path, err) + } + + fetch, err := util.MakeFetchOp(l, d.Node, d.Contents.Resource) + if err != nil { + return fmt.Errorf("failed to resolve directory %q: %v", d.Path, err) + } + fetch.Mode = util.FetchExtract + fetch.ArchiveType = util.ArchiveType(*d.Contents.Archive) + + op := func() error { + return u.PerformFetch(fetch) + } + if err := l.LogOp(op, "populating directory %q", d.Path); err != nil { + return fmt.Errorf("failed to populate directory %q: %v", d.Path, err) + } + } + if err := u.SetPermissions(d.Mode, d.Node); err != nil { return fmt.Errorf("error setting directory permissions for %s: %v", d.Path, err) } diff --git a/internal/exec/util/archive.go b/internal/exec/util/archive.go new file mode 100644 index 0000000000..ab51ec76e6 --- /dev/null +++ b/internal/exec/util/archive.go @@ -0,0 +1,250 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "archive/tar" + "errors" + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/sys/unix" + + "github.com/coreos/ignition/v2/internal/log" +) + +const filemode = 07777 + +type archiveWalker interface { + Walk(l *log.Logger, path string, fn WalkFunc) error +} + +type FileInfo struct { + Name string + Linkname string + Mode int64 + Size int64 + UID int + GID int + Xattrs map[string]string +} + +type WalkFunc func(fi FileInfo, r io.Reader) error + +type tarWalker struct{} + +func (tarWalker) Walk(l *log.Logger, path string, fn WalkFunc) error { + ar, err := os.Open(path) + if err != nil { + return err + } + defer ar.Close() + + rd := tar.NewReader(ar) + for { + hdr, err := rd.Next() + switch { + case err == io.EOF: + return nil + case err != nil: + return err + } + + fi := FileInfo{ + Name: hdr.Name, + Mode: hdr.Mode & filemode, + Size: hdr.Size, + UID: hdr.Uid, + GID: hdr.Gid, + Xattrs: make(map[string]string), + } + + switch hdr.Typeflag { + case tar.TypeLink: + fi.Linkname = hdr.Linkname + fallthrough + case tar.TypeReg: + fi.Mode |= unix.S_IFREG + case tar.TypeSymlink: + fi.Linkname = hdr.Linkname + fi.Mode |= unix.S_IFLNK + case tar.TypeDir: + fi.Mode |= unix.S_IFDIR + default: + l.Warning("Unsupported TAR file type %q, skipping it.", hdr.Typeflag) + } + + // Prefer user/group names for portability. Most archives don't have + // users/groups other than 0, but a file owned by "bin" may have + // different IDs depending on the distribution. + if hdr.Uname != "" { + usr, err := user.Lookup(hdr.Uname) + if err == nil { + fi.UID, err = strconv.Atoi(usr.Uid) + } + if err != nil { + l.Warning("could not look up user %v, defaulting to UID %d: %v", hdr.Uname, fi.UID, err) + } + } + if hdr.Gname != "" { + grp, err := user.LookupGroup(hdr.Gname) + if err == nil { + fi.GID, err = strconv.Atoi(grp.Gid) + } + if err != nil { + l.Warning("could not look up group %v, defaulting to GID %d: %v", hdr.Gname, fi.GID, err) + } + } + + for k, v := range hdr.PAXRecords { + if !strings.HasPrefix(k, "SCHILY.xattr.") { + continue + } + fi.Xattrs[k[len("SCHILY.xattr."):]] = v + } + + if err := fn(fi, rd); err != nil { + return err + } + } +} + +func (u Util) extract(walker archiveWalker, from, to string) error { + destdir, err := unix.Open(to, unix.O_PATH|unix.O_DIRECTORY, 0) + if err != nil { + return &os.PathError{Op: "open", Path: to, Err: err} + } + defer unix.Close(destdir) + + return walker.Walk(u.Logger, from, func(fi FileInfo, r io.Reader) error { + + u.Debug("extracting %v", fi.Name) + + // Make sure we do not follow symlinks or magic links, and that we + // resolve paths relative to the destination. This is less expensive + // than chroot, and prevents any nonsense with regard to tarballs + // trying to write outside of the destination directory. + const resolve = unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_SYMLINKS + + dirfd, err := unix.Openat2(destdir, filepath.Dir(fi.Name), &unix.OpenHow{ + Flags: unix.O_PATH | unix.O_DIRECTORY | unix.O_NOFOLLOW, + Resolve: resolve, + }) + if err != nil { + return &os.PathError{Op: "open", Path: filepath.Dir(fi.Name), Err: err} + } + defer unix.Close(dirfd) + + name := filepath.Base(fi.Name) + + setattrs := func(fd int, name string) error { + if err := unix.Fchownat(fd, name, fi.UID, fi.GID, unix.AT_EMPTY_PATH); err != nil { + return &os.PathError{Op: "chown", Path: fi.Name, Err: err} + } + + if name == "" { + if err := unix.Fchmod(fd, uint32(fi.Mode&filemode)); err != nil { + return &os.PathError{Op: "chmod", Path: fi.Name, Err: err} + } + + for attr, val := range fi.Xattrs { + if err := unix.Fsetxattr(fd, attr, []byte(val), 0); err != nil { + return &os.PathError{Op: "setxattr", Path: fi.Name, Err: err} + } + } + } else { + if err := unix.Fchmodat(fd, name, uint32(fi.Mode&filemode), 0); err != nil { + return &os.PathError{Op: "chmod", Path: fi.Name, Err: err} + } + + // There is no fsetxattrat, but lsetxattr is good enough. + for attr, val := range fi.Xattrs { + if err := unix.Lsetxattr(fi.Name, attr, []byte(val), 0); err != nil { + return &os.PathError{Op: "setxattr", Path: fi.Name, Err: err} + } + } + } + return nil + } + + switch fi.Mode & unix.S_IFMT { + case unix.S_IFREG: + if fi.Linkname != "" { + target := fi.Linkname + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(fi.Name), target) + } + + srcfd, err := unix.Openat2(destdir, target, &unix.OpenHow{ + Flags: unix.O_PATH | unix.O_NOFOLLOW, + Resolve: resolve, + }) + if err != nil { + return &os.PathError{Op: "open", Path: target, Err: err} + } + defer unix.Close(srcfd) + + if err := unix.Linkat(srcfd, "", dirfd, name, unix.AT_EMPTY_PATH); err != nil { + return &os.PathError{Op: "link", Path: fi.Name, Err: err} + } + } else { + fd, err := unix.Openat2(dirfd, name, &unix.OpenHow{ + Flags: unix.O_WRONLY | unix.O_CREAT | unix.O_TRUNC, + Resolve: resolve, + }) + if err != nil { + return &os.PathError{Op: "open", Path: fi.Name, Err: err} + } + f := os.NewFile(uintptr(fd), fi.Name) + defer f.Close() + + if _, err := io.Copy(f, r); err != nil { + return &os.PathError{Op: "extract", Path: fi.Name, Err: err} + } + + if err := setattrs(fd, ""); err != nil { + return err + } + + if err := f.Close(); err != nil { + return err + } + } + case unix.S_IFDIR: + if err := unix.Mkdirat(dirfd, name, 0); err != nil && !errors.Is(err, unix.EEXIST) { + return &os.PathError{Op: "mkdir", Path: fi.Name, Err: err} + } + if err := setattrs(dirfd, name); err != nil { + return err + } + case unix.S_IFLNK: + if err := unix.Symlinkat(fi.Linkname, dirfd, name); err != nil { + return &os.PathError{Op: "symlink", Path: fi.Name, Err: err} + } + if err := setattrs(dirfd, name); err != nil { + return err + } + default: + return &os.PathError{Op: "walk", Path: fi.Name, Err: fmt.Errorf("Unsupported file type %v", fi.Mode&unix.S_IFMT)} + } + + return nil + }) +} diff --git a/internal/exec/util/file.go b/internal/exec/util/file.go index db666915b1..f996422a94 100644 --- a/internal/exec/util/file.go +++ b/internal/exec/util/file.go @@ -40,15 +40,30 @@ const ( DefaultFilePermissions os.FileMode = 0644 ) +type FetchMode int + +const ( + FetchReplace FetchMode = iota + FetchAppend + FetchExtract +) + +type ArchiveType string + +const ( + ArchiveTAR ArchiveType = "tar" +) + type FetchOp struct { Hash hash.Hash Url url.URL FetchOptions resource.FetchOptions - Append bool + Mode FetchMode + ArchiveType ArchiveType Node types.Node } -func newFetchOp(l *log.Logger, node types.Node, contents types.Resource) (FetchOp, error) { +func MakeFetchOp(l *log.Logger, node types.Node, contents types.Resource) (FetchOp, error) { var expectedSum []byte uri, err := url.Parse(*contents.Source) @@ -106,7 +121,7 @@ func (u Util) PrepareFetches(l *log.Logger, f types.File) ([]FetchOp, error) { ops := []FetchOp{} if f.Contents.Source != nil { - if base, err := newFetchOp(l, f.Node, f.Contents); err != nil { + if base, err := MakeFetchOp(l, f.Node, f.Contents); err != nil { return nil, err } else { ops = append(ops, base) @@ -114,10 +129,10 @@ func (u Util) PrepareFetches(l *log.Logger, f types.File) ([]FetchOp, error) { } for _, appendee := range f.Append { - if op, err := newFetchOp(l, f.Node, appendee); err != nil { + if op, err := MakeFetchOp(l, f.Node, appendee); err != nil { return nil, err } else { - op.Append = true + op.Mode = FetchAppend ops = append(ops, op) } } @@ -216,7 +231,8 @@ func (u Util) PerformFetch(f FetchOp) error { return err } - if f.Append { + switch f.Mode { + case FetchAppend: // Make sure that we're appending to a file finfo, err := os.Lstat(path) switch { @@ -244,10 +260,21 @@ func (u Util) PerformFetch(f FetchOp) error { if _, err = io.Copy(targetFile, tmp); err != nil { return err } - } else { + case FetchReplace: if err = os.Rename(tmp.Name(), path); err != nil { return err } + case FetchExtract: + var walker archiveWalker + switch f.ArchiveType { + case ArchiveTAR: + walker = tarWalker{} + default: + return fmt.Errorf("unsupported archive type %s", string(f.ArchiveType)) + } + if err := u.extract(walker, tmp.Name(), path); err != nil { + return err + } } return nil diff --git a/tests/negative/files/directory.go b/tests/negative/files/directory.go new file mode 100644 index 0000000000..119a6b3660 --- /dev/null +++ b/tests/negative/files/directory.go @@ -0,0 +1,131 @@ +// Copyright 2022 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package files + +import ( + "github.com/coreos/ignition/v2/tests/register" + "github.com/coreos/ignition/v2/tests/types" +) + +func init() { + register.Register(register.NegativeTest, DirectoryFromArchiveMustSetOverwrite()) + register.Register(register.NegativeTest, DirectoryFromArchiveMustSetArchive()) + register.Register(register.NegativeTest, DirectoryFromArchiveConflicts()) +} + +func DirectoryFromArchiveMustSetOverwrite() types.Test { + name := "directories.must-set-overwrite" + in := types.GetBaseDisk() + out := in + config := `{ + "ignition": { "version": "$version" }, + "storage": { + "directories": [ + { + "path": "/foo/bar", + "contents": { + "archive": "tar", + "source": "data:;base64," + } + } + ] + } + }` + configMinVersion := "3.4.0-experimental" + + return types.Test{ + Name: name, + In: in, + Out: out, + Config: config, + ConfigMinVersion: configMinVersion, + ConfigShouldBeBad: true, + } +} + +func DirectoryFromArchiveMustSetArchive() types.Test { + name := "directories.must-set-archive" + in := types.GetBaseDisk() + out := in + config := `{ + "ignition": { "version": "$version" }, + "storage": { + "directories": [ + { + "path": "/foo/bar", + "overwrite": true, + "contents": { + "source": "data:;base64," + } + } + ] + } + }` + configMinVersion := "3.4.0-experimental" + + return types.Test{ + Name: name, + In: in, + Out: out, + Config: config, + ConfigMinVersion: configMinVersion, + ConfigShouldBeBad: true, + } +} + +func DirectoryFromArchiveConflicts() types.Test { + name := "directories.conflict" + in := types.GetBaseDisk() + out := in + config := `{ + "ignition": { "version": "$version" }, + "storage": { + "directories": [ + { + "path": "/foo", + "overwrite": true, + "contents": { + "archive": "tar", + "source": "data:;base64," + } + }, + { + "path": "/foo/bar" + } + ], + "files": [ + { + "path": "/foo/baz" + } + ], + "links": [ + { + "path": "/foo/quxx", + "target": "/" + } + ] + } + }` + configMinVersion := "3.4.0-experimental" + + return types.Test{ + Name: name, + In: in, + Out: out, + Config: config, + ConfigMinVersion: configMinVersion, + ConfigShouldBeBad: true, + } +} diff --git a/tests/positive/files/directory.go b/tests/positive/files/directory.go index 5bda64c136..a256d473bc 100644 --- a/tests/positive/files/directory.go +++ b/tests/positive/files/directory.go @@ -15,6 +15,19 @@ package files import ( + "archive/tar" + "compress/gzip" + "encoding/base64" + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/sys/unix" + "github.com/coreos/ignition/v2/tests/register" "github.com/coreos/ignition/v2/tests/types" ) @@ -26,6 +39,7 @@ func init() { register.Register(register.PositiveTest, DirCreationOverNonemptyDir()) register.Register(register.PositiveTest, CheckOrdering()) register.Register(register.PositiveTest, ApplyDefaultDirectoryPermissions()) + register.Register(register.PositiveTest, CreateDirectoryFromTAR()) } func CreateDirectoryOnRoot() types.Test { @@ -279,3 +293,210 @@ func ApplyDefaultDirectoryPermissions() types.Test { ConfigMinVersion: configMinVersion, } } + +func CreateDirectoryFromTAR() types.Test { + name := "directories.tar" + in := types.GetBaseDisk() + out := types.GetBaseDisk() + + handleErr := func(err error) { + if err != nil { + panic(fmt.Sprintf("error generating test archive: %v", err)) + } + } + + type filespec struct { + tar.Header + Contents string + } + + usr, err := user.Lookup("bin") + handleErr(err) + binUID, err := strconv.Atoi(usr.Uid) + handleErr(err) + + grp, err := user.Lookup("daemon") + handleErr(err) + daemonGID, err := strconv.Atoi(grp.Gid) + handleErr(err) + + files := []filespec{ + { + Header: tar.Header{ + Typeflag: tar.TypeReg, + Name: "reg", + }, + Contents: "Hello, world\n", + }, + { + Header: tar.Header{ + Typeflag: tar.TypeDir, + Name: "dir", + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeSymlink, + Name: "symlink", + Linkname: "reg", + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeSymlink, + Name: "dir/symlink", + Linkname: "../reg", + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeLink, + Name: "link", + Linkname: "/reg", + }, + }, + { + // Verify that hard link resolution is done relative to target + Header: tar.Header{ + Typeflag: tar.TypeLink, + Name: "dir/link", + Linkname: "../reg", + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeReg, + Name: "xattrs", + Xattrs: map[string]string{ + "security.capability": "\x00\x00\x00\x02\xc2\x10\x2c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", // some permitted-only capabilities + "user.foo": "bar", + }, + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeReg, + Name: "uidgid", + Uid: 1, + Gid: 2, + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeReg, + Name: "usergroup", + Uname: "bin", + Gname: "daemon", + Uid: binUID, + Gid: daemonGID, + }, + }, + { + Header: tar.Header{ + Typeflag: tar.TypeReg, + Name: "mode", + Mode: 01234, + }, + }, + } + + var archive strings.Builder + + enc := base64.NewEncoder(base64.StdEncoding, &archive) + gz := gzip.NewWriter(enc) + ar := tar.NewWriter(gz) + + for _, file := range files { + node := types.Node{ + Name: filepath.Base(file.Name), + Directory: filepath.Join("dir", filepath.Dir(file.Name)), + User: file.Uid, + Group: file.Gid, + } + + switch file.Typeflag { + case tar.TypeReg: + if file.Size == 0 { + file.Size = int64(len(file.Contents)) + } + + // types.File.Mode is actually an os.FileMode more than a file mode + // as defined by stat, so we have to convert between the upper bits + // and the portable Go definitions of setuid/setgid/sticky. + mode := os.FileMode(file.Mode & 0777) + if file.Mode&unix.S_ISUID != 0 { + mode |= os.ModeSetuid + } + if file.Mode&unix.S_ISGID != 0 { + mode |= os.ModeSetgid + } + if file.Mode&unix.S_ISVTX != 0 { + mode |= os.ModeSticky + } + + out[0].Partitions.AddFiles("ROOT", []types.File{{ + Node: node, + Contents: file.Contents, + Mode: int(mode), + }}) + case tar.TypeDir: + out[0].Partitions.AddDirectories("ROOT", []types.Directory{{ + Node: node, + }}) + case tar.TypeLink: + target := file.Linkname + if filepath.IsAbs(target) { + target = filepath.Join("dir", target) + } else { + target = filepath.Join(node.Directory, file.Linkname) + } + out[0].Partitions.AddLinks("ROOT", []types.Link{{ + Node: node, + Target: target, + Hard: true, + }}) + case tar.TypeSymlink: + out[0].Partitions.AddLinks("ROOT", []types.Link{{ + Node: node, + Target: file.Linkname, + }}) + } + + handleErr(ar.WriteHeader(&file.Header)) + if file.Typeflag == tar.TypeReg { + _, err := io.WriteString(ar, file.Contents) + handleErr(err) + } + } + + handleErr(ar.Close()) + handleErr(gz.Close()) + handleErr(enc.Close()) + + config := fmt.Sprintf(`{ + "ignition": { "version": "$version" }, + "storage": { + "directories": [ + { + "path": "/dir", + "overwrite": true, + "contents": { + "archive": "tar", + "source": "data:;base64,%v", + "compression": "gzip" + } + } + ] + } + }`, archive.String()) + + configMinVersion := "3.4.0-experimental" + + return types.Test{ + Name: name, + In: in, + Out: out, + Config: config, + ConfigMinVersion: configMinVersion, + } +}