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:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user