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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ build: generate-api ## Build Druid and helper binaries
CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=$(VERSION)" -o ./bin/druid ./apps/druid
CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=$(VERSION)" -o ./bin/druid-coldstarter ./apps/druid-coldstarter
CGO_ENABLED=0 go build -ldflags "-X github.com/highcard-dev/daemon/internal.Version=$(VERSION)" -o ./bin/druid-dev ./apps/druid-dev
CGO_ENABLED=0 go build -trimpath -o ./bin/druid-scroll-validator ./apps/druid-scroll-validator

k3d-build-pull-image: ## Build the unified Druid runtime image and import it into local k3d.
docker build . -f Dockerfile --build-arg "VERSION=$(VERSION)" -t "$(DRUID_K8S_PULL_IMAGE)"
Expand All @@ -65,6 +66,7 @@ install: build ## Build and install Druid binaries
install -m 0755 ./bin/druid /usr/local/bin/druid
install -m 0755 ./bin/druid-coldstarter /usr/local/bin/druid-coldstarter
install -m 0755 ./bin/druid-dev /usr/local/bin/druid-dev
install -m 0755 ./bin/druid-scroll-validator /usr/local/bin/druid-scroll-validator

generate-md-docs:
go run ./docs_md/main.go
Expand Down
28 changes: 28 additions & 0 deletions apps/druid-scroll-validator/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// druid-scroll-validator validates bounded YAML through the runtime's semantic
// rules. It never expands environment variables, reads configuration, unpacks
// artifacts, contacts a daemon, or executes Scroll commands.
package main

import (
"encoding/json"
"io"
"os"

"github.com/highcard-dev/daemon/internal/core/domain"
"gopkg.in/yaml.v2"
)

const maxInputBytes = 4 * 1024 * 1024

func validate(input io.Reader, output io.Writer) {
data, err := io.ReadAll(io.LimitReader(input, maxInputBytes+1))
scroll := &domain.Scroll{}
valid := err == nil && len(data) <= maxInputBytes && yaml.Unmarshal(data, &scroll.File) == nil && scroll.Validate(false) == nil
// Do not echo untrusted content or host details in validation errors.
_ = json.NewEncoder(output).Encode(struct {
Version int `json:"version"`
Valid bool `json:"valid"`
}{Version: 1, Valid: valid})
}

func main() { validate(os.Stdin, os.Stdout) }
48 changes: 48 additions & 0 deletions apps/druid-scroll-validator/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"bytes"
"encoding/json"
"strings"
"testing"
)

const validScroll = "name: fixture\ndesc: Fixture\nversion: 1.0.0\napp_version: '1'\ncommands:\n start:\n procedures:\n - image: busybox:1.36\n command: [sleep, '600']\n"

func TestValidateUsesRuntimeSemanticsWithoutHostExpansion(t *testing.T) {
t.Setenv("SECRET_IMAGE", "")
for _, fixture := range []struct {
name, yaml string
valid bool
}{
{"valid", validScroll, true},
{"literal environment placeholder", strings.ReplaceAll(validScroll, "busybox:1.36", "$SECRET_IMAGE"), true},
{"missing description", strings.ReplaceAll(validScroll, "desc: Fixture\n", ""), false},
{"invalid version", strings.ReplaceAll(validScroll, "version: 1.0.0", "version: invalid"), false},
{"empty procedures", "name: fixture\ndesc: Fixture\nversion: 1.0.0\napp_version: '1'\ncommands:\n start: {}\n", false},
{"missing image", strings.ReplaceAll(validScroll, "image: busybox:1.36", "image: ''"), false},
{"signal without target", strings.ReplaceAll(validScroll, "image: busybox:1.36", "type: signal\n signal: SIGTERM"), false},
{"escaping mount", validScroll + " mounts:\n - path: /server\n sub_path: ../secret\n", false},
{"relative mount", validScroll + " mounts:\n - path: server\n", false},
{"unknown expected port", validScroll + " expectedPorts:\n - name: missing\n", false},
{"duplicate ids", validScroll + " id: duplicate\n - image: busybox\n id: duplicate\n", false},
{"legacy mode", validScroll + " mode: exec\n", false},
{"malformed yaml", "commands: [", false},
{"oversized input", strings.Repeat("x", maxInputBytes+1), false},
} {
t.Run(fixture.name, func(t *testing.T) {
var output bytes.Buffer
validate(strings.NewReader(fixture.yaml), &output)
var result struct {
Version int `json:"version"`
Valid bool `json:"valid"`
}
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Version != 1 || result.Valid != fixture.valid {
t.Fatalf("got %+v, want valid=%v", result, fixture.valid)
}
})
}
}
19 changes: 19 additions & 0 deletions apps/druid/adapters/cli/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/highcard-dev/daemon/internal/core/domain"
"github.com/highcard-dev/daemon/internal/core/services/registry"
Expand Down Expand Up @@ -69,6 +71,9 @@ var PushCommand = &cobra.Command{
}

