Rename budget to chultun

This commit is contained in:
ms2k 2026-10-18 20:03:53 +09:00
commit b529740ee3
27 changed files with 126 additions and 126 deletions

View file

@ -1,15 +1,15 @@
# budget # chultun
Resource limits for desktop apps and terminal workloads. Requires Linux, cgroup v2, a systemd user session, and Go 1.26+. Resource limits for desktop apps and terminal workloads. Requires Linux, cgroup v2, a systemd user session, and Go 1.26+.
Copy `profiles.example.toml` to `~/.config/budget/profiles.toml` and edit limits, app IDs, and terminal prefixes. Copy `profiles.example.toml` to `~/.config/chultun/profiles.toml` and edit limits, app IDs, and terminal prefixes.
| Action | Command | | Action | Command |
| ------------------------------ | -------------------------------- | | ------------------------------ | --------------------------------- |
| Install and apply | `./install.sh` | | Install and apply | `./install.sh` |
| Apply configuration edits | `budget apply` | | Apply configuration edits | `chultun apply` |
| Show configuration | `budget status` | | Show configuration | `chultun status` |
| Launch with a profile | `budget run compute -- COMMAND` | | Launch with a profile | `chultun run compute -- COMMAND` |
| Override a running app or unit | `budget set unrestricted TARGET` | | Override a running app or unit | `chultun set unrestricted TARGET` |
| Test | `go test -race ./...` | | Test | `go test -race ./...` |
| Lint | `golangci-lint run` | | Lint | `golangci-lint run` |

View file

@ -1,15 +0,0 @@
package main
import (
"fmt"
"os"
"budget/internal/budget"
)
func main() {
if err := budget.Run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "budget:", err)
os.Exit(1)
}
}

15
cmd/chultun/main.go Normal file
View file

@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"chultun/internal/chultun"
)
func main() {
if err := chultun.Run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "chultun:", err)
os.Exit(1)
}
}

2
go.mod
View file

@ -1,4 +1,4 @@
module budget module chultun
go 1.26.0 go 1.26.0

View file

@ -1,8 +1,8 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
cd -- "$(dirname -- "$0")" cd -- "$(dirname -- "$0")"
CGO_ENABLED=0 go build -trimpath -o bin/budget ./cmd/budget CGO_ENABLED=0 go build -trimpath -o bin/chultun ./cmd/chultun
install -Dm755 bin/budget "$HOME/.local/bin/.budget.new" install -Dm755 bin/chultun "$HOME/.local/bin/.chultun.new"
mv "$HOME/.local/bin/.budget.new" "$HOME/.local/bin/budget" mv "$HOME/.local/bin/.chultun.new" "$HOME/.local/bin/chultun"
test -f "$HOME/.config/budget/profiles.toml" || install -Dm644 profiles.example.toml "$HOME/.config/budget/profiles.toml" test -f "$HOME/.config/chultun/profiles.toml" || install -Dm644 profiles.example.toml "$HOME/.config/chultun/profiles.toml"
"$HOME/.local/bin/budget" apply "$HOME/.local/bin/chultun" apply

119
internal/chultun/app.go Normal file
View file

