Модуль, снятый с лицензии, отключается один раз: служба остановлена (bj-keymedia unit-disable), одно оповещение, в списках не показывается; вернули в лицензию — служба включается
Также: версия файла, совпадающего с выпуском, записывается (нет ложного «новее»); признак неудачной проверки отдельно от результатов установки; окно обновления само перезагружает страницу. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -168,7 +168,11 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
return licenseAccept(rc, r.Token, "продлена")
|
||||
if err := licenseAccept(rc, r.Token, "продлена"); err != nil {
|
||||
return err
|
||||
}
|
||||
enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие
|
||||
return nil
|
||||
}
|
||||
|
||||
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package lkgateway
|
||||
|
||||
// Модули, снятые с лицензии: служба модуля останавливается и отключается один раз, с одним
|
||||
// оповещением; дальше модуль не показывается и не предлагается к обновлению. Вернули в лицензию —
|
||||
// служба включается обратно. Работает только при действующей лицензии: истёкший или непроверенный
|
||||
// ключ модули не трогает (временная ошибка не должна гасить работающие службы).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
)
|
||||
|
||||
var moduleEnforceMu sync.Mutex
|
||||
|
||||
// moduleOff — модуль отключён как снятый с лицензии (по записи в настройках).
|
||||
func moduleOff(rc *RuntimeConfig, name string) bool {
|
||||
_, ok := rc.Snapshot().Update.DisabledModules[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func enforceModuleLicense(ctx context.Context, rc *RuntimeConfig) {
|
||||
moduleEnforceMu.Lock()
|
||||
defer moduleEnforceMu.Unlock()
|
||||
if _, err := os.Stat(keymediaReqDir); err != nil {
|
||||
return // нет root-службы (разработка, тесты)
|
||||
}
|
||||
st := licenseStatus(rc)
|
||||
if !st.Valid {
|
||||
return
|
||||
}
|
||||
cfg := rc.Snapshot().Update
|
||||
changed := false
|
||||
for _, c := range release.Components {
|
||||
if c.Core || c.Feature == "" || c.Feature == "updates" || c.Unit == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(c.Path); err != nil {
|
||||
continue // модуль не установлен
|
||||
}
|
||||
allowed := moduleAllowed(rc, c.Feature)
|
||||
_, off := cfg.DisabledModules[c.Name]
|
||||
switch {
|
||||
case !allowed && !off:
|
||||
if _, err := runKeymediaOp(ctx, "unit-disable", map[string]string{"extra": c.Unit}); err != nil {
|
||||
log.Printf("modules: отключение %s: %v", c.Unit, err)
|
||||
continue
|
||||
}
|
||||
if cfg.DisabledModules == nil {
|
||||
cfg.DisabledModules = map[string]string{}
|
||||
}
|
||||
cfg.DisabledModules[c.Name] = time.Now().Format("02.01.2006 15:04")
|
||||
changed = true
|
||||
log.Printf("modules: %s снят с лицензии — служба %s остановлена и отключена", c.Name, c.Unit)
|
||||
raiseSystemAlert(rc, "Модуль «"+c.Title+"» отключён: не входит в лицензию",
|
||||
"Служба "+c.Unit+" остановлена, раздел скрыт, обновления для модуля не выдаются. Чтобы подключить модуль, свяжитесь с ZETIT — после добавления в лицензию он включится сам.")
|
||||
case allowed && off:
|
||||
if _, err := runKeymediaOp(ctx, "unit-enable", map[string]string{"extra": c.Unit}); err != nil {
|
||||
log.Printf("modules: включение %s: %v", c.Unit, err)
|
||||
continue
|
||||
}
|
||||
delete(cfg.DisabledModules, c.Name)
|
||||
changed = true
|
||||
log.Printf("modules: %s снова в лицензии — служба %s включена", c.Name, c.Unit)
|
||||
raiseSystemAlert(rc, "Модуль «"+c.Title+"» снова включён", "Модуль добавлен в лицензию, служба "+c.Unit+" запущена.")
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
cur := rc.Snapshot().Update
|
||||
cur.DisabledModules = cfg.DisabledModules
|
||||
if err := rc.SaveUpdateSettings(cur); err != nil {
|
||||
log.Printf("modules: сохранение: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enforceModuleLicenseAsync(rc *RuntimeConfig) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
enforceModuleLicense(ctx, rc)
|
||||
}()
|
||||
}
|
||||
@@ -86,14 +86,20 @@ type LicenseSettings struct {
|
||||
|
||||
// UpdateSettings — авто-обновления из артефактории (#18/#20).
|
||||
type UpdateSettings struct {
|
||||
BaseURL string `json:"base_url"` // https://updates.example.com
|
||||
Channel string `json:"channel"` // "stable" | "beta"
|
||||
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
|
||||
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||||
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||||
LastResult string `json:"last_result"` // текст результата проверки
|
||||
Available string `json:"available_version"` // доступная версия (если новее)
|
||||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||
BaseURL string `json:"base_url"` // https://updates.example.com
|
||||
Channel string `json:"channel"` // "stable" | "beta"
|
||||
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
|
||||
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||||
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||||
LastResult string `json:"last_result"` // текст результата проверки
|
||||
// CheckFailed — последняя ПРОВЕРКА обновлений не удалась (сервер недоступен, нет лицензии…):
|
||||
// тогда версии из прошлой проверки не предлагаются. Неудачная установка сюда не относится.
|
||||
CheckFailed bool `json:"check_failed,omitempty"`
|
||||
// DisabledModules — модули, отключённые как снятые с лицензии (имя → когда): служба остановлена,
|
||||
// в списке компонентов не показываются; при возврате в лицензию включаются обратно.
|
||||
DisabledModules map[string]string `json:"disabled_modules,omitempty"`
|
||||
Available string `json:"available_version"` // доступная версия (если новее)
|
||||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
||||
Components map[string]string `json:"components,omitempty"`
|
||||
// ComponentsChannel — канал, в котором эти версии были найдены. Если канал
|
||||
|
||||
@@ -79,6 +79,9 @@ func unitViews(rc *RuntimeConfig) []UnitView {
|
||||
if !present && !c.Core {
|
||||
continue // модуля на сервере нет — и службы быть не должно
|
||||
}
|
||||
if moduleOff(rc, c.Name) {
|
||||
continue // снят с лицензии: служба отключена намеренно
|
||||
}
|
||||
v := unitState(c.Unit)
|
||||
v.Title, v.Core, v.Present = c.Title, c.Core, true
|
||||
out = append(out, v)
|
||||
|
||||
@@ -10,9 +10,12 @@ package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -95,6 +98,7 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||
return st, err
|
||||
}
|
||||
st.Available, st.Notes = m.Version, m.Notes
|
||||
syncSameFileVersions(m)
|
||||
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||
release.MarkSplitModules(st.Components, u.allowed)
|
||||
off := markUnlicensed(st.Components, u.allowed)
|
||||
@@ -121,9 +125,59 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// syncSameFileVersions — если файл компонента на сервере побайтно совпадает с файлом выпуска, а
|
||||
// записана другая версия (компонент не менялся, а номер выпуска вырос, или файл выложен вручную),
|
||||
// записываем версию выпуска: иначе список предлагает «обновить» то, что установщик отклонит как
|
||||
// «обновление не требуется».
|
||||
func syncSameFileVersions(m *release.Manifest) {
|
||||
installed := release.LoadInstalled(release.InstalledFile)
|
||||
changed := false
|
||||
for _, a := range m.Artifacts {
|
||||
c, ok := release.ComponentByName(a.Name)
|
||||
if !ok || a.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
v := a.Version
|
||||
if v == "" {
|
||||
v = m.Version
|
||||
}
|
||||
if cur, ok := installed[a.Name]; ok && cur.Version == v {
|
||||
continue
|
||||
}
|
||||
sum, err := fileSHA256(c.Path)
|
||||
if err != nil || !strings.EqualFold(sum, a.SHA256) {
|
||||
continue
|
||||
}
|
||||
installed[a.Name] = release.Installed{Version: v, SHA256: sum, InstalledAt: time.Now().UTC()}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := release.SaveInstalled(release.InstalledFile, installed); err != nil {
|
||||
log.Printf("lk-gateway: запись версий совпадающих компонентов: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// markUnlicensed — модули, установленные на сервере, но снятые с лицензии: не обновляются (сервер
|
||||
// обновлений их файлы не отдаёт), в списке — с пояснением. Возвращает названия таких модулей.
|
||||
func markUnlicensed(states []release.ComponentState, allowed func(string) bool) []string {
|
||||
var disabled map[string]string
|
||||
if globalRC != nil {
|
||||
disabled = globalRC.Snapshot().Update.DisabledModules
|
||||
}
|
||||
var off []string
|
||||
for i := range states {
|
||||
c := &states[i]
|
||||
@@ -131,7 +185,7 @@ func markUnlicensed(states []release.ComponentState, allowed func(string) bool)
|
||||
continue
|
||||
}
|
||||
c.Update, c.New = false, false
|
||||
if c.Present {
|
||||
if _, already := disabled[c.Name]; c.Present && !already { // об отключённом уже сказано один раз
|
||||
c.Blocked = "не входит в лицензию — модуль отключён и не обновляется; чтобы подключить, свяжитесь с ZETIT"
|
||||
off = append(off, "«"+c.Title+"»")
|
||||
}
|
||||
@@ -163,6 +217,7 @@ func (u *Updater) saveCheckResult(st UpdateStatus, avail map[string]string) {
|
||||
cfg := u.rc.Snapshot().Update
|
||||
cfg.LastCheck = time.Now().UTC()
|
||||
cfg.LastResult = st.Message
|
||||
cfg.CheckFailed = avail == nil // nil — манифест получить не удалось
|
||||
cfg.Available = st.Available
|
||||
cfg.Notes = st.Notes
|
||||
if avail != nil {
|
||||
@@ -202,6 +257,7 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("манифест: %w", err)
|
||||
}
|
||||
syncSameFileVersions(m)
|
||||
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||
release.MarkSplitModules(plan, u.allowed)
|
||||
off := markUnlicensed(plan, u.allowed)
|
||||
@@ -414,7 +470,10 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
|
||||
r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed))
|
||||
// Модуль, которого нет на сервере и нет в лицензии, не показываем: поставить его нельзя,
|
||||
// а в списке он выглядит так, будто входит в поставку.
|
||||
if r.Present || r.Core || (r.Available != "" && moduleAllowed(rc, r.Feature)) {
|
||||
// Модуль вне лицензии не показываем вовсе (и установленный: его служба отключена), пока
|
||||
// лицензия действует; без действующей лицензии список как есть.
|
||||
unlicensed := !r.Core && r.Feature != "" && r.Feature != "updates" && licenseStatus(rc).Valid && !moduleAllowed(rc, r.Feature)
|
||||
if !unlicensed && (r.Present || r.Core || (r.Available != "" && moduleAllowed(rc, r.Feature))) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
@@ -422,7 +481,7 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
|
||||
markUnlicensed(out, func(f string) bool { return moduleAllowed(rc, f) })
|
||||
// Последняя проверка не удалась — предлагать установку по старым данным нельзя:
|
||||
// кнопка ушла бы в тот же отказ. Показываем причину и просим проверить заново.
|
||||
if updateResultFailed(set.LastResult) {
|
||||
if set.CheckFailed {
|
||||
for i := range out {
|
||||
if out[i].Update && out[i].Available != "" {
|
||||
out[i].Blocked = "проверка обновлений не удалась — нажмите «Проверить обновления»"
|
||||
@@ -476,6 +535,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
||||
return
|
||||
case <-time.After(time.Minute):
|
||||
cleanupStaging(time.Hour)
|
||||
enforceModuleLicense(ctx, u.rc)
|
||||
u.ensureSplitModules(ctx)
|
||||
check()
|
||||
}
|
||||
|
||||
@@ -593,12 +593,22 @@ label { font-size:13px; font-weight:500; }
|
||||
detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done');
|
||||
function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; }
|
||||
function show(){ modal.style.display='flex'; }
|
||||
var finished=false;
|
||||
function finish(text, ok){
|
||||
finished=true;
|
||||
stageEl.textContent=text; bar.style.width='100%';
|
||||
bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)';
|
||||
doneEl.style.display='block';
|
||||
if(ok){ // после успешного обновления страница перезагружается сама — с новыми версиями
|
||||
var left=5, tick=function(){ if(left<=0){ location.reload(); return; }
|
||||
doneEl.querySelector('button').textContent='Готово (страница обновится через '+left+' с)'; left--; setTimeout(tick,1000); };
|
||||
tick();
|
||||
}
|
||||
}
|
||||
function run(form){
|
||||
var lastChange=Date.now(), lastKey='';
|
||||
// сторож: если отчёт не меняется 2 минуты, перезагружаем страницу — не оставляем «зависшее» окно
|
||||
(function watch(){ if(finished) return; if(Date.now()-lastChange>120000){ location.reload(); return; } setTimeout(watch,5000); })();
|
||||
var note='', startVer=null, target='', seenActive=false, postDone=false, lost=false, t0=Date.now(), seq0=null;
|
||||
show(); stageEl.textContent='Отправка запроса…'; bar.style.width='3%'; detail.textContent='';
|
||||
// номер последнего отчёта до отправки: отчёт прошлой попытки (например, с ошибкой) не показываем
|
||||
@@ -620,6 +630,7 @@ label { font-size:13px; font-weight:500; }
|
||||
function poll(){
|
||||
fetch('/admin/setup/update/progress',{cache:'no-store',credentials:'same-origin'}).then(function(r){ if(!r.ok) throw 0; return r.json(); }).then(function(p){
|
||||
if(startVer===null){ startVer=p.version; }
|
||||
var key=[p.seq,p.step,p.done,p.finished,p.version].join('/'); if(key!==lastKey){ lastKey=key; lastChange=Date.now(); }
|
||||
if(p.target) target=p.target;
|
||||
if(p.note) note=p.note;
|
||||
if(lost){ // bj вернулся после перезапуска
|
||||
|
||||
Reference in New Issue
Block a user