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}}

-
-
- -
-
-
2.4 cores for a minute{{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