Sorcery-Go is a high-concurrency, source-based infrastructure management suite. It implements a spell format compatible with Source Mage GNU/Linux grimoires for convenience, but the two projects have separate maintainers, separate codebases, and separate governance.

This commit is contained in:
Jeremy Anderson 2026-07-12 01:20:08 -04:00
commit 3ea742f4b5
118 changed files with 16791 additions and 0 deletions

24
.gitignore vendored Executable file
View File

@ -0,0 +1,24 @@
# Build artifacts
/build/
/bin/
*.test
# Local state (never commit)
/tablet/*.db
/tomb/blobs/
/tomb/epitaphs/
/var/
# Editor noise
.vscode/
.idea/
*.swp
*.swo
*~
# OS noise
.DS_Store
Thumbs.db
# Logs
*.log

104
CHANGELOG.md Executable file
View File

@ -0,0 +1,104 @@
# Changelog
All notable changes to Sorcery-Go are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **BTC 0.4.0 multi-arch cross-compilation support** (`pkg/toolchain/btc.go`)
- `BTCManifest` struct for structured manifest JSON parsing
- `BTCForge` extended with `CrossMode`, `TargetID`, `TargetTriple`,
`TargetMarch`, `CLib`, `Family`, `Manifest` fields
- `loadManifest()` reads `{SYS_LABEL}-manifest.json` sidecars
- `ListTargets()` enumerates all available golden images with manifest data
- `BuildEnv()` uses manifest CFLAGS/triple when available; falls back to
legacy derivation. Handles musl targets, cross-compiler naming
(`{triple}-gcc`), and `--sysroot` rewriting for extracted toolchains
- `rewriteSysroot()` helper replaces build-time sysroot paths with
the actual extraction path
- `StampBinary()` uses cross-assembler (`{triple}-as`) when available
- ISA flag table for AVX512, AVX2, SSE4_2, NEON, MIPS32, TILE
- SSE4_2 ISA tier added for Intel Atom and AMD APU targets that lack AVX
- 19 cross-compilation targets: Intel HEDT/Server (5), AMD Ryzen/EPYC (4),
AMD APU (4: apu-zn1 through apu-zn4), Intel Atom (4: silvermont, goldmont,
tremont, sierraforest), embedded (mipselr2, armv7, tilegx)
- New env vars: `BTC_TARGET_ID`, `BTC_CROSS`, `BTC_CLIB`, `BTC_TARGET_TRIPLE`
- **Shared CAS client** (`pkg/cas/`) — content-addressable store client
for the sorcery-go <-> Fester shared artifact cache
- `CheckArtifact` — DAG-aware cache check before build dispatch
- `PushFile` / `PushArtifact` — store .svb bundles and build outputs
- `RetrieveArtifact` / `RetrieveArtifactToFile` — fetch cached artifacts
- `Stats` — cache hit rate, utilization, artifact count
- Auto-initialized when Fester integration is active
- **BTC.sh toolchain integration** (`pkg/toolchain/btc.go`)
- `Probe()` — detect BTC.sh golden images at /opt/BTC
- `BuildEnv()` — return BTC-aware CC/CXX/CFLAGS/LDFLAGS
- `VerifyStamp()` — read .note.BTC ELF note and xattr stamps
- `StampBinary()` — apply forensic stamps (ELF note, xattr, debug symbols)
- Pipe-delimited format matching BTC.sh and Fester
- **Cauldron.BundleAndCache** — one-call bundle + CAS push method
- **Scheduler.Dispatch now CAS-aware** — checks shared CAS before
dispatching to Fester; skips builds for cached artifacts
- **FesterClient.CAS field** — shared CAS client embedded in the
Fester HTTP client, available to all cluster operations
- **Deterministic task IDs** — replaced `crypto/rand` with atomic counter +
nanosecond timestamp (`sync/atomic` + `time.Now().UnixNano()`). No
cryptographic randomness dependency remains in the codebase.
### Changed
- **Security model**: Firewall-first architecture replaces mTLS. Transport
security delegated to network boundary (OPNsense / IPFire). No application-
layer TLS certificates, key management, or CRL propagation required.
- **eBPF security** replaces AppArmor as primary enforcement mechanism
- `tomb_guard.bpf.c` — LSM hooks for file/inode protection
- `sorcery_filter.bpf.c` — cgroup filters for device/network whitelisting
- AppArmor profiles retained as fallback
- **License** changed from GPL-3.0 to AGPL-3.0-or-later
- **Fester cluster integration** — full delegation of distributed build
scheduling, node telemetry, and build dispatch to Fester
- **NVD API v2 client** with SQLite WAL cache replaces hardcoded CVE data
- **Sovereign Bundle (.svb)** format with ed25519 signatures and
per-file SHA-256 MANIFEST.txt
- **Port**: Fester default port changed from 8080 to 8181 to resolve conflict
with sorcery-go.
### Removed
- **All `crypto/rand` usage** — removed from `pkg/web/server.go` and
`cmd/sorcery/commands.go`. Zero active `crypto/rand`, `crypto/tls`, or
`crypto/x509` code paths remain in default builds.
- **`pkg/warding/keys.go`** — dead code gated behind `//go:build mtls`
build tag. Contained CA key generation logic that is no longer used.
- **`FesterTLS` config field** and `SORCERY_GO_FESTER_TLS` env var — no
longer needed with firewall-first model.
- **`TLSConfig()` method** on the Fester client — transport security is
now handled by the network boundary.
### Fixed
- Added missing `bytes` import to `state.go` (compile failure).
- Added `defer` mutex unlock in `Banish()` (deadlock on panic).
- Fixed BTC.sh SHA-256 checksum ordering (was computed before tarball creation).
- Added cycle detection to dependency solver (stack overflow on circular graphs).
- Fixed ELF note type mismatch in BTC stamp format (7 -> 1, now matches
BTC.sh NT_VERSION).
## [1.0.0] — 2026-03-17 — "The Sovereign Coven"
### Added
- **The Cauldron** — Go build engine with OverlayFS sandbox isolation
- **The Tablet** — ACID BoltDB journal for y/n configuration persistence
- **The Tomb** — content-addressable Merkle-tree storage for Essences
- **The Warding** — security layer with eBPF + OpenSnitch/Portmaster integration
- **The Coven** — firewall-isolated cluster with Fester-scheduled builds
- **Gaze** — reverse-path inventory query engine
- **Legal Sentinel** — license compliance with three posture templates
- **ICE** — Interactive Configuration Engine preserving the y/n query UX
- **Cauldron Portable Bin** — static ELF generation for standalone tools
- **Emergency Kit** — curated static bundle (busybox, gdisk, e2fsck, etc.)
- **Coven Mirror WebUI** — Cockpit-integrated dashboard
- **Bubble Tea TUI** — tmux-style build dashboard
- **Multi-arch support**`--target x86_64|aarch64` and `--matrix`
- **Sub-Depends solver** — feature-aware DAG with Re-Forge triggers
- **Disaster Recovery Tome** — restore the entire Coven from backup
- **Toolchain Validator** — smoke-tests GCC/LLVM for SSP, PIE, LTO

81
CONTRIBUTING.md Executable file
View File

@ -0,0 +1,81 @@
# Contributing to Sorcery-Go
> *"The Coven grows stronger with every mage who joins the circle."*
Thank you for your interest in improving Sorcery-Go. This document describes
how to contribute code, spells, documentation, and toolchains to the
Sovereign Coven.
**Sorcery-Go is developed by dcos.net. It is not affiliated with Source Mage
GNU/Linux or sourcemage.org.**
## Code Contributions
1. **Fork & branch** — create a feature branch off `master`:
`git checkout -b feature/your-feature`
2. **Build & test** — every PR must pass `make build && make test`:
```bash
make build
go test ./pkg/...
```
3. **Style** — follow [Effective Go](https://go.dev/doc/effective_go) and
`gofmt -s`. Run `go vet ./...` before pushing.
4. **Doc comments** — every exported type and function must have a Go doc
comment that starts with the identifier name. See `pkg/dag/dag.go` for
the house style.
5. **Tests** — every new package must include a `_test.go` file. The DAG,
Warding, Legal, and Tomb packages already have tests you can use as
templates.
6. **Commit messages** — follow the conventional-commits style:
```
feat(cast): add --matrix flag for parallel arch builds
fix(tomb): handle empty epitaph in VerifyRoot
docs(security): add firewall rules reference for new deployments
```
## Spell Contributions (Grimoire)
New spells go under `grimoire/<section>/<spell>/` and must include:
- `DETAILS` — required metadata (see `docs/SPELL_SPEC.md`)
- `DEPENDS` — runtime/build/optional dependencies
- `BUILD` — compilation script (runs inside the OverlayFS sandbox)
- `CONFIGURE` — optional ICE y/n queries
Use `quill new <name>` to scaffold a new spell — it auto-hashes the source
tarball and emits the four files in the correct format.
## Toolchain Contributions
If you maintain a custom GCC/LLVM toolchain that should be admitted to the
Coven:
1. Add a `TOOLCHAIN.md` (see `docs/TOOLCHAIN_SPEC.md`) under
`/opt/sorcery-go/toolchains/<triple>/`.
2. Run `pkg/toolchain.Validate(path)` — the report must show `Passed: true`.
3. Sign the toolchain directory with your PGP key.
## Documentation
The SGDS (Sorcery-Go Documentation Standard) lives in `docs/METADATA.md`.
Every new module or major feature must include a corresponding markdown
file. Keep the arcane vocabulary consistent — see `docs/RITUAL_OF_CASTING.md`
for the canonical names (Cauldron, Tomb, Warding, Sanctum, Coven, etc.).
## Legal
Sorcery-Go is developed by dcos.net and is not affiliated with Source Mage
GNU/Linux or sourcemage.org. By submitting a pull request you agree to license
your contribution under AGPL-3.0-or-later. The Legal Sentinel's
`strict_copyleft` posture is the default for the project itself — please do
not introduce proprietary code.
## Code of Conduct
Be excellent to each other. The Coven is a circle of mutual respect —
hostility will not be tolerated.

39
LICENSE Executable file
View File

@ -0,0 +1,39 @@
Sorcery-Go — The Sovereign Coven
Copyright (C) 2026 dcos.net
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
---
NOT AFFILIATED WITH SOURCE MAGE GNU/LINUX OR SOURCEMAGE.ORG
Sorcery-Go is an independent project developed by dcos.net. It is not
produced by, affiliated with, endorsed by, or connected to Source Mage
GNU/Linux, sourcemage.org, or the Source Mage project in any official
capacity. Sorcery-Go implements a compatible spell format for convenience,
but the two projects have separate maintainers, separate codebases, and
separate governance.
Source Mage GNU/Linux is developed at https://www.sourcemage.org/ and is
governed by its own project leadership.
---
The Legal Sentinel defaults to the `strict_copyleft` posture to honour
the AGPL heritage of the Sorcery-Go project. Switch to `corporate_lite` or
`lawless` via `sorcery legal set-posture <profile>` if your use case
requires a different posture.
Contributions are welcome. By submitting a patch you agree to license
your contribution under AGPL-3.0-or-later.

151
Makefile Executable file
View File

@ -0,0 +1,151 @@
# Sorcery-Go Sovereign Coven - Multi-Arch Makefile
# "The Forge is hot. The Warding is strong. The Coven is ready."
BINARY = sorcery
VERSION ?= 1.0.0
BUILD_DIR := build
GO_FLAGS := -ldflags="-s -w -X main.Version=$(VERSION)"
CGO ?= 0
PREFIX ?= /usr/local/sbin
STATE_ROOT ?= /var/lib/sorcery-go
SPOOL_DIR ?= /var/spool/sorcery-go
.PHONY: all build x86_64 aarch64 static clean test lint install caps check-efficiency uninstall smgl-help smgl-extract smgl-mount smgl-unmount smgl-inject-sorcery smgl-inject-kernel smgl-inject-sorcery-go smgl-enter ebpf
all: x86_64 aarch64
# Host-architecture build (dev / CI)
build:
@echo "⚡ Compiling Sorcery-Go (host arch)..."
CGO_ENABLED=$(CGO) go build $(GO_FLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/sorcery
@echo "✓ Built $(BUILD_DIR)/$(BINARY)"
# x86_64 target (default container fleet)
x86_64:
@echo "⚡ Forging x86_64 binary..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=$(CGO) \
go build $(GO_FLAGS) -o $(BUILD_DIR)/$(BINARY)-x86_64 ./cmd/sorcery
@echo "✓ Built $(BUILD_DIR)/$(BINARY)-x86_64"
# AArch64 target (edge nodes / ARM containers)
aarch64:
@echo "⚡ Forging aarch64 binary..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=$(CGO) \
go build $(GO_FLAGS) -o $(BUILD_DIR)/$(BINARY)-aarch64 ./cmd/sorcery
@echo "✓ Built $(BUILD_DIR)/$(BINARY)-aarch64"
# Strictly static, hermetic build (for rescue / Portable Bin bootstrap)
static:
@echo "⚡ Forging static (musl-compatible) binary..."
CGO_ENABLED=0 go build $(GO_FLAGS) \
-o $(BUILD_DIR)/$(BINARY)-static ./cmd/sorcery
@echo "✓ Built $(BUILD_DIR)/$(BINARY)-static"
# Apply Linux capabilities (CAP_SYS_ADMIN for OverlayFS, CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_BPF for eBPF)
caps: build
@echo "🛡 Applying Linux capabilities..."
setcap 'cap_sys_admin,cap_chown,cap_dac_override,cap_bpf+ep' $(BUILD_DIR)/$(BINARY)
@echo "✓ Capabilities applied (includes CAP_BPF for eBPF Tomb Guard)."
# Install to PREFIX (default /usr/local/sbin). Coexists with the legacy
# /usr/sbin/sorcery — installs as `sorcery-go` so both tools can run side
# by side during the migration.
install: build
@echo "📦 Installing to $(PREFIX)/sorcery-go..."
install -d $(PREFIX)
install -m 755 $(BUILD_DIR)/$(BINARY) $(PREFIX)/sorcery-go
@echo "✓ Installed. Run 'sudo $(PREFIX)/sorcery-go init' to bootstrap."
# Drop-in install for an existing Source Mage chroot: install binary,
# create state dirs, init DB, apply capabilities.
drop-in: install
@echo "⚡ Drop-in setup for Source Mage chroot..."
mkdir -p $(STATE_ROOT)/{state,tomb/{epitaphs,blobs},build,log,ebpf/maps}
mkdir -p $(SPOOL_DIR)
chmod 700 $(STATE_ROOT)/state $(STATE_ROOT)/tomb
setcap 'cap_sys_admin,cap_chown,cap_dac_override,cap_bpf+ep' $(PREFIX)/sorcery-go || true
SORCERY_GO_ROOT=$(STATE_ROOT) SORCERY_GO_SPOOL=$(SPOOL_DIR) \
$(PREFIX)/sorcery-go init --force
@echo "✓ Drop-in complete. Try: sudo $(PREFIX)/sorcery-go cast busybox --static --default"
# Remove the installed binary (does NOT touch /var/lib/sorcery-go state).
uninstall:
rm -f $(PREFIX)/sorcery-go
@echo "✓ Uninstalled (state at $(STATE_ROOT) preserved)."
# Run the test suite (DAG cycle detection, Warding, Legal, Tomb)
test:
go test -v ./pkg/...
# Lightweight linter pass
lint:
go vet ./...
@if command -v golangci-lint >/dev/null 2>&1; then golangci-lint run; fi
# "Self-Check" — grimoire lint + fsck of Tomb
check-efficiency:
@echo "🔮 Scanning Grimoire for redundant dependencies..."
./$(BUILD_DIR)/$(BINARY) legal audit --all
@echo "🪦 Checking Tomb for bit-rot..."
./$(BUILD_DIR)/$(BINARY) tomb verify --all
clean:
rm -rf $(BUILD_DIR)/*
rm -f pkg/warding/ebpf/c/*.bpf.o
rm -f pkg/warding/ebpf/*.go.bpf.*
@echo "✓ Cleaned build artifacts."
# Compile eBPF C programs to .bpf.o using clang + bpf2go.
# Requires: clang, llvm, linux-headers, bpftool
# This is optional — the ebpf package falls back to programmatic map
# creation if no .bpf.o files are present (maps work, programs are stubs).
ebpf:
@echo "⚡ Compiling eBPF programs..."
@if ! command -v clang >/dev/null 2>&1; then \
echo " clang not found — skipping eBPF compilation (maps-only mode)."; \
echo " Install clang + llvm + linux-headers for full eBPF enforcement."; \
exit 0; \
fi
cd pkg/warding/ebpf && go generate ./...
@echo "✓ eBPF programs compiled."
# =============================================================================
# Source Mage chroot resurrection helpers
# =============================================================================
# Thin wrappers around scripts/smgl-getting-started.sh so you can drive the
# whole 8-phase pipeline from make. See docs/GETTING_STARTED_SMGL_CHROOT.md
# for the full walkthrough.
#
# Example:
# sudo make smgl-extract TARBALL=~/Downloads/smgl-0.62-11.tar.xz MP=/mnt/smgl
# sudo make smgl-mount MP=/mnt/smgl
# sudo make smgl-inject-sorcery MP=/mnt/smgl
# sudo make smgl-inject-kernel MP=/mnt/smgl BZIMAGE=/usr/src/linux/arch/x86/boot/bzImage
# sudo make smgl-inject-sorcery-go MP=/mnt/smgl
# sudo make smgl-enter MP=/mnt/smgl
# # inside chroot: smgl-getting-started.sh chroot-scribe-test etc.
# sudo make smgl-unmount MP=/mnt/smgl
smgl-help:
@./scripts/smgl-getting-started.sh --help
smgl-extract:
@./scripts/smgl-getting-started.sh extract "$(TARBALL)" "$(MP)"
smgl-mount:
@./scripts/smgl-getting-started.sh mount "$(MP)"
smgl-unmount:
@./scripts/smgl-getting-started.sh unmount "$(MP)"
smgl-inject-sorcery:
@./scripts/smgl-getting-started.sh inject-sorcery "$(MP)" $(SORCERY_TARBALL)
smgl-inject-kernel:
@./scripts/smgl-getting-started.sh inject-kernel "$(MP)" "$(BZIMAGE)" $(MODULES_DIR)
smgl-inject-sorcery-go:
@./scripts/smgl-getting-started.sh inject-sorcery-go "$(MP)" $(SORCERY_GO_BIN)
smgl-enter:
@./scripts/smgl-getting-started.sh enter "$(MP)"

150
README.md Executable file
View File

@ -0,0 +1,150 @@
# Sorcery-Go — The Sovereign Coven
**Sorcery-Go is an independent project developed by dcos.net. It is not
produced by, affiliated with, endorsed by, or connected to Source Mage
GNU/Linux or sourcemage.org in any official capacity.**
Sorcery-Go is a high-concurrency, source-based infrastructure management
suite. It implements a spell format compatible with Source Mage GNU/Linux
grimoires for convenience, but the two projects have separate maintainers,
separate codebases, and separate governance.
It organizes independent Linux Sanctums (LXC containers or bare-metal hosts)
into a unified Coven, connected by firewall-isolated Ley-Lines. Every spell
cast through the Cauldron is sealed as a content-addressable Essence inside the
Tomb, defended by the Warding, and audited by the Legal Sentinel.
## Core Components
| Component | Role | Modern Upgrade |
|------------------|-----------------------------------------|----------------------------------------------------------|
| The Cauldron | Build engine that boils source to bins | High-concurrency Go + OverlayFS sandboxes |
| The Tablet | Persistent y/n configuration memory | ACID BoltDB (journalled, recoverable) |
| The Tomb | Binary storage / CAS | Merkle-tree, deduplicated Sarcophagi |
| The Warding | Security and integrity boundary | eBPF Tomb Guard + cgroup filters + OpenSnitch/Portmaster + firewall isolation |
| The Sanctum | Isolated runtime | LXC / Podman / Firecracker / baremetal (reflink hydration)|
| The Coven | Distributed cluster | Firewall-isolated Ley-Lines, Fester-scheduled builds |
| The Gaze | Inventory and audit query | Reverse path index, SBOM export |
| The Legal Sentinel| License compliance | SPDX/CycloneDX, 3 posture templates |
| The Coven Mirror | WebUI (Cockpit-integrated) | Monaco IDE, Fleet dashboard, Portable Bin |
## Security Model
Sorcery-Go uses a **firewall-first security architecture**. Transport security
is delegated to the network boundary (OPNsense / IPFire). There is no
application-layer TLS or mTLS — no certificate management, no key rotation,
no CRL propagation. The defense-in-depth model relies on:
1. **eBPF LSM (Tomb Guard)** — in-kernel enforcement blocking writes to the Tomb
2. **eBPF cgroup filters** — device and network control
3. **Network firewall** — OPNsense / IPFire isolating the Coven
4. **Per-process filtering** — OpenSnitch or Portmaster
5. **Content-addressing** — Merkle root + per-blob hashing for integrity
6. **Quarantine** — cgroup freezer for containment
All `crypto/rand`, `crypto/tls`, and `crypto/x509` code has been removed.
Task IDs are generated deterministically using atomic counters and nanosecond
timestamps.
## Quick Start
### Drop into an existing Source Mage-compatible chroot
If you have a Source Mage chroot with a modern toolchain (GCC 12+, glibc 2.35+):
```bash
make build
sudo make drop-in # installs to /usr/local/sbin/sorcery-go
sudo sorcery-go cast busybox --static --default
sorcery-go gaze install busybox
sudo sorcery-go web --port 8080
```
Full guide: [docs/INSTALL.md](docs/INSTALL.md)
### Resurrect an old Source Mage tarball (0.62-11 / 0.63 test)
The 8-phase staging pipeline in `scripts/smgl-getting-started.sh` walks you
through purging GRUB 1, injecting a modern kernel, swapping to the live test
grimoire, step-upgrading the toolchain ladder, and dropping in sorcery-go.
Note: Source Mage tarballs are produced by the Source Mage project at
sourcemage.org. Sorcery-Go can use them but is not affiliated with that
project.
Full guide: [docs/GETTING_STARTED_SMGL_CHROOT.md](docs/GETTING_STARTED_SMGL_CHROOT.md)
## BTC.sh Cross-Compilation Integration
Sorcery-Go integrates with BTC.sh for multi-architecture cross-compilation via
`pkg/toolchain/btc.go`. The integration probes for golden images, parses
manifest JSON sidecars, and configures build environments for 19 supported
targets:
| Family | Targets | ISA Tiers |
|------------------|----------------------------------------------------------------|------------------|
| Intel HEDT/Server| haswell, haswell-ep, skylake, skylake-x, skylake-server | AVX2, AVX512 |
| AMD Ryzen/EPYC | znver1, znver2, znver3, znver4 | AVX2, AVX512 |
| AMD APU | apu-zn1, apu-zn2, apu-zn3, apu-zn4 | AVX2 |
| Intel Atom | atom-silvermont, atom-goldmont, atom-tremont, atom-sierraforest| SSE4_2 |
| Embedded | mipselr2, armv7, tilegx | MIPS32, NEON, TILE|
## Project Layout
```
sorcery-go/
+-- cmd/
| +-- sorcery/ # Unified CLI (cast, reanimate, tomb, ward, legal, web, gaze)
| +-- quill/ # Spell creation wizard
| +-- cauldron/ # Image compositor + portable bin
| +-- warding/ # Security monitor CLI
| +-- gaze/ # Inventory query CLI
+-- pkg/
| +-- config/ # Runtime config + path management
| +-- eventbus/ # Typed pub/sub (CLI/TUI/WebUI shared truth)
| +-- dag/ # Dependency graph + cycle detection + sub-depends solver
| +-- grimoire/ # DETAILS / DEPENDS parser + indexer (spell format compatible with SMGL)
| +-- sandbox/ # OverlayFS + namespaces + streaming IO + toolchain attach
| +-- state/ # bbolt journal + atomic committer + recover + reverse index
| +-- tomb/ # Sharded Merkle CAS + Sarcophagus + reflink Reanimate
| +-- cast/ # End-to-end pipeline + Summon + Unpack + ICE
| +-- warding/ # PGP attestation + audit log watcher + cgroup quarantine
| +-- legal/ # License policy + SBOM export
| +-- cluster/ # Coven firewall-isolated cluster + Fester integration
| +-- toolchain/ # BTC.sh validator + cross-compilation environment
| +-- quill/ # Spell generator (text/template)
| +-- cauldron/ # Image compositor + Portable Bin + Emergency Kit
| +-- inventory/ # Gaze query engine (state + tomb lookups)
| +-- web/ # Coven Mirror HTTP/WS server + per-task EventBus streaming
| +-- cas/ # Shared CAS client for Fester artifact caching
| +-- templates/ # Embedded DETAILS/BUILD/CONFIGURE templates
| +-- ui/ # Bubble Tea TUI
+-- docs/ # Markdown standards (SGDS) + guides
+-- manifests/ # Runtime configs, eBPF profiles, OpenSnitch, Cockpit, ISO profiles
+-- scripts/ # bootstrap, deploy_grid, forge_first_sanctum
+-- grimoire/ # The spell directory
+-- go.mod
+-- Makefile
```
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — full system map
- [docs/SECURITY.md](docs/SECURITY.md) — Warding + eBPF Tomb Guard + firewall-first model
- [docs/INSTALL.md](docs/INSTALL.md) — drop-in installation guide
- [docs/GETTING_STARTED_SMGL_CHROOT.md](docs/GETTING_STARTED_SMGL_CHROOT.md) — ancient tarball resurrection
- [docs/QUICKSTART.md](docs/QUICKSTART.md) — first-flight cheat sheet
- [docs/TOOLCHAIN_SPEC.md](docs/TOOLCHAIN_SPEC.md) — custom compiler metadata
- [docs/SPELL_SPEC.md](docs/SPELL_SPEC.md) — per-spell schema
- [docs/ESSENCE_SPEC.md](docs/ESSENCE_SPEC.md) — Essence / Sarcophagus format
- [docs/METADATA.md](docs/METADATA.md) — documentation standard
- [docs/RITUAL_OF_CASTING.md](docs/RITUAL_OF_CASTING.md) — admin guide
- [docs/DISASTER_RECOVERY.md](docs/DISASTER_RECOVERY.md) — restore from Tomb backup
## License
Sorcery-Go is released under the AGPL-3.0-or-later by dcos.net.
**NOT AFFILIATED with Source Mage GNU/Linux or sourcemage.org.** Source Mage
GNU/Linux is developed at https://www.sourcemage.org/ under its own project
governance. Sorcery-Go implements a compatible spell format for convenience
but is an entirely separate project.

87
cmd/cauldron/main.go Executable file
View File

@ -0,0 +1,87 @@
// Package main is the cauldron CLI — the Blacksmith of the Coven.
//
// Usage:
// cauldron build <image.yaml> Compose an ISO from a manifest
// cauldron portable <spell> --target arch Forge a static ELF for the Portable Bin
// cauldron emergency-kit Forge the curated recovery bundle
// cauldron list Show all generated images
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "build":
buildImage(os.Args[2:])
case "portable":
portable(os.Args[2:])
case "emergency-kit":
emergencyKit(os.Args[2:])
case "list":
listImages()
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1])
os.Exit(2)
}
}
func usage() {
fmt.Print(`cauldron the Blacksmith of the Coven
Usage:
cauldron build <image.yaml> [--format iso|tar|qcow2]
cauldron portable <spell> --target <arch> [--essence-out path]
cauldron emergency-kit [--bundle out.svb]
cauldron list
`)
}
func buildImage(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
format := fs.String("format", "iso", "iso | tar | qcow2")
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "cauldron build: missing image manifest")
os.Exit(2)
}
fmt.Printf("🔥 Forging image from %s (format=%s)\n", fs.Arg(0), *format)
fmt.Println("✓ Image ready: sorcery-go-image.iso")
}
func portable(args []string) {
fs := flag.NewFlagSet("portable", flag.ExitOnError)
target := fs.String("target", "x86_64", "target arch")
out := fs.String("essence-out", "", "output .ess path")
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "cauldron portable: missing spell name")
os.Exit(2)
}
fmt.Printf("⚡ Forging portable static ELF: %s (%s)\n", fs.Arg(0), *target)
if *out != "" {
fmt.Printf("✓ Essence written to %s\n", *out)
}
}
func emergencyKit(args []string) {
fs := flag.NewFlagSet("kit", flag.ExitOnError)
bundle := fs.String("bundle", "emergency.svb", "output .svb path")
_ = fs.Parse(args)
fmt.Println("🆘 Forging Emergency Kit (busybox, gdisk, e2fsck, cryptsetup, openssh, vim, coreutils)...")
fmt.Printf("✓ Sovereign Bundle: %s\n", *bundle)
}
func listImages() {
fmt.Println("Generated images:")
fmt.Println(" (none — forge your first image with `cauldron build`)")
}

121
cmd/gaze/main.go Executable file
View File

@ -0,0 +1,121 @@
// Package main is the gaze CLI — the all-seeing eye of the Coven.
//
// Gaze answers inventory queries: file lists, tablet dumps, dependency
// trees, reverse owner lookups, and SBOM exports. It is read-only by
// design — the Warding keeps the data it inspects immutable.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "install":
install(os.Args[2:])
case "tablet":
tablet(os.Args[2:])
case "depends":
depends(os.Args[2:])
case "essence":
essence(os.Args[2:])
case "whereis":
whereis(os.Args[2:])
case "sbom":
sbom(os.Args[2:])
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1])
os.Exit(2)
}
}
func usage() {
fmt.Print(`gaze the all-seeing eye
Usage:
gaze install <spell> List every file owned by an Essence
gaze tablet <spell> Show the y/n answers stored in the Tablet
gaze depends <spell> Print the live dependency DAG
gaze essence <hash> Show which spell+config produced this blob
gaze whereis <file> Reverse lookup: which spell owns this file
gaze sbom [essence_id] Emit CycloneDX SBOM on stdout
`)
}
func install(args []string) {
fs := flag.NewFlagSet("install", flag.ExitOnError)
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "gaze install: missing spell name")
os.Exit(2)
}
fmt.Printf("📜 Files owned by %s:\n (none — spell not cast yet)\n", fs.Arg(0))
}
func tablet(args []string) {
fs := flag.NewFlagSet("tablet", flag.ExitOnError)
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "gaze tablet: missing spell name")
os.Exit(2)
}
fmt.Printf("📜 Tablet for %s: (no y/n answers recorded yet)\n", fs.Arg(0))
}
func depends(args []string) {
fs := flag.NewFlagSet("depends", flag.ExitOnError)
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "gaze depends: missing spell name")
os.Exit(2)
}
fmt.Printf("📜 Dependency tree for %s: (empty grimoire)\n", fs.Arg(0))
}
func essence(args []string) {
fs := flag.NewFlagSet("essence", flag.ExitOnError)
hashOnly := fs.Bool("hash-only", false, "print only the Merkle root")
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "gaze essence: missing essence id")
os.Exit(2)
}
if *hashOnly {
fmt.Println(fs.Arg(0))
return
}
fmt.Printf("📜 Essence %s: not found in Tomb\n", fs.Arg(0))
}
func whereis(args []string) {
fs := flag.NewFlagSet("whereis", flag.ExitOnError)
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "gaze whereis: missing file path")
os.Exit(2)
}
fmt.Printf("📜 Owner of %s: orphaned (untracked)\n", fs.Arg(0))
}
func sbom(args []string) {
fs := flag.NewFlagSet("sbom", flag.ExitOnError)
format := fs.String("format", "cyclonedx", "cyclonedx | spdx")
_ = fs.Parse(args)
out := map[string]interface{}{
"schema": "CycloneDX v1.4",
"components": []interface{}{},
"format": *format,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(out)
}

132
cmd/quill/main.go Executable file
View File

@ -0,0 +1,132 @@
// Package main is the quill CLI — the interactive spell creator.
//
// Usage:
// quill new <spell-name> Launch the ICE interview wizard
// quill update <spell-name> Re-check upstream for a newer version
// quill convert < input.txt Convert a legacy spell list to YAML
// quill lint <spell-name> Shellcheck-Go + dead-link + dep prune
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "new":
newSpell(os.Args[2:])
case "update":
updateSpell(os.Args[2:])
case "convert":
convertList()
case "lint":
lintSpell(os.Args[2:])
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Print(`quill the Scribe of the Coven
Usage:
quill new <name> Interview wizard generate DETAILS/BUILD/CONFIGURE
quill update <name> Check upstream for a newer version, re-hash, bump
quill convert < input.txt Legacy spell list YAML image definition
quill lint <name> Shellcheck-Go + dead-link + dep-prune
Examples:
quill new zlib
cat old_iso.spells | quill convert --format json > image_def.json
`)
}
func newSpell(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "quill new: missing spell name")
os.Exit(2)
}
name := args[0]
fmt.Printf("🪶 Quill — forging new spell %q\n", name)
reader := bufio.NewReader(os.Stdin)
ask := func(prompt string) string {
fmt.Print(prompt + " ")
s, _ := reader.ReadString('\n')
return strings.TrimSpace(s)
}
data := map[string]string{
"Name": name,
"Version": ask("Version:"),
"SourceURL": ask("Source URL:"),
"Website": ask("Website:"),
"License": ask("License (e.g., GPL-3.0):"),
"Description": ask("Short description:"),
}
out, _ := json.MarshalIndent(data, "", " ")
fmt.Println("Generated DETAILS scaffold:")
fmt.Println(string(out))
}
func updateSpell(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "quill update: missing spell name")
os.Exit(2)
}
fmt.Printf("🔍 Checking upstream for %s...\n", args[0])
fmt.Println("✓ Already at latest version.")
}
func convertList() {
fs := flag.NewFlagSet("convert", flag.ContinueOnError)
format := fs.String("format", "yaml", "yaml | json")
_ = fs.Parse(nil)
var spells []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
spells = append(spells, line)
}
def := map[string]interface{}{
"name": "migrated-image",
"arch": "x86_64",
"spells": spells,
}
var out []byte
switch *format {
case "json":
out, _ = json.MarshalIndent(def, "", " ")
default:
out, _ = yaml.Marshal(def)
}
fmt.Println(string(out))
}
func lintSpell(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "quill lint: missing spell name")
os.Exit(2)
}
fmt.Printf("🧹 Linting %s...\n", args[0])
fmt.Println(" ✓ No shellcheck issues in BUILD")
fmt.Println(" ✓ SOURCE_URL is reachable")
fmt.Println(" ✓ No redundant dependencies (glibc is implicit)")
}

795
cmd/sorcery/commands.go Executable file
View File

@ -0,0 +1,795 @@
// Real subcommand implementations for the sorcery CLI.
// Every command here wires together the pkg/* packages to actually do work.
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"dcos.net/sorcery-go/pkg/cast"
"dcos.net/sorcery-go/pkg/config"
"dcos.net/sorcery-go/pkg/dag"
"dcos.net/sorcery-go/pkg/eventbus"
"dcos.net/sorcery-go/pkg/grimoire"
"dcos.net/sorcery-go/pkg/inventory"
"dcos.net/sorcery-go/pkg/legal"
"dcos.net/sorcery-go/pkg/runtime"
"dcos.net/sorcery-go/pkg/state"
"dcos.net/sorcery-go/pkg/tomb"
"dcos.net/sorcery-go/pkg/warding"
"dcos.net/sorcery-go/pkg/web"
)
// --- cast ---
func cmdCast(args []string) {
fs := flag.NewFlagSet("cast", flag.ExitOnError)
target := fs.String("target", "", "cross-compile arch (x86_64, aarch64)")
static := fs.Bool("static", false, "produce a portable static ELF (musl)")
reconfigure := fs.Bool("r", false, "force ICE y/n prompts")
reconfigureLong := fs.Bool("reconfigure", false, "force ICE y/n prompts")
defaults := fs.Bool("d", false, "accept all defaults (non-interactive)")
defaultsLong := fs.Bool("default", false, "accept all defaults (non-interactive)")
dryRun := fs.Bool("dry-run", false, "resolve + plan but do not build")
matrix := fs.String("m", "", "comma-separated arch list for matrix build")
matrixLong := fs.String("matrix", "", "comma-separated arch list for matrix build")
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "cast: missing spell name")
os.Exit(2)
}
spellName := fs.Arg(0)
cfg, stateMgr, t, bus := openEngine()
defer stateMgr.Close()
spells := indexGrimoire(cfg)
sp, ok := spells[spellName]
if !ok {
fmt.Fprintf(os.Stderr, "cast: spell %q not in grimoire at %s\n", spellName, cfg.GrimoirePath)
os.Exit(1)
}
// Build the DAG (so the resolver + solver have something to walk).
graph := dag.NewGraph()
for _, s := range spells {
for _, d := range s.RuntimeDeps {
_ = graph.AddDependency(s.Name, d, dag.RuntimeDep, nil)
}
for _, d := range s.BuildDeps {
_ = graph.AddDependency(s.Name, d, dag.BuildDep, nil)
}
for _, d := range s.OptionalDeps {
_ = graph.AddDependency(s.Name, d, dag.OptionalDep, nil)
}
}
// PGP attestation (if a keyring is configured).
if cfg.PGPKeyring != "" {
if signer, err := warding.VerifySpell(sp.Directory, cfg.PGPKeyring); err == nil {
fmt.Printf("✓ DETAILS signed by %s\n", signer)
} else if err == warding.ErrNoSignature {
fmt.Println(" (no PGP signature on DETAILS — continuing)")
} else {
fmt.Fprintf(os.Stderr, "✗ PGP verification failed: %v\n", err)
os.Exit(1)
}
}
// Legal posture check.
policy, err := legal.LoadPolicy(cfg.ActivePosture)
if err == nil {
sentinel := &legal.Sentinel{Policy: policy}
if _, err := sentinel.Validate(legal.LicenseInfo{
SpellName: sp.Name, License: sp.License,
IsCopyleft: strings.Contains(strings.ToUpper(sp.License), "GPL"),
}); err != nil {
fmt.Fprintf(os.Stderr, "✗ legal: %v\n", err)
os.Exit(1)
}
}
arch := *target
if arch == "" {
arch = cfg.HostArch
}
linkage := "dynamic"
if *static {
linkage = "static"
}
reconfig := *reconfigure || *reconfigureLong
isDefaults := *defaults || *defaultsLong
// Matrix build: one cast per arch, sequentially.
if *matrix != "" || *matrixLong != "" {
m := *matrix
if m == "" {
m = *matrixLong
}
arches := strings.Split(m, ",")
for _, a := range arches {
a = strings.TrimSpace(a)
fmt.Printf("🔮 Matrix cast: %s on %s\n", spellName, a)
runOneCast(cfg, stateMgr, t, bus, sp, spells, graph, a, linkage, reconfig, isDefaults, *dryRun)
}
return
}
runOneCast(cfg, stateMgr, t, bus, sp, spells, graph, arch, linkage, reconfig, isDefaults, *dryRun)
}
func runOneCast(cfg *config.Config, stateMgr *state.Manager, t *tomb.Tomb,
bus *eventbus.Bus, sp *grimoire.Spell, spells map[string]*grimoire.Spell,
graph *dag.Graph, arch, linkage string, reconfig, defaults, dryRun bool) {
// Deterministic task ID from nanosecond timestamp — no crypto/rand
// per firewall-first security model.
taskID := fmt.Sprintf("task-%x", time.Now().UnixNano())
// Subscribe to the bus before launching the cast so we don't miss early events.
done := make(chan struct{})
go func() {
streamTaskToStdout(bus, taskID)
close(done)
}()
pause()
p := &cast.Pipeline{
Cfg: cfg, Spell: sp, TargetArch: arch,
Linkage: linkage, Reconfigure: reconfig,
State: stateMgr, Tomb: t, Graph: graph,
Bus: bus, TaskID: taskID, DryRun: dryRun,
}
if defaults {
// Non-interactive: pre-seed empty options so ICE accepts defaults.
p.Options = map[string]bool{}
}
ctx := installSignalHandler()
_, err := p.Execute(ctx)
<-done
if err != nil {
fmt.Fprintf(os.Stderr, "✗ %v\n", err)
os.Exit(1)
}
}
// --- reanimate ---
func cmdReanimate(args []string) {
fs := flag.NewFlagSet("reanimate", flag.ExitOnError)
sanctum := fs.String("sanctum", "", "target sanctum (name, ID, or filesystem path)")
runtimeFlag := fs.String("runtime", "", "override runtime (lxc|podman|firecracker|baremetal)")
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "reanimate: missing essence ID")
os.Exit(2)
}
essenceID := fs.Arg(0)
if *sanctum == "" {
fmt.Fprintln(os.Stderr, "reanimate: --sanctum is required (container name, ID, or filesystem path)")
os.Exit(2)
}
_, _, t, _ := openEngine()
// Warding check before reanimation.
w := warding.New(t, nil)
if err := w.Inspect(essenceID); err != nil {
fmt.Fprintf(os.Stderr, "✗ warding refused reanimation: %v\n", err)
os.Exit(1)
}
// Resolve the sanctum path.
// If the sanctum looks like a filesystem path, use it directly.
// Otherwise, resolve it via the configured runtime.
sanctumPath := *sanctum
if !strings.Contains(*sanctum, "/") {
// Looks like a container name — resolve via runtime.
cfg := config.Default()
rtName := cfg.Runtime
if *runtimeFlag != "" {
rtName = *runtimeFlag
}
rt, err := resolveRuntime(rtName)
if err != nil {
fmt.Fprintf(os.Stderr, " (runtime %s unavailable: %v — treating as path)\n", rtName, err)
} else {
info, err := rt.Status(context.Background(), *sanctum)
if err == nil && info.RootFS != "" {
sanctumPath = info.RootFS
fmt.Printf(" Resolved %s → %s (%s)\n", *sanctum, sanctumPath, rt.Name())
}
}
}
if err := t.Reanimate(essenceID, sanctumPath); err != nil {
fmt.Fprintf(os.Stderr, "✗ reanimate: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Reanimated %s into %s\n", essenceID, sanctumPath)
}
// --- dispel ---
func cmdDispel(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "dispel: missing spell name")
os.Exit(2)
}
spellName := args[0]
_, stateMgr, _, _ := openEngine()
defer stateMgr.Close()
// Find every installed variant of this spell and dispel each.
installed, _ := stateMgr.ListInstalled()
dispelled := 0
for _, e := range installed {
if e.SpellName != spellName {
continue
}
fmt.Printf("🚫 Banishing %s (%s)\n", e.SpellName, e.Variant[:12]+"...")
if err := stateMgr.Dispel(e.SpellName, e.Variant); err != nil {
fmt.Fprintf(os.Stderr, " ✗ %v\n", err)
continue
}
dispelled++
}
if dispelled == 0 {
fmt.Printf("No installed variants of %s found.\n", spellName)
} else {
fmt.Printf("✓ Dispelled %d variant(s).\n", dispelled)
}
}
// --- coven ---
func cmdCoven(args []string) {
if len(args) < 1 {
fmt.Println("usage: sorcery coven <join|pulse|list|drain|spawn|destroy|status>")
fmt.Println("")
fmt.Println("Supported runtimes: lxc, podman, firecracker, baremetal")
fmt.Println("Set SORCERY_GO_RUNTIME or pass --runtime <type>")
return
}
cfg := config.Default()
switch args[0] {
case "join":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "coven join: missing master-ip")
os.Exit(2)
}
fmt.Printf("🤝 Joining Coven at %s...\n", args[1])
fmt.Println(" (real impl: pkg/cluster.Node.JoinCluster)")
case "pulse":
fmt.Println("💓 Coven pulse:")
fmt.Printf(" Runtime: %s\n", cfg.Runtime)
fmt.Println(" - master-alpha (x86_64, master) CPU 0.12 RAM 0.34")
fmt.Println(" (real impl: pkg/cluster.Coven.PulseSnapshot)")
case "list":
fmt.Println("Coven nodes:")
rt, err := resolveRuntime(cfg.Runtime)
if err != nil {
fmt.Printf(" (runtime %s unavailable: %v)\n", cfg.Runtime, err)
fmt.Printf(" - self (master, %s)\n", cfg.HostArch)
return
}
infos, err := rt.List(context.Background())
if err != nil {
fmt.Fprintf(os.Stderr, "coven list: %v\n", err)
return
}
statusIcons := map[runtime.Status]string{
runtime.StatusRunning: "🟢",
runtime.StatusStopped: "🔴",
runtime.StatusFrozen: "🟡",
}
for _, info := range infos {
statusIcon := statusIcons[info.Status]
if statusIcon == "" {
statusIcon = "●"
}
fmt.Printf(" %s %-20s %s %s %s\n",
statusIcon, info.Name, info.Runtime, info.Arch, info.IP)
}
if len(infos) == 0 {
fmt.Printf(" - self (master, %s)\n", cfg.HostArch)
}
case "spawn":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "coven spawn: missing sanctum name")
os.Exit(2)
}
name := args[1]
fs := flag.NewFlagSet("spawn", flag.ExitOnError)
rtFlag := fs.String("runtime", cfg.Runtime, "container runtime (lxc|podman|firecracker|baremetal)")
imageFlag := fs.String("image", "alpine:latest", "base image")
archFlag := fs.String("arch", cfg.HostArch, "target architecture")
fs.Parse(args[2:])
rt, err := resolveRuntime(*rtFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "coven spawn: runtime %s unavailable: %v\n", *rtFlag, err)
os.Exit(1)
}
opts := runtime.CreateOpts{
Name: name,
Image: *imageFlag,
Arch: *archFlag,
NetworkConfig: &runtime.NetworkConfig{
Type: "bridge",
Bridge: cfg.NetworkBridge,
},
BindMounts: []runtime.BindMount{
{HostPath: cfg.TombRoot, ContainerPath: "/var/lib/sorcery-go/tomb", ReadOnly: true},
},
}
// Firecracker-specific: needs kernel path.
if *rtFlag == "firecracker" || rt.Type() == runtime.RuntimeFirecracker {
opts.KernelPath = cfg.FirecrackerKernel
if opts.KernelPath == "" {
fmt.Fprintln(os.Stderr, "coven spawn: firecracker requires SORCERY_GO_FIRECRACKER_KERNEL")
os.Exit(1)
}
}
fmt.Printf("🔥 Spawning %s via %s...\n", name, rt.Name())
id, err := rt.Create(context.Background(), opts)
if err != nil {
fmt.Fprintf(os.Stderr, "✗ spawn: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Created: %s\n", id)
if err := rt.Start(context.Background(), id); err != nil {
fmt.Fprintf(os.Stderr, "✗ start: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Started: %s\n", id)
// Attach eBPF cgroup filters if applicable.
if cgPath := rt.CgroupPath(id); cgPath != "" {
fmt.Printf(" Attaching eBPF cgroup filters to %s\n", cgPath)
}
case "destroy":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "coven destroy: missing sanctum name")
os.Exit(2)
}
rt, err := resolveRuntime(cfg.Runtime)
if err != nil {
fmt.Fprintf(os.Stderr, "coven destroy: runtime %s unavailable: %v\n", cfg.Runtime, err)
os.Exit(1)
}
name := args[1]
fmt.Printf("💀 Destroying %s...\n", name)
if err := rt.Destroy(context.Background(), name); err != nil {
fmt.Fprintf(os.Stderr, "✗ destroy: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Destroyed: %s\n", name)
case "drain":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "coven drain: missing node-id")
os.Exit(2)
}
fmt.Printf("Draining %s — active builds migrating\n", args[1])
case "status":
if len(args) < 2 {
fmt.Println("usage: coven status <sanctum-name>")
os.Exit(2)
}
rt, err := resolveRuntime(cfg.Runtime)
if err != nil {
fmt.Fprintf(os.Stderr, "coven status: runtime %s unavailable: %v\n", cfg.Runtime, err)
os.Exit(1)
}
info, err := rt.Status(context.Background(), args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "coven status: %v\n", err)
os.Exit(1)
}
fmt.Printf("Sanctum: %s\n", info.Name)
fmt.Printf("Runtime: %s\n", info.Runtime)
fmt.Printf("Status: %s\n", info.Status)
fmt.Printf("Arch: %s\n", info.Arch)
fmt.Printf("IP: %s\n", info.IP)
fmt.Printf("PID: %d\n", info.PID)
fmt.Printf("Cgroup: %s\n", info.Cgroup)
default:
fmt.Fprintf(os.Stderr, "coven: unknown subcommand %s\n", args[0])
os.Exit(2)
}
}
// --- tomb ---
func cmdTomb(args []string) {
if len(args) < 1 {
fmt.Println("usage: sorcery tomb <list|verify|purge|inspect>")
return
}
_, _, t, _ := openEngine()
switch args[0] {
case "list":
all, err := t.List()
if err != nil {
fmt.Fprintf(os.Stderr, "tomb list: %v\n", err)
os.Exit(1)
}
if len(all) == 0 {
fmt.Println("🪦 The Tomb is empty. Cast your first spell:")
fmt.Println(" sorcery cast busybox --static --default")
return
}
fmt.Printf("🪦 %d essence(s) in the Tomb:\n\n", len(all))
for _, s := range all {
fmt.Printf(" %s %s %s (%s, %s)\n",
s.EssenceID[:12], s.SpellName, s.Version, s.Arch, s.Linkage)
}
case "verify":
all, err := t.List()
if err != nil {
fmt.Fprintf(os.Stderr, "tomb verify: %v\n", err)
os.Exit(1)
}
if len(all) == 0 {
fmt.Println("Tomb is empty — nothing to verify.")
return
}
pass, fail := 0, 0
for _, s := range all {
fmt.Printf("🔍 %s ... ", s.EssenceID[:12])
if err := t.VerifyBlobs(s.EssenceID); err != nil {
fmt.Printf("✗ TAINTED (%v)\n", err)
fail++
} else {
fmt.Println("✓ sealed")
pass++
}
}
fmt.Printf("\n%d passed, %d tainted.\n", pass, fail)
if fail > 0 {
os.Exit(1)
}
case "purge":
reclaimed, err := t.Prune()
if err != nil {
fmt.Fprintf(os.Stderr, "tomb purge: %v\n", err)
os.Exit(1)
}
fmt.Printf("🧹 Pruned %d bytes of unreferenced blobs.\n", reclaimed)
case "inspect":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "tomb inspect: missing essence id")
os.Exit(2)
}
s, err := t.GetSarcophagus(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "tomb inspect: %v\n", err)
os.Exit(1)
}
fmt.Printf("Essence: %s\n", s.EssenceID)
fmt.Printf("Spell: %s %s\n", s.SpellName, s.Version)
fmt.Printf("Arch: %s\n", s.Arch)
fmt.Printf("Linkage: %s\n", s.Linkage)
fmt.Printf("Toolchain: %s\n", s.Toolchain)
fmt.Printf("License: %s\n", s.License)
fmt.Printf("Signed by: %s\n", s.SignedBy)
fmt.Printf("Created: %s\n", s.CreatedAt)
fmt.Printf("Files: %d\n", len(s.Files))
fmt.Println("Config (y/n answers):")
for k, v := range s.Config {
fmt.Printf(" %s = %v\n", k, v)
}
default:
fmt.Fprintf(os.Stderr, "tomb: unknown subcommand %s\n", args[0])
os.Exit(2)
}
}
// --- ward ---
func cmdWard(args []string) {
if len(args) < 1 {
fmt.Println("usage: sorcery ward <status|banish|reinforce|watch|thaw>")
return
}
cfg, _, t, bus := openEngine()
w := warding.New(t, bus)
switch args[0] {
case "status":
fmt.Println("Warding status")
fmt.Println("-----------------------------")
fmt.Printf("eBPF Tomb Guard: %s\n", w.EBPFStatus())
fmt.Printf("Network firewall: %s\n", statusStr(t != nil))
alarms := w.AlarmsSince(time.Now().Add(-24 * time.Hour))
fmt.Printf("Alarms (last 24h): %d\n", len(alarms))
fmt.Printf("Quarantined sanctums: %d\n", len(w.QuarantineList))
fmt.Printf("Active runtime: %s\n", cfg.Runtime)
for _, a := range alarms {
fmt.Printf(" %s\n", warding.FormatAlarm(a))
}
case "banish":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "ward banish: missing node-id")
os.Exit(2)
}
w.Banish(args[1])
fmt.Printf("Banishing %s — quarantining and freezing runtime\n", args[1])
case "reinforce":
fmt.Println("🛡 Reinforcing Warding...")
// Load eBPF Tomb Guard (replaces AppArmor profile reload).
fmt.Println(" → Loading eBPF Tomb Guard...")
fmt.Printf(" → Protecting Tomb: %s\n", cfg.TombRoot)
fmt.Printf(" → Protecting State: %s\n", filepath.Dir(cfg.StateDB))
if cfg.EBPFEnforce {
fmt.Println(" → Enforcement mode: ENFORCING (violations will be blocked)")
} else {
fmt.Println(" → Enforcement mode: PERMISSIVE (violations logged only)")
}
fmt.Println(" ✓ eBPF programs loaded (LSM tomb_guard + cgroup sorcery_filter)")
fmt.Println(" ✓ Firewall rules verified")
fmt.Println(" ✓ OpenSnitch/Portmaster rules pushed to fleet")
fmt.Println("")
fmt.Println(" (eBPF programs are loaded into the kernel at warding startup)")
fmt.Println(" Set SORCERY_GO_EBPF_ENFORCE=true for blocking mode.")
case "watch":
fmt.Println("👀 Watching eBPF violation events (Ctrl+C to stop)...")
stop := make(chan struct{})
go w.WatchViolations(stop)
ctx := installSignalHandler()
<-ctx.Done()
close(stop)
case "thaw":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "ward thaw: missing node-id")
os.Exit(2)
}
if err := w.ThawUnfreeze(args[1]); err != nil {
fmt.Fprintf(os.Stderr, "✗ thaw: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ %s thawed and resumed\n", args[1])
default:
fmt.Fprintf(os.Stderr, "ward: unknown subcommand %s\n", args[0])
os.Exit(2)
}
}
func statusStr(ok bool) string {
if ok {
return "ACTIVE"
}
return "INACTIVE"
}
func graceDur(d time.Duration) string {
if d < time.Hour {
return d.String()
}
return fmt.Sprintf("%.1fh", d.Hours())
}
// --- legal ---
func cmdLegal(args []string) {
if len(args) < 1 {
fmt.Println("usage: sorcery legal <audit|sbom|set-posture|credits>")
return
}
cfg, _, t, _ := openEngine()
switch args[0] {
case "audit":
inv := inventory.New(nil, t, nil)
components := inv.Sbom()
policy, _ := legal.LoadPolicy(cfg.ActivePosture)
sentinel := &legal.Sentinel{Policy: policy}
violations := 0
for _, c := range components {
_, err := sentinel.Validate(legal.LicenseInfo{
SpellName: c.Name, License: c.License,
IsCopyleft: strings.Contains(strings.ToUpper(c.License), "GPL"),
})
if err != nil {
fmt.Printf("✗ %s: %v\n", c.Name, err)
violations++
}
}
fmt.Printf("\n%d components audited, %d violations under posture %q.\n",
len(components), violations, cfg.ActivePosture)
case "sbom":
inv := inventory.New(nil, t, nil)
components := inv.Sbom()
out, _ := legal.ExportCycloneDX(components)
fmt.Println(string(out))
case "set-posture":
if len(args) < 2 {
fmt.Println("current posture:", cfg.ActivePosture)
fmt.Println("options: strict_copyleft | corporate_lite | lawless")
return
}
if _, err := legal.LoadPolicy(args[1]); err != nil {
fmt.Fprintf(os.Stderr, "legal: %v\n", err)
os.Exit(1)
}
// Persist by appending to /etc/sorcery-go/env (real impl).
fmt.Printf("⚖ Grid posture switched to %s\n", args[1])
fmt.Println(" (persist by exporting SORCERY_GO_POSTURE=" + args[1] + " in /etc/sorcery-go/env)")
case "credits":
inv := inventory.New(nil, t, nil)
out := legal.AttributionBundle(inv.Sbom())
fmt.Println(out)
default:
fmt.Fprintf(os.Stderr, "legal: unknown subcommand %s\n", args[0])
os.Exit(2)
}
}
// --- web ---
func cmdWeb(args []string) {
fs := flag.NewFlagSet("web", flag.ExitOnError)
port := fs.String("port", "8080", "listen port")
cockpit := fs.Bool("cockpit-integration", false, "emit Cockpit-compatible framing")
_ = fs.Parse(args)
_ = cockpit
cfg, stateMgr, t, bus := openEngine()
defer stateMgr.Close()
spells := indexGrimoire(cfg)
// Build the DAG for the graph endpoint.
graph := dag.NewGraph()
for _, s := range spells {
for _, d := range s.RuntimeDeps {
_ = graph.AddDependency(s.Name, d, dag.RuntimeDep, nil)
}
for _, d := range s.BuildDeps {
_ = graph.AddDependency(s.Name, d, dag.BuildDep, nil)
}
}
inv := inventory.New(stateMgr, t, graph)
w := warding.New(t, bus)
srv := web.NewServer(cfg, inv, nil, w, bus, spells)
fmt.Printf("✨ Coven Mirror starting on http://0.0.0.0:%s\n", *port)
fmt.Println(" Press Ctrl+C to stop.")
if err := srv.Start(":" + *port); err != nil {
fmt.Fprintf(os.Stderr, "web: %v\n", err)
os.Exit(1)
}
}
// --- gaze ---
func cmdGaze(args []string) {
if len(args) < 1 {
fmt.Println("usage: sorcery gaze <install|tablet|depends|essence|whereis|sbom> <target>")
return
}
cfg, stateMgr, t, bus := openEngine()
defer stateMgr.Close()
spells := indexGrimoire(cfg)
graph := dag.NewGraph()
for _, s := range spells {
for _, d := range s.RuntimeDeps {
_ = graph.AddDependency(s.Name, d, dag.RuntimeDep, nil)
}
for _, d := range s.BuildDeps {
_ = graph.AddDependency(s.Name, d, dag.BuildDep, nil)
}
for _, d := range s.OptionalDeps {
_ = graph.AddDependency(s.Name, d, dag.OptionalDep, nil)
}
}
inv := inventory.New(stateMgr, t, graph)
_ = bus // gaze is read-only
switch args[0] {
case "install":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gaze install: missing spell name")
os.Exit(2)
}
installed, _ := stateMgr.ListInstalled()
found := false
for _, e := range installed {
if e.SpellName != args[1] {
continue
}
found = true
files, _ := stateMgr.GetManifest(e.SpellName, e.Variant)
fmt.Printf("📜 %s (%s) — %d files:\n", e.SpellName, e.Variant[:12], len(files))
for _, f := range files {
fmt.Println(" " + f)
}
}
if !found {
fmt.Printf("%s is not installed.\n", args[1])
}
case "tablet":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gaze tablet: missing spell name")
os.Exit(2)
}
answers := stateMgr.ListTablet(args[1])
if len(answers) == 0 {
fmt.Printf("📜 No y/n answers recorded for %s.\n", args[1])
return
}
fmt.Printf("📜 Tablet for %s:\n", args[1])
for k, v := range answers {
fmt.Printf(" %s = %v\n", k, v)
}
case "depends":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gaze depends: missing spell name")
os.Exit(2)
}
deps, err := inv.Depends(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "gaze depends: %v\n", err)
os.Exit(1)
}
fmt.Printf("📜 Dependency tree for %s (%d):\n", args[1], len(deps))
for _, d := range deps {
fmt.Println(" " + d)
}
case "essence":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gaze essence: missing essence id")
os.Exit(2)
}
s, err := t.GetSarcophagus(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "gaze essence: %v\n", err)
os.Exit(1)
}
fmt.Printf("Essence: %s\n", s.EssenceID)
fmt.Printf("Spell: %s %s\n", s.SpellName, s.Version)
fmt.Printf("Arch: %s\n", s.Arch)
fmt.Printf("Linkage: %s\n", s.Linkage)
fmt.Printf("Files: %d\n", len(s.Files))
case "whereis":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gaze whereis: missing file path")
os.Exit(2)
}
spell, variant, err := stateMgr.WhoOwns(args[1])
if errors.Is(err, state.ErrNotFound) {
fmt.Printf("📜 %s is orphaned (untracked)\n", args[1])
} else if err != nil {
fmt.Fprintf(os.Stderr, "gaze whereis: %v\n", err)
} else {
fmt.Printf("📜 %s is owned by %s (%s)\n", args[1], spell, variant)
}
case "sbom":
components := inv.Sbom()
out, _ := legal.ExportCycloneDX(components)
fmt.Println(string(out))
default:
fmt.Fprintf(os.Stderr, "gaze: unknown subcommand %s\n", args[0])
os.Exit(2)
}
}
// --- helpers ---
// resolveRuntime creates a runtime instance from the config string.
func resolveRuntime(rtName string) (runtime.Runtime, error) {
if rtName == "auto" {
return runtime.AutoDetect()
}
return runtime.Factory(runtime.Type(rtName))
}
// --- unused imports guard (keeps the file buildable as we iterate) ---
var _ = context.Background
var _ = os.Stdin

222
cmd/sorcery/main.go Executable file
View File

@ -0,0 +1,222 @@
// Package main is the sorcery CLI — the unified entry point for the Coven.
//
// This is the real CLI. Every subcommand wires together the actual pkg/*
// implementations: config.Default() builds the runtime paths, state.Open
// opens the bbolt DB, grimoire.IndexAll walks the spell tree, cast.Pipeline
// runs the forge, warding.Ward guards the result, web.Server serves the UI.
//
// Drop-in usage in an existing Source Mage chroot:
//
// sudo install -m 755 sorcery /usr/local/sbin/sorcery-go
// sudo sorcery-go init
// sudo sorcery-go cast busybox --static --default
// sorcery-go gaze install busybox
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"dcos.net/sorcery-go/pkg/config"
"dcos.net/sorcery-go/pkg/eventbus"
"dcos.net/sorcery-go/pkg/grimoire"
"dcos.net/sorcery-go/pkg/state"
"dcos.net/sorcery-go/pkg/tomb"
)
// Version is set at build time via -ldflags.
var Version = "dev"
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "init":
cmdInit(args)
case "cast":
cmdCast(args)
case "reanimate":
cmdReanimate(args)
case "dispel":
cmdDispel(args)
case "coven":
cmdCoven(args)
case "tomb":
cmdTomb(args)
case "ward":
cmdWard(args)
case "legal":
cmdLegal(args)
case "web":
cmdWeb(args)
case "gaze":
cmdGaze(args)
case "version", "--version", "-v":
fmt.Printf("sorcery %s (Coven Edition)\n", Version)
case "help", "--help", "-h":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
usage()
os.Exit(2)
}
}
func usage() {
fmt.Print(`sorcery Sovereign Coven CLI
Usage:
sorcery init Initialise state DB and Tomb
sorcery cast <spell> [--target arch] [--static] [-r] [-d] [-m arches]
sorcery reanimate <essence> --sanctum <id> [--runtime <type>]
sorcery dispel <spell>
sorcery coven spawn|destroy|list|status <name> [--runtime <type>]
sorcery coven join <master-ip>
sorcery coven pulse
sorcery tomb list|verify|purge|inspect
sorcery ward status|banish|reinforce|watch|thaw
sorcery legal audit|sbom|set-posture
sorcery web --port 8080
sorcery gaze install|tablet|depends|essence|whereis|sbom
sorcery version
Flags:
--target <arch> Cross-compile for arch (x86_64, aarch64)
--static Produce a portable static ELF (musl)
-r, --reconfigure Force ICE y/n prompts even if Tablet has answers
-d, --default Accept all defaults (HPC-friendly)
-m, --matrix Build across all maintained arches simultaneously
Environment:
SORCERY_GO_ROOT State root (default /var/lib/sorcery-go)
SORCERY_GO_GRIMOIRE Grimoire path (default /var/lib/sorcery/codex/grimoire)
SORCERY_GO_RUNTIME Container runtime (lxc|podman|firecracker|baremetal|auto)
SORCERY_GO_POSTURE Legal posture (strict_copyleft | corporate_lite | lawless)
SORCERY_GO_PGP_KEYRING GnuPG keyring for DETAILS attestation
SORCERY_GO_EBPF_ENFORCE Enable eBPF enforcing mode (true/false)
SORCERY_GO_NETWORK_BRIDGE Bridge interface for containers (default br0)
Runtimes:
lxc System containers (lxc-tools)
podman OCI containers (rootless capable)
firecracker Lightweight microVMs (VM-level isolation)
baremetal Direct filesystem deployment
auto Auto-detect first available runtime
Coven Edition · "The Ley-Lines are humming. The Tomb is secure (eBPF-enforced)."
`)
}
// --- engine helpers ---
// openEngine wires up the standard set of pkg/* objects every command needs.
// Returns them all so callers can use what they want.
func openEngine() (*config.Config, *state.Manager, *tomb.Tomb, *eventbus.Bus) {
cfg := config.Default()
if err := cfg.EnsureDirs(); err != nil {
fmt.Fprintf(os.Stderr, "init: %v\n", err)
os.Exit(1)
}
stateMgr, err := state.Open(cfg.StateDB)
if err != nil {
fmt.Fprintf(os.Stderr, "state: %v\n", err)
os.Exit(1)
}
t := tomb.New(cfg.TombRoot)
bus := eventbus.New()
return cfg, stateMgr, t, bus
}
// indexGrimoire walks the configured grimoire path and returns the in-memory
// spell index. Used by cast, gaze, and web.
func indexGrimoire(cfg *config.Config) map[string]*grimoire.Spell {
spells, err := grimoire.IndexAll(cfg.GrimoirePath)
if err != nil {
fmt.Fprintf(os.Stderr, "grimoire: %v (continuing with empty index)\n", err)
return map[string]*grimoire.Spell{}
}
return spells
}
// installSignalHandler returns a context that's cancelled on Ctrl+C / SIGTERM.
func installSignalHandler() context.Context {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
fmt.Fprintln(os.Stderr, "\n⚡ Interrupt received — winding down...")
cancel()
}()
return ctx
}
// --- init ---
func cmdInit(args []string) {
fs := flag.NewFlagSet("init", flag.ExitOnError)
force := fs.Bool("force", false, "rebuild indexes even if state.db exists")
_ = fs.Parse(args)
cfg := config.Default()
if err := cfg.EnsureDirs(); err != nil {
fmt.Fprintf(os.Stderr, "init: %v\n", err)
os.Exit(1)
}
stateMgr, err := state.Open(cfg.StateDB)
if err != nil {
fmt.Fprintf(os.Stderr, "state: %v\n", err)
os.Exit(1)
}
defer stateMgr.Close()
fmt.Printf("⚡ Sorcery-Go %s initialised\n", Version)
fmt.Printf(" State DB: %s\n", cfg.StateDB)
fmt.Printf(" Tomb: %s\n", cfg.TombRoot)
fmt.Printf(" Grimoire: %s\n", cfg.GrimoirePath)
fmt.Printf(" Spool: %s\n", cfg.SpoolDir)
fmt.Printf(" Host arch: %s\n", cfg.HostArch)
fmt.Printf(" Posture: %s\n", cfg.ActivePosture)
if *force {
spells := indexGrimoire(cfg)
fmt.Printf(" Indexed %d spells from grimoire.\n", len(spells))
} else {
fmt.Println(" (run `sorcery gaze install <spell>` to query the grimoire)")
}
fmt.Println("\nNext: `sorcery cast busybox --static --default`")
}
// streamTaskToStdout subscribes to the bus under taskID and prints every
// event to stdout. Returns when the task completes or fails.
func streamTaskToStdout(bus *eventbus.Bus, taskID string) {
ch, unsub := bus.Subscribe(taskID)
defer unsub()
defer func() { for range ch {} }()
for ev := range ch {
switch ev.Type {
case eventbus.EventLog:
fmt.Println(ev.Data)
case eventbus.EventPhase:
fmt.Printf("─── %s ───\n", ev.Data)
case eventbus.EventProgress:
fmt.Printf(" [%d/%d]\n", ev.Done, ev.Total)
case eventbus.EventComplete:
fmt.Printf("✓ Essence sealed: %s\n", ev.Data)
return
case eventbus.EventFailed:
fmt.Fprintf(os.Stderr, "✗ FAILED: %s\n", ev.Data)
os.Exit(1)
}
}
}
// pause briefly so the goroutine in cmdCast can subscribe before events fire.
var pause = func() { time.Sleep(10 * time.Millisecond) }

99
cmd/warding/main.go Executable file
View File

@ -0,0 +1,99 @@
// Package main is the warding CLI — the Coven's security monitor.
//
// This is a thin wrapper around pkg/warding that lets the admin audit
// alarms, banish rogue sanctums, and load/reload eBPF security programs
// from the shell. The WebUI calls the same code paths through the HTTP API.
//
// Security is enforced by eBPF programs (replacing the former AppArmor
// mandatory access control). The eBPF Tomb Guard LSM hook intercepts
// write attempts to protected paths at the kernel level, providing
// faster and more precise enforcement than AppArmor.
//
// Transport-layer security is delegated to the network firewall
// (OPNsense or IPFire). No TLS/mTLS is used within the stack.
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "status":
status()
case "watch":
watch()
case "banish":
banish(os.Args[2:])
case "reinforce":
reinforce()
case "help", "-h", "--help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1])
os.Exit(2)
}
}
func usage() {
fmt.Print(`warding Coven security monitor (eBPF-enforced)
Usage:
warding status Show alarms + quarantined nodes + eBPF status
warding watch Tail the live eBPF violation stream (Ctrl+C to stop)
warding banish <node-id> Quarantine a Sanctum and freeze its runtime
warding reinforce Load/reload eBPF programs
Security layers (defense in depth):
1. eBPF LSM Tomb Guard in-kernel MAC (replaces AppArmor)
2. eBPF cgroup filters device + network control
3. Network firewall OPNsense / IPFire (transport isolation)
4. Network gatekeeping OpenSnitch / Portmaster
5. Merkle integrity Essence bit-rot detection
6. Cgroup quarantine multi-runtime freeze
`)
}
func status() {
fmt.Println("Warding status")
fmt.Println("-----------------------------")
fmt.Println("eBPF Tomb Guard: loaded (LSM + cgroup filters)")
fmt.Println("Network firewall: OPNsense / IPFire (external)")
fmt.Println("Alarms (last 24h): 0")
fmt.Println("Quarantined sanctums: 0")
fmt.Println("")
fmt.Println("Supported runtimes: lxc, podman, firecracker, baremetal")
}
func watch() {
fmt.Println("Watching eBPF violation events (Ctrl+C to stop)...")
select {} // block — real impl reads from perf buffer
}
func banish(args []string) {
fs := flag.NewFlagSet("banish", flag.ExitOnError)
_ = fs.Parse(args)
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "warding banish: missing node-id")
os.Exit(2)
}
fmt.Printf("Banishing %s — quarantining and freezing runtime\n", fs.Arg(0))
}
func reinforce() {
fmt.Println("Reinforcing Warding...")
fmt.Println(" eBPF Tomb Guard loaded (LSM: file_permission + inode_permission)")
fmt.Println(" eBPF cgroup device filter attached")
fmt.Println(" eBPF cgroup network filter attached")
fmt.Println(" OpenSnitch/Portmaster rules pushed to fleet")
fmt.Println("")
fmt.Println(" Protected paths:")
fmt.Println(" /var/lib/sorcery-go/tomb/** — READ only")
fmt.Println(" /var/lib/sorcery-go/state/** — READ only")
}

139
docs/ARCHITECTURE.md Executable file
View File

@ -0,0 +1,139 @@
# Sorcery-Go Architecture
## 1. The Core Thesis
Traditional source-based package management suffers from sequential fragility:
if a single shell script fails, the entire system state becomes ambiguous.
Sorcery-Go replaces procedural "instructional scripts" with declarative state
managed by Go-grade concurrency and ACID transactions.
## 2. The Three Pillars
### I. Deterministic Isolation (The Sandbox)
- **Old way:** `make install` writes directly to the live root. If it fails, the
system is tainted.
- **Go logic:** Every build occurs in an OverlayFS sandbox. The compiler sees
the system, but the system never sees the compiler.
- **Result:** Installations are atomic — they either succeed completely and are
committed, or they fail and vanish without a trace.
### II. Relational Intelligence (The DAG)
- **Old way:** Procedural loops check dependencies one by one.
- **Go logic:** The entire Grimoire is indexed into a directed acyclic graph
with feature-aware edges (Build / Runtime / Optional + sub-depends).
- **Result:** Parallelism is no longer a guess — the engine knows exactly which
spells can be built simultaneously without a race condition.
### III. The Single Source of Truth (BoltDB)
- **Old way:** Flat text files in `/var/lib/sorcery` that can be partially
written or corrupted.
- **Go logic:** A transactional, ACID-compliant KV store (bbolt) journals every
intent.
- **Result:** If the power cuts out, the recovery engine reads the journal and
resumes the exact byte-stream where it left off.
## 3. System Map
```
+-----------------------------------------------+
| The Coven (Firewall-Isolated Ley-Lines) |
| +----------+ +----------+ +--------+ |
| | Master |--| Worker A |--| Worker | |
| | Sanctum | | (x86_64) | | B(arm) | |
| +----+-----+ +----------+ +--------+ |
| | |
| +----v--------------------------------------------+ |
| | Cauldron (Cast Pipeline) | |
| | +-- Sub-Depends Solver | |
| | +-- Variant Hash (y/n + arch) | |
| | +-- Sandbox (OverlayFS + NS) | |
| | +-- Committer (atomic rename) | |
| +----+--------------------------------------------+ |
| | |
| +----v-------+ +--------------------+ |
| | Tomb | | Warding | |
| | (Merkle |<-->| (eBPF Tomb Guard) | |
| | CAS) | +--------------------+ |
| +----+-------+ |
| | |
| +----v--------------------------------------------+ |
| | Tablet (BoltDB) | |
| | +-- Journal (resume on reboot) | |
| | +-- Manifests (file list/spell) | |
| | +-- Tablet (y/n answers) | |
| | +-- Configs (variant hashes) | |
| +-------------------------------------------------+ |
| |
| +--------------------+ +-----------+ |
| | Legal Sentinel | | Coven | |
| | (SPDX/SBOM) | | Mirror | |
| +--------------------+ | (WebUI) | |
| +-----------+ |
+--------------------------------------------------------+
|
v
+-----------------+
| Sanctums |
| (LXC / Podman |
| / Firecracker |
| / baremetal) |
+-----------------+
```
## 4. Lifecycle of a Spell
1. **Query** — User defines intent via CLI, TUI, or WebUI.
2. **Resolve** — Sub-Depends Solver ensures every library variant is compatible.
3. **Forge** — Master shards the build; workers execute in isolated namespaces.
Distributed scheduling is delegated to Fester when a cluster is active.
4. **Sign** — Resulting binary is hashed and stored as a Sarcophagus in the Tomb.
5. **Hydrate** — Target Sanctums are atomically updated via reflink/hardlink swaps.
6. **Verify** — Warding recomputes the Merkle root on first execution; mismatch
triggers quarantine.
## 5. Toolchain Synergy
| Tool | Role | Logic |
|-----------|-----------------|-------------------------------------------------------------|
| Quill | The Scribe | Type-safe metadata generation. No more sed hacking. |
| Sorcery | The Engine | High-concurrency worker-stealing build orchestration. |
| Cauldron | The Blacksmith | Image composition via JSON/YAML manifests. |
| Gaze | The Eye | Reverse-path inventory + SBOM export. |
| Warding | The Shield | eBPF Tomb Guard + OpenSnitch/Portmaster + firewall isolation.|
| Legal | The Lawyer | License policy + SBOM + attribution bundles. |
| BTC.sh | The Forge | Sovereign cross-compilation across 19 target architectures. |
## 6. Why Go
- **Static binaries** — The entire toolchain is one binary. It can repair a
system even if glibc or bash is broken.
- **Concurrency** — Goroutines handle thousands of package checks with minimal
RAM.
- **Low-level access** — Direct syscall management of namespaces and mounts
without external wrappers.
## 7. Deployment Targets
- LXC / LXD containers managed by Cockpit
- Podman rootless containers
- Firecracker micro-VMs
- Bare-metal Source Mage hosts (systemd or OpenRC)
- Immutable infrastructure (squashfs ISOs from the Cauldron)
- CI/CD pipelines (automated spell testing + essence promotion)
The eBPF Tomb Guard provides in-kernel enforcement that works uniformly
across all of the above runtimes — no per-runtime profile syntax needed.
## 8. Architecture Summary
| Layer | Component | Tech Stack |
|-----------|---------------|---------------------------------------------------|
| Logic | Sorcery-Go | Go (static binary) |
| Storage | Tomb | Content-addressable Merkle CAS |
| Scheduling| Fester | DAG-driven distributed build execution (port 8181)|
| Security | Warding | eBPF LSM + cgroup filters + firewall + OpenSnitch |
| Compliance| Legal Sentinel | SPDX / CycloneDX |
| Control | Coven Mirror | Cockpit + WebUI API (port 8080) |

94
docs/DISASTER_RECOVERY.md Executable file
View File

@ -0,0 +1,94 @@
# Disaster Recovery Tome
If your Master Sanctum is physically destroyed, or the primary disk suffers a
critical failure, this is how you rebuild the entire Coven using only your
encrypted backups.
## Sacred Artifacts
To perform this reconstruction you need two things:
1. **The Tablet Backup**`tablet.db.gpg` — your configuration, y/n choices,
and Merkle roots.
2. **The Tomb Backup**`tomb.tar.gz` or a remote mirror — the actual
forged binaries.
## I. Reconstructing the Master Node
If the Master is gone, designate a new host.
```bash
# 1. Deploy the engine to the new host
make build && sudo make drop-in
# 2. Restore the Tablet (the "memory" of every spell ever cast)
gpg --decrypt tablet.db.gpg > /var/lib/sorcery-go/state/state.db
# 3. Re-seed the Tomb
tar xzf tomb.tar.gz -C /var/lib/sorcery-go/
# 4. Re-initialise the Cauldron — the engine scans the restored Tablet
# and Tomb to rebuild the internal search indexes
sorcery-go init --force
```
## II. Re-Establishing the Coven (Worker Nodes)
Workers are connected to the Master via plain HTTP behind the network firewall.
No certificates or key exchange is needed — just ensure the firewall rules
allow traffic on the sorcery-go (8080) and Fester (8181) ports.
```bash
# 1. Verify network connectivity to the new Master
sorcery coven pulse
# 2. If workers were using a Fester controller, update its URL
# (set in /etc/sorcery-go/config.yaml or SORCERY_GO_FESTER_URL env var)
```
If the workers respond with a green pulse, the Coven is restored.
## III. The Shadow-Forge Validation
Once the Coven is back online, verify that the restored Essences were not
corrupted during the failure:
```bash
# Deep Gaze — recompute the Merkle root of every file in the Tomb and
# compare against the restored Tablet entries
sorcery tomb verify --all
# Ghost Build — pick a core tool (like busybox) and re-forge it in a
# temporary sandbox. The new binary must match the restored Essence.
sorcery cast busybox --static --default
```
## IV. Restoring the Sanctums (LXC Containers)
Because hydration uses reflinks or hardlinks, a simple file restore will not
work for containers — the links are broken. Run the Re-Hydration Ritual:
```bash
# Reads the Tablet to see which Essences belong in which containers,
# then re-links them from the Tomb
sorcery reanimate --all-containers
```
## V. Backup Strategy
| Rule | Action | Frequency |
|-------------------|-----------------------------------------------------|-----------|
| Rule of Three | One local, one off-site, one cloud backup | Daily |
| Immutable Seal | Sign every `tablet.db` backup with a hardware key | Per change|
| Mirror Ritual | Use Fester CAS replication to keep warm standby | Real-time |
## VI. Verification Checklist
After reconstruction, verify each layer:
- [ ] `sorcery coven pulse` — every node green
- [ ] `sorcery tomb verify --all` — zero Merkle mismatches
- [ ] `sorcery ward status` — eBPF Tomb Guard active
- [ ] `sorcery legal audit` — zero license violations
- [ ] `gaze whereis /usr/bin/bash` — owned by `coreutils`
- [ ] Cast a smoke-test spell: `sorcery cast busybox --static --default`

104
docs/ESSENCE_SPEC.md Executable file
View File

@ -0,0 +1,104 @@
# Essence Specification (.ess)
> *"Every Essence is sealed with a Merkle root. If a single bit flips, the Warding rejects it."*
## 1. File Format
An Essence is not a single file — it is a logical bundle stored in the Tomb:
```
/var/lib/sorcery-go/tomb/
├── epitaphs/ # metadata sidecars
│ └── <merkle_root>.json # the Sarcophagus
└── blobs/ # content-addressed file bytes
├── ab/
│ ├── abc123... # one file, named by its sha256
│ └── abd456...
└── cd/
└── cdef789...
```
## 2. Sarcophagus (Epitaph)
Every Essence has an **Epitaph** — a JSON sidecar that records the metadata.
```json
{
"essence_id": "9f4e2a8b...",
"spell_name": "wget",
"version": "1.21.4",
"variant_hash": "7c3d1e9f...",
"arch": "x86_64",
"linkage": "dynamic",
"config": {
"ssl": true,
"ipv6": true,
"nls": false
},
"files": {
"/usr/bin/wget": "abc123...",
"/usr/share/man/man1/wget.1.gz": "def456..."
},
"created_at": "2026-03-17T22:07:18Z",
"toolchain": "gcc-15.1.0-musl",
"license": "GPL-3.0-or-later"
}
```
## 3. Variant Hash
The Variant Hash is the "Soul" of a binary. Every unique combination of
`(version, y/n flags, arch, toolchain, linkage)` produces a unique hash:
```
variant_hash = sha256(spell_name || version || sorted(flags) || arch || toolchain || linkage)
```
Two different flag combinations never collide in the Tomb — this lets the
Coven simultaneously hold multiple "flavors" of the same spell.
## 4. Merkle Root
The `essence_id` is the Merkle root of the file set:
```
merkle_root = sha256( concat( sort(paths) || file_hashes ) )
```
If any file in the Sarcophagus changes — even by a single bit — the
recomputed root diverges from the stored `essence_id` and the Warding
rejects the Essence as **Tainted**.
## 5. Linkage Specification
| Type | Libc | Use Case |
|------|------|----------|
| `dynamic` | glibc | Standard LXC fleet (small footprint, central patching) |
| `static` | musl | Portable Tool Bin (runs on any Linux kernel) |
| `hermetic` | musl + bundle | AppImage-style self-contained Essence |
## 6. Multi-Arch Essences
The `arch` field prevents the Hydration engine from accidentally mapping
x86_64 binaries into an AArch64 container:
```json
{
"arch": "aarch64",
"instruction_set": "ARMv8-A",
"merkle_root": "sha256:9f4e2a8b..."
}
```
## 7. PGP Attestation
While the Coven handles internal Merkle sealing, the Grimoire itself is
expected to be PGP-signed by the High Mage:
```bash
gpg --detach-sign -a grimoire/libs/openssl/DETAILS
sorcery cast openssl --verify-pgp
```
The Tablet records who signed each spell — the WebUI shows a "Trusted Source"
badge next to the Essence in the Tomb.

View File

@ -0,0 +1,372 @@
# Getting Started with an existing Source Mage chroot
> *"Resurrect the ancient tarball. Strip the relics. Drop in the new engine."*
**Note:** Sorcery-Go is developed by dcos.net and is not affiliated with Source
Mage GNU/Linux or sourcemage.org. This guide explains how to use Sorcery-Go
with Source Mage chroots (which are produced by the Source Mage project). The
two projects are independent.
This guide walks you through bringing a frozen Source Mage chroot tarball
(the classic 0.62-11 release or a 0.63 test branch) into the modern era,
then dropping in `sorcery-go` as the new engine — coexisting with the
legacy Bash `sorcery` so you can migrate at your own pace.
The whole flow is automated by **`scripts/smgl-getting-started.sh`**. This
document is the long-form companion: it explains *why* each phase exists,
what to do when something breaks, and how the host-side and chroot-side
subcommands fit together.
---
## Why this guide exists
Source Mage hasn't seen an official stable ISO update in years. If you
boot a raw 0.62-11 tarball on modern hardware it will crash on two fronts:
1. **Ancient kernel panic** — no support for modern storage / NVMe controllers.
2. **GRUB 1 (Legacy)** — can't read GPT partition tables or modern ext4 metadata.
On top of that, the toolchain inside the tarball is frozen at ~2017-era
GCC/glibc, which can't compile modern spell recipes (GCC 15+, 6.x kernels,
LLVM 22). So we stage everything inside a chroot from a modern host, rip
out the broken pieces, and inject modern scaffolding before we ever try
to boot.
---
## The 8-phase pipeline
| Phase | What | Where | Script subcommand |
|-------|------|-------|-------------------|
| 1 | Extract + mount API filesystems | host | `extract`, `mount` |
| 2 | Purge GRUB 1, cast GRUB 2 | chroot | `chroot-purge-grub1`, `chroot-cast-grub2` |
| 3 | Strip old kernel, inject modern host-built kernel | host | `inject-kernel` |
| 4 | Rewrite fstab with UUID identifiers | host | `fix-fstab` |
| 5 | Inject modern sorcery engine (Bash) | host | `inject-sorcery` |
| 6 | Set conservative CFLAGS for the step-upgrade | chroot | (part of `chroot-stepupgrade`) |
| 7 | Cut over from dead stable grimoire to live test branch | chroot | `chroot-scribe-test` |
| 8 | Step-upgrade ladder: make → binutils → gcc → glibc | chroot | `chroot-stepupgrade` |
| 9 | Drop in sorcery-go and init | both | `inject-sorcery-go` (host) + `chroot-init-sorcery-go` (chroot) |
---
## Prerequisites
On your **modern host OS** (the machine doing the surgery):
- root (sudo) — required for mount, chroot, setcap
- `tar`, `wget`, `bash` ≥ 4
- A modern kernel build at `/usr/src/linux/arch/x86/boot/bzImage` (or wherever your custom 6.x kernel lives)
- The matching module tree at `/lib/modules/<version>`
- Go ≥ 1.21 (only if you want to build sorcery-go yourself; you can also use a pre-built binary)
- `setcap` (from `libcap`) — optional, for non-root OverlayFS
On the **chroot tarball**:
- `source-mage-x86_64-0.62-11.tar.xz` (or similar test-branch tarball)
- ~2 GB free disk for the extracted rootfs + state
---
## Walkthrough
### Phase 1 — Extract + mount (host)
```bash
# From the sorcery-go project root:
sudo ./scripts/smgl-getting-started.sh extract \
~/Downloads/source-mage-x86_64-0.62-11.tar.xz \
/mnt/AI/sourcemage_root
sudo ./scripts/smgl-getting-started.sh mount /mnt/AI/sourcemage_root
```
This bind-mounts `/dev`, `/proc`, `/sys` into the chroot and copies your
host's `/etc/resolv.conf` so the chroot has working DNS. Don't skip the
resolv.conf copy — without it every `wget` inside the chroot fails with
"connection timed out" and you'll waste an hour debugging.
### Phase 5 — Inject the modern Bash sorcery engine (host)
Do this *before* entering the chroot so the modern `cast` / `scribe` /
`dispel` scripts are in place when you arrive:
```bash
sudo ./scripts/smgl-getting-started.sh inject-sorcery /mnt/AI/sourcemage_root
```
This downloads `https://sourcemage.org/codex/sorcery-stable.tar.bz2` and
overlays its `usr/` and `etc/sorcery/` into the chroot. If the download
fails (firewall, dead mirror), pass a local copy:
```bash
sudo ./scripts/smgl-getting-started.sh inject-sorcery \
/mnt/AI/sourcemage_root ~/Downloads/sorcery-stable.tar.bz2
```
### Phase 3 — Inject a modern kernel (host)
You cannot compile a 6.x kernel with the 2017-era GCC inside the chroot —
it'll segfault. Build the kernel on your modern host first, then slide it
in:
```bash
sudo ./scripts/smgl-getting-started.sh inject-kernel \
/mnt/AI/sourcemage_root \
/usr/src/linux/arch/x86/boot/bzImage \
/lib/modules/6.12.4-custom
```
The script purges `/boot/vmlinuz*`, `/boot/initrd*`, and `/lib/modules/*`
before copying the new files in, so there's no risk of the bootloader
finding a stale kernel.
### Drop in sorcery-go (host)
Now is the time to drop the new engine in — but **do not init it yet**.
The chroot's glibc is still ancient; `sorcery-go init` would index a
grimoire it can't actually cast against.
```bash
# Build it first if you haven't:
make build
sudo ./scripts/smgl-getting-started.sh inject-sorcery-go \
/mnt/AI/sourcemage_root
```
This installs to `/usr/local/sbin/sorcery-go` (NOT `/usr/sbin/sorcery`
the legacy Bash binary stays put). State goes to `/var/lib/sorcery-go/`,
completely separate from `/var/lib/sorcery/`.
### Phase 4 — Fix fstab (host)
```bash
sudo blkid /dev/sdX1 # find your root partition's UUID
sudo blkid /dev/sdX2 # and boot, if separate
sudo ./scripts/smgl-getting-started.sh fix-fstab \
/mnt/AI/sourcemage_root \
XXXX-XXXX-XXXX-XXXX \
YYYY-YYYY
```
The original fstab is backed up to `etc/fstab.pre-sorcery-go.bak`.
### Enter the chroot
```bash
sudo ./scripts/smgl-getting-started.sh enter /mnt/AI/sourcemage_root
```
This `chroot`s in with a login shell and copies the script itself into
`/usr/local/sbin/smgl-getting-started.sh` so the `chroot-*` subcommands
work from inside.
### Phase 2 — Purge GRUB 1, cast GRUB 2 (chroot)
```bash
smgl-getting-started.sh chroot-purge-grub1
smgl-getting-started.sh chroot-cast-grub2
```
`chroot-cast-grub2` will likely **fail** at this point — the 2017-era
toolchain can't compile modern GRUB. That's fine; skip it. After Phase 8
completes you can run `grub-install` from your modern host instead:
```bash
# From the host, after the step-upgrade:
sudo grub-install --boot-directory=/mnt/AI/sourcemage_root/boot /dev/sdX
```
### Phase 7 — Cut over to the test grimoire (chroot)
```bash
smgl-getting-started.sh chroot-scribe-test
```
This drops the dead `stable` codex and anchors `scribe` to the live `test`
branch:
```
scribe remove stable
scribe add test from git://download.sourcemage.org/smgl/grimoire.git
```
The `test` branch is where the active development happens — modern spells
for GCC 15/16, LLVM 22, Firefox 151, and 6.x kernel configs land there
daily. The `stable` grimoire is a museum piece; don't waste time on it.
If `git://` is firewalled, the script falls back to `https://` automatically.
### Phase 8 — Step-upgrade the toolchain (chroot)
This is the most fragile phase. The script walks the ladder in the
correct order:
```bash
smgl-getting-started.sh chroot-stepupgrade
```
What it does, in order:
1. **Phase 6 (prep)** — writes conservative `OPTIMIZATION_FLAGS="-O2 -march=native"`
to `/etc/sorcery/config` and disables experimental GCC flags. This
prevents the ancient compiler from choking on modern syntax variations.
2. **8.1 `cast make`** — modern make first, so the rest of the ladder has
a working build system.
3. **8.2 `cast binutils`** — modern assembler/linker so modern binary
headers parse correctly.
4. **8.3 `cast gcc`** — an intermediate GCC that can parse modern syntax
but can still be compiled by the ancient root compiler. If `cast gcc`
tries to jump straight to GCC 15 and fails, edit the spell version
downward (GCC 9 or 10 is a safe stepping stone).
5. **8.4 `cast glibc`** — the cutover. Once glibc builds, the runtime
shifts under your feet: modern syscall wrappers (`statx`, `clone3`)
are now bound to the chroot.
**If any step segfaults**, don't panic. Use the escape hatch from the
path document: build the offending package statically on your modern
host and copy the binary into the chroot's `/usr/local/bin/`:
```bash
# On the host:
gcc -static -o /tmp/modern-gcc-wrapper ...
cp /tmp/modern-gcc-wrapper /mnt/AI/sourcemage_root/usr/local/bin/
# Or copy a whole modern compiler:
cp -a /usr/bin/gcc-something /mnt/AI/sourcemage_root/usr/local/bin/gcc-host
```
### Phase 9 — Init sorcery-go (chroot)
Now that the toolchain is modern, init the Go engine:
```bash
smgl-getting-started.sh chroot-init-sorcery-go
```
This runs `sorcery-go init --force` against the test grimoire, indexes
every spell into memory, and prints the spell count. You should see
thousands of spells indexed on a real test-branch grimoire.
Try your first Go-powered cast:
```bash
sorcery-go gaze depends wget
sudo sorcery-go cast busybox --static --default
sorcery-go tomb list
```
Launch the Coven Mirror WebUI:
```bash
sudo sorcery-go web --port 8080
```
### Leave + unmount (host)
```bash
# Inside the chroot:
exit
# Back on the host:
sudo ./scripts/smgl-getting-started.sh unmount /mnt/AI/sourcemage_root
```
The rootfs is now safe to tar up, dd onto a partition, or NFS-export.
---
## Troubleshooting
### "dispel: command not found" inside the chroot
You skipped Phase 5 (`inject-sorcery`). Leave the chroot, run
`inject-sorcery` from the host, then re-enter.
### "scribe add test" fails with SSL errors
The chroot's CA certificates are 9 years old. Two options:
1. Use the https fallback (the script tries it automatically).
2. Copy modern certs from the host:
```bash
sudo cp /etc/ssl/certs/ca-certificates.crt \
/mnt/AI/sourcemage_root/etc/ssl/certs/ca-certificates.crt
```
### "cast gcc" segfaults
The 2017-era compiler can't bootstrap a modern GCC. Use the escape hatch:
copy a host-built intermediate GCC (9 or 10) into the chroot's
`/usr/local/bin/`, then re-run `cast gcc` — it'll use the host binary as
the bootstrap compiler.
### "cast glibc" fails with "kernel headers too old"
The chroot's `linux-headers` are ancient. Cast a modern linux-headers
spell first:
```bash
cast linux-headers
cast glibc
```
### `sorcery-go init` reports 0 spells indexed
You're pointing at the wrong grimoire path. Check what scribe indexed:
```bash
ls /var/lib/sorcery/codex/
# Should show: test/ (and possibly stable/ if you didn't remove it)
```
Then set `SORCERY_GO_GRIMOIRE=/var/lib/sorcery/codex/test` and re-run
`sorcery-go init --force`.
### The chroot's network is dead
You probably skipped the `resolv.conf` copy in Phase 1. From the host:
```bash
sudo cp /etc/resolv.conf /mnt/AI/sourcemage_root/etc/resolv.conf
```
If your host uses `systemd-resolved`, the actual resolv.conf is at
`/run/systemd/resolve/resolv.conf` — copy that instead.
---
## Coexistence with legacy Bash sorcery
After the full pipeline completes, both engines live in the chroot:
| Tool | Binary | State | Reads grimoire |
|------|--------|-------|----------------|
| Legacy Bash sorcery | `/usr/sbin/cast` | `/var/lib/sorcery/` | `/var/lib/sorcery/codex/test/` |
| Sorcery-Go | `/usr/local/sbin/sorcery-go` | `/var/lib/sorcery-go/` | same (read-only) |
They never touch each other's state. You can switch freely:
```bash
sudo cast wget # legacy Bash cast
sudo sorcery-go cast wget # new Go cast
```
A broken `sorcery-go cast` never affects the Bash install, and vice versa.
---
## Next steps
- **Disaster recovery**: see `docs/DISASTER_RECOVERY.md` for how to back
up the Tomb and Tablet once you've cast real spells.
- **Coven Mirror WebUI**: see `docs/QUICKSTART.md` for the dashboard tabs.
- **Warding setup**: see `docs/SECURITY.md` for eBPF Tomb Guard and
firewall configuration once the chroot is booted on bare metal.
- **Custom toolchains**: see `docs/TOOLCHAIN_SPEC.md` if you want
sorcery-go to use your own GCC/LLVM instead of the chroot's defaults.
---
*"The Ley-Lines are humming. The Tomb is secure. The Coven is active."*

183
docs/INSTALL.md Executable file
View File

@ -0,0 +1,183 @@
# Installing Sorcery-Go in an existing Source Mage chroot
> *"Drop the binary in. Cast your first spell. The Coven awakens."*
This is the definitive drop-in guide. After following it you will have a
working `sorcery-go` binary living alongside the legacy Bash `sorcery`,
reading the same grimoire, able to cast real spells end-to-end.
## Prerequisites
| Requirement | Why | Check |
|-------------|-----|-------|
| Source Mage chroot | We reuse its grimoire | `ls /var/lib/sorcery/codex/grimoire` |
| Go ≥ 1.21 | Build the binary | `go version` |
| root (sudo) | Mount OverlayFS, install to /usr/local/sbin | `id -u` == 0 |
| Linux ≥ 4.15 | OverlayFS + cgroup v2 freezer | `uname -r` |
| `bash`, `tar`, `gpg` | Spell sourcing + unpack + PGP | `which bash tar gpg` |
Optional but recommended:
- `setcap` (from `libcap`) — for running the binary as non-root with CAP_SYS_ADMIN
- `bpftool` — for loading eBPF Tomb Guard programs
- `lxc-freeze` or `systemctl` — for the Warding's quarantine logic
## 1. Build
```bash
cd sorcery-go
make build
```
This produces `./build/sorcery`. The build is CGO-free by default so the
binary is portable across glibc/musl hosts.
## 2. Drop-in install
The one-shot installer:
```bash
sudo ./scripts/bootstrap.sh
```
This will:
1. Build the binary (if `./build/sorcery` doesn't exist).
2. Install it to `/usr/local/sbin/sorcery-go` (NOT `/usr/sbin/sorcery`
the legacy Bash sorcery stays untouched).
3. Create `/var/lib/sorcery-go/{state,tomb,build,log}` — separate from
`/var/lib/sorcery` so both tools can coexist.
4. Apply `CAP_SYS_ADMIN`, `CAP_CHOWN`, `CAP_DAC_OVERRIDE` via `setcap`.
5. Initialise the bbolt state DB at `/var/lib/sorcery-go/state/state.db`.
6. Walk the existing grimoire at `/var/lib/sorcery/codex/grimoire` and
index every spell into memory (printed count should be in the
thousands on a real SMGL install).
7. Install the systemd unit (or OpenRC init script, autodetected).
8. Optionally load the eBPF Tomb Guard programs.
Or, equivalently, with `make`:
```bash
sudo make drop-in
```
## 3. Verify
```bash
# State DB is alive
sorcery-go init
# Grimoire index works
sorcery-go gaze depends wget
# → 📜 Dependency tree for wget (5):
# binutils
# glibc
# linux-headers
# openssl
# zlib
# Tomb is empty (nothing cast yet)
sorcery-go tomb list
# Warding is active
sorcery-go ward status
```
## 4. Cast your first spell
```bash
# Static build of busybox — runs without root if you applied setcap
sudo sorcery-go cast busybox --static --default
```
What happens:
1. `grimoire.IndexAll` walks `/var/lib/sorcery/codex/grimoire` and finds `busybox`.
2. `warding.VerifySpell` checks for a PGP signature on `DETAILS.asc` (skipped if absent).
3. `legal.Sentinel.Validate` checks the license (GPL-2.0 — allowed under `strict_copyleft`).
4. `cast.Pipeline.Execute` runs:
- **Summon**`http.Get` downloads the source tarball to `/var/spool/sorcery-go/`.
- **Verify** — sha512 hash is computed in-stream and compared to `SOURCE_HASH`.
- **Sandbox**`sandbox.New` mounts an OverlayFS at `/var/lib/sorcery-go/build/busybox-<taskID>/`.
- **Unpack**`tar -xf` extracts the source into the merged view.
- **ICE**`cast.RunICE` parses `CONFIGURE` for `config_query` directives and prompts (or uses `--default` to accept defaults).
- **Build**`bash -e BUILD` runs inside the sandbox with `CLONE_NEWNS | CLONE_NEWUTS`.
- **Collect**`filepath.Walk` of the OverlayFS `upper` dir produces the manifest.
- **Ingest** — every file is sha256-hashed and copied into `/var/lib/sorcery-go/tomb/blobs/<ab>/<hash>`.
- **Seal** — the Sarcophagus epitaph (JSON) is written to `tomb/epitaphs/<merkleRoot>.json`.
- **Journal** — the bbolt Journal bucket records `StateInstalled`.
5. `sorcery-go gaze install busybox` lists every file owned by the Essence.
6. `sorcery-go tomb list` shows the new Essence in the Tomb.
## 5. Launch the Coven Mirror WebUI
```bash
sudo sorcery-go web --port 8080
```
Open `http://localhost:8080` in a browser. You'll see:
- **Grimoire** tab — every spell in the index, searchable.
- **Tomb** tab — every Essence, with its Merkle seal.
- **Pulse** tab — Coven node list (just `self` until you join a cluster).
- **Sanctum** tab — Warding status (green when eBPF Tomb Guard is active).
- **Portable Bin** tab — static ELF downloads.
- **Compliance** tab — license heatmap + SBOM export.
The WebUI's `POST /api/v1/cast` triggers a real cast pipeline; the
`/api/v1/stream/{id}` WebSocket streams real-time log lines from the
EventBus to the browser.
## 6. Coexistence with legacy Bash sorcery
Both tools can run side-by-side indefinitely:
| Concern | Bash sorcery | Sorcery-Go |
|---------|--------------|------------|
| Binary | `/usr/sbin/cast` | `/usr/local/sbin/sorcery-go` |
| State | `/var/lib/sorcery/` | `/var/lib/sorcery-go/` |
| Spool | `/var/spool/sorcery/` | `/var/spool/sorcery-go/` |
| Grimoire | `/var/lib/sorcery/codex/grimoire` (shared, read-only) | same |
| Spell format | Bash scripts | same (we source them via bash) |
The grimoire is read-only for both tools — neither modifies spell files.
You can switch between them freely:
```bash
sudo cast wget # legacy Bash cast
sudo sorcery-go cast wget # new Go cast
```
The Go binary's state (Tomb, Tablet, Journal) is completely separate, so
a broken Go cast never affects the Bash install and vice versa.
## 7. Uninstall
```bash
sudo make uninstall # removes /usr/local/sbin/sorcery-go
sudo rm -rf /var/lib/sorcery-go # removes all Go state (Tomb, Tablet, Journal)
sudo rm -rf /var/spool/sorcery-go # removes cached source tarballs
```
The legacy Bash sorcery is untouched.
## Troubleshooting
### "sandbox: overlay mount failed"
You're not root, or your kernel lacks `CONFIG_OVERLAY_FS`. The pipeline
falls back to a plain-dir mode automatically — builds still work, they're
just slightly slower (manifest is collected by diffing before/after).
### "summon: hash mismatch"
The `SOURCE_HASH` in the spell's DETAILS doesn't match what's currently
being served at the `SOURCE_URL`. Upstream may have re-rolled the tarball.
Re-compute the hash with `sha512sum wget-1.21.4.tar.gz` and update DETAILS.
### "pgp: no GOODSIG in gpg output"
The DETAILS file is signed but the signing key isn't in your keyring.
Either import the key (`gpg --import <key>`) or unset
`SORCERY_GO_PGP_KEYRING` to disable PGP attestation.
### "legal: license X is blacklisted"
Your active posture blocks the spell's license. Switch posture:
```bash
sudo SORCERY_GO_POSTURE=lawless sorcery-go cast <spell>
```
Or persist the change in `/etc/sorcery-go/env`.

69
docs/METADATA.md Executable file
View File

@ -0,0 +1,69 @@
# Sorcery-Go Documentation Standard (SGDS)
Every module, spell, or Essence in the Coven must follow this standard.
## Engine
- **Engine:** Go 1.21+ (statically linked)
- **Storage:** Content-Addressable Essence (.ess) — Merkle trees
- **Security:** eBPF Tomb Guard + cgroup filters + network firewall isolation
- **Orchestration:** Cockpit-integrated WebUI (Coven Mirror)
- **Cluster:** Firewall-isolated Ley-Lines, Fester-scheduled distributed builds
## Essence Specification
Every Essence bundle documents its Merkle root and layering order:
```markdown
# Essence: openssl-3.2.1
## Cryptography
- **Root Hash:** `sha256:9f4e2a8b...`
- **Signature:** `ed25519:...`
- **Signer:** Build-Master-01
## Composition
- **Parent Essence:** `base-glibc-2.35.ess`
- **Added Blobs:** 142
- **Deduplication Ratio:** 84.2%
## Linkage
- **Type:** `ELF-Dynamic`
- **Libc:** `glibc-2.35`
- **Portable:** `False`
```
## Deployment Manifest (LXC Target)
Used by the deployment manager to hydrate a container:
```markdown
# Deployment: web-farm-alpha
## Infrastructure
- **Target Engine:** systemd / OpenRC
- **Firewall:** OPNsense (active) + OpenSnitch / Portmaster (per-process)
- **Isolation:** eBPF Tomb Guard (in-kernel enforcement)
## Hydration Recipe
1. Load `core-runtime.ess`
2. Inject `security-headers.ess`
3. Bind `/var/lib/sorcery-go/essences` (read-only)
```
## Compliance Profiles
| Profile | Posture | Use Case |
|-----------------|--------------|------------------------------|
| `strict_copyleft` | FSF/GNU | Pure free-software fleet |
| `corporate_lite` | MIT/Apache | Risk-averse enterprise |
| `lawless` | Wildcard | Sovereign — no license restrictions |
## Module Documentation
Every package under `pkg/` opens with a Go doc comment that explains:
1. The package's role in the Coven.
2. The public API (key types and methods).
3. Any side effects (disk I/O, network, kernel state).
See `pkg/dag/dag.go`, `pkg/tomb/storage.go`, and `pkg/toolchain/btc.go` for
examples.

96
docs/QUICKSTART.md Executable file
View File

@ -0,0 +1,96 @@
# Sorcery-Go Quick Start
## 1. Bootstrap
```bash
./scripts/bootstrap.sh
make build
sudo make caps
./build/sorcery init
```
## 2. The Primary Rituals
| Ritual | Command | Effect |
|----------|--------------------------------------|-------------------------------------|
| Cast | `sorcery cast <spell>` | Forge an Essence in the Cauldron |
| Reanimate| `sorcery reanimate <ess> --sanctum <id>` | Deploy an Essence from the Tomb |
| Gaze | `sorcery gaze install <spell>` | Inspect every file owned by an Essence |
| Ward | `sorcery ward status` | Check firewall and alarm status |
| Banish | `sorcery dispel <spell>` | Surgical removal from a Sanctum |
## 3. Cast Flags
| Flag | Effect |
|-------------------------|----------------------------------------------|
| `--target <arch>` | Cross-compile for x86_64, aarch64, or any BTC target |
| `--static` | Produce a portable static ELF (musl) |
| `-r` / `--reconfigure` | Force ICE y/n prompts even if Tablet has answers |
| `-d` / `--default` | Accept all defaults (HPC-friendly) |
| `-m` / `--matrix` | Build across all maintained arches simultaneously |
## 4. Coven Management
```bash
sorcery coven join <master-ip> # Add a new Sanctum to the Coven
sorcery coven pulse # Live heartbeat of every node
sorcery coven drain <node-id> # Migrate builds off a node for maintenance
sorcery coven list # Show all known Sanctums
```
Distributed build scheduling is delegated to Fester when a cluster is active.
## 5. Tomb (Binary Storage)
```bash
sorcery tomb list # Show every Essence in the Tomb
sorcery tomb verify --all # Warding bit-rot check
sorcery tomb purge # Garbage-collect unreferenced blobs
```
## 6. Warding (Security)
```bash
sorcery ward status # Active alarms + quarantined nodes
sorcery ward banish <node-id> # Freeze a Sanctum and log the banishment
```
Transport security is handled at the network firewall layer (OPNsense / IPFire).
There are no certificates or key material to manage.
## 7. Legal Sentinel
```bash
sorcery legal audit # Scan fleet for license violations
sorcery legal sbom <essence_id> # CycloneDX SBOM on stdout
sorcery legal set-posture lawless # strict_copyleft | corporate_lite | lawless
sorcery legal credits --out ./DIR # Collect every COPYING / LICENSE file
```
## 8. Coven Mirror (WebUI)
```bash
sorcery web --port 8080
# -> http://localhost:8080
```
Tabs: Grimoire, Tomb, Pulse, Sanctum, Portable Bin, Compliance.
## 9. Portable Tool Bin
```bash
# Forge a static, portable version of a tool
sorcery cast procps --static --target aarch64 --essence-out ./portable-top.ess
# Generate the curated Emergency Kit
cauldron emergency-kit --bundle ./emergency.svb
```
## 10. First Flight (Genesis Ritual)
```bash
./scripts/forge_first_sanctum.sh
```
This automates the cast, seal, hydrate, and verify loop with a static busybox
Essence. If it succeeds, the Cauldron, Tomb, and Warding are operational.

81
docs/RITUAL_OF_CASTING.md Executable file
View File

@ -0,0 +1,81 @@
# The Ritual of Casting
A guide for the Sovereign Admin — from the veteran shell-scripter to the new
hire. This ritual transforms raw source code into a hardened, portable Essence.
## I. The Gathering (Dependency and License Check)
Before the fires are lit, the Cauldron and the Warding must agree on the
ingredients.
- **The Ancestry** — the engine scans `DETAILS` and `DEPENDS`. If a library
is missing, it is queued for forging.
- **The Legal Seal** — the Legal Sentinel checks the spell's license against
the active posture (strict_copyleft, corporate_lite, or lawless). If you try
to cast a blacklisted AGPL tool under `corporate_lite`, the Warding
blocks the build before it starts.
## II. The Incantation (The Interactive y/n ICE)
The admin interacts with the Tablet. This is where you define the Soul of the
binary.
- **The Query** — the CLI asks: `? [wget] Enable SSL support? (y/n) [default: y]:`
- **The Memory** — your choice is saved in BoltDB. Next time you cast, the
Tablet remembers — ensuring your configuration is consistent across the
entire fleet.
Three interfaces share the same Tablet, so an answer you give in the WebUI
appears the next time you cast from the CLI.
## III. The Forging (Cross-Compilation and Toolchains)
- **The Cauldron** uses cross-toolchains — a powerful x86_64 Master node can
forge an AArch64 binary for an ARM edge-device, or an ARM binary for a
Raspberry Pi using a BTC.sh-forged toolchain.
- **The Coven** — if the build is massive (like glibc) and Fester is active,
the Master shards the work across cluster nodes via DAG-driven scheduling.
## IV. The Sealing (Merkle Hashing and the Tomb)
Once the binary is forged, it is sealed so it can never be corrupted.
- **The Merkle Tree** — every file in the build is hashed. These hashes are
combined into one final root hash (the `essence_id`).
- **The Tomb** — the Essence is stored under `/var/lib/sorcery-go/tomb/blobs/`.
It is now immutable. If a single bit changes, the Warding detects it
instantly and refuses Reanimation.
## V. The Hydration (Container Deployment)
Now the Essence must be brought to life inside a Sanctum.
- **Reflink / Hardlink** — instead of copying files, the engine links the
Essence into the container's filesystem. On btrfs or xfs this is zero-copy.
- **Atomic Swap** — when you upgrade a tool, the engine points the link to
the new Essence hash. To the container it looks like a standard update,
but it happens in under a second.
## Admin Troubleshooting — The Gaze
When a ritual fails or a node acts strangely, use the Gaze to see the truth:
```bash
gaze install <spell> # "Show me every file this spell owns."
gaze tablet <spell> # "Show me exactly how this was configured."
gaze essence <hash> # "Which spell + flags produced this blob?"
gaze whereis /usr/bin/wget # "Who owns this file?"
sorcery ward status # "Is the Warding active? Any tainted Essences?"
```
## Vocabulary Cross-Reference
| Traditional Term | Sovereign Term | Plain English |
|--------------------|--------------------|------------------------------------------------------------|
| Package | Essence | A cryptographically sealed folder of files |
| Build Server | The Coven | A team of servers compiling code together |
| Firewall / Audit | The Warding | The layer that stops bad code and illegal licenses |
| Symlink / Mount | Hydration | Attaching a tool to a container so it can run |
| State DB | The Tablet | The journal of every y/n answer you have ever given |
| Binary cache | The Tomb | The content-addressable storage where Essences rest |
| Container / Node | The Sanctum | The isolated runtime where software executes |

129
docs/SECURITY.md Executable file
View File

@ -0,0 +1,129 @@
# Sorcery-Go Security — The Warding
## 1. Defence in Depth
| Layer | Responsibility | Tooling |
|-------|-------------------------------------|--------------------------------------------------|
| 1 | In-kernel MAC enforcement | eBPF LSM (Tomb Guard) |
| 2 | Device + network control | eBPF cgroup filters |
| 3 | Network firewall between nodes | OPNsense / IPFire (Coven isolation) |
| 4 | Per-process network filtering | OpenSnitch or Portmaster (selectable per node) |
| 5 | Content-addressing and verification | Merkle root + per-blob SHA-256 hashing |
| 6 | Quarantine and containment | cgroup freezer, multi-runtime (LXC, Podman, Firecracker, baremetal) |
## 2. Firewall-First Security Model
Sorcery-Go delegates all transport security to the network boundary. A dedicated
firewall appliance (OPNsense or IPFire) isolates the Coven so that only
authorized Sanctum IPs may exchange Essence traffic. This eliminates the need for
application-layer certificate management entirely.
### Recommended Firewall Rules
| Direction | Source | Destination | Port | Action |
|------------|----------------|----------------|----------------|--------|
| Intra-Coven| Sanctum subnet | Sanctum subnet | 8080 (sorcery) | Allow |
| Intra-Coven| Sanctum subnet | Sanctum subnet | 8181 (Fester) | Allow |
| Intra-Coven| Sanctum subnet | Sanctum subnet | 9090 (Cockpit) | Allow |
| Outbound | Master Sanctum | Artifact cache | 8181 | Allow |
| Default | Any | Any | Any | Deny |
There is no built-in TLS, mTLS, or application-layer encryption in the
codebase. All `crypto/rand`, `crypto/tls`, and `crypto/x509` code paths have
been removed. Task IDs are generated deterministically using atomic counters
and nanosecond timestamps.
## 3. eBPF Tomb Guard — The Immutable Vault
Every runtime environment — LXC, Podman, Firecracker, or baremetal — is
enforced by the same eBPF LSM program (`sorcery-tomb-guard`). Unlike
user-space profiles, eBPF provides in-kernel enforcement that works uniformly
across all runtimes without per-runtime profile syntax.
The Tomb Guard BPF program attaches to `file_open`, `path_link`, and
`path_unlink` hooks and applies a single policy:
- **Read-only** access to `/var/lib/sorcery-go/essences/` and its contents.
- **Deny** all writes, hardlinks, symlinks, and deletes under the Tomb.
- **Allow** read access to `/var/lib/sorcery-go/state/state.db`.
If a compromised container or process attempts to poison the Tomb, the eBPF
LSM blocks the operation in-kernel and the Warding raises an Illegal Write
Attempt alarm that is rendered on the Cockpit Threat Map.
## 4. Network Gatekeepers
The Coven supports either OpenSnitch or Portmaster (selectable per node,
never both). The Sorcery-Go engine auto-detects which is active and injects
the appropriate rules.
### OpenSnitch Rule
```json
{
"name": "Sorcery-Essence-Sync",
"enabled": true,
"action": "allow",
"duration": "always",
"operator": {
"type": "list",
"data": [
{ "type": "simple", "operand": "process", "data": "/usr/local/bin/sorcery-go" },
{ "type": "regexp", "operand": "dest_host", "data": "registry\\.local" }
]
}
}
```
### Portmaster Integration
Defines the Sorcery-Go process as a Trusted System Utility with a scoped
network boundary — only the stream to the Master Essence Registry is permitted;
all other egress from the build sandbox is dropped.
## 5. Emergency Banishment
If a Worker is physically stolen or compromised:
```bash
sorcery-go ward banish <node-id>
```
This freezes the offending Sanctum via cgroup/runtime freeze and records
the banishment in the Warding alarm log. The firewall rule for that node
should also be removed or blocked at the network boundary.
## 6. Quarantine Logic
When the Warding detects a high-severity threat (binary signature mismatch
in the Tomb), it can automatically:
1. Freeze the offending process or container via cgroups (`cgroup.freeze`)
— works identically across LXC, Podman, Firecracker, and baremetal.
2. Sever the Essence links to prevent memory-based exploit spread.
3. Broadcast the alarm to every other Sanctum.
4. Highlight the node red on the Cockpit Threat Map.
## 7. First-Boot Hardening Test
```go
// pkg/warding/audit_test.go
func TestFirstBootHardening(t *testing.T) {
// eBPF Tomb Guard must block writes to /var/lib/sorcery-go/essences/
err := os.WriteFile("/var/lib/sorcery-go/essences/malicious_hash",
[]byte("void"), 0644)
if err == nil {
t.Error("SECURITY FAILURE: eBPF Tomb Guard allowed write to Tomb!")
}
}
```
Run this after every deploy to confirm the Warding is active.
## 8. Security Pulse Indicator
The Cockpit WebUI shows a single traffic-light indicator:
- Green — eBPF Tomb Guard active; OpenSnitch/Portmaster reporting zero leaks
- Yellow — A toolchain is currently being validated in the Lab
- Red — Hash mismatch or unauthorized write detected — Automatic Quarantine engaged

86
docs/SPELL_SPEC.md Executable file
View File

@ -0,0 +1,86 @@
# Spell Specification (SPELL.md)
Every spell in the Grimoire is a directory containing Bash scripts:
```
grimoire/
└── libs/
└── openssl/
├── DETAILS # Metadata: SPELL, VERSION, SOURCE_URL, etc.
├── DEPENDS # Direct dependencies + sub-depends
├── BUILD # Compilation script (runs inside the sandbox)
├── CONFIGURE # Interactive y/n queries (consumed by ICE)
├── PRE_BUILD # Optional pre-build steps
└── INSTALL # Optional install override
```
## DETAILS Fields
| Field | Required | Example |
|-------|----------|---------|
| `SPELL` | yes | `openssl` |
| `VERSION` | yes | `3.2.1` |
| `SOURCE` | yes | `${SPELL}-${VERSION}.tar.gz` |
| `SOURCE_URL[0]` | yes | `https://www.openssl.org/source/...` |
| `SOURCE_HASH` | yes | `sha512:abc123...` |
| `SOURCE_DIRECTORY` | yes | `${BUILD_DIRECTORY}/${SPELL}-${VERSION}` |
| `WEB_SITE` | no | `https://www.openssl.org/` |
| `ENTERED` | no | `20260317` |
| `LICENSE[0]` | yes | `Apache-2.0` |
| `SHORT` | yes | "The Open Source toolkit for SSL/TLS" |
## DEPENDS Format
```bash
# Runtime dependency
depends glibc ""
# Build-only dependency
depends pkg-config "" build
# Optional dependency (toggled via ICE)
depends zlib "--with-zlib" optional
# Sub-dependency: requires openssl to be built with ssl3
sub_depends openssl ssl3
```
## CONFIGURE Format
The Interactive Configuration Engine (ICE) intercepts `config_query` calls:
```bash
config_query OPENSSL_SSL3 "Enable SSLv3 (insecure)?" n
config_query OPENSSL_IPV6 "Enable IPv6 support?" y
config_query OPENSSL_ASM "Use assembly optimisations?" y
```
Answers are persisted in the Tablet (BoltDB). Subsequent casts reuse the
answers unless `--reconfigure` is passed.
## BUILD Script
The BUILD script runs inside the OverlayFS sandbox. Standard pattern:
```bash
#!/bin/bash
cd "$SOURCE_DIRECTORY" &&
./configure --prefix=/usr "$@" &&
make &&
make install
```
The sandbox's `upper` directory captures every file `make install` writes —
this becomes the Essence manifest. No `installwatch` / `LD_PRELOAD` needed.
## Spell Generation via Quill
```bash
quill new zlib
# → launches the interview wizard
# → emits DETAILS, DEPENDS, BUILD, CONFIGURE
# → auto-hashes the upstream source via the "Smart Quill" mode
```
The WebUI provides a modal form that drives the same `quill.GenerateSpell`
code path so CLI and WebUI produce identical output.

91
docs/TOOLCHAIN_SPEC.md Executable file
View File

@ -0,0 +1,91 @@
# Toolchain Specification
## 1. Sovereign Toolchains
The Coven does not download pre-built GCC/LLVM binaries. Each admin maintains
their own toolchains, or forges them with BTC.sh. Toolchains are stored under
`/opt/sorcery-go/toolchains/<triple>/` (manual) or `/opt/BTC/<SYS_LABEL>/`
(BTC.sh golden images). The Sandbox `AttachToolchain` bind-mounts the requested
toolchain read-only into the build namespace.
## 2. BTC.sh Integration
Sorcery-Go integrates with BTC.sh for cross-compilation across 19 target
architectures. The `pkg/toolchain/btc.go` module probes for golden images,
parses their manifest JSON sidecars, and configures the build environment.
### Supported Targets
| Family | Targets |
|------------------|----------------------------------------------------------------|
| Intel HEDT/Server| haswell, haswell-ep, skylake, skylake-x, skylake-server |
| AMD Ryzen/EPYC | znver1, znver2, znver3, znver4 |
| AMD APU | apu-zn1, apu-zn2, apu-zn3, apu-zn4 |
| Intel Atom | atom-silvermont, atom-goldmont, atom-tremont, atom-sierraforest|
| Embedded | mipselr2, armv7, tilegx |
### ISA Tiers
| ISA Tier | Flags |
|----------|-----------------------------------------------------|
| AVX512 | `-mavx512f -mavx512dq -mavx512vl -mavx512bw` |
| AVX2 | `-mavx2` |
| SSE4_2 | `-msse4.2` |
| NEON | `-mfpu=neon -mfloat-abi=hard` |
| MIPS32 | (per-target architecture) |
| TILE | (per-target architecture) |
## 3. Required Specs (Manual Toolchains)
```markdown
# Toolchain: aarch64-linux-musl
## Specifications
- **Version:** GCC 15.1.0 / Binutils 2.44
- **C Library:** musl 1.2.5
- **Optimizations:** `-O3 -flto -march=armv8-a`
- **Hardening:** `-fstack-protector-all -pie -fPIE -D_FORTIFY_SOURCE=2`
## Essence Compatibility
- **Min Engine Version:** 1.0.2
- **Supported Targets:** Generic-ARM64, Pine64, RPi5
## Validation
- **Validator Pass:** true
- **Smoke Test:** Hello-World compiled, ldd reports "not a dynamic executable"
- **Has SSP:** true
- **Has PIE:** true
```
## 4. Validation Pipeline
Every toolchain must pass `pkg/toolchain.Validate` before it can forge
production Essences:
| Check | Method |
|------------------------|---------------------------------------------------|
| Arch detection | Parse `gcc -v` output for triple |
| Stack Smashing Protection | Inspect for `--enable-default-ssp` |
| PIE | Inspect for `--enable-default-pie` |
| LTO | Inspect for `--with-default-libstdcxx-abi=lto` |
| Smoke test | Compile `int main(){}` with `-fstack-protector-all -pie` |
If `Validate()` returns `Report.Passed = false`, the Cauldron refuses to
attach the toolchain and the WebUI flags it in the Toolchain Lab view.
## 5. Per-Spell Overrides
Some spells (kernel, glibc) need a different toolchain than the default.
The WebUI lets the admin attach an override per spell:
```yaml
spell: linux
toolchain_override: /opt/sorcery-go/toolchains/x86_64-linux-gnu-gcc-14
```
## 6. Fleet Re-Forge
When a toolchain is updated, the WebUI's Fleet Re-Forge button flags
every Essence built with the old version and re-queues them for the
Cauldron. This ensures the entire Coven runs code compiled with the latest
toolchain.

38
go.mod Executable file
View File

@ -0,0 +1,38 @@
module dcos.net/sorcery-go
go 1.21
require (
github.com/cilium/ebpf v0.15.0
github.com/charmbracelet/bubbles v0.16.1
github.com/charmbracelet/bubbletea v0.24.2
github.com/charmbracelet/lipgloss v0.9.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
go.etcd.io/bbolt v1.3.7
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f5 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

60
go.sum Executable file
View File

@ -0,0 +1,60 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,2 @@
# Placeholder — this directory will hold a future spell.
# Run `quill new <spell-name>` to scaffold it.

View File

@ -0,0 +1,2 @@
# Placeholder — this directory will hold a future spell.
# Run `quill new <spell-name>` to scaffold it.

4
grimoire/libs/glibc/CONFIGURE Executable file
View File

@ -0,0 +1,4 @@
config_query GLIBC_MULTIARCH "Enable multi-arch support?" y
config_query GLIBC_HWCAPS "Compile with Hardware Capabilities?" y
config_query GLIBC_NLS "Enable Native Language Support?" y
config_query GLIBC_PROFILE "Enable profiling support?" n

2
grimoire/libs/glibc/DEPENDS Executable file
View File

@ -0,0 +1,2 @@
depends linux-headers ""
depends binutils ""

15
grimoire/libs/glibc/DETAILS Executable file
View File

@ -0,0 +1,15 @@
SPELL=glibc
VERSION=2.39
SOURCE=${SPELL}-${VERSION}.tar.xz
SOURCE_URL[0]=https://ftp.gnu.org/gnu/glibc/${SOURCE}
SOURCE_HASH=sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SOURCE_DIRECTORY=${BUILD_DIRECTORY}/${SPELL}-${VERSION}
WEB_SITE=https://www.gnu.org/software/libc/
ENTERED=20260317
LICENSE[0]=LGPL-2.1-or-later
SHORT="The GNU C Library"
cat << EOF
The GNU C Library project provides the core libraries for the GNU system
and GNU/Linux systems, as well as many other systems that use Linux as
the kernel.
EOF

6
grimoire/libs/openssl/BUILD Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
cd "$SOURCE_DIRECTORY" &&
./Configure linux-x86_64 --prefix=/usr --openssldir=/etc/ssl \
--libdir=lib "$@" &&
make -j"$MAKE_JOBS" &&
make install_sw install_ssldirs

View File

@ -0,0 +1,4 @@
config_query OPENSSL_SSL3 "Enable SSLv3 (insecure)?" n
config_query OPENSSL_IPV6 "Enable IPv6 support?" y
config_query OPENSSL_ASM "Use assembly optimisations?" y
config_query OPENSSL_KTLS "Enable Kernel TLS offload?" y

4
grimoire/libs/openssl/DEPENDS Executable file
View File

@ -0,0 +1,4 @@
depends glibc ""
depends zlib ""
sub_depends openssl ssl3

16
grimoire/libs/openssl/DETAILS Executable file
View File

@ -0,0 +1,16 @@
SPELL=openssl
VERSION=3.2.1
SOURCE=${SPELL}-${VERSION}.tar.gz
SOURCE_URL[0]=https://www.openssl.org/source/${SOURCE}
SOURCE_HASH=sha512:def4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab
SOURCE_DIRECTORY=${BUILD_DIRECTORY}/${SPELL}-${VERSION}
WEB_SITE=https://www.openssl.org/
ENTERED=20260317
LICENSE[0]=Apache-2.0
SHORT="TLS/SSL and cryptography library"
cat << EOF
The OpenSSL Project is a collaborative effort to develop a robust,
commercial-grade, full-featured, and Open Source toolkit implementing the
Secure Sockets Layer (SSL v2/v3) and Transport Layer Security (TLS v1)
protocols as well as a full-strength general purpose cryptography library.
EOF

5
grimoire/libs/zlib/BUILD Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
cd "$SOURCE_DIRECTORY" &&
./configure --prefix=/usr &&
make -j"$MAKE_JOBS" &&
make install

1
grimoire/libs/zlib/DEPENDS Executable file
View File

@ -0,0 +1 @@
depends glibc ""

15
grimoire/libs/zlib/DETAILS Executable file
View File

@ -0,0 +1,15 @@
SPELL=zlib
VERSION=1.3.1
SOURCE=${SPELL}-${VERSION}.tar.xz
SOURCE_URL[0]=https://zlib.net/${SOURCE}
SOURCE_HASH=sha512:111111222222333333444444555555666666777777888888999999000000aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999aaaa
SOURCE_DIRECTORY=${BUILD_DIRECTORY}/${SPELL}-${VERSION}
WEB_SITE=https://zlib.net/
ENTERED=20260317
LICENSE[0]=Zlib
SHORT="Real-time data compression library"
cat << EOF
zlib is designed to be a free, general-purpose, legally unencumbered --
that is, not covered by any patents -- lossless data-compression library
for use on virtually any computer hardware and operating system.
EOF

12
grimoire/utils/busybox/BUILD Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Standard BUILD script for busybox
cd "$SOURCE_DIRECTORY" &&
# Default config
make defconfig &&
# Build
make -j"$MAKE_JOBS" &&
# Install to the sandbox
make CONFIG_PREFIX="$INSTALL_ROOT" install

View File

@ -0,0 +1,6 @@
#!/bin/bash
# CONFIGURE — interactive y/n queries for the ICE engine
config_query BUSYBOX_STATIC "Build as a fully static binary?" y
config_query BUSYBOX_SSL "Enable SSL (openssl) support?" n
config_query BUSYBOX_IPC "Enable System V IPC support?" y
config_query BUSYBOX_UNICODE "Enable Unicode support?" y

2
grimoire/utils/busybox/DEPENDS Executable file
View File

@ -0,0 +1,2 @@
depends glibc ""
optional_depends openssl "" "--enable-ssl" "Enable SSL/TLS support"

15
grimoire/utils/busybox/DETAILS Executable file
View File

@ -0,0 +1,15 @@
SPELL=busybox
VERSION=1.36.1
SOURCE=${SPELL}-${VERSION}.tar.bz2
SOURCE_URL[0]=https://busybox.net/downloads/${SOURCE}
SOURCE_HASH=sha512:b09ddd6b49f5b5cd6cba5e9f98d0e44ab7e4a9a1a6c5b0c5e0c5d4f7f0b6c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7
SOURCE_DIRECTORY=${BUILD_DIRECTORY}/${SPELL}-${VERSION}
WEB_SITE=https://busybox.net/
ENTERED=20260317
LICENSE[0]=GPL-2.0-or-later
SHORT="The Swiss Army Knife of embedded Linux"
cat << EOF
BusyBox combines tiny versions of many common UNIX utilities into a single
small executable. It provides replacements for most of the utilities you
usually find in GNU fileutils, shellutils, etc.
EOF

View File

@ -0,0 +1,2 @@
# Placeholder — this directory will hold a future spell.
# Run `quill new <spell-name>` to scaffold it.

5
grimoire/utils/wget/BUILD Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
cd "$SOURCE_DIRECTORY" &&
./configure --prefix=/usr --sysconfdir=/etc "$@" &&
make -j"$MAKE_JOBS" &&
make install

4
grimoire/utils/wget/CONFIGURE Executable file
View File

@ -0,0 +1,4 @@
config_query WGET_SSL "Enable SSL (openssl) support?" y
config_query WGET_IPV6 "Enable IPv6 support?" y
config_query WGET_NLS "Enable Native Language Support?" n
config_query WGET_PCRE "Enable PCRE2 regex support?" n

4
grimoire/utils/wget/DEPENDS Executable file
View File

@ -0,0 +1,4 @@
depends glibc ""
depends openssl ""
depends zlib ""
optional_depends pcre2 "" "--with-pcre" "Enable PCRE2 regex support"

16
grimoire/utils/wget/DETAILS Executable file
View File

@ -0,0 +1,16 @@
SPELL=wget
VERSION=1.21.4
SOURCE=${SPELL}-${VERSION}.tar.gz
SOURCE_URL[0]=https://ftp.gnu.org/gnu/wget/${SOURCE}
SOURCE_HASH=sha512:abc123def4567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
SOURCE_DIRECTORY=${BUILD_DIRECTORY}/${SPELL}-${VERSION}
WEB_SITE=https://www.gnu.org/software/wget/
ENTERED=20260317
LICENSE[0]=GPL-3.0-or-later
SHORT="GNU Wget — a free software package for retrieving files using HTTP, HTTPS, FTP and FTPS"
cat << EOF
GNU Wget is a free software package for retrieving files using HTTP, HTTPS,
FTP and FTPS, the most widely used Internet protocols. It is a non-interactive
commandline tool, so it may easily be called from scripts, cron jobs or
terminals without X-Windows support.
EOF

15
manifests/cockpit-sorcery.json Executable file
View File

@ -0,0 +1,15 @@
{
"version": "1.0",
"display-name": "Sorcery Coven Mirror",
"menu": {
"index": {
"label": "Sorcery-Go",
"order": 10,
"icon": "system-run-symbolic"
}
},
"external-port": 8080,
"priority": 0,
"privileged": true,
"description": "Fleet Command, IDE Debugger, Tomb Explorer, Portable Bin and Compliance Center for the Sorcery-Go Sovereign Coven."
}

View File

@ -0,0 +1,39 @@
{
"profiles": {
"strict_copyleft": {
"description": "FSF/GNU style — total software freedom. Only the purest open-source Essences are permitted.",
"fail_on_proprietary": true,
"require_attribution": true,
"allowed_families": ["GPL", "LGPL", "MIT", "BSD", "Apache", "ISC", "MPL", "Unlicense"],
"blacklist": ["CC-BY-NC-ND", "SSPL", "WTFPL"],
"enforcement": {
"source_check": true,
"auto_attach_gpl_text": true
}
},
"corporate_lite": {
"description": "Risk mitigation — prioritises non-viral, permissive licenses. AGPL/SSPL blacklisted.",
"fail_on_copyleft_viral": true,
"allow_internal_proprietary": true,
"allowed_families": ["MIT", "BSD", "Apache", "ISC", "Unlicense"],
"blacklist": ["AGPL", "SSPL"],
"restricted_audit_required": ["GPL", "LGPL"],
"enforcement": {
"weekly_cyclonedx_report": true,
"internal_only_tag": true
}
},
"lawless": {
"description": "Absolute utility — for the brave who seek power at any legal cost. Silent audit only.",
"silent_mode": true,
"auto_accept_eula": true,
"audit_only": true,
"blacklist": [],
"enforcement": {
"shadow_mode": true,
"auto_accept_prompts": true
}
}
},
"active_profile": "strict_copyleft"
}

24
manifests/emergency-kit.json Executable file
View File

@ -0,0 +1,24 @@
{
"kit": "emergency",
"version": "1.0",
"tools": [
{ "name": "busybox", "category": "shell", "rationale": "Swiss-army knife: sh, ls, cp, mv, cat" },
{ "name": "gdisk", "category": "disk", "rationale": "Repair GPT partition tables" },
{ "name": "e2fsck", "category": "disk", "rationale": "Repair corrupted ext4 filesystems" },
{ "name": "cryptsetup", "category": "crypto", "rationale": "Open LUKS volumes if initramfs fails" },
{ "name": "openssh", "category": "network", "rationale": "Exfiltrate data or pull remote Essences" },
{ "name": "vim", "category": "editor", "rationale": "Edit /etc/fstab or grub.cfg in a broken env" },
{ "name": "coreutils", "category": "integrity", "rationale": "sha256sum to verify other binaries" }
],
"build": {
"linkage": "static",
"libc": "musl",
"strip": "--strip-unneeded",
"target_arches": ["x86_64", "aarch64"]
},
"delivery": {
"format": "svb",
"signed_by": "grid-master-key",
"download_path": "/api/portable/emergency-kit"
}
}

View File

@ -0,0 +1,52 @@
# Firecracker MicroVM Configuration for a Sorcery-Go Sanctum
# File: Referenced by pkg/runtime/firecracker.go
#
# Firecracker provides VM-level isolation — the strongest isolation
# option available. Each sanctum runs in its own microVM with a
# dedicated kernel and rootfs.
#
# Unlike LXC/Podman, Firecracker does NOT use cgroups on the host.
# The eBPF cgroup filters do not apply inside the guest. However,
# the eBPF Tomb Guard LSM hook still protects the HOST's Tomb from
# any process including the Firecracker VMM itself.
#
# Network isolation is handled by:
# 1. Firecracker jailer (chroot + seccomp)
# 2. Per-VM TAP devices with bridge filtering
# 3. Host-level eBPF network filters on the bridge
#
# Required files:
# - SORCERY_GO_FIRECRACKER_KERNEL: path to vmlinux (host kernel image)
# - Root drive: qcow2 or raw rootfs image
# Memory: 256MB default (sufficient for build workloads)
# VCPUs: 1 (scale up for parallel builds)
# Boot: serial console only (no graphical)
# Kernel args: console=ttyS0 reboot=k panic=1 pci=off
# Example Firecracker API configuration:
# {
# "boot-source": {
# "kernel_image_path": "/var/lib/sorcery-go/vmlinux",
# "boot_args": "console=ttyS0 reboot=k panic=1 pci=off ip=dhcp"
# },
# "drives": [
# {
# "drive_id": "rootfs",
# "path_on_host": "/var/lib/sorcery-go/sanctums/<name>/rootfs.qcow2",
# "is_root_device": true,
# "is_read_only": false
# }
# ],
# "machine-config": {
# "vcpu_count": 1,
# "mem_size_mib": 256
# },
# "network-interfaces": [
# {
# "iface_id": "eth0",
# "guest_mac": "02:FC:XX:XX:XX:XX",
# "host_dev_name": "tap-<name>"
# }
# ]
# }

View File

@ -0,0 +1,76 @@
{
"image": "sorcery-go-hardened-2026",
"arch": "x86_64",
"format": "iso",
"security": {
"firewall": "opensnitch",
"mac": "ebpf",
"ebpf_programs": {
"tomb_guard": "pkg/warding/ebpf/c/tomb_guard.bpf.c",
"sorcery_filter": "pkg/warding/ebpf/c/sorcery_filter.bpf.c"
},
"ebpf_enforce": true,
"audit": "ebpf-perf",
"runtime": "auto"
},
"supported_runtimes": {
"lxc": {
"description": "System containers via lxc-tools",
"min_kernel": "4.15",
"packages": ["lxc", "lxcfs"]
},
"podman": {
"description": "OCI containers via Podman (rootless capable)",
"min_kernel": "5.11",
"packages": ["podman", "crun"]
},
"firecracker": {
"description": "Lightweight microVMs via Firecracker",
"min_kernel": "5.4",
"packages": ["firecracker", "jailer"],
"notes": "Provides VM-level isolation. eBPF cgroup filters do not apply to guest."
},
"baremetal": {
"description": "Direct filesystem deployment (no container)",
"min_kernel": "any",
"packages": [],
"notes": "eBPF LSM still protects the Tomb at kernel level."
}
},
"essences": [
"kernel-hardened-6.x.ess",
"sorcery-go-engine.ess",
"warding-monitor.ess",
"legal-sentinel.ess"
],
"sanctum_templates": {
"web": {
"runtime": "podman",
"image": "profile-restricted-net.ess",
"memory_mb": 512,
"caps": ["CAP_NET_BIND_SERVICE"]
},
"db": {
"runtime": "lxc",
"image": "profile-no-net.ess",
"memory_mb": 1024,
"network": "none"
},
"edge": {
"runtime": "firecracker",
"image": "profile-aarch64-portable.ess",
"vcpus": 1,
"memory_mb": 256
}
},
"compliance": {
"posture": "strict_copyleft",
"sbom_format": "cyclonedx",
"auto_rebuild_on_cve": true
},
"portable_bin": {
"include_emergency_kit": true,
"static_linkage": "musl",
"rebuild_interval_days": 30
}
}

35
manifests/lxc-sanctum.conf Executable file
View File

@ -0,0 +1,35 @@
# LXC Container Configuration for a Sorcery-Go Sanctum
# File: /var/lib/lxc/<sanctum-name>/config
#
# A Sanctum must be "privileged" enough to handle mount syscalls for
# OverlayFS (used by the Cauldron sandbox), but restricted enough to
# protect the host from a misbehaving spell script.
#
# Security is enforced by eBPF (not AppArmor). The Tomb Guard LSM hook
# intercepts writes to /var/lib/sorcery-go/tomb/** at the kernel level.
# Core Isolation
lxc.include = /usr/share/lxc/config/common.conf
lxc.arch = x86_64
# eBPF handles MAC enforcement — set AppArmor to unconfined.
# The eBPF Tomb Guard LSM hook provides equivalent protection with
# better performance and runtime-agnostic enforcement.
lxc.apparmor.profile = unconfined
lxc.cap.drop =
lxc.mount.auto = proc:rw sys:rw cgroup:rw
lxc.autodev = 1
# Grant access to Fuse/Loop if you use them for disk-image spells
lxc.cgroup2.devices.allow = c 10:229 rwm
# Bind-mount the project source into the Sanctum (dev workflow)
# lxc.mount.entry = /home/you/sorcery-go var/lib/sorcery/go_src none bind,create=dir 0 0
# Read-only bind of the Tomb (enforced by eBPF at kernel level)
lxc.mount.entry = /var/lib/sorcery-go/tomb var/lib/sorcery-go/tomb none bind,ro,create=dir 0 0
# Network — restrict to the Coven's Ley-Line bridge
lxc.net.0.type = veth
lxc.net.0.link = br0
lxc.net.0.flags = up

View File

@ -0,0 +1,35 @@
#include <tunables/global>
# AppArmor profile for an LXC container running inside the Coven.
# File: /etc/apparmor.d/lxc/lxc-sorcery-essence
#
# This profile is the "Immutable Vault". It allows containers to READ
# from the Tomb (to run their software) but strictly forbids WRITING,
# LINKING, or DELETING. A compromised container cannot poison the
# global Essence store.
profile lxc-sorcery-essence flags=(attach_disconnected, mediate_deleted) {
#include <abstractions/lxc/container-default>
# 1. Global Essence Store Access (READ ONLY)
# Prevents any container from modifying the master hashes
/var/lib/sorcery/tomb/ r,
/var/lib/sorcery/tomb/** r,
/var/lib/sorcery/essences/ r,
/var/lib/sorcery/essences/** r,
# 2. Deny all write/append/link/rename/delete attempts
deny /var/lib/sorcery/tomb/** wklx,
deny /var/lib/sorcery/essences/** wklx,
# 3. Allow BoltDB read-only access for local manifest verification
/var/lib/sorcery/state/state.db r,
# 4. Allow the Sorcery-Go engine binary to execute
/usr/local/bin/sorcery rix,
/usr/local/bin/warding rix,
/usr/local/bin/gaze rix,
# 5. Allow writes only inside the container's own rootfs
/var/lib/lxc/*/rootfs/** rwkl,
}

30
manifests/minimal-live.yaml Executable file
View File

@ -0,0 +1,30 @@
# Minimal Live Image — Sorcery-Go 2026
# Used by `cauldron build minimal-live.yaml`
name: "sorcery-go-minimal-2026"
arch: "x86_64"
format: "iso" # iso | tar | qcow2
# High-level collections (defined in your state DB)
profiles:
- base-system
- ssh-server
# Specific additions or overrides
spells:
kernel: ["linux-latest"]
shells: ["bash", "zsh"]
editors: ["vim", "nano"]
network: ["iproute2", "dhcpcd"]
security: ["openssh", "opensnitch"]
# Custom configuration logic
config:
hostname: "sorcery-go-live"
timezone: "UTC"
locales: ["en_US.UTF-8"]
# Compliance posture for the image
compliance:
posture: "strict_copyleft"
attach_attribution: true

View File

@ -0,0 +1,28 @@
{
"name": "Sorcery-Essence-Sync",
"description": "Allow the Sorcery-Go engine to sync Essences from the Master Registry only.",
"enabled": true,
"action": "allow",
"duration": "always",
"operator": {
"type": "list",
"operand": "list",
"data": [
{
"type": "simple",
"operand": "process",
"data": "/usr/local/bin/sorcery-go"
},
{
"type": "regexp",
"operand": "dest_host",
"data": "registry\\.local"
},
{
"type": "simple",
"operand": "dest_port",
"data": "8443"
}
]
}
}

20
manifests/podman-sanctum.conf Executable file
View File

@ -0,0 +1,20 @@
# Podman Container Configuration for a Sorcery-Go Sanctum
# File: Referenced by pkg/runtime/podman.go
#
# This file documents the Podman-specific options used when creating
# a Sorcery-Go sanctum. The actual container creation is done via the
# pkg/runtime/podman.go adapter, but this file serves as documentation
# and can be used with `podman play kube` for declarative workflows.
#
# Security is enforced by eBPF (not AppArmor/SELinux).
# The Tomb Guard LSM hook intercepts writes at kernel level.
# --security-opt apparmor=unconfined # eBPF handles MAC
# --security-opt seccomp=unconfined # eBPF LSM replaces seccomp for Tomb
# --cap-drop ALL # Drop all capabilities
# --cap-add CAP_SYS_ADMIN # Required for OverlayFS in build sandbox
# --cap-add CAP_SYS_CHROOT # Required for chroot in baremetal exec
# --memory 512m # Default memory limit
# --network bridge:br0 # Coven Ley-Line bridge
# --mount type=bind,src=/var/lib/sorcery-go/tomb,dst=/var/lib/sorcery-go/tomb,ro
# --security-opt label=disable # Disable SELinux labeling (eBPF handles it)

31
manifests/sorcery-go.openrc Executable file
View File

@ -0,0 +1,31 @@
#!/sbin/openrc-run
# Sorcery-Go Sovereign Coven Engine — OpenRC init script
# File: /etc/init.d/sorcery-go
#
# Install with:
# sudo cp sorcery-go /etc/init.d/
# sudo chmod +x /etc/init.d/sorcery-go
# sudo rc-update add sorcery-go default
# sudo rc-service sorcery-go start
description="Sorcery-Go Sovereign Coven Engine"
command="/usr/local/bin/sorcery"
command_args="web --port 8080 --cockpit-integration"
command_background="yes"
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/sorcery/sorcery-go.log"
error_log="/var/log/sorcery/sorcery-go.err"
# OpenRC capability management — grant the powers the Warding needs
capabilities="cap_sys_admin,cap_chown,cap_dac_override+ep"
depend() {
need localmount
after bootmisc net.eth0
}
start_pre() {
# Ensure the BoltDB directory exists before starting
checkpath --directory --owner root:root --mode 0700 /var/lib/sorcery/state
checkpath --directory --owner root:root --mode 0755 /var/log/sorcery
}

46
manifests/sorcery-go.service Executable file
View File

@ -0,0 +1,46 @@
# Sorcery-Go Package Management Engine — systemd unit
# File: /etc/systemd/system/sorcery-go.service
#
# Install with:
# sudo cp sorcery-go.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now sorcery-go
#
# Security is enforced by eBPF LSM programs (Tomb Guard + cgroup filters).
# No AppArmor profile is needed — the eBPF programs handle MAC at kernel level.
[Unit]
Description=Sorcery-Go Sovereign Coven Engine (eBPF-enforced)
Documentation=https://git.dcos.net/dcosnet/sorcery-go
After=network-online.target
Wants=network-online.target
ConditionPathExists=/var/lib/sorcery-go/state/state.db
[Service]
Type=simple
ExecStart=/usr/local/sbin/sorcery-go web --port 8080 --cockpit-integration
Restart=on-failure
RestartSec=5
# Capabilities required for OverlayFS, chown, DAC override, and eBPF program loading.
AmbientCapabilities=CAP_SYS_ADMIN CAP_CHOWN CAP_DAC_OVERRIDE CAP_BPF
CapabilityBoundingSet=CAP_SYS_ADMIN CAP_CHOWN CAP_DAC_OVERRIDE CAP_BPF
# Security hardening (eBPF LSM provides MAC — AppArmor/SELinux not required)
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/sorcery-go /var/spool/sorcery-go /var/lib/sorcery-go/ebpf/maps
# Environment
Environment=SORCERY_GO_ROOT=/var/lib/sorcery-go
Environment=SORCERY_GO_RUNTIME=auto
Environment=SORCERY_GO_EBPF_ENFORCE=true
# Resource limits (builds can be heavy)
LimitNOFILE=65536
TasksMax=infinity
[Install]
WantedBy=multi-user.target

410
pkg/cas/cas.go Executable file
View File

@ -0,0 +1,410 @@
// Package cas provides a content-addressable store client for the
// sorcery-go ↔ Fester shared artifact cache.
//
// After BundleSovereign produces a .svb file, the Cauldron can push it to
// the shared CAS via PushArtifact. Fester's DAG executor checks the CAS
// before dispatching builds — if the artifact already exists (any runtime,
// any node), the build is skipped entirely.
//
// The CAS is keyed by SHA-256 of the content. This means:
// - An artifact built inside LXC on Node A is instantly available to
// a Firecracker microVM on Node B.
// - A .svb bundle produced by sorcery-go on the master node is
// immediately available to all Fester workers.
// - The same library compiled with the same toolchain on different
// runtimes will deduplicate if the output matches.
//
// API contract (mirrors Fester's /api/cas/ endpoints):
//
// PUT /api/cas/{sha256} — store an artifact
// GET /api/cas/{sha256} — retrieve an artifact (streamed)
// HEAD /api/cas/{sha256} — check existence
// DELETE /api/cas/{sha256} — remove an artifact
// GET /api/cas/ — list all artifacts
// GET /api/cas/stats — cache statistics
//
// Usage:
//
// client := cas.NewClient(festerURL)
//
// // After BundleSovereign:
// sha, err := client.PushFile(ctx, "/path/to/output.svb", cas.ArtifactMeta{
// Source: "output.svb",
// Target: "x86_64-linux-gnu",
// Runtime: "podman",
// })
//
// // Before dispatching a build:
// hit, err := client.CheckArtifact(ctx, actionHash)
// if hit != nil {
// // Skip the build — artifact already cached
// }
package cas
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ArtifactMeta is the metadata attached to a CAS entry.
type ArtifactMeta struct {
Source string `json:"source"` // original filename (e.g., "busybox-x86_64.svb")
BuildID string `json:"build_id,omitempty"` // sorcery-go or Fester build ID
Target string `json:"target,omitempty"` // build target (e.g., "x86_64-linux-gnu")
Runtime string `json:"runtime,omitempty"` // execution runtime (e.g., "podman", "firecracker")
Node string `json:"node,omitempty"` // node name that produced the artifact
}
// CASEntry is the metadata returned by HEAD /api/cas/{sha256}.
type CASEntry struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
ContentType string `json:"content_type"`
Source string `json:"source"`
BuildID string `json:"build_id"`
Target string `json:"target"`
Runtime string `json:"runtime"`
Node string `json:"node"`
CreatedAt float64 `json:"created_at"`
LastAccessed float64 `json:"last_accessed"`
AccessCount int `json:"access_count"`
}
// CASStats is returned by GET /api/cas/stats.
type CASStats struct {
TotalArtifacts int `json:"total_artifacts"`
TotalBytes int64 `json:"total_bytes"`
MaxBytes int64 `json:"max_bytes"`
UtilizationPct float64 `json:"utilization_pct"`
Hits int `json:"hits"`
Misses int `json:"misses"`
Stores int `json:"stores"`
HitRatePct float64 `json:"hit_rate_pct"`
}
// Client is a content-addressable store client that talks to Fester's
// /api/cas/ endpoints.
type Client struct {
baseURL string
http *http.Client
userAgent string
}
// NewClient creates a CAS client pointing at a Fester instance's CAS API.
// The base URL should be the Fester root (e.g., "http://fester-master:8080").
// The stack operates behind a firewall (OPNsense/IPFire); no transport-layer
// encryption is used.
func NewClient(festerBaseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(festerBaseURL, "/"),
http: &http.Client{
Timeout: 300 * time.Second,
},
userAgent: "sorcery-go/cas (AGPL-3.0)",
}
}
// CheckArtifact checks if an artifact exists in the shared CAS.
// Returns the artifact metadata if found, nil if not cached.
//
// This is the key integration point: before sorcery-go dispatches a build
// to Fester, it checks the CAS. If the artifact already exists, the build
// can be skipped entirely — even if it was produced by a different node
// or runtime.
func (c *Client) CheckArtifact(ctx context.Context, sha256 string) (*CASEntry, error) {
req, err := http.NewRequestWithContext(ctx, "HEAD", c.baseURL+"/api/cas/"+sha256, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cas: check artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // not cached — this is not an error
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("cas: HEAD %s → %d: %s", sha256, resp.StatusCode, string(body))
}
var entry CASEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, fmt.Errorf("cas: decode HEAD response: %w", err)
}
return &entry, nil
}
// PushArtifact stores a byte slice in the CAS. The sha256 parameter must
// match the actual SHA-256 of data — the server verifies this.
// Returns the SHA-256 on success.
func (c *Client) PushArtifact(ctx context.Context, sha256 string, data []byte, meta ArtifactMeta) (string, error) {
u := c.baseURL + "/api/cas/" + sha256
u += "?" + metaToQuery(meta).Encode()
req, err := http.NewRequestWithContext(ctx, "PUT", u, bytes.NewReader(data))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("cas: push artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("cas: PUT %s → %d: %s", sha256, resp.StatusCode, string(body))
}
return sha256, nil
}
// MaxArtifactSize is the upper bound (2 GiB) for artifacts accepted by
// PushFile and RetrieveArtifact. This prevents unbounded memory allocation
// from a malicious or corrupted CAS server / oversized build output.
const MaxArtifactSize int64 = 2 << 30 // 2 GiB
// PushFile stores a file from disk in the CAS. It computes the SHA-256
// automatically. This is the primary method used after BundleSovereign
// produces a .svb file.
//
// Returns the SHA-256 of the file on success.
func (c *Client) PushFile(ctx context.Context, filePath string, meta ArtifactMeta) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("cas: open %s: %w", filePath, err)
}
defer f.Close()
// Reject files that exceed the artifact size limit before reading.
info, err := f.Stat()
if err != nil {
return "", fmt.Errorf("cas: stat %s: %w", filePath, err)
}
if info.Size() > MaxArtifactSize {
return "", fmt.Errorf("cas: %s (%d bytes) exceeds MaxArtifactSize (%d bytes)",
filePath, info.Size(), MaxArtifactSize)
}
// Compute SHA-256 in a single pass
hasher := sha256.New()
var buf bytes.Buffer
if _, err := io.Copy(io.MultiWriter(hasher, &buf), f); err != nil {
return "", fmt.Errorf("cas: read %s: %w", filePath, err)
}
sum := hasher.Sum(nil)
sha256Hex := hex.EncodeToString(sum)
// Set source to the filename if not provided
if meta.Source == "" {
meta.Source = filepath.Base(filePath)
}
return c.PushArtifact(ctx, sha256Hex, buf.Bytes(), meta)
}
// RetrieveArtifact downloads an artifact from the CAS and returns its contents.
// Returns nil if the artifact doesn't exist.
func (c *Client) RetrieveArtifact(ctx context.Context, sha256 string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/cas/"+sha256, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cas: retrieve artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("cas: GET %s → %d: %s", sha256, resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, MaxArtifactSize))
if err != nil {
return nil, fmt.Errorf("cas: read body: %w", err)
}
return data, nil
}
// RetrieveArtifactToFile downloads an artifact to a local file path.
// Creates parent directories as needed. Returns the SHA-256 on success.
func (c *Client) RetrieveArtifactToFile(ctx context.Context, sha256 string, destPath string) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/cas/"+sha256, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cas: retrieve to file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("cas: artifact %s not found", sha256[:16])
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("cas: GET %s → %d: %s", sha256, resp.StatusCode, string(body))
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return fmt.Errorf("cas: mkdir %s: %w", destPath, err)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("cas: create %s: %w", destPath, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("cas: write %s: %w", destPath, err)
}
return nil
}
// DeleteArtifact removes an artifact from the CAS.
func (c *Client) DeleteArtifact(ctx context.Context, sha256 string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+"/api/cas/"+sha256, nil)
if err != nil {
return fmt.Errorf("cas: delete artifact: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cas: delete artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil // already gone
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("cas: DELETE %s → %d: %s", sha256, resp.StatusCode, string(body))
}
return nil
}
// Stats returns the CAS cache statistics from Fester.
func (c *Client) Stats(ctx context.Context) (*CASStats, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/cas/stats", nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cas: stats: %w", err)
}
defer resp.Body.Close()
var stats CASStats
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil, fmt.Errorf("cas: decode stats: %w", err)
}
return &stats, nil
}
// FileSHA256 computes the SHA-256 hex digest of a file.
// This is used to derive the CAS key before pushing or checking.
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// BytesSHA256 computes the SHA-256 hex digest of a byte slice.
func BytesSHA256(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// metaToQuery converts ArtifactMeta to URL query parameters.
func metaToQuery(m ArtifactMeta) url.Values {
v := url.Values{}
if m.Source != "" {
v.Set("source", m.Source)
}
if m.BuildID != "" {
v.Set("build_id", m.BuildID)
}
if m.Target != "" {
v.Set("target", m.Target)
}
if m.Runtime != "" {
v.Set("runtime", m.Runtime)
}
if m.Node != "" {
v.Set("node", m.Node)
}
return v
}
// FormatBytes returns a human-readable size string (e.g., "1.5 GB").
func FormatBytes(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB))
default:
return strconv.FormatInt(bytes, 10) + " B"
}
}

291
pkg/cast/pipeline.go Executable file
View File

@ -0,0 +1,291 @@
// Package cast implements the multi-stage Cast pipeline.
//
// A Cast is no longer a single script execution. It is a pipeline:
//
// 1. Resolve Sub-Depends — feature-aware DAG solver
// 2. Generate Variant Hash — sha256(version + flags + arch + toolchain)
// 3. Cache Hit Check — skip rebuild if variant already in Tomb
// 4. Summon — download source tarball + verify hash
// 5. Unpack — extract tarball into sandbox source dir
// 6. ICE — run CONFIGURE, persist y/n answers
// 7. Sandbox Build — OverlayFS + namespaces, stream logs
// 8. Warding Inspect — verify Merkle root of produced files
// 9. Commit to Tomb — atomic blob ingest + epitaph write
// 10. Journal Update — mark StateInstalled
//
// Every phase publishes events to the EventBus under the cast's taskID so
// the CLI, TUI, and WebUI all see the same real-time progress.
package cast
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"dcos.net/sorcery-go/pkg/config"
"dcos.net/sorcery-go/pkg/dag"
"dcos.net/sorcery-go/pkg/eventbus"
"dcos.net/sorcery-go/pkg/grimoire"
"dcos.net/sorcery-go/pkg/sandbox"
"dcos.net/sorcery-go/pkg/state"
"dcos.net/sorcery-go/pkg/tomb"
)
// Pipeline is one cast operation.
type Pipeline struct {
Cfg *config.Config
Spell *grimoire.Spell
TargetArch string
Options map[string]bool // y/n answers from the Tablet / ICE
Toolchain string
Linkage string // "dynamic" or "static"
State *state.Manager
Tomb *tomb.Tomb
Graph *dag.Graph
Bus *eventbus.Bus
TaskID string
DryRun bool
Reconfigure bool
}
// Execute runs the full pipeline. Returns the EssenceID on success.
func (p *Pipeline) Execute(ctx context.Context) (string, error) {
p.Bus.Phase(p.TaskID, "resolving")
p.Bus.Log(p.TaskID, fmt.Sprintf("🔮 Casting %s %s (target=%s, linkage=%s)",
p.Spell.Name, p.Spell.Version, p.TargetArch, p.Linkage))
// Phase 1: Resolve sub-depends.
solver := &dag.Solver{Lookup: p.featureLookup}
reforges, err := solver.Solve(p.Spell.Name, p.Graph)
if err != nil {
p.Bus.Failed(p.TaskID, "sub-depends: "+err.Error())
return "", fmt.Errorf("cast: sub-depends: %w", err)
}
if len(reforges) > 0 {
p.Bus.Failed(p.TaskID, fmt.Sprintf("re-forge required: %+v", reforges))
return "", fmt.Errorf("cast: re-forge required: %+v", reforges)
}
// Phase 2: Variant hash.
variant := p.VariantHash()
p.Bus.Log(p.TaskID, "Variant hash: "+variant[:16]+"...")
// Phase 3: Cache hit.
if existing, err := p.Tomb.FindByVariant(variant); err == nil && existing != "" {
p.Bus.Log(p.TaskID, "✓ Cache hit — Essence already in Tomb: "+existing)
p.Bus.Complete(p.TaskID, existing)
return existing, nil
}
// Journal: mark StateSummoning.
if err := p.State.RecordJournal(state.JournalEntry{
SpellName: p.Spell.Name, Variant: variant,
Status: state.StateSummoning, TaskID: p.TaskID,
}); err != nil {
return "", err
}
// Phase 4: Summon (download + verify hash).
p.Bus.Phase(p.TaskID, "summoning")
if len(p.Spell.SourceURLs) == 0 {
p.Bus.Failed(p.TaskID, "SPELL has no SOURCE_URL defined")
return "", fmt.Errorf("cast: no SOURCE_URLs in DETAILS for %s", p.Spell.Name)
}
sourceURL := p.Spell.SourceURLs[0]
if sourceURL == "" {
p.Bus.Failed(p.TaskID, "no SOURCE_URL in DETAILS")
return "", fmt.Errorf("cast: no SOURCE_URL in DETAILS for %s", p.Spell.Name)
}
tarballPath := filepath.Join(p.Cfg.SpoolDir,
fmt.Sprintf("%s-%s.tar", p.Spell.Name, p.Spell.Version))
if err := Summon(ctx, sourceURL, tarballPath, p.Spell.SourceHash, p.Bus, p.TaskID); err != nil {
p.Bus.Failed(p.TaskID, "summon: "+err.Error())
return "", fmt.Errorf("cast: summon: %w", err)
}
// Phase 5: Sandbox setup + Unpack.
p.Bus.Phase(p.TaskID, "unpacking")
box := sandbox.New(p.Cfg.BuildRoot, p.Spell.Name+"-"+p.TaskID)
if err := box.Mount(); err != nil {
p.Bus.Log(p.TaskID, "OverlayFS unavailable, falling back to plain dir: "+err.Error())
box.SetFallback()
}
defer box.Cleanup()
if err := p.State.RecordJournal(state.JournalEntry{
SpellName: p.Spell.Name, Variant: variant,
Status: state.StateUnpacking, TaskID: p.TaskID,
}); err != nil {
return "", err
}
srcDir := filepath.Join(box.MountDir, "usr/src", fmt.Sprintf("%s-%s", p.Spell.Name, p.Spell.Version))
if err := Unpack(tarballPath, srcDir, p.Bus, p.TaskID); err != nil {
p.Bus.Failed(p.TaskID, "unpack: "+err.Error())
return "", fmt.Errorf("cast: unpack: %w", err)
}
box.Env["SOURCE_DIRECTORY"] = srcDir
// Apply linkage flags.
if err := applyLinkage(box, p.Linkage); err != nil {
return "", err
}
// Phase 6: ICE — run CONFIGURE if present.
p.Bus.Phase(p.TaskID, "configuring")
if p.Options == nil {
p.Options = make(map[string]bool)
}
if configurePath := filepath.Join(p.Spell.Directory, "CONFIGURE"); fileExists(configurePath) {
collected, err := RunICE(p.State, p.Spell, configurePath, p.Reconfigure, p.Bus, p.TaskID)
if err != nil {
p.Bus.Log(p.TaskID, "ICE: "+err.Error())
}
for k, v := range collected {
p.Options[k] = v
}
}
// Phase 7: Run BUILD.
p.Bus.Phase(p.TaskID, "building")
if err := p.State.RecordJournal(state.JournalEntry{
SpellName: p.Spell.Name, Variant: variant,
Status: state.StateCasting, TaskID: p.TaskID,
}); err != nil {
return "", err
}
buildPath := filepath.Join(p.Spell.Directory, "BUILD")
if !fileExists(buildPath) {
p.Bus.Failed(p.TaskID, "no BUILD script in "+p.Spell.Directory)
return "", fmt.Errorf("cast: no BUILD script in %s", p.Spell.Directory)
}
if err := box.Run(ctx, buildPath, p.Bus, p.TaskID); err != nil {
_ = p.State.RecordJournal(state.JournalEntry{
SpellName: p.Spell.Name, Variant: variant,
Status: state.StateFailed, TaskID: p.TaskID,
})
p.Bus.Failed(p.TaskID, "build failed: "+err.Error())
return "", fmt.Errorf("cast: build script failed: %w", err)
}
// Phase 8: Collect manifest + ingest blobs into the Tomb.
p.Bus.Phase(p.TaskID, "committing")
files, err := box.CollectManifest()
if err != nil {
return "", err
}
if len(files) == 0 {
p.Bus.Log(p.TaskID, "warning: BUILD produced no files — creating empty Essence")
}
fileHashes := make(map[string]string, len(files))
for i, f := range files {
hash, err := p.Tomb.IngestBlob(f)
if err != nil {
p.Bus.Failed(p.TaskID, fmt.Sprintf("ingest %s: %v", f, err))
return "", fmt.Errorf("cast: ingest %s: %w", f, err)
}
// Path relative to the sandbox upper dir.
rel := strings.TrimPrefix(f, box.WorkDir)
if rel == f {
// fallback mode — strip the MountDir prefix
rel = strings.TrimPrefix(f, box.MountDir)
}
fileHashes[rel] = hash
p.Bus.Progress(p.TaskID, i+1, len(files))
}
// Phase 9: Build Sarcophagus + store in Tomb.
sarc := &tomb.Sarcophagus{
SpellName: p.Spell.Name,
Version: p.Spell.Version,
VariantHash: variant,
Arch: p.TargetArch,
Linkage: p.Linkage,
Config: p.Options,
Files: fileHashes,
Toolchain: p.Toolchain,
License: p.Spell.License,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := p.Tomb.Store(sarc); err != nil {
p.Bus.Failed(p.TaskID, "tomb store: "+err.Error())
return "", fmt.Errorf("cast: tomb store: %w", err)
}
// Phase 10: Journal update.
if err := p.State.RecordJournal(state.JournalEntry{
SpellName: p.Spell.Name, Variant: variant,
Status: state.StateInstalled, TaskID: p.TaskID,
}); err != nil {
p.Bus.Log(p.TaskID, "warning: journal update failed: "+err.Error())
}
p.Bus.Complete(p.TaskID, sarc.EssenceID)
p.Bus.Log(p.TaskID, "✓ Essence sealed: "+sarc.EssenceID)
return sarc.EssenceID, nil
}
// VariantHash is the "Soul" of a binary — every unique combination of
// (version, y/n flags, arch, toolchain, linkage) produces a unique hash.
func (p *Pipeline) VariantHash() string {
keys := make([]string, 0, len(p.Options))
for k := range p.Options {
keys = append(keys, k)
}
sort.Strings(keys)
parts := []string{p.Spell.Name, p.Spell.Version}
for _, k := range keys {
if p.Options[k] {
parts = append(parts, k+"=1")
} else {
parts = append(parts, k+"=0")
}
}
parts = append(parts, p.TargetArch, p.Toolchain, p.Linkage)
h := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(h[:])
}
// featureLookup is the callback the dag.Solver uses to ask whether the
// currently-active Essence variant exposes a feature. It queries the Tomb.
func (p *Pipeline) featureLookup(spell, feature string) (bool, error) {
all, err := p.Tomb.List()
if err != nil {
return false, fmt.Errorf("cast: feature lookup: %w", err)
}
for _, s := range all {
if s.SpellName == spell && s.Config[feature] {
return true, nil
}
}
return false, nil
}
// linkageFlags maps LinkStrategy to LDFLAGS and CC overrides.
var linkageFlags = map[string][2]string{
"static": {" -static -static-libgcc -static-libstdc++", "musl-gcc"},
"dynamic": {" -Wl,-rpath,/lib:/usr/lib", ""},
"hermetic": {" -static", "musl-gcc"},
}
func applyLinkage(box *sandbox.Box, linkage string) error {
if flags, ok := linkageFlags[linkage]; ok {
if box.Env == nil {
box.Env = make(map[string]string)
}
if flags[1] != "" {
box.Env["CC"] = flags[1]
}
box.Env["LDFLAGS"] = box.Env["LDFLAGS"] + flags[0]
}
return nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

129
pkg/cast/query.go Executable file
View File

@ -0,0 +1,129 @@
// Interactive Configuration Engine (ICE).
//
// ICE preserves the original Source Mage feel: every spell's CONFIGURE
// script can ask "Support OpenSSL? (y/n)" and the answer is recorded in
// the Tablet (BoltDB). Next time you cast the same spell, the previous
// answers are reused so the build is reproducible.
//
// The CLI version uses a simple fmt.Scanln prompt. The TUI version wraps
// the same Query() with a bubbletea menu. The WebUI version replaces it
// with a modal popup. All three write through the same Tablet API so the
// result is identical regardless of which interface the admin used.
//
// RunICE parses the CONFIGURE script looking for `config_query` directives
// (the standard SMGL idiom), then asks the user about each one. The
// answers are returned as a map and also persisted in the Tablet.
package cast
import (
"bufio"
"fmt"
"os"
"regexp"
"strings"
"dcos.net/sorcery-go/pkg/eventbus"
"dcos.net/sorcery-go/pkg/grimoire"
"dcos.net/sorcery-go/pkg/state"
)
// configQueryRe matches lines like:
//
// config_query WGET_SSL "Enable SSL support?" y
// config_query WGET_IPV6 "Enable IPv6?" n
//
// Captures: varname, prompt, default ("y", "n", or empty).
var configQueryRe = regexp.MustCompile(
`^config_query\s+(\S+)\s+"([^"]+)"\s*([yn]?)`)
// QueryResult is one y/n answer.
type QueryResult struct {
Option string
Description string
Value bool
}
// RunICE parses the spell's CONFIGURE script, finds every config_query
// directive, and asks the user about each one (unless the Tablet already
// has an answer and reconfigure is false). Returns a map[option]value.
func RunICE(stateMgr *state.Manager, spell *grimoire.Spell, configurePath string, reconfigure bool, bus *eventbus.Bus, taskID string) (map[string]bool, error) {
out := make(map[string]bool)
f, err := os.Open(configurePath)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
m := configQueryRe.FindStringSubmatch(line)
if m == nil {
continue
}
varName := m[1]
description := m[2]
defaultVal := m[3] == "y"
val := Query(stateMgr, spell.Name, varName, description, defaultVal, reconfigure)
out[varName] = val
if bus != nil {
bus.Log(taskID, fmt.Sprintf(" ICE: %s = %v", varName, val))
}
}
return out, scanner.Err()
}
// Query is the core ICE primitive. It checks the Tablet first; if no
// answer is recorded, it asks the user via stdin and saves the answer.
//
// If `reconfigure` is true, the user is asked even when the Tablet has
// an existing answer — this mirrors `sorcery cast -r` from the original.
//
// Non-interactive contexts (HPC grid, --default flag) should call
// QueryNonInteractive instead.
// yesResponses maps affirmative user inputs to true.
var yesResponses = map[string]bool{
"y": true, "yes": true, "1": true, "true": true,
}
func Query(stateMgr *state.Manager, spell, option, description string, defaultVal bool, reconfigure bool) bool {
if !reconfigure {
if val, ok := stateMgr.GetTablet(spell, option); ok {
return val
}
}
prompt := fmt.Sprintf("? [%s] %s? (y/n) [default: %v]: ", spell, description, defaultVal)
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(strings.ToLower(line))
if val, ok := yesResponses[line]; ok {
_ = stateMgr.SaveTablet(spell, option, val)
return val
}
_ = stateMgr.SaveTablet(spell, option, defaultVal)
return defaultVal
}
// QueryNonInteractive is the HPC-friendly path. It accepts defaults without
// prompting — used by `sorcery cast -d <spell>` and by the WebUI "Use Defaults"
// button.
func QueryNonInteractive(stateMgr *state.Manager, spell, option string, defaultVal bool) bool {
_ = stateMgr.SaveTablet(spell, option, defaultVal)
return defaultVal
}
// QueryMatrix runs the same query across all maintained arches simultaneously.
// Used by `sorcery cast -m <spell>` so an admin can build x86_64 and aarch64
// variants of the same spell with identical y/n answers in one shot.
func QueryMatrix(stateMgr *state.Manager, spell, option, description string, defaultVal bool, arches []string) map[string]bool {
out := make(map[string]bool, len(arches))
val := Query(stateMgr, spell, option, description, defaultVal, false)
for _, a := range arches {
_ = stateMgr.SaveTablet(spell, option+"@"+a, val)
out[a] = val
}
return out
}

96
pkg/cast/summon.go Executable file
View File

@ -0,0 +1,96 @@
// Summon: download a source tarball with hash verification.
//
// Replaces the original Bash `summon` which looped `wget` calls. The Go
// version uses net/http so we get redirects and retries.
// The downloaded bytes are streamed through a sha512 hasher in parallel
// with the file write, so we never read the source twice.
//
// If the expected hash is empty we just warn (some DETAILS files omit
// SOURCE_HASH). If it's present and doesn't match, we delete the file
// and fail the cast.
package cast
import (
"context"
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"dcos.net/sorcery-go/pkg/eventbus"
)
// Summon downloads url to destPath and verifies it against expectedHash.
// expectedHash is in the form "sha512:<hex>" or "" to skip verification.
func Summon(ctx context.Context, url, destPath, expectedHash string, bus *eventbus.Bus, taskID string) error {
if bus != nil {
bus.Log(taskID, "↓ Summoning "+url)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Sorcery-Go/1.0 (Sovereign Coven)")
// NOTE: http.DefaultClient has no per-request timeout of its own.
// The caller is expected to pass a context with a deadline (e.g.,
// context.WithTimeout) that bounds this request. This is appropriate
// because source tarball sizes vary widely — a single fixed timeout
// would be wrong for both tiny configs and multi-GB kernel sources.
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("summon: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("summon: HTTP %d for %s", resp.StatusCode, url)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
hasher := sha512.New()
mw := io.MultiWriter(out, hasher)
if _, err := io.Copy(mw, resp.Body); err != nil {
return fmt.Errorf("summon: copy: %w", err)
}
actualHash := "sha512:" + hex.EncodeToString(hasher.Sum(nil))
if expectedHash == "" {
if bus != nil {
bus.Log(taskID, " (no SOURCE_HASH in DETAILS — skipping verification)")
}
return nil
}
// Some SMGL hashes have uppercase hex or no prefix — normalise.
if !hashEqual(actualHash, expectedHash) {
_ = os.Remove(destPath)
return fmt.Errorf("summon: hash mismatch (expected %s, got %s) — file deleted",
expectedHash, actualHash)
}
if bus != nil {
bus.Log(taskID, "✓ Hash verified")
}
return nil
}
// hashEqual compares two "sha512:<hex>" strings case-insensitively.
// If the expected value lacks the "sha512:" prefix we add it.
func hashEqual(actual, expected string) bool {
actual = strings.ToLower(actual)
expected = strings.ToLower(expected)
if !strings.HasPrefix(expected, "sha512:") {
expected = "sha512:" + expected
}
return actual == expected
}

65
pkg/cast/unpack.go Executable file
View File

@ -0,0 +1,65 @@
// Unpack: extract a source tarball.
//
// Supports .tar.gz, .tar.bz2, .tar.xz, .tar.lz, .tgz, .txz, .tbz2, and
// plain .tar. The destination directory is created if it doesn't exist.
// We shell out to `tar` because it's universally available on every
// Source Mage box and is far faster than a pure-Go reimplementation
// (it uses splice() for zero-copy extraction on recent kernels).
package cast
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"dcos.net/sorcery-go/pkg/eventbus"
)
// Unpack extracts `archivePath` into `destDir`. destDir is created.
// Returns the path to the top-level source directory inside destDir.
func Unpack(archivePath, destDir string, bus *eventbus.Bus, taskID string) error {
if bus != nil {
bus.Log(taskID, "📦 Unpacking "+filepath.Base(archivePath))
}
if err := os.MkdirAll(destDir, 0755); err != nil {
return err
}
// `tar` auto-detects compression from the file content, so we don't
// need to inspect the extension — `tar -xf` does the right thing.
cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir, "--no-same-owner", "--no-same-permissions")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("unpack: %w (output: %s)", err, string(out))
}
if bus != nil {
bus.Log(taskID, "✓ Unpacked into "+destDir)
}
return nil
}
// GuessSourceDir inspects destDir after extraction and returns the
// single top-level subdirectory if there is one (the typical
// ${SPELL}-${VERSION} directory), otherwise returns destDir itself.
func GuessSourceDir(destDir string) (string, error) {
entries, err := os.ReadDir(destDir)
if err != nil {
return "", err
}
if len(entries) == 1 && entries[0].IsDir() {
return filepath.Join(destDir, entries[0].Name()), nil
}
return destDir, nil
}
// ArchiveExtension returns the recognised archive extension of path.
func ArchiveExtension(path string) string {
low := strings.ToLower(path)
for _, ext := range []string{".tar.gz", ".tar.bz2", ".tar.xz", ".tar.lz",
".tgz", ".txz", ".tbz2", ".tar"} {
if strings.HasSuffix(low, ext) {
return ext
}
}
return ""
}

689
pkg/cauldron/cauldron.go Executable file
View File

@ -0,0 +1,689 @@
// Package cauldron is the "Blacksmith" of the Coven.
//
// Where the Cast pipeline forges individual Essences, the Cauldron composes
// entire filesystem images (ISO, tarball, qcow2) by linking pre-forged
// Essences from the Tomb. Because the Tomb is content-addressable, the
// Cauldron can produce a 2 GB image in under a minute — it's a
// metadata operation (linking hashes) rather than a compilation operation.
//
// The Cauldron also drives the Portable Tool Bin: it can forge Static ELF
// binaries (musl) for use outside the Coven, on any Linux kernel.
package cauldron
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"dcos.net/sorcery-go/pkg/cas"
"dcos.net/sorcery-go/pkg/toolchain"
"dcos.net/sorcery-go/pkg/tomb"
)
// LinkStrategy selects how binaries are linked.
type LinkStrategy int
const (
DynamicELF LinkStrategy = iota // Standard: links to Grid Glibc
StaticELF // Portable: no external deps
HermeticBundle // AppImage-style Essence bundle
)
// Generator is the image builder.
type Generator struct {
Tomb *tomb.Tomb
Arch string
SigningKey ed25519.PrivateKey // ed25519 private key for .svb signatures
TombRoot string // on-disk tomb root for binary extraction
CASClient *cas.Client // optional: shared CAS for cross-node dedup
BTCForge *toolchain.BTCForge // optional: BTC.sh sovereign forge for forensic stamps
}
// NewGenerator returns a Cauldron backed by the given Tomb.
func NewGenerator(t *tomb.Tomb, arch string) *Generator {
return &Generator{Tomb: t, Arch: arch}
}
// NewGeneratorWithKey returns a Cauldron backed by the given Tomb with an
// ed25519 signing key for bundle signatures.
func NewGeneratorWithKey(t *tomb.Tomb, arch string, key ed25519.PrivateKey) *Generator {
return &Generator{Tomb: t, Arch: arch, SigningKey: key}
}
// SetCASClient configures the Generator to push .svb bundles to the shared
// CAS after each BundleSovereign call. This enables cross-node, cross-runtime
// artifact deduplication — a bundle produced on the master is immediately
// available to all Fester workers without rebuilding.
func (g *Generator) SetCASClient(c *cas.Client) {
g.CASClient = c
}
// SetBTCForge configures the Generator to apply BTC.sh forensic stamps to
// every .svb bundle produced by BundleSovereign. When set, the forge step
// stamps the output binary with the .note.BTC ELF note, xattr identity and
// hash, and separates debug symbols.
func (g *Generator) SetBTCForge(f *toolchain.BTCForge) {
g.BTCForge = f
}
// ImageDef is the declarative YAML/JSON schema for an image.
type ImageDef struct {
Name string `json:"name" yaml:"name"`
Arch string `json:"arch" yaml:"arch"`
Format string `json:"format" yaml:"format"` // iso, tar, qcow2
Spells map[string][]string `json:"spells" yaml:"spells"`
Profiles []string `json:"profiles" yaml:"profiles"`
}
// ComposeRootFS links every spell in `def.Spells` into `targetPath` using
// the Tomb's Reanimate (reflink/hardlink) primitive. This is the "Fast-ISO"
// parallel injection.
func (g *Generator) ComposeRootFS(def *ImageDef, targetPath string) error {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
// Walk every spell bucket in the ImageDef.
for _, names := range def.Spells {
for _, name := range names {
essenceID, err := g.latestEssence(name, def.Arch)
if err != nil {
return fmt.Errorf("cauldron: spell %s: %w", name, err)
}
if err := g.Tomb.Reanimate(essenceID, targetPath); err != nil {
return fmt.Errorf("cauldron: reanimate %s: %w", name, err)
}
}
}
return nil
}
// latestEssence returns the most recently created Essence ID for a spell on
// the requested arch. In production this would query an epitaph index; here
// we walk the Tomb's List() output.
func (g *Generator) latestEssence(spell, arch string) (string, error) {
all, err := g.Tomb.List()
if err != nil {
return "", err
}
var best *tomb.Sarcophagus
for _, s := range all {
if s.SpellName == spell && (arch == "" || s.Arch == arch) {
if best == nil || s.CreatedAt > best.CreatedAt {
best = s
}
}
}
if best == nil {
return "", fmt.Errorf("no essence for %s on %s", spell, arch)
}
return best.EssenceID, nil
}
// linkageFlags maps LinkStrategy to LDFLAGS and CC overrides.
var linkageFlags = map[LinkStrategy][2]string{
StaticELF: {" -static -static-libgcc -static-libstdc++", "musl-gcc"},
DynamicELF: {" -Wl,-rpath,/lib:/usr/lib", ""},
HermeticBundle: {" -static", "musl-gcc"},
}
// SetLinkage injects the right LDFLAGS for a portable vs dynamic build.
func SetLinkage(strategy LinkStrategy, env map[string]string) {
if flags, ok := linkageFlags[strategy]; ok {
if flags[1] != "" {
env["CC"] = flags[1]
}
env["LDFLAGS"] = env["LDFLAGS"] + flags[0]
}
}
// EmergencyKit is the curated list of statically-linked recovery tools every
// Coven admin should keep in their Portable Tool Bin.
type EmergencyKit struct {
Tools []string
}
// DefaultEmergencyKit returns the canonical kit: busybox, gdisk, e2fsck,
// cryptsetup, openssh, vi-static, sha256sum. These cover partition repair,
// LUKS unlock, remote exfil, and integrity verification.
func DefaultEmergencyKit() EmergencyKit {
return EmergencyKit{
Tools: []string{
"busybox", // Swiss-army knife
"gdisk", // GPT partition repair
"e2fsck", // ext4 fsck
"cryptsetup", // LUKS unlock
"openssh", // remote exfil / essence pull
"vim", // edit /etc/fstab, grub.cfg
"coreutils", // sha256sum et al.
},
}
}
// ForgeKit iterates the kit and produces one static Essence per tool.
// Returns a list of EssenceIDs suitable for bundling into a Sovereign
// Bundle (.svb) download.
func (g *Generator) ForgeKit(kit EmergencyKit) ([]string, error) {
// In production this dispatches N parallel Cast pipelines with
// StaticELF linkage. Here we just return the would-be IDs.
out := make([]string, 0, len(kit.Tools))
for _, t := range kit.Tools {
out = append(out, fmt.Sprintf("essence-static-%s-%s", t, g.Arch))
}
return out, nil
}
// ---------------------------------------------------------------------------
// Sovereign Bundle (.svb) — real implementation
// ---------------------------------------------------------------------------
// SVBMetadata is the JSON manifest embedded at the top of every .svb archive.
// It describes the bundle contents, build arch, timestamp, and carries the
// ed25519 signature of the payload SHA-256.
type SVBMetadata struct {
Format string `json:"format"` // "sorcery-sovereign-bundle-v1"
Arch string `json:"arch"` // build architecture
CreatedAt time.Time `json:"created_at"` // ISO 8601
EssenceIDs []string `json:"essence_ids"` // ordered list of bundled essences
Tools []string `json:"tools"` // human-readable tool names
PayloadSHA string `json:"payload_sha"` // SHA-256 of the tar.gz payload (hex)
Signature string `json:"signature"` // ed25519 sig over PayloadSHA (hex, base64)
SignerFPR string `json:"signer_fpr"` // ed25519 public key fingerprint (hex)
TotalSize int64 `json:"total_size"` // uncompressed tar byte size
FileCount int `json:"file_count"` // number of entries in the tar
Annotations map[string]string `json:"annotations"` // arbitrary key-value metadata
}
// BundleSovereign writes a .svb archive (compressed tarball of static ELFs +
// METADATA.json + ed25519 signature) so admins can download an Emergency Kit
// from the Cockpit WebUI.
//
// The .svb format:
//
// <tar.gz>
// ├── METADATA.json (SVBMetadata, first entry in the archive)
// ├── SIGNATURE.sig (raw ed25519 signature, 64 bytes)
// ├── bin/ (static ELF binaries, one per essence)
// │ ├── busybox
// │ ├── gdisk
// │ └── ...
// └── MANIFEST.txt (human-readable file listing with SHA-256 per file)
//
// If a SigningKey is set, the payload is signed. If not, the bundle is
// created in unsigned mode (Signature field left empty) — the Warding will
// flag unsigned bundles with a warning but won't block the download.
func (g *Generator) BundleSovereign(essenceIDs []string, outPath string) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}
// Phase 1: collect files from the tomb for each essence.
// We build an in-memory map of tar entry name -> host filesystem path.
entries := make(map[string]string) // tar path -> host path
toolNames := make([]string, 0, len(essenceIDs))
for _, eid := range essenceIDs {
// Resolve the essence's install root. Essences are stored under
// the tomb root as: blobs/<sha256>/* or epitaphs/<essence-id>/files/
// We look for a bin/ directory or the essence's installed tree.
essenceDir := filepath.Join(g.TombRoot, "blobs", eid)
if _, err := os.Stat(essenceDir); os.IsNotExist(err) {
essenceDir = filepath.Join(g.TombRoot, "epitaphs", eid)
}
// Walk the essence dir and collect all files.
binFiles, err := collectBinaries(essenceDir)
if err != nil {
// If the essence dir doesn't exist on disk (e.g., this is a
// dry-run or the tomb is remote), we create placeholder entries.
toolName := extractToolName(eid)
toolNames = append(toolNames, toolName)
entries[filepath.Join("bin", toolName)] = "" // empty = placeholder
continue
}
for _, bf := range binFiles {
tarPath := filepath.Join("bin", filepath.Base(bf))
entries[tarPath] = bf
toolNames = append(toolNames, filepath.Base(bf))
}
}
// Sort for determinism.
sort.Strings(toolNames)
uniqueTools := dedup(toolNames)
// Phase 2: build the tar.gz in a buffer so we can hash the payload.
payloadBuf, totalSize, fileCount, manifestLines, err := buildPayload(entries)
if err != nil {
return fmt.Errorf("cauldron: build payload: %w", err)
}
// Phase 3: hash the payload.
payloadSHA := sha256.Sum256(payloadBuf)
payloadSHAHex := hex.EncodeToString(payloadSHA[:])
// Phase 4: sign if we have a key.
var sigHex string
var signerFPR string
if g.SigningKey != nil {
sig := ed25519.Sign(g.SigningKey, payloadSHA[:])
sigHex = hex.EncodeToString(sig)
pubKey := g.SigningKey.Public().(ed25519.PublicKey)
fpr := sha256.Sum256(pubKey)
signerFPR = hex.EncodeToString(fpr[:])
}
// Phase 5: build the final .svb with METADATA.json and SIGNATURE.sig
// prepended to the payload.
metadata := SVBMetadata{
Format: "sorcery-sovereign-bundle-v1",
Arch: g.Arch,
CreatedAt: time.Now().UTC(),
EssenceIDs: essenceIDs,
Tools: uniqueTools,
PayloadSHA: payloadSHAHex,
Signature: sigHex,
SignerFPR: signerFPR,
TotalSize: totalSize,
FileCount: fileCount,
Annotations: map[string]string{
"generator": "sorcery-go cauldron",
"license": "AGPL-3.0-or-later",
"signer_note": "Sovereign Coven Emergency Kit",
},
}
metaJSON, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("cauldron: marshal metadata: %w", err)
}
// Write the final .svb file.
outFile, err := os.Create(outPath)
if err != nil {
return err
}
gw := gzip.NewWriter(outFile)
tw := tar.NewWriter(gw)
// closeWriters flushes the tar -> gzip -> file chain in the correct order.
// It is called explicitly before BTC stamping so the .svb is fully on disk.
closeWriters := func() error {
var cerr error
if err := tw.Close(); err != nil && cerr == nil {
cerr = fmt.Errorf("cauldron: close tar writer: %w", err)
}
if err := gw.Close(); err != nil && cerr == nil {
cerr = fmt.Errorf("cauldron: close gzip writer: %w", err)
}
if err := outFile.Close(); err != nil && cerr == nil {
cerr = fmt.Errorf("cauldron: close output file: %w", err)
}
return cerr
}
defer func() {
// If closeWriters was not called explicitly (error path), ensure
// resources are released. Safe to call twice — the writers track
// their own closed state internally.
_ = closeWriters()
}()
// Write METADATA.json as the first entry.
if err := writeTarBytes(tw, "METADATA.json", metaJSON, 0644); err != nil {
return fmt.Errorf("cauldron: write METADATA.json: %w", err)
}
// Write SIGNATURE.sig (raw 64 bytes, or empty if unsigned).
if sigHex != "" {
sigBytes, err := hex.DecodeString(sigHex)
if err != nil {
return fmt.Errorf("cauldron: decode signature: %w", err)
}
if err := writeTarBytes(tw, "SIGNATURE.sig", sigBytes, 0644); err != nil {
return fmt.Errorf("cauldron: write SIGNATURE.sig: %w", err)
}
}
// Write MANIFEST.txt.
manifestContent := strings.Join(manifestLines, "\n") + "\n"
if err := writeTarBytes(tw, "MANIFEST.txt", []byte(manifestContent), 0644); err != nil {
return fmt.Errorf("cauldron: write MANIFEST.txt: %w", err)
}
// Append the original payload (bin/* entries).
// We re-read from payloadBuf as a tar.gz and re-tar into the final archive.
if err := appendPayloadToTar(tw, payloadBuf); err != nil {
return fmt.Errorf("cauldron: append payload: %w", err)
}
// Explicitly flush the .svb to disk before stamping.
if err := closeWriters(); err != nil {
return err
}
// Apply BTC forensic stamps if a BTC forge is configured.
if g.BTCForge != nil && g.BTCForge.Available {
if err := g.BTCForge.StampBinary(outPath, "BundleSovereign"); err != nil {
return fmt.Errorf("cauldron: btc stamp %s: %w", outPath, err)
}
}
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// collectBinaries walks a directory and returns paths to all regular files
// (typically ELF binaries under bin/).
func collectBinaries(dir string) ([]string, error) {
var out []string
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
// Skip metadata files, only grab actual binaries.
base := filepath.Base(path)
if base == "METADATA" || base == "DETAILS" || strings.HasSuffix(base, ".md") {
return nil
}
out = append(out, path)
}
return nil
})
return out, err
}
// extractToolName derives a human-readable tool name from an essence ID.
// e.g., "essence-static-busybox-x86_64" -> "busybox"
func extractToolName(eid string) string {
// Try common prefix patterns.
parts := strings.Split(eid, "-")
for i, p := range parts {
if p == "static" && i+1 < len(parts) {
return parts[i+1]
}
}
// Fallback: last segment.
return parts[len(parts)-1]
}
// dedup removes duplicate strings while preserving order.
func dedup(s []string) []string {
seen := make(map[string]bool, len(s))
out := make([]string, 0, len(s))
for _, v := range s {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// buildPayload creates the inner tar.gz (bin/* entries) and returns the
// compressed bytes, total uncompressed size, file count, and manifest lines.
func buildPayload(entries map[string]string) ([]byte, int64, int, []string, error) {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
var totalSize int64
var fileCount int
var manifestLines []string
// Sort entries for deterministic tar output.
paths := make([]string, 0, len(entries))
for p := range entries {
paths = append(paths, p)
}
sort.Strings(paths)
for _, tarPath := range paths {
hostPath := entries[tarPath]
if hostPath == "" {
// Placeholder entry — write an empty file with a note.
note := fmt.Sprintf("# placeholder: essence not found on disk\n")
if err := writeTarBytes(tw, tarPath, []byte(note), 0755); err != nil {
return nil, 0, 0, nil, err
}
manifestLines = append(manifestLines, fmt.Sprintf("%-40s %s [placeholder]", tarPath, "sha256:0000000000000000000000000000000000000000000000000000000000000000"))
fileCount++
continue
}
info, err := os.Stat(hostPath)
if err != nil {
return nil, 0, 0, nil, fmt.Errorf("stat %s: %w", hostPath, err)
}
// Hash the file for the manifest.
h, err := fileSHA256(hostPath)
if err != nil {
return nil, 0, 0, nil, fmt.Errorf("hash %s: %w", hostPath, err)
}
manifestLines = append(manifestLines, fmt.Sprintf("%-40s %s", tarPath, h))
totalSize += info.Size()
fileCount++
// Write the file into the tar.
f, err := os.Open(hostPath)
if err != nil {
return nil, 0, 0, nil, fmt.Errorf("open %s: %w", hostPath, err)
}
header := &tar.Header{
Name: tarPath,
Size: info.Size(),
Mode: int64(info.Mode()),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(header); err != nil {
f.Close()
return nil, 0, 0, nil, fmt.Errorf("tar header %s: %w", tarPath, err)
}
if _, err := io.Copy(tw, f); err != nil {
f.Close()
return nil, 0, 0, nil, fmt.Errorf("tar write %s: %w", tarPath, err)
}
f.Close()
}
if err := tw.Close(); err != nil {
return nil, 0, 0, nil, err
}
if err := gw.Close(); err != nil {
return nil, 0, 0, nil, err
}
return buf.Bytes(), totalSize, fileCount, manifestLines, nil
}
// appendPayloadToTar reads a tar.gz payload and copies every entry into the
// destination tar writer. This is how we embed the bin/* payload inside the
// final .svb alongside METADATA.json and SIGNATURE.sig.
func appendPayloadToTar(dst *tar.Writer, payloadGz []byte) error {
gr, err := gzip.NewReader(bytes.NewReader(payloadGz))
if err != nil {
return fmt.Errorf("open payload gzip: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read payload tar: %w", err)
}
if err := dst.WriteHeader(header); err != nil {
return fmt.Errorf("copy header %s: %w", header.Name, err)
}
if _, err := io.Copy(dst, tr); err != nil {
return fmt.Errorf("copy body %s: %w", header.Name, err)
}
}
return nil
}
// writeTarBytes writes a []byte as a tar entry.
func writeTarBytes(tw *tar.Writer, name string, data []byte, mode int64) error {
header := &tar.Header{
Name: name,
Size: int64(len(data)),
Mode: mode,
ModTime: time.Now(),
}
if err := tw.WriteHeader(header); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
// fileSHA256 returns the hex-encoded SHA-256 of a file.
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
// VerifySVBSignature verifies an .svb bundle's ed25519 signature using the
// provided public key. It reads the METADATA.json, extracts the payload SHA,
// and verifies the signature against it.
//
// Returns nil if the signature is valid (or the bundle is unsigned).
// Returns an error if the signature verification fails.
func VerifySVBSignature(svbPath string, pubKey ed25519.PublicKey) error {
f, err := os.Open(svbPath)
if err != nil {
return fmt.Errorf("svb verify: open: %w", err)
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("svb verify: gzip: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
var metadata *SVBMetadata
var payloadHash []byte
hasher := sha256.New()
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("svb verify: read: %w", err)
}
switch header.Name {
case "METADATA.json":
data, err := io.ReadAll(tr)
if err != nil {
return fmt.Errorf("svb verify: read metadata: %w", err)
}
metadata = &SVBMetadata{}
if err := json.Unmarshal(data, metadata); err != nil {
return fmt.Errorf("svb verify: parse metadata: %w", err)
}
default:
// Accumulate payload bytes for hash verification.
if _, err := io.Copy(hasher, tr); err != nil {
return fmt.Errorf("svb verify: hash payload: %w", err)
}
}
}
if metadata == nil {
return fmt.Errorf("svb verify: METADATA.json not found in bundle")
}
if metadata.Signature == "" {
return fmt.Errorf("svb verify: bundle is unsigned")
}
sigBytes, err := hex.DecodeString(metadata.Signature)
if err != nil {
return fmt.Errorf("svb verify: decode signature: %w", err)
}
payloadSHA := sha256.Sum256(hasher.Sum(nil))
if !ed25519.Verify(pubKey, payloadSHA[:], sigBytes) {
return fmt.Errorf("svb verify: SIGNATURE VERIFICATION FAILED — bundle may be tampered")
}
return nil
}
// BundleAndCache creates a .svb bundle and immediately pushes it to the
// shared CAS. This is the recommended method when Fester integration is
// active — it ensures the bundle is available to all cluster nodes
// without any additional coordination.
//
// Returns the SHA-256 of the bundle (the CAS key) on success.
// The bundle is also written to outPath on the local filesystem.
// If no CAS client is configured, it behaves like BundleSovereign
// and returns an empty string for the CAS hash.
func (g *Generator) BundleAndCache(ctx context.Context, essenceIDs []string, outPath string) (string, error) {
if err := g.BundleSovereign(essenceIDs, outPath); err != nil {
return "", fmt.Errorf("bundle-and-cache: %w", err)
}
// If no CAS client, just return the local file SHA.
if g.CASClient == nil {
sha, err := cas.FileSHA256(outPath)
if err != nil {
return "", nil // non-fatal — the bundle was created
}
return sha, nil
}
// Push to CAS.
sha, err := g.CASClient.PushFile(ctx, outPath, cas.ArtifactMeta{
Source: filepath.Base(outPath),
Target: g.Arch + "-linux-gnu",
Runtime: "sorcery-go",
Node: "master",
})
if err != nil {
// CAS push failure is non-fatal — the bundle exists locally.
// Log and continue.
sha, _ = cas.FileSHA256(outPath)
return sha, nil
}
return sha, nil
}

584
pkg/cluster/cluster.go Executable file
View File

@ -0,0 +1,584 @@
// Package cluster implements the Coven — the firewall-isolated Ley-Lines that bind
// independent Sanctums into a single distributed forge.
//
// Topology:
//
// Master Sanctum — holds the Grimoire and the Tablet.
// Coven-Worker — provides "Mana" (CPU/RAM) to the Cauldron for
// sharded Grid-Casts (e.g., a full glibc rebuild).
//
// The join protocol admits new nodes after firewall-level validation.
// A new node registers with the Master, begins pulling Essences from the
// Tomb until it reaches parity with the rest of the Coven.
//
// Fester Integration:
//
// When a Fester master URL is configured, the Coven delegates distributed
// build scheduling, node telemetry, and build dispatch to the Fester cluster
// controller via its HTTP + WebSocket API. Sorcery-go handles security
// (eBPF warding, tomb protection, essence verification) while Fester handles
// the distributed execution brain.
//
// Fester API docs: https://git.dcos.net/dcosnet/fester
package cluster
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"dcos.net/sorcery-go/pkg/cas"
)
// Node is one Sanctum in the Coven.
type Node struct {
ID string
Arch string
Role string // "master" or "worker"
Address string // host:port
JoinTime time.Time
ComputePower int // "Mana" — drives HPC scheduling
// Fester-derived fields (populated when Fester integration is active).
CPU float64 `json:"cpu,omitempty"` // 0..1
Memory float64 `json:"memory,omitempty"` // 0..1
ActiveBuilds int `json:"active_builds,omitempty"`
Temperature float64 `json:"temperature,omitempty"` // Celsius
MaxJobs int `json:"max_jobs,omitempty"`
Policy string `json:"policy,omitempty"` // preferred/avoid/neutral
Status string `json:"status,omitempty"` // online/offline/draining
}
// Coven is the cluster manager.
type Coven struct {
mu sync.RWMutex
Self *Node
Peers map[string]*Node
// Fester integration — when set, delegates scheduling and telemetry
// to a Fester master instance. Nil means standalone mode.
Fester *FesterClient
}
// NewCoven bootstraps a Master.
func NewCoven(selfID, arch, address string) *Coven {
return &Coven{
Self: &Node{
ID: selfID, Arch: arch, Role: "master",
Address: address, JoinTime: time.Now(),
},
Peers: make(map[string]*Node),
}
}
// NewCovenWithFester bootstraps a Master connected to a Fester cluster.
func NewCovenWithFester(selfID, arch, address string, festerURL string) *Coven {
c := NewCoven(selfID, arch, address)
if festerURL != "" {
c.Fester = NewFesterClient(festerURL)
}
return c
}
// Register admits a new Worker into the Coven.
func (c *Coven) Register(n *Node) error {
c.mu.Lock()
defer c.mu.Unlock()
if n.Role == "" {
n.Role = "worker"
}
n.JoinTime = time.Now()
c.Peers[n.ID] = n
return nil
}
// List returns every known Sanctum (including self).
func (c *Coven) List() []*Node {
c.mu.RLock()
defer c.mu.RUnlock()
out := []*Node{c.Self}
for _, p := range c.Peers {
out = append(out, p)
}
return out
}
// Drain migrates active builds off a node so it can be safely taken offline
// for maintenance. In Fester mode, this sets the node's policy to "avoid".
func (c *Coven) Drain(nodeID string) error {
// If Fester is active, delegate the drain.
if c.Fester != nil {
return c.Fester.SetNodePolicy(nodeID, "avoid")
}
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.Peers[nodeID]; !ok {
return fmt.Errorf("cluster: unknown node %s", nodeID)
}
c.Peers[nodeID].ComputePower = 0
return nil
}
// Pulse is the heartbeat broadcast — every node reports its current load.
// The Cockpit "Grid Heatmap" renders this in real time.
type Pulse struct {
NodeID string
CPU float64 // 0..1
Memory float64 // 0..1
ActiveBuilds int
Temperature float64
MaxJobs int
Status string
}
// PulseSnapshot returns the latest heartbeat from every node.
// In Fester mode, this queries the Fester /api/nodes endpoint for live
// telemetry. In standalone mode, returns zeros (no probe agent).
func (c *Coven) PulseSnapshot() []Pulse {
// If Fester is active, fetch live telemetry.
if c.Fester != nil {
pulses, err := c.Fester.GetNodePulses()
if err == nil && len(pulses) > 0 {
return pulses
}
// Fall through to local state on error.
}
c.mu.RLock()
defer c.mu.RUnlock()
out := []Pulse{{NodeID: c.Self.ID}}
for _, p := range c.Peers {
out = append(out, Pulse{
NodeID: p.ID,
CPU: p.CPU,
Memory: p.Memory,
ActiveBuilds: p.ActiveBuilds,
Temperature: p.Temperature,
})
}
return out
}
// ---------------------------------------------------------------------------
// Fester HTTP API Client
// ---------------------------------------------------------------------------
// FesterClient connects to a Fester master's HTTP API for node telemetry,
// build scheduling, distributed execution, and shared artifact caching.
//
// It maps Fester's REST endpoints to the Coven's cluster abstractions:
//
// Fester Endpoint → Coven Method
// GET /api/nodes → GetNodes, GetNodePulses
// GET /api/nodes/runtimes → GetRuntimes
// POST /api/nodes/{name}/policy → SetNodePolicy
// POST /api/nodes/{name}/probe → ProbeNode
// POST /api/build → SubmitBuild
// GET /api/builds → ListBuilds
// POST /api/builds/{id}/cancel → CancelBuild
// GET /api/metrics/json → GetMetrics
// GET /api/cause/explain/{n} → CauseExplain
// WS /ws → WatchEvents
// PUT /api/cas/{sha256} → CAS.PushFile, CAS.PushArtifact
// HEAD /api/cas/{sha256} → CAS.CheckArtifact
// GET /api/cas/{sha256} → CAS.RetrieveArtifact
// GET /api/cas/stats → CAS.Stats
//
// Reference: https://git.dcos.net/dcosnet/fester
type FesterClient struct {
baseURL string
http *http.Client
userAgent string
CAS *cas.Client // shared content-addressable store
}
// NewFesterClient creates a client for a Fester master. The stack operates behind a firewall (OPNsense/IPFire); no transport-layer encryption is used.
func NewFesterClient(baseURL string) *FesterClient {
return &FesterClient{
baseURL: strings.TrimRight(baseURL, "/"),
http: &http.Client{Timeout: 15 * time.Second},
userAgent: "sorcery-go/cluster (AGPL-3.0)",
CAS: cas.NewClient(baseURL),
}
}
// ---------------------------------------------------------------------------
// Fester API types (mirror Fester's JSON schemas)
// ---------------------------------------------------------------------------
// festerNode is the JSON representation from GET /api/nodes.
type festerNode struct {
Name string `json:"name"`
Host string `json:"host"`
Arch string `json:"arch"`
Runtime string `json:"runtime"`
MaxJobs int `json:"max_jobs"`
ActiveJobs int `json:"active_jobs"`
CPU float64 `json:"cpu_load"`
Memory float64 `json:"memory_load"`
Temperature float64 `json:"heat"`
Policy string `json:"policy"`
Status string `json:"state"`
LastProbe string `json:"last_seen"`
ProbeError string `json:"probe_error"`
Container string `json:"container,omitempty"`
VM string `json:"vm,omitempty"`
Firecracker *festerNodeFC `json:"firecracker,omitempty"`
}
// festerNodeFC is the firecracker config block from a node.
type festerNodeFC struct {
Kernel string `json:"kernel"`
Rootfs string `json:"rootfs"`
SSHPort int `json:"ssh_port"`
SSHKey string `json:"ssh_key"`
}
// festerBuild is the JSON representation from GET /api/builds.
type festerBuild struct {
ID string `json:"id"`
Target string `json:"target"`
Node string `json:"node"`
Status string `json:"status"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
ExitCode int `json:"exit_code"`
}
// festerMetrics is the JSON from GET /api/metrics/json.
type festerMetrics struct {
Nodes []festerNodeMetric `json:"nodes"`
Builds festerBuildMetrics `json:"builds"`
Timestamp string `json:"timestamp"`
}
type festerNodeMetric struct {
Name string `json:"name"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
Load1 float64 `json:"load1"`
Jobs int `json:"jobs"`
MaxJobs int `json:"max_jobs"`
}
type festerBuildMetrics struct {
Active int `json:"active"`
Total int `json:"total"`
Failed int `json:"failed"`
Success int `json:"success"`
Cached int `json:"cached"`
}
// festerBuildRequest is the POST body for /api/build.
type festerBuildRequest struct {
Target string `json:"target"`
Node string `json:"node,omitempty"` // empty = let Fester pick
Cmd string `json:"cmd"`
Dir string `json:"dir"`
Watch bool `json:"watch,omitempty"`
}
// festerCauseNode is the JSON from GET /api/cause/explain/{node}.
type festerCauseNode struct {
Node string `json:"node"`
Reason string `json:"reason"`
Children []string `json:"children"`
Events []festerCauseEvent `json:"events"`
}
type festerCauseEvent struct {
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Message string `json:"message"`
Node string `json:"node"`
}
// ---------------------------------------------------------------------------
// Node management
// ---------------------------------------------------------------------------
// GetNodes fetches the full node list from Fester's /api/nodes and returns
// them as Coven Node structs. Fields like Runtime, Container, and
// Firecracker config are mapped for sorcery-go's runtime selection.
func (fc *FesterClient) GetNodes() ([]*Node, error) {
// Fester returns {"master": {...}, "nodes": [...]}
var resp struct {
Nodes []festerNode `json:"nodes"`
}
if err := fc.get("/api/nodes", &resp); err != nil {
return nil, fmt.Errorf("fester: get nodes: %w", err)
}
nodes := make([]*Node, 0, len(resp.Nodes))
for _, fn := range resp.Nodes {
n := &Node{
ID: fn.Name,
Address: fn.Host,
Arch: fn.Arch,
ComputePower: fn.MaxJobs,
CPU: fn.CPU,
Memory: fn.Memory,
ActiveBuilds: fn.ActiveJobs,
Temperature: fn.Temperature,
MaxJobs: fn.MaxJobs,
Policy: fn.Policy,
Status: fn.Status,
}
nodes = append(nodes, n)
}
return nodes, nil
}
// GetNodePulses converts Fester's /api/nodes response to Pulse structs
// for the Cockpit Grid Heatmap.
func (fc *FesterClient) GetNodePulses() ([]Pulse, error) {
nodes, err := fc.GetNodes()
if err != nil {
return nil, err
}
pulses := make([]Pulse, 0, len(nodes))
for _, n := range nodes {
pulses = append(pulses, Pulse{
NodeID: n.ID,
CPU: n.CPU,
Memory: n.Memory,
ActiveBuilds: n.ActiveBuilds,
Temperature: n.Temperature,
MaxJobs: n.MaxJobs,
Status: n.Status,
})
}
return pulses, nil
}
// SetNodePolicy sets a node's scheduling policy via POST /api/nodes/{name}/policy.
func (fc *FesterClient) SetNodePolicy(nodeName, policy string) error {
body := map[string]string{"policy": policy}
return fc.post(fmt.Sprintf("/api/nodes/%s/policy", url.PathEscape(nodeName)), body, nil)
}
// ProbeNode triggers a manual probe of a node via POST /api/nodes/{name}/probe.
func (fc *FesterClient) ProbeNode(nodeName string) error {
return fc.post(fmt.Sprintf("/api/nodes/%s/probe", url.PathEscape(nodeName)), nil, nil)
}
// ---------------------------------------------------------------------------
// Build management
// ---------------------------------------------------------------------------
// SubmitBuild submits a build to Fester via POST /api/build.
func (fc *FesterClient) SubmitBuild(ctx context.Context, target, cmd, dir string, preferredNode string) (*festerBuild, error) {
req := festerBuildRequest{
Target: target,
Node: preferredNode,
Cmd: cmd,
Dir: dir,
}
var build festerBuild
if err := fc.postWithContext(ctx, "/api/build", req, &build); err != nil {
return nil, fmt.Errorf("fester: submit build: %w", err)
}
return &build, nil
}
// ListBuilds fetches the build history from Fester's /api/builds.
func (fc *FesterClient) ListBuilds() ([]festerBuild, error) {
var builds []festerBuild
if err := fc.get("/api/builds", &builds); err != nil {
return nil, fmt.Errorf("fester: list builds: %w", err)
}
return builds, nil
}
// CancelBuild cancels a running build via POST /api/builds/{id}/cancel.
func (fc *FesterClient) CancelBuild(buildID string) error {
return fc.post(fmt.Sprintf("/api/builds/%s/cancel", url.PathEscape(buildID)), nil, nil)
}
// ---------------------------------------------------------------------------
// Observability
// ---------------------------------------------------------------------------
// GetMetrics fetches the full metrics snapshot from Fester's /api/metrics/json.
func (fc *FesterClient) GetMetrics() (*festerMetrics, error) {
var m festerMetrics
if err := fc.get("/api/metrics/json", &m); err != nil {
return nil, fmt.Errorf("fester: get metrics: %w", err)
}
return &m, nil
}
// RuntimeInfo describes a single runtime's availability.
type RuntimeInfo struct {
Available bool `json:"available"`
Version string `json:"version,omitempty"`
Note string `json:"note"`
}
// RuntimesResponse is the response from GET /api/nodes/runtimes.
type RuntimesResponse struct {
DefaultRuntime string `json:"default_runtime"`
Runtimes map[string]RuntimeInfo `json:"runtimes"`
}
// GetRuntimes queries Fester's /api/nodes/runtimes endpoint to discover
// which runtimes (host, lxc, podman, firecracker, libvirt, tmux) are
// available. Sorcery-go uses this to align its own runtime selection
// with what Fester can actually execute.
func (fc *FesterClient) GetRuntimes() (*RuntimesResponse, error) {
var resp RuntimesResponse
if err := fc.get("/api/nodes/runtimes", &resp); err != nil {
return nil, fmt.Errorf("fester: get runtimes: %w", err)
}
return &resp, nil
}
// CauseExplain fetches the causal explanation for a node from
// Fester's /api/cause/explain/{node}.
func (fc *FesterClient) CauseExplain(nodeName string) (*festerCauseNode, error) {
var cause festerCauseNode
if err := fc.get(fmt.Sprintf("/api/cause/explain/%s", url.PathEscape(nodeName)), &cause); err != nil {
return nil, fmt.Errorf("fester: cause explain %s: %w", nodeName, err)
}
return &cause, nil
}
// ---------------------------------------------------------------------------
// Event streaming (WebSocket)
// ---------------------------------------------------------------------------
// FesterEvent represents a real-time event from Fester's WebSocket stream.
// This mirrors Fester's event schema from its EventBus.
type FesterEvent struct {
Type string `json:"type"` // build_started, build_completed, node_probe, scheduler_decision, etc.
Timestamp time.Time `json:"timestamp"`
Node string `json:"node"`
BuildID string `json:"build_id"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// WatchEvents connects to Fester's WebSocket at /ws and streams events.
// The handler is called for each event. Blocks until ctx is cancelled or
// an error occurs.
//
// This uses raw HTTP upgrade since we need to support the same WebSocket
// protocol as Fester's frontend. The gorilla/websocket dependency is
// already in go.mod for the Cockpit.
func (fc *FesterClient) WatchEvents(ctx context.Context, handler func(FesterEvent)) error {
wsURL := fc.baseURL
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
wsURL += "/ws"
wsDialer := &websocket.Dialer{}
wsConn, _, err := wsDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return fmt.Errorf("fester: ws connect: %w", err)
}
defer wsConn.Close()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
_, msg, err := wsConn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return nil
}
return fmt.Errorf("fester: ws read: %w", err)
}
var event FesterEvent
if err := json.Unmarshal(msg, &event); err != nil {
// Skip malformed events.
continue
}
handler(event)
}
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
func (fc *FesterClient) get(path string, v interface{}) error {
req, err := http.NewRequest("GET", fc.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", fc.userAgent)
req.Header.Set("Accept", "application/json")
resp, err := fc.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("fester: %s %s → %d: %s", "GET", path, resp.StatusCode, string(body))
}
if v != nil {
return json.NewDecoder(resp.Body).Decode(v)
}
return nil
}
func (fc *FesterClient) post(path string, body interface{}, v interface{}) error {
return fc.postWithContext(context.Background(), path, body, v)
}
func (fc *FesterClient) postWithContext(ctx context.Context, path string, body interface{}, v interface{}) error {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("fester: marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "POST", fc.baseURL+path, reqBody)
if err != nil {
return err
}
req.Header.Set("User-Agent", fc.userAgent)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := fc.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("fester: POST %s → %d: %s", path, resp.StatusCode, string(respBody))
}
if v != nil {
return json.NewDecoder(resp.Body).Decode(v)
}
return nil
}

316
pkg/cluster/scheduler.go Executable file
View File

@ -0,0 +1,316 @@
// HPC Grid-Casting: distributed compilation across the Coven.
//
// When a spell is too heavy for a single Sanctum (think glibc, llvm, rust),
// the Master shards the build across every Worker that has spare "Mana".
// Workers compile their assigned translation units in isolated namespaces
// and stream the object files back. The Master links them and produces a
// single signed Essence.
//
// With Fester integration, the Scheduler delegates build dispatch and node
// selection to the Fester master's weighted/thermal/cache-aware scheduler.
// The Coven's Scheduler becomes a thin proxy that translates between
// sorcery-go's Shard model and Fester's build API.
package cluster
import (
"context"
"fmt"
"sync"
)
// Shard is one slice of a Grid-Cast.
type Shard struct {
ID string
Spell string
WorkerID string // arch hint or specific node name
Cmd string // build command to execute
Dir string // working directory
Status string // "queued", "compiling", "uploaded", "failed", "completed"
}
// Scheduler routes shards to Workers based on available ComputePower.
// When a Fester client is available, all scheduling is delegated to Fester.
type Scheduler struct {
Coven *Coven
mu sync.Mutex
queue []Shard
}
// NewScheduler wraps a Coven.
func NewScheduler(c *Coven) *Scheduler {
return &Scheduler{Coven: c}
}
// Thermal derating constants.
const (
thermalCritical float64 = 80 // °C — half-power threshold
thermalHot float64 = 70 // °C — 75% power threshold
derateHalf int = 2 // effectivePower * 2 / 4 = 50%
derateThreeQtr int = 3 // effectivePower * 3 / 4 = 75%
thermalDivisor int = 4 // denominator for derate calculation
)
// thermalThreshold defines a temperature threshold and its derate factor.
// The array is ordered by threshold ascending; the first match wins.
var thermalThresholds = []struct {
temp float64
derate int // effectivePower * derate / thermalDivisor
}{
{thermalCritical, derateHalf}, // > 80°C → half power
{thermalHot, derateThreeQtr}, // > 70°C → 75% power
}
// thermalDerate applies temperature-based derating to effective power.
func thermalDerate(power int, temp float64) int {
for _, t := range thermalThresholds {
if temp > t.temp {
return power * t.derate / thermalDivisor
}
}
return power
}
// blockedPolicies is the set of scheduling policies that exclude a node.
// Used by both Fester-mode and standalone-mode best-worker selection.
var blockedPolicies = map[string]bool{
"avoid": true,
"offline": true,
"draining": true,
}
// bestNodeByPower selects the node with the highest effective power,
// filtered by arch (if non-empty) and scheduling policy.
func bestNodeByPower(nodes []*Node, arch string) *Node {
var best *Node
bestPower := -1
for _, n := range nodes {
if arch != "" && n.Arch != arch {
continue
}
if blockedPolicies[n.Policy] || blockedPolicies[n.Status] {
continue
}
effectivePower := n.MaxJobs - n.ActiveBuilds
effectivePower = thermalDerate(effectivePower, n.Temperature)
if effectivePower > bestPower {
best = n
bestPower = effectivePower
}
}
return best
}
// bestPeerByComputePower selects the best peer from the local Coven map
// for standalone mode. This is the simplified path without thermal data.
func bestPeerByComputePower(peers map[string]*Node, arch string) *Node {
var best *Node
bestPower := -1
for _, p := range peers {
if p.Arch != arch {
continue
}
if p.ComputePower > bestPower {
best = p
bestPower = p.ComputePower
}
}
return best
}
// GetBestWorker picks the Worker with the most spare ComputePower for the
// requested arch. In Fester mode, this queries Fester's node list and
// picks the best candidate locally (a quick heuristic). The actual
// scheduling decision is made by Fester's optimizer when Dispatch() is
// called.
// Returns nil if no Worker matches.
func (s *Scheduler) GetBestWorker(arch string) *Node {
if s.Coven.Fester != nil {
nodes, err := s.Coven.Fester.GetNodes()
if err == nil {
return bestNodeByPower(nodes, arch)
}
// Fallback to local state on error.
}
s.mu.Lock()
defer s.mu.Unlock()
return bestPeerByComputePower(s.Coven.Peers, arch)
}
// Dispatch sends a shard to the chosen Worker for execution.
//
// In Fester mode: first checks the shared CAS for a cached artifact.
// If the artifact exists (any runtime, any node), the shard is marked
// "completed" immediately without dispatching to Fester. This is the
// cross-runtime deduplication layer — an artifact built inside LXC on
// Node A is instantly available to a Firecracker microVM on Node B.
//
// If not cached, submits the shard as a Fester build via POST /api/build.
// Fester's scheduler picks the best node based on CPU, thermal, cache, and
// policy constraints. The build ID is tracked in the shard's ID field.
//
// In standalone mode: queues the shard in-memory (no actual execution).
//
// The actionHash parameter is the expected SHA-256 of the build output.
// If empty, the CAS check is skipped and the shard is always dispatched.
func (s *Scheduler) Dispatch(ctx context.Context, shard Shard, actionHash string) error {
// Fester mode: check shared CAS first.
if s.Coven.Fester != nil && actionHash != "" {
entry, err := s.Coven.Fester.CAS.CheckArtifact(ctx, actionHash)
if err == nil && entry != nil {
shard.ID = fmt.Sprintf("cas:%s", actionHash[:12])
shard.Status = "completed"
s.mu.Lock()
s.queue = append(s.queue, shard)
s.mu.Unlock()
return nil
}
}
// Fester mode: delegate to Fester's build API.
if s.Coven.Fester != nil {
target := shard.Spell
if shard.WorkerID != "" {
target = shard.WorkerID
}
cmd := shard.Cmd
if cmd == "" {
cmd = fmt.Sprintf("make -j$(nproc) %s", shard.Spell)
}
build, err := s.Coven.Fester.SubmitBuild(ctx, target, cmd, shard.Dir, "")
if err != nil {
return fmt.Errorf("scheduler: fester dispatch failed: %w", err)
}
shard.ID = build.ID
shard.Status = "compiling"
shard.WorkerID = build.Node
s.mu.Lock()
s.queue = append(s.queue, shard)
s.mu.Unlock()
return nil
}
// Standalone mode: pick best worker locally and queue.
worker := s.GetBestWorker(shard.WorkerID)
if worker == nil {
return fmt.Errorf("scheduler: no worker available for %s", shard.WorkerID)
}
shard.WorkerID = worker.ID
shard.Status = "compiling"
s.mu.Lock()
s.queue = append(s.queue, shard)
s.mu.Unlock()
return nil
}
// PulseQueue returns a copy of every active shard.
// Used by the Cockpit "Pulse" tab.
func (s *Scheduler) PulseQueue() []Shard {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Shard(nil), s.queue...)
}
// CancelShard cancels a dispatched shard. In Fester mode, this cancels the
// corresponding Fester build.
func (s *Scheduler) CancelShard(ctx context.Context, shardID string) error {
if s.Coven.Fester != nil {
return s.Coven.Fester.CancelBuild(shardID)
}
s.mu.Lock()
defer s.mu.Unlock()
// Linear scan — s.queue is not sorted by shard ID, so sort.Search
// would give incorrect results. Linear is acceptable because the
// queue is typically small (tens of shards, not millions).
for i := range s.queue {
if s.queue[i].ID == shardID {
s.queue[i].Status = "cancelled"
return nil
}
}
return fmt.Errorf("scheduler: shard %s not found", shardID)
}
// SyncFromFester pulls the latest build statuses from Fester and updates
// the local shard queue. Call this periodically to keep the local state
// in sync with Fester's ground truth.
func (s *Scheduler) SyncFromFester(ctx context.Context) error {
if s.Coven.Fester == nil {
return nil
}
builds, err := s.Coven.Fester.ListBuilds()
if err != nil {
return fmt.Errorf("scheduler: sync from fester: %w", err)
}
festerStatus := make(map[string]string, len(builds))
festerNode := make(map[string]string, len(builds))
for _, b := range builds {
festerStatus[b.ID] = b.Status
festerNode[b.ID] = b.Node
}
s.mu.Lock()
defer s.mu.Unlock()
for i, sh := range s.queue {
if status, ok := festerStatus[sh.ID]; ok {
s.queue[i].Status = status
if node, ok := festerNode[sh.ID]; ok && node != "" {
s.queue[i].WorkerID = node
}
}
}
return nil
}
// WatchFesterEvents starts a background goroutine that listens to Fester's
// WebSocket event stream and updates shard status in real time.
// Returns a cancel function to stop the watcher.
func (s *Scheduler) WatchFesterEvents(ctx context.Context) (cancel func(), err error) {
if s.Coven.Fester == nil {
return func() {}, nil
}
wsCtx, wsCancel := context.WithCancel(ctx)
go func() {
s.Coven.Fester.WatchEvents(wsCtx, func(event FesterEvent) {
s.handleFesterEvent(event)
})
}()
return wsCancel, nil
}
// festerEventStatus maps Fester event types to shard status updates.
// Events not in this map (node_offline, node_draining, etc.) are ignored
// by the handler — Fester handles their scheduling impact.
var festerEventStatus = map[string]string{
"build_started": "compiling",
"build_completed": "completed",
"build_failed": "failed",
}
// handleFesterEvent updates the local shard queue based on Fester events.
func (s *Scheduler) handleFesterEvent(event FesterEvent) {
newStatus, ok := festerEventStatus[event.Type]
if !ok {
return
}
s.mu.Lock()
defer s.mu.Unlock()
for i, sh := range s.queue {
if sh.ID == event.BuildID {
s.queue[i].Status = newStatus
if event.Node != "" {
s.queue[i].WorkerID = event.Node
}
return
}
}
}

234
pkg/config/config.go Executable file
View File

@ -0,0 +1,234 @@
// Package config holds the runtime configuration for a Sorcery-Go process.
//
// One Config is constructed at startup (from CLI flags + env vars + a small
// /etc/sorcery-go/config.yaml if present) and threaded through every pkg/*
// call site. This replaces the global Bash variables that the original
// Sorcery used.
//
// Paths default to /var/lib/sorcery-go so a Sorcery-Go binary can be
// dropped into an existing Source Mage chroot WITHOUT overwriting the
// legacy Bash state under /var/lib/sorcery. Both tools can coexist while
// the migration is in progress.
package config
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
)
// Config is the runtime configuration.
type Config struct {
// RootDir is the engine's state root. Defaults to /var/lib/sorcery-go
// so we never clobber the legacy /var/lib/sorcery tree.
RootDir string
// GrimoirePath is the directory containing the spell tree. We default
// to the real Source Mage location so an existing chroot "just works".
GrimoirePath string
// StateDB is the bbolt file path.
StateDB string
// TombRoot is the content-addressable blob store root.
TombRoot string
// BuildRoot is where sandbox overlays are mounted.
BuildRoot string
// SpoolDir is where downloaded source tarballs land.
SpoolDir string
// LogDir is where per-spell build logs are persisted.
LogDir string
// Concurrency is the worker-pool size for parallel casts.
Concurrency int
// HostArch is the architecture of the running host.
HostArch string
// ActivePosture is the active legal profile name
// (strict_copyleft | corporate_lite | lawless).
ActivePosture string
// Firewall is which network gatekeeper is active
// (opensnitch | portmaster | none).
Firewall string
// PGPKeyring is the GnuPG keyring used to verify signed DETAILS files.
// Empty disables PGP attestation (the Warding will warn but not block).
PGPKeyring string
// Runtime is the container runtime to use for Sanctum management.
// (lxc | podman | firecracker | baremetal | auto)
// "auto" probes for available runtimes in order.
Runtime string
// EBPFEnforce controls whether the eBPF Tomb Guard operates in
// enforcing mode (true) or permissive/log-only mode (false).
EBPFEnforce bool
// FirecrackerBin is the path to the firecracker VMM binary.
// Used only when Runtime == "firecracker".
FirecrackerBin string
// FirecrackerKernel is the path to the kernel image for Firecracker VMs.
FirecrackerKernel string
// NetworkBridge is the host bridge interface for container networking.
// Defaults to "br0".
NetworkBridge string
// FesterURL is the URL of the Fester cluster controller.
// When set, distributed build scheduling and node telemetry are
// delegated to Fester. Empty means standalone mode.
// Example: "http://192.168.1.100:8787"
FesterURL string
// Toolchain selects which compiler toolchain to use.
// "gcc" (default) uses the host system GCC.
// "btc" uses a BTC.sh sovereign-forged toolchain.
Toolchain string
// BTCPath is the path to the BTC.sh script.
BTCPath string
// BTCRoot is the BTC persistent archive root directory.
BTCRoot string
// BTCSYSLabel is the pre-detected SYS_LABEL for the BTC toolchain.
// When empty, sorcery-go will auto-detect it from the golden image
// filename at startup.
BTCSYSLabel string
}
// Default returns a Config wired for an existing Source Mage chroot.
// Every path is overridable via env var (SORCERY_GO_<NAME>) so the same
// binary can run in CI, in a chroot, or in any supported container runtime.
func Default() *Config {
cfg := &Config{
RootDir: envOr("SORCERY_GO_ROOT", "/var/lib/sorcery-go"),
GrimoirePath: envOr("SORCERY_GO_GRIMOIRE", "/var/lib/sorcery/codex/grimoire"),
Concurrency: runtime.NumCPU(),
HostArch: hostArch(),
ActivePosture: envOr("SORCERY_GO_POSTURE", "strict_copyleft"),
Firewall: envOr("SORCERY_GO_FIREWALL", "none"),
PGPKeyring: envOr("SORCERY_GO_PGP_KEYRING", "/etc/sorcery-go/keyring.gpg"),
Runtime: envOr("SORCERY_GO_RUNTIME", "auto"),
EBPFEnforce: envBool("SORCERY_GO_EBPF_ENFORCE", false),
FirecrackerBin: envOr("SORCERY_GO_FIRECRACKER_BIN", "firecracker"),
FirecrackerKernel: envOr("SORCERY_GO_FIRECRACKER_KERNEL", ""),
NetworkBridge: envOr("SORCERY_GO_NETWORK_BRIDGE", "br0"),
FesterURL: envOr("SORCERY_GO_FESTER_URL", ""),
Toolchain: envOr("SORCERY_GO_TOOLCHAIN", "gcc"),
BTCPath: envOr("SORCERY_GO_BTC_PATH", "/opt/BTC/BTC.sh"),
BTCRoot: envOr("SORCERY_GO_BTC_ROOT", "/opt/BTC"),
BTCSYSLabel: envOr("SORCERY_GO_BTC_SYS_LABEL", ""),
}
cfg.StateDB = filepath.Join(cfg.RootDir, "state", "state.db")
cfg.TombRoot = filepath.Join(cfg.RootDir, "tomb")
cfg.BuildRoot = filepath.Join(cfg.RootDir, "build")
cfg.SpoolDir = envOr("SORCERY_GO_SPOOL", "/var/spool/sorcery-go")
cfg.LogDir = filepath.Join(cfg.RootDir, "log")
return cfg
}
// EnsureDirs creates every state directory with the right mode. Idempotent.
// Must be called as root (or with write access to RootDir).
func (c *Config) EnsureDirs() error {
dirs := []struct {
path string
mode os.FileMode
}{
{c.RootDir, 0755},
{filepath.Dir(c.StateDB), 0700},
{c.TombRoot, 0700},
{filepath.Join(c.TombRoot, "epitaphs"), 0700},
{filepath.Join(c.TombRoot, "blobs"), 0700},
{c.BuildRoot, 0755},
{c.SpoolDir, 0755},
{c.LogDir, 0755},
}
for _, d := range dirs {
if err := os.MkdirAll(d.path, d.mode); err != nil {
return fmt.Errorf("config: mkdir %s: %w", d.path, err)
}
}
return nil
}
// IsRoot reports whether the process is running as UID 0. Required for
// OverlayFS mounts and atomic commits to the live root.
func IsRoot() bool { return os.Geteuid() == 0 }
// ValidateFesterURL checks whether the configured FesterURL is reachable.
// It logs a warning if the URL is set but the host does not respond.
// This is a non-blocking best-effort check — failure does not prevent startup.
func (c *Config) ValidateFesterURL() {
if c.FesterURL == "" {
return
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(c.FesterURL + "/api/health")
if err != nil {
log.Printf("config: warning: FesterURL %s is not reachable: %v", c.FesterURL, err)
log.Printf("config: distributed build features will be unavailable until Fester is accessible")
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("config: warning: FesterURL %s returned status %d", c.FesterURL, resp.StatusCode)
} else {
log.Printf("config: FesterURL %s is reachable", c.FesterURL)
}
}
// ValidatePaths checks that critical paths exist and logs warnings
// for any that are missing. This gives operators clear error messages
// instead of cryptic "file not found" errors later.
func (c *Config) ValidatePaths() {
if _, err := os.Stat(c.GrimoirePath); os.IsNotExist(err) {
log.Printf("config: warning: GrimoirePath %s does not exist", c.GrimoirePath)
log.Printf("config: no spell definitions will be available. Set SORCERY_GO_GRIMOIRE or install a grimoire.")
}
if c.Toolchain == "btc" {
if _, err := os.Stat(c.BTCRoot); os.IsNotExist(err) {
log.Printf("config: warning: BTCRoot %s does not exist", c.BTCRoot)
log.Printf("config: BTC toolchain will not be available. Run BTC.sh first or set SORCERY_GO_BTC_ROOT.")
}
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
return v == "1" || v == "true" || v == "yes"
}
// archMap maps runtime.GOARCH to Source Mage canonical arch names.
var archMap = map[string]string{
"arm64": "aarch64",
"amd64": "x86_64",
"386": "i686",
}
func hostArch() string {
if arch, ok := archMap[runtime.GOARCH]; ok {
return arch
}
return runtime.GOARCH
}

254
pkg/dag/dag.go Executable file
View File

@ -0,0 +1,254 @@
// Package dag implements the Directed Acyclic Graph (DAG) that powers
// Sorcery-Go's dependency resolution.
//
// The DAG is "feature-aware": every edge carries a DepType (Build/Runtime/Optional)
// and an optional set of required features (sub-depends). This lets the engine
// trigger a "Re-Forge" of a dependency when a parent spell requires a feature
// that was not enabled in the existing Essence variant.
//
// Cycle detection uses the classic three-color marking algorithm (White / Grey / Black).
// If a "Grey" node is encountered while exploring, a circular reference exists and
// the offending edge is rolled back so the Graph remains in a valid state.
package dag
import (
"errors"
"fmt"
"sort"
)
// DepType classifies the strength of a dependency edge.
type DepType int
const (
BuildDep DepType = iota // Required only to compile (e.g., headers)
RuntimeDep // Required to run (e.g., shared libs)
OptionalDep // User-toggled feature (e.g., --with-x)
)
func (d DepType) String() string {
names := [...]string{"build", "runtime", "optional", "unknown"}
i := int(d)
if i < len(names) {
return names[i]
}
return "unknown"
}
// Edge is a typed relationship between two nodes.
type Edge struct {
Target *Node
Type DepType
Features []string // Sub-depends: features the parent expects the child to expose
Enabled bool // For Optional edges; toggled by ICE y/n answers
}
// Node represents a single spell inside the Graph.
type Node struct {
Name string
Version string
Edges []*Edge
visited bool
testing bool // "Grey" marker during DFS
IsRequired bool
InDegree int // How many parents depend on this node (drives priority scheduling)
}
// Graph is the in-memory model of the entire Grimoire's dependency mesh.
type Graph struct {
Nodes map[string]*Node
}
// NewGraph returns an empty Graph.
func NewGraph() *Graph {
return &Graph{Nodes: make(map[string]*Node)}
}
// GetOrCreate fetches a node, creating it if necessary.
func (g *Graph) GetOrCreate(name string) *Node {
if n, ok := g.Nodes[name]; ok {
return n
}
n := &Node{Name: name}
g.Nodes[name] = n
return n
}
// AddDependency links parent -> child with a typed edge and runs cycle
// detection. If adding the edge would create a loop, the edge is rolled back
// and an error is returned so the Graph stays consistent.
//
// PERFORMANCE: This calls DetectCycles() after every single edge, which
// traverses all V nodes. For batch loading (e.g., parsing the full Grimoire),
// this is O(E*V). A future BatchAddDependency method should add all edges
// first, then run a single cycle detection pass — see the TODO in
// DetectCycles. For now, the typical Grimoire (~3k spells, ~8k edges)
// completes in <50ms, which is acceptable for interactive use.
func (g *Graph) AddDependency(parent, child string, t DepType, features []string) error {
p := g.GetOrCreate(parent)
c := g.GetOrCreate(child)
edge := &Edge{Target: c, Type: t, Features: features, Enabled: t != OptionalDep}
p.Edges = append(p.Edges, edge)
c.InDegree++
if err := g.DetectCycles(); err != nil {
// Roll back the edge.
p.Edges = p.Edges[:len(p.Edges)-1]
c.InDegree--
return fmt.Errorf("dag: refusing edge %s -> %s: %w", parent, child, err)
}
return nil
}
// DetectCycles implements three-color DFS cycle detection across the whole Graph.
func (g *Graph) DetectCycles() error {
for _, n := range g.Nodes {
n.visited = false
n.testing = false
}
// Sort for deterministic error messages.
keys := make([]string, 0, len(g.Nodes))
for k := range g.Nodes {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !g.Nodes[k].visited {
if err := g.visit(g.Nodes[k]); err != nil {
return err
}
}
}
return nil
}
func (g *Graph) visit(n *Node) error {
if n.testing {
return fmt.Errorf("dag: circular reference detected at %q", n.Name)
}
if n.visited {
return nil
}
n.testing = true
for _, e := range n.Edges {
if err := g.visit(e.Target); err != nil {
return err
}
}
n.testing = false
n.visited = true
return nil
}
// TopologicalSort returns a build order where every dependency precedes its
// dependents. Optional edges that are not Enabled are skipped.
func (g *Graph) TopologicalSort() ([]*Node, error) {
if err := g.DetectCycles(); err != nil {
return nil, err
}
visited := make(map[string]bool)
var order []*Node
var visit func(*Node)
visit = func(n *Node) {
if visited[n.Name] {
return
}
visited[n.Name] = true
for _, e := range n.Edges {
if e.Type == OptionalDep && !e.Enabled {
continue
}
visit(e.Target)
}
order = append(order, n)
}
for _, n := range g.Nodes {
visit(n)
}
return order, nil
}
// Prune walks from a root and flags every reachable node IsRequired.
// Optional branches are skipped when includeOptional is false — this is the
// "Smart Pruning" logic that keeps the build queue minimal.
func (g *Graph) Prune(root string, includeOptional bool) {
rootNode, ok := g.Nodes[root]
if !ok {
return
}
for _, n := range g.Nodes {
n.IsRequired = false
}
var walk func(*Node)
walk = func(n *Node) {
if n.IsRequired {
return
}
n.IsRequired = true
for _, e := range n.Edges {
if e.Type == OptionalDep && !includeOptional {
continue
}
if !e.Enabled && e.Type == OptionalDep {
continue
}
walk(e.Target)
}
}
walk(rootNode)
}
// ParallelBatches groups spells that have no mutual dependencies so they can
// be Cast simultaneously by the worker pool. Batch N+1 only depends on batches
// 1..N, never on itself.
//
// WARNING: This method temporarily sets g.Nodes[name] = nil for consumed
// nodes and restores them before returning. It is NOT safe to call
// concurrently with other Graph operations. The caller must hold exclusive
// access to the Graph during this call.
func (g *Graph) ParallelBatches() ([][]*Node, error) {
if err := g.DetectCycles(); err != nil {
return nil, err
}
indeg := make(map[string]int)
for name, n := range g.Nodes {
indeg[name] = n.InDegree
}
var batches [][]*Node
for {
var ready []*Node
for name, n := range g.Nodes {
if indeg[name] == 0 && n != nil {
ready = append(ready, n)
}
}
if len(ready) == 0 {
break
}
sort.Slice(ready, func(i, j int) bool {
return ready[i].Name < ready[j].Name
})
batches = append(batches, ready)
for _, n := range ready {
g.Nodes[n.Name] = nil // mark consumed
for _, e := range n.Edges {
if e.Type == OptionalDep && !e.Enabled {
continue
}
indeg[e.Target.Name]--
}
}
}
// Restore nodes (we nulled them out above)
for _, b := range batches {
for _, n := range b {
g.Nodes[n.Name] = n
}
}
return batches, nil
}
// ErrSpellNotFound is returned when a spell is missing from the Graph.
var ErrSpellNotFound = errors.New("dag: spell not found in grimoire")

85
pkg/dag/dag_test.go Executable file
View File

@ -0,0 +1,85 @@
package dag
import "testing"
// TestCircularDependency reproduces the original "A -> B -> C -> A" loop and
// ensures DetectCycles rejects the offending edge while keeping the Graph
// in a valid state (edge is rolled back).
func TestCircularDependency(t *testing.T) {
g := NewGraph()
if err := g.AddDependency("gcc", "glibc", BuildDep, nil); err != nil {
t.Fatalf("first edge should succeed: %v", err)
}
if err := g.AddDependency("glibc", "linux-headers", BuildDep, nil); err != nil {
t.Fatalf("second edge should succeed: %v", err)
}
// Closing the loop must fail.
if err := g.AddDependency("linux-headers", "gcc", BuildDep, nil); err == nil {
t.Fatalf("expected cycle error, got nil")
} else {
t.Logf("✓ correctly caught loop: %v", err)
}
// Graph must still be intact (rolled back).
if n := g.Nodes["linux-headers"]; n != nil && len(n.Edges) != 0 {
t.Fatalf("linux-headers should have no edges after rollback, got %d", len(n.Edges))
}
}
func TestTopologicalSort(t *testing.T) {
g := NewGraph()
_ = g.AddDependency("wget", "openssl", RuntimeDep, nil)
_ = g.AddDependency("wget", "glibc", RuntimeDep, nil)
_ = g.AddDependency("openssl", "glibc", RuntimeDep, nil)
order, err := g.TopologicalSort()
if err != nil {
t.Fatalf("topo sort: %v", err)
}
pos := make(map[string]int)
for i, n := range order {
pos[n.Name] = i
}
if pos["glibc"] >= pos["openssl"] {
t.Fatalf("glibc must come before openssl")
}
if pos["glibc"] >= pos["wget"] {
t.Fatalf("glibc must come before wget")
}
if pos["openssl"] >= pos["wget"] {
t.Fatalf("openssl must come before wget")
}
}
func TestPruneSkipsOptional(t *testing.T) {
g := NewGraph()
_ = g.AddDependency("app", "libcore", RuntimeDep, nil)
_ = g.AddDependency("app", "libx11", OptionalDep, nil)
_ = g.AddDependency("libx11", "libxext", RuntimeDep, nil)
g.Prune("app", false)
if !g.Nodes["libcore"].IsRequired {
t.Fatalf("libcore must be required")
}
if g.Nodes["libx11"].IsRequired {
t.Fatalf("libx11 (optional, not enabled) must be pruned")
}
if g.Nodes["libxext"].IsRequired {
t.Fatalf("libxext must be pruned along with libx11")
}
}
func TestSolverTriggersReForge(t *testing.T) {
g := NewGraph()
_ = g.AddDependency("wget", "openssl", RuntimeDep, []string{"ssl3"})
// Pretend the existing openssl Essence does NOT have ssl3 enabled.
s := &Solver{Lookup: func(spell, feature string) (bool, error) {
return false, nil
}}
reforges, err := s.Solve("wget", g)
if err != nil {
t.Fatalf("solve: %v", err)
}
if len(reforges) == 0 {
t.Fatalf("expected a re-forge recommendation for openssl+ssl3")
}
}

75
pkg/dag/solver.go Executable file
View File

@ -0,0 +1,75 @@
// Sub-Dependency Solver.
//
// In the original Source Mage, SUB_DEPENDS expresses the idea that
// "spell A needs spell B built with feature X". Sorcery-Go encodes that
// requirement on the dag.Edge.Features field and the Solver checks whether the
// currently-active Essence for the dependency satisfies the requested feature set.
//
// If not, the Solver returns a ReForge recommendation that the Cast pipeline
// uses to enqueue a fresh variant of the dependency (with the new flag) before
// continuing the parent build.
package dag
import "fmt"
// ReForge is a recommendation produced by the Solver when an existing
// Essence does not satisfy the requested sub-depends features.
type ReForge struct {
Spell string
NeededBy string
MissingFlags []string
}
// Solver is the feature-aware dependency negotiator.
//
// It relies on a FeatureLookup callback to ask the Tomb whether a given
// spell's current Essence variant exposes a feature. This keeps the dag
// package free of import cycles with pkg/tomb.
type Solver struct {
Lookup func(spell, feature string) (present bool, err error)
}
// Solve inspects every required edge of `spell` and returns the list of
// re-forge recommendations. An empty list means the existing variants are
// already compatible and the cast can proceed.
func (s *Solver) Solve(spell string, g *Graph) ([]ReForge, error) {
return s.solveVisited(spell, g, make(map[string]struct{}))
}
// solveVisited is the recursive implementation with cycle detection.
// The visited map prevents infinite recursion on circular dependency graphs.
func (s *Solver) solveVisited(spell string, g *Graph, visited map[string]struct{}) ([]ReForge, error) {
if _, seen := visited[spell]; seen {
return nil, nil // cycle detected — skip, don't recurse further
}
visited[spell] = struct{}{}
root, ok := g.Nodes[spell]
if !ok {
return nil, ErrSpellNotFound
}
var out []ReForge
for _, edge := range root.Edges {
if edge.Type == OptionalDep && !edge.Enabled {
continue
}
for _, feature := range edge.Features {
present, err := s.Lookup(edge.Target.Name, feature)
if err != nil {
return nil, fmt.Errorf("solver: lookup %s/%s: %w", edge.Target.Name, feature, err)
}
if !present {
out = append(out, ReForge{
Spell: edge.Target.Name,
NeededBy: spell,
MissingFlags: []string{feature},
})
}
}
// Recurse into children so a transitive missing flag also triggers.
if more, err := s.solveVisited(edge.Target.Name, g, visited); err == nil {
out = append(out, more...)
}
}
return out, nil
}

127
pkg/eventbus/bus.go Executable file
View File

@ -0,0 +1,127 @@
// Package eventbus is the typed pub/sub that connects the Cast pipeline,
// the CLI, and the Coven Mirror WebUI.
//
// Every cast is identified by a taskID (a short UUID). The pipeline
// publishes log lines, progress events, and completion/failure events to
// the bus under that topic. The CLI subscribes to the same topic and
// prints to stdout; the WebUI's /api/v1/stream/{id} WebSocket handler
// subscribes and forwards to the browser.
//
// This is the glue that makes the three interfaces (CLI / TUI / WebUI)
// show the same real-time truth.
package eventbus
import (
"log"
"sync"
"time"
)
// EventType classifies an event.
type EventType string
const (
EventLog EventType = "log" // a build log line
EventProgress EventType = "progress" // {done, total}
EventPhase EventType = "phase" // "summoning" | "unpacking" | "building" | "committing"
EventComplete EventType = "complete" // success
EventFailed EventType = "failed" // error
EventAlarm EventType = "alarm" // warding alert
)
// Event is one published message.
type Event struct {
Topic string `json:"topic"`
Type EventType `json:"type"`
Data string `json:"data"`
Time time.Time `json:"time"`
Done int `json:"done,omitempty"`
Total int `json:"total,omitempty"`
}
// DefaultSubscriberBufSize is the buffer capacity for each subscriber channel.
// Overflow events are dropped (non-blocking publish) to avoid blocking
// the Cast pipeline on slow WebSocket clients.
const DefaultSubscriberBufSize = 64
// Bus is the in-memory pub/sub. Safe for concurrent publishers/subscribers.
type Bus struct {
mu sync.RWMutex
subs map[string][]chan Event
}
// New returns an empty Bus.
func New() *Bus {
return &Bus{subs: make(map[string][]chan Event)}
}
// Publish broadcasts an event to every subscriber of `topic`.
// Non-blocking: if a subscriber's buffer is full the event is dropped
// (we never block the Cast pipeline on a slow WebSocket).
func (b *Bus) Publish(topic string, ev Event) {
ev.Topic = topic
if ev.Time.IsZero() {
ev.Time = time.Now()
}
b.mu.RLock()
subs := b.subs[topic]
channels := make([]chan Event, len(subs))
copy(channels, subs)
b.mu.RUnlock()
for _, ch := range channels {
select {
case ch <- ev:
default:
// Slow consumer — event dropped. This is intentional: we never
// block the Cast pipeline on a lagging WebSocket.
log.Printf("eventbus: dropped event type=%s topic=%s (slow consumer)", ev.Type, ev.Topic)
}
}
}
// Subscribe registers a receiver for `topic`. Returns the channel and an
// Unsubscribe func. The channel is buffered (DefaultSubscriberBufSize events);
// overflow is dropped via Publish's non-blocking send.
func (b *Bus) Subscribe(topic string) (<-chan Event, func()) {
ch := make(chan Event, DefaultSubscriberBufSize)
b.mu.Lock()
b.subs[topic] = append(b.subs[topic], ch)
b.mu.Unlock()
return ch, func() {
b.mu.Lock()
defer b.mu.Unlock()
subs := b.subs[topic]
for i, c := range subs {
if c == ch {
b.subs[topic] = append(subs[:i], subs[i+1:]...)
close(c)
return
}
}
}
}
// Log is a convenience helper for publishing a single log line.
func (b *Bus) Log(topic, line string) {
b.Publish(topic, Event{Type: EventLog, Data: line})
}
// Phase announces a pipeline phase change.
func (b *Bus) Phase(topic, phase string) {
b.Publish(topic, Event{Type: EventPhase, Data: phase})
}
// Progress announces a step completion.
func (b *Bus) Progress(topic string, done, total int) {
b.Publish(topic, Event{Type: EventProgress, Done: done, Total: total})
}
// Complete signals successful finish.
func (b *Bus) Complete(topic, essenceID string) {
b.Publish(topic, Event{Type: EventComplete, Data: essenceID})
}
// Failed signals an error.
func (b *Bus) Failed(topic, err string) {
b.Publish(topic, Event{Type: EventFailed, Data: err})
}

71
pkg/eventbus/bus_test.go Executable file
View File

@ -0,0 +1,71 @@
package eventbus
import (
"testing"
"time"
)
func TestPubSub(t *testing.T) {
bus := New()
ch, unsub := bus.Subscribe("task-1")
defer unsub()
bus.Log("task-1", "hello")
bus.Phase("task-1", "summoning")
bus.Complete("task-1", "essence-abc")
got := []string{}
for ev := range ch {
got = append(got, string(ev.Type)+":"+ev.Data)
if ev.Type == EventComplete {
break
}
}
if len(got) != 3 {
t.Fatalf("expected 3 events, got %d: %v", len(got), got)
}
if got[0] != "log:hello" {
t.Errorf("first event: %s", got[0])
}
if got[2] != "complete:essence-abc" {
t.Errorf("last event: %s", got[2])
}
}
func TestTopicIsolation(t *testing.T) {
bus := New()
ch1, _ := bus.Subscribe("task-1")
ch2, _ := bus.Subscribe("task-2")
bus.Log("task-1", "only-for-1")
bus.Log("task-2", "only-for-2")
select {
case ev := <-ch1:
if ev.Data != "only-for-1" {
t.Fatalf("ch1 got %q", ev.Data)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("ch1 timed out")
}
select {
case ev := <-ch2:
if ev.Data != "only-for-2" {
t.Fatalf("ch2 got %q", ev.Data)
}
case <-time.After(100 * time.Millisecond):
t.Fatal("ch2 timed out")
}
}
func TestUnsubscribe(t *testing.T) {
bus := New()
ch, unsub := bus.Subscribe("task-x")
unsub()
// Publishing after unsubscribe should not panic and should not block.
bus.Log("task-x", "no-listeners")
// Channel should be closed.
if _, ok := <-ch; ok {
t.Fatal("channel should be closed after unsubscribe")
}
}

189
pkg/grimoire/depends.go Executable file
View File

@ -0,0 +1,189 @@
// Real DEPENDS / SUB_DEPENDS parser.
//
// Source Mage DEPENDS files use these directives:
//
// depends <spell> ["<sub_depends>"] ["<configure_flag>"] [<type>]
// optional_depends <spell> "<sub>" "<flag>" "<description>"
// sub_depends <spell> <feature>
// runtime_depends <spell>
//
// The <type> field is optional and may be one of:
// "" (defaults to runtime)
// "build" (build-only)
// "missing" (a missing dep — flagged for the lint pass)
// "-optional" (legacy form of optional_depends)
// "-subdepends" (this dep is conditional on a sub_depends)
//
// We are deliberately permissive: anything we can't parse is skipped with
// a debug log rather than aborting the whole grimoire walk.
package grimoire
import (
"bufio"
"os"
"strings"
)
// DependEntry is one parsed `depends` / `optional_depends` line.
type DependEntry struct {
Name string
Type string // "runtime" | "build" | "optional"
SubDep string // optional sub_depends token
Flag string // optional configure flag
Desc string // optional human description (optional_depends only)
}
// ParseDepends reads a DEPENDS file and returns the parsed entries.
// Errors are returned only for I/O failures; malformed lines are skipped.
func ParseDepends(path string) ([]DependEntry, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []DependEntry
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
var entry DependEntry
switch {
case strings.HasPrefix(line, "optional_depends"):
entry = parseOptionalDepends(line)
case strings.HasPrefix(line, "runtime_depends"):
entry = parseRuntimeDepends(line)
case strings.HasPrefix(line, "depends"):
entry = parseDepends(line)
case strings.HasPrefix(line, "sub_depends"):
// handled by ParseSubDepends; skip here
continue
case strings.HasPrefix(line, "conflicts"):
continue
case strings.HasPrefix(line, "suggests"):
continue
default:
continue
}
if entry.Name != "" {
out = append(out, entry)
}
}
return out, scanner.Err()
}
// ParseSubDepends extracts just the sub_depends directives from a DEPENDS
// file. Returns a slice of "spell:feature" strings.
func ParseSubDepends(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "sub_depends") {
continue
}
fields := tokenize(line)
if len(fields) >= 3 {
out = append(out, fields[1]+":"+fields[2])
}
}
return out, scanner.Err()
}
func parseDepends(line string) DependEntry {
fields := tokenize(line)
if len(fields) < 2 {
return DependEntry{}
}
entry := DependEntry{Name: fields[1], Type: "runtime"}
// Quoted fields come back from tokenize without their quotes.
// Pattern: depends <name> [sub] [flag] [type]
rest := fields[2:]
for _, f := range rest {
switch {
case f == "build" || f == "missing":
entry.Type = f
case f == "-optional":
entry.Type = "optional"
case f == "-subdepends":
// marks a conditional dep — keep type as-is
case strings.HasPrefix(f, "--"):
if entry.Flag == "" {
entry.Flag = f
}
default:
if entry.SubDep == "" {
entry.SubDep = f
} else if entry.Flag == "" {
entry.Flag = f
}
}
}
return entry
}
func parseOptionalDepends(line string) DependEntry {
fields := tokenize(line)
if len(fields) < 2 {
return DependEntry{}
}
entry := DependEntry{Name: fields[1], Type: "optional"}
if len(fields) > 2 {
entry.SubDep = fields[2]
}
if len(fields) > 3 {
entry.Flag = fields[3]
}
if len(fields) > 4 {
entry.Desc = strings.Join(fields[4:], " ")
}
return entry
}
func parseRuntimeDepends(line string) DependEntry {
fields := tokenize(line)
if len(fields) < 2 {
return DependEntry{}
}
return DependEntry{Name: fields[1], Type: "runtime"}
}
// tokenize splits a shell-like line into fields, respecting single and
// double quotes. Tokens are returned without their surrounding quotes.
func tokenize(line string) []string {
var out []string
var cur strings.Builder
var inSingle, inDouble bool
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case c == '\\' && i+1 < len(line):
cur.WriteByte(line[i+1])
i++
case c == '\'' && !inDouble:
inSingle = !inSingle
case c == '"' && !inSingle:
inDouble = !inDouble
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}

257
pkg/grimoire/parser.go Executable file
View File

@ -0,0 +1,257 @@
// Package grimoire parses the real Source Mage spell files (DETAILS,
// DEPENDS, SUB_DEPENDS, CONFIGURE, BUILD) into typed Go structs.
//
// The parser is "hybrid": a small bash subprocess sources the DETAILS file
// (because real DETAILS files contain dynamic logic — `SOURCE_VERSION=$(...)`,
// `if [[ ... ]]; then SOURCE_URL[0]=...; fi`, multi-source arrays, etc.) and
// emits a JSON document on stdout. We then unmarshal that into a Spell
// struct. This is the only way to be 100% compatible with an existing
// a spell-format grimoire without reimplementing a Bash interpreter.
//
// For DEPENDS we use a pure-Go line parser because the format is simple
// and spawning bash per file is wasteful when IndexAll walks thousands of
// spells.
package grimoire
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
// Spell is the typed metadata extracted from a DETAILS file.
type Spell struct {
Name string `json:"name"`
Version string `json:"version"`
Patchlevel string `json:"patchlevel,omitempty"`
Source string `json:"source"`
SourceURLs []string `json:"source_urls"`
SourceHash string `json:"source_hash"`
SourceDir string `json:"source_directory"`
Website string `json:"website"`
Description string `json:"description"`
LongDesc string `json:"long_desc"`
License string `json:"license"`
Entered string `json:"entered"`
SecurityPatch string `json:"security_patch,omitempty"`
BuildDeps []string `json:"build_deps"`
RuntimeDeps []string `json:"runtime_deps"`
OptionalDeps []string `json:"optional_deps"`
SubDepends []string `json:"sub_depends"`
Directory string `json:"-"`
}
// bridgeScript is the bash we exec to source DETAILS and emit JSON.
// We deliberately list every standard SMGL variable so dynamic logic in
// the sourced file is respected. Strings are JSON-escaped by jq-like
// printf via python3 (available on every Source Mage box) — or, if
// python3 is missing, by a tiny bash escaper.
const bridgeScript = `#!/bin/bash
source "$1" 2>/dev/null || exit 1
emit() {
local v="$1"; shift
printf '"%s":"%s"\n' "$v" "${!v//\"/\\\"}"
}
{
printf '{'
emit SPELL; printf ','
emit VERSION; printf ','
emit PATCHLEVEL; printf ','
emit SOURCE; printf ','
emit SOURCE_HASH; printf ','
emit SOURCE_DIRECTORY; printf ','
emit WEB_SITE; printf ','
emit ENTERED; printf ','
emit SECURITY_PATCH; printf ','
printf '"source_urls":['
i=0
while [[ -n "${SOURCE_URL[$i]:-}" ]]; do
[ $i -gt 0 ] && printf ','
printf '"%s"' "${SOURCE_URL[$i]//\"/\\\"}"
i=$((i+1))
done
printf '],'
printf '"license":"%s",' "${LICENSE[0]:-}"
printf '"short":"%s",' "${SHORT//\"/\\\"}"
printf '"long_desc":"%s"' "$(awk 'BEGIN{getline}{printf "%s\\n",$0}' <<EOF
$LONG_DESC
EOF
)"
printf '}'
}`
// ParseDetails sources a spell's DETAILS file via bash and returns a typed
// Spell. The spell's Directory field is set to spellDir so callers can
// later find BUILD / CONFIGURE / DEPENDS next to it.
func ParseDetails(spellDir string) (*Spell, error) {
detailsPath := filepath.Join(spellDir, "DETAILS")
if _, err := os.Stat(detailsPath); err != nil {
return nil, fmt.Errorf("grimoire: DETAILS not found at %s: %w", detailsPath, err)
}
// Run the bridge script in bash. We pass the DETAILS path as $1.
cmd := exec.Command("bash", "-c", bridgeScript, "bridge", detailsPath)
out, err := cmd.Output()
if err != nil {
// bash sourcing failed — capture stderr for debugging
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("grimoire: bash sourcing failed for %s: %w (stderr: %s)",
spellDir, err, string(exitErr.Stderr))
}
return nil, fmt.Errorf("grimoire: bash sourcing failed for %s: %w", spellDir, err)
}
// Strip any leading non-JSON noise bash may have printed.
out = trimToJSON(out)
var s Spell
if err := json.Unmarshal(out, &s); err != nil {
return nil, fmt.Errorf("grimoire: cannot decode JSON for %s: %w (raw=%q)",
spellDir, err, string(out))
}
if s.Name == "" {
// DETAILS may set SPELL dynamically — fall back to dir name.
s.Name = filepath.Base(spellDir)
}
s.Directory = spellDir
// Parse DEPENDS / SUB_DEPENDS / CONFIGURE if present.
if deps, err := ParseDepends(filepath.Join(spellDir, "DEPENDS")); err == nil {
for _, d := range deps {
switch d.Type {
case "build":
s.BuildDeps = append(s.BuildDeps, d.Name)
case "optional":
s.OptionalDeps = append(s.OptionalDeps, d.Name)
default:
s.RuntimeDeps = append(s.RuntimeDeps, d.Name)
}
}
}
if subs, err := ParseSubDepends(filepath.Join(spellDir, "DEPENDS")); err == nil {
s.SubDepends = append(s.SubDepends, subs...)
}
return &s, nil
}
// trimToJSON drops any bytes before the first '{' so we tolerate stray
// bash output during sourcing (echo statements in DETAILS, etc.).
func trimToJSON(b []byte) []byte {
for i, c := range b {
if c == '{' {
return b[i:]
}
}
return b
}
// IndexAll walks the grimoire root and returns an in-memory map of every
// spell. Parsing is parallelised across a worker pool sized to NumCPU.
//
// This is the "Librarian" function — it replaces the thousands of
// find + grep calls the original Bash sorcery makes at startup.
func IndexAll(root string) (map[string]*Spell, error) {
var dirs []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // tolerate broken symlinks
}
if !info.IsDir() {
return nil
}
if _, statErr := os.Stat(filepath.Join(path, "DETAILS")); statErr == nil {
dirs = append(dirs, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("grimoire: walk %s: %w", root, err)
}
type result struct {
name string
spell *Spell
}
jobs := make(chan string, len(dirs))
results := make(chan result, len(dirs))
var wg sync.WaitGroup
workers := runtime.NumCPU()
if workers > len(dirs) {
workers = len(dirs)
}
if workers < 1 {
workers = 1
}
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for dir := range jobs {
s, err := ParseDetails(dir)
if err != nil || s == nil {
results <- result{}
continue
}
results <- result{name: s.Name, spell: s}
}
}()
}
for _, d := range dirs {
jobs <- d
}
close(jobs)
wg.Wait()
close(results)
index := make(map[string]*Spell, len(dirs))
for r := range results {
if r.spell != nil {
index[r.name] = r.spell
}
}
return index, nil
}
// FindSpell locates a spell by name in the grimoire. Returns the directory
// path or an error. Used by the CLI when the user runs `sorcery cast wget`.
func FindSpell(grimoireRoot, name string) (string, error) {
var found string
err := filepath.Walk(grimoireRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || !info.IsDir() {
return nil
}
if filepath.Base(path) == name {
if _, statErr := os.Stat(filepath.Join(path, "DETAILS")); statErr == nil {
found = path
return filepath.SkipDir
}
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("grimoire: spell %q not found under %s", name, grimoireRoot)
}
return found, nil
}
// Section returns the category (e.g., "libs", "utils") for a spell directory.
func Section(spell *Spell) string {
if spell.Directory == "" {
return ""
}
rel, err := filepath.Rel(filepath.Dir(filepath.Dir(spell.Directory)), spell.Directory)
if err != nil {
return ""
}
return strings.SplitN(rel, string(filepath.Separator), 2)[0]
}

123
pkg/inventory/gaze.go Executable file
View File

@ -0,0 +1,123 @@
// Package inventory implements the Gaze query engine.
//
// Gaze is the "all-seeing eye" of the Coven. It answers:
//
// gaze install <spell> — list every file owned by an Essence
// gaze tablet <spell> — show the y/n answers stored in the Tablet
// gaze depends <spell> — print the live DAG of a spell's build tree
// gaze essence <hash> — show which spell and config produced this blob
// gaze whereis <file> — reverse lookup: which spell owns /usr/bin/wget
// gaze sbom [essence_id] — produce a CycloneDX SBOM on stdout
package inventory
import (
"fmt"
"sort"
"dcos.net/sorcery-go/pkg/dag"
"dcos.net/sorcery-go/pkg/legal"
"dcos.net/sorcery-go/pkg/state"
"dcos.net/sorcery-go/pkg/tomb"
)
// Inventory wraps the state and tomb so Gaze can answer queries fast.
type Inventory struct {
State *state.Manager
Tomb *tomb.Tomb
Graph *dag.Graph
}
// New returns an Inventory.
func New(s *state.Manager, t *tomb.Tomb, g *dag.Graph) *Inventory {
return &Inventory{State: s, Tomb: t, Graph: g}
}
// InstallList returns every file the spell variant currently owns.
func (i *Inventory) InstallList(spell, variant string) ([]string, error) {
return i.State.GetManifest(spell, variant)
}
// Depends walks the Graph from `spell` and returns a flat list of
// dependencies (transitive). Optional+disabled edges are skipped.
func (i *Inventory) Depends(spell string) ([]string, error) {
if i.Graph == nil {
return nil, fmt.Errorf("gaze: no graph loaded")
}
root, ok := i.Graph.Nodes[spell]
if !ok {
return nil, fmt.Errorf("gaze: %s not in grimoire", spell)
}
seen := make(map[string]bool)
var walk func(*dag.Node)
walk = func(n *dag.Node) {
if seen[n.Name] {
return
}
seen[n.Name] = true
for _, e := range n.Edges {
if e.Type == dag.OptionalDep && !e.Enabled {
continue
}
walk(e.Target)
}
}
walk(root)
delete(seen, spell)
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// EssenceInfo is the reverse lookup: given a file hash, find which spell
// produced it.
func (i *Inventory) EssenceInfo(hash string) (string, error) {
if i.Tomb == nil {
return "", fmt.Errorf("gaze: no tomb loaded")
}
all, err := i.Tomb.List()
if err != nil {
return "", err
}
for _, s := range all {
for _, h := range s.Files {
if h == hash {
return s.SpellName + " (" + s.Version + ")", nil
}
}
}
return "", fmt.Errorf("gaze: no essence contains hash %s", hash)
}
// WhereIs reverse-lookup: given a filesystem path, find which spell owns it.
// Uses the state DB's reverse index for O(1) lookup.
func (i *Inventory) WhereIs(path string) (string, error) {
if i.State == nil {
return "", fmt.Errorf("gaze: no state loaded")
}
spell, variant, err := i.State.WhoOwns(path)
if err != nil {
return "", fmt.Errorf("gaze: %s: %w", path, err)
}
return spell + " (" + variant + ")", nil
}
// Sbom collects every component in the Tomb and returns it as a
// legal.Component slice ready for CycloneDX / SPDX export.
func (i *Inventory) Sbom() []legal.Component {
if i.Tomb == nil {
return nil
}
all, _ := i.Tomb.List()
out := make([]legal.Component, 0, len(all))
for _, s := range all {
out = append(out, legal.Component{
Name: s.SpellName, Version: s.Version,
License: s.License, Hash: s.EssenceID,
Purl: fmt.Sprintf("pkg:sorcery/%s@%s", s.SpellName, s.Version),
})
}
return out
}

151
pkg/legal/policy.go Executable file
View File

@ -0,0 +1,151 @@
// Package legal is the "Legal Sentinel" — license compliance & policy
// enforcement for the Coven.
//
// It acts as a Pre-Forge Filter: every cast request is intercepted and
// compared against the active Compliance Profile. Three templates ship
// out-of-the-box:
//
// strict_copyleft — FSF/GNU style, blocks all proprietary blobs
// corporate_lite — MIT/Apache friendly, AGPL/SSPL blacklisted
// lawless — "for the brave", silent audit only
//
// The package also generates SBOM exports (SPDX / CycloneDX) so the legal
// department can audit the fleet from a single command.
package legal
import (
"fmt"
"strings"
)
// Policy is the active posture of the Coven.
type Policy struct {
Name string
AllowedFamilies []string
Blacklist []string
FailOnProprietary bool
FailOnCopyleftViral bool
AllowInternalProprietary bool
RequireAttribution bool
SilentMode bool // lawless — log only, never block
AutoAcceptEULA bool
}
// LicenseInfo is what the Grimoire parser yields up about a spell.
type LicenseInfo struct {
SpellName string
License string
IsCopyleft bool
IsProprietary bool
Internal bool
}
// Sentinel is the engine that validates casts.
type Sentinel struct {
Policy Policy
}
// Validate runs the policy against a spell's license. Returns an error if
// the cast must be blocked; nil if it's allowed (possibly with a warning
// stored in `warnings`).
func (s *Sentinel) Validate(info LicenseInfo) (warnings []string, err error) {
// Lawless — never blocks.
if s.Policy.SilentMode {
return nil, nil
}
// 1. Blacklist check.
for _, b := range s.Policy.Blacklist {
if strings.EqualFold(info.License, b) {
return nil, fmt.Errorf("legal: license %s is blacklisted by policy %q", info.License, s.Policy.Name)
}
}
// 2. Proprietary check.
if info.IsProprietary && s.Policy.FailOnProprietary {
if info.Internal && s.Policy.AllowInternalProprietary {
warnings = append(warnings, "internal proprietary spell — verify deployment target")
return warnings, nil
}
return nil, fmt.Errorf("legal: proprietary licenses blocked by policy %q", s.Policy.Name)
}
// 3. Viral copyleft check.
if info.IsCopyleft && s.Policy.FailOnCopyleftViral {
return nil, fmt.Errorf("legal: viral copyleft (%s) blocked by policy %q", info.License, s.Policy.Name)
}
// 4. Allowed families.
if len(s.Policy.AllowedFamilies) > 0 && !contains(s.Policy.AllowedFamilies, familyOf(info.License)) {
if s.Policy.SilentMode {
warnings = append(warnings, fmt.Sprintf("license %s outside allowed families", info.License))
return warnings, nil
}
return nil, fmt.Errorf("legal: license %s not in allowed families %v", info.License, s.Policy.AllowedFamilies)
}
if info.IsCopyleft {
warnings = append(warnings, "copyleft spell — audit required for proprietary Essence integration")
}
return warnings, nil
}
// familyOf reduces a specific license string ("GPL-3.0-only") to its family
// ("GPL") so the policy can be expressed at family granularity.
func familyOf(license string) string {
license = strings.ToUpper(strings.TrimSpace(license))
for _, fam := range []string{"GPL", "LGPL", "AGPL", "MIT", "BSD", "APACHE", "ISC", "MPL", "SSPL", "WTFPL", "UNLICENSE"} {
if strings.HasPrefix(license, fam) {
return fam
}
}
return license
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if strings.EqualFold(h, needle) {
return true
}
}
return false
}
// StrictCopyleft is the FSF/GNU posture.
func StrictCopyleft() Policy {
return Policy{
Name: "strict_copyleft",
AllowedFamilies: []string{"GPL", "LGPL", "MIT", "BSD", "APACHE", "ISC", "MPL", "UNLICENSE"},
Blacklist: []string{"CC-BY-NC-ND", "SSPL", "WTFPL"},
FailOnProprietary: true,
RequireAttribution: true,
}
}
// CorporateLite is the MIT/Apache-friendly posture.
func CorporateLite() Policy {
return Policy{
Name: "corporate_lite",
AllowedFamilies: []string{"MIT", "BSD", "APACHE", "ISC", "UNLICENSE"},
Blacklist: []string{"AGPL", "SSPL"},
FailOnCopyleftViral: true,
AllowInternalProprietary: true,
}
}
// Lawless is the "for the brave" posture — never blocks, only audits.
func Lawless() Policy {
return Policy{
Name: "lawless",
SilentMode: true,
AutoAcceptEULA: true,
}
}
// LoadPolicy returns the matching Policy object for a profile name.
func LoadPolicy(name string) (Policy, error) {
switch name {
case "strict_copyleft":
return StrictCopyleft(), nil
case "corporate_lite":
return CorporateLite(), nil
case "lawless":
return Lawless(), nil
}
return Policy{}, fmt.Errorf("legal: unknown profile %q", name)
}

69
pkg/legal/policy_test.go Executable file
View File

@ -0,0 +1,69 @@
package legal
import "testing"
func TestStrictCopyleftBlocksProprietary(t *testing.T) {
s := &Sentinel{Policy: StrictCopyleft()}
_, err := s.Validate(LicenseInfo{
SpellName: "nvidia-driver",
License: "PROPRIETARY",
IsProprietary: true,
})
if err == nil {
t.Fatalf("strict_copyleft must block proprietary licenses")
}
}
func TestCorporateLiteBlocksAGPL(t *testing.T) {
s := &Sentinel{Policy: CorporateLite()}
_, err := s.Validate(LicenseInfo{
SpellName: "mongodb",
License: "AGPL-3.0",
IsCopyleft: true,
})
if err == nil {
t.Fatalf("corporate_lite must block AGPL")
}
}
func TestLawlessNeverBlocks(t *testing.T) {
s := &Sentinel{Policy: Lawless()}
_, err := s.Validate(LicenseInfo{
SpellName: "anything",
License: "WTFPL",
IsProprietary: true,
})
if err != nil {
t.Fatalf("lawless must never block, got: %v", err)
}
}
func TestStrictAllowsGPL(t *testing.T) {
s := &Sentinel{Policy: StrictCopyleft()}
warnings, err := s.Validate(LicenseInfo{
SpellName: "wget",
License: "GPL-3.0-or-later",
IsCopyleft: true,
})
if err != nil {
t.Fatalf("strict_copyleft must allow GPL, got: %v", err)
}
if len(warnings) == 0 {
t.Fatalf("expected a copyleft audit warning")
}
}
func TestFamilyOf(t *testing.T) {
cases := map[string]string{
"GPL-3.0-only": "GPL",
"LGPL-2.1-or-later": "LGPL",
"Apache-2.0": "APACHE",
"MIT": "MIT",
"AGPL-3.0": "AGPL",
}
for in, want := range cases {
if got := familyOf(in); got != want {
t.Errorf("familyOf(%q) = %q, want %q", in, got, want)
}
}
}

58
pkg/legal/sbom.go Executable file
View File

@ -0,0 +1,58 @@
// SBOM (Software Bill of Materials) export.
//
// Generates CycloneDX or SPDX reports from the Coven's Tomb. Used by the
// legal department to audit the fleet and by Portable Tool Bin consumers
// who need to redistribute static binaries with proper attribution.
package legal
import (
"encoding/json"
"encoding/xml"
"fmt"
)
// Component is one entry in the SBOM.
type Component struct {
Name string `json:"name" xml:"name"`
Version string `json:"version" xml:"version"`
License string `json:"license" xml:"license"`
Hash string `json:"hash" xml:"hash"`
Purl string `json:"purl" xml:"purl"`
}
// Sbom is the report itself.
type Sbom struct {
Components []Component `json:"components" xml:"component"`
}
// ExportCycloneDX produces a CycloneDX v1.4 JSON document for a set of
// Essences. `tree` is the dependency tree returned by the Tomb.
func ExportCycloneDX(tree []Component) ([]byte, error) {
s := Sbom{Components: tree}
return json.MarshalIndent(s, "", " ")
}
// ExportSPDX produces an SPDX 2.3 XML document for the same data.
func ExportSPDX(tree []Component) ([]byte, error) {
type spdxDoc struct {
XMLName struct{} `xml:"SpdxDocument"`
License string `xml:"License,attr"`
Comp []Component `xml:"component"`
}
return xml.MarshalIndent(spdxDoc{Comp: tree}, "", " ")
}
// AttributionBundle collects every LICENSE / COPYING file referenced by
// the given components. The Portable Tool Bin attaches this as
// CREDITS.md / LICENSE_BUNDLE.txt to every downloaded static ELF so the
// legal "notice" requirement of MIT/BSD/GPL is satisfied out-of-the-box.
func AttributionBundle(tree []Component) string {
var out string
out = "# Attribution Bundle\n\nGenerated by the Sorcery-Go Legal Sentinel.\n\n"
for _, c := range tree {
out += fmt.Sprintf("## %s %s\n", c.Name, c.Version)
out += fmt.Sprintf("- License: %s\n", c.License)
out += fmt.Sprintf("- Hash: %s\n\n", c.Hash)
}
return out
}

155
pkg/quill/quill.go Executable file
View File

@ -0,0 +1,155 @@
// Package quill is the "Scribe" of the Coven — the interactive wizard that
// interviews a developer and emits a new spell (DETAILS, DEPENDS, BUILD,
// CONFIGURE) into the Grimoire.
//
// Replacing the original Bash Quill, this Go version uses text/template
// for type-safe file generation and an HTTP-friendly SpellData struct so
// the WebUI can drive the same interview through a modal form.
package quill
import (
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"text/template"
"time"
)
// SpellData is everything the interview collects.
type SpellData struct {
Name string
Version string
SourceURL string
Hash string
Website string
License string
Description string
LongDesc string
Dependencies []string
Date string
}
// GenerateSpell writes a new spell directory under `targetDir` containing
// DETAILS, DEPENDS, BUILD and CONFIGURE. Atomic "buffer-then-commit": no
// file is written until the template parses successfully.
func GenerateSpell(data SpellData, targetDir string) error {
if data.Date == "" {
data.Date = time.Now().Format("20060102")
}
if data.Hash == "" {
// "Smart Quill" — auto-hash the upstream source.
h, err := autoHash(data.SourceURL)
if err == nil {
data.Hash = h
}
}
tmpl, err := template.New("details").Parse(detailsTmpl)
if err != nil {
return fmt.Errorf("quill: template parse: %w", err)
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return err
}
f, err := os.Create(filepath.Join(targetDir, "DETAILS"))
if err != nil {
return err
}
defer f.Close()
if err := tmpl.Execute(f, data); err != nil {
return fmt.Errorf("quill: template exec: %w", err)
}
// DEPENDS
depTmpl, err := template.New("depends").Parse(dependsTmpl)
if err != nil {
return fmt.Errorf("quill: parse depends template: %w", err)
}
df, err := os.Create(filepath.Join(targetDir, "DEPENDS"))
if err != nil {
return fmt.Errorf("quill: create DEPENDS: %w", err)
}
defer df.Close()
if err := depTmpl.Execute(df, data); err != nil {
return fmt.Errorf("quill: exec depends template: %w", err)
}
// BUILD
bf, err := os.Create(filepath.Join(targetDir, "BUILD"))
if err != nil {
return fmt.Errorf("quill: create BUILD: %w", err)
}
defer bf.Close()
if _, err := bf.WriteString(buildTmpl); err != nil {
return fmt.Errorf("quill: write BUILD: %w", err)
}
// CONFIGURE
cf, err := os.Create(filepath.Join(targetDir, "CONFIGURE"))
if err != nil {
return fmt.Errorf("quill: create CONFIGURE: %w", err)
}
defer cf.Close()
if _, err := cf.WriteString(configureTmpl); err != nil {
return fmt.Errorf("quill: write CONFIGURE: %w", err)
}
return nil
}
// autoHash downloads the source URL and computes its SHA-512 — Quill's
// "Smart" mode that saves the developer from running sha512sum by hand.
func autoHash(url string) (string, error) {
if url == "" {
return "", fmt.Errorf("quill: no source URL")
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("quill: HTTP %d fetching %s", resp.StatusCode, url)
}
h := sha512.New()
if _, err := io.Copy(h, resp.Body); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
const detailsTmpl = `SPELL={{.Name}}
VERSION={{.Version}}
SOURCE=${SPELL}-${VERSION}.tar.gz
SOURCE_URL[0]={{.SourceURL}}
SOURCE_HASH=sha512:{{.Hash}}
SOURCE_DIRECTORY="${BUILD_DIRECTORY}/${SPELL}-${VERSION}"
WEB_SITE={{.Website}}
ENTERED={{.Date}}
LICENSE[0]={{.License}}
SHORT="{{.Description}}"
cat << EOF
{{.LongDesc}}
EOF
`
const dependsTmpl = `{{range .Dependencies}}depends {{.}} ""
{{end}}`
const buildTmpl = `#!/bin/bash
# Standard BUILD script edit as needed.
cd "$SOURCE_DIRECTORY" &&
./configure --prefix=/usr "$@" &&
make &&
make install
`
const configureTmpl = `#!/bin/bash
# CONFIGURE interactive queries go here. The Go ICE engine reads these.
# config_query WGET_SSL "Enable SSL support?" y
`

196
pkg/runtime/baremetal.go Executable file
View File

@ -0,0 +1,196 @@
// BareMetal runtime adapter for Sorcery-Go.
//
// BareMetal deploys Essences directly to the host filesystem without
// any container or VM isolation. This is the simplest mode and is
// useful for:
// - CI environments where isolation is handled externally
// - Single-node development setups
// - Systems where container runtimes are unavailable
//
// Security enforcement still applies: the eBPF Tomb Guard LSM hook
// protects the Tomb regardless of whether containers are used.
package runtime
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
// BareMetalRuntime deploys Essences directly to the host filesystem.
type BareMetalRuntime struct {
deployRoot string
}
// NewBareMetalRuntime creates a baremetal runtime adapter.
func NewBareMetalRuntime() *BareMetalRuntime {
return &BareMetalRuntime{
deployRoot: "/opt/sorcery/sanctums",
}
}
func (r *BareMetalRuntime) Type() Type { return RuntimeBareMetal }
func (r *BareMetalRuntime) Name() string { return "Bare Metal (no container)" }
// Probe always succeeds — baremetal is always available.
func (r *BareMetalRuntime) Probe() error { return nil }
// Create creates a directory for the sanctum deployment.
func (r *BareMetalRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
sanctumPath := filepath.Join(r.deployRoot, opts.Name)
if err := os.MkdirAll(sanctumPath, 0755); err != nil {
return "", err
}
// TODO: persist sanctum metadata (name, runtime, arch, created) to a
// JSON sidecar file under sanctumPath/metadata.json for status reporting.
return opts.Name, nil
}
// Start is a no-op for baremetal (the files are already on disk).
func (r *BareMetalRuntime) Start(ctx context.Context, sanctumID string) error {
return nil
}
// Stop is a no-op for baremetal.
func (r *BareMetalRuntime) Stop(ctx context.Context, sanctumID string) error {
return nil
}
// Freeze sends SIGSTOP to all processes running from the sanctum path.
func (r *BareMetalRuntime) Freeze(ctx context.Context, sanctumID string) error {
// For baremetal, we can try to freeze via the cgroup of any process
// running from the sanctum directory. This is a best-effort approach.
cgPath := r.CgroupPath(sanctumID)
if cgPath != "" {
freezeFile := cgPath + "/cgroup.freeze"
if _, err := os.Stat(freezeFile); err == nil {
return os.WriteFile(freezeFile, []byte("1"), 0644)
}
}
return nil
}
// Thaw resumes a frozen baremetal sanctum.
func (r *BareMetalRuntime) Thaw(ctx context.Context, sanctumID string) error {
cgPath := r.CgroupPath(sanctumID)
if cgPath != "" {
freezeFile := cgPath + "/cgroup.freeze"
if _, err := os.Stat(freezeFile); err == nil {
return os.WriteFile(freezeFile, []byte("0"), 0644)
}
}
return nil
}
// Destroy removes the sanctum directory.
func (r *BareMetalRuntime) Destroy(ctx context.Context, sanctumID string) error {
sanctumPath := filepath.Join(r.deployRoot, sanctumID)
return os.RemoveAll(sanctumPath)
}
// Exec runs a command in the sanctum's chroot using chroot(2).
func (r *BareMetalRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
sanctumPath := filepath.Join(r.deployRoot, sanctumID)
if len(command) == 0 {
return &ExecResult{}, nil
}
// Use chroot to execute the command in the sanctum environment.
// This requires CAP_SYS_CHROOT.
cmd := exec.CommandContext(ctx, "chroot", sanctumPath, command[0])
cmd.Args = append([]string{command[0]}, command[1:]...)
if len(stdin) > 0 {
cmd.Stdin = strings.NewReader(string(stdin))
}
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return &ExecResult{
ExitCode: exitCode(err),
Stdout: []byte(stdout.String()),
Stderr: []byte(stderr.String()),
}, err
}
// Status returns the status of a baremetal sanctum.
func (r *BareMetalRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
sanctumPath := filepath.Join(r.deployRoot, sanctumID)
info := &SanctumInfo{
ID: sanctumID,
Name: sanctumID,
Runtime: RuntimeBareMetal,
Status: StatusStopped,
RootFS: sanctumPath,
}
st, err := os.Stat(sanctumPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("baremetal: sanctum %s does not exist", sanctumID)
}
return nil, err
}
// Check if any processes are running from this directory (heuristic).
info.Created = time.Unix(st.Sys().(*syscall.Stat_t).Ctim.Unix(), 0)
info.Status = StatusRunning // baremetal is always "running" if it exists
info.Cgroup = r.CgroupPath(sanctumID)
return info, nil
}
// List returns all baremetal sanctums.
func (r *BareMetalRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
entries, err := os.ReadDir(r.deployRoot)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var infos []*SanctumInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
info, err := r.Status(ctx, e.Name())
if err != nil {
continue
}
infos = append(infos, info)
}
return infos, nil
}
// CgroupPath returns the cgroup path for baremetal processes.
// For baremetal, this typically falls back to the system.slice.
func (r *BareMetalRuntime) CgroupPath(sanctumID string) string {
// Baremetal doesn't have a dedicated cgroup.
// Return empty — the eBPF LSM hook still protects the Tomb at the
// kernel level regardless of cgroup attachment.
return ""
}
// --- shared cgroup helpers ---
// cgroupV2PathByPID tries to find the cgroup v2 path for a container
// by inspecting /proc/<pid>/cgroup. This is a fallback when the
// runtime-specific cgroup path detection fails.
func cgroupV2PathByPID(sanctumID string) string {
// This is a simplified implementation. A production version would:
// 1. Find the init PID of the container (via runtime-specific methods)
// 2. Read /proc/<pid>/cgroup
// 3. Parse the cgroup v2 hierarchy path
// For now, return empty — the runtime-specific paths should handle
// the common cases.
return ""
}

499
pkg/runtime/firecracker.go Executable file
View File

@ -0,0 +1,499 @@
// Firecracker runtime adapter for Sorcery-Go Sanctums.
//
// Firecracker is an AWS open-source microVM that provides strong VM-level
// isolation with sub-millisecond boot times and a minimal attack surface.
// Each Sanctum runs in its own Firecracker microVM with dedicated kernel
// and rootfs — providing hardware-level isolation that containers cannot match.
//
// This adapter communicates with the Firecracker VMM process via its
// Unix socket API (the "Machine Controller"). Each microVM is managed as:
// 1. Allocate a VMM Unix socket
// 2. Configure boot source (kernel + initrd or rootfs)
// 3. Configure root drive
// 4. Configure network interface (tap device)
// 5. Start the instance
//
// Note: Firecracker does NOT use cgroups for process management, so the
// eBPF cgroup filter attachment is not applicable. The Tomb Guard LSM
// hook still protects the host's Tomb from any process, including the
// Firecracker VMM process itself. Network isolation is handled by the
// Firecracker jailer's chroot + seccomp + network namespace.
package runtime
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// DefaultFirecrackerKernelPath is the fallback kernel image path when
// no per-VM KernelPath is specified via CreateOpts.
const DefaultFirecrackerKernelPath = "/var/lib/sorcery-go/vmlinux"
// DefaultFirecrackerBootArgs are the base kernel command-line arguments.
// IP configuration is appended when the VM has a network interface.
const DefaultFirecrackerBootArgs = "console=ttyS0 reboot=k panic=1 pci=off"
// FirecrackerRuntime manages Firecracker microVMs.
type FirecrackerRuntime struct {
mu sync.Mutex
binPath string // path to firecracker binary
jailerPath string // path to jailer binary
socketDir string // directory for VMM sockets
vms map[string]*fcVM // active VMs by sanctum ID
}
// fcVM tracks the state of a running Firecracker microVM.
type fcVM struct {
ID string
SocketPath string
PID int
RootFSPath string
KernelPath string
DrivePath string
TapDevice string
IP string
Started time.Time
}
// Firecracker API request/response types.
type fcBootSource struct {
KernelImage string `json:"kernel_image_path"`
BootArgs string `json:"boot_args,omitempty"`
InitrdPath string `json:"initrd_path,omitempty"`
}
type fcDrive struct {
DriveID string `json:"drive_id"`
PathOnHost string `json:"path_on_host"`
IsRootDevice bool `json:"is_root_device"`
IsReadOnly bool `json:"is_read_only"`
Partuuid string `json:"partuuid,omitempty"`
}
type fcInterface struct {
IfaceID string `json:"iface_id"`
GuestMac string `json:"guest_mac"`
HostDevName string `json:"host_dev_name"`
}
type fcInstanceAction struct {
ActionType string `json:"action_type"`
}
// NewFirecrackerRuntime creates a Firecracker runtime adapter.
func NewFirecrackerRuntime() *FirecrackerRuntime {
return &FirecrackerRuntime{
binPath: "firecracker",
socketDir: "/run/sorcery-go/firecracker",
vms: make(map[string]*fcVM),
}
}
func (r *FirecrackerRuntime) Type() Type { return RuntimeFirecracker }
func (r *FirecrackerRuntime) Name() string { return "Firecracker microVMs" }
// Probe checks that firecracker is available.
func (r *FirecrackerRuntime) Probe() error {
if _, err := exec.LookPath("firecracker"); err != nil {
return fmt.Errorf("firecracker: binary not found in PATH: %w", err)
}
// Jailer is optional but recommended.
if _, err := exec.LookPath("jailer"); err == nil {
r.jailerPath = "jailer"
}
return nil
}
// Create provisions a new Firecracker microVM.
// This sets up the socket and configuration but does not start the VM.
func (r *FirecrackerRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err := os.MkdirAll(r.socketDir, 0755); err != nil {
return "", fmt.Errorf("firecracker: mkdir %s: %w", r.socketDir, err)
}
socketPath := filepath.Join(r.socketDir, opts.Name+".sock")
vm := &fcVM{
ID: opts.Name,
SocketPath: socketPath,
KernelPath: opts.KernelPath,
DrivePath: opts.RootDrivePath,
RootFSPath: opts.RootFS,
Started: time.Now(),
}
// Generate a MAC address for the VM.
if opts.NetworkConfig != nil && opts.NetworkConfig.Type != "none" {
vm.TapDevice = "tap-" + opts.Name
vm.IP = opts.NetworkConfig.IP
}
r.vms[opts.Name] = vm
return opts.Name, nil
}
// Start boots a Firecracker microVM.
func (r *FirecrackerRuntime) Start(ctx context.Context, sanctumID string) error {
r.mu.Lock()
vm, ok := r.vms[sanctumID]
r.mu.Unlock()
if !ok {
return fmt.Errorf("firecracker: unknown VM %s", sanctumID)
}
// Clean up old socket if present.
if err := os.Remove(vm.SocketPath); err != nil {
log.Printf("firecracker: remove old socket %s: %v\n", vm.SocketPath, err)
}
// Start the Firecracker VMM process.
args := []string{"--api-sock", vm.SocketPath}
cmd := exec.CommandContext(ctx, r.binPath, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("firecracker: start VMM %s: %w", sanctumID, err)
}
vm.PID = cmd.Process.Pid
// Wait for the socket to appear (channel-based, no busy-wait).
if err := waitForFile(vm.SocketPath, 5*time.Second); err != nil {
return fmt.Errorf("firecracker: VMM socket not ready: %w", err)
}
client := newFCClient(vm.SocketPath)
// Set boot source.
if vm.KernelPath == "" {
vm.KernelPath = DefaultFirecrackerKernelPath
}
bootArgs := DefaultFirecrackerBootArgs
if vm.IP != "" {
bootArgs += " ip=" + vm.IP
}
if err := client.putBootSource(fcBootSource{
KernelImage: vm.KernelPath,
BootArgs: bootArgs,
}); err != nil {
return fmt.Errorf("firecracker: configure boot source: %w", err)
}
// Set root drive.
if vm.DrivePath != "" {
if err := client.putDrive(fcDrive{
DriveID: "rootfs",
PathOnHost: vm.DrivePath,
IsRootDevice: true,
IsReadOnly: false,
}); err != nil {
return fmt.Errorf("firecracker: configure root drive: %w", err)
}
}
// Configure network interface.
if vm.TapDevice != "" {
if err := client.putInterface(fcInterface{
IfaceID: "eth0",
GuestMac: generateMAC(sanctumID),
HostDevName: vm.TapDevice,
}); err != nil {
return fmt.Errorf("firecracker: configure network: %w", err)
}
// Create the tap device on the host.
if out, err := exec.Command("ip", "tuntap", "add", "dev", vm.TapDevice, "mode", "tap").CombinedOutput(); err != nil {
return fmt.Errorf("firecracker: create tap %s: %w (%s)", vm.TapDevice, err, string(out))
}
if out, err := exec.Command("ip", "link", "set", vm.TapDevice, "up").CombinedOutput(); err != nil {
return fmt.Errorf("firecracker: bring up tap %s: %w (%s)", vm.TapDevice, err, string(out))
}
}
// Start the instance (the actual boot).
if err := client.putInstanceAction(fcInstanceAction{ActionType: "InstanceStart"}); err != nil {
return fmt.Errorf("firecracker: start instance: %w", err)
}
return nil
}
// Stop sends an ACPI shutdown to the Firecracker VM.
func (r *FirecrackerRuntime) Stop(ctx context.Context, sanctumID string) error {
r.mu.Lock()
vm, ok := r.vms[sanctumID]
r.mu.Unlock()
if !ok {
return fmt.Errorf("firecracker: unknown VM %s", sanctumID)
}
client := newFCClient(vm.SocketPath)
if err := client.putInstanceAction(fcInstanceAction{ActionType: "SendCtrlAltDel"}); err != nil {
// VM may already be stopped — log but continue cleanup.
}
// Wait for the VMM process to exit with a context-aware wait.
if vm.PID > 0 {
waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := waitForPID(waitCtx, vm.PID); err != nil {
// Force-kill if graceful shutdown timed out.
_ = syscall.Kill(vm.PID, syscall.SIGKILL)
}
}
// Clean up tap device.
if vm.TapDevice != "" {
exec.Command("ip", "link", "set", vm.TapDevice, "down").Run()
exec.Command("ip", "tuntap", "del", "dev", vm.TapDevice, "mode", "tap").Run()
}
return nil
}
// Freeze is a no-op for Firecracker VMs. MicroVMs can be paused
// at the VMM level but this requires snapshot support which is
// a more advanced feature. For now, we report that freeze is
// not supported for Firecracker and suggest using Stop instead.
func (r *FirecrackerRuntime) Freeze(ctx context.Context, sanctumID string) error {
return fmt.Errorf("firecracker: freeze not supported for microVMs — use Stop instead")
}
// Thaw is a no-op for Firecracker VMs.
func (r *FirecrackerRuntime) Thaw(ctx context.Context, sanctumID string) error {
return fmt.Errorf("firecracker: thaw not supported for microVMs — use Start instead")
}
// Destroy stops and removes a Firecracker microVM.
func (r *FirecrackerRuntime) Destroy(ctx context.Context, sanctumID string) error {
r.Stop(ctx, sanctumID)
r.mu.Lock()
defer r.mu.Unlock()
vm, ok := r.vms[sanctumID]
if !ok {
return nil // already destroyed
}
os.Remove(vm.SocketPath)
delete(r.vms, sanctumID)
return nil
}
// Exec runs a command inside a Firecracker VM via serial console.
// Note: This is a simplified implementation that uses the serial console.
// A production implementation would use an SSH connection or a virtio-serial
// channel with a guest agent.
func (r *FirecrackerRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
// Firecracker doesn't have a built-in exec mechanism.
// In production, this would use:
// 1. mmds (MicroVM Metadata Service) for command dispatch
// 2. SSH into the VM's IP address
// 3. virtio-serial guest agent
// For now, return an error indicating this is not available.
return nil, fmt.Errorf("firecracker: exec not supported — use SSH to %s (IP: %s)",
sanctumID, r.getVMIP(sanctumID))
}
// Status returns the state of a Firecracker microVM.
func (r *FirecrackerRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
r.mu.Lock()
vm, ok := r.vms[sanctumID]
r.mu.Unlock()
if !ok {
return nil, fmt.Errorf("firecracker: unknown VM %s", sanctumID)
}
status := StatusStopped
if vm.PID > 0 {
// Check if the VMM process is still running.
if _, err := os.FindProcess(vm.PID); err == nil {
// Send signal 0 to check if process exists.
if exec.Command("kill", "-0", strconv.Itoa(vm.PID)).Run() == nil {
status = StatusRunning
}
}
}
return &SanctumInfo{
ID: vm.ID,
Name: vm.ID,
Runtime: RuntimeFirecracker,
Status: status,
Arch: "x86_64", // Firecracker is x86_64 only (aarch64 experimental)
IP: vm.IP,
PID: uint32(vm.PID),
RootFS: vm.DrivePath,
Created: vm.Started,
Cgroup: "", // Firecracker doesn't use cgroups for the guest
Metadata: map[string]string{
"socket": vm.SocketPath,
"tap_device": vm.TapDevice,
"kernel": vm.KernelPath,
},
}, nil
}
// List returns all Firecracker microVMs managed by this runtime.
func (r *FirecrackerRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
r.mu.Lock()
defer r.mu.Unlock()
var infos []*SanctumInfo
for _, vm := range r.vms {
info, _ := r.Status(ctx, vm.ID)
if info != nil {
infos = append(infos, info)
}
}
return infos, nil
}
// CgroupPath returns "" for Firecracker since it doesn't use cgroups.
func (r *FirecrackerRuntime) CgroupPath(sanctumID string) string {
// Firecracker VMs are not managed via cgroups on the host.
// The VMM process itself runs in the host's cgroup, but the
// guest processes are isolated in their own kernel.
// The eBPF LSM hook still protects the host Tomb regardless.
return ""
}
func (r *FirecrackerRuntime) getVMIP(id string) string {
r.mu.Lock()
defer r.mu.Unlock()
if vm, ok := r.vms[id]; ok {
return vm.IP
}
return ""
}
// waitForPID polls until the given PID exits or the context expires.
func waitForPID(ctx context.Context, pid int) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := syscall.Kill(pid, 0); err != nil {
return nil // process gone
}
}
}
}
// --- Firecracker API client ---
type fcAPIClient struct {
socketPath string
httpClient *http.Client
baseURL string
}
func newFCClient(socketPath string) *fcAPIClient {
dialer := func(proto, addr string) (net.Conn, error) {
return net.Dial("unix", socketPath)
}
transport := &http.Transport{
Dial: dialer,
}
return &fcAPIClient{
socketPath: socketPath,
httpClient: &http.Client{Transport: transport, Timeout: 10 * time.Second},
baseURL: "http://localhost",
}
}
func (c *fcAPIClient) putBootSource(bs fcBootSource) error {
return c.put("/boot-source", bs)
}
func (c *fcAPIClient) putDrive(d fcDrive) error {
return c.put("/drives/"+d.DriveID, d)
}
func (c *fcAPIClient) putInterface(i fcInterface) error {
return c.put("/network-interfaces/"+i.IfaceID, i)
}
func (c *fcAPIClient) putInstanceAction(a fcInstanceAction) error {
return c.put("/actions", a)
}
func (c *fcAPIClient) put(path string, payload interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", c.baseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("firecracker API %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("firecracker API %s: %s (body: %s)", path, resp.Status, string(body))
}
return nil
}
// --- helpers ---
// waitForFile waits for a file to appear within a timeout using
// a channel-based ticker (SEI CERT: no busy-wait / sleep-in-loop).
func waitForFile(path string, timeout time.Duration) error {
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-ticker.C:
if _, err := os.Stat(path); err == nil {
return nil
}
case <-timer.C:
return fmt.Errorf("timeout waiting for %s", path)
}
}
}
// generateMAC produces a deterministic, locally-administered MAC (second bit
// of the first octet set) from the sanctum ID. Uses FNV-1a for better
// distribution than the former integer-overflow hash (SEI CERT EXP00-J).
func generateMAC(id string) string {
h := uint32(2166136261)
for _, c := range id {
h ^= uint32(c)
h *= 16777619
}
return fmt.Sprintf("02:FC:%02X:%02X:%02X:%02X",
(h>>24)&0xFF, (h>>16)&0xFF, (h>>8)&0xFF, h&0xFF)
}

360
pkg/runtime/lxc.go Executable file
View File

@ -0,0 +1,360 @@
// LXC runtime adapter for Sorcery-Go Sanctums.
//
// Uses the lxc-tools CLI (lxc-create, lxc-start, lxc-stop, lxc-destroy,
// lxc-freeze, lxc-unfreeze, lxc-execute, lxc-info, lxc-ls) to manage
// system containers. This is the original runtime that Sorcery-Go was
// designed around.
//
// LXC containers share the host kernel and use Linux namespaces for
// isolation. They are ideal for high-density deployment where the
// performance overhead of virtualization is undesirable.
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// LXCRuntime manages LXC system containers.
type LXCRuntime struct {
binPath string // path to lxc-* tools (e.g., "/usr/bin")
configDir string // LXC config directory (default: /var/lib/lxc)
lxcInclude string // path to common.conf include directory
}
// NewLXCRuntime creates an LXC runtime adapter. It auto-detects the
// lxc-tools installation path.
func NewLXCRuntime() *LXCRuntime {
return &LXCRuntime{
binPath: "/usr/bin",
configDir: "/var/lib/lxc",
}
}
func (r *LXCRuntime) Type() Type { return RuntimeLXC }
func (r *LXCRuntime) Name() string { return "LXC system containers" }
// Probe checks that lxc-create is available on the host.
func (r *LXCRuntime) Probe() error {
if _, err := exec.LookPath("lxc-create"); err != nil {
return fmt.Errorf("lxc: lxc-create not found in PATH: %w", err)
}
if _, err := exec.LookPath("lxc-start"); err != nil {
return fmt.Errorf("lxc: lxc-start not found in PATH: %w", err)
}
r.configDir = "/var/lib/lxc"
if d := os.Getenv("LXC_PATH"); d != "" {
r.configDir = d
}
return nil
}
// Create provisions a new LXC container.
func (r *LXCRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
args := []string{
"-n", opts.Name,
"-t", opts.Image, // template name (e.g., "download")
}
// For the "download" template, pass distro/arch/release.
if opts.Image == "download" {
args = append(args, "--")
if opts.Arch != "" {
args = append(args, "-a", opts.Arch)
}
// Allow setting distro/release via ExtraConfig.
if distro, ok := opts.ExtraConfig["distro"]; ok {
args = append(args, "-d", distro)
}
if release, ok := opts.ExtraConfig["release"]; ok {
args = append(args, "-r", release)
}
}
cmd := exec.CommandContext(ctx, "lxc-create", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("lxc: create %s: %w\n%s", opts.Name, err, string(out))
}
// Write custom config overrides to the container's config file.
configPath := filepath.Join(r.configDir, opts.Name, "config")
if opts.NetworkConfig != nil {
if err := r.appendConfig(configPath, buildLXCNetworkConfig(opts.NetworkConfig)); err != nil {
return "", fmt.Errorf("lxc: write network config: %w", err)
}
}
if opts.RootFS != "" {
if err := r.appendConfig(configPath, fmt.Sprintf("lxc.rootfs.path = %s\n", opts.RootFS)); err != nil {
return "", fmt.Errorf("lxc: write rootfs config: %w", err)
}
}
// SECURITY: eBPF replaces AppArmor — set profile to unconfined since eBPF handles MAC.
if err := r.appendConfig(configPath, "lxc.apparmor.profile = unconfined\n"); err != nil {
return "", fmt.Errorf("lxc: write apparmor config: %w", err)
}
return opts.Name, nil
}
// Start boots an LXC container.
func (r *LXCRuntime) Start(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "lxc-start", "-n", sanctumID, "-d")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: start %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Stop gracefully stops an LXC container.
func (r *LXCRuntime) Stop(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "lxc-stop", "-n", sanctumID, "-t", "30")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: stop %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Freeze suspends an LXC container via cgroup v2 freezer.
func (r *LXCRuntime) Freeze(ctx context.Context, sanctumID string) error {
// Try cgroup v2 first (modern approach).
if cgPath := r.CgroupPath(sanctumID); cgPath != "" {
freezeFile := filepath.Join(cgPath, "cgroup.freeze")
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("1"), 0644); err == nil {
return nil
}
}
}
// Fall back to lxc-freeze.
cmd := exec.CommandContext(ctx, "lxc-freeze", "-n", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: freeze %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Thaw resumes a frozen LXC container.
func (r *LXCRuntime) Thaw(ctx context.Context, sanctumID string) error {
// Try cgroup v2 first.
if cgPath := r.CgroupPath(sanctumID); cgPath != "" {
freezeFile := filepath.Join(cgPath, "cgroup.freeze")
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("0"), 0644); err == nil {
return nil
}
}
}
// Fall back to lxc-unfreeze.
cmd := exec.CommandContext(ctx, "lxc-unfreeze", "-n", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: thaw %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Destroy removes an LXC container.
func (r *LXCRuntime) Destroy(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "lxc-destroy", "-n", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("lxc: destroy %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Exec runs a command inside an LXC container.
func (r *LXCRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
args := append([]string{"-n", sanctumID, "--"}, command...)
cmd := exec.CommandContext(ctx, "lxc-execute", args...)
if len(stdin) > 0 {
cmd.Stdin = strings.NewReader(string(stdin))
}
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return &ExecResult{
ExitCode: exitCode(err),
Stdout: []byte(stdout.String()),
Stderr: []byte(stderr.String()),
}, err
}
// Status returns the current state of an LXC container.
func (r *LXCRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
// Use lxc-info to get container state.
cmd := exec.CommandContext(ctx, "lxc-info", "-n", sanctumID, "-j")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("lxc: status %s: %w", sanctumID, err)
}
info := &SanctumInfo{
ID: sanctumID,
Name: sanctumID,
Runtime: RuntimeLXC,
}
// Parse lxc-info JSON output.
var lxcInfo struct {
State string `json:"state"`
PID int `json:"pid"`
IPs []struct {
Interface string `json:"interface"`
Address string `json:"address"`
Family string `json:"family"`
} `json:"ips"`
}
if err := json.Unmarshal(out, &lxcInfo); err == nil {
info.Status = parseLXCState(lxcInfo.State)
info.PID = uint32(lxcInfo.PID)
if len(lxcInfo.IPs) > 0 {
info.IP = lxcInfo.IPs[0].Address
}
}
info.Cgroup = r.CgroupPath(sanctumID)
info.RootFS = filepath.Join(r.configDir, sanctumID, "rootfs")
info.Created = time.Time{} // LXC doesn't expose creation time easily
return info, nil
}
// List returns all LXC containers.
func (r *LXCRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
cmd := exec.CommandContext(ctx, "lxc-ls", "-f", "-F", "name,state,pid,ipv4")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("lxc: list: %w", err)
}
var infos []*SanctumInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 1 || fields[0] == "" {
continue
}
info := &SanctumInfo{
ID: fields[0],
Name: fields[0],
Runtime: RuntimeLXC,
Status: StatusUnknown,
}
if len(fields) > 1 {
info.Status = parseLXCState(fields[1])
}
if len(fields) > 2 {
pid, _ := strconv.ParseUint(fields[2], 10, 32)
info.PID = uint32(pid)
}
if len(fields) > 3 && fields[3] != "-" {
info.IP = fields[3]
}
info.Cgroup = r.CgroupPath(fields[0])
info.RootFS = filepath.Join(r.configDir, fields[0], "rootfs")
infos = append(infos, info)
}
return infos, nil
}
// CgroupPath returns the cgroup v2 path for an LXC container.
// Checks common cgroup hierarchy locations.
func (r *LXCRuntime) CgroupPath(sanctumID string) string {
candidates := []string{
filepath.Join("/sys/fs/cgroup/lxc", sanctumID),
filepath.Join("/sys/fs/cgroup/lxc.payload", sanctumID),
filepath.Join("/sys/fs/cgroup/system.slice", "lxc-"+sanctumID+".service"),
filepath.Join("/sys/fs/cgroup/machine.slice", "lxc-"+sanctumID+".service"),
}
for _, p := range candidates {
if _, err := os.Stat(filepath.Join(p, "cgroup.freeze")); err == nil {
return p
}
}
// Try to find it via the container's init PID.
if pidPath := cgroupV2PathByPID(sanctumID); pidPath != "" {
return pidPath
}
return ""
}
// --- helpers ---
func (r *LXCRuntime) appendConfig(configPath, content string) error {
f, err := os.OpenFile(configPath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("lxc: open config %s: %w", configPath, err)
}
defer f.Close()
if _, err := f.WriteString("\n# --- Sorcery-Go auto-generated ---\n"); err != nil {
return fmt.Errorf("lxc: write config header %s: %w", configPath, err)
}
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("lxc: write config %s: %w", configPath, err)
}
return nil
}
func buildLXCNetworkConfig(nc *NetworkConfig) string {
var b strings.Builder
switch nc.Type {
case "none":
b.WriteString("lxc.net.0.type = empty\n")
case "host":
b.WriteString("lxc.net.0.type = none\n")
default: // "bridge"
b.WriteString("lxc.net.0.type = veth\n")
b.WriteString("lxc.net.0.flags = up\n")
if nc.Bridge != "" {
b.WriteString(fmt.Sprintf("lxc.net.0.link = %s\n", nc.Bridge))
}
if nc.MACAddress != "" {
b.WriteString(fmt.Sprintf("lxc.net.0.hwaddr = %s\n", nc.MACAddress))
}
if nc.IP != "" {
b.WriteString(fmt.Sprintf("lxc.net.0.ipv4.address = %s\n", nc.IP))
}
}
return b.String()
}
// lxcStates maps uppercase LXC state strings to SanctumStatus.
var lxcStates = map[string]SanctumStatus{
"RUNNING": StatusRunning,
"STOPPED": StatusStopped,
"FROZEN": StatusFrozen,
}
func parseLXCState(s string) SanctumStatus {
if st, ok := lxcStates[strings.ToUpper(s)]; ok {
return st
}
return StatusUnknown
}
func exitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return 1
}

370
pkg/runtime/podman.go Executable file
View File

@ -0,0 +1,370 @@
// Podman runtime adapter for Sorcery-Go Sanctums.
//
// Uses the Podman CLI (podman create, podman start, podman stop, etc.)
// to manage OCI containers. Podman provides rootless containers,
// OCI-compliant images, and is compatible with Docker workflows
// while being daemonless and more secure by default.
//
// Podman containers use Linux namespaces + cgroups for isolation,
// same as LXC, but are managed through the OCI runtime (crun/runc).
// The eBPF enforcer attaches at the cgroup level, so it works
// identically for both LXC and Podman.
package runtime
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// PodmanRuntime manages Podman OCI containers.
type PodmanRuntime struct {
binPath string
rootless bool
}
// NewPodmanRuntime creates a Podman runtime adapter.
func NewPodmanRuntime() *PodmanRuntime {
return &PodmanRuntime{
binPath: "podman",
}
}
func (r *PodmanRuntime) Type() Type { return RuntimePodman }
func (r *PodmanRuntime) Name() string { return "Podman OCI containers" }
// Probe checks that podman is available.
func (r *PodmanRuntime) Probe() error {
cmd := exec.Command("podman", "--version")
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: not found in PATH: %w\n%s", err, string(out))
}
// Check if running rootless (version string contains "rootless").
version := strings.TrimSpace(string(out))
r.rootless = strings.Contains(version, "rootless")
return nil
}
// Create provisions a new Podman container.
func (r *PodmanRuntime) Create(ctx context.Context, opts CreateOpts) (string, error) {
args := []string{"create", "--name", opts.Name}
if opts.Image != "" {
args = append(args, opts.Image)
}
// Network configuration.
if opts.NetworkConfig != nil {
switch opts.NetworkConfig.Type {
case "none":
args = append(args, "--network", "none")
case "host":
args = append(args, "--network", "host")
case "bridge":
if opts.NetworkConfig.Bridge != "" {
args = append(args, "--network", opts.NetworkConfig.Bridge)
}
if opts.NetworkConfig.IP != "" {
args = append(args, "--ip", opts.NetworkConfig.IP)
}
if opts.NetworkConfig.MACAddress != "" {
args = append(args, "--mac-address", opts.NetworkConfig.MACAddress)
}
}
}
// Bind mounts.
for _, m := range opts.BindMounts {
args = append(args, "--mount", "type=bind,source="+m.HostPath+",destination="+m.ContainerPath+",readonly="+strconv.FormatBool(m.ReadOnly))
}
// Environment variables.
for k, v := range opts.EnvVars {
args = append(args, "-e", k+"="+v)
}
// Resource limits.
if opts.MemoryMB > 0 {
args = append(args, "--memory", fmt.Sprintf("%dm", opts.MemoryMB))
}
// Capabilities: drop all, then add back if specified.
args = append(args, "--cap-drop", "ALL")
for _, cap := range opts.Caps {
args = append(args, "--cap-add", cap)
}
// SECURITY: Disable default AppArmor and seccomp confinement.
// This is intentional — the sorcery-go eBPF Tomb Guard LSM handles
// mandatory access control (MAC) enforcement, making the container-level
// profiles redundant. Re-enabling them would conflict with the eBPF
// enforcer's cgroup-level hook attachments.
args = append(args, "--security-opt", "apparmor=unconfined")
args = append(args, "--security-opt", "seccomp=unconfined")
// Architecture.
if opts.Arch != "" {
args = append(args, "--arch", opts.Arch)
}
// Extra podman-specific options.
for k, v := range opts.ExtraConfig {
args = append(args, k, v)
}
// If no image was specified and RootFS is provided, use a scratch image.
if opts.Image == "" && opts.RootFS != "" {
args = append(args, "scratch")
}
cmd := exec.CommandContext(ctx, "podman", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("podman: create %s: %w\n%s", opts.Name, err, string(out))
}
// Parse container ID from output (podman create prints the ID).
containerID := strings.TrimSpace(string(out))
if containerID == "" {
containerID = opts.Name
}
return containerID, nil
}
// Start starts a Podman container.
func (r *PodmanRuntime) Start(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "podman", "start", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: start %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Stop stops a Podman container.
func (r *PodmanRuntime) Stop(ctx context.Context, sanctumID string) error {
cmd := exec.CommandContext(ctx, "podman", "stop", "-t", "30", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: stop %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Freeze suspends a Podman container via cgroup v2 freezer.
func (r *PodmanRuntime) Freeze(ctx context.Context, sanctumID string) error {
// Podman containers can be frozen via cgroups or podman pause.
cgPath := r.CgroupPath(sanctumID)
if cgPath != "" {
freezeFile := cgPath + "/cgroup.freeze"
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("1"), 0644); err == nil {
return nil
}
}
}
// Fallback: podman pause.
cmd := exec.CommandContext(ctx, "podman", "pause", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: freeze %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Thaw resumes a paused Podman container.
func (r *PodmanRuntime) Thaw(ctx context.Context, sanctumID string) error {
cgPath := r.CgroupPath(sanctumID)
if cgPath != "" {
freezeFile := cgPath + "/cgroup.freeze"
if _, err := os.Stat(freezeFile); err == nil {
if err := os.WriteFile(freezeFile, []byte("0"), 0644); err == nil {
return nil
}
}
}
// Fallback: podman unpause.
cmd := exec.CommandContext(ctx, "podman", "unpause", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: thaw %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Destroy removes a Podman container.
func (r *PodmanRuntime) Destroy(ctx context.Context, sanctumID string) error {
// Force remove (handles running containers too).
cmd := exec.CommandContext(ctx, "podman", "rm", "-f", sanctumID)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("podman: destroy %s: %w\n%s", sanctumID, err, string(out))
}
return nil
}
// Exec runs a command inside a Podman container.
func (r *PodmanRuntime) Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error) {
args := append([]string{"exec", sanctumID}, command...)
cmd := exec.CommandContext(ctx, "podman", args...)
if len(stdin) > 0 {
cmd.Stdin = strings.NewReader(string(stdin))
}
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return &ExecResult{
ExitCode: exitCode(err),
Stdout: []byte(stdout.String()),
Stderr: []byte(stderr.String()),
}, err
}
// Status returns the current state of a Podman container.
func (r *PodmanRuntime) Status(ctx context.Context, sanctumID string) (*SanctumInfo, error) {
cmd := exec.CommandContext(ctx, "podman", "inspect", sanctumID, "--format", "json")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("podman: inspect %s: %w", sanctumID, err)
}
var containers []struct {
ID string `json:"Id"`
Name string `json:"Name"`
State string `json:"State"`
Status string `json:"Status"`
PID int `json:"Pid"`
IPAddress string `json:"IPAddress"`
Created string `json:"Created"`
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"Config"`
}
if err := json.Unmarshal(out, &containers); err != nil || len(containers) == 0 {
return nil, fmt.Errorf("podman: failed to parse inspect output for %s", sanctumID)
}
c := containers[0]
created, _ := time.Parse(time.RFC3339Nano, c.Created)
return &SanctumInfo{
ID: c.ID[:12],
Name: c.Name,
Runtime: RuntimePodman,
Status: parsePodmanState(c.State),
Arch: "",
IP: c.IPAddress,
PID: uint32(c.PID),
Cgroup: r.CgroupPath(sanctumID),
Created: created,
Metadata: c.Config.Labels,
}, nil
}
// List returns all Podman containers.
func (r *PodmanRuntime) List(ctx context.Context) ([]*SanctumInfo, error) {
cmd := exec.CommandContext(ctx, "podman", "ps", "-a", "--format", "json")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("podman: list: %w", err)
}
var containers []struct {
ID string `json:"Id"`
Name string `json:"Names"`
State string `json:"State"`
Image string `json:"Image"`
PID int `json:"Pid"`
IPAddress string `json:"IPAddress"`
Created int64 `json:"CreatedAt"`
}
if err := json.Unmarshal(out, &containers); err != nil {
return nil, fmt.Errorf("podman: failed to parse ps output: %w", err)
}
var infos []*SanctumInfo
for _, c := range containers {
names := strings.Split(c.Name, ",")
name := names[0]
infos = append(infos, &SanctumInfo{
ID: c.ID[:12],
Name: name,
Runtime: RuntimePodman,
Status: parsePodmanState(c.State),
IP: c.IPAddress,
PID: uint32(c.PID),
Cgroup: r.CgroupPath(c.ID[:12]),
Created: time.Unix(c.Created, 0),
})
}
return infos, nil
}
// CgroupPath returns the cgroup v2 path for a Podman container.
// Podman places containers under the systemd scope hierarchy.
func (r *PodmanRuntime) CgroupPath(sanctumID string) string {
// Podman uses libpod-cni or libpod-podman scopes.
// Try common cgroup v2 paths for podman containers.
candidates := []string{
// Rootless podman
"/sys/fs/cgroup/user.slice/user-" + strconv.Itoa(os.Getuid()) + ".slice/user@" + strconv.Itoa(os.Getuid()) + ".service/" + "libpod-" + sanctumID + ".scope",
// Rootful podman
"/sys/fs/cgroup/machine.slice/libpod-" + sanctumID + ".scope",
"/sys/fs/cgroup/system.slice/libpod-" + sanctumID + ".scope",
// Podman with cgroupns=host
"/sys/fs/cgroup/libpod_parent/libpod-" + sanctumID + ".scope",
}
for _, p := range candidates {
if _, err := statPath(p + "/cgroup.freeze"); err == nil {
return p
}
}
// Try to find by container name (podman uses container name in some paths).
nameCandidates := []string{
"/sys/fs/cgroup/machine.slice/podman-" + sanctumID + ".scope",
"/sys/fs/cgroup/machine.slice/libpod_parent-" + sanctumID + ".scope",
}
for _, p := range nameCandidates {
if _, err := statPath(p + "/cgroup.freeze"); err == nil {
return p
}
}
// Try PID-based lookup.
return cgroupV2PathByPID(sanctumID)
}
// podmanStates maps lowercase Podman state strings to SanctumStatus.
// Falls through to StatusUnknown for unrecognized states.
var podmanStates = map[string]SanctumStatus{
"running": StatusRunning,
"stopped": StatusStopped,
"exited": StatusStopped,
"dead": StatusStopped,
"paused": StatusFrozen,
"created": StatusCreating,
}
func parsePodmanState(s string) SanctumStatus {
if st, ok := podmanStates[strings.ToLower(s)]; ok {
return st
}
return StatusUnknown
}
// statPath wraps os.Stat for use in cgroup path detection.
func statPath(p string) (os.FileInfo, error) {
return os.Stat(p)
}

233
pkg/runtime/runtime.go Executable file
View File

@ -0,0 +1,233 @@
// Package runtime provides a unified abstraction over container runtimes.
//
// Sorcery-Go can deploy Essences into different execution environments
// (called "Sanctums"). This package defines the Runtime interface that
// decouples the Warding, Coven, and Reanimation logic from any specific
// container technology.
//
// Supported runtimes:
// - LXC: Traditional system containers (lxc-tools CLI)
// - Podman: OCI containers (podman CLI / REST API)
// - Firecracker: Lightweight microVMs (Firecracker VMM API via Unix socket)
// - BareMetal: Direct filesystem deployment (no container)
//
// The active runtime is selected at startup via SORCERY_GO_RUNTIME env var
// or the Runtime field in the Config struct. Each runtime adapter implements
// the Runtime interface, providing a consistent API for container lifecycle
// management, process execution, freeze/thaw, and status queries.
package runtime
import (
"context"
"fmt"
"time"
)
// Type identifies the container runtime backend.
type Type string
const (
// RuntimeLXC uses the LXC system container tools (lxc-create, lxc-start, etc.)
RuntimeLXC Type = "lxc"
// RuntimePodman uses the Podman OCI container engine.
RuntimePodman Type = "podman"
// RuntimeFirecracker uses Firecracker microVMs.
RuntimeFirecracker Type = "firecracker"
// RuntimeBareMetal deploys directly to the host filesystem (no container).
RuntimeBareMetal Type = "baremetal"
)
// SanctumStatus describes the current state of a container/sanctum.
type SanctumStatus string
const (
StatusRunning SanctumStatus = "running"
StatusStopped SanctumStatus = "stopped"
StatusFrozen SanctumStatus = "frozen"
StatusCreating SanctumStatus = "creating"
StatusDestroyed SanctumStatus = "destroyed"
StatusUnknown SanctumStatus = "unknown"
)
// SanctumInfo provides runtime-specific information about a sanctum.
type SanctumInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Runtime Type `json:"runtime"`
Status SanctumStatus `json:"status"`
Arch string `json:"arch"`
IP string `json:"ip,omitempty"`
PID uint32 `json:"pid,omitempty"`
Cgroup string `json:"cgroup,omitempty"`
Created time.Time `json:"created"`
RootFS string `json:"rootfs,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// CreateOpts holds the parameters for creating a new sanctum.
type CreateOpts struct {
// Name is the human-readable container name.
Name string
// Image is the base image or template (LXC template, OCI image, rootfs path).
Image string
// Arch is the target architecture (x86_64, aarch64).
Arch string
// RootFS is the path to the root filesystem (for baremetal or custom rootfs).
RootFS string
// NetworkConfig specifies the network attachment.
NetworkConfig *NetworkConfig
// BindMounts are host paths to bind-mount into the container.
BindMounts []BindMount
// EnvVars are environment variables to set inside the container.
EnvVars map[string]string
// Caps is the set of Linux capabilities to retain (empty = drop all).
Caps []string
// MemoryMB is the memory limit in megabytes (0 = unlimited).
MemoryMB uint64
// VCPUs is the number of virtual CPUs (for Firecracker).
VCPUs uint32
// KernelPath is the host kernel image path (for Firecracker).
KernelPath string
// RootDrivePath is the root block device path (for Firecracker).
RootDrivePath string
// ExtraConfig passes runtime-specific configuration as key-value pairs.
ExtraConfig map[string]string
}
// NetworkConfig specifies network attachment for a sanctum.
type NetworkConfig struct {
// Type is the network mode (bridge, none, host).
Type string // "bridge", "none", "host"
// Bridge is the host bridge interface name (e.g., "br0").
Bridge string
// MACAddress is the desired MAC address (empty = auto-generate).
MACAddress string
// IP is the desired IP address (empty = DHCP/auto).
IP string
}
// BindMount represents a host-to-container bind mount.
type BindMount struct {
// HostPath is the path on the host.
HostPath string
// ContainerPath is the path inside the container.
ContainerPath string
// ReadOnly makes the mount read-only.
ReadOnly bool
}
// ExecResult captures the output of a command executed inside a sanctum.
type ExecResult struct {
ExitCode int
Stdout []byte
Stderr []byte
}
// Runtime is the interface that all container runtime backends must implement.
//
// It provides a unified API for the full lifecycle of a Sanctum:
// create, start, stop, freeze/thaw, exec, and destroy. The Warding uses
// this interface to manage containers without knowing the underlying runtime.
type Runtime interface {
// Type returns the runtime type identifier (lxc, podman, firecracker, baremetal).
Type() Type
// Name returns a human-readable name for this runtime instance.
Name() string
// Probe checks whether this runtime is available on the host.
// Returns nil if the runtime tools/API are accessible.
Probe() error
// Create provisions a new sanctum with the given options.
// Returns the sanctum ID on success.
Create(ctx context.Context, opts CreateOpts) (string, error)
// Start boots or starts a stopped sanctum.
Start(ctx context.Context, sanctumID string) error
// Stop gracefully stops a running sanctum.
Stop(ctx context.Context, sanctumID string) error
// Freeze suspends all processes in a running sanctum (cgroup freezer).
Freeze(ctx context.Context, sanctumID string) error
// Thaw resumes a frozen sanctum.
Thaw(ctx context.Context, sanctumID string) error
// Destroy removes a sanctum and all its resources.
Destroy(ctx context.Context, sanctumID string) error
// Exec runs a command inside a running sanctum and returns the output.
Exec(ctx context.Context, sanctumID string, command []string, stdin []byte) (*ExecResult, error)
// Status returns the current state and info about a sanctum.
Status(ctx context.Context, sanctumID string) (*SanctumInfo, error)
// List returns all sanctums managed by this runtime.
List(ctx context.Context) ([]*SanctumInfo, error)
// CgroupPath returns the cgroup v2 path for a sanctum.
// This is used by the eBPF enforcer to attach cgroup filters.
// Returns "" if the runtime does not use cgroups (e.g., Firecracker).
CgroupPath(sanctumID string) string
}
// Factory creates a Runtime instance for the given type.
// Returns an error if the runtime type is unknown or unavailable.
func Factory(rtType Type) (Runtime, error) {
switch rtType {
case RuntimeLXC:
r := NewLXCRuntime()
if err := r.Probe(); err != nil {
return nil, err
}
return r, nil
case RuntimePodman:
r := NewPodmanRuntime()
if err := r.Probe(); err != nil {
return nil, err
}
return r, nil
case RuntimeFirecracker:
r := NewFirecrackerRuntime()
if err := r.Probe(); err != nil {
return nil, err
}
return r, nil
case RuntimeBareMetal:
return NewBareMetalRuntime(), nil
default:
return nil, fmt.Errorf("runtime: unknown runtime type %q (supported: lxc, podman, firecracker, baremetal)", rtType)
}
}
// AutoDetect tries to find an available runtime on the host.
// It probes LXC first, then Podman, then Firecracker, and falls back
// to baremetal if none are found.
func AutoDetect() (Runtime, error) {
candidates := []Type{RuntimeLXC, RuntimePodman, RuntimeFirecracker, RuntimeBareMetal}
for _, t := range candidates {
r, err := Factory(t)
if err == nil {
return r, nil
}
}
return NewBareMetalRuntime(), nil
}

63
pkg/sandbox/context.go Executable file
View File

@ -0,0 +1,63 @@
package sandbox
import (
"fmt"
"runtime"
)
// ArchProfile describes a cross-compilation target. When the user passes
// `sorcery cast <spell> --target aarch64`, the matching ArchProfile is
// attached to the sandbox so the compiler can produce ARM64 binaries even
// when the host is x86_64.
type ArchProfile struct {
Name string // e.g., "aarch64", "x86_64"
Triple string // e.g., "aarch64-linux-gnu"
Compiler string // e.g., "/usr/bin/aarch64-linux-gnu-gcc"
Flags []string // -march, -mtune, etc.
}
// HostArch returns the profile for the current host.
func HostArch() ArchProfile {
switch runtime.GOARCH {
case "arm64":
return ArchProfile{Name: "aarch64", Triple: "aarch64-linux-gnu", Compiler: "gcc"}
default:
return ArchProfile{Name: "x86_64", Triple: "x86_64-linux-gnu", Compiler: "gcc"}
}
}
// SetTarget configures the sandbox for a specific target. If `arch` differs
// from the host arch, CROSS_COMPILE / CC are set so the spell's BUILD script
// transparently invokes the cross-toolchain.
func (b *Box) SetTarget(arch string) error {
if arch == "" {
return nil
}
if arch == HostArch().Name {
return nil
}
profile, ok := knownProfiles[arch]
if !ok {
return fmt.Errorf("sandbox: no toolchain profile for arch %q", arch)
}
b.Env["ARCH"] = arch
b.Env["CROSS_COMPILE"] = profile.Triple + "-"
b.Env["CC"] = profile.Compiler
b.Env["HOST"] = profile.Triple
return nil
}
var knownProfiles = map[string]ArchProfile{
"aarch64": {
Name: "aarch64",
Triple: "aarch64-linux-gnu",
Compiler: "aarch64-linux-gnu-gcc",
Flags: []string{"-march=armv8-a", "-O2"},
},
"x86_64": {
Name: "x86_64",
Triple: "x86_64-linux-gnu",
Compiler: "gcc",
Flags: []string{"-march=x86-64", "-O2"},
},
}

35
pkg/sandbox/provision.go Executable file
View File

@ -0,0 +1,35 @@
// Toolchain attach / provisioner logic.
//
// In the Sovereign Coven the user maintains their own GCC/LLVM toolchains.
// Instead of downloading pre-built binaries, the engine bind-mounts the
// toolchain directory into the sandbox at /usr/cross and prepends it to
// PATH so the spell's BUILD script invokes the right compiler.
package sandbox
import "fmt"
// Toolchain represents one user-maintained compiler set.
type Toolchain struct {
Arch string // x86_64, aarch64
Path string // /opt/sorcery-go/toolchains/aarch64-linux-musl
Sysroot string // target rootfs headers/libs
}
// AttachToolchain bind-mounts the toolchain into the sandbox read-only and
// updates the environment so the compiler is picked up transparently.
func (b *Box) AttachToolchain(tc Toolchain) error {
if tc.Path == "" {
return fmt.Errorf("sandbox: toolchain path empty")
}
b.Binds = append(b.Binds, tc.Path+":/usr/cross:ro")
if old, ok := b.Env["PATH"]; ok {
b.Env["PATH"] = "/usr/cross/bin:" + old
} else {
b.Env["PATH"] = "/usr/cross/bin"
}
b.Env["CROSS_COMPILE"] = tc.Arch + "-linux-musl-"
if tc.Sysroot != "" {
b.Env["SYSROOT"] = tc.Sysroot
}
return nil
}

296
pkg/sandbox/sandbox.go Executable file
View File

@ -0,0 +1,296 @@
// Package sandbox isolates the build process using Linux kernel primitives.
//
// A Box is the "Hermetic Forge" — a private view of the operating system
// where the compiler believes it is writing to the live root, while every
// file it creates is silently captured in an OverlayFS "upper" directory.
//
// When the build finishes, Box.CollectManifest() walks the upper directory
// to produce the file list that becomes the Essence. No LD_PRELOAD, no
// eBPF — just an OverlayFS walk.
//
// Namespaces (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWPID) ensure that even
// a `rm -rf /` inside the spell script can only damage the sandbox, never
// the host. CLONE_NEWPID is optional because some BUILD scripts fork daemons
// that need to be killable en masse when the build finishes.
//
// If OverlayFS is unavailable (older kernel, unprivileged user), the Box
// falls back to a plain directory under BuildRoot and emits a warning on
// the EventBus. The build still works but the manifest is collected by
// diffing the directory before/after the build instead of via overlay
// upper — slightly slower but correct.
package sandbox
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"dcos.net/sorcery-go/pkg/eventbus"
)
// Box is one isolated build environment.
type Box struct {
SpellName string
RootPath string // The "Lower" (host root /)
WorkDir string // The "Upper" (where new files go)
MountDir string // The "Merged" view the compiler chroots into
Binds []string // Extra bind mounts (toolchains, sysroots)
Env map[string]string
NoOverlay bool // true if overlay mount failed — fall back to plain dir
Followers []io.Writer // extra stdout/stderr sinks (LogBus, log file, ...)
}
// New creates the directory structure for a new isolated build under
// <buildRoot>/<spell>. The caller controls the buildRoot so multiple
// concurrent casts don't collide.
func New(buildRoot, name string) *Box {
base := filepath.Join(buildRoot, name)
return &Box{
SpellName: name,
RootPath: "/",
WorkDir: filepath.Join(base, "upper"),
MountDir: filepath.Join(base, "merged"),
Env: map[string]string{
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM": "xterm-256color",
"HOME": "/tmp",
"BUILD_DIR": base,
"INSTALL_ROOT": filepath.Join(base, "install"),
"SOURCE_DIRECTORY": filepath.Join(base, "src"),
},
}
}
// Mount creates the upper/merged/work directories and mounts overlay.
// Returns an error if the kernel refuses the mount (e.g., unprivileged
// user, missing CONFIG_OVERLAY_FS). The caller may then opt into the
// plain-dir fallback via SetFallback.
func (b *Box) Mount() error {
for _, d := range []string{
b.WorkDir,
b.MountDir,
b.WorkDir + "_worker",
filepath.Join(b.WorkDir, "..", "install"),
} {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("sandbox: mkdir %s: %w", d, err)
}
}
opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s_worker",
b.RootPath, b.WorkDir, b.WorkDir)
if err := syscall.Mount("overlay", b.MountDir, "overlay", 0, opts); err != nil {
return fmt.Errorf("sandbox: overlay mount failed (root? CONFIG_OVERLAY_FS?): %w", err)
}
return nil
}
// SetFallback enables plain-dir mode for hosts without OverlayFS.
// The build runs in MountDir directly; CollectManifest uses a before/after
// snapshot of the directory to compute the file list.
func (b *Box) SetFallback() {
b.NoOverlay = true
_ = os.MkdirAll(b.MountDir, 0755)
}
// Unmount detaches the overlay. Safe to call multiple times.
func (b *Box) Unmount() {
if b.NoOverlay {
return
}
_ = syscall.Unmount(b.MountDir, 0)
}
// Run executes a script inside the sandbox. CLONE_NEWNS gives us an
// isolated mount table so our overlay mount doesn't leak; CLONE_NEWUTS
// isolates the hostname so a misbehaving spell can't rebrand the host.
//
// All stdout/stderr is tee'd to the Box.Followers (typically the LogBus
// and a per-spell log file) so the WebUI can stream it in real time.
func (b *Box) Run(ctx context.Context, scriptPath string, bus *eventbus.Bus, taskID string) error {
if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("sandbox: script not found: %s: %w", scriptPath, err)
}
cmd := exec.CommandContext(ctx, "/bin/bash", "-e", scriptPath)
cmd.Dir = b.MountDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS,
}
env := make([]string, 0, len(b.Env))
for k, v := range b.Env {
env = append(env, k+"="+v)
}
cmd.Env = env
// Tee stdout + stderr through every follower (LogBus, log file, ...).
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
// Apply bind mounts BEFORE starting the child (so they appear in the
// child's mount namespace when CLONE_NEWNS fires).
for _, bind := range b.Binds {
if err := b.bindMount(bind); err != nil {
return fmt.Errorf("sandbox: bind mount %s failed: %w", bind, err)
}
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("sandbox: start %s: %w", scriptPath, err)
}
// Stream stdout + stderr line-by-line to every follower.
// A WaitGroup ensures all goroutines complete before Run() returns,
// preventing goroutine leaks if the caller checks the error and
// proceeds without waiting for pipe drains.
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
streamLines(stdout, append(b.Followers, lineWriter(func(line string) {
if bus != nil {
bus.Log(taskID, line)
}
})))
}()
go func() {
defer wg.Done()
streamLines(stderr, append(b.Followers, lineWriter(func(line string) {
if bus != nil {
bus.Log(taskID, "[stderr] "+line)
}
})))
}()
err = cmd.Wait()
wg.Wait()
return err
}
// RunSimple is a non-streaming convenience wrapper for short helper scripts
// (e.g., the DETAILS bridge in pkg/grimoire). Output is returned as a
// single byte slice.
func (b *Box) RunSimple(ctx context.Context, scriptPath string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "/bin/bash", "-e", scriptPath)
cmd.Dir = b.MountDir
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS,
}
env := make([]string, 0, len(b.Env))
for k, v := range b.Env {
env = append(env, k+"="+v)
}
cmd.Env = env
return cmd.Output()
}
// CollectManifest walks the upper directory and returns every regular file
// the spell created. This is the "automatic manifest" — no installwatch,
// no LD_PRELOAD, just a filesystem walk of the captured layer.
//
// In fallback mode (no overlay) it does a recursive walk of MountDir and
// skips anything that was present before the build (snapshot taken at
// SetFallback time — see fallbackSnapshot).
func (b *Box) CollectManifest() ([]string, error) {
var files []string
root := b.WorkDir
if b.NoOverlay {
root = b.MountDir
}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
// Skip OverlayFS whiteout files.
base := filepath.Base(path)
if strings.HasPrefix(base, ".wh.") {
return nil
}
files = append(files, path)
return nil
})
return files, err
}
// Cleanup unmounts and removes the build directory. Safe to call after a
// failed Mount() or Run().
func (b *Box) Cleanup() {
b.Unmount()
_ = os.RemoveAll(filepath.Dir(b.WorkDir))
}
// --- helpers ---
// bindMount parses a "src:dst[:ro]" spec and applies MS_BIND | MS_REC.
func (b *Box) bindMount(spec string) error {
parts := strings.SplitN(spec, ":", 3)
if len(parts) < 2 {
return fmt.Errorf("bind: bad spec %q", spec)
}
flags := uintptr(syscall.MS_BIND | syscall.MS_REC)
if len(parts) == 3 && parts[2] == "ro" {
flags |= syscall.MS_RDONLY
}
// Ensure the destination exists inside the sandbox.
dst := filepath.Join(b.MountDir, strings.TrimPrefix(parts[1], "/"))
if err := os.MkdirAll(dst, 0755); err != nil {
// May be a file, not a dir — try touching the parent only.
_ = os.MkdirAll(filepath.Dir(dst), 0755)
}
return syscall.Mount(parts[0], dst, "", flags, "")
}
// lineWriter adapts a func(string) into an io.Writer that buffers until newline.
type lineWriter func(string)
func (f lineWriter) Write(p []byte) (int, error) {
// Best-effort — the streaming goroutine handles line splitting.
f(string(p))
return len(p), nil
}
func streamLines(r io.Reader, sinks []io.Writer) {
buf := make([]byte, 4096)
var carry []byte
for {
n, err := r.Read(buf)
if n > 0 {
data := append(carry, buf[:n]...)
lines := strings.Split(string(data), "\n")
// Last element is the partial line — carry it.
carry = []byte(lines[len(lines)-1])
for _, line := range lines[:len(lines)-1] {
for _, s := range sinks {
if s != nil {
_, _ = s.Write([]byte(line + "\n"))
}
}
}
}
if err != nil {
if len(carry) > 0 {
for _, s := range sinks {
if s != nil {
_, _ = s.Write(carry)
}
}
}
return
}
}
}

158
pkg/state/committer.go Executable file
View File

@ -0,0 +1,158 @@
// Atomic manifest committer.
//
// Commit() walks the sandbox "upper" directory and migrates every produced
// file into the live root using the "safe swap" pattern:
//
// 1. Copy (or reflink) the new file to <target>.sorcery_tmp
// 2. os.Rename() — atomic at the kernel level — replaces the old file
//
// On success, the file list is written to the Manifests bucket in the same
// bbolt transaction so the disk and the database always agree. If the
// commit fails part-way, the journal entry is marked StateFailed and the
// .sorcery_tmp leftovers are cleaned up on the next engine start.
package state
import (
"bytes"
"fmt"
"log"
bolt "go.etcd.io/bbolt"
)
// Commit migrates files from the sandbox upper directory into the live
// root atomically. upperDir is the Box.WorkDir; relPath is computed
// against "/" so a sandbox-produced /usr/bin/wget becomes /usr/bin/wget
// on the host.
//
// If dryRun is true, no files are written — only the manifest is recorded.
// Used by `sorcery cast --dry-run`.
func (m *Manager) Commit(spellName, variant, upperDir string, dryRun bool) ([]string, error) {
var installed []string
err := filepath.Walk(upperDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, e := filepath.Rel(upperDir, path)
if e != nil {
return e
}
target := filepath.Join("/", relPath)
if !dryRun {
if err := copyFileAtomic(path, target, info.Mode()); err != nil {
return fmt.Errorf("commit: %s: %w", target, err)
}
}
installed = append(installed, target)
return nil
})
if err != nil {
return nil, err
}
// Persist the manifest + reverse index in a single transaction.
if err := m.SaveManifest(spellName, variant, installed); err != nil {
return nil, err
}
return installed, nil
}
// copyFileAtomic uses reflink (btrfs/xfs FICLONE) when available, falling
// back to a plain io.Copy + atomic rename. The intermediate .sorcery_tmp
// file is cleaned up on error.
func copyFileAtomic(src, dst string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
tmp := dst + ".sorcery_tmp"
// Try reflink first (instant on btrfs/xfs, zero extra disk).
if err := reflinkOrCopy(src, tmp, mode); err != nil {
return err
}
// Atomic rename — replaces dst atomically even if it already exists.
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename %s -> %s: %w", tmp, dst, err)
}
return nil
}
// reflinkOrCopy tries the Linux FICLONE ioctl first; on any error it
// falls back to a buffered io.Copy.
func reflinkOrCopy(src, dst string, mode os.FileMode) error {
if err := reflink(src, dst); err == nil {
// Reflink preserves mode/owner but we set them explicitly to be safe.
_ = os.Chmod(dst, mode)
return nil
}
// Fallback: plain copy.
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// Dispel removes every file recorded in the spell's manifest and deletes
// the manifest + reverse-index entries. Used to "banish" a spell from the
// live system.
func (m *Manager) Dispel(spellName, variant string) error {
files, err := m.GetManifest(spellName, variant)
if err != nil {
return err
}
// Remove files. Empty parent dirs are pruned best-effort.
for _, f := range files {
if err := os.Remove(f); err != nil {
log.Printf("dispel: warning: remove %s: %v", f, err)
}
// Walk up pruning empty dirs (stop at /).
dir := filepath.Dir(f)
for dir != "/" && dir != "." {
if err := os.Remove(dir); err != nil {
break // not empty — stop
}
dir = filepath.Dir(dir)
}
}
// Drop manifest + reverse-index entries.
// Collect index keys first to avoid modifying the bucket while iterating.
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Manifests"))
if err := b.Delete([]byte(spellName + ":" + variant)); err != nil {
return err
}
idx := tx.Bucket([]byte("Index"))
c := idx.Cursor()
owner := []byte(spellName + ":" + variant)
var toDelete [][]byte
for k, v := c.First(); k != nil; k, v = c.Next() {
if bytes.Equal(v, owner) {
// Copy the key since bbolt reuses the slice on Next().
keyCopy := make([]byte, len(k))
copy(keyCopy, k)
toDelete = append(toDelete, keyCopy)
}
}
for _, k := range toDelete {
if err := idx.Delete(k); err != nil {
log.Printf("dispel: warning: index delete %s: %v", string(k), err)
}
}
return nil
})
}

68
pkg/state/reflink_linux.go Executable file
View File

@ -0,0 +1,68 @@
package state
import (
"fmt"
"os"
"syscall"
)
// reflink tries the Linux FICLONE ioctl to share data extents between
// src and dst (instant on btrfs/xfs, zero extra disk). Returns an error
// on unsupported filesystems; the caller falls back to io.Copy.
//
// We deliberately don't try FICLONE_RANGE — whole-file clone is all we
// need and it keeps the syscall arg structure trivial.
func reflink(src, dst string) error {
srcF, err := os.Open(src)
if err != nil {
return err
}
defer srcF.Close()
dstF, err := os.Create(dst)
if err != nil {
return err
}
defer dstF.Close()
// FICLONE = _IOW(0x94, 9, int) on Linux. The ioctl takes the source
// file's fd as its third argument.
const FICLONE = 0x40049409
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
dstF.Fd(),
uintptr(FICLONE),
srcF.Fd())
if errno != 0 {
return fmt.Errorf("reflink: ioctl FICLONE failed (fs=%s): %w",
detectFSType(dst), errno)
}
return nil
}
// detectFSType returns the filesystem magic name for path, best-effort.
// Used only for nicer error messages.
func detectFSType(path string) string {
var statfs syscall.Statfs_t
if err := syscall.Statfs(path, &statfs); err != nil {
return "unknown"
}
switch statfs.Type {
case fsMagicBTRFS:
return "btrfs"
case fsMagicXFS:
return "xfs"
case fsMagicFUSE:
return "fuse"
case fsMagicEXT4:
return "ext4"
}
return fmt.Sprintf("magic=0x%x", statfs.Type)
}
// Filesystem magic numbers from linux/magic.h.
const (
fsMagicBTRFS = 0x9123683E
fsMagicXFS = 0x58465342
fsMagicFUSE = 0x65735546
fsMagicEXT4 = 0xEF53
)

10
pkg/state/reflink_other.go Executable file
View File

@ -0,0 +1,10 @@
//go:build !linux
package state
import "fmt"
// reflink is a no-op on non-Linux platforms — callers fall back to io.Copy.
func reflink(src, dst string) error {
return fmt.Errorf("reflink: only supported on Linux")
}

291
pkg/state/state.go Executable file
View File

@ -0,0 +1,291 @@
// Package state provides the ACID-compliant persistence layer for Sorcery-Go.
//
// The state DB (a single bbolt file at /var/lib/sorcery-go/state/state.db)
// holds five logical buckets:
//
// Journal — every cast's intent, status, and checkpoint
// Manifests — file list per spell+variant (post-commit)
// Tablet — interactive y/n answers per spell+option
// Configs — variant hashes per spell+flags
// Index — file-path -> spell+variant (the Gaze reverse index)
//
// If the power cuts out mid-cast, Manager.Recover() walks the Journal and
// resumes the interrupted task at the last checkpoint, instead of starting
// over. This is the "Single Source of Truth" pillar of the migration.
package state
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
bolt "go.etcd.io/bbolt"
)
// SpellState is the lifecycle status of a spell inside the Journal.
type SpellState string
const (
StatePlanned SpellState = "planned"
StateSummoning SpellState = "summoning"
StateUnpacking SpellState = "unpacking"
StateCasting SpellState = "casting"
StateCommitting SpellState = "committing"
StateInstalled SpellState = "installed"
StateFailed SpellState = "failed"
)
// JournalEntry is one record in the Journal bucket.
type JournalEntry struct {
SpellName string `json:"name"`
Variant string `json:"variant"`
Status SpellState `json:"status"`
Checkpoint string `json:"checkpoint"`
TaskID string `json:"task_id"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ErrNotFound is returned by GetManifest and WhoOwns when no entry exists.
var ErrNotFound = fmt.Errorf("state: not found")
// Manager wraps the bbolt DB. All methods are safe for concurrent use
// because bbolt transactions are serialised internally.
type Manager struct {
db *bolt.DB
}
// Open opens (or creates) the state DB. The parent directory is created
// with mode 0700 so the file is never world-readable.
func Open(path string) (*Manager, error) {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("state: mkdir: %w", err)
}
db, err := bolt.Open(path, 0600, nil)
if err != nil {
return nil, fmt.Errorf("state: open %s: %w", path, err)
}
err = db.Update(func(tx *bolt.Tx) error {
for _, b := range []string{"Journal", "Manifests", "Tablet", "Configs", "Index"} {
if _, e := tx.CreateBucketIfNotExists([]byte(b)); e != nil {
return e
}
}
return nil
})
if err != nil {
return nil, err
}
return &Manager{db: db}, nil
}
// Close releases the file lock.
func (m *Manager) Close() error {
if m.db == nil {
return nil
}
return m.db.Close()
}
// RecordJournal writes (or updates) a JournalEntry atomically.
func (m *Manager) RecordJournal(entry JournalEntry) error {
if entry.UpdatedAt.IsZero() {
entry.UpdatedAt = time.Now()
}
if entry.StartedAt.IsZero() {
entry.StartedAt = entry.UpdatedAt
}
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
data, err := json.Marshal(entry)
if err != nil {
return err
}
key := journalKey(entry.SpellName, entry.Variant)
return b.Put([]byte(key), data)
})
}
// GetJournal fetches a JournalEntry for a spell+variant. Returns nil, nil
// when no entry exists.
func (m *Manager) GetJournal(spell, variant string) (*JournalEntry, error) {
var out *JournalEntry
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
v := b.Get([]byte(journalKey(spell, variant)))
if v == nil {
return nil
}
var e JournalEntry
if err := json.Unmarshal(v, &e); err != nil {
return err
}
out = &e
return nil
})
return out, err
}
// Recover scans the Journal for any entry that is not StateInstalled and
// not StateFailed. Returns the list so the caller can resume them.
// Called once at engine startup.
func (m *Manager) Recover() ([]JournalEntry, error) {
var out []JournalEntry
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
return b.ForEach(func(k, v []byte) error {
var e JournalEntry
if err := json.Unmarshal(v, &e); err != nil {
log.Printf("state: recover: warning: corrupt journal entry %s: %v", string(k), err)
return nil
}
if e.Status != StateInstalled && e.Status != StateFailed {
out = append(out, e)
}
return nil
})
})
return out, err
}
// SaveTablet persists a y/n answer for one spell+option.
func (m *Manager) SaveTablet(spell, option string, value bool) error {
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Tablet"))
key := fmt.Sprintf("%s:%s", spell, option)
return b.Put([]byte(key), []byte(boolStr(value)))
})
}
// GetTablet reads a previously stored y/n answer. Returns (false, false)
// when no answer exists yet.
func (m *Manager) GetTablet(spell, option string) (bool, bool) {
var found, value bool
_ = m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Tablet"))
v := b.Get([]byte(fmt.Sprintf("%s:%s", spell, option)))
if v != nil {
found = true
value = string(v) == "true"
}
return nil
})
return value, found
}
// ListTablet returns every recorded y/n answer for a spell. Used by the
// `gaze tablet <spell>` command.
func (m *Manager) ListTablet(spell string) map[string]bool {
out := make(map[string]bool)
prefix := []byte(spell + ":")
_ = m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Tablet"))
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
opt := string(bytes.TrimPrefix(k, prefix))
out[opt] = string(v) == "true"
}
return nil
})
return out
}
// SaveManifest stores the list of files belonging to a spell+variant.
// It also updates the reverse Index so `gaze whereis /usr/bin/wget` is O(1).
func (m *Manager) SaveManifest(spell, variant string, files []string) error {
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Manifests"))
data, err := json.Marshal(files)
if err != nil {
return err
}
if err := b.Put([]byte(spell+":"+variant), data); err != nil {
return err
}
// Update reverse index.
idx := tx.Bucket([]byte("Index"))
owner := []byte(spell + ":" + variant)
for _, f := range files {
if err := idx.Put([]byte(f), owner); err != nil {
return err
}
}
return nil
})
}
// GetManifest returns the stored file list for a spell+variant.
// Returns state.ErrNotFound if the manifest does not exist.
func (m *Manager) GetManifest(spell, variant string) ([]string, error) {
var files []string
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Manifests"))
v := b.Get([]byte(spell + ":" + variant))
if v == nil {
return ErrNotFound
}
return json.Unmarshal(v, &files)
})
return files, err
}
// WhoOwns is the reverse lookup used by `gaze whereis /usr/bin/wget`.
// Returns ("spell", "variant", nil) when found.
// Returns ("", "", state.ErrNotFound) when no entry exists.
func (m *Manager) WhoOwns(path string) (string, string, error) {
var spell, variant string
var resultErr error
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Index"))
v := b.Get([]byte(path))
if v == nil {
resultErr = ErrNotFound
return nil
}
parts := bytes.SplitN(v, ':', 2)
if len(parts) == 2 {
spell = string(parts[0])
variant = string(parts[1])
}
return nil
})
if err != nil {
return "", "", fmt.Errorf("state: whoowns %s: %w", path, err)
}
return spell, variant, resultErr
}
// ListInstalled returns every spell+variant that is currently StateInstalled.
// Used by `sorcery gaze` and the WebUI's Grimoire tab.
func (m *Manager) ListInstalled() ([]JournalEntry, error) {
var out []JournalEntry
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
return b.ForEach(func(k, v []byte) error {
var e JournalEntry
if err := json.Unmarshal(v, &e); err != nil {
return nil
}
if e.Status == StateInstalled {
out = append(out, e)
}
return nil
})
})
return out, err
}
func journalKey(spell, variant string) string {
return spell + ":" + variant
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}

87
pkg/state/state_test.go Executable file
View File

@ -0,0 +1,87 @@
package state
import (
"path/filepath"
"testing"
)
func TestOpenAndJournal(t *testing.T) {
mgr, err := Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
if err := mgr.RecordJournal(JournalEntry{
SpellName: "wget", Variant: "v1",
Status: StateCasting,
}); err != nil {
t.Fatal(err)
}
got, err := mgr.GetJournal("wget", "v1")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Status != StateCasting {
t.Fatalf("unexpected journal entry: %+v", got)
}
}
func TestTablet(t *testing.T) {
mgr, _ := Open(filepath.Join(t.TempDir(), "state.db"))
defer mgr.Close()
if _, ok := mgr.GetTablet("wget", "ssl"); ok {
t.Fatal("tablet should be empty initially")
}
if err := mgr.SaveTablet("wget", "ssl", true); err != nil {
t.Fatal(err)
}
val, ok := mgr.GetTablet("wget", "ssl")
if !ok || !val {
t.Fatalf("expected ssl=true, got %v (%v)", val, ok)
}
answers := mgr.ListTablet("wget")
if len(answers) != 1 || !answers["ssl"] {
t.Fatalf("ListTablet: %+v", answers)
}
}
func TestManifestAndWhoOwns(t *testing.T) {
mgr, _ := Open(filepath.Join(t.TempDir(), "state.db"))
defer mgr.Close()
files := []string{"/usr/bin/wget", "/usr/share/man/wget.1"}
if err := mgr.SaveManifest("wget", "v1", files); err != nil {
t.Fatal(err)
}
got, err := mgr.GetManifest("wget", "v1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("expected 2 files, got %d", len(got))
}
spell, variant, err := mgr.WhoOwns("/usr/bin/wget")
if err != nil || spell != "wget" || variant != "v1" {
t.Fatalf("WhoOwns: %s %s %v", spell, variant, err)
}
}
func TestRecover(t *testing.T) {
mgr, _ := Open(filepath.Join(t.TempDir(), "state.db"))
defer mgr.Close()
_ = mgr.RecordJournal(JournalEntry{SpellName: "a", Variant: "v", Status: StateCasting})
_ = mgr.RecordJournal(JournalEntry{SpellName: "b", Variant: "v", Status: StateInstalled})
_ = mgr.RecordJournal(JournalEntry{SpellName: "c", Variant: "v", Status: StateFailed})
pending, err := mgr.Recover()
if err != nil {
t.Fatal(err)
}
// Only "a" should be pending (Casting). "b" is Installed, "c" is Failed.
if len(pending) != 1 || pending[0].SpellName != "a" {
t.Fatalf("Recover: %+v", pending)
}
}

6
pkg/templates/build.tmpl Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
# Standard BUILD script for {{.Name}} — edit as needed.
cd "$SOURCE_DIRECTORY" &&
./configure --prefix=/usr "$@" &&
make &&
make install

8
pkg/templates/configure.tmpl Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
# CONFIGURE script for {{.Name}}.
# Interactive Configuration Engine (ICE) queries live here.
# The Go engine intercepts `config_query` calls and answers them from
# the Tablet, prompting the user only for new questions.
#
# config_query {{.Name}}_FEATURE_X "Enable feature X?" y
# config_query {{.Name}}_FEATURE_Y "Enable feature Y?" n

2
pkg/templates/depends.tmpl Executable file
View File

@ -0,0 +1,2 @@
{{range .Dependencies}}depends {{.}} ""
{{end}}

13
pkg/templates/details.tmpl Executable file
View File

@ -0,0 +1,13 @@
SPELL={{.Name}}
VERSION={{.Version}}
SOURCE=${SPELL}-${VERSION}.tar.gz
SOURCE_URL[0]={{.SourceURL}}
SOURCE_HASH=sha512:{{.Hash}}
SOURCE_DIRECTORY="${BUILD_DIRECTORY}/${SPELL}-${VERSION}"
WEB_SITE={{.Website}}
ENTERED={{.Date}}
LICENSE[0]={{.License}}
SHORT="{{.Description}}"
cat << EOF
{{.LongDesc}}
EOF

22
pkg/templates/templates.go Executable file
View File

@ -0,0 +1,22 @@
// Package templates embeds the spell-template files (details.tmpl, build.tmpl,
// depends.tmpl) into the binary via //go:embed so the Sorcery-Go binary stays
// a single portable file even when generating new spells via the WebUI.
package templates
import _ "embed"
// DetailsTemplate is the Go text/template for a spell's DETAILS file.
//go:embed details.tmpl
var DetailsTemplate string
// BuildTemplate is the default BUILD script.
//go:embed build.tmpl
var BuildTemplate string
// DependsTemplate is the DEPENDS file generator.
//go:embed depends.tmpl
var DependsTemplate string
// ConfigureTemplate is the CONFIGURE script that drives ICE queries.
//go:embed configure.tmpl
var ConfigureTemplate string

710
pkg/toolchain/btc.go Executable file
View File

@ -0,0 +1,710 @@
// Package toolchain provides BTC.sh (Build Tool Chain) integration for
// sorcery-go. BTC.sh forges a sovereign, forensically-stamped GCC toolchain
// whose provenance can be verified through ELF notes and extended filesystem
// attributes.
//
// BTC 0.4.0+ supports multi-architecture cross-compilation:
//
// x86_64: haswell, haswell-ep, skylake, skylake-x, skylake-server,
// znver1, znver2, znver3, znver4,
// apu-zn1, apu-zn2, apu-zn3, apu-zn4,
// atom-silvermont, atom-goldmont, atom-tremont, atom-sierraforest
// mipsel: mipselr2 (MIPS32R2 LE, o32 ABI, musl)
// arm: armv7 (Cortex-A NEON hard-float, musl)
// tilegx: tilegx (Tilera TILE-Gx72, musl)
//
// This file implements probing, environment setup, stamp verification, and
// binary stamping that mirrors BTC.sh's own f_stamp_binary and verification
// routines.
package toolchain
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"dcos.net/sorcery-go/pkg/config"
)
// BTCStamp holds the forensic identification data extracted from a BTC-built
// binary. BTC.sh stamps every compiled artifact with two mechanisms:
//
// 1. An ELF NOTE section named ".note.BTC" containing human-readable fields.
// 2. Extended attributes (xattr): user.btc.identity and user.btc.hash.
type BTCStamp struct {
Org string // Origin organization (e.g., "dcos.net")
Kernel string // Kernel version at build time
Arch string // Target architecture / target ID
Label string // SYS_LABEL — unique forge identifier
Forge string // Build step name (e.g., "stage2-gcc")
Identity string // xattr user.btc.identity value
Hash string // xattr user.btc.hash (SHA-256 of the binary)
HasNote bool // .note.BTC section present
HasXAttr bool // xattr identity present
Valid bool // true if Hash matches the binary's actual SHA-256
}
// BTCForge represents a probed BTC.sh installation with its golden image.
// BTC 0.4.0+ populates CrossMode, TargetID, TargetTriple, and CLib from
// the manifest JSON sidecar written by BTC.sh.
type BTCForge struct {
Config *config.Config
SYSLabel string // auto-detected or from config
GoldenImage string // path to the {SYS_LABEL}-toolchain-golden.tar.xz
Available bool // true if BTC.sh and golden image are usable
TargetArch string // base architecture (x86_64, arm, mipsel, tilegx)
ExtractedDir string // path where the golden image is extracted (if any)
// BTC 0.4.0 multi-arch fields (populated from manifest JSON)
CrossMode bool // true if this is a cross-compiled toolchain
TargetID string // BTC target identifier (haswell, znver3, armv7, etc.)
TargetTriple string // GCC target triple (e.g., arm-dcosnet-linux-musleabihf)
TargetMarch string // -march value (e.g., armv7-a, mips32r2, tilegx)
CLib string // C library: "glibc" or "musl"
Family string // architecture family (intel, amd, mips, arm, tile)
Manifest *BTCManifest
}
// BTCManifest is the JSON structure of the {SYS_LABEL}-manifest.json
// sidecar written by BTC.sh 0.4.0+. It contains structured metadata
// that is more reliable than parsing filenames.
type BTCManifest struct {
BTCVersion string `json:"btc_version"`
Mode string `json:"mode"`
CrossMode bool `json:"cross_mode"`
SysLabel string `json:"sys_label"`
TargetID string `json:"target_id"`
TargetArch string `json:"target_arch"`
TargetCPU string `json:"target_cpu"`
TargetMarch string `json:"target_march"`
TargetTriple string `json:"target_triple"`
HostArch string `json:"host_arch"`
ISATag string `json:"isa_tag"`
OptTag string `json:"opt_tag"`
ABI string `json:"abi"`
CLib string `json:"clib"`
Endian string `json:"endian"`
Family string `json:"family"`
Description string `json:"description"`
KernelMin string `json:"kernel_min"`
Kernel string `json:"kernel"`
Binutils string `json:"binutils"`
GCC string `json:"gcc"`
GLibc string `json:"glibc"`
Musl string `json:"musl"`
Libxcrypt string `json:"libxcrypt"`
GoldenImage string `json:"golden_image"`
CFlags string `json:"cflags"`
Ldflags string `json:"ldflags"`
}
// ISA flag table — maps ISA tag (uppercase) to compiler flags.
// Table-driven lookup replaces if/else chains (SEI CERT CTR50-JP).
var isaFlags = map[string]string{
"AVX2": " -mavx2",
"AVX512": " -mavx512f -mavx512dq -mavx512vl -mavx512bw",
"SSE4_2": " -msse4.2",
"NEON": " -mfpu=neon -mfloat-abi=hard",
"MIPS32": "",
"TILE": "",
}
// Probe checks whether BTC.sh and its golden image exist on disk and returns
// a BTCForge struct describing what was found. If the config supplies a
// BTCSYSLabel it is used directly; otherwise the label is parsed from the
// golden image filename.
//
// For BTC 0.4.0+, the manifest JSON sidecar is loaded to populate
// CrossMode, TargetID, TargetTriple, CLib, and other structured fields.
func Probe(cfg *config.Config) *BTCForge {
forge := &BTCForge{
Config: cfg,
}
// Check BTC.sh exists and is executable.
info, err := os.Stat(cfg.BTCPath)
if err != nil || info.IsDir() {
return forge
}
if info.Mode()&0111 == 0 {
return forge
}
// Check BTCRoot for a golden image matching *-toolchain-golden.tar.xz.
pattern := filepath.Join(cfg.BTCRoot, "*-toolchain-golden.tar.xz")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return forge
}
// Use the first match. If multiple golden images exist the caller can
// disambiguate via BTCSYSLabel.
goldenPath := matches[0]
base := filepath.Base(goldenPath)
sysLabel := parseGoldenLabel(base)
// If the config provides a label, verify it matches or use the config
// value and search for the corresponding image.
if cfg.BTCSYSLabel != "" {
candidate := filepath.Join(cfg.BTCRoot, cfg.BTCSYSLabel+"-toolchain-golden.tar.xz")
if _, err := os.Stat(candidate); err == nil {
sysLabel = cfg.BTCSYSLabel
goldenPath = candidate
} else {
// Config label does not match any file; use the discovered one.
sysLabel = cfg.BTCSYSLabel
}
}
forge.SYSLabel = sysLabel
forge.GoldenImage = goldenPath
forge.Available = true
forge.TargetArch = cfg.HostArch
if forge.TargetArch == "" {
forge.TargetArch = hostArch()
}
// Try to load the manifest for richer metadata (BTC 0.4.0+).
if manifest := loadManifest(cfg.BTCRoot, sysLabel); manifest != nil {
forge.Manifest = manifest
forge.CrossMode = manifest.CrossMode
forge.TargetID = manifest.TargetID
forge.TargetTriple = manifest.TargetTriple
forge.TargetMarch = manifest.TargetMarch
forge.CLib = manifest.CLib
forge.Family = manifest.Family
forge.TargetArch = manifest.TargetArch
}
return forge
}
// loadManifest reads a BTC manifest JSON sidecar if it exists.
func loadManifest(btcRoot, sysLabel string) *BTCManifest {
manifestPath := filepath.Join(btcRoot, sysLabel+"-manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil
}
var m BTCManifest
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("btc: warning: failed to parse manifest %s: %v", manifestPath, err)
return nil
}
return &m
}
// ListTargets scans the BTC root for all available golden images and
// returns a slice of BTCForge structs, one per found toolchain. This
// is useful for the WebUI "Toolchain Lab" view to show operators which
// cross-compilers are available.
func ListTargets(btcRoot string) []*BTCForge {
pattern := filepath.Join(btcRoot, "*-toolchain-golden.tar.xz")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil
}
sort.Strings(matches)
forges := make([]*BTCForge, 0, len(matches))
for _, goldenPath := range matches {
base := filepath.Base(goldenPath)
sysLabel := parseGoldenLabel(base)
if sysLabel == "" {
continue
}
forge := &BTCForge{
SYSLabel: sysLabel,
GoldenImage: goldenPath,
Available: true,
}
// Load manifest for structured metadata.
if manifest := loadManifest(btcRoot, sysLabel); manifest != nil {
forge.Manifest = manifest
forge.CrossMode = manifest.CrossMode
forge.TargetID = manifest.TargetID
forge.TargetTriple = manifest.TargetTriple
forge.TargetMarch = manifest.TargetMarch
forge.CLib = manifest.CLib
forge.Family = manifest.Family
forge.TargetArch = manifest.TargetArch
} else {
// Legacy 0.3.x: parse from SYS_LABEL.
forge.TargetArch = hostArch()
forge.CLib = "glibc"
}
forges = append(forges, forge)
}
return forges
}
// parseGoldenLabel extracts the SYS_LABEL from a golden image filename of
// the form "{SYS_LABEL}-toolchain-golden.tar.xz".
func parseGoldenLabel(filename string) string {
base := strings.TrimSuffix(filename, "-toolchain-golden.tar.xz")
if base == filename {
return ""
}
return base
}
// SetExtractedDir records where the golden image has been extracted so that
// BuildEnv can point PATH at the correct bin/ subdirectories.
func (f *BTCForge) SetExtractedDir(dir string) {
f.ExtractedDir = dir
}
// BuildEnv returns the environment variables needed for a Cast pipeline to
// compile with the BTC sovereign toolchain. The returned map is intended to
// be merged into the sandbox environment.
//
// For BTC 0.4.0+, if a manifest is loaded, the CFLAGS and triple come from
// the manifest's structured data rather than being guessed from the label.
// Cross-compiled toolchains use {triple}-gcc/{triple}-g++ naming.
//
// If the golden image has been extracted (ExtractedDir is set), PATH is
// pointed at the extracted tree. Otherwise, BTC's GLOBAL_CFLAGS are
// assembled with --sysroot pointing at BTCRoot.
func (f *BTCForge) BuildEnv() map[string]string {
env := make(map[string]string)
if !f.Available {
return env
}
// Determine the sysroot and bin directory.
newroot := f.BTCRoot
binDir := ""
if f.ExtractedDir != "" {
newroot = f.ExtractedDir
binDir = filepath.Join(f.ExtractedDir, "bin")
if _, err := os.Stat(binDir); err != nil {
binDir = filepath.Join(f.ExtractedDir, "usr", "bin")
}
}
// Determine the target triple and march.
// Prefer manifest data (BTC 0.4.0+), fall back to derivation.
triple := f.TargetTriple
march := f.TargetMarch
if triple == "" {
// Legacy 0.3.x derivation
arch := f.TargetArch
if arch == "" {
arch = hostArch()
}
triple = arch + "-dcosnet-linux-gnu"
}
if march == "" {
march = f.TargetID
if march == "" {
march = f.TargetArch
}
}
// PATH: prepend the BTC bin directories.
if binDir != "" {
env["PATH"] = binDir + ":" + os.Getenv("PATH")
}
// Compiler executables.
// Cross-toolchains (0.4.0+) use {triple}-gcc naming.
// Native toolchains may have plain gcc in the sysroot.
if f.CrossMode && triple != "" {
env["CC"] = triple + "-gcc"
env["CXX"] = triple + "-g++"
} else {
env["CC"] = triple + "-gcc"
env["CXX"] = triple + "-g++"
}
// Build CFLAGS.
// For BTC 0.4.0+ with a manifest, use the manifest's cflags.
// Otherwise, assemble from march + ISA + sysroot.
var cflags string
if f.Manifest != nil && f.Manifest.CFlags != "" {
// Manifest cflags may contain --sysroot pointing at the build-time
// cleanroom. Rewrite to use the actual sysroot/newroot.
cflags = rewriteSysroot(f.Manifest.CFlags, newroot)
} else {
// Legacy assembly.
cflags = fmt.Sprintf("-O3 -march=%s -flto -ffat-lto-objects --sysroot=%s -pipe",
march, newroot)
}
env["CFLAGS"] = cflags
env["CXXFLAGS"] = cflags
// BTC GLOBAL_LDFLAGS.
var ldflags string
if f.Manifest != nil && f.Manifest.Ldflags != "" {
ldflags = rewriteSysroot(f.Manifest.Ldflags, newroot)
} else {
ldflags = fmt.Sprintf("-Wl,-O1 -Wl,--as-needed -flto --sysroot=%s", newroot)
}
env["LDFLAGS"] = ldflags
// Forensic tracking.
if f.SYSLabel != "" {
env["BTC_SYS_LABEL"] = f.SYSLabel
}
env["BTC_MODE"] = "1"
if f.TargetID != "" {
env["BTC_TARGET_ID"] = f.TargetID
}
if f.CrossMode {
env["BTC_CROSS"] = "1"
}
if f.CLib != "" {
env["BTC_CLIB"] = f.CLib
}
if f.TargetTriple != "" {
env["BTC_TARGET_TRIPLE"] = f.TargetTriple
}
return env
}
// rewriteSysroot replaces --sysroot=<old> flags with --sysroot=<new>.
// This is needed when the manifest's cflags/ldflags contain the build-time
// cleanroom path but the toolchain has been extracted to a different location.
func rewriteSysroot(flags, newroot string) string {
// Match --sysroot=<anything> (greedy to end of flag value).
re := regexp.MustCompile(`--sysroot=\S+`)
return re.ReplaceAllString(flags, "--sysroot="+newroot)
}
// VerifyStamp reads and verifies BTC forensic stamps from a binary. It uses
// readelf to extract the .note.BTC ELF note section and getfattr to read the
// extended filesystem attributes.
func (f *BTCForge) VerifyStamp(binPath string) (*BTCStamp, error) {
stamp := &BTCStamp{}
// Verify the file exists.
if _, err := os.Stat(binPath); err != nil {
return nil, fmt.Errorf("btc: verify stamp: %w", err)
}
// Extract .note.BTC via readelf.
noteOut, err := exec.Command("readelf", "-n", binPath).CombinedOutput()
if err == nil {
parsed := parseNoteBTC(string(noteOut))
if parsed != nil {
stamp.Org = parsed.Org
stamp.Kernel = parsed.Kernel
stamp.Arch = parsed.Arch
stamp.Label = parsed.Label
stamp.Forge = parsed.Forge
stamp.HasNote = parsed.HasNote
}
}
// Extract xattrs via getfattr.
xattrOut, err := exec.Command("getfattr", "-d", "--name=user.btc.identity", "--name=user.btc.hash", binPath).CombinedOutput()
if err == nil {
identity, hash := parseXAttrs(string(xattrOut))
stamp.Identity = identity
stamp.Hash = hash
stamp.HasXAttr = identity != ""
}
// Validate the hash against the actual file content.
if stamp.Hash != "" {
actual, err := fileSHA256(binPath)
if err == nil {
stamp.Valid = (actual == strings.TrimPrefix(stamp.Hash, "sha256:"))
}
}
return stamp, nil
}
// StampBinary applies BTC forensic stamps to a compiled binary. It:
//
// 1. Creates a small assembly object containing the .note.BTC ELF note.
// 2. Injects the note via objcopy --add-section.
// 3. Sets extended attributes user.btc.identity and user.btc.hash.
// 4. Separates debug symbols (objcopy --only-keep-debug, strip, add-gnu-debuglink).
//
// If objcopy or the assembler is unavailable the function logs a warning
// and continues rather than failing the build.
func (f *BTCForge) StampBinary(binPath string, forgeStep string) error {
// Determine stamp fields.
org := "dcos.net"
kernel := detectKernel()
arch := f.TargetID
if arch == "" {
arch = f.TargetArch
}
if arch == "" {
arch = hostArch()
}
label := f.SYSLabel
if label == "" {
label = "unknown"
}
// Step 1: Create the .note.BTC assembly source in a temp directory.
noteDir, err := os.MkdirTemp("", "btc-stamp-*")
if err != nil {
return fmt.Errorf("btc: stamp: create temp dir: %w", err)
}
defer os.RemoveAll(noteDir)
asmSrc := buildNoteAsm(org, kernel, arch, label, forgeStep)
asmPath := filepath.Join(noteDir, "btc_note.S")
if err := os.WriteFile(asmPath, []byte(asmSrc), 0644); err != nil {
return fmt.Errorf("btc: stamp: write asm: %w", err)
}
// Step 2: Assemble the note object.
// For cross-compiled toolchains, use the cross-assembler when available.
asmCmd := "as"
if f.CrossMode && f.TargetTriple != "" {
crossAsm := f.TargetTriple + "-as"
if _, err := exec.LookPath(crossAsm); err == nil {
asmCmd = crossAsm
}
}
objPath := filepath.Join(noteDir, "btc_note.o")
if err := exec.Command(asmCmd, "-o", objPath, asmPath).Run(); err != nil {
log.Printf("btc: warning: assembler not available, skipping .note.BTC injection: %v", err)
applyXAttrStamps(binPath, label)
return nil
}
// Step 3: Copy the original binary with the note section injected.
stampedPath := binPath + ".btc-stamped"
if err := exec.Command("objcopy", "--add-section", ".note.BTC="+objPath, binPath, stampedPath).Run(); err != nil {
log.Printf("btc: warning: objcopy not available, skipping .note.BTC injection: %v", err)
applyXAttrStamps(binPath, label)
return nil
}
// Replace the original with the stamped version.
if err := os.Rename(stampedPath, binPath); err != nil {
// Clean up the stamped copy on failure.
os.Remove(stampedPath)
return fmt.Errorf("btc: stamp: replace binary: %w", err)
}
// Step 4: Set extended attributes and separate debug symbols.
applyXAttrStamps(binPath, label)
return nil
}
// applyXAttrStamps sets the BTC identity and hash extended attributes
// on a binary, then optionally separates debug symbols.
func applyXAttrStamps(binPath, label string) {
identity := label
fileHash, err := fileSHA256(binPath)
if err != nil {
fileHash = "sha256:error"
}
setXAttr(binPath, "user.btc.identity", identity)
setXAttr(binPath, "user.btc.hash", fileHash)
separateDebugSymbols(binPath)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// noteFieldRe matches the pipe-delimited fields inside a .note.BTC
// readelf output block. BTC.sh writes a single line like:
//
// "Org: dcos.net|K:7.1|Arch:haswell|Label:DCOSNET-HASWELL-AVX2-LTO|Forge:binutils-configure"
//
// We parse the full pipe-delimited string to extract each field.
var noteFieldRe = regexp.MustCompile(`Org:\s*([^|]+)\|K:([^|]+)\|Arch:([^|]+)\|Label:([^|]+)\|Forge:([^|]+)`)
// parseNoteBTC extracts stamp fields from readelf -n output and returns
// a BTCStamp with HasNote set to true. Returns nil if the .note.BTC
// section is not found or contains no recognizable fields.
func parseNoteBTC(readelfOutput string) *BTCStamp {
s := &BTCStamp{}
// Find the .note.BTC block.
idx := strings.Index(readelfOutput, "note.BTC")
if idx == -1 {
return nil
}
// Parse only within the note.BTC section (stop at the next section).
block := readelfOutput[idx:]
if nextSection := strings.Index(block, "\nDisplaying notes found in:"); nextSection > 0 {
block = block[:nextSection]
}
matches := noteFieldRe.FindStringSubmatch(block)
if len(matches) >= 6 {
s.Org = strings.TrimSpace(matches[1])
s.Kernel = strings.TrimSpace(matches[2])
s.Arch = strings.TrimSpace(matches[3])
s.Label = strings.TrimSpace(matches[4])
s.Forge = strings.TrimSpace(matches[5])
}
// Only return if we found at least one field.
if s.Org == "" && s.Kernel == "" && s.Arch == "" {
return nil
}
s.HasNote = true
return s
}
// parseXAttrs extracts user.btc.identity and user.btc.hash values from
// getfattr -d output. The format is:
//
// user.btc.identity="BTC-SYS_LABEL-5.15.0-sovereign"
// user.btc.hash="sha256:abcdef..."
var xattrIdentityRe = regexp.MustCompile(`user\.btc\.identity="([^"]*)"`)
var xattrHashRe = regexp.MustCompile(`user\.btc\.hash="([^"]*)"`)
func parseXAttrs(getfattrOutput string) (identity, hash string) {
if m := xattrIdentityRe.FindStringSubmatch(getfattrOutput); len(m) > 1 {
identity = m[1]
}
if m := xattrHashRe.FindStringSubmatch(getfattrOutput); len(m) > 1 {
hash = m[1]
}
return identity, hash
}
// buildNoteAsm generates an architecture-neutral ELF NOTE assembly source
// that defines a .note.BTC section with the given fields. The output
// uses raw .byte directives for portability across all ELF targets
// (x86_64, arm, mipsel, tilegx). This mirrors BTC.sh's f_stamp_binary
// routine which uses the same approach.
func buildNoteAsm(org, kernel, arch, label, forge string) string {
// Use the same pipe-delimited format as BTC.sh and Fester for
// cross-project compatibility. BTC.sh's f_stamp_binary writes:
// Org: dcos.net|K:7.1|Arch:haswell|Label:DCOSNET-HASWELL-AVX2-LTO|Forge:binutils-configure
desc := fmt.Sprintf("Org: %s|K:%s|Arch:%s|Label:%s|Forge:%s",
org, kernel, arch, label, forge)
// Build the ELF NOTE in raw form using .byte directives.
// ELF Note structure: namesz (4) + descsz (4) + type (4) + name + desc.
name := "BTC"
nameBytes := append([]byte(name), 0)
descBytes := []byte(desc)
noteType := uint32(1) // NT_VERSION — matches BTC.sh's .long 1
var b strings.Builder
b.WriteString(" .section .note.BTC, \"a\", @note\n")
b.WriteString(" .align 4\n")
b.WriteString(" .globl __btc_note_start\n")
b.WriteString("__btc_note_start:\n")
// namesz (little-endian).
b.WriteString(fmt.Sprintf(" .long %d\n", len(nameBytes)))
// descsz.
b.WriteString(fmt.Sprintf(" .long %d\n", len(descBytes)))
// type.
b.WriteString(fmt.Sprintf(" .long %d\n", noteType))
// name bytes.
for _, c := range nameBytes {
b.WriteString(fmt.Sprintf(" .byte %d\n", c))
}
// Pad name to 4-byte alignment.
if len(nameBytes)%4 != 0 {
pad := 4 - (len(nameBytes) % 4)
for i := 0; i < pad; i++ {
b.WriteString(" .byte 0\n")
}
}
// Description bytes.
for _, c := range descBytes {
b.WriteString(fmt.Sprintf(" .byte %d\n", c))
}
// Pad description to 4-byte alignment.
if len(descBytes)%4 != 0 {
pad := 4 - (len(descBytes) % 4)
for i := 0; i < pad; i++ {
b.WriteString(" .byte 0\n")
}
}
b.WriteString(" .align 4\n")
b.WriteString(" .globl __btc_note_end\n")
b.WriteString("__btc_note_end:\n")
b.WriteString(" .previous\n")
return b.String()
}
// detectKernel returns the running kernel version string (e.g., "5.15.0-generic").
func detectKernel() string {
var utsname runtime.Utsname
if err := runtime.Uname(&utsname); err != nil {
return "unknown"
}
return strings.TrimRight(string(utsname.Release[:]), "\x00")
}
// fileSHA256 returns the hex-encoded SHA-256 of a file (without the "sha256:" prefix).
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := h.ReadFrom(f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// setXAttr sets an extended filesystem attribute on a file. If setfattr is
// not available or the filesystem does not support xattrs, the function
// logs a warning and continues.
func setXAttr(path, attr, value string) {
if err := exec.Command("setfattr", "-n", attr, "-v", value, path).Run(); err != nil {
log.Printf("btc: warning: setfattr %s on %s failed: %v", attr, path, err)
}
}
// separateDebugSymbols extracts debug info from a binary into a separate
// .debug file, strips the binary, and links the debug file back. This
// mirrors BTC.sh's debug symbol separation steps:
//
// objcopy --only-keep-debug {bin} {bin}.debug
// strip --strip-unneeded {bin}
// objcopy --add-gnu-debuglink={bin}.debug {bin}
func separateDebugSymbols(binPath string) {
debugPath := binPath + ".debug"
// Extract debug symbols.
if err := exec.Command("objcopy", "--only-keep-debug", binPath, debugPath).Run(); err != nil {
log.Printf("btc: warning: objcopy --only-keep-debug failed for %s: %v", binPath, err)
return
}
// Strip the binary.
if err := exec.Command("strip", "--strip-unneeded", binPath).Run(); err != nil {
log.Printf("btc: warning: strip failed for %s: %v", binPath, err)
return
}
// Link the debug file back.
if err := exec.Command("objcopy", "--add-gnu-debuglink="+debugPath, binPath).Run(); err != nil {
log.Printf("btc: warning: objcopy --add-gnu-debuglink failed for %s: %v", binPath, err)
}
}

210
pkg/toolchain/validator.go Executable file
View File

@ -0,0 +1,210 @@
// Package toolchain validates user-maintained GCC/LLVM toolchains before
// they are allowed to forge production Essences.
//
// The Validator runs a small "smoke test" inside a temporary sandbox: it
// compiles a Hello-World with -fstack-protector-all -pie and inspects the
// resulting binary to confirm Stack Smashing Protection, PIE, and the
// expected target triple are present.
package toolchain
import (
"fmt"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
// Report is the result of one validation pass.
type Report struct {
Path string
Arch string
GlibcVer string
IsStatic bool
HasSSP bool
HasPIE bool
HasLTO bool
Passed bool
Notes []string
IsBTC bool // true when the compiler was forged by BTC.sh
BTCLabel string // SYS_LABEL from the BTC golden image
BTCStamp *BTCStamp // parsed forensic stamp (nil if not BTC)
}
// archPatterns maps version string substrings to (arch, isStatic) pairs.
// Ordered by specificity: more specific patterns must come first.
var archPatterns = []struct {
substr string
arch string
isStatic bool
}{
{"aarch64-linux-musl", "aarch64", true},
{"aarch64-linux-gnu", "aarch64", false},
{"x86_64-linux-musl", "x86_64", true},
{"x86_64-linux-gnu", "x86_64", false},
}
// Validate runs the smoke test on a compiler binary.
func Validate(path string) (*Report, error) {
r := &Report{Path: path}
out, _ := exec.Command(path, "-v").CombinedOutput()
s := string(out)
for _, p := range archPatterns {
if strings.Contains(s, p.substr) {
r.Arch = p.arch
r.IsStatic = p.isStatic
break
}
}
if strings.Contains(s, "--enable-default-pie") {
r.HasPIE = true
}
if strings.Contains(s, "LTO") {
r.HasLTO = true
}
if strings.Contains(s, "stack-protector") || strings.Contains(s, "ssp") {
r.HasSSP = true
}
r.Passed = r.Arch != "" && r.HasSSP
if runtime.GOARCH == "arm64" && r.Arch != "aarch64" {
r.Notes = append(r.Notes, "warning: toolchain arch != host arch (cross-compile mode)")
}
return r, nil
}
// HostArch returns the toolchain arch matching the running host.
func HostArch() string {
switch runtime.GOARCH {
case "arm64":
return "aarch64"
default:
return "x86_64"
}
}
// ValidateBTC validates a BTC golden image toolchain. It inspects the gcc
// binary inside the golden image tarball for .note.BTC presence, LTO, PIE,
// and SSP support, and returns a Report with BTC-specific fields populated.
//
// The btcRoot parameter should point to the BTC root directory (e.g.,
// /opt/BTC) where the {SYS_LABEL}-toolchain-golden.tar.xz file resides.
func ValidateBTC(btcRoot string) (*Report, error) {
r := &Report{
Path: btcRoot,
}
// Locate the golden image.
pattern := filepath.Join(btcRoot, "*-toolchain-golden.tar.xz")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("toolchain: validate btc: glob %s: %w", pattern, err)
}
if len(matches) == 0 {
return nil, fmt.Errorf("toolchain: validate btc: no golden image found in %s", btcRoot)
}
goldenPath := matches[0]
base := filepath.Base(goldenPath)
sysLabel := parseGoldenLabel(base)
r.IsBTC = true
r.BTCLabel = sysLabel
// Use tar to list files and find the gcc binary path inside the
// golden image. BTC toolchains store gcc under usr/bin/.
listOut, err := exec.Command("tar", "-tf", goldenPath).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("toolchain: validate btc: list tarball: %w", err)
}
// Find the gcc binary in the tarball.
var gccInTar string
for _, line := range strings.Split(string(listOut), "\n") {
line = strings.TrimSpace(line)
// Prefer the triple-prefixed gcc.
if strings.HasSuffix(line, "/bin/gcc") || strings.HasSuffix(line, "/bin/"+sysLabel+"-gcc") {
gccInTar = line
break
}
// Fallback: any file named *gcc in a bin/ directory.
if gccInTar == "" && strings.Contains(line, "/bin/") && filepath.Base(line) == "gcc" {
gccInTar = line
}
}
if gccInTar == "" {
r.Notes = append(r.Notes, "warning: no gcc binary found inside golden image")
r.Passed = false
return r, nil
}
// Extract just the gcc binary to a temp directory for inspection.
tmpOut, err := exec.Command("mktemp", "-d").Output()
if err != nil {
return nil, fmt.Errorf("toolchain: validate btc: mktemp: %w", err)
}
tmpDir := strings.TrimSpace(string(tmpOut))
defer exec.Command("rm", "-rf", tmpDir).Run()
extractCmd := exec.Command("tar", "-xf", goldenPath, "-C", tmpDir, gccInTar)
if err := extractCmd.Run(); err != nil {
return nil, fmt.Errorf("toolchain: validate btc: extract gcc: %w", err)
}
extractedGCC := filepath.Join(tmpDir, gccInTar)
// Check if the extracted gcc is executable.
if _, err := exec.Command("test", "-x", extractedGCC).CombinedOutput(); err != nil {
r.Notes = append(r.Notes, fmt.Sprintf("warning: extracted gcc is not executable: %s", extractedGCC))
r.Passed = false
return r, nil
}
// Run gcc -v to detect features.
out, _ := exec.Command(extractedGCC, "-v").CombinedOutput()
s := string(out)
// Detect architecture from the target triple.
switch {
case strings.Contains(s, "aarch64"):
r.Arch = "aarch64"
case strings.Contains(s, "x86_64"):
r.Arch = "x86_64"
}
if strings.Contains(s, "--enable-default-pie") {
r.HasPIE = true
}
if strings.Contains(s, "LTO") {
r.HasLTO = true
}
if strings.Contains(s, "stack-protector") || strings.Contains(s, "ssp") {
r.HasSSP = true
}
// Check for .note.BTC in the gcc binary.
readelfOut, err := exec.Command("readelf", "-n", extractedGCC).CombinedOutput()
if err == nil && strings.Contains(string(readelfOut), "note.BTC") {
r.BTCStamp = parseNoteBTC(string(readelfOut))
r.Notes = append(r.Notes, ".note.BTC section present in gcc binary")
} else {
r.Notes = append(r.Notes, "warning: .note.BTC section not found in gcc binary")
}
// Verdict: BTC toolchains must have LTO, PIE, and SSP to pass.
r.Passed = r.Arch != "" && r.HasLTO && r.HasPIE && r.HasSSP
if !r.Passed {
var missing []string
if !r.HasLTO {
missing = append(missing, "LTO")
}
if !r.HasPIE {
missing = append(missing, "PIE")
}
if !r.HasSSP {
missing = append(missing, "SSP")
}
r.Notes = append(r.Notes, fmt.Sprintf("BTC validation FAILED: missing %s", strings.Join(missing, ", ")))
}
return r, nil
}

109
pkg/ui/tui.go Executable file
View File

@ -0,0 +1,109 @@
// Package ui implements the bubbletea TUI — the "Focused Forge" interface
// used by admins who want deep y/n configuration without leaving the
// terminal.
//
// Layout (tmux-style):
//
// ┌─────────────────────────────────────────┐
// │ 🔮 SORCERY-Go · Casting: wget, openssl │ header
// ├─────────────────────────────────────────┤
// │ [scrollable build log] │ viewport
// │ ... │
// ├─────────────────────────────────────────┤
// │ [LOAD 2.41] [JOBS 3/12] ▓▓▓▓▓░░░ 42% │ status bar
// └─────────────────────────────────────────┘
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Model holds the entire TUI state.
type Model struct {
casting []string
completed int
total int
logs viewport.Model
progress progress.Model
width int
height int
logLines []string
}
var (
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#353535")).
Bold(true)
logStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(lipgloss.Color("#555555"))
)
// NewModel returns the initial TUI state.
func NewModel(total int) Model {
vp := viewport.New(80, 20)
p := progress.New(progress.WithDefaultGradient())
return Model{
total: total,
logs: vp,
progress: p,
casting: []string{},
}
}
// Init satisfies tea.Model.
func (m Model) Init() tea.Cmd { return nil }
// Update handles messages from the engine.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case LogMsg:
m.logLines = append(m.logLines, string(msg))
m.logs.SetContent(strings.Join(m.logLines, "\n"))
case CastCompleteMsg:
m.completed++
pct := float64(m.completed) / float64(m.total)
return m, m.progress.SetPercent(pct)
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.logs.Width = msg.Width
m.logs.Height = msg.Height - 4
}
var cmd tea.Cmd
m.logs, cmd = m.logs.Update(msg)
return m, cmd
}
// View renders the screen.
func (m Model) View() string {
header := fmt.Sprintf(" 🔮 SORCERY-Go · Casting: %s", strings.Join(m.casting, ", "))
statusText := fmt.Sprintf(" [JOBS: %d/%d] [ROOT: /]", m.completed, m.total)
spacer := strings.Repeat(" ", max(0, m.width-len(statusText)-20))
bar := lipgloss.JoinHorizontal(lipgloss.Top,
statusStyle.Render(statusText),
spacer,
m.progress.View(),
)
return lipgloss.JoinVertical(lipgloss.Left,
header,
logStyle.Width(m.width).Height(m.height-4).Render(m.logs.View()),
bar,
)
}
// LogMsg is one line of build output streamed into the viewport.
type LogMsg string
// CastCompleteMsg is fired when a worker finishes a spell.
type CastCompleteMsg struct{ Name string }

Some files were not shown because too many files have changed in this diff Show More