overrides := map[string]string{}
if err := reproducibleCreatedAnnotation(overrides, os.Getenv("SOURCE_DATE_EPOCH")); err != nil {
return err
}
if pushMinRAM != "" {
overrides["gg.druid.scroll.minRam"] = pushMinRAM
}
Expand Down Expand Up @@ -107,6 +112,20 @@ var PushCommand = &cobra.Command{
},
}

// ORAS otherwise stamps the current time, making an identical CI rebuild a
// different immutable revision. Only explicitly reproducible builds override it.
func reproducibleCreatedAnnotation(annotations map[string]string, epoch string) error {
if epoch == "" {
return nil
}
seconds, err := strconv.ParseInt(epoch, 10, 64)
if err != nil || seconds < 0 || seconds > 253402300799 {
return fmt.Errorf("SOURCE_DATE_EPOCH must be a nonnegative Unix timestamp before year 10000")
}
annotations["org.opencontainers.image.created"] = time.Unix(seconds, 0).UTC().Format(time.RFC3339)
return nil
}

func init() {
RootCmd.AddCommand(PushCommand)
PushCommand.Flags().StringVarP(&pushMinRAM, "min-ram", "r", pushMinRAM, "Minimum RAM required to run the application. (Will be added as a manifest annotation gg.druid.scroll.minRam)")
Expand Down
27 changes: 27 additions & 0 deletions apps/druid/adapters/cli/push_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cli

import "testing"

func TestExplicitSourceDateMakesReleaseCreationDeterministic(t *testing.T) {
for i := 0; i < 2; i++ {
annotations := map[string]string{}
if err := reproducibleCreatedAnnotation(annotations, "0"); err != nil {
t.Fatal(err)
}
if annotations["org.opencontainers.image.created"] != "1970-01-01T00:00:00Z" {
t.Fatal("unstable creation annotation")
}
}
annotations := map[string]string{}
if err := reproducibleCreatedAnnotation(annotations, ""); err != nil {
t.Fatal(err)
}
if len(annotations) != 0 {
t.Fatal("ordinary pushes unexpectedly changed")
}
for _, invalid := range []string{"-1", "tomorrow", "253402300800"} {
if err := reproducibleCreatedAnnotation(annotations, invalid); err == nil {
t.Fatal("invalid source date accepted")
}
}
}
49 changes: 49 additions & 0 deletions apps/druid/adapters/cli/worker_glob_protection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package cli

import (
"os"
"path/filepath"
"testing"
)

func TestWorkerUpdatePreservesGlobProtectedData(t *testing.T) {
for _, nested := range []bool{false, true} {
for _, declaration := range []string{"installed", "candidate"} {
t.Run(declaration+map[bool]string{false: "/flat", true: "/nested"}[nested], func(t *testing.T) {
root, candidate := t.TempDir(), t.TempDir()
chunks := "chunks:\n - name: config\n path: '*.cfg'\n skip_update: true\n"
directory := ""
if nested {
directory = "servers/one"
chunks = "chunks:\n - name: servers\n path: 'servers/*'\n chunks:\n - name: config\n path: '*.cfg'\n skip_update: true\n"
}
installedYAML, candidateYAML := "name: example\n", "name: example\n"
if declaration == "installed" {
installedYAML += chunks
} else {
candidateYAML += chunks
}
mustWrite(t, filepath.Join(root, "scroll.yaml"), installedYAML)
mustWrite(t, filepath.Join(candidate, "scroll.yaml"), candidateYAML)
mustWrite(t, filepath.Join(root, "data", directory, "edited.cfg"), "user edits")
mustWrite(t, filepath.Join(root, "data", directory, "removed.cfg"), "user save")
mustWrite(t, filepath.Join(root, "data", directory, "obsolete.txt"), "old release")
mustWrite(t, filepath.Join(candidate, "data", directory, "edited.cfg"), "new default")
mustWrite(t, filepath.Join(candidate, "data", directory, "absent.cfg"), "must not appear")
mustWrite(t, filepath.Join(candidate, "data", directory, "release.txt"), "new release")
if err := pullWorkerUpdate(root, candidate, nil, nil); err != nil {
t.Fatal(err)
}
assertFile(t, filepath.Join(root, "data", directory, "edited.cfg"), "user edits")
assertFile(t, filepath.Join(root, "data", directory, "removed.cfg"), "user save")
assertFile(t, filepath.Join(root, "data", directory, "release.txt"), "new release")
for _, name := range []string{"absent.cfg", "obsolete.txt"} {
if _, err := os.Stat(filepath.Join(root, "data", directory, name)); !os.IsNotExist(err) {
t.Fatalf("%s should be absent: %v", name, err)
}
}
assertFile(t, filepath.Join(root, "scroll.yaml"), candidateYAML)
})
}
}
}
84 changes: 84 additions & 0 deletions apps/druid/adapters/cli/worker_ownership.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cli

import (
"fmt"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"

"github.com/highcard-dev/daemon/internal/utils"
)

type workerDataOwner struct{ uid, gid int }

func ownershipID(value string) (int, error) {
id, err := strconv.ParseUint(value, 10, 32)
if err != nil || id == 1<<32-1 {
return 0, fmt.Errorf("invalid ownership ID %q", value)
}
return int(id), nil
}

// Match Docker user[:group] identity inside the worker image, including named
// users. Numeric users absent from passwd default to group 0, as Docker does.
func resolveWorkerDataOwner(value string) (*workerDataOwner, error) {
if value == "" {
return nil, nil
}
parts := strings.Split(value, ":")
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && parts[1] == "" {
return nil, fmt.Errorf("invalid new-data-owner %q", value)
}
owner := &workerDataOwner{}
uid, numericErr := ownershipID(parts[0])
var account *user.User
var err error
if numericErr == nil {
owner.uid = uid
account, _ = user.LookupId(parts[0])
} else {
account, err = user.Lookup(parts[0])
if err != nil {
return nil, fmt.Errorf("resolve new-data-owner: %w", err)
}
owner.uid, err = ownershipID(account.Uid)
if err != nil {
return nil, err
}
}
if account != nil {
owner.gid, err = ownershipID(account.Gid)
if err != nil {
return nil, err
}
}
if len(parts) == 2 {
owner.gid, err = ownershipID(parts[1])
if err != nil {
group, lookupErr := user.LookupGroup(parts[1])
if lookupErr != nil {
return nil, fmt.Errorf("resolve new-data-owner group: %w", lookupErr)
}
owner.gid, err = ownershipID(group.Gid)
if err != nil {
return nil, err
}
}
}
return owner, nil
}

func applyWorkerDataOwner(root string, owner *workerDataOwner) error {
if owner == nil {
return nil
}
return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
// WalkDir and Lchown both avoid following symlinks outside the stage.
return utils.SetPathOwner(path, owner.uid, owner.gid)
})
}
48 changes: 48 additions & 0 deletions apps/druid/adapters/cli/worker_ownership_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cli

