313 lines
9.3 KiB
Go
313 lines
9.3 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Load imports the images of a `docker save` archive or an OCI image
|
|
// layout into the store (docker load semantics): the outer archive is
|
|
// unpacked to a staging directory, each image's layers are flattened
|
|
// into a rootfs, and the OCI config becomes the image metadata. The
|
|
// archive names its images (RepoTags, or the OCI ref.name annotation);
|
|
// reference, when given, overrides that for a single-image archive.
|
|
// Returns the references loaded, in short form.
|
|
func (s Store) Load(archive, reference string) ([]string, error) {
|
|
archiveAbs, err := filepath.Abs(archive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := os.Stat(archiveAbs); err != nil {
|
|
return nil, fmt.Errorf("archive: %w", err)
|
|
}
|
|
staging, err := os.MkdirTemp(s.imagesRoot(), ".load-")
|
|
if err != nil {
|
|
if mkErr := os.MkdirAll(s.imagesRoot(), 0o755); mkErr != nil {
|
|
return nil, mkErr
|
|
}
|
|
if staging, err = os.MkdirTemp(s.imagesRoot(), ".load-"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
defer os.RemoveAll(staging)
|
|
unpacked := filepath.Join(staging, "archive")
|
|
if err := untar(archiveAbs, unpacked); err != nil {
|
|
return nil, err
|
|
}
|
|
images, err := readArchiveImages(unpacked)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", archiveAbs, err)
|
|
}
|
|
if reference != "" {
|
|
if len(images) != 1 {
|
|
return nil, fmt.Errorf("%s holds %d images; a name can only be given for one", archiveAbs, len(images))
|
|
}
|
|
images[0].names = []string{reference}
|
|
}
|
|
var loaded []string
|
|
for i, img := range images {
|
|
if len(img.names) == 0 {
|
|
return nil, fmt.Errorf("%s: image %d carries no name (RepoTags); load it with --name", archiveAbs, i+1)
|
|
}
|
|
for _, name := range img.names {
|
|
if _, err := ParseReference(name); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := s.Dir(name); err == nil {
|
|
return nil, fmt.Errorf("image %q already exists (werkdock rmi %s to replace it)", name, name)
|
|
}
|
|
}
|
|
dir := filepath.Join(staging, fmt.Sprintf("image-%d", i))
|
|
if err := flattenImage(unpacked, img, dir, archiveAbs); err != nil {
|
|
return nil, err
|
|
}
|
|
// The first name gets the tree, further names of the same image
|
|
// get a hardlinked copy — a tag is a copy in this store (RFC 0004).
|
|
sources := []string{dir}
|
|
for j := 1; j < len(img.names); j++ {
|
|
copy := filepath.Join(staging, fmt.Sprintf("image-%d-%d", i, j))
|
|
if err := copyTree(dir, copy); err != nil {
|
|
return nil, err
|
|
}
|
|
sources = append(sources, copy)
|
|
}
|
|
for j, name := range img.names {
|
|
ref, _ := ParseReference(name)
|
|
if err := writeMeta(sources[j], ImageMeta{Name: ref.Short(), Source: archiveAbs}.merge(img.config)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.place(sources[j], ref); err != nil {
|
|
return nil, err
|
|
}
|
|
loaded = append(loaded, ref.Short())
|
|
}
|
|
}
|
|
return loaded, nil
|
|
}
|
|
|
|
// archiveImage is one image of an archive: where its config and layers
|
|
// are (paths relative to the unpacked archive) and what it is called.
|
|
type archiveImage struct {
|
|
configPath string
|
|
layers []string
|
|
names []string
|
|
config ociConfig
|
|
}
|
|
|
|
// ociConfig is the OCI image configuration blob, as far as werkdock
|
|
// reads it.
|
|
type ociConfig struct {
|
|
Architecture string `json:"architecture"`
|
|
OS string `json:"os"`
|
|
Created time.Time `json:"created"`
|
|
Config ImageConfig `json:"config"`
|
|
}
|
|
|
|
func (m ImageMeta) merge(c ociConfig) ImageMeta {
|
|
m.Architecture, m.OS, m.Config = c.Architecture, c.OS, c.Config
|
|
m.CreatedAt = c.Created
|
|
if m.CreatedAt.IsZero() {
|
|
m.CreatedAt = time.Now().UTC()
|
|
}
|
|
if m.Architecture == "" {
|
|
m.Architecture = runtime.GOARCH
|
|
}
|
|
if m.OS == "" {
|
|
m.OS = runtime.GOOS
|
|
}
|
|
return m
|
|
}
|
|
|
|
// readArchiveImages understands the docker save manifest (manifest.json,
|
|
// written by every docker version, also beside the OCI layout newer
|
|
// ones produce) and, failing that, a plain OCI image layout.
|
|
func readArchiveImages(dir string) ([]archiveImage, error) {
|
|
if raw, err := os.ReadFile(filepath.Join(dir, "manifest.json")); err == nil {
|
|
return readDockerManifest(dir, raw)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "index.json")); err == nil {
|
|
return readOCILayout(dir)
|
|
}
|
|
return nil, fmt.Errorf("neither a docker save archive (manifest.json) nor an OCI image layout (index.json); a plain rootfs archive is imported with: werkdock import")
|
|
}
|
|
|
|
func readDockerManifest(dir string, raw []byte) ([]archiveImage, error) {
|
|
var manifest []struct {
|
|
Config string `json:"Config"`
|
|
RepoTags []string `json:"RepoTags"`
|
|
Layers []string `json:"Layers"`
|
|
}
|
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
|
return nil, fmt.Errorf("manifest.json: %w", err)
|
|
}
|
|
var images []archiveImage
|
|
for _, m := range manifest {
|
|
img := archiveImage{configPath: m.Config, layers: m.Layers, names: m.RepoTags}
|
|
if err := img.readConfig(dir); err != nil {
|
|
return nil, err
|
|
}
|
|
images = append(images, img)
|
|
}
|
|
return images, nil
|
|
}
|
|
|
|
// OCI descriptors and manifests, the fields werkdock needs.
|
|
type ociDescriptor struct {
|
|
MediaType string `json:"mediaType"`
|
|
Digest string `json:"digest"`
|
|
Annotations map[string]string `json:"annotations"`
|
|
Platform *struct {
|
|
Architecture string `json:"architecture"`
|
|
OS string `json:"os"`
|
|
} `json:"platform"`
|
|
}
|
|
|
|
type ociManifest struct {
|
|
MediaType string `json:"mediaType"`
|
|
Manifests []ociDescriptor `json:"manifests"` // an index
|
|
Config ociDescriptor `json:"config"` // an image manifest
|
|
Layers []ociDescriptor `json:"layers"`
|
|
}
|
|
|
|
const refNameAnnotation = "org.opencontainers.image.ref.name"
|
|
|
|
func readOCILayout(dir string) ([]archiveImage, error) {
|
|
raw, err := os.ReadFile(filepath.Join(dir, "index.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var index ociManifest
|
|
if err := json.Unmarshal(raw, &index); err != nil {
|
|
return nil, fmt.Errorf("index.json: %w", err)
|
|
}
|
|
var images []archiveImage
|
|
for _, desc := range index.Manifests {
|
|
img, err := resolveOCIDescriptor(dir, desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name := desc.Annotations[refNameAnnotation]; name != "" {
|
|
img.names = []string{name}
|
|
}
|
|
images = append(images, img)
|
|
}
|
|
return images, nil
|
|
}
|
|
|
|
// resolveOCIDescriptor follows a descriptor to an image manifest,
|
|
// choosing the host's platform from a nested index.
|
|
func resolveOCIDescriptor(dir string, desc ociDescriptor) (archiveImage, error) {
|
|
blob := blobPath(desc.Digest)
|
|
raw, err := os.ReadFile(filepath.Join(dir, blob))
|
|
if err != nil {
|
|
return archiveImage{}, err
|
|
}
|
|
var m ociManifest
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return archiveImage{}, fmt.Errorf("%s: %w", blob, err)
|
|
}
|
|
if len(m.Manifests) > 0 {
|
|
var platforms []string
|
|
for _, sub := range m.Manifests {
|
|
if sub.Platform == nil {
|
|
continue
|
|
}
|
|
platforms = append(platforms, sub.Platform.OS+"/"+sub.Platform.Architecture)
|
|
if sub.Platform.OS == runtime.GOOS && sub.Platform.Architecture == runtime.GOARCH {
|
|
return resolveOCIDescriptor(dir, sub)
|
|
}
|
|
}
|
|
sort.Strings(platforms)
|
|
return archiveImage{}, fmt.Errorf("%s: no manifest for %s/%s (available: %s)", blob, runtime.GOOS, runtime.GOARCH, strings.Join(platforms, ", "))
|
|
}
|
|
img := archiveImage{configPath: blobPath(m.Config.Digest)}
|
|
for _, l := range m.Layers {
|
|
img.layers = append(img.layers, blobPath(l.Digest))
|
|
}
|
|
return img, img.readConfig(dir)
|
|
}
|
|
|
|
func blobPath(digest string) string {
|
|
alg, hex, _ := strings.Cut(digest, ":")
|
|
return filepath.Join("blobs", alg, hex)
|
|
}
|
|
|
|
func (img *archiveImage) readConfig(dir string) error {
|
|
if img.configPath == "" {
|
|
return fmt.Errorf("image without a config blob")
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(dir, img.configPath))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := json.Unmarshal(raw, &img.config); err != nil {
|
|
return fmt.Errorf("%s: %w", img.configPath, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// flattenImage applies the layers in order into dir/rootfs.
|
|
func flattenImage(unpacked string, img archiveImage, dir, archive string) error {
|
|
rootfs := filepath.Join(dir, "rootfs")
|
|
if err := os.MkdirAll(rootfs, 0o755); err != nil {
|
|
return err
|
|
}
|
|
for _, layer := range img.layers {
|
|
if err := applyLayer(rootfs, filepath.Join(unpacked, layer)); err != nil {
|
|
return fmt.Errorf("%s: layer %s: %w", archive, layer, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeMeta(dir string, meta ImageMeta) error {
|
|
raw, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(dir, "image.json"), append(raw, '\n'), 0o644)
|
|
}
|
|
|
|
// place moves a finished image directory to its store location.
|
|
func (s Store) place(src string, ref Reference) error {
|
|
dir := s.imageDir(ref)
|
|
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(src, dir); err != nil {
|
|
s.pruneEmptyParents(dir)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// copyTree duplicates an image directory with hardlinks for files, so a
|
|
// second tag costs no data.
|
|
func copyTree(src, dst string) error {
|
|
return filepath.Walk(src, func(p string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(src, p)
|
|
target := filepath.Join(dst, rel)
|
|
switch {
|
|
case fi.IsDir():
|
|
return os.MkdirAll(target, fi.Mode()|0o700)
|
|
case fi.Mode()&os.ModeSymlink != 0:
|
|
link, err := os.Readlink(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Symlink(link, target)
|
|
default:
|
|
return os.Link(p, target)
|
|
}
|
|
})
|
|
}
|