Rename budget to chultun
This commit is contained in:
parent
2bf14948d7
commit
b529740ee3
27 changed files with 126 additions and 126 deletions
14
README.md
14
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+.
|
||||
|
||||
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 |
|
||||
| ------------------------------ | -------------------------------- |
|
||||
| ------------------------------ | --------------------------------- |
|
||||
| Install and apply | `./install.sh` |
|
||||
| Apply configuration edits | `budget apply` |
|
||||
| Show configuration | `budget status` |
|
||||
| Launch with a profile | `budget run compute -- COMMAND` |
|
||||
| Override a running app or unit | `budget set unrestricted TARGET` |
|
||||
| Apply configuration edits | `chultun apply` |
|
||||
| Show configuration | `chultun status` |
|
||||
| Launch with a profile | `chultun run compute -- COMMAND` |
|
||||
| Override a running app or unit | `chultun set unrestricted TARGET` |
|
||||
| Test | `go test -race ./...` |
|
||||
| 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
|
||||
|
||||
|
|
|
|||
10
install.sh
10
install.sh
|
|
@ -1,8 +1,8 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
cd -- "$(dirname -- "$0")"
|
||||
CGO_ENABLED=0 go build -trimpath -o bin/budget ./cmd/budget
|
||||
install -Dm755 bin/budget "$HOME/.local/bin/.budget.new"
|
||||
mv "$HOME/.local/bin/.budget.new" "$HOME/.local/bin/budget"
|
||||
test -f "$HOME/.config/budget/profiles.toml" || install -Dm644 profiles.example.toml "$HOME/.config/budget/profiles.toml"
|
||||
"$HOME/.local/bin/budget" apply
|
||||
CGO_ENABLED=0 go build -trimpath -o bin/chultun ./cmd/chultun
|
||||
install -Dm755 bin/chultun "$HOME/.local/bin/.chultun.new"
|
||||
mv "$HOME/.local/bin/.chultun.new" "$HOME/.local/bin/chultun"
|
||||
test -f "$HOME/.config/chultun/profiles.toml" || install -Dm644 profiles.example.toml "$HOME/.config/chultun/profiles.toml"
|
||||
"$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