import (
"os"
"os/user"
"path/filepath"
"testing"

"github.com/highcard-dev/daemon/internal/utils"
)

func TestWorkerOwnerResolution(t *testing.T) {
owner, err := resolveWorkerDataOwner("1234:2345")
if err != nil || owner.uid != 1234 || owner.gid != 2345 {
t.Fatalf("numeric owner: %+v %v", owner, err)
}
current, err := user.Current()
if err != nil {
t.Fatal(err)
}
owner, err = resolveWorkerDataOwner(current.Username)
if err != nil || owner.uid != os.Getuid() || owner.gid != os.Getgid() {
t.Fatalf("named owner: %+v %v", owner, err)
}
for _, invalid := range []string{"-1:0", ":0", "1000:", "1:2:3", "4294967295:0", "0:4294967295"} {
if _, err := resolveWorkerDataOwner(invalid); err == nil {
t.Errorf("accepted invalid owner %q", invalid)
}
}
}

func TestWorkerOwnershipDoesNotFollowSymlinks(t *testing.T) {
root := t.TempDir()
if err := os.Symlink(filepath.Join(t.TempDir(), "absent"), filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
if err := applyWorkerDataOwner(root, &workerDataOwner{os.Getuid(), os.Getgid()}); err != nil {
t.Fatal(err)
}
// A dangling target makes any accidental target dereference fail.
target := filepath.Join(t.TempDir(), "copy")
if err := utils.CopyPath(root, target, true); err != nil {
t.Fatal(err)
}
if _, err := os.Readlink(filepath.Join(target, "link")); err != nil {
t.Fatal(err)
}
}
Loading
Loading