refactor(settings): move snapshot sharing and CPU limit to config.yaml
- `snapshots.share` and `cpu_limit.*` are now config.yaml fields managed with `vps config set` (validated, shown by `vps config list/help`) instead of being panel/DB-only settings; the admin panel now shows the CPU rule read-only and the snapshot-sharing card is gone - both apply immediately: `vps config set` writes the config file, mirrors the value into the DB and applies it right away — no panel restart — the same pattern as net.v4_forward. `vps install` refreshes the mirror from the file - the CPU rule is read from the mirror (falling back to the config file) so the enforcer and the read-only card pick up a change live; the share toggle too - drop the now-unused admin routes/handlers, the DB-only rule setting and the ConfigSet CPU form Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
1 parent
65b54d7970
commit
3b8cfdcd93
17 files changed
+394
-328
No files matched your search
+10
-9
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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.
|
||||
|
||||
+23
-29
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{if eq .Lang "zh"}}CPU 动态限制规则(全局){{else}}Dynamic CPU limit rule (global){{end}}</h2>
|
||||
<form action="{{.Prefix}}/cpu-limit" method="post" class="form-grid" id="cpuLimitForm">
|
||||
<div class="field" style="grid-column:1/-1">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:600">
|
||||
<input type="checkbox" name="enabled" id="cpuLimitEnabled" {{if .CPULimitEnabled}}checked{{end}}>
|
||||
{{if eq .Lang "zh"}}启用限制(默认:不限制){{else}}Enable limit (default: no limit){{end}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}连续 x 分钟{{else}}Consecutive x minutes{{end}}</label><input name="window_minutes" id="cpuLimitWindow" type="number" min="1" step="1" value="{{.CPULimitMinutes}}" required></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}占用配额超过 y%{{else}}Over quota y%{{end}}</label><input name="percent" id="cpuLimitPercent" type="number" min="1" max="100" step="1" value="{{.CPULimitPercent}}" required title="{{if eq .Lang "zh"}}相对于容器自身配额;如 4 核配额下 60% = 单分钟持续超过 2.4 核{{else}}relative to the container's own quota; e.g. 60% of a 4-core quota = sustained >2.4 cores for a minute{{end}}"></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}限制到 z 核{{else}}Limit to z cores{{end}}</label><input name="cores" id="cpuLimitCores" type="number" min="0.1" max="1" step="0.1" value="{{.CPULimitCores}}" required title="{{if eq .Lang "zh"}}0.1~1.0(步长 0.1),单核的时间片占比{{else}}0.1..1.0 (steps of 0.1), a time slice of one core{{end}}"></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}持续 n 小时{{else}}For n hours{{end}}</label><input name="hours" id="cpuLimitHours" type="number" min="0" step="1" value="{{.CPULimitHours}}" required></div>
|
||||
<div class="field"><label>{{if eq .Lang "zh"}}m 分钟{{else}}m minutes{{end}}</label><input name="dur_minutes" id="cpuLimitDurMin" type="number" min="0" max="59" step="1" value="{{.CPULimitDurMin}}" required></div>
|
||||
<button type="submit" class="primary neutral" id="cpuLimitBtn">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
|
||||
</form>
|
||||
<small style="color:#9aa0a8;display:block;margin-top:8px">{{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}}</small>
|
||||
<h2>{{if eq .Lang "zh"}}CPU 动态限制规则(全局,只读){{else}}Dynamic CPU limit rule (global, read-only){{end}}</h2>
|
||||
{{if .CPULimitEnabled}}
|
||||
<p style="margin:0 0 10px">{{if eq .Lang "zh"}}容器连续 <b>{{.CPULimitMinutes}}</b> 分钟占用其配额超过 <b>{{.CPULimitPercent}}%</b> 时,限制到 <b>{{.CPULimitCores}}</b> 核,持续 <b>{{.CPULimitHours}}</b> 小时 <b>{{.CPULimitDurMin}}</b> 分钟后自动恢复原配额。配额本身不高于该核数的容器会被跳过。{{else}}When a container stays above <b>{{.CPULimitPercent}}%</b> of its quota for <b>{{.CPULimitMinutes}}</b> minutes, it is capped to <b>{{.CPULimitCores}}</b> core(s) for <b>{{.CPULimitHours}}</b>h <b>{{.CPULimitDurMin}}</b>m, then its quota is restored. Containers whose own quota is at or below that are skipped.{{end}}</p>
|
||||
{{else}}
|
||||
<p style="margin:0 0 10px">{{if eq .Lang "zh"}}未启用(默认:不限制)。{{else}}Disabled (default: no limit).{{end}}</p>
|
||||
{{end}}
|
||||
<small style="color:#9aa0a8;display:block">{{if eq .Lang "zh"}}用 <code>vps config set cpu_limit.*</code> 修改(<code>vps config help</code> 查看各项)。面板不提供编辑入口。{{else}}Change it with <code>vps config set cpu_limit.*</code> (see <code>vps config help</code>); there is no editor in this panel.{{end}}</small>
|
||||
<div style="margin-top:10px">
|
||||
{{if .CPULimitActive}}
|
||||
<div style="font-size:12px;color:#6b7280;margin-bottom:6px">{{if eq .Lang "zh"}}当前被限制容器{{else}}Currently limited containers{{end}}({{len .CPULimitActive}})</div>
|
||||
@@ -336,20 +326,6 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>{{if eq .Lang "zh"}}快照分享{{else}}Snapshot sharing{{end}}</h2>
|
||||
<form action="{{.Prefix}}/share-toggle" method="post" id="shareForm">
|
||||
<div class="field">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:600">
|
||||
<input type="checkbox" name="enabled" id="shareEnabled" {{if .ShareEnabled}}checked{{end}}>
|
||||
{{if eq .Lang "zh"}}允许用户分享检查点,并从分享码安装{{else}}Allow users to share a checkpoint and install from a share code{{end}}
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="primary neutral" id="shareBtn">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
|
||||
</form>
|
||||
<small style="color:#9aa0a8;display:block;margin-top:8px">{{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}}</small>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- modal -->
|
||||
@@ -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(){
|
||||
|
||||
+24
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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_-]+$`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-23
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+34
@@ -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
|
||||
|
||||
Reference in new issue
Block a user