2026-10-05 19:19:05 +09:00
|
|
|
package budget
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
2026-10-05 21:45:05 +09:00
|
|
|
[profiles.default]
|
|
|
|
|
cpu = "400%"
|
|
|
|
|
high = "6G"
|
|
|
|
|
memory = "8G"
|
|
|
|
|
swap = "2G"
|
|
|
|
|
|
2026-10-05 19:19:05 +09:00
|
|
|
[profiles.compute]
|
|
|
|
|
cpu = "800%"
|
|
|
|
|
high = "12G"
|
|
|
|
|
memory = "16G"
|
|
|
|
|
swap = "2G"
|
|
|
|
|
|
2026-10-05 21:45:05 +09:00
|
|
|
[profiles.unrestricted]
|
|
|
|
|
cpu = ""
|
|
|
|
|
high = "infinity"
|
|
|
|
|
memory = "infinity"
|
|
|
|
|
swap = "infinity"
|
|
|
|
|
|
2026-10-05 19:19:05 +09:00
|
|
|
[[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()
|
2026-10-05 21:45:05 +09:00
|
|
|
want.Profiles = map[string]profile{
|
|
|
|
|
"compute": want.Profiles["compute"], "default": want.Profiles["default"], "unrestricted": want.Profiles["unrestricted"],
|
|
|
|
|
}
|
2026-10-05 19:19:05 +09:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|