@ -0,0 +1,119 @@
package chultun
import (
"fmt"
"io"
"strings"
)
type application struct {
config config
paths paths
ctl unitCtl
run func(...string) error
out io.Writer
}
func (a application) apply() error {
files, err := a.config.render()
if err != nil {
return err
}
if err := writeFiles(a.paths, files); err != nil {
return err
}
if err := a.run("daemon-reload"); err != nil {
return err
}
units, err := a.ctl.Units()
if err != nil {
return err
}
for _, unit := range units {
name := a.config.activeProfile(unit)
if name == "" || unit.Active != "active" {
continue
}
values, err := a.config.properties(name)
if err != nil {
return err
}
if err := a.ctl.Set(unit.Name, values); err != nil {
return err
}
}
if err := a.run("enable", "chultun.service"); err != nil {
return err
}
if err := a.run("restart", "chultun.service"); err != nil {
return err
}
_, err = fmt.Fprintf(a.out, "Applied %d resource configuration files\n", len(files))
return err
}
func selectUnits(c config, units []unit, target string) []string {
selected := map[string]bool{}
if strings.HasSuffix(target, ".slice") || strings.HasSuffix(target, ".scope") || strings.HasSuffix(target, ".service") {
selected[target] = true
} else {
for _, unit := range units {
if unit.Active == "active" && appMatches(target, unit.Name) {
selected[unit.Name] = true
}
}
if _, shared := c.Shared[target]; shared {
selected[c.sharedSlice()] = true
}
}
return keys(selected)
}
func (a application) override(name, target string) error {
values, err := a.config.properties(name)
if err != nil {
return err
}
units, err := a.ctl.Units()
if err != nil {
return err
}
selected := selectUnits(a.config, units, target)
if len(selected) == 0 {
return fmt.Errorf("no running unit matches %s", target)
}
all := map[string]bool{}
for _, name := range selected {
all[name] = true
}
for _, unit := range selected {
if !strings.HasSuffix(unit, ".slice") {
continue
}
group, err := a.ctl.Group(unit)
if err != nil {
return err
}
for _, child := range units {
if child.Active != "active" || all[child.Name] || (!strings.HasSuffix(child.Name, ".scope") && !strings.HasSuffix(child.Name, ".service")) {
continue
}
childGroup, err := a.ctl.Group(child.Name)
if err != nil {
return err
}
if strings.HasPrefix(childGroup, group+"/") {
all[child.Name] = true
}
}
}
for _, unit := range keys(all) {
if err := a.ctl.Set(unit, values); err != nil {
return err
}
if _, err := fmt.Fprintf(a.out, "%s: %s\n", unit, name); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,214 @@
package chultun
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestApply(t *testing.T) {
f := newFake()
f.units = []unit{
{Name: "app-browser-123.scope", Active: "active"},
{Name: "app-org.example.Terminal.service", Active: "active"},
{Name: "containerd.service", Active: "active"},
{Name: "session-ui.service", Active: "active"},
{Name: "app-closed-123.scope", Active: "inactive"},
}
events := []string{}
f.events = &events
a := application{testConfig(), homePaths(t.TempDir()), f, func(args ...string) error {
events = append(events, strings.Join(args, " "))
return nil
}, io.Discard}
if err := a.apply(); err != nil {
t.Fatal(err)
}
want := []string{"daemon-reload", "set app-browser-123.scope", "set app-org.example.Terminal.service", "set containerd.service", "enable chultun.service", "restart chultun.service"}
if !reflect.DeepEqual(events, want) {
t.Fatalf("got %v, want %v", events, want)
}
if values(f.sets["app-browser-123.scope"])["MemoryMax"].Value() != uint64(8589934592) {
t.Fatal("default ceiling missing")
}
if len(f.sets) != 3 {
t.Fatal("session infrastructure or inactive apps were changed")
}
f.setErr = errors.New("set failed")
events = nil
if err := a.apply(); err != f.setErr {
t.Fatal(err)
}
if len(events) != 2 {
t.Fatal("continued after failure", events)
}
}
func TestApplyShared(t *testing.T) {
c := testConfig()
c.Shared["editor"] = "compute"
f := newFake()
f.units = []unit{{Name: "[email protected]", Active: "active"}}
a := application{c, homePaths(t.TempDir()), f, func(...string) error { return nil }, io.Discard}
if err := a.apply(); err != nil {
t.Fatal(err)
}
if got := values(f.sets[f.units[0].Name])["MemoryMax"].Value(); got != uint64(17179869184) {
t.Fatalf("shared app ceiling: got %v, want 16 GiB", got)
}
data, err := os.ReadFile(filepath.Join(a.paths.units, "[email protected]/50-chultun.conf"))
if err != nil || !strings.Contains(string(data), "MemoryMax=16G\n") {
t.Fatalf("persistent shared ceiling: %q, %v", data, err)
}
}
func TestApplyScopes(t *testing.T) {
c := testConfig()
c.Profiles["compute"] = profile{"400%", "2G", "4G", "1G"}
c.Profiles["bulk"] = profile{"200%", "1G", "2G", "1G"}
c.Profiles["cache"] = profile{"100%", "1G", "3G", "0"}
f := newFake()
f.units = []unit{
{Name: "chultun-c123abc.scope", Description: "compute workload", Active: "active"},
{Name: "chultun-b123abc.scope", Description: "bulk workload", Active: "active"},
{Name: "chultun-c456abc.scope", Description: "cache workload", Active: "active"},
{Name: "chultun-s123abc.scope", Description: "search workload", Active: "active"},
{Name: "chultun-c123def.scope", Description: "compute workload", Active: "inactive"},
}
a := application{c, homePaths(t.TempDir()), f, func(...string) error { return nil }, io.Discard}
if err := a.apply(); err != nil {
t.Fatal(err)
}
for name, want := range map[string]uint64{
"chultun-c123abc.scope": 4294967296, "chultun-b123abc.scope": 2147483648,
"chultun-c456abc.scope": 3221225472,
} {
if got := values(f.sets[name])["MemoryMax"].Value(); got != want {
t.Errorf("%s ceiling: got %v, want %d", name, got, want)
}
}
if len(f.sets) != 3 {
t.Fatalf("unexpected scope updates: %v", keys(f.sets))
}
}
func TestApplyInvalid(t *testing.T) {
for name, change := range map[string]func(*config){
"resource": func(c *config) {
p := c.Profiles["default"]
p.High = "invalid"
c.Profiles["default"] = p
},
"command": func(c *config) { c.Commands["java"] = "missing" },
"prefix": func(c *config) { c.Prefixes["default"] = []string{"snap-firefox"} },
} {
for _, active := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/active=%t", name, active), func(t *testing.T) {
c := testConfig()
change(&c)
f := newFake()
if active {
f.units = []unit{{Name: "app-browser-123.scope", Active: "active"}}
}
var events []string
f.events = &events
p := homePaths(t.TempDir())
put(t, filepath.Join(p.units, "user.conf"), "keep")
a := application{c, p, f, func(args ...string) error {
events = append(events, strings.Join(args, " "))
return nil
}, io.Discard}
if err := a.apply(); err == nil {
t.Fatal("invalid configuration accepted")
}
if len(events) != 0 {
t.Fatal("invalid configuration changed units:", events)
}
entries, err := os.ReadDir(p.units)
if err != nil || len(entries) != 1 || entries[0].Name() != "user.conf" {
t.Fatalf("invalid configuration changed files: %v, %v", entries, err)
}
if _, err := os.Stat(p.state); !os.IsNotExist(err) {
t.Fatalf("invalid configuration created state: %v", err)
}
})
}
}
}
func TestSharedOverride(t *testing.T) {
f := newFake()
f.units = []unit{
{Name: "[email protected]", Active: "active"},
{Name: `app-editor\x2duri\[email protected]`, Active: "active"},
{Name: "chultun-d123abc.scope", Active: "active"},
{Name: "app-other-123.scope", Active: "active"},
{Name: "[email protected]", Active: "inactive"},
}
f.groups = map[string]string{
"app-editor.slice": "/app.slice/app-editor.slice",
"[email protected]": "/app.slice/app-editor.slice/[email protected]",
`app-editor\x2duri\[email protected]`: "/app.slice/app-editor.slice/uri.service",
"chultun-d123abc.scope": "/app.slice/app-editor.slice/chultun-d123abc.scope",
"app-other-123.scope": "/app.slice/app-editor.slice-neighbor/app-other-123.scope",
}
a := application{config: testConfig(), ctl: f, out: io.Discard}
if err := a.override("compute", "editor"); err != nil {
t.Fatal(err)
}
want := []string{"app-editor.slice", "[email protected]", `app-editor\x2duri\[email protected]`, "chultun-d123abc.scope"}
if !reflect.DeepEqual(keys(f.sets), want) {
t.Fatalf("got %v, want %v", keys(f.sets), want)
}
for name, props := range f.sets {
if values(props)["MemoryMax"].Value() != uint64(17179869184) {
t.Fatal(name, props)
}
}
if err := a.override("default", "app-editor.slice"); err != nil {
t.Fatal(err)
}
for name, props := range f.sets {
if values(props)["MemoryMax"].Value() != uint64(8589934592) {
t.Fatal(name, props)
}
}
}
func TestOverrideTargets(t *testing.T) {
f := newFake()
f.units = []unit{{Name: "app-flatpak-org.example.Download-123.scope", Active: "active"}}
a := application{config: testConfig(), ctl: f, out: io.Discard}
if err := a.override("unrestricted", "org.example.Download"); err != nil {
t.Fatal(err)
}
if len(f.sets) != 1 {
t.Fatal(f.sets)
}
if err := a.override("default", "no-such-app"); err == nil {
t.Fatal("missing app accepted")
}
if err := a.override("missing", "org.example.Download"); err == nil {
t.Fatal("missing profile accepted")
}
}
func TestAppOutput(t *testing.T) {
want := errors.New("output failed")
f := newFake()
a := application{
config: testConfig(), paths: homePaths(t.TempDir()), ctl: f,
run: func(...string) error { return nil }, out: failWriter{want},
}
if err := a.apply(); !errors.Is(err, want) {
t.Fatal(err)
}
if err := a.override("default", "app-example.service"); !errors.Is(err, want) {
t.Fatal(err)
}
}

