Rename budget to chultun
This commit is contained in:
parent
2bf14948d7
commit
b529740ee3
27 changed files with 126 additions and 126 deletions
22
README.md
22
README.md
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
15
cmd/chultun/main.go
Normal 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
2
go.mod
|
|
@ -1,4 +1,4 @@
|
||||||
module budget
|
module chultun
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
|
|
|
||||||
10
install.sh
10
install.sh
|
|
@ -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
119
internal/chultun/app.go
Normal 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
|
||||||
|
}
|
||||||
214
internal/chultun/app_test.go
Normal file
214
internal/chultun/app_test.go
Normal 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
113
internal/chultun/bus.go
Normal 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
103
internal/chultun/cli.go
Normal 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
|
||||||
|
}
|
||||||
66
internal/chultun/cli_test.go
Normal file
66
internal/chultun/cli_test.go
Normal 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
175
internal/chultun/config.go
Normal 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
|
||||||
|
}
|
||||||
256
internal/chultun/config_test.go
Normal file
256
internal/chultun/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
internal/chultun/example_test.go
Normal file
47
internal/chultun/example_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
internal/chultun/fake_test.go
Normal file
75
internal/chultun/fake_test.go
Normal 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
59
internal/chultun/files.go
Normal 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)
|
||||||
|
}
|
||||||
85
internal/chultun/files_test.go
Normal file
85
internal/chultun/files_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/chultun/launch.go
Normal file
52
internal/chultun/launch.go
Normal 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())
|
||||||
|
}
|
||||||
35
internal/chultun/launch_test.go
Normal file
35
internal/chultun/launch_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
215
internal/chultun/live_test.go
Normal file
215
internal/chultun/live_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
internal/chultun/proc_linux.go
Normal file
169
internal/chultun/proc_linux.go
Normal 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
Loading…
Reference in a new issue