feat(bootstrap): первичная настройка инстанса и CLI-команды

- internal/bootstrap: создание инстанс-админа, главного сервера, ролей
  «Администратор» и «Пользователь» (all permissions / дефолтный набор §6.3),
  общей текстовой комнаты; повторный запуск идемпотентен
- админ получает флаг is_instance_admin и системный бейдж instance_admin
- CLI: glchat bootstrap-admin, reset-password, make-admin, remove-admin
  (работают внутри контейнера через DATA_DIR)
- auth: UserByEmail, SetPassword (отзывает сессии), RegisterAdmin (в обход
  флага регистрации — bootstrap выполняет установщик)
- тесты: создание админа и главного сервера, идемпотентность, автовступление
  нового пользователя с ролью @user
This commit is contained in:
2026-09-19 21:37:13 +03:00
parent f61751ed26
commit c0537d3c17
6 changed files with 699 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"glchat/internal/auth"
"glchat/internal/bootstrap"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/store"
)
// runCLI обрабатывает подкоманды обслуживания (AGENT.md 10.7). Команды,
// требующие БД, работают внутри контейнера: путь берётся из DATA_DIR.
func runCLI(args []string) int {
switch args[0] {
case "bootstrap-admin":
return cliBootstrapAdmin(args[1:])
case "reset-password":
return cliResetPassword(args[1:])
case "make-admin", "remove-admin":
return cliSetInstanceAdmin(args[0], args[1:])
default:
fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0])
return 2
}
}
func withServices(fn func(ctx context.Context, st *store.Store, authService *auth.Service, cfg config.Config) error) int {
ctx := context.Background()
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
db, err := database.Open(ctx, database.Options{
Path: cfg.DatabasePath,
ReadPool: cfg.ReadPoolSize,
CacheSize: cfg.SQLiteCache,
MmapSize: cfg.SQLiteMmap,
Migrate: true,
})
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
defer func() { _ = db.Close() }()
st := store.New(db)
authService, err := auth.New(ctx, cfg, st, newLogger(cfg))
if err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
if err := fn(ctx, st, authService, cfg); err != nil {
fmt.Fprintf(os.Stderr, "glchat: %v\n", err)
return 1
}
return 0
}
func cliBootstrapAdmin(args []string) int {
flags := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError)
email := flags.String("email", "", "email администратора инстанса")
password := flags.String("password", "", "пароль администратора")
username := flags.String("username", "", "username (по умолчанию — часть email)")
instanceName := flags.String("instance-name", "", "название инстанса")
mainGuild := flags.String("main-guild", "", "название главного сервера")
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat bootstrap-admin: укажите --email")
return 2
}
if *password == "" {
*password = os.Getenv("ADMIN_PASSWORD")
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, cfg config.Config) error {
runner := bootstrap.New(st, authService, newLogger(cfg))
result, err := runner.Run(ctx, bootstrap.Options{
Email: *email,
Password: *password,
Username: *username,
InstanceName: *instanceName,
MainGuild: *mainGuild,
})
if err != nil {
return err
}
if result.CreatedUser {
fmt.Println("инстанс-администратор создан")
} else {
fmt.Println("инстанс-администратор уже существует (права подтверждены)")
}
fmt.Printf("главный сервер: %d (создан: %t)\n", result.GuildID, result.CreatedGuild)
fmt.Printf("роли: администратор=%d пользователь=%d\n", result.AdminRoleID, result.MemberRoleID)
return nil
})
}
func cliResetPassword(args []string) int {
flags := flag.NewFlagSet("reset-password", flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
password := flags.String("password", "", "новый пароль")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintln(os.Stderr, "glchat reset-password: укажите email")
return 2
}
if *password == "" {
fmt.Fprintln(os.Stderr, "glchat reset-password: укажите --password")
return 2
}
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := authService.SetPassword(ctx, user.ID, *password); err != nil {
return err
}
fmt.Printf("пароль пользователя %s обновлён, все сессии отозваны\n", user.Username)
return nil
})
}
func cliSetInstanceAdmin(command string, args []string) int {
flags := flag.NewFlagSet(command, flag.ContinueOnError)
email := flags.String("email", "", "email пользователя")
positional := flags.Args()
if err := flags.Parse(args); err != nil {
return 2
}
if *email == "" && len(positional) > 0 {
*email = positional[0]
}
if *email == "" {
fmt.Fprintf(os.Stderr, "glchat %s: укажите email\n", command)
return 2
}
grant := command == "make-admin"
return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error {
user, err := authService.UserByEmail(ctx, *email)
if err != nil {
return fmt.Errorf("пользователь с таким email не найден")
}
if err := st.SetInstanceAdmin(ctx, user.ID, grant); err != nil {
return err
}
badges := []string{}
if grant {
// Бейдж администратора выдаётся только системой (AGENT.md 7.2).
badges = append(badges, "instance_admin")
}
if err := st.SetUserBadges(ctx, user.ID, badges); err != nil {
return err
}
action := "выданы"
if !grant {
action = "сняты"
}
fmt.Printf("права администратора сервера %s пользователю %s\n", action, user.Username)
return nil
})
}
+8
View File
@@ -33,6 +33,14 @@ func main() {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin. Остальные команды реализует shell-обёртка glchat.
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin":
os.Exit(runCLI(os.Args[1:]))
}
}
if err := run(); err != nil {
slog.Error("glchat exited with error", slog.Any("error", err))
os.Exit(1)