113
internal/chultun/bus.go Normal file
View file

@ -0,0 +1,113 @@
package chultun
import (
"errors"
"strings"
"github.com/godbus/dbus/v5"
)
const api = "org.freedesktop.systemd1"
const noUnit = api + ".NoSuchUnit"
const noPID = "org.freedesktop.DBus.Error.UnixProcessIdUnknown"
type unit struct {
Name string
Description string
Load string
Active string
Sub string
Following string
Path dbus.ObjectPath
Job uint32
JobType string
JobPath dbus.ObjectPath
}
type unitState struct {
Active string
Group string
}
type unitCtl interface {
Units() ([]unit, error)
Group(string) (string, error)
Set(string, []property) error
Start(string, []property) error
State(string) (unitState, error)
Stop(string) error
}
type manager struct {
conn *dbus.Conn
obj dbus.BusObject
}
type auxiliary struct {
Name string
Properties []property
}
func connect() (*manager, error) {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return nil, err
}
return &manager{conn: conn, obj: conn.Object(api, "/org/freedesktop/systemd1")}, nil
}
func busError(err error, name string) bool {
var e dbus.Error
return errors.As(err, &e) && e.Name == name
}
func (m *manager) Units() ([]unit, error) {
var units []unit
err := m.obj.Call(api+".Manager.ListUnits", 0).Store(&units)
return units, err
}
func (m *manager) object(name string) (dbus.BusObject, error) {
var path dbus.ObjectPath
if err := m.obj.Call(api+".Manager.GetUnit", 0, name).Store(&path); err != nil {
return nil, err
}
return m.conn.Object(api, path), nil
}
func (m *manager) Group(name string) (string, error) {
obj, err := m.object(name)
if err != nil {
return "", err
}
kind := name[strings.LastIndex(name, ".")+1:]
kind = strings.ToUpper(kind[:1]) + kind[1:]
var group string
err = obj.StoreProperty(api+"."+kind+".ControlGroup", &group)
return group, err
}
func (m *manager) Set(name string, values []property) error {
return m.obj.Call(api+".Manager.SetUnitProperties", 0, name, true, values).Err
}
func (m *manager) Start(name string, values []property) error {
return m.obj.Call(api+".Manager.StartTransientUnit", 0, name, "fail", values, []auxiliary{}).Err
}
func (m *manager) State(name string) (unitState, error) {
obj, err := m.object(name)
if err != nil {
return unitState{}, err
}
var state unitState
if err = obj.StoreProperty(api+".Unit.ActiveState", &state.Active); err != nil {
return unitState{}, err
}
err = obj.StoreProperty(api+".Scope.ControlGroup", &state.Group)
return state, err
}
func (m *manager) Stop(name string) error {
return m.obj.Call(api+".Manager.StopUnit", 0, name, "replace").Err
}

103
internal/chultun/cli.go Normal file
View file

