diff --git a/docs/architecture.md b/docs/architecture.md
index 0ddec07..2c53a33 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -261,15 +261,16 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
reinstalled is treated as a plain restore, since cloning-then-deleting would
destroy the snapshot first.
- The whole feature is behind an **admin toggle** (`snapshot_share_enabled`,
- shown on the admin overview; absent = **enabled**, so an upgraded host gets it
- on). Turning it off hides the user-side share/import entry points and makes
- the manager refuse new shares and installs, but deliberately keeps the stored
- codes and leaves every container and checkpoint alone: re-enabling restores
- the codes for as long as their checkpoint still exists. The code→checkpoint
- mapping is only ever removed when the checkpoint itself is deleted (from the
- panel) or the container goes away, and a code whose checkpoint has vanished
- is reported as invalid instead of half-building a container.
+ The whole feature is behind the `snapshots.share` config setting (`vps config
+ set snapshots.share false`; shown read-only on the admin overview; absent =
+ **enabled**, so an upgraded host gets it on). Turning it off hides the
+ user-side share/import entry points and makes the manager refuse new shares
+ and installs, but deliberately keeps the stored codes and leaves every
+ container and checkpoint alone: re-enabling restores the codes for as long as
+ their checkpoint still exists. The code→checkpoint mapping is only ever
+ removed when the checkpoint itself is deleted (from the panel) or the
+ container goes away, and a code whose checkpoint has vanished is reported as
+ invalid instead of half-building a container.
### User groups (multi-container users)
diff --git a/docs/configuration.md b/docs/configuration.md
index db10955..f013ddc 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -53,6 +53,13 @@ same table; `vps config list` shows the live values with this annotation.
| `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 checkpoints a user may keep per container (`0` = disable new snapshots). Restoring to an older checkpoint auto-deletes the ones created after it (see below) |
+| `snapshots.share` | operator | **applied immediately** | allow users to share a checkpoint behind a code and install from it (`true` by default). Turning it off hides the user-side entry points and refuses new shares/installs; stored codes are kept, so re-enabling restores them while the checkpoint exists |
+| `cpu_limit.enabled` | operator | **applied immediately** | global dynamic CPU limit (default `false`). With it on, a container that stays over `cpu_limit.percent` of its own quota for `cpu_limit.window_minutes` in a row is capped to `cpu_limit.cores` for the configured duration |
+| `cpu_limit.window_minutes` | operator | **applied immediately** | consecutive minutes over the threshold before the cap applies (`>= 1`) |
+| `cpu_limit.percent` | operator | **applied immediately** | percent of the container's **own** quota that counts as over (`1`-`100`) |
+| `cpu_limit.cores` | operator | **applied immediately** | core count the container is capped to (`0.1`-`1.0`, one-decimal time slice) |
+| `cpu_limit.duration_hours` | operator | **applied immediately** | hours the cap lasts once applied (`>= 0`) |
+| `cpu_limit.duration_minutes` | operator | **applied immediately** | additional minutes the cap lasts (`0`-`59`) |
### How "fixed at install" is enforced
@@ -124,6 +131,16 @@ incus:
snapshots:
limit: 1 # 0 disables new snapshots
+ share: true # allow checkpoint share codes (install
+ # from someone's checkpoint)
+
+cpu_limit:
+ enabled: false # global dynamic CPU cap (default off)
+ window_minutes: 10 # over the threshold this many minutes in a row
+ percent: 60 # % of the container's OWN quota
+ cores: 0.5 # cap it to this many cores (0.1..1.0)
+ duration_hours: 2 # how long the cap lasts
+ duration_minutes: 30
```
## Port scheme
diff --git a/src/internal/admin/admin.go b/src/internal/admin/admin.go
index de38089..4e79210 100644
--- a/src/internal/admin/admin.go
+++ b/src/internal/admin/admin.go
@@ -77,8 +77,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/power", s.requireAuth(s.requirePost(s.handlePower)))
mux.HandleFunc("/reset-panel-pass", s.requireAuth(s.requirePost(s.handleResetPanelPass)))
mux.HandleFunc("/admin-pass", s.requireAuth(s.requirePost(s.handleAdminPass)))
- mux.HandleFunc("/cpu-limit", s.requireAuth(s.requirePost(s.handleCPULimitRule)))
- mux.HandleFunc("/share-toggle", s.requireAuth(s.requirePost(s.handleShareToggle)))
mux.HandleFunc("/keys", s.requireAuth(s.requirePost(s.handleAdminKeys)))
mux.HandleFunc("/login-as", s.requireAuth(s.requirePost(s.handleLoginAs)))
mux.HandleFunc("/flash", s.requireAuth(s.requirePost(s.handleFlash)))
diff --git a/src/internal/admin/handlers.go b/src/internal/admin/handlers.go
index ad0dd7f..16b808b 100644
--- a/src/internal/admin/handlers.go
+++ b/src/internal/admin/handlers.go
@@ -50,8 +50,9 @@ type pageData struct {
PoolTotal int
// AdminKeys is the operator's own SSH-key store (management panel only).
AdminKeys []sshKeyRow
- // Global dynamic CPU limit rule, prefilled into the overview form. The
- // Active list is the containers currently capped by it.
+ // Global dynamic CPU limit rule, shown READ-ONLY (changed via
+ // `vps config set cpu_limit.*`). The Active list is the containers
+ // currently capped by it.
CPULimitEnabled bool
CPULimitMinutes int
CPULimitPercent int
@@ -59,9 +60,6 @@ type pageData struct {
CPULimitHours int
CPULimitDurMin int
CPULimitActive []cpuLimitRow
- // ShareEnabled is the snapshot-sharing toggle (admin-managed, default on
- // after an upgrade).
- ShareEnabled bool
}
// cpuLimitRow is one container currently under the dynamic CPU limit, shown in
@@ -167,15 +165,13 @@ func (s *Server) buildPageData(msg, errMsg string) pageData {
d.AdminKeys = append(d.AdminKeys, sshKeyRow{ID: k.ID, Name: k.Name, Key: k.Key, Active: k.Active})
}
}
- if rule, err := s.mgr.CPULimitRule(); err == nil {
- d.CPULimitEnabled = rule.Enabled
- d.CPULimitMinutes = rule.WindowMinutes
- d.CPULimitPercent = rule.Percent
- d.CPULimitCores = mgr.FormatCPU(rule.CoresX10)
- d.CPULimitHours = rule.DurationSeconds / 3600
- d.CPULimitDurMin = (rule.DurationSeconds % 3600) / 60
- }
- d.ShareEnabled = s.mgr.SnapshotShareEnabled()
+ rule := s.mgr.CPULimitRule()
+ d.CPULimitEnabled = rule.Enabled
+ d.CPULimitMinutes = rule.WindowMinutes
+ d.CPULimitPercent = rule.Percent
+ d.CPULimitCores = mgr.FormatCPU(rule.CoresX10)
+ d.CPULimitHours = rule.DurationSeconds / 3600
+ d.CPULimitDurMin = (rule.DurationSeconds % 3600) / 60
nowUnix := time.Now().Unix()
for name, st := range s.mgr.CPULimits() {
if st.Until <= nowUnix {
@@ -655,81 +651,6 @@ func (s *Server) handleAdminPass(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p(""), s.t(r, "admin_pass_changed"))
}
-// handleCPULimitRule saves the global dynamic CPU limit rule and applies it
-// immediately: a disabled rule restores every capped container right away
-// instead of waiting for the next 60s pass.
-func (s *Server) handleCPULimitRule(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), 400)
- return
- }
- enabled := r.FormValue("enabled") != ""
- minutes, err := strconv.Atoi(strings.TrimSpace(r.FormValue("window_minutes")))
- if err != nil || minutes < 1 {
- s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_window"))
- return
- }
- percent, err := strconv.Atoi(strings.TrimSpace(r.FormValue("percent")))
- if err != nil || percent < 1 || percent > 100 {
- s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_percent"))
- return
- }
- cores, err := mgr.ParseLimitCores(r.FormValue("cores"))
- if err != nil {
- s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_cores"))
- return
- }
- hours, err := strconv.Atoi(strings.TrimSpace(r.FormValue("hours")))
- if err != nil || hours < 0 {
- s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_duration"))
- return
- }
- durMin, err := strconv.Atoi(strings.TrimSpace(r.FormValue("dur_minutes")))
- if err != nil || durMin < 0 || durMin > 59 {
- s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_duration"))
- return
- }
- duration := hours*3600 + durMin*60
- if enabled && duration <= 0 {
- s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_cpu_limit_duration"))
- return
- }
- rule := mgr.CPULimitRule{
- Enabled: enabled,
- WindowMinutes: minutes,
- Percent: percent,
- CoresX10: cores,
- DurationSeconds: duration,
- }
- if err := s.mgr.SetCPULimitRule(rule); err != nil {
- s.redirect(w, r, s.p(""), "error: "+err.Error())
- return
- }
- if err := s.mgr.EnforceCPULimits(); err != nil {
- s.redirect(w, r, s.p(""), "error: "+err.Error())
- return
- }
- _ = s.db.AddAuditLog("000", "cpu_limit.update")
- s.redirect(w, r, s.p(""), s.t(r, "cpu_limit_saved"))
-}
-
-// handleShareToggle turns the snapshot-sharing feature on or off. Disabling is
-// purely a visibility/authorisation gate: stored codes and existing containers
-// are left untouched, so re-enabling restores sharing while the checkpoint
-// still exists.
-func (s *Server) handleShareToggle(w http.ResponseWriter, r *http.Request) {
- if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), 400)
- return
- }
- if err := s.mgr.SetSnapshotShareEnabled(r.FormValue("enabled") != ""); err != nil {
- s.redirect(w, r, s.p(""), "error: "+err.Error())
- return
- }
- _ = s.db.AddAuditLog("000", "share.toggle")
- s.redirect(w, r, s.p(""), s.t(r, "share_toggled"))
-}
-
// handleLoginAs ("log in as user" / impersonation) creates a user-panel
// session for the given username and hands the browser its cookie, dropping the
// operator straight into that user's panel — regardless of the user's password.
diff --git a/src/internal/admin/i18n.go b/src/internal/admin/i18n.go
index 4661955..5350847 100644
--- a/src/internal/admin/i18n.go
+++ b/src/internal/admin/i18n.go
@@ -79,35 +79,29 @@ func (s *Server) t(r *http.Request, key string, args ...any) string {
// stable identifiers; args are %-formatted into the chosen language's template.
func tr(l, key string, args ...any) string {
m := map[string][2]string{ // key -> [zh, en]
- "err_bad_login": {"管理员密码错误", "invalid admin password"},
- "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"},
- "err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"},
- "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"},
- "err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"},
- "err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"},
- "user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"},
- "user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"},
- "quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"},
- "bandwidth_reset": {"已重置 %[1]v 的流量统计(若曾限速则已解除)", "bandwidth reset for %[1]v (throttle lifted if active)"},
- "power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"},
- "new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"},
- "admin_pass_changed": {"管理员密码已修改", "admin password changed"},
- "err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"},
- "domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"},
- "domains_updated": {"域名设置已保存", "domain settings saved"},
- "blocked_saved": {"封禁域名列表已保存", "blocked-domains list saved"},
- "blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"},
- "pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"},
- "pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"},
- "color_updated": {"已设置 %[1]v 的专属颜色", "color set for %[1]v"},
- "color_reset": {"已清除 %[1]v 的专属颜色", "color cleared for %[1]v"},
- "err_invalid_color": {"颜色不在可选范围内", "color is not in the allowed palette"},
- "cpu_limit_saved": {"CPU 动态限制规则已保存", "dynamic CPU limit rule saved"},
- "share_toggled": {"快照分享设置已保存", "snapshot sharing setting saved"},
- "err_cpu_limit_window": {"连续时长必须是 ≥1 的整数分钟", "window must be an integer >= 1 minute"},
- "err_cpu_limit_percent": {"百分比必须是 1..100 的整数", "percent must be an integer between 1 and 100"},
- "err_cpu_limit_cores": {"限制核心数必须是 0.1..1.0(步长 0.1)", "cores must be a one-decimal value in 0.1..1.0"},
- "err_cpu_limit_duration": {"时长必须有效:小时 ≥0,分钟 0..59,且启用时总时长 >0", "duration must be valid: hours >= 0, minutes 0..59, and > 0 when enabled"},
+ "err_bad_login": {"管理员密码错误", "invalid admin password"},
+ "err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"},
+ "err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"},
+ "err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"},
+ "err_pass_short": {"密码长度不得低于 10 位", "password must be at least 10 characters"},
+ "err_pass_weak": {"密码需同时包含大写字母、小写字母和数字", "password must contain uppercase letters, lowercase letters and digits"},
+ "user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"},
+ "user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"},
+ "quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"},
+ "bandwidth_reset": {"已重置 %[1]v 的流量统计(若曾限速则已解除)", "bandwidth reset for %[1]v (throttle lifted if active)"},
+ "power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"},
+ "new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"},
+ "admin_pass_changed": {"管理员密码已修改", "admin password changed"},
+ "err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"},
+ "domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"},
+ "domains_updated": {"域名设置已保存", "domain settings saved"},
+ "blocked_saved": {"封禁域名列表已保存", "blocked-domains list saved"},
+ "blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"},
+ "pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"},
+ "pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"},
+ "color_updated": {"已设置 %[1]v 的专属颜色", "color set for %[1]v"},
+ "color_reset": {"已清除 %[1]v 的专属颜色", "color cleared for %[1]v"},
+ "err_invalid_color": {"颜色不在可选范围内", "color is not in the allowed palette"},
}
pair, ok := m[key]
if !ok {
diff --git a/src/internal/admin/templates/admin_overview.html b/src/internal/admin/templates/admin_overview.html
index 990c5ca..9b9f871 100644
--- a/src/internal/admin/templates/admin_overview.html
+++ b/src/internal/admin/templates/admin_overview.html
@@ -98,7 +98,6 @@ form.inline[hidden] { display: none; }
.field small.hint-bad { color: #b91c1c; }
@media (prefers-color-scheme: dark) { .field small.hint-bad { color: #f87171; } }
#adminPassForm .pwcheck { grid-column: 1 / -1; margin: -2px 0 0; }
-#cpuLimitForm .cpu-limit-off input[type="number"] { opacity: .5; }
.pwcheck { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12px; color: #6b7280; }
@media (prefers-color-scheme: dark) { .pwcheck { color: #9aa0a8; } }
.pwcheck li { display: inline-block; margin: 2px 14px 2px 0; }
@@ -309,22 +308,13 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
-
{{if eq .Lang "zh"}}CPU 动态限制规则(全局){{else}}Dynamic CPU limit rule (global){{end}}
-
-
{{if eq .Lang "zh"}}容器连续 x 分钟占用其配额超过 y% 时,限制到 z 核,持续 n 小时 m 分钟后自动恢复原配额。配额本身不高于 z 的容器会被跳过。{{else}}When a container stays above y% of its quota for x minutes, it is capped to z cores for n hours m minutes, then its quota is restored. Containers whose own quota is at or below z are skipped.{{end}}
+
{{if eq .Lang "zh"}}CPU 动态限制规则(全局,只读){{else}}Dynamic CPU limit rule (global, read-only){{end}}
+ {{if .CPULimitEnabled}}
+
{{if eq .Lang "zh"}}容器连续 {{.CPULimitMinutes}} 分钟占用其配额超过 {{.CPULimitPercent}}% 时,限制到 {{.CPULimitCores}} 核,持续 {{.CPULimitHours}} 小时 {{.CPULimitDurMin}} 分钟后自动恢复原配额。配额本身不高于该核数的容器会被跳过。{{else}}When a container stays above {{.CPULimitPercent}}% of its quota for {{.CPULimitMinutes}} minutes, it is capped to {{.CPULimitCores}} core(s) for {{.CPULimitHours}}h {{.CPULimitDurMin}}m, then its quota is restored. Containers whose own quota is at or below that are skipped.{{end}}
+ {{else}}
+
{{if eq .Lang "zh"}}未启用(默认:不限制)。{{else}}Disabled (default: no limit).{{end}}
+ {{end}}
+
{{if eq .Lang "zh"}}用 vps config set cpu_limit.* 修改(vps config help 查看各项)。面板不提供编辑入口。{{else}}Change it with vps config set cpu_limit.* (see vps config help); there is no editor in this panel.{{end}}
{{if .CPULimitActive}}
{{if eq .Lang "zh"}}当前被限制容器{{else}}Currently limited containers{{end}}({{len .CPULimitActive}})
@@ -336,20 +326,6 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
{{end}}
-
-
-
{{if eq .Lang "zh"}}快照分享{{else}}Snapshot sharing{{end}}
-
-
{{if eq .Lang "zh"}}默认开启。关闭后用户端不再显示分享/导入入口;已存在的分享码保留在数据库中(重新开启后仍可使用,前提是检查点还在),已有容器与检查点不受影响。{{else}}On by default. When off, the user panels hide the share/import entry points; stored codes are kept in the database (they work again after re-enabling, as long as the checkpoint still exists) and existing containers and checkpoints are unaffected.{{end}}
-
@@ -712,22 +688,6 @@ var child = low.match(/^([a-z][a-z0-9-]*[a-z])-([0-9]+)$/);
btn.disabled = true; btn.classList.add('busy');
});
})();
-// Dynamic CPU limit rule: dim the parameters while the rule is disabled (they
-// stay enabled so their values are always submitted), and show a busy state on
-// save.
-(function(){
- var f = document.getElementById('cpuLimitForm');
- if (!f) return;
- var en = document.getElementById('cpuLimitEnabled');
- function sync(){ f.classList.toggle('cpu-limit-off', !(en && en.checked)); }
- if (en) en.addEventListener('change', sync);
- sync();
- var btn = document.getElementById('cpuLimitBtn');
- f.addEventListener('submit', function(){
- if (!f.checkValidity()) return;
- if (btn) { btn.disabled = true; btn.classList.add('busy'); }
- });
-})();
// Quota update can take 10s+ (live LXD config changes); show a spinner on the
// Save button while the request is in flight so the modal does not look dead.
(function(){
diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go
index 523d786..b92c63d 100644
--- a/src/internal/cfg/cfg.go
+++ b/src/internal/cfg/cfg.go
@@ -107,6 +107,7 @@ type Config struct {
Net NetCfg `yaml:"net"`
Incus IncusCfg `yaml:"incus"`
Snapshots SnapshotsCfg `yaml:"snapshots"`
+ CPULimit CPULimitCfg `yaml:"cpu_limit"`
}
type PanelCfg struct {
@@ -239,6 +240,27 @@ type SnapshotsCfg struct {
// new ones, but existing snapshots are left untouched. Negative is treated
// as 0 (the config validator rejects negatives anyway).
Limit int `yaml:"limit"`
+ // Share enables snapshot sharing: a user may publish a checkpoint behind a
+ // random code and another user may install a container by cloning it.
+ // Default true (an upgraded host gets it on). Disabling hides the user-side
+ // entry points and refuses new shares/installs, but keeps the stored codes
+ // and leaves every container and checkpoint untouched. See
+ // `vps config set snapshots.share false`.
+ Share bool `yaml:"share"`
+}
+
+// CPULimitCfg is the global dynamic CPU limit rule: when a container keeps
+// using over Percent of its own CPU quota for WindowMinutes in a row, it is
+// capped to Cores (a time slice, like a fractional quota) for the configured
+// duration. Managed with `vps config set cpu_limit.*` (the admin panel shows
+// the rule read-only).
+type CPULimitCfg struct {
+ Enabled bool `yaml:"enabled"`
+ WindowMinutes int `yaml:"window_minutes"`
+ Percent int `yaml:"percent"`
+ Cores float64 `yaml:"cores"`
+ DurationHours int `yaml:"duration_hours"`
+ DurationMinutes int `yaml:"duration_minutes"`
}
func Default() *Config {
@@ -246,7 +268,8 @@ func Default() *Config {
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1}
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Traefik: true, UserPorts: DefaultUserPorts}
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio}
- c.Snapshots = SnapshotsCfg{Limit: 1}
+ c.Snapshots = SnapshotsCfg{Limit: 1, Share: true}
+ c.CPULimit = CPULimitCfg{Enabled: false, WindowMinutes: 10, Percent: 60, Cores: 0.5, DurationHours: 2, DurationMinutes: 30}
return c
}
diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go
index dc2c576..01269b6 100644
--- a/src/internal/cfg/registry.go
+++ b/src/internal/cfg/registry.go
@@ -410,6 +410,100 @@ var Fields = []Field{
c.Snapshots.Limit = n
return nil
}},
+ {"snapshots.share", KindOperator, ApplyImmediate,
+ "allow users to share a checkpoint behind a code and install a container from it (default true); stored codes are kept when turned off",
+ "true or false",
+ getStr(func(c *Config) string { return strconv.FormatBool(c.Snapshots.Share) }),
+ func(c *Config, v string) error {
+ b, ok := parseBool(v)
+ if !ok {
+ return fmt.Errorf("snapshots.share must be true/false or 1/0")
+ }
+ c.Snapshots.Share = b
+ return nil
+ }},
+ {"cpu_limit.enabled", KindOperator, ApplyImmediate,
+ "global dynamic CPU limit: cap a container to cpu_limit.cores when it stays over cpu_limit.percent of its quota for cpu_limit.window_minutes in a row",
+ "true or false",
+ getStr(func(c *Config) string { return strconv.FormatBool(c.CPULimit.Enabled) }),
+ func(c *Config, v string) error {
+ b, ok := parseBool(v)
+ if !ok {
+ return fmt.Errorf("cpu_limit.enabled must be true/false or 1/0")
+ }
+ if b && c.CPULimit.DurationHours*3600+c.CPULimit.DurationMinutes*60 <= 0 {
+ return fmt.Errorf("set cpu_limit.duration_hours/duration_minutes > 0 before enabling")
+ }
+ c.CPULimit.Enabled = b
+ return nil
+ }},
+ {"cpu_limit.window_minutes", KindOperator, ApplyImmediate,
+ "cpu_limit: consecutive minutes over the threshold before the limit applies (>= 1)",
+ "10",
+ getStr(func(c *Config) string { return strconv.Itoa(c.CPULimit.WindowMinutes) }),
+ func(c *Config, v string) error {
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil || n < 1 {
+ return fmt.Errorf("cpu_limit.window_minutes must be an integer >= 1")
+ }
+ c.CPULimit.WindowMinutes = n
+ return nil
+ }},
+ {"cpu_limit.percent", KindOperator, ApplyImmediate,
+ "cpu_limit: percent of the container's OWN quota that counts as \"over\" (1..100)",
+ "60",
+ getStr(func(c *Config) string { return strconv.Itoa(c.CPULimit.Percent) }),
+ func(c *Config, v string) error {
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil || n < 1 || n > 100 {
+ return fmt.Errorf("cpu_limit.percent must be an integer between 1 and 100")
+ }
+ c.CPULimit.Percent = n
+ return nil
+ }},
+ {"cpu_limit.cores", KindOperator, ApplyImmediate,
+ "cpu_limit: core count the container is capped to (0.1..1.0, one-decimal time slice)",
+ "0.5",
+ getStr(func(c *Config) string { return strconv.FormatFloat(c.CPULimit.Cores, 'f', -1, 64) }),
+ func(c *Config, v string) error {
+ f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
+ t := int(f*10 + 0.5)
+ if err != nil || t < 1 || t > 10 || f*10-float64(t) > 1e-9 || float64(t)-f*10 > 1e-9 {
+ return fmt.Errorf("cpu_limit.cores must be a one-decimal value in 0.1..1.0")
+ }
+ c.CPULimit.Cores = float64(t) / 10
+ return nil
+ }},
+ {"cpu_limit.duration_hours", KindOperator, ApplyImmediate,
+ "cpu_limit: how many hours the cap lasts once applied (>= 0)",
+ "2",
+ getStr(func(c *Config) string { return strconv.Itoa(c.CPULimit.DurationHours) }),
+ func(c *Config, v string) error {
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil || n < 0 {
+ return fmt.Errorf("cpu_limit.duration_hours must be an integer >= 0")
+ }
+ if c.CPULimit.Enabled && n*3600+c.CPULimit.DurationMinutes*60 <= 0 {
+ return fmt.Errorf("the duration must be > 0 while cpu_limit.enabled is true")
+ }
+ c.CPULimit.DurationHours = n
+ return nil
+ }},
+ {"cpu_limit.duration_minutes", KindOperator, ApplyImmediate,
+ "cpu_limit: additional minutes the cap lasts (0..59)",
+ "30",
+ getStr(func(c *Config) string { return strconv.Itoa(c.CPULimit.DurationMinutes) }),
+ func(c *Config, v string) error {
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil || n < 0 || n > 59 {
+ return fmt.Errorf("cpu_limit.duration_minutes must be an integer between 0 and 59")
+ }
+ if c.CPULimit.Enabled && c.CPULimit.DurationHours*3600+n*60 <= 0 {
+ return fmt.Errorf("the duration must be > 0 while cpu_limit.enabled is true")
+ }
+ c.CPULimit.DurationMinutes = n
+ return nil
+ }},
}
var secretPathRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go
index 585badc..486eea8 100644
--- a/src/internal/cfg/registry_test.go
+++ b/src/internal/cfg/registry_test.go
@@ -162,6 +162,56 @@ func TestAdminPassHashManagedElsewhere(t *testing.T) {
}
}
+func TestSnapshotShareAndCPULimitFields(t *testing.T) {
+ c := Default()
+
+ // snapshots.share: boolean toggle, junk refused.
+ if err := FieldFor("snapshots.share").Assign(c, "false"); err != nil || c.Snapshots.Share {
+ t.Fatalf("snapshots.share=false: err=%v share=%v", err, c.Snapshots.Share)
+ }
+ if err := FieldFor("snapshots.share").Assign(c, "true"); err != nil || !c.Snapshots.Share {
+ t.Fatalf("snapshots.share=true: err=%v share=%v", err, c.Snapshots.Share)
+ }
+ if err := FieldFor("snapshots.share").Assign(c, "maybe"); err == nil {
+ t.Error("snapshots.share=maybe accepted")
+ }
+
+ // cpu_limit per-field ranges.
+ bad := []struct{ key, val string }{
+ {"cpu_limit.window_minutes", "0"},
+ {"cpu_limit.percent", "101"},
+ {"cpu_limit.percent", "0"},
+ {"cpu_limit.cores", "1.5"},
+ {"cpu_limit.cores", "0.05"},
+ {"cpu_limit.duration_minutes", "60"},
+ {"cpu_limit.duration_hours", "-1"},
+ }
+ for _, tc := range bad {
+ if err := FieldFor(tc.key).Assign(c, tc.val); err == nil {
+ t.Errorf("%s=%q accepted", tc.key, tc.val)
+ }
+ }
+ if err := FieldFor("cpu_limit.cores").Assign(c, "0.5"); err != nil {
+ t.Fatalf("cores=0.5: %v", err)
+ }
+ if c.CPULimit.Cores != 0.5 {
+ t.Errorf("cores = %v, want 0.5", c.CPULimit.Cores)
+ }
+
+ // Cross-field: enabling requires a positive duration; zeroing the duration
+ // while enabled is refused.
+ c2 := Default()
+ if err := FieldFor("cpu_limit.duration_minutes").Assign(c2, "0"); err != nil {
+ t.Fatalf("duration_minutes=0 (disabled): %v", err)
+ }
+ if err := FieldFor("cpu_limit.enabled").Assign(c2, "true"); err != nil {
+ t.Fatalf("enabling with hours=2: %v", err)
+ }
+ if err := FieldFor("cpu_limit.duration_hours").Assign(c2, "0"); err == nil {
+ t.Error("zeroing the duration while enabled accepted")
+ }
+}
+
func TestEditableClassification(t *testing.T) {
cases := map[string]string{
"panel.listen": "yes",
diff --git a/src/internal/db/settings.go b/src/internal/db/settings.go
index f5ba0ec..ea45537 100644
--- a/src/internal/db/settings.go
+++ b/src/internal/db/settings.go
@@ -39,22 +39,19 @@ const (
// subdomains are refused by AddDomain (admin-managed via the web UI).
SettingBlockedDomains = "blocked_domains"
- // SettingCPULimitRule is the admin-managed global dynamic CPU limit rule
- // (JSON), applied to every container. Absent = the rule is disabled.
+ // SettingCPULimitRule mirrors the `cpu_limit.*` config values so the
+ // long-running panel applies a `vps config set cpu_limit.…` immediately
+ // (its in-memory config is only loaded at startup). The config file stays
+ // authoritative; this is the live channel, refreshed on set and on install.
SettingCPULimitRule = "cpu_limit_rule"
// SettingCPULimitActive is the JSON map of containers currently under a
- // dynamic CPU limit (name -> {until, cores_x10}). Persisted so the limit
- // survives a panel restart and the countdown stays accurate.
+ // dynamic CPU limit (name -> {until, cores_x10}). Persisted so an active
+ // limit survives a panel restart and the countdown stays accurate.
SettingCPULimitActive = "cpu_limit_active"
- // SettingSnapshotShareEnabled toggles the snapshot-sharing feature (publish
- // a checkpoint behind a code, install from someone's checkpoint). Absent
- // means ENABLED, so a host upgraded to this version gets the feature on by
- // default; the admin can turn it off. Disabling hides the user-side UI and
- // refuses new shares/installs, but the stored codes are deliberately kept
- // (not deleted) so re-enabling restores them while their checkpoint still
- // exists. Stored as "1"/"0".
+ // SettingSnapshotShareEnabled mirrors `snapshots.share` so the panel sees a
+ // `vps config set snapshots.share` without a restart. Absent = enabled.
SettingSnapshotShareEnabled = "snapshot_share_enabled"
)
@@ -100,20 +97,13 @@ func (d *DB) SetBlockedDomains(list []string) error {
return d.SetSetting(SettingBlockedDomains, strings.Join(list, "\n"))
}
-// SnapshotShareEnabled reports whether snapshot sharing is on. An absent key
-// means enabled — the feature's default after an upgrade.
-func (d *DB) SnapshotShareEnabled() (bool, error) {
- v, ok, err := d.GetSetting(SettingSnapshotShareEnabled)
- if err != nil {
- return false, err
- }
- if !ok || v == "" {
- return true, nil
- }
- return v != "0" && v != "false", nil
+// SnapshotShareEnabled reports the mirrored snapshot-sharing toggle. An absent
+// key means unset (the caller falls back to the config file).
+func (d *DB) SnapshotShareEnabled() (value string, ok bool, err error) {
+ return d.GetSetting(SettingSnapshotShareEnabled)
}
-// SetSnapshotShareEnabled turns snapshot sharing on or off (stored as "1"/"0").
+// SetSnapshotShareEnabled writes the mirror (stored as "1"/"0").
func (d *DB) SetSnapshotShareEnabled(on bool) error {
v := "0"
if on {
diff --git a/src/internal/db/snapshot_share_test.go b/src/internal/db/snapshot_share_test.go
index 053ad6d..33f51cb 100644
--- a/src/internal/db/snapshot_share_test.go
+++ b/src/internal/db/snapshot_share_test.go
@@ -59,27 +59,27 @@ func TestSnapshotShareStore(t *testing.T) {
}
}
-func TestSnapshotShareEnabledSetting(t *testing.T) {
- d, err := Open(t.TempDir() + "/share-flag.db")
+func TestSnapshotShareMirror(t *testing.T) {
+ d, err := Open(t.TempDir() + "/share-mirror.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
- // Absent key means enabled: the feature is on by default after an upgrade.
- if on, err := d.SnapshotShareEnabled(); err != nil || !on {
- t.Fatalf("default = %v, err=%v, want true", on, err)
+ // Unset: ok=false so the caller falls back to the config file.
+ if _, ok, err := d.SnapshotShareEnabled(); err != nil || ok {
+ t.Fatalf("unset mirror: ok=%v err=%v", ok, err)
}
if err := d.SetSnapshotShareEnabled(false); err != nil {
t.Fatal(err)
}
- if on, _ := d.SnapshotShareEnabled(); on {
- t.Fatal("still enabled after disabling")
+ if v, ok, _ := d.SnapshotShareEnabled(); !ok || v != "0" {
+ t.Fatalf("mirror after disable = %q, ok=%v", v, ok)
}
if err := d.SetSnapshotShareEnabled(true); err != nil {
t.Fatal(err)
}
- if on, _ := d.SnapshotShareEnabled(); !on {
- t.Fatal("not enabled after re-enabling")
+ if v, ok, _ := d.SnapshotShareEnabled(); !ok || v != "1" {
+ t.Fatalf("mirror after enable = %q, ok=%v", v, ok)
}
}
diff --git a/src/internal/mgr/cpu.go b/src/internal/mgr/cpu.go
index a2904a2..eab1b38 100644
--- a/src/internal/mgr/cpu.go
+++ b/src/internal/mgr/cpu.go
@@ -63,23 +63,3 @@ func FormatCPU(tenths int) string {
}
return "0." + strconv.Itoa(tenths)
}
-
-// ParseLimitCores parses the dynamic CPU limit target z into tenths of a core.
-// Unlike ParseCPU it is capped at one core: z is a time-slice limit, so it
-// accepts any one-decimal value in 0.1..1.0 (e.g. "0.1", "0.5", "1", "1.0"),
-// yielding 1..10 tenths.
-func ParseLimitCores(s string) (int, error) {
- s = strings.TrimSpace(s)
- if s == "" {
- return 0, errors.New("cores: empty input")
- }
- f, err := strconv.ParseFloat(s, 64)
- if err != nil {
- return 0, errors.New("cores must be a value in 0.1..1.0 (one-decimal steps)")
- }
- t := int(f*10 + 0.5)
- if t < 1 || t > 10 || f*10-float64(t) > 1e-9 || float64(t)-f*10 > 1e-9 {
- return 0, errors.New("cores must be a value in 0.1..1.0 (one-decimal steps)")
- }
- return t, nil
-}
diff --git a/src/internal/mgr/cpu_limit.go b/src/internal/mgr/cpu_limit.go
index 472c13d..5d2a760 100644
--- a/src/internal/mgr/cpu_limit.go
+++ b/src/internal/mgr/cpu_limit.go
@@ -4,22 +4,25 @@ import (
"encoding/json"
"errors"
"fmt"
+ "math"
"time"
+ "vpsmgr/internal/cfg"
"vpsmgr/internal/db"
)
-// CPULimitRule is the global dynamic CPU limit rule managed from the admin
-// panel. It applies to every container: when a container keeps its CPU usage
-// above Percent of its own quota for WindowMinutes in a row, it is capped to
-// CoresX10 tenths of a core (the same time-slice semantics as a fractional
-// quota) for DurationSeconds, then its normal quota is restored.
+// CPULimitRule is the global dynamic CPU limit rule, configured under
+// `cpu_limit.*` in config.yaml (`vps config set cpu_limit.…`). It applies to
+// every container: when a container keeps its CPU usage above Percent of its
+// own quota for WindowMinutes in a row, it is capped to CoresX10 tenths of a
+// core (the same time-slice semantics as a fractional quota) for
+// DurationSeconds, then its normal quota is restored.
type CPULimitRule struct {
- Enabled bool `json:"enabled"`
- WindowMinutes int `json:"window_minutes"` // x: consecutive minutes over the threshold
- Percent int `json:"percent"` // y: percent of the container's own quota, 1..100
- CoresX10 int `json:"cores_x10"` // z*10: limit target, 1..10 tenths (0.1..1.0 core)
- DurationSeconds int `json:"duration_seconds"` // how long the limit lasts once applied
+ Enabled bool
+ WindowMinutes int // consecutive minutes over the threshold
+ Percent int // percent of the container's own quota, 1..100
+ CoresX10 int // limit target, 1..10 tenths (0.1..1.0 core)
+ DurationSeconds int // how long the limit lasts once applied
}
// CPULimitState is the persisted state of one container's active dynamic limit.
@@ -28,20 +31,9 @@ type CPULimitState struct {
CoresX10 int `json:"cores_x10"` // the cap that was applied
}
-// DefaultCPULimitRule is the prefilled rule shown in the admin panel before
-// anything is saved. It is disabled by default ("no limit").
-func DefaultCPULimitRule() CPULimitRule {
- return CPULimitRule{
- Enabled: false,
- WindowMinutes: 10,
- Percent: 60,
- CoresX10: 5,
- DurationSeconds: 2*3600 + 30*60, // 2h30m
- }
-}
-
-// ValidateCPULimitRule rejects out-of-range rule parameters before they are
-// persisted, so the enforcement loop only ever sees sane values.
+// ValidateCPULimitRule rejects out-of-range rule parameters, so the enforcement
+// loop only ever acts on a sane rule (the config registry guarantees it on set;
+// this is the belt-and-braces check for a hand-edited config file).
func ValidateCPULimitRule(r CPULimitRule) error {
if r.WindowMinutes < 1 {
return errors.New("window must be at least 1 minute")
@@ -61,12 +53,32 @@ func ValidateCPULimitRule(r CPULimitRule) error {
return nil
}
-// CPULimitRule returns the stored rule, or the disabled default when unset.
-func (m *Manager) CPULimitRule() (CPULimitRule, error) {
- return m.loadCPULimitRule()
+// CPULimitRuleFromConfig derives the rule from the config file (`cpu_limit.*`).
+func CPULimitRuleFromConfig(c *cfg.Config) CPULimitRule {
+ cl := c.CPULimit
+ return CPULimitRule{
+ Enabled: cl.Enabled,
+ WindowMinutes: cl.WindowMinutes,
+ Percent: cl.Percent,
+ CoresX10: int(math.Round(cl.Cores * 10)),
+ DurationSeconds: cl.DurationHours*3600 + cl.DurationMinutes*60,
+ }
}
-// SetCPULimitRule validates and persists the rule.
+// CPULimitRule returns the live rule: the DB mirror written by `vps config set`
+// (and `vps install`), falling back to the config file when the mirror is
+// unset. The config file stays authoritative; the mirror is what lets the
+// long-running panel see a change without a restart (same pattern as
+// net.v4_forward).
+func (m *Manager) CPULimitRule() CPULimitRule {
+ if r, ok, err := m.mirroredCPULimitRule(); err == nil && ok {
+ return r
+ }
+ return CPULimitRuleFromConfig(m.cfg)
+}
+
+// SetCPULimitRule writes the mirror so the running panel applies the rule
+// immediately. Called by `vps config set cpu_limit.*` and by `vps install`.
func (m *Manager) SetCPULimitRule(r CPULimitRule) error {
if err := ValidateCPULimitRule(r); err != nil {
return err
@@ -78,6 +90,18 @@ func (m *Manager) SetCPULimitRule(r CPULimitRule) error {
return m.db.SetSetting(db.SettingCPULimitRule, string(b))
}
+func (m *Manager) mirroredCPULimitRule() (CPULimitRule, bool, error) {
+ v, ok, err := m.db.GetSetting(db.SettingCPULimitRule)
+ if err != nil || !ok || v == "" {
+ return CPULimitRule{}, false, err
+ }
+ var r CPULimitRule
+ if err := json.Unmarshal([]byte(v), &r); err != nil {
+ return CPULimitRule{}, false, err
+ }
+ return r, true, nil
+}
+
// CPULimits returns the containers currently carrying a dynamic CPU limit,
// keyed by container name. Safe to call from panel goroutines.
func (m *Manager) CPULimits() map[string]CPULimitState {
@@ -88,21 +112,6 @@ func (m *Manager) CPULimits() map[string]CPULimitState {
return active
}
-func (m *Manager) loadCPULimitRule() (CPULimitRule, error) {
- v, ok, err := m.db.GetSetting(db.SettingCPULimitRule)
- if err != nil {
- return CPULimitRule{}, err
- }
- if !ok || v == "" {
- return DefaultCPULimitRule(), nil
- }
- var r CPULimitRule
- if err := json.Unmarshal([]byte(v), &r); err != nil {
- return CPULimitRule{}, fmt.Errorf("parse cpu limit rule: %w", err)
- }
- return r, nil
-}
-
func (m *Manager) loadCPULimits() (map[string]CPULimitState, error) {
v, ok, err := m.db.GetSetting(db.SettingCPULimitActive)
if err != nil {
@@ -134,18 +143,19 @@ func (m *Manager) saveCPULimits(active map[string]CPULimitState) error {
// EnforceCPULimits applies and expires the dynamic CPU limit for every
// container. It restores the normal quota of containers whose limit expired,
-// whose user was deleted, or while the rule is disabled, then caps any
+// whose user was deleted, or while the rule is disabled/invalid, then caps any
// container that has been over Percent of its quota for WindowMinutes straight.
-// Called by the 60s sampler (single goroutine) and by the admin handler on a
-// rule change; cpuLimitMu serializes the two. Restoring uses the user's current
-// DB quota (users.cpu), so an admin quota edit during a limit wins.
+// Called by the 60s sampler (single goroutine). Restoring uses the user's
+// current DB quota (users.cpu), so a quota edit during a limit wins.
func (m *Manager) EnforceCPULimits() error {
m.cpuLimitMu.Lock()
defer m.cpuLimitMu.Unlock()
- rule, err := m.loadCPULimitRule()
- if err != nil {
- return err
+ rule := m.CPULimitRule()
+ // A hand-edited config can carry an out-of-range rule: treat it as disabled
+ // (never trigger; restore anything active) instead of acting on it.
+ if err := ValidateCPULimitRule(rule); err != nil {
+ rule.Enabled = false
}
active, err := m.loadCPULimits()
if err != nil {
diff --git a/src/internal/mgr/cpu_limit_test.go b/src/internal/mgr/cpu_limit_test.go
index fc4bfb3..569e0f4 100644
--- a/src/internal/mgr/cpu_limit_test.go
+++ b/src/internal/mgr/cpu_limit_test.go
@@ -9,26 +9,6 @@ import (
"vpsmgr/internal/db"
)
-func TestParseLimitCores(t *testing.T) {
- valid := map[string]int{"0.1": 1, "0.5": 5, "0.9": 9, "1": 10, "1.0": 10, ".5": 5}
- for in, want := range valid {
- got, err := ParseLimitCores(in)
- if err != nil {
- t.Errorf("ParseLimitCores(%q) error: %v", in, err)
- continue
- }
- if got != want {
- t.Errorf("ParseLimitCores(%q) = %d, want %d", in, got, want)
- }
- }
- invalid := []string{"", " ", "0", "0.0", "0.05", "1.1", "1.5", "2", "-0.5", "abc", "1e2"}
- for _, in := range invalid {
- if _, err := ParseLimitCores(in); err == nil {
- t.Errorf("ParseLimitCores(%q) accepted, want error", in)
- }
- }
-}
-
func TestValidateCPULimitRule(t *testing.T) {
valid := []CPULimitRule{
{Enabled: true, WindowMinutes: 1, Percent: 100, CoresX10: 1, DurationSeconds: 60},
@@ -56,32 +36,44 @@ func TestValidateCPULimitRule(t *testing.T) {
}
}
-func TestCPULimitRuleRoundTrip(t *testing.T) {
+func TestCPULimitRuleFromConfig(t *testing.T) {
+ c := cfg.Default()
+ c.CPULimit = cfg.CPULimitCfg{
+ Enabled: true, WindowMinutes: 3, Percent: 25,
+ Cores: 0.2, DurationHours: 1, DurationMinutes: 5,
+ }
d, err := db.Open(filepath.Join(t.TempDir(), "rule.db"))
if err != nil {
t.Fatal(err)
}
defer d.Close()
- m := New(cfg.Default(), d)
+ m := New(c, d)
- // Unset: the disabled prefilled default is returned.
- got, err := m.CPULimitRule()
+ want := CPULimitRule{Enabled: true, WindowMinutes: 3, Percent: 25, CoresX10: 2, DurationSeconds: 3600 + 300}
+ if got := m.CPULimitRule(); got != want {
+ t.Fatalf("CPULimitRule() = %+v, want %+v", got, want)
+ }
+ if err := ValidateCPULimitRule(m.CPULimitRule()); err != nil {
+ t.Fatalf("config-derived rule should validate: %v", err)
+ }
+ // The mirror (written by `vps config set`) takes precedence over the file.
+ mirror := CPULimitRule{Enabled: true, WindowMinutes: 1, Percent: 90, CoresX10: 1, DurationSeconds: 60}
+ if err := m.SetCPULimitRule(mirror); err != nil {
+ t.Fatal(err)
+ }
+ if got := m.CPULimitRule(); got != mirror {
+ t.Fatalf("mirrored rule not used: %+v", got)
+ }
+ // The default config is the disabled 10/60/0.5/2h30m rule (fresh DB).
+ d2, err := db.Open(filepath.Join(t.TempDir(), "rule2.db"))
if err != nil {
t.Fatal(err)
}
- if got != DefaultCPULimitRule() {
- t.Fatalf("unset rule = %+v, want default %+v", got, DefaultCPULimitRule())
- }
- want := CPULimitRule{Enabled: true, WindowMinutes: 3, Percent: 25, CoresX10: 2, DurationSeconds: 3600}
- if err := m.SetCPULimitRule(want); err != nil {
- t.Fatal(err)
- }
- got, err = m.CPULimitRule()
- if err != nil || got != want {
- t.Fatalf("round trip = %+v, err=%v, want %+v", got, err, want)
- }
- if err := m.SetCPULimitRule(CPULimitRule{Enabled: true, WindowMinutes: 0}); err == nil {
- t.Fatal("invalid rule accepted")
+ defer d2.Close()
+ m2 := New(cfg.Default(), d2)
+ got := m2.CPULimitRule()
+ if got.Enabled || got.WindowMinutes != 10 || got.Percent != 60 || got.CoresX10 != 5 || got.DurationSeconds != 9000 {
+ t.Fatalf("default rule = %+v", got)
}
}
diff --git a/src/internal/mgr/share.go b/src/internal/mgr/share.go
index 8b594c2..91bb512 100644
--- a/src/internal/mgr/share.go
+++ b/src/internal/mgr/share.go
@@ -67,21 +67,21 @@ func olderSnapshotNames(snaps []lx.SnapshotInfo, target string) []string {
return older
}
-// SnapshotShareEnabled reports whether the admin has left snapshot sharing on.
-// A read failure is treated as enabled (the default) so a transient DB error
-// never silently hides the feature.
+// SnapshotShareEnabled reports whether snapshot sharing is enabled: the DB
+// mirror written by `vps config set snapshots.share`, falling back to the
+// config file when unset. The mirror is what lets the running panel see the
+// change without a restart (same pattern as net.v4_forward).
func (m *Manager) SnapshotShareEnabled() bool {
- on, err := m.db.SnapshotShareEnabled()
- if err != nil {
- return true
+ if v, ok, err := m.db.SnapshotShareEnabled(); err == nil && ok {
+ return v == "1"
}
- return on
+ return m.cfg.Snapshots.Share
}
-// SetSnapshotShareEnabled turns snapshot sharing on or off. Disabling keeps the
-// stored codes (so re-enabling restores them) and leaves existing containers
-// and checkpoints untouched — it only hides the user-side UI and refuses new
-// shares/installs.
+// SetSnapshotShareEnabled writes the mirror so the running panel applies the
+// toggle immediately. Called by `vps config set snapshots.share` and by
+// `vps install`. Disabling keeps the stored codes and leaves existing
+// containers and checkpoints untouched.
func (m *Manager) SetSnapshotShareEnabled(on bool) error {
return m.db.SetSnapshotShareEnabled(on)
}
diff --git a/src/internal/mgr/share_test.go b/src/internal/mgr/share_test.go
index 784caf7..b66cd7b 100644
--- a/src/internal/mgr/share_test.go
+++ b/src/internal/mgr/share_test.go
@@ -140,10 +140,12 @@ func TestSnapshotSharingToggleEnforced(t *testing.T) {
}
m := New(c, d)
- // Default is enabled (the feature is on after an upgrade).
+ // Default is enabled (snapshots.share defaults to true).
if !m.SnapshotShareEnabled() {
t.Fatal("sharing should default to enabled")
}
+ // `vps config set snapshots.share false` mirrors the toggle; the running
+ // panel sees it without a restart.
if err := m.SetSnapshotShareEnabled(false); err != nil {
t.Fatal(err)
}
diff --git a/src/main.go b/src/main.go
index 90ee3f6..01e8602 100644
--- a/src/main.go
+++ b/src/main.go
@@ -458,6 +458,18 @@ func cmdInstall() error {
if err := m2.ApplyV4State(); err != nil {
return fmt.Errorf("apply v4 policy: %w", err)
}
+ // Mirror the config-owned runtime settings the panel reads live (the DB is
+ // the channel between the CLI and the long-running panel), so an upgrade or
+ // a hand-edited config.yaml takes effect without a restart.
+ if err := m2.SetCPULimitRule(mgr.CPULimitRuleFromConfig(c)); err != nil {
+ return fmt.Errorf("apply cpu limit rule: %w", err)
+ }
+ if err := m2.SetSnapshotShareEnabled(c.Snapshots.Share); err != nil {
+ return fmt.Errorf("apply snapshot share toggle: %w", err)
+ }
+ if err := m2.EnforceCPULimits(); err != nil {
+ log.Printf("warn: cpu dynamic limit enforcement: %v", err)
+ }
// Admin panel: on a FRESH install (admin enabled and no hash yet in the
// DB) generate a random admin password and show it once. When admin is
// disabled nothing is printed.
@@ -1004,6 +1016,28 @@ func configSet(args []string) error {
case cfg.ApplyNextAdd:
fmt.Printf("%s saved. Applies on the next vps add / reinstall.\n", key)
case cfg.ApplyImmediate:
+ // Config-owned runtime settings the panel reads live: write the DB mirror
+ // (the channel to the long-running panel) and apply now. No restart.
+ if strings.HasPrefix(key, "cpu_limit.") || key == "snapshots.share" {
+ d, err := db.Open(c.Panel.DB)
+ if err != nil {
+ return fmt.Errorf("config saved, but applying it failed: %w", err)
+ }
+ defer d.Close()
+ m := mgr.New(c, d)
+ if strings.HasPrefix(key, "cpu_limit.") {
+ if err := m.SetCPULimitRule(mgr.CPULimitRuleFromConfig(c)); err != nil {
+ return fmt.Errorf("config saved, but applying the CPU limit rule failed: %w", err)
+ }
+ if err := m.EnforceCPULimits(); err != nil {
+ return fmt.Errorf("config saved, but enforcing CPU limits failed: %w", err)
+ }
+ } else if err := m.SetSnapshotShareEnabled(c.Snapshots.Share); err != nil {
+ return fmt.Errorf("config saved, but applying the share toggle failed: %w", err)
+ }
+ fmt.Printf("%s updated and applied.\n", key)
+ return nil
+ }
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