Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,16 @@ components:

UpdateScrollRequest:
type: object
required: [artifact]
properties:
artifact:
type: string
description: Optional target artifact. If omitted, the daemon refreshes the runtime's current artifact.
pattern: '^[^@\s]+@sha256:[a-f0-9]{64}$'
description: Explicitly accepted immutable target. Missing or mutable tag references are rejected before stopping the runtime.
expected_installed_digest:
type: string
pattern: '^sha256:[a-f0-9]{64}$'
description: Optional precondition checked against the actual installed descriptor under the maintenance lock.
registry_credentials:
type: array
items:
Expand Down Expand Up @@ -482,6 +488,33 @@ paths:
'404':
description: Runtime scroll not found

/api/v1/scrolls/{id}/release:
get:
operationId: getInstalledRelease
tags: [scrolls]
summary: Read the installed release descriptor from the runtime volume
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Installed canonical repository and last successful release digest
content:
application/json:
schema:
type: object
required: [repository, digest]
properties:
repository:
type: string
digest:
type: string
'404':
description: Runtime scroll not found

/api/v1/scrolls/{id}/start:
post:
operationId: startScroll
Expand Down Expand Up @@ -532,7 +565,7 @@ paths:
schema:
type: string
requestBody:
required: false
required: true
content:
application/json:
schema:
Expand All @@ -544,6 +577,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/RuntimeScroll'
'400':
description: An explicitly accepted SHA256 artifact reference is required
'409':
description: Installed release changed since the update was checked
'404':
description: Runtime scroll not found

Expand Down
11 changes: 4 additions & 7 deletions apps/druid/adapters/cli/client/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,11 @@ package client
import "github.com/spf13/cobra"

var UpdateCommand = &cobra.Command{
Use: "update <name> [artifact]",
Short: "Update a daemon-managed scroll runtime",
Args: cobra.RangeArgs(1, 2),
Use: "update <name> <repository@sha256:digest>",
Short: "Apply an explicitly accepted immutable Scroll revision",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
artifact := ""
if len(args) == 2 {
artifact = args[1]
}
artifact := args[1]
daemon, err := runtimeDaemonClient()
if err != nil {
return err
Expand Down
204 changes: 134 additions & 70 deletions apps/druid/adapters/cli/worker_pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/highcard-dev/daemon/internal/core/ports"
coreservices "github.com/highcard-dev/daemon/internal/core/services"
"github.com/highcard-dev/daemon/internal/core/services/registry"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -52,7 +53,7 @@ func init() {
WorkerPullCommand.Flags().StringVar(&workerPullAction.CallbackURL, "callback-url", "", "Daemon worker callback URL")
WorkerPullCommand.Flags().StringVar(&workerPullAction.TokenFile, "callback-token-file", "", "Projected ServiceAccount token file for callbacks")
WorkerPullCommand.Flags().BoolVar(&workerPullAction.PreserveReleaseManifest, "preserve-release-manifest", false, "Restore the release manifest.json carried by a backup")
WorkerPullCommand.Flags().StringVar(&workerPullMode, "mode", string(ports.RuntimeWorkerModeCreate), "Pull mode: create, update, or restore")
WorkerPullCommand.Flags().StringVar(&workerPullMode, "mode", string(ports.RuntimeWorkerModeCreate), "Worker mode: create, update, restore, or inspect")
WorkerPullCommand.MarkFlagRequired("artifact")
WorkerPullCommand.MarkFlagRequired("runtime-id")
}
Expand All @@ -67,6 +68,9 @@ func runWorkerPull(action ports.RuntimeWorkerAction) ports.RuntimeWorkerResult {
if root == "" {
root = "/scroll"
}
if action.Mode == ports.RuntimeWorkerModeInspect {
return inspectInstalledRelease(root)
}
oci := registry.NewOciClient(loadWorkerRegistryStore())
digest, err := oci.ResolveDigest(action.Artifact)
if err == nil {
Expand Down Expand Up @@ -97,6 +101,39 @@ func runWorkerPull(action ports.RuntimeWorkerAction) ports.RuntimeWorkerResult {
return result
}

// Inspection reads the installed descriptor from the volume, never from a
// mutable registry tag or a separately persisted release baseline.
func inspectInstalledRelease(root string) ports.RuntimeWorkerResult {
result := ports.RuntimeWorkerResult{}
file, err := os.Open(filepath.Join(root, "manifest.json"))
if err != nil {
result.Error = err.Error()
return result
}
defer file.Close()
var descriptor v1.Descriptor
if err := json.NewDecoder(io.LimitReader(file, 4*1024*1024)).Decode(&descriptor); err != nil {
result.Error = fmt.Sprintf("invalid installed release descriptor: %v", err)
return result
}
if descriptor.Digest.Validate() != nil || descriptor.Digest.Algorithm() != "sha256" {
result.Error = "installed release descriptor requires a valid sha256 digest"
return result
}
yaml, err := os.ReadFile(filepath.Join(root, "scroll.yaml"))
if err != nil {
result.Error = err.Error()
return result
}
if _, err := domain.NewScrollFromBytes(root, yaml); err != nil {
result.Error = err.Error()
return result
}
result.ArtifactDigest = descriptor.Digest.String()
result.ScrollYAML = string(yaml)
return result
}

func loadWorkerRegistryStore() *registry.CredentialStore {
var config struct {
Registries []domain.RegistryCredential `json:"registries"`
Expand Down Expand Up @@ -161,7 +198,86 @@ func pullWorkerUpdate(root string, artifact string, oci ports.OciRegistryInterfa
}
skipData := map[string]bool{}
collectSkipUpdatePaths(skipData, "", scroll.Chunks)
return mergePulledRoot(tmp, root, skipData)
installedYAML, err := os.ReadFile(filepath.Join(root, "scroll.yaml"))
if err != nil {
return fmt.Errorf("read installed update protection: %w", err)
}
installed, err := domain.NewScrollFromBytes(root, installedYAML)
if err != nil {
return fmt.Errorf("parse installed update protection: %w", err)
}
// Honor both sides of this transition, including protected chunks removed
// by the candidate. Do not rewrite the candidate's immutable Scroll metadata.
collectSkipUpdatePaths(skipData, "", installed.Chunks)
if err := os.MkdirAll(root, 0755); err != nil {
return err
}
stage, err := os.MkdirTemp(root, ".druid-worker-update-stage-*")
if err != nil {
return err
}
defer os.RemoveAll(stage)
if err := copyPath(tmp, stage); err != nil {
return err
}
if err := preserveSkippedUpdateData(root, stage, skipData); err != nil {
return err
}
return replaceRestoredRoot(root, stage)
}

// preserveSkippedUpdateData copies only the paths a Scroll explicitly marks as
// skip_update from the installed root into the staged candidate. Candidate
// content is otherwise complete, so unprotected files absent from the
// candidate are removed when the staged root replaces the installed root.
func preserveSkippedUpdateData(root string, stage string, skipData map[string]bool) error {
for skip := range skipData {
skip = filepath.ToSlash(filepath.Clean(skip))
if !filepath.IsLocal(skip) {
return fmt.Errorf("skip_update path must stay inside runtime data: %q", skip)
}
if skip == "." {
skip = ""
}
source := filepath.Join(root, domain.RuntimeDataDir, filepath.FromSlash(skip))
target := filepath.Join(stage, domain.RuntimeDataDir, filepath.FromSlash(skip))
for _, base := range []string{root, stage} {
if err := rejectSymlinkParents(base, filepath.Join(domain.RuntimeDataDir, filepath.FromSlash(skip))); err != nil {
return err
}
}
if err := os.RemoveAll(target); err != nil {
return err
}
if _, err := os.Lstat(source); os.IsNotExist(err) {
continue
} else if err != nil {
return err
}
if err := copyPath(source, target); err != nil {
return err
}
}
return nil
}

func rejectSymlinkParents(root, relative string) error {
parts := strings.Split(filepath.Clean(relative), string(filepath.Separator))
current := root
for _, part := range parts[:len(parts)-1] {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("protected update path traverses symlink: %s", relative)
}
}
return nil
}

func pullWorkerRestore(root string, artifact string, oci ports.OciRegistryInterface) error {
Expand Down Expand Up @@ -200,7 +316,12 @@ func replaceRestoredRootWithRename(root string, stage string, rename func(string
if err != nil {
return err
}
defer os.RemoveAll(rollback)
keepRecovery := false
defer func() {
if !keepRecovery {
_ = os.RemoveAll(rollback)
}
}()

stageName := filepath.Base(stage)
rollbackName := filepath.Base(rollback)
Expand Down Expand Up @@ -242,7 +363,8 @@ func replaceRestoredRootWithRename(root string, stage string, rename func(string
for _, entry := range original {
if err := rename(filepath.Join(root, entry), filepath.Join(rollback, entry)); err != nil {
if rollbackErr := restoreOriginal(nil); rollbackErr != nil {
return fmt.Errorf("%s failed to restore original runtime after moving %s: %w", restoreRootUnsafePrefix, entry, rollbackErr)
keepRecovery = true
return fmt.Errorf("%s recovery files retained at %s; failed to restore original runtime after moving %s: %w", restoreRootUnsafePrefix, rollback, entry, rollbackErr)
}
return fmt.Errorf("restore transaction rolled back while moving original entry %s: %w", entry, err)
}
Expand All @@ -253,7 +375,8 @@ func replaceRestoredRootWithRename(root string, stage string, rename func(string
name := entry.Name()
if err := rename(filepath.Join(stage, name), filepath.Join(root, name)); err != nil {
if rollbackErr := restoreOriginal(installed); rollbackErr != nil {
return fmt.Errorf("%s failed to restore original runtime after staging %s: %w", restoreRootUnsafePrefix, name, rollbackErr)
keepRecovery = true
return fmt.Errorf("%s recovery files retained at %s; failed to restore original runtime after staging %s: %w", restoreRootUnsafePrefix, rollback, name, rollbackErr)
}
return fmt.Errorf("restore transaction rolled back while staging %s: %w", name, err)
}
Expand All @@ -278,79 +401,20 @@ func collectSkipUpdatePaths(out map[string]bool, parent string, chunks []*domain
}
}

func mergePulledRoot(src string, dst string, skipData map[string]bool) error {
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
entries, err := os.ReadDir(src)
func copyPath(src string, dst string) error {
info, err := os.Lstat(src)
if err != nil {
return err
}
for _, entry := range entries {
name := entry.Name()
srcPath := filepath.Join(src, name)
dstPath := filepath.Join(dst, name)
if name == domain.RuntimeDataDir {
if err := copyDataUpdate(srcPath, dstPath, skipData); err != nil {
return err
}
continue
}
if err := os.RemoveAll(dstPath); err != nil {
return err
}
if err := copyPath(srcPath, dstPath); err != nil {
return err
}
}
return nil
}

func copyDataUpdate(srcData string, dstData string, skipData map[string]bool) error {
return filepath.WalkDir(srcData, func(srcPath string, entry os.DirEntry, err error) error {
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(src)
if err != nil {
return err
}
rel, err := filepath.Rel(srcData, srcPath)
if err != nil {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
if rel == "." {
return os.MkdirAll(dstData, 0755)
}
rel = filepath.ToSlash(rel)
if shouldSkipWorkerUpdate(rel, skipData) {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
target := filepath.Join(dstData, filepath.FromSlash(rel))
if entry.IsDir() {
info, err := entry.Info()
if err != nil {
return err
}
return os.MkdirAll(target, info.Mode().Perm())
}
return copyPath(srcPath, target)
})
}

func shouldSkipWorkerUpdate(rel string, skipData map[string]bool) bool {
rel = filepath.ToSlash(filepath.Clean(rel))
for skip := range skipData {
if skip == "" || rel == skip || strings.HasPrefix(rel, skip+"/") {
return true
}
}
return false
}

func copyPath(src string, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
return os.Symlink(target, dst)
}
if info.IsDir() {
return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
Expand Down
Loading
Loading