@ -0,0 +1,103 @@
package chultun
import (
"errors"
"fmt"
"io"
"os"
"github.com/BurntSushi/toml"
)
const usage = `usage: chultun COMMAND
apply Apply configured limits and restart the watcher
watch Assign profiles to terminal workloads
status Show the configured resource profiles
run PROFILE -- COMMAND Launch a native command in a profile
set PROFILE TARGET Override a running app ID or systemd unit
Configuration: ~/.config/chultun/profiles.toml
`
type request struct {
action string
profile string
target string
command []string
}
func parseArgs(args []string) (request, error) {
if len(args) == 0 {
return request{}, fmt.Errorf("a command is required")
}
r := request{action: args[0]}
switch r.action {
case "-h", "--help":
r.action = "help"
case "apply", "watch", "status":
if len(args) != 1 {
return request{}, fmt.Errorf("%s takes no arguments", r.action)
}
case "run":
if len(args) < 3 {
return request{}, fmt.Errorf("usage: chultun run PROFILE -- COMMAND")
}
r.profile, r.command = args[1], args[2:]
case "set":
if len(args) != 3 {
return request{}, fmt.Errorf("usage: chultun set PROFILE TARGET")
}
r.profile, r.target = args[1], args[2]
default:
return request{}, fmt.Errorf("unknown command %q", r.action)
}
return r, nil
}
func Run(args []string, out io.Writer) (err error) {
r, err := parseArgs(args)
if err != nil {
return err
}
if r.action == "help" {
_, err := io.WriteString(out, usage)
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
paths := homePaths(home)
c, err := loadConfig(paths.config)
if err != nil {
return err
}
if r.profile != "" {
if _, err := c.getProfile(r.profile); err != nil {
return err
}
}
if r.action == "status" {
return toml.NewEncoder(out).Encode(c)
}
if r.action == "run" {
return launch(c, r.profile, r.command)
}
m, err := connect()
if err != nil {
return err
}
defer func() { err = errors.Join(err, m.conn.Close()) }()
a := application{config: c, paths: paths, ctl: m, run: systemctl, out: out}
switch r.action {
case "apply":
return a.apply()
case "set":
return a.override(r.profile, r.target)
case "watch":
w := watcher{config: c, ctl: m, procs: procFS{root: "/proc", uid: uint32(os.Getuid())}, cgroup: "/sys/fs/cgroup", out: out}
return w.watch()
}
return nil
}

View file

@ -0,0 +1,66 @@
package chultun
import (
"bytes"
"reflect"
"strings"
"testing"
"github.com/BurntSushi/toml"
)
func TestParseArgs(t *testing.T) {
for _, action := range []string{"apply", "watch", "status"} {
r, err := parseArgs([]string{action})
if err != nil || r.action != action {
t.Fatalf("%#v, %v", r, err)
}
}
r, err := parseArgs([]string{"run", "compute", "--", "java", "-Xmx8G", "a b"})
if err != nil || r.profile != "compute" || !reflect.DeepEqual(r.command, []string{"--", "java", "-Xmx8G", "a b"}) {
t.Fatalf("%#v, %v", r, err)
}
r, err = parseArgs([]string{"set", "unrestricted", "editor"})
if err != nil || r.profile != "unrestricted" || r.target != "editor" {
t.Fatalf("%#v, %v", r, err)
}
for _, args := range [][]string{nil, {"unknown"}, {"apply", "extra"}, {"run"}, {"set", "compute"}, {"set", "compute", "editor", "extra"}} {
if _, err := parseArgs(args); err == nil {
t.Errorf("accepted %v", args)
}
}
}
func TestHelp(t *testing.T) {
for _, flag := range []string{"-h", "--help"} {
var out bytes.Buffer
if err := Run([]string{flag}, &out); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "run PROFILE") || !strings.Contains(out.String(), "~/.config/chultun/profiles.toml") {
t.Fatal(out.String())
}
}
}
func TestStatus(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
want := testConfig()
var data bytes.Buffer
if err := toml.NewEncoder(&data).Encode(want); err != nil {
t.Fatal(err)
}
put(t, homePaths(home).config, data.String())
var out bytes.Buffer
if err := Run([]string{"status"}, &out); err != nil {
t.Fatal(err)
}
var got config
if _, err := toml.Decode(out.String(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}

175
internal/chultun/config.go Normal file
View file

@ -0,0 +1,175 @@
package chultun
import (
"fmt"
"maps"
"math"
"slices"
"strconv"
"strings"
"github.com/BurntSushi/toml"
"github.com/godbus/dbus/v5"
)
type profile struct {
CPU string `toml:"cpu"`
High string `toml:"high"`
Memory string `toml:"memory"`
Swap string `toml:"swap"`
}
type ioLimit struct {
Read string `toml:"read"`
Write string `toml:"write"`
RIOPS string `toml:"riops"`
WIOPS string `toml:"wiops"`
}
type device struct {
Path string `toml:"path"`
Limits map[string]ioLimit `toml:"limits"`
}
type config struct {
Profiles map[string]profile `toml:"profiles"`
Devices []device `toml:"devices"`
Apps map[string][]string `toml:"apps"`
Units map[string][]string `toml:"units"`
Prefixes map[string][]string `toml:"prefixes"`
Commands map[string]string `toml:"commands"`
Python string `toml:"python"`
Terminals []string `toml:"terminals"`
Shared map[string]string `toml:"shared"`
}
type property struct {
Name string
Value dbus.Variant
}
type diskLimit struct {
Path string
Limit uint64
}
type ioField struct {
name string
get func(ioLimit) string
}
var ioFields = []ioField{
{"IOReadBandwidthMax", func(l ioLimit) string { return l.Read }},
{"IOWriteBandwidthMax", func(l ioLimit) string { return l.Write }},
{"IOReadIOPSMax", func(l ioLimit) string { return l.RIOPS }},
{"IOWriteIOPSMax", func(l ioLimit) string { return l.WIOPS }},
}
func keys[V any](m map[string]V) []string {
return slices.Sorted(maps.Keys(m))
}
func loadConfig(path string) (config, error) {
var c config
_, err := toml.DecodeFile(path, &c)
if err != nil {
return c, err
}
return c, c.validate()
}
func parseSize(value string, base uint64) (uint64, error) {
if value == "infinity" {
return math.MaxUint64, nil
}
factor := uint64(1)
for i, suffix := range []string{"K", "M", "G"} {
if strings.HasSuffix(value, suffix) {
value = strings.TrimSuffix(value, suffix)
for range i + 1 {
factor *= base
}
break
}
}
number, err := strconv.ParseUint(value, 10, 64)
return number * factor, err
}
func prop(name string, value any) property {
return property{Name: name, Value: dbus.MakeVariant(value)}
}
func (c config) getProfile(name string) (profile, error) {
p, ok := c.Profiles[name]
if !ok {
return profile{}, fmt.Errorf("unknown profile %q", name)
}
return p, nil
}
func (c config) properties(name string) ([]property, error) {
p, err := c.getProfile(name)
if err != nil {
return nil, err
}
quota := uint64(math.MaxUint64)
if p.CPU != "" {
percentage, ok := strings.CutSuffix(p.CPU, "%")
if !ok {
return nil, fmt.Errorf("CPUQuota: expected a percentage, got %q", p.CPU)
}
quota, err = strconv.ParseUint(percentage, 10, 64)
if err != nil {
return nil, fmt.Errorf("CPUQuota: %w", err)
}
quota *= 10000
}
result := []property{prop("CPUQuotaPerSecUSec", quota)}
for _, field := range []struct{ name, value string }{
{"MemoryHigh", p.High}, {"MemoryMax", p.Memory}, {"MemorySwapMax", p.Swap},
} {
value, err := parseSize(field.value, 1024)
if err != nil {
return nil, fmt.Errorf("%s: %w", field.name, err)
}
result = append(result, prop(field.name, value))
}
for _, field := range ioFields {
limits := make([]diskLimit, 0, len(c.Devices))
for _, d := range c.Devices {
limit, ok := d.Limits[name]
if !ok {
continue
}
value, err := parseSize(field.get(limit), 1000)
if err != nil {
return nil, fmt.Errorf("%s: %w", field.name, err)
}
limits = append(limits, diskLimit{Path: d.Path, Limit: value})
}
result = append(result, prop(field.name, limits))
}
return result, nil
}
func (c config) assignments(name string) ([]string, error) {
p, err := c.getProfile(name)
if err != nil {
return nil, err
}
result := []string{"CPUQuota=" + p.CPU, "MemoryHigh=" + p.High, "MemoryMax=" + p.Memory, "MemorySwapMax=" + p.Swap}
for _, field := range ioFields {
found := false
for _, d := range c.Devices {
if limit, ok := d.Limits[name]; ok {
result = append(result, field.name+"="+d.Path+" "+field.get(limit))
found = true
}
}
if !found {
result = append(result, field.name+"=")
}
}
return result, nil
}

View file

@ -0,0 +1,256 @@
package chultun
import (
"bytes"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/BurntSushi/toml"
"github.com/godbus/dbus/v5"
)
func testConfig() config {
return config{
Profiles: map[string]profile{
"default": {"400%", "6G", "8G", "2G"},
"compute": {"800%", "12G", "16G", "2G"},
"bulk": {"400%", "6G", "8G", "2G"},
"search": {"200%", "512M", "1G", "0"},
"unrestricted": {"", "infinity", "infinity", "infinity"},
},
Devices: []device{{"/dev/mock", map[string]ioLimit{
"default": {"100M", "100M", "4000", "4000"},
"compute": {"500M", "500M", "20000", "20000"},
"bulk": {"500M", "500M", "10000", "10000"},
"search": {"50M", "5M", "1000", "1000"},
}}},
Apps: map[string][]string{
"unrestricted": {"org.example.Terminal", "org.example.Console"},
"compute": {"game", "org.example.Game"},
"bulk": {"org.example.Download"},
},
Units: map[string][]string{"unrestricted": {"containerd.service", "worker.service"}},
Prefixes: map[string][]string{"unrestricted": {"app-terminal-.scope.d"}},
Commands: map[string]string{"rg": "search", "java": "compute", "rsync": "bulk"},
Python: "compute",
Terminals: []string{"app-terminal-", "app-org.example.Terminal", "app-org.example.Console"},
Shared: map[string]string{"editor": "default", "editor-uri-handler": "default"},
}
}
func values(properties []property) map[string]dbus.Variant {
result := map[string]dbus.Variant{}
for _, p := range properties {
result[p.Name] = p.Value
}
return result
}
func TestParseSize(t *testing.T) {
for _, tc := range []struct {
value string
base uint64
want uint64
}{
{"8G", 1024, 8589934592}, {"12G", 1024, 12884901888},
{"16G", 1024, 17179869184}, {"512M", 1024, 536870912},
{"50M", 1000, 50000000}, {"5K", 1000, 5000},
{"1000", 1000, 1000}, {"0", 1024, 0}, {"infinity", 1024, math.MaxUint64},
} {
t.Run(tc.value, func(t *testing.T) {
got, err := parseSize(tc.value, tc.base)
if err != nil || got != tc.want {
t.Fatalf("got %d, %v; want %d", got, err, tc.want)
}
})
}
for _, input := range []string{"", "-1", "abc", "1T"} {
if _, err := parseSize(input, 1024); err == nil {
t.Errorf("accepted %q", input)
}
}
}
func TestComputeProps(t *testing.T) {
props, err := testConfig().properties("compute")
if err != nil {
t.Fatal(err)
}
got := values(props)
for name, want := range map[string]uint64{
"CPUQuotaPerSecUSec": 8000000, "MemoryHigh": 12884901888,
"MemoryMax": 17179869184, "MemorySwapMax": 2147483648,
} {
if got[name].Signature().String() != "t" || got[name].Value() != want {
t.Errorf("%s: %v", name, got[name])
}
}
if got["IOReadBandwidthMax"].Signature().String() != "a(st)" {
t.Fatal("wrong I/O wire signature")
}
want := []diskLimit{{"/dev/mock", 500000000}}
if !reflect.DeepEqual(got["IOReadBandwidthMax"].Value(), want) {
t.Fatal(got["IOReadBandwidthMax"])
}
if dbus.SignatureOf(props).String() != "a(sv)" {
t.Fatal("wrong property wire signature")
}
if dbus.SignatureOf([]auxiliary{}).String() != "a(sa(sv))" {
t.Fatal("wrong auxiliary wire signature")
}
}
func TestUnrestricted(t *testing.T) {
c := testConfig()
props, err := c.properties("unrestricted")
if err != nil {
t.Fatal(err)
}
got := values(props)
for _, name := range []string{"CPUQuotaPerSecUSec", "MemoryHigh", "MemoryMax", "MemorySwapMax"} {
if got[name].Value() != uint64(math.MaxUint64) {
t.Errorf("%s wasn't cleared", name)
}
}
for _, field := range ioFields {
if got[field.name].Signature().String() != "a(st)" || len(got[field.name].Value().([]diskLimit)) != 0 {
t.Errorf("%s wasn't cleared", field.name)
}
}
assignments, err := c.assignments("unrestricted")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"CPUQuota=", "MemoryMax=infinity", "IOReadBandwidthMax=", "IOWriteIOPSMax="} {
if !strings.Contains(strings.Join(assignments, "\n"), want+"\n") && assignments[len(assignments)-1] != want {
t.Errorf("missing reset %q", want)
}
}
}
func TestProfileErrors(t *testing.T) {
c := testConfig()
if _, err := c.properties("missing"); err == nil {
t.Fatal("unknown profile accepted")
}
if _, err := c.assignments("missing"); err == nil {
t.Fatal("unknown profile accepted")
}
for _, p := range []profile{{"bad%", "6G", "8G", "2G"}, {"400%", "bad", "8G", "2G"}} {
c.Profiles["bad"] = p
if _, err := c.properties("bad"); err == nil {
t.Fatal("invalid size accepted")
}
}
c.Devices[0].Limits["default"] = ioLimit{"bad", "100M", "4000", "4000"}
if _, err := c.properties("default"); err == nil {
t.Fatal("invalid disk rate accepted")
}
}
func TestLoadConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "profiles.toml")
data := `python = "compute"
terminals = ["app-terminal-", "app-org.example.Console"]
[profiles.default]
cpu = "400%"
high = "6G"
memory = "8G"
swap = "2G"
[profiles.compute]
cpu = "800%"
high = "12G"
memory = "16G"
swap = "2G"
[profiles.unrestricted]
cpu = ""
high = "infinity"
memory = "infinity"
swap = "infinity"
[[devices]]
path = '/dev/mock'
[devices.limits.compute]
read = "500M"
write = "500M"
riops = "20000"
wiops = "20000"
[apps]
compute = ["game", "org.example.Game"]
[units]
unrestricted = ["containerd.service"]
[prefixes]
unrestricted = ["app-terminal-.scope.d"]
[commands]
java = "compute"
[shared]
"editor-uri-handler" = "default"
`
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
c, err := loadConfig(path)
if err != nil {
t.Fatal(err)
}
want := testConfig()
want.Profiles = map[string]profile{
"compute": want.Profiles["compute"], "default": want.Profiles["default"], "unrestricted": want.Profiles["unrestricted"],
}
want.Devices[0].Limits = map[string]ioLimit{"compute": want.Devices[0].Limits["compute"]}
want.Apps = map[string][]string{"compute": want.Apps["compute"]}
want.Units["unrestricted"] = []string{"containerd.service"}
want.Commands = map[string]string{"java": "compute"}
want.Terminals = []string{"app-terminal-", "app-org.example.Console"}
want.Shared = map[string]string{"editor-uri-handler": "default"}
if !reflect.DeepEqual(c, want) {
t.Fatalf("got %#v, want %#v", c, want)
}
if err := os.WriteFile(path, []byte("{"), 0600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(path); err == nil {
t.Fatal("invalid TOML accepted")
}
if _, err := loadConfig(path + "-missing"); !os.IsNotExist(err) {
t.Fatal(err)
}
}
func TestConfigRoundTrip(t *testing.T) {
want := testConfig()
var out bytes.Buffer
if err := toml.NewEncoder(&out).Encode(want); err != nil {
t.Fatal(err)
}
var got config
if _, err := toml.Decode(out.String(), &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v, want %#v", got, want)
}
}
func TestExample(t *testing.T) {
c, err := loadConfig(filepath.Join("..", "..", "profiles.example.toml"))
if err != nil {
t.Fatal(err)
}
if _, err := c.render(); err != nil {
t.Fatal(err)
}
for name := range c.Profiles {
if _, err := c.properties(name); err != nil {
t.Fatal(name, err)
}
}
}

View file

@ -0,0 +1,47 @@
package chultun
import (
"path/filepath"
"strings"
"testing"
)
func TestUbuntuUnits(t *testing.T) {
c, err := loadConfig(filepath.Join("..", "..", "profiles.example.toml"))
if err != nil {
t.Fatal(err)
}
for unit, want := range map[string]string{
"gnome-terminal-server.service": "unrestricted",
"app-org.gnome.Terminal.service": "unrestricted",
"app-org.gnome.Terminal-123.scope": "unrestricted",
"app-org.gnome.Nautilus-123.scope": "default",
"snap.firefox.firefox-123.scope": "default",
"snap.firefox.firefox-helper.service": "",
"chultun.service": "",
} {
if got := c.unitProfile(unit); got != want {
t.Errorf("%s: got %q, want %q", unit, got, want)
}
}
group := "/app.slice/app-org.gnome.Terminal.slice/gnome-terminal-server.service"
for exe, want := range map[string]string{
"rg": "search", "java": "compute", "python3.12": "compute", "rsync": "bulk",
"cargo": "", "go": "", "npm": "", "kubectl": "",
} {
got, slice := c.classify(exe, group)
if got != want || slice != "" {
t.Errorf("%s: got %q, %q; want %q, empty slice", exe, got, slice, want)
}
}
files, err := c.render()
if err != nil {
t.Fatal(err)
}
if got := files["snap.firefox.firefox-.scope.d/50-chultun.conf"]; !strings.Contains(got, "[Scope]\n") || !strings.Contains(got, "MemoryMax=3G\n") {
t.Fatal("Firefox Snap ceiling missing", got)
}
if got := files["gnome-terminal-server.service.d/50-chultun.conf"]; !strings.Contains(got, "MemoryMax=infinity\n") {
t.Fatal("terminal exception missing", got)
}
}

View file

@ -0,0 +1,75 @@
package chultun
import (
"os"
"github.com/godbus/dbus/v5"
)
type fakeCtl struct {
units []unit
groups map[string]string
states map[string]unitState
sets map[string][]property
starts map[string][]property
stops []string
stateErr map[string]error
startErr error
setErr error
events *[]string
onStart func(string, []property)
}
func newFake() *fakeCtl {
return &fakeCtl{groups: map[string]string{}, states: map[string]unitState{}, sets: map[string][]property{}, starts: map[string][]property{}, stateErr: map[string]error{}}
}
func (f *fakeCtl) Units() ([]unit, error) { return f.units, nil }
func (f *fakeCtl) Group(name string) (string, error) { return f.groups[name], nil }
func (f *fakeCtl) Set(name string, props []property) error {
if f.events != nil {
*f.events = append(*f.events, "set "+name)
}
f.sets[name] = props
return f.setErr
}
func (f *fakeCtl) Start(name string, props []property) error {
if f.startErr != nil {
return f.startErr
}
f.starts[name] = props
f.states[name] = unitState{Active: "activating"}
if f.onStart != nil {
f.onStart(name, props)
}
return nil
}
func (f *fakeCtl) State(name string) (unitState, error) {
if err := f.stateErr[name]; err != nil {
return unitState{}, err
}
if state, ok := f.states[name]; ok {
return state, nil
}
return unitState{}, dbus.Error{Name: noUnit}
}
func (f *fakeCtl) Stop(name string) error { f.stops = append(f.stops, name); return nil }
type fakeProcs struct {
tasks map[uint32]task
groups map[uint32]string
}
type failWriter struct {
err error
}
func (w failWriter) Write([]byte) (int, error) { return 0, w.err }
func (f fakeProcs) Snapshot(config) (map[uint32]task, error) { return f.tasks, nil }
func (f fakeProcs) Group(pid uint32) (string, error) {
if group, ok := f.groups[pid]; ok {
return group, nil
}
return "", os.ErrNotExist
}

59
internal/chultun/files.go Normal file
View file

@ -0,0 +1,59 @@
package chultun
import (
"encoding/json"
"os"
"path/filepath"
)
type paths struct {
config string
units string
state string
}
func homePaths(home string) paths {
return paths{
config: filepath.Join(home, ".config/chultun/profiles.toml"),
units: filepath.Join(home, ".config/systemd/user"),
state: filepath.Join(home, ".local/state/chultun"),
}
}
func writeFiles(p paths, files map[string]string) error {
if err := os.MkdirAll(p.state, 0755); err != nil {
return err
}
manifest := filepath.Join(p.state, "files.json")
previous := []string{}
data, err := os.ReadFile(manifest)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
if err := json.Unmarshal(data, &previous); err != nil {
return err
}
}
for _, relative := range previous {
if _, keep := files[relative]; !keep {
if err := os.Remove(filepath.Join(p.units, relative)); err != nil && !os.IsNotExist(err) {
return err
}
}
}
for _, relative := range keys(files) {
target := filepath.Join(p.units, relative)
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
if err := os.WriteFile(target, []byte(files[relative]), 0644); err != nil {
return err
}
}
data, err = json.MarshalIndent(keys(files), "", " ")
if err != nil {
return err
}
return os.WriteFile(manifest, append(data, '\n'), 0644)
}

View file

@ -0,0 +1,85 @@
package chultun
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestWriteFiles(t *testing.T) {
p := homePaths(t.TempDir())
old := "old.scope.d/50-chultun.conf"
put(t, filepath.Join(p.units, old), "old")
put(t, filepath.Join(p.units, "user.conf"), "keep")
put(t, filepath.Join(p.state, "files.json"), `["old.scope.d/50-chultun.conf"]`)
files := map[string]string{"app-.scope.d/50-chultun.conf": "[Scope]\nMemoryMax=8G\n", "search.slice": "[Slice]\n"}
if err := writeFiles(p, files); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(p.units, old)); !os.IsNotExist(err) {
t.Fatalf("stale owned file remains: %v", err)
}
data, err := os.ReadFile(filepath.Join(p.units, "user.conf"))
if err != nil || string(data) != "keep" {
t.Fatal("unmanaged configuration changed")
}
data, err = os.ReadFile(filepath.Join(p.state, "files.json"))
if err != nil {
t.Fatal(err)
}
var manifest []string
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(manifest, keys(files)) {
t.Fatal(manifest)
}
for name, want := range files {
data, err := os.ReadFile(filepath.Join(p.units, name))
if err != nil || string(data) != want {
t.Fatalf("%s: %q, %v", name, data, err)
}
}
if err := writeFiles(p, files); err != nil {
t.Fatal("second apply failed:", err)
}
}
func TestFilesRetry(t *testing.T) {
p := homePaths(t.TempDir())
old := "old-a.scope.d/50-chultun.conf"
put(t, filepath.Join(p.units, old), "old")
put(t, filepath.Join(p.state, "files.json"), `["old-a.scope.d/50-chultun.conf", "old-b.scope.d/50-chultun.conf"]`)
blocker := filepath.Join(p.units, "new.scope.d")
put(t, blocker, "blocked")
files := map[string]string{"new.scope.d/50-chultun.conf": "[Scope]\nMemoryMax=8G\n"}
if err := writeFiles(p, files); err == nil {
t.Fatal("write failure ignored")
}
if _, err := os.Stat(filepath.Join(p.units, old)); !os.IsNotExist(err) {
t.Fatalf("cleanup did not finish before the write failure: %v", err)
}
if err := os.Remove(blocker); err != nil {
t.Fatal(err)
}
if err := writeFiles(p, files); err != nil {
t.Fatal("retry failed:", err)
}
data, err := os.ReadFile(filepath.Join(p.units, "new.scope.d/50-chultun.conf"))
if err != nil || string(data) != files["new.scope.d/50-chultun.conf"] {
t.Fatalf("replacement file: %q, %v", data, err)
}
data, err = os.ReadFile(filepath.Join(p.state, "files.json"))
if err != nil {
t.Fatal(err)
}
var manifest []string
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(manifest, keys(files)) {
t.Fatal("manifest was not updated:", manifest)
}
}

View file

@ -0,0 +1,52 @@
package chultun
import (
"fmt"
"os"
"os/exec"
"syscall"
)
func systemctl(args ...string) error {
cmd := exec.Command("systemctl", append([]string{"--user"}, args...)...)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd.Run()
}
func runArgs(c config, name, id string, command []string) ([]string, error) {
if len(command) > 0 && command[0] == "--" {
command = command[1:]
}
if len(command) == 0 {
return nil, fmt.Errorf("a command is required")
}
values, err := c.assignments(name)
if err != nil {
return nil, err
}
slice := "app.slice"
if name == "search" {
slice = "search.slice"
}
args := []string{"systemd-run", "--user", "--scope", "--collect", "--quiet", "--unit=work-" + id, "--slice=" + slice}
for _, value := range values {
args = append(args, "--property="+value)
}
return append(append(args, "--"), command...), nil
}
func launch(c config, name string, command []string) error {
id, err := randomID()
if err != nil {
return err
}
args, err := runArgs(c, name, id, command)
if err != nil {
return err
}
path, err := exec.LookPath(args[0])
if err != nil {
return err
}
return syscall.Exec(path, args, os.Environ())
}

View file

@ -0,0 +1,35 @@
package chultun
import (
"reflect"
"strings"
"testing"
)
func TestRunArgs(t *testing.T) {
command := []string{"--", "rg", "--glob", "*.go", "a b", "-"}
args, err := runArgs(testConfig(), "unrestricted", "123abc", command)
if err != nil {
t.Fatal(err)
}
wantTail := []string{"--", "rg", "--glob", "*.go", "a b", "-"}
if !reflect.DeepEqual(args[len(args)-len(wantTail):], wantTail) {
t.Fatal("command arguments changed", args)
}
if !strings.Contains(strings.Join(args, "\n"), "--property=CPUQuota=\n") {
t.Fatal("quota wasn't reset")
}
args, err = runArgs(testConfig(), "search", "123abc", []string{"rg", "pattern", "path"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(args, "\n"), "--slice=search.slice\n") {
t.Fatal("search didn't join shared slice")
}
if _, err := runArgs(testConfig(), "compute", "123abc", []string{"--"}); err == nil {
t.Fatal("empty command accepted")
}
if _, err := runArgs(testConfig(), "missing", "123abc", []string{"rg"}); err == nil {
t.Fatal("unknown profile accepted")
}
}

View file

@ -0,0 +1,215 @@
package chultun
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func waitGroup(t *testing.T, p procFS, pid uint32, part string) string {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
group, err := p.Group(pid)
if err != nil {
t.Fatal(err)
}
if strings.Contains(group, part) {
return group
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("PID %d never entered %s", pid, part)
return ""
}
func startChild(t *testing.T, command string, args ...string) *exec.Cmd {
t.Helper()
cmd := exec.Command(command, args...)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if cmd.ProcessState != nil {
return
}
if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
t.Error(err)
}
var exit *exec.ExitError
if err := cmd.Wait(); err != nil && !errors.As(err, &exit) {
t.Error(err)
}
})
return cmd
}
func TestLiveManager(t *testing.T) {
if os.Getenv("CHULTUN_LIVE") != "1" {
t.Skip("set CHULTUN_LIVE=1 for the user-manager check")
}
home, err := os.UserHomeDir()
if err != nil {
t.Fatal(err)
}
p := homePaths(home)
c, err := loadConfig(p.config)
if err != nil {
t.Fatal(err)
}
files, err := c.render()
if err != nil {
t.Fatal(err)
}
for name, want := range files {
data, err := os.ReadFile(filepath.Join(p.units, name))
if err != nil || string(data) != want {
t.Fatalf("generated file differs: %s, %v", name, err)
}
}
m, err := connect()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := m.conn.Close(); err != nil {
t.Error(err)
}
})
if _, err := m.Units(); err != nil {
t.Fatal(err)
}
child := startChild(t, "sleep", "30")
id, err := randomID()
if err != nil {
t.Fatal(err)
}
name := "app-bud-" + id + ".scope"
if err := m.Start(name, []property{prop("PIDs", []uint32{uint32(child.Process.Pid)}), prop("Slice", "app.slice"), prop("CollectMode", "inactive-or-failed")}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := m.Stop(name); err != nil && !busError(err, noUnit) {
t.Error(err)
}
})
group := waitGroup(t, procFS{"/proc", uint32(os.Getuid())}, uint32(child.Process.Pid), "/"+name)
root := filepath.Join("/sys/fs/cgroup", strings.TrimPrefix(group, "/"))
props, err := c.properties("default")
if err != nil {
t.Fatal(err)
}
if err := m.Set(name, props); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(root, "memory.max"))
if err != nil {
t.Fatal(err)
}
want := strconv.FormatUint(values(props)["MemoryMax"].Value().(uint64), 10)
if strings.TrimSpace(string(got)) != want {
t.Fatalf("memory.max = %q, want %q", got, want)
}
state, err := m.State(name)
if err != nil || state.Active != "active" || state.Group != group {
t.Fatalf("%#v, %v", state, err)
}
props, err = c.properties("unrestricted")
if err != nil {
t.Fatal(err)
}
if err := m.Set(name, props); err != nil {
t.Fatal(err)
}
for file, want := range map[string]string{"cpu.max": "max 100000", "memory.max": "max", "io.max": ""} {
got, err := os.ReadFile(filepath.Join(root, file))
if err != nil || strings.TrimSpace(string(got)) != want {
t.Fatalf("%s = %q, %v; want %q", file, got, err, want)
}
}
if err := m.Stop(name); err != nil {
t.Fatal(err)
}
}
func security(t *testing.T, pid int) map[string]string {
t.Helper()
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
if err != nil {
t.Fatal(err)
}
result := map[string]string{}
for _, line := range strings.Split(string(data), "\n") {
key, value, _ := strings.Cut(line, ":")
if key == "NoNewPrivs" || key == "Seccomp" {
result[key] = strings.TrimSpace(value)
}
}
return result
}
func TestLiveWatcher(t *testing.T) {
if os.Getenv("CHULTUN_WATCH") != "1" {
t.Skip("set CHULTUN_WATCH=1 for the terminal watcher check")
}
root := t.TempDir()
children := []*exec.Cmd{}
before := []map[string]string{}
for i := range 2 {
fifo := filepath.Join(root, "input-"+strconv.Itoa(i))
if err := syscall.Mkfifo(fifo, 0600); err != nil {
t.Fatal(err)
}
child := startChild(t, "rg", "--no-mmap", "-q", "absent", fifo)
children = append(children, child)
before = append(before, security(t, child.Process.Pid))
}
groups := []string{}
for _, child := range children {
groups = append(groups, waitGroup(t, procFS{"/proc", uint32(os.Getuid())}, uint32(child.Process.Pid), "/search.slice/chultun-s"))
}
if groups[0] == groups[1] {
t.Fatal("searches didn't receive distinct scopes")
}
for i, child := range children {
after := security(t, child.Process.Pid)
if before[i]["NoNewPrivs"] != after["NoNewPrivs"] || before[i]["Seccomp"] != after["Seccomp"] {
t.Fatal("sandbox security changed")
}
stream, err := os.OpenFile(filepath.Join(root, "input-"+strconv.Itoa(i)), os.O_WRONLY, 0600)
if err != nil {
t.Fatal(err)
}
if _, err := stream.WriteString("present\n"); err != nil {
t.Fatal(err)
}
if err := stream.Close(); err != nil {
t.Fatal(err)
}
var exit *exec.ExitError
if err := child.Wait(); !errors.As(err, &exit) || exit.ExitCode() != 1 {
t.Fatalf("unexpected search exit: %v", err)
}
}
}
func TestLiveLaunch(t *testing.T) {
if os.Getenv("CHULTUN_LIVE") != "1" {
t.Skip("set CHULTUN_LIVE=1 for the native launcher check")
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(filepath.Join("..", "..", "bin", "chultun"), "run", "unrestricted", "--", "sh", "-c", `test "$(pwd -P)" = "$1" && exit 7`, "sh", wd)
var exit *exec.ExitError
if err := cmd.Run(); !errors.As(err, &exit) || exit.ExitCode() != 7 {
t.Fatalf("working directory or exit status changed: %v", err)
}
}

View file

@ -0,0 +1,169 @@
package chultun
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"syscall"
)
type task struct {
Parent uint32
Group string
Profile string
Slice string
}
type procSource interface {
Snapshot(config) (map[uint32]task, error)
Group(uint32) (string, error)
}
type procFS struct {
root string
uid uint32
}
var pythonName = regexp.MustCompile(`^python[23]?(?:\.\d+)?$`)
func gone(err error) bool {
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH)
}
func (c config) sourceGroup(group string) (bool, bool) {
unit := filepath.Base(group)
terminal := false
for _, prefix := range c.Terminals {
if strings.HasPrefix(unit, prefix) {
terminal = true
break
}
}
job := (strings.HasPrefix(unit, "chultun-c") || strings.HasPrefix(unit, "chultun-b")) && strings.HasSuffix(unit, ".scope")
return terminal, job
}
func (c config) classify(exe, group string) (string, string) {
terminal, job := c.sourceGroup(group)
if !terminal && !job {
return "", ""
}
name := c.Commands[exe]
slice := ""
if shared, ok := c.Shared[exe]; ok {
name, slice = shared, c.sharedSlice()
}
if pythonName.MatchString(exe) {
name = c.Python
}
if job && name != "search" && slice == "" {
name = ""
}
return name, slice
}
func readGroup(path string) (string, error) {
data, err := os.ReadFile(path)
return strings.TrimPrefix(strings.TrimSpace(string(data)), "0::"), err
}
func (p procFS) Group(pid uint32) (string, error) {
return readGroup(filepath.Join(p.root, strconv.FormatUint(uint64(pid), 10), "cgroup"))
}
func parseParent(stat string) (uint32, error) {
i := strings.LastIndex(stat, ") ")
if i < 0 {
return 0, fmt.Errorf("invalid proc stat")
}
fields := strings.Fields(stat[i+2:])
if len(fields) < 2 {
return 0, fmt.Errorf("missing parent in proc stat")
}
value, err := strconv.ParseUint(fields[1], 10, 32)
return uint32(value), err
}
func (p procFS) readTask(path string, c config) (task, bool, error) {
info, err := os.Stat(path)
if err != nil {
return task{}, false, err
}
if info.Sys().(*syscall.Stat_t).Uid != p.uid {
return task{}, false, nil
}
group, err := readGroup(filepath.Join(path, "cgroup"))
if err != nil {
return task{}, false, err
}
terminal, job := c.sourceGroup(group)
if !terminal && !job {
return task{}, false, nil
}
exe, err := os.Readlink(filepath.Join(path, "exe"))
if err != nil {
return task{}, false, err
}
stat, err := os.ReadFile(filepath.Join(path, "stat"))
if err != nil {
return task{}, false, err
}
parent, err := parseParent(string(stat))
if err != nil {
return task{}, false, err
}
name, slice := c.classify(filepath.Base(exe), group)
return task{Parent: parent, Group: group, Profile: name, Slice: slice}, true, nil
}
func (p procFS) Snapshot(c config) (map[uint32]task, error) {
entries, err := os.ReadDir(p.root)
if err != nil {
return nil, err
}
tasks := map[uint32]task{}
for _, entry := range entries {
pid, err := strconv.ParseUint(entry.Name(), 10, 32)
if err != nil {
continue
}
value, include, err := p.readTask(filepath.Join(p.root, entry.Name()), c)
if gone(err) {
continue
}
if err != nil {
return nil, err
}
if include {
tasks[uint32(pid)] = value
}
}
return tasks, nil
}
func family(tasks map[uint32]task, pid uint32) []uint32 {
group := tasks[pid].Group
seen := map[uint32]bool{pid: true}
pending := []uint32{pid}
for len(pending) > 0 {
parent := pending[0]
pending = pending[1:]
for child, value := range tasks {
if value.Parent == parent && value.Group == group && !seen[child] {
seen[child] = true
pending = append(pending, child)
}
}
}
result := make([]uint32, 0, len(seen))
for child := range seen {
result = append(result, child)
}
slices.Sort(result)
return result
}

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