feat(swap): configurable per-container swap allowance
Incus 7 on cgroup v2 writes memory.swap.max=0 for every container unless limits.memory.swap carries an explicit byte amount (true/false both end up as 0), so containers previously got no host swap at all and `free` inside showed 0. Add incus.swap_ratio (default 0.5) so each container gets limits.memory.swap = limits.memory x ratio, set at create/reinstall and on quota changes. - config: incus.swap_ratio field + registry entry (non-negative float, 0 disables swap; ApplyImmediate) - lx: Client carries swapRatio; SetMem keeps swap proportional, SetSwap refreshes only the swap key - mgr: ApplySwapToAll refreshes every container from the DB memory quota (idempotent, no restart) - cli: `vps config set incus.swap_ratio N` re-applies to all containers automatically; `vps swap-reapply` is the manual/repair path for containers created before swap support - docs: configuration/architecture/README updated Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
1 parent
8188bd2dbf
commit
7b31f2d4ec
11 files changed
+211
-12
No files matched your search
@@ -64,12 +64,15 @@ vps del <name>
|
||||
vps panel-url
|
||||
vps admin-passwd
|
||||
vps config list|set|help
|
||||
vps swap-reapply
|
||||
```
|
||||
|
||||
The default allocation is 1 CPU core, 1 GB of RAM, and 10 GB of disk. CPU values may be whole cores or a fraction from `0.1` to `0.9`; disk capacity can only be increased. Generated passwords are shown once.
|
||||
|
||||
Bandwidth quotas are monthly GiB totals for upload and download combined. Containers over quota are rate-limited to 1 Mbps in both directions without a restart.
|
||||
|
||||
Container swap is controlled by `incus.swap_ratio` (default 0.5 — a 1 GiB memory container may use up to 512 MiB of host swap). Setting the value applies it to all existing containers immediately (no restart); `vps swap-reapply` is available for a manual refresh when needed.
|
||||
|
||||
Users can define an init script in the panel. It runs as root inside the container after reinstall, with output written to `/var/log/vpsmgr-init.log`.
|
||||
|
||||
## Additional Images
|
||||
|
||||
@@ -64,12 +64,15 @@ vps del <name>
|
||||
vps panel-url
|
||||
vps admin-passwd
|
||||
vps config list|set|help
|
||||
vps swap-reapply
|
||||
```
|
||||
|
||||
默认配额为 1 核、1 GB 内存和 10 GB 磁盘。CPU 支持整数核心,或 `0.1` 到 `0.9` 的小数核心;磁盘只能扩容。密码只显示一次。
|
||||
|
||||
月度流量配额单位为 GiB,统计上传和下载总量。超额后容器双向限速为 1 Mbps,无需重启。
|
||||
|
||||
容器 swap 由 `incus.swap_ratio` 控制(默认 0.5,即 1 GiB 内存容器最多用 512 MiB 宿主 swap)。设置该配置后立即应用到所有容器(无需重启);`vps swap-reapply` 可在需要时手动重刷。
|
||||
|
||||
用户可以在面板设置初始化脚本。重装后脚本会在容器内以 root 运行,日志位于 `/var/log/vpsmgr-init.log`。
|
||||
|
||||
## 额外镜像
|
||||
|
||||
@@ -115,6 +115,18 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
|
||||
- Quotas: CPU (whole cores ≥ 1, or a fraction 0.1..0.9 of one core), memory
|
||||
(MiB), disk (GiB). Disk maps onto the ZFS quota and can only grow, never
|
||||
shrink.
|
||||
- **Container swap**: Incus 7 on cgroup v2 writes `memory.swap.max=0` for every
|
||||
container unless `limits.memory.swap` carries an explicit byte amount
|
||||
(`true`/`false` both end up as 0), so without an explicit value containers
|
||||
cannot use the host's swap at all and `free` inside shows 0. vpsmgr sets
|
||||
`limits.memory.swap = limits.memory × incus.swap_ratio` (default 0.5) at
|
||||
create/reinstall and on quota changes. The allowance is a **per-container
|
||||
cap**, not a reservation — the host swap pool is shared, so the sum of all
|
||||
container allowances may over-subscribe a small host swap (e.g. zram);
|
||||
lxcfs clamps the visible `SwapTotal` to the host's actual swap, so a
|
||||
container never sees more swap than physically exists. Setting
|
||||
`incus.swap_ratio` re-applies the allowance to every existing container
|
||||
immediately (no restart); `vps swap-reapply` does the same on demand.
|
||||
- **Bandwidth quota**: each user can carry a monthly bandwidth quota in GiB
|
||||
(`users.bandwidth_quota_gb`, 0 = unlimited; counts upload + download of the
|
||||
current month). The 60s bandwidth sampler enforces it: over-quota containers
|
||||
|
||||
@@ -40,6 +40,7 @@ same table; `vps config list` shows the live values with this annotation.
|
||||
| `incus.pool` | **fixed at install** | — | storage pool |
|
||||
| `incus.bridge` | **fixed at install** | — | managed bridge |
|
||||
| `incus.socket` | operator | restart panel | Incus daemon Unix socket |
|
||||
| `incus.swap_ratio` | operator | **applied immediately** | swap granted to each container as a multiple of its memory limit (`limits.memory.swap = limits.memory × ratio`); `0` disables container swap. Setting it re-applies the allowance to **all existing containers** (no restart) |
|
||||
| `snapshots.limit` | operator | restart panel | max snapshots a user may keep per container (`0` = default `1`) |
|
||||
|
||||
### How "fixed at install" is enforced
|
||||
@@ -103,6 +104,10 @@ incus:
|
||||
pool: vpsmgr
|
||||
bridge: incusbr0
|
||||
socket: "/var/lib/incus/unix.socket" # Incus daemon Unix socket (REST API)
|
||||
swap_ratio: 0.5 # swap per container as a multiple of its
|
||||
# memory limit (0 = no swap; 0.5 = a 1 GiB
|
||||
# container may use 512 MiB host swap);
|
||||
# applied to all containers immediately on set
|
||||
```
|
||||
|
||||
## Port scheme (fixed)
|
||||
|
||||
+15
-1
@@ -36,6 +36,15 @@ const (
|
||||
// talks to it directly (no `incus` process spawn per call).
|
||||
DefaultSocket = "/var/lib/incus/unix.socket"
|
||||
|
||||
// DefaultSwapRatio is the swap granted to each container as a multiple
|
||||
// of its memory limit (limits.memory.swap = limits.memory * ratio).
|
||||
// Incus 7 on cgroup v2 sets memory.swap.max to 0 unless limits.memory.swap
|
||||
// is given a byte amount, so without this every container gets zero swap.
|
||||
// 0.5 means a 1 GiB container may use up to 512 MiB of host swap — the
|
||||
// host swap pool is shared by all containers, so a ratio < 1 avoids
|
||||
// over-subscribing it (see docs/configuration.md).
|
||||
DefaultSwapRatio = 0.5
|
||||
|
||||
// Panel listen port: a FRESH install picks a random free port in
|
||||
// PanelPortMin..PanelPortMax (written to panel.listen). DefaultListen
|
||||
// (8443) is only a code-level fallback, never the fresh-install default.
|
||||
@@ -171,6 +180,11 @@ type IncusCfg struct {
|
||||
// default matches the Debian package install
|
||||
// (`/var/lib/incus/unix.socket`).
|
||||
Socket string `yaml:"socket,omitempty"`
|
||||
// SwapRatio is the swap allowed to each new container as a multiple of
|
||||
// its memory limit (limits.memory.swap = limits.memory * SwapRatio).
|
||||
// 0 disables container swap entirely; see DefaultSwapRatio.
|
||||
// Deliberately NOT omitempty: 0 must round-trip through the config.
|
||||
SwapRatio float64 `yaml:"swap_ratio"`
|
||||
}
|
||||
|
||||
// SnapshotsCfg controls per-container user snapshots.
|
||||
@@ -186,7 +200,7 @@ func Default() *Config {
|
||||
c := &Config{}
|
||||
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3}
|
||||
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true}
|
||||
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket}
|
||||
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio}
|
||||
c.Snapshots = SnapshotsCfg{Limit: 1}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -332,6 +332,19 @@ var Fields = []Field{
|
||||
"/var/lib/incus/unix.socket",
|
||||
getStr(func(c *Config) string { return c.Incus.Socket }),
|
||||
nonEmpty("incus.socket", func(c *Config, v string) { c.Incus.Socket = v })},
|
||||
{"incus.swap_ratio", KindOperator, ApplyImmediate,
|
||||
"swap granted to each new container as a multiple of its memory limit (0 = no swap; 0.5 = 1 GiB container gets 512 MiB swap) — applied to ALL containers immediately on set",
|
||||
"0.5",
|
||||
getStr(func(c *Config) string { return strconv.FormatFloat(c.Incus.SwapRatio, 'f', -1, 64) }),
|
||||
func(c *Config, v string) error {
|
||||
v = strings.TrimSpace(v)
|
||||
r, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil || r < 0 {
|
||||
return fmt.Errorf("incus.swap_ratio must be a non-negative number (0 disables container swap)")
|
||||
}
|
||||
c.Incus.SwapRatio = r
|
||||
return nil
|
||||
}},
|
||||
{"snapshots.limit", KindOperator, ApplyRestart,
|
||||
"max snapshots a user may keep per container (0 = default 1)",
|
||||
"1",
|
||||
|
||||
@@ -286,3 +286,28 @@ func TestNonEmptyOperators(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapRatioValidator(t *testing.T) {
|
||||
c := Default()
|
||||
if got := c.Incus.SwapRatio; got != DefaultSwapRatio {
|
||||
t.Fatalf("default swap ratio = %v, want %v", got, DefaultSwapRatio)
|
||||
}
|
||||
if err := FieldFor("incus.swap_ratio").Assign(c, "0.75"); err != nil {
|
||||
t.Errorf("valid 0.75 rejected: %v", err)
|
||||
}
|
||||
if c.Incus.SwapRatio != 0.75 {
|
||||
t.Errorf("after set 0.75, ratio = %v", c.Incus.SwapRatio)
|
||||
}
|
||||
// 0 must round-trip (disables container swap), not be treated as unset.
|
||||
if err := FieldFor("incus.swap_ratio").Assign(c, "0"); err != nil {
|
||||
t.Errorf("valid 0 rejected: %v", err)
|
||||
}
|
||||
if c.Incus.SwapRatio != 0 {
|
||||
t.Errorf("after set 0, ratio = %v", c.Incus.SwapRatio)
|
||||
}
|
||||
for _, bad := range []string{"-1", "abc", ""} {
|
||||
if err := FieldFor("incus.swap_ratio").Assign(c, bad); err == nil {
|
||||
t.Errorf("invalid %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-8
@@ -30,6 +30,13 @@ type Client struct {
|
||||
socket string
|
||||
http *http.Client
|
||||
|
||||
// swapRatio is the per-container swap allowance as a multiple of the
|
||||
// memory limit (limits.memory.swap = limits.memory * swapRatio).
|
||||
// Incus 7 on cgroup v2 writes memory.swap.max=0 unless limits.memory.swap
|
||||
// carries a byte amount, so 0 disables container swap entirely and any
|
||||
// positive value grants that much host swap on top of limits.memory.
|
||||
swapRatio float64
|
||||
|
||||
// devLocks serialize device-map read-modify-write PATCHes per instance.
|
||||
// SetDisk, EnsureEth0Options and EnsureNicRateLimit each read the full
|
||||
// devices map and PATCH it back as a whole; without a per-instance lock
|
||||
@@ -41,8 +48,10 @@ type Client struct {
|
||||
}
|
||||
|
||||
// New creates a client for the Incus Unix socket at path. The connection is
|
||||
// lazy: nothing dials the socket until the first request.
|
||||
func New(socket string) *Client {
|
||||
// lazy: nothing dials the socket until the first request. swapRatio is the
|
||||
// per-container swap allowance as a multiple of the memory limit (0 disables
|
||||
// container swap; see cfg.DefaultSwapRatio).
|
||||
func New(socket string, swapRatio float64) *Client {
|
||||
t := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
@@ -51,10 +60,11 @@ func New(socket string) *Client {
|
||||
// Per-request cap guards against a hung daemon. The operation wait uses
|
||||
// timeout=30 server-side, so 60s covers it with margin.
|
||||
return &Client{
|
||||
base: "http://unix",
|
||||
socket: socket,
|
||||
http: &http.Client{Transport: t, Timeout: 60 * time.Second},
|
||||
devLocks: map[string]*sync.Mutex{},
|
||||
base: "http://unix",
|
||||
socket: socket,
|
||||
http: &http.Client{Transport: t, Timeout: 60 * time.Second},
|
||||
swapRatio: swapRatio,
|
||||
devLocks: map[string]*sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +479,18 @@ func (c *Client) Restart(name string) error {
|
||||
// now — no config knob; the admin panel renders it as "<used> / 4096".
|
||||
const DefaultProcessesLimit = "4096"
|
||||
|
||||
// swapSizeBytes converts a memory limit in MiB and a swap ratio (multiple of
|
||||
// the memory limit) into the limits.memory.swap value: the swap allowance in
|
||||
// MiB, or "" when the ratio is <= 0 (no swap). Incus 7 on cgroup v2 writes
|
||||
// memory.swap.max=0 unless limits.memory.swap carries a byte amount, so we
|
||||
// must set an explicit byte value to enable swap at all.
|
||||
func swapSizeBytes(memMB int, ratio float64) string {
|
||||
if ratio <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.0fMiB", float64(memMB)*ratio)
|
||||
}
|
||||
|
||||
// cpuLimitConfig maps a CPU quota in tenths of a core onto Incus config keys.
|
||||
// Whole cores set `limits.cpu=<n>`. Fractional quotas (0.1..0.9) pin the
|
||||
// container to a single core and add a time allowance
|
||||
@@ -503,9 +525,25 @@ func (c *Client) SetAutostart(name string, on bool) error {
|
||||
return c.patch("/1.0/instances/"+url.PathEscape(name), body, nil)
|
||||
}
|
||||
|
||||
// SetMem live-updates the memory limit.
|
||||
// SetMem live-updates the memory limit and keeps the swap allowance
|
||||
// proportional (limits.memory.swap = limits.memory * c.swapRatio).
|
||||
func (c *Client) SetMem(name string, mb int) error {
|
||||
body := map[string]map[string]string{"config": {"limits.memory": strconv.Itoa(mb) + "MiB"}}
|
||||
body := map[string]map[string]string{"config": {
|
||||
"limits.memory": strconv.Itoa(mb) + "MiB",
|
||||
"limits.memory.swap": swapSizeBytes(mb, c.swapRatio),
|
||||
}}
|
||||
return c.patch("/1.0/instances/"+url.PathEscape(name), body, nil)
|
||||
}
|
||||
|
||||
// SetSwap applies only the swap allowance for an existing container from its
|
||||
// current memory limit (limits.memory.swap = limits.memory * c.swapRatio),
|
||||
// leaving the memory limit untouched. Used to refresh containers created
|
||||
// before a swap_ratio change or before swap support existed. The swap key is
|
||||
// removed entirely when the ratio is <= 0 (PATCH merges; empty deletes).
|
||||
func (c *Client) SetSwap(name string, memMB int) error {
|
||||
body := map[string]map[string]string{"config": {
|
||||
"limits.memory.swap": swapSizeBytes(memMB, c.swapRatio),
|
||||
}}
|
||||
return c.patch("/1.0/instances/"+url.PathEscape(name), body, nil)
|
||||
}
|
||||
|
||||
@@ -876,6 +914,7 @@ func (c *Client) Launch(pool, bridge, name, image, ip, ipv6, block, poolIPv6, ex
|
||||
}
|
||||
config := cpuLimitConfig(cpu)
|
||||
config["limits.memory"] = strconv.Itoa(memMB) + "MiB"
|
||||
config["limits.memory.swap"] = swapSizeBytes(memMB, c.swapRatio)
|
||||
config["limits.processes"] = DefaultProcessesLimit
|
||||
config["boot.autostart"] = "true"
|
||||
config["security.nesting"] = "true"
|
||||
|
||||
+21
-1
@@ -67,7 +67,7 @@ type Manager struct {
|
||||
}
|
||||
|
||||
func New(c *cfg.Config, d *db.DB) *Manager {
|
||||
m := &Manager{cfg: c, db: d, lx: lx.New(c.Incus.Socket), fw: fw.New(c), tfx: tfx.New(c)}
|
||||
m := &Manager{cfg: c, db: d, lx: lx.New(c.Incus.Socket, c.Incus.SwapRatio), fw: fw.New(c), tfx: tfx.New(c)}
|
||||
m.RefreshHostStats()
|
||||
return m
|
||||
}
|
||||
@@ -1306,6 +1306,26 @@ func (m *Manager) HardenAll() error {
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// ApplySwapToAll refreshes the swap allowance (limits.memory.swap) of every
|
||||
// existing container from its current memory limit and the configured
|
||||
// incus.swap_ratio. Memory limits are untouched. Idempotent — safe to run
|
||||
// after every swap_ratio change, and the way containers created before swap
|
||||
// support get a swap allowance at all. Containers not present in Incus are
|
||||
// skipped; the first error encountered is returned (others are still tried).
|
||||
func (m *Manager) ApplySwapToAll() error {
|
||||
users, err := m.db.ListUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for _, u := range users {
|
||||
if err := m.lx.SetSwap(u.Name, u.MemMB); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// EnsureBlockRoutes adds the deterministic /112 block (ipv6.routes) to every
|
||||
// existing container's eth0, so an upgrade to the /112 scheme routes each
|
||||
// container's whole block. Idempotent; a container without IPv6 (or not in
|
||||
|
||||
@@ -71,3 +71,24 @@ func TestUpdateQuotasValidatesBeforeApplying(t *testing.T) {
|
||||
t.Errorf("quotas changed despite validation failure: cpu=%d mem=%d disk=%d", u.CPU, u.MemMB, u.DiskGB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplySwapToAllPropagatesError ensures swap-reapply actually attempts an
|
||||
// Incus call for every DB user, so an unreachable daemon surfaces as an error
|
||||
// (and a silent no-op cannot be mistaken for success).
|
||||
func TestApplySwapToAllPropagatesError(t *testing.T) {
|
||||
c := cfg.Default()
|
||||
c.Incus.Socket = "/nonexistent/vpsmgr-test.sock"
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
if _, err := d.CreateUser("carol", "h", "10.115.0.4", 3, 30003, 10200, 10, 1024, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := New(c, d)
|
||||
|
||||
if err := m.ApplySwapToAll(); err == nil {
|
||||
t.Fatal("expected an error: Incus is unreachable")
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -142,6 +142,10 @@ func main() {
|
||||
// Re-attach IPv6 routes/proxy_ndp for all existing containers.
|
||||
// Run by the vps-ipv6.service boot unit and `vps install`.
|
||||
err = cmdIPv6Reapply()
|
||||
case "swap-reapply":
|
||||
// Refresh limits.memory.swap on every existing container from the
|
||||
// configured incus.swap_ratio (idempotent).
|
||||
err = cmdSwapReapply()
|
||||
case "ip6":
|
||||
// Root helper behind the sudoers whitelist: validates every argument
|
||||
// before running the pinned ip -6 operations (see main_ip6.go).
|
||||
@@ -173,6 +177,7 @@ usage:
|
||||
vps del <name>
|
||||
vps panel-url print panel address
|
||||
vps config list|set|help inspect/change config.yaml (per-field validated edits)
|
||||
vps swap-reapply re-apply incus.swap_ratio to all containers (auto after config set)
|
||||
vps version
|
||||
system:
|
||||
vps install | serve | ipv6-reapply
|
||||
@@ -425,6 +430,25 @@ func cmdIPv6Reapply() error {
|
||||
return m.EnsureRoutedIPv6()
|
||||
}
|
||||
|
||||
// cmdSwapReapply refreshes limits.memory.swap on every existing container from
|
||||
// the configured incus.swap_ratio (memory limits untouched). Setting
|
||||
// incus.swap_ratio already does this automatically; this command is the manual
|
||||
// / repair path (e.g. after containers were created before swap support, or to
|
||||
// fix a container whose swap key drifted). Idempotent.
|
||||
func cmdSwapReapply() error {
|
||||
c, err := cfg.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
d, err := db.Open(c.Panel.DB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
m := mgr.New(c, d)
|
||||
return m.ApplySwapToAll()
|
||||
}
|
||||
|
||||
func writeUnit(name, content string) error {
|
||||
p := filepath.Join("/etc/systemd/system", name)
|
||||
tmp := p + ".tmp"
|
||||
@@ -862,6 +886,23 @@ func configSet(args []string) error {
|
||||
case cfg.ApplyNextAdd:
|
||||
fmt.Printf("%s saved. Applies on the next vps add / reinstall.\n", key)
|
||||
case cfg.ApplyImmediate:
|
||||
if key == "incus.swap_ratio" {
|
||||
// The panel daemon reads the config at startup, but swap-reapply
|
||||
// needs the live config (the ratio just saved) plus the DB. It
|
||||
// refreshes limits.memory.swap on every container from the new
|
||||
// ratio — no restart needed (live update).
|
||||
d, err := db.Open(c.Panel.DB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config saved, but swap refresh failed: %w", err)
|
||||
}
|
||||
defer d.Close()
|
||||
m := mgr.New(c, d)
|
||||
if err := m.ApplySwapToAll(); err != nil {
|
||||
return fmt.Errorf("config saved, but applying it to containers failed: %w", err)
|
||||
}
|
||||
fmt.Printf("%s updated and applied to all containers.\n", key)
|
||||
return nil
|
||||
}
|
||||
if err := applyV4State(c); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -928,9 +969,12 @@ func confirmApply(c *cfg.Config, f *cfg.Field, key string) (bool, error) {
|
||||
case cfg.ApplyInstall:
|
||||
what = "re-runs `vps install`, regenerating the firewall / routing / container wiring"
|
||||
case cfg.ApplyImmediate:
|
||||
if key == "net.v4_forward" {
|
||||
switch key {
|
||||
case "net.v4_forward":
|
||||
what = "toggles container IPv4 inbound immediately — SSH / port / domain reachability of every container changes"
|
||||
} else {
|
||||
case "incus.swap_ratio":
|
||||
what = "re-applies the swap allowance of every existing container (no restart)"
|
||||
default:
|
||||
what = "applies immediately"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user