From 0dd38bbae4522ff0239c014e16b204e7b22680c9 Mon Sep 17 00:00:00 2001 From: zuevav Date: Mon, 5 Oct 2026 19:25:17 +0300 Subject: [PATCH] =?UTF-8?q?=C2=AB=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=C2=BB=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=BE=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83=D0=BA?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=BC:=20bj=20=D1=83=D0=B7=D0=BD=D0=B0=D1=91=D1=82=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=20=D0=BB=D0=B8=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=B7=D0=B8=D0=B8,=20=D1=81=D1=82=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B8,=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D0=B2=20=D0=BD=D0=B5=D1=91=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=D1=8F=D1=82=20(=D0=B8=D0=B7=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B0,=20=D0=B5=D1=81=D0=BB=D0=B8=20=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=20=D1=82=D0=BE=D1=82=20=D0=B6=D0=B5=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D0=BA,=20=E2=80=94=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F),?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B8=D1=85=20=D1=81=D0=BB=D1=83=D0=B6=D0=B1=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=B5=D1=80;=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B0=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D0=B8=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B0=20=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=B7=D0=B8=D0=B8=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D0=B2=D1=8F=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B7=D1=83,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D0=BB=D0=B5=D0=B4=D1=83=D1=8E=D1=89?= =?UTF-8?q?=D0=B5=D0=BC=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B5=20bj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/admin.go | 8 +- internal/lkgateway/firstrun.go | 2 +- internal/lkgateway/licenseclient.go | 40 +++- internal/lkgateway/onboarding_test.go | 65 ++++++ internal/lkgateway/prepare.go | 190 ++++++++++++++++++ internal/lkgateway/setup.go | 9 +- internal/lkgateway/updater.go | 45 ++++- .../web/templates/admin_prepare.html | 63 ++++++ 8 files changed, 413 insertions(+), 9 deletions(-) create mode 100644 internal/lkgateway/prepare.go create mode 100644 internal/lkgateway/web/templates/admin_prepare.html diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 3290840..5e90ee1 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -26,7 +26,7 @@ type admin struct { home, claims, claim, status, setup *template.Template help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template incoming, incomingDetail, fancyQueue, quikInstr *template.Template - wizard, news, keyWizard *template.Template + wizard, news, keyWizard, prepare *template.Template handbook *template.Template m2mtest, m2mpreview, depoSim, depoTest *template.Template journal *template.Template @@ -304,6 +304,10 @@ func newAdmin() (*admin, error) { if err != nil { return nil, fmt.Errorf("parse admin_wizard: %w", err) } + prepare, err := parse("admin_prepare.html") + if err != nil { + return nil, fmt.Errorf("parse admin_prepare: %w", err) + } news, err := parse("admin_news.html") if err != nil { return nil, fmt.Errorf("parse admin_news: %w", err) @@ -380,7 +384,7 @@ func newAdmin() (*admin, error) { home: home, claims: claims, claim: claim, status: status, setup: setup, help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys, helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage, - wizard: wizard, news: news, keyWizard: keyWizard, + wizard: wizard, news: news, keyWizard: keyWizard, prepare: prepare, incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr, handbook: handbook, m2mtest: m2mtest, diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index b3d78ca..3fecc28 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -171,7 +171,7 @@ func registerFirstRun(mux *http.ServeMux) { http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Ключ продукта не принят: "+err.Error()+". Проверьте его и введите ещё раз."), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/wizard?flash="+url.QueryEscape("Администратор создан, лицензия активирована. Мастер проведёт по остальным шагам — любой можно пропустить."), http.StatusSeeOther) + http.Redirect(w, r, "/admin/setup/prepare?next=/admin/wizard&flash="+url.QueryEscape("Администратор создан, ключ продукта принят."), http.StatusSeeOther) return } if d.NeedKey { diff --git a/internal/lkgateway/licenseclient.go b/internal/lkgateway/licenseclient.go index 7b10368..cc85e91 100644 --- a/internal/lkgateway/licenseclient.go +++ b/internal/lkgateway/licenseclient.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/hwid" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" "io" "log" "net/http" @@ -167,6 +168,7 @@ func LicenseActivate(ctx context.Context, rc *RuntimeConfig, code string) error return err } log.Printf("lk-gateway: лицензия активирована на сервере лицензий: %s, лицензия %s", r.Tenant, r.LicenseID) + afterLicenseChange(rc) return nil } @@ -196,10 +198,46 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error { if err := licenseAccept(rc, r.Token, "продлена"); err != nil { return err } - enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие + if nl, err := verifyLicenseToken(r.Token); err == nil && licenseScopeChanged(lic, nl) { + afterLicenseChange(rc) // в лицензию добавили модуль или план — поставить, не дожидаясь перезапуска bj + } else { + enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие + } return nil } +// licenseScopeChanged — изменился ли состав лицензии: план или признаки модулей. +func licenseScopeChanged(a, b *license.License) bool { + if a == nil || b == nil { + return a != b + } + if a.Plan != b.Plan || len(a.Features) != len(b.Features) { + return true + } + for _, f := range b.Features { + if !a.HasFeature(f) { + return true + } + } + return false +} + +// afterLicenseChange — лицензию активировали или изменили её состав: службы модулей — в соответствие +// лицензии, а модули, которые в неё входят, но ещё не стоят, ставятся сразу. На сервере с образа ключ +// вводят уже после установки, и без этого модули (служба подписи и др.) встали бы только после +// перезапуска bj. +func afterLicenseChange(rc *RuntimeConfig) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + enforceModuleLicense(ctx, rc) + if _, err := os.Stat(keymediaReqDir); err != nil { + return // нет root-службы (разработка, тесты) + } + _, _ = NewUpdater(rc).ensureSplitModules(ctx) + }() +} + // licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или // выключенный на сервере лицензий, доходит до установки быстро. После неудачи — не чаще раза в час. func licenseNeedsRenew(rc *RuntimeConfig) bool { diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index 5b10248..610e4f0 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -12,6 +12,9 @@ import ( "strings" "testing" "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" ) func TestAccPrefix(t *testing.T) { @@ -273,3 +276,65 @@ func TestNotifyKindsByLicense(t *testing.T) { t.Fatalf("маршруты после сохранения: %+v", r) } } + +// Модули ставятся сразу, когда состав лицензии изменился (план или признаки), а не при каждом продлении. +func TestLicenseScopeChanged(t *testing.T) { + a := &license.License{Plan: "pro", Features: []string{"updates", "quik-mnp"}} + same := &license.License{Plan: "pro", Features: []string{"quik-mnp", "updates"}} + more := &license.License{Plan: "pro", Features: []string{"updates", "quik-mnp", "npr"}} + other := &license.License{Plan: "enterprise", Features: []string{"updates", "quik-mnp"}} + if licenseScopeChanged(a, same) { + t.Fatal("тот же состав в другом порядке — не изменение") + } + if !licenseScopeChanged(a, more) || !licenseScopeChanged(a, other) || !licenseScopeChanged(nil, a) { + t.Fatal("новый модуль или план — изменение") + } +} + +// «Подготовка модулей»: страница собирается, переход — только на свои страницы, без лицензии — одно ядро. +func TestPrepareScreen(t *testing.T) { + a, err := newAdmin() + if err != nil { + t.Fatal(err) + } + var b strings.Builder + d := PrepareData{page: page{Title: "Подготовка модулей", Active: "wizard"}, Tenant: "ТЕСТ ZETIT", Plan: "pro", Until: "активна до 04.12.2026", Next: "/admin/wizard", Flash: "Администратор создан, ключ продукта принят."} + if err := a.prepare.ExecuteTemplate(&b, "layout", d); err != nil { + t.Fatal(err) + } + for _, want := range []string{"Подготовка модулей", "ТЕСТ ZETIT", "/admin/setup/prepare/status", "Перейти к настройке"} { + if !strings.Contains(b.String(), want) { + t.Fatalf("нет %q", want) + } + } + for in, want := range map[string]string{"": "/admin/wizard", "/admin/setup?x=1": "/admin/setup?x=1", "https://evil": "/admin/wizard", "//evil/admin/": "/admin/wizard", "/admin/wizard\r\nX: y": "/admin/wizard"} { + if got := safeNext(in); got != want { + t.Errorf("safeNext(%q) = %q, ожидалось %q", in, got, want) + } + } + rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") + if items := prepareItems(rc, false, false, ""); len(items) != 1 || items[0].State != "ok" { + t.Fatalf("без лицензии — только ядро: %+v", items) + } +} + +// Модуль из комплекта образа берётся без скачивания, только если это тот же файл, что в выпуске. +func TestFromLocalBundle(t *testing.T) { + dir, staging := t.TempDir(), t.TempDir() + old := localBundleDir + localBundleDir = dir + defer func() { localBundleDir = old }() + if err := os.WriteFile(filepath.Join(dir, "crypto-service.jar"), []byte("jar-2026.09.25.14"), 0o644); err != nil { + t.Fatal(err) + } + sum, _, _ := release.HashFile(filepath.Join(dir, "crypto-service.jar")) + if !fromLocalBundle(release.Artifact{Name: "crypto-service.jar", File: "crypto-service.jar", SHA256: sum}, staging) { + t.Fatal("тот же файл — должен взяться из образа") + } + if b, _ := os.ReadFile(filepath.Join(staging, "crypto-service.jar")); string(b) != "jar-2026.09.25.14" { + t.Fatalf("скопировано: %q", b) + } + if fromLocalBundle(release.Artifact{Name: "crypto-service.jar", File: "crypto-service.jar", SHA256: strings.Repeat("0", 64)}, t.TempDir()) { + t.Fatal("другой выпуск — качать с сервера, а не брать из образа") + } +} diff --git a/internal/lkgateway/prepare.go b/internal/lkgateway/prepare.go new file mode 100644 index 0000000..de1b982 --- /dev/null +++ b/internal/lkgateway/prepare.go @@ -0,0 +1,190 @@ +package lkgateway + +// «Подготовка модулей» (05.10.2026) — экран между вводом ключа продукта и мастером настройки. Образ ставит +// базовый набор (ядро); после ввода ключа bj узнаёт состав лицензии, ставит модули, которые в неё входят +// (файлы — из комплекта образа, если там тот же выпуск, иначе с сервера обновлений), запускает их службы +// и только потом открывает мастер: мастер настраивает уже установленное. + +import ( + "context" + "net/http" + "os" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" +) + +// PrepareItem — строка экрана: что входит в лицензию и в каком оно состоянии. +type PrepareItem struct { + Title string `json:"title"` + State string `json:"state"` // ok | run | wait | warn | err | info + Detail string `json:"detail,omitempty"` +} + +var prepareRun struct { + sync.Mutex + Running, Finished bool + Result, Err string +} + +// prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет). +var prepareCoreFeatures = []string{"quik-mnp", "esia-finance"} + +// prepareItems — состав лицензии этой установки и состояние каждого модуля. +func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem { + out := []PrepareItem{{Title: "Ядро bj: обмен с НРД, переводы M2M", State: "ok", Detail: "установлено с образа, версия " + BuildVersion}} + for _, f := range prepareCoreFeatures { + if moduleAllowed(rc, f) { + out = append(out, PrepareItem{Title: moduleTitles[f], State: "ok", Detail: "входит в ядро — устанавливать нечего"}) + } + } + for _, c := range release.Components { + if !c.FromCore || !moduleAllowed(rc, c.Feature) { + continue + } + it := PrepareItem{Title: prepareTitle(c)} + _, statErr := os.Stat(c.Path) + present := statErr == nil + us := "" + if present && c.Unit != "" { + us = unitState(c.Unit).State + } + switch { + case us == "active": + it.State, it.Detail = "ok", "установлен, служба работает" + case us == "activating" || us == "reloading": + it.State, it.Detail = "run", "установлен, служба запускается…" + case present && c.Unit == "bj-crypto": + it.State, it.Detail = "warn", "установлен, служба не запущена ("+us+") — обмену не мешает: пакеты для НРД подписывает шлюз" + case present: + it.State, it.Detail = "ok", "установлен; служба заработает, когда модуль будет настроен (глава «Запуск»)" + case running: + it.State, it.Detail = "run", "устанавливается…" + case runErr != "": + it.State, it.Detail = "err", "не установлен — "+runErr + case finished: + it.State, it.Detail = "warn", "не установлен: в текущем выпуске на сервере обновлений его нет — поставится обновлением" + default: + it.State, it.Detail = "wait", "будет установлен" + } + out = append(out, it) + } + if moduleAllowed(rc, "web-cabinet") { + out = append(out, PrepareItem{Title: moduleTitles["web-cabinet"], State: "info", Detail: "ставится на веб-сервер организации: дистрибутив и ключ кабинета — в мастере, глава «Запуск»"}) + } + return out +} + +// prepareTitle — название модуля для экрана. +func prepareTitle(c release.Component) string { + switch c.Name { + case "crypto-service.jar": + return "Служба пробной подписи (Валидата)" + } + if t := moduleTitles[c.Feature]; t != "" { + return t + } + return c.Title +} + +// prepareStart — поставить модули лицензии (один запуск за раз; повторный — пока идёт — ничего не делает). +func prepareStart(rc *RuntimeConfig) { + prepareRun.Lock() + if prepareRun.Running { + prepareRun.Unlock() + return + } + prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err = true, false, "", "" + prepareRun.Unlock() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + var msg, errText string + if _, err := os.Stat(keymediaReqDir); err != nil { + errText = "на сервере нет службы установки (bj-keymedia)" + } else if m, err := NewUpdater(rc).ensureSplitModules(ctx); err != nil { + errText = strings.TrimPrefix(err.Error(), "Установка модулей ") + } else { + msg = m + } + prepareRun.Lock() + prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err = false, true, msg, errText + prepareRun.Unlock() + }() +} + +// safeNext — куда идти после подготовки: только свои страницы (как return_to). +func safeNext(v string) string { + if strings.HasPrefix(v, "/admin/") && !strings.HasPrefix(v, "//") && !strings.ContainsAny(v, "\r\n") { + return v + } + return "/admin/wizard" +} + +// PrepareData — данные страницы «Подготовка модулей». +type PrepareData struct { + page + Tenant, Plan, Until string + Next string + Flash string +} + +func registerPrepare(mux *http.ServeMux, h *setupHandlers) { + mux.HandleFunc("/admin/setup/prepare", func(w http.ResponseWriter, r *http.Request) { + st := licenseStatus(h.rc) + if !st.Valid { + http.Redirect(w, r, "/admin/wizard?step=1", http.StatusSeeOther) + return + } + prepareRun.Lock() + if !prepareRun.Running { // новый заход (ключ сменили, модуль добавили) — прошлый итог не в счёт + prepareRun.Finished, prepareRun.Result, prepareRun.Err = false, "", "" + } + prepareRun.Unlock() + d := PrepareData{page: nowPage("Подготовка модулей", "wizard"), Tenant: st.Tenant, Plan: st.Plan, Until: st.Message, + Next: safeNext(r.URL.Query().Get("next")), Flash: r.URL.Query().Get("flash")} + render(w, h.tpl.a.prepare, d) + }) + mux.HandleFunc("/admin/setup/prepare/start", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if !licenseStatus(h.rc).Valid { + jsonOut(w, map[string]any{"ok": false, "error": "лицензия не активирована"}) + return + } + prepareStart(h.rc) + jsonOut(w, map[string]any{"ok": true}) + }) + mux.HandleFunc("/admin/setup/prepare/status", func(w http.ResponseWriter, r *http.Request) { + prepareRun.Lock() + running, finished, res, errText := prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err + prepareRun.Unlock() + items := prepareItems(h.rc, running, finished, errText) + pending := false // модули лицензии, которых на сервере ещё нет + for _, c := range release.Components { + if c.FromCore && moduleAllowed(h.rc, c.Feature) { + if _, err := os.Stat(c.Path); err != nil { + pending = true + } + } + } + updProg.Lock() + pg := updProg.s + updProg.Unlock() + busy := false // служба модуля ещё запускается + for _, it := range items { + if it.State == "run" { + busy = true + } + } + out := map[string]any{"running": running, "finished": finished, "items": items, "pending": pending, "busy": busy, "result": res, "error": errText} + if running && pg.Active { + out["stage"], out["done"], out["total"] = pg.Stage, pg.Done, pg.Total + } + jsonOut(w, out) + }) +} diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index ecbbc5e..5690f90 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -76,6 +76,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix) mux.HandleFunc("/admin/setup/license", h.saveLicense) mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate) + registerPrepare(mux, h) // «Подготовка модулей»: после ключа — модули лицензии, потом мастер mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow) mux.HandleFunc("/admin/setup/logo", h.saveLogo) mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo) @@ -1425,8 +1426,12 @@ func (h *setupHandlers) licenseActivate(w http.ResponseWriter, r *http.Request) setupFlash(w, r, "Лицензия: активация не удалась — "+err.Error()) return } - st := licenseStatus(h.rc) - setupFlash(w, r, fmt.Sprintf("Лицензия активирована: %s, план %s, %s", st.Tenant, st.Plan, st.Message)) + // дальше — «Подготовка модулей»: модули лицензии ставятся и запускаются, затем возврат туда, откуда пришли + next := safeReturnPath(r.FormValue("return_to")) + if strings.HasPrefix(next, "/admin/wizard") { + next = "/admin/wizard" // мастер сам откроется на первом несделанном шаге — теперь с шагами модулей лицензии + } + http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape(next), http.StatusSeeOther) } // licenseRenewNow — POST /admin/setup/license/renew: продлить ключ сейчас. diff --git a/internal/lkgateway/updater.go b/internal/lkgateway/updater.go index 681df49..8b02808 100644 --- a/internal/lkgateway/updater.go +++ b/internal/lkgateway/updater.go @@ -20,6 +20,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" @@ -340,6 +341,10 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro skip := false for _, a := range m.Artifacts { if a.Name == c.Name { + if fromLocalBundle(a, staging) { + log.Printf("lk-gateway: %s %s — из комплекта образа, без скачивания", c.Name, pickVersion(c.Available)) + continue + } if _, err := cl.DownloadArtifact(ctx, a, staging); err != nil { // Модуль сняли с лицензии, а bj ещё помнит прежний её состав (узнаёт при продлении): // сервер обновлений файл не отдаёт — пропускаем модуль, остальное ставим. @@ -408,10 +413,14 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro // allowed — разрешён ли лицензией признак компонента. func (u *Updater) allowed(feature string) bool { return moduleAllowed(u.rc, feature) } +var splitModulesMu sync.Mutex + // ensureSplitModules — выделенные из ядра модули, которые входят в лицензию, но ещё не стоят на этом // сервере (например, ядро обновилось прежним установщиком), ставятся сами: без своих служб разделы // этих модулей в bj не работают. Раз за запуск bj-server; ошибки — только в журнал и «Последнюю проверку». -func (u *Updater) ensureSplitModules(ctx context.Context) { +func (u *Updater) ensureSplitModules(ctx context.Context) (string, error) { + splitModulesMu.Lock() // запуск bj, активация ключа и «Подготовка модулей» — по очереди: второй увидит, что всё уже стоит + defer splitModulesMu.Unlock() var names []string installed := release.LoadInstalled(release.InstalledFile) for _, c := range release.Plan("", nil, installed, BuildVersion) { @@ -432,7 +441,7 @@ func (u *Updater) ensureSplitModules(ctx context.Context) { } } if len(names) == 0 { - return + return "", nil } cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) defer cancel() @@ -446,6 +455,36 @@ func (u *Updater) ensureSplitModules(ctx context.Context) { cfg := u.rc.Snapshot().Update cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC() _ = u.rc.SaveUpdateSettings(cfg) + return msg, err +} + +// localBundleDir — комплект компонентов, с которого сервер ставился с образа (bj-firstboot). +var localBundleDir = "/var/lib/bj/bundle" + +// fromLocalBundle — файл компонента из комплекта образа, если это тот же файл, что в выпуске (совпадает +// sha256): модуль, который уже лежит на диске, не качается заново — после ввода ключа он ставится сразу. +func fromLocalBundle(a release.Artifact, staging string) bool { + src := filepath.Join(localBundleDir, filepath.Base(a.File)) + sum, _, err := release.HashFile(src) + if err != nil || !strings.EqualFold(sum, a.SHA256) { + return false + } + in, err := os.Open(src) + if err != nil { + return false + } + defer in.Close() + dst := filepath.Join(staging, filepath.Base(a.File)) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) + if err != nil { + return false + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + _ = os.Remove(dst) + return false + } + return out.Close() == nil } // updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории: @@ -536,7 +575,7 @@ func (u *Updater) updateLoop(ctx context.Context) { case <-time.After(time.Minute): cleanupStaging(time.Hour) enforceModuleLicense(ctx, u.rc) - u.ensureSplitModules(ctx) + _, _ = u.ensureSplitModules(ctx) check() } for { diff --git a/internal/lkgateway/web/templates/admin_prepare.html b/internal/lkgateway/web/templates/admin_prepare.html new file mode 100644 index 0000000..f86152b --- /dev/null +++ b/internal/lkgateway/web/templates/admin_prepare.html @@ -0,0 +1,63 @@ +{{define "content"}} + +
+

Подготовка модулей

+

Ключ принят: {{.Tenant}}. bj ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.

+
+
+ {{if .Flash}}

✓ {{.Flash}}

{{end}} +

Лицензия: план {{.Plan}}, {{.Until}}

+

Проверяю состав лицензии…

+ + +
+ +{{end}}