mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-02 03:24:42 +08:00
Today `atelet` and `ateom` derive the per-actor directories (oci
bundles, runsc state, pid files, checkpoint and restore state,
durable-dir, system-info and volume roots) from the actor UID through
the same package, `internal/ateompath`.
This PR:
1. Add an `ActorDirs` message to RunWorkloadRequest,
RestoreWorkloadRequest, CheckpointWorkloadRequest and
TerminateWorkloadRequest, and have atelet fill it with the directories
it prepared. **The ateoms do not read it yet.**
2. Split `internal/ateompath` into:
- `cmd/atelet/internal/ateletpath`: atelet's pathes, the per-actor
directories (and `ActorDirs`, built from them) plus the directories only
atelet uses.
- `internal/nodepath`: shared pathes, the base dir both mount, the ateom
socket, the OTLP
sockets, and the netns name.
- `internal/ateompath`: what the ateoms still derive from the actor UID,
each function marked with the `ActorDirs` field it duplicates. atelet no
longer imports it.
---------
This is the first part of #1604 to decouple atelet and ateom shared
pathes. Behavior **does not change**: both sides still compute the same
paths, atelet from `ateletpath` and the ateoms from `ateompath`.
Followup PRs will remove ateompath by making `ateom-gvisor` and
`ateom-microvm` read the `ActorDirs` message from RPC.
191 lines
7.4 KiB
Go
191 lines
7.4 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// 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 imagecache
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// ImageVolumeMountPath is where one image volume is composed inside a
|
|
// bundle. atelet writes it into config.json as the bind source; the ateom
|
|
// mounts the volume there (see setupImageVolumes).
|
|
func ImageVolumeMountPath(bundlePath, volumeName string) string {
|
|
return filepath.Join(bundlePath, "volumes", volumeName)
|
|
}
|
|
|
|
// OverlaySpecFileName is the file atelet writes into each container bundle,
|
|
// next to config.json, describing how to compose the bundle's rootfs from
|
|
// cached layers. Its absence means the bundle's rootfs is a plain directory
|
|
// (e.g. one prepared by a pre-imagecache atelet) and needs no mount.
|
|
const OverlaySpecFileName = "rootfs-overlay.json"
|
|
|
|
// OverlaySpec is the contract between atelet (which cannot mount) and the
|
|
// ateom runtimes (which mount the rootfs overlay just before running the
|
|
// workload). The overlay's mountpoint, upperdir, and workdir are always the
|
|
// bundle-local rootfs/, upper/, and work/ directories — derived from the
|
|
// bundle path by the consumer rather than trusted from the file.
|
|
type OverlaySpec struct {
|
|
Version int `json:"version"`
|
|
// ImageDigest is the manifest digest the bundle's image ref resolved to
|
|
// ("sha256:<hex>"). For a multi-arch ref this is the index digest; the
|
|
// cache may hold a twin record under the platform-child digest, and GC
|
|
// must treat the pair as one image. The GC's root-set scan uses it to
|
|
// protect the image while the bundle exists; consumers ignore it.
|
|
// Optional: older specs lack it.
|
|
ImageDigest string `json:"imageDigest,omitempty"`
|
|
// Layers are the cached layer directories (each holding its tree under
|
|
// fs/), bottom-most layer first — the order the image manifest lists
|
|
// them. Consumers reverse this into overlayfs's top-first lowerdir.
|
|
Layers []string `json:"layers"`
|
|
// ExtraDirs are absolute in-rootfs directories the consumer creates after
|
|
// mounting (they land in the actor's private upper): bind-mount targets
|
|
// that must exist for the runtime to attach them, e.g. the actor identity
|
|
// mount.
|
|
ExtraDirs []string `json:"extraDirs,omitempty"`
|
|
// ImageVolumes are read-only image contents to expose beside the rootfs,
|
|
// one per image-typed volume the container mounts. The consumer composes
|
|
// each at the volume's bundle-local mount point, which the OCI spec binds
|
|
// into the container.
|
|
ImageVolumes []ImageVolumeOverlay `json:"imageVolumes,omitempty"`
|
|
}
|
|
|
|
// ImageVolumeOverlay is one image volume's contents.
|
|
type ImageVolumeOverlay struct {
|
|
// Name is the ActorTemplate's name for the volume.
|
|
Name string `json:"name"`
|
|
// ImageDigest is the manifest digest the volume's ref resolved to, in the
|
|
// same form and for the same reason as OverlaySpec.ImageDigest: the GC's
|
|
// root-set scan protects an image by digest.
|
|
ImageDigest string `json:"imageDigest,omitempty"`
|
|
// Layers are the cached layer directories, bottom-most first.
|
|
Layers []string `json:"layers"`
|
|
}
|
|
|
|
// WriteSpec writes spec into the bundle at bundlePath.
|
|
//
|
|
// The write is atomic (temp file + rename): concurrent readers — notably
|
|
// the cache GC's root-set scan — must never see a partial spec, which
|
|
// could parse with layers missing and leave them eligible for eviction
|
|
// while the actor is using them.
|
|
func WriteSpec(bundlePath string, spec *OverlaySpec) error {
|
|
spec.Version = 1
|
|
b, err := json.MarshalIndent(spec, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("while encoding overlay spec: %w", err)
|
|
}
|
|
path := filepath.Join(bundlePath, OverlaySpecFileName)
|
|
tmp, err := os.CreateTemp(bundlePath, "."+OverlaySpecFileName+".tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("while creating overlay spec temp file: %w", err)
|
|
}
|
|
defer os.Remove(tmp.Name()) // no-op once the rename succeeds
|
|
if _, err := tmp.Write(b); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("while writing overlay spec: %w", err)
|
|
}
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("while setting overlay spec mode: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("while closing overlay spec: %w", err)
|
|
}
|
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
|
return fmt.Errorf("while moving overlay spec into place: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadSpec reads the bundle's overlay spec. It returns (nil, nil) when the
|
|
// bundle has none.
|
|
func ReadSpec(bundlePath string) (*OverlaySpec, error) {
|
|
b, err := os.ReadFile(filepath.Join(bundlePath, OverlaySpecFileName))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("while reading overlay spec: %w", err)
|
|
}
|
|
var spec OverlaySpec
|
|
if err := json.Unmarshal(b, &spec); err != nil {
|
|
return nil, fmt.Errorf("while decoding overlay spec: %w", err)
|
|
}
|
|
if spec.Version != 1 {
|
|
return nil, fmt.Errorf("overlay spec %q has version %d, want 1", filepath.Join(bundlePath, OverlaySpecFileName), spec.Version)
|
|
}
|
|
return &spec, nil
|
|
}
|
|
|
|
// overlayLowerDirs returns the overlayfs lowerdir paths for the spec's
|
|
// layers: top-most layer first (the reverse of the spec's bottom-first
|
|
// order), each pointing at the layer's fs/ tree.
|
|
//
|
|
// An image may legitimately list the same layer at several positions
|
|
// (repeated identical build steps produce identical diffids; real images
|
|
// in the wild do this). The pool stores that layer once, and overlayfs
|
|
// rejects a repeated lower directory (its overlapping-layers check fails
|
|
// the mount with ELOOP).
|
|
// For identical content only the topmost occurrence can affect the merged
|
|
// view, so keep the first one seen walking top-first and drop the rest.
|
|
//
|
|
// Each path is handed to the kernel in its own fsconfig(2) "lowerdir+" call,
|
|
// so no separator escaping or aggregate option-string length cap applies.
|
|
func overlayLowerDirs(layers []string) []string {
|
|
lowers := make([]string, 0, len(layers))
|
|
seen := make(map[string]bool, len(layers))
|
|
for _, layer := range slices.Backward(layers) {
|
|
p := filepath.Join(layer, layerFSDirName)
|
|
if seen[p] {
|
|
continue
|
|
}
|
|
seen[p] = true
|
|
lowers = append(lowers, p)
|
|
}
|
|
return lowers
|
|
}
|
|
|
|
// createExtraDirs creates the spec's ExtraDirs inside the (mounted) rootfs.
|
|
// It uses os.Root so the operation is confined to rootfsPath: a symlink
|
|
// planted by the image cannot redirect the write outside the rootfs.
|
|
func createExtraDirs(rootfsPath string, extraDirs []string) error {
|
|
if len(extraDirs) == 0 {
|
|
return nil
|
|
}
|
|
root, err := os.OpenRoot(rootfsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("while opening rootfs %q: %w", rootfsPath, err)
|
|
}
|
|
defer root.Close()
|
|
|
|
for _, d := range extraDirs {
|
|
rel := strings.TrimPrefix(d, "/")
|
|
if rel == "" {
|
|
continue
|
|
}
|
|
if !filepath.IsLocal(rel) {
|
|
return fmt.Errorf("extra dir %q escapes the rootfs", d)
|
|
}
|
|
if err := root.MkdirAll(rel, 0o755); err != nil {
|
|
return fmt.Errorf("while creating extra dir %q: %w", rel, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|