Добавить установщик, проверку версий и инструкцию деплоя на сервер.
Интерактивная настройка домена и БД, эндпоинты /health и /version, скрипты install/check для Linux и Windows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shop/internal/version"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusWarn Status = "warn"
|
||||
StatusError Status = "error"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
Name string `json:"name"`
|
||||
Status Status `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Expected string `json:"expected,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
AppVersion string `json:"app_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
Items []Item `json:"checks"`
|
||||
}
|
||||
|
||||
func (r Report) Healthy() bool {
|
||||
for _, it := range r.Items {
|
||||
if it.Status == StatusError {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func AppInfo() Report {
|
||||
return Report{
|
||||
AppVersion: version.AppVersion,
|
||||
GoVersion: version.GoRuntime(),
|
||||
Items: []Item{
|
||||
{
|
||||
Name: "go_runtime",
|
||||
Status: goRuntimeStatus(),
|
||||
Detail: version.GoRuntime(),
|
||||
Expected: ">=" + version.MinGoVersion,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func goRuntimeStatus() Status {
|
||||
v := strings.TrimPrefix(version.GoRuntime(), "go")
|
||||
major, minor, ok := parseGoVersion(v)
|
||||
if !ok {
|
||||
return StatusWarn
|
||||
}
|
||||
expMajor, expMinor, _ := parseGoVersion(version.MinGoVersion)
|
||||
if major > expMajor || (major == expMajor && minor >= expMinor) {
|
||||
return StatusOK
|
||||
}
|
||||
return StatusWarn
|
||||
}
|
||||
|
||||
func parseGoVersion(v string) (major, minor int, ok bool) {
|
||||
parts := strings.Split(v, ".")
|
||||
if len(parts) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
major, err1 := strconv.Atoi(parts[0])
|
||||
minor, err2 := strconv.Atoi(parts[1])
|
||||
return major, minor, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
func WithDatabase(ctx context.Context, pool *pgxpool.Pool) (Report, error) {
|
||||
r := AppInfo()
|
||||
dbItems, err := Database(ctx, pool)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.Items = append(r.Items, dbItems...)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func Database(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var pgVersion string
|
||||
if err := pool.QueryRow(ctx, `SHOW server_version`).Scan(&pgVersion); err != nil {
|
||||
return []Item{{
|
||||
Name: "database",
|
||||
Status: StatusError,
|
||||
Detail: err.Error(),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
major, ok := postgresMajor(pgVersion)
|
||||
pgStatus := StatusOK
|
||||
detail := pgVersion
|
||||
expected := fmt.Sprintf("%d.x", version.ExpectedPostgresMajor)
|
||||
|
||||
if !ok {
|
||||
pgStatus = StatusWarn
|
||||
detail = pgVersion + " (не удалось определить major)"
|
||||
} else if major != version.ExpectedPostgresMajor {
|
||||
pgStatus = StatusWarn
|
||||
detail = fmt.Sprintf("%s (ожидается PostgreSQL %d)", pgVersion, version.ExpectedPostgresMajor)
|
||||
}
|
||||
|
||||
return []Item{
|
||||
{Name: "database", Status: StatusOK, Detail: "подключено"},
|
||||
{Name: "postgresql", Status: pgStatus, Detail: detail, Expected: expected},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var pgMajorRe = regexp.MustCompile(`^(\d+)`)
|
||||
|
||||
func postgresMajor(v string) (int, bool) {
|
||||
m := pgMajorRe.FindStringSubmatch(strings.TrimSpace(v))
|
||||
if len(m) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
func ToolVersions(ctx context.Context) []Item {
|
||||
var items []Item
|
||||
if out, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err == nil {
|
||||
items = append(items, Item{Name: "docker", Status: StatusOK, Detail: strings.TrimSpace(out)})
|
||||
} else {
|
||||
items = append(items, Item{Name: "docker", Status: StatusWarn, Detail: "не найден"})
|
||||
}
|
||||
if out, err := run(ctx, "docker", "compose", "version", "--short"); err == nil {
|
||||
items = append(items, Item{Name: "docker_compose", Status: StatusOK, Detail: strings.TrimSpace(out)})
|
||||
} else {
|
||||
items = append(items, Item{Name: "docker_compose", Status: StatusWarn, Detail: "не найден"})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out, err := cmd.Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func Merge(reports ...Report) Report {
|
||||
if len(reports) == 0 {
|
||||
return AppInfo()
|
||||
}
|
||||
out := reports[0]
|
||||
for _, r := range reports[1:] {
|
||||
if out.AppVersion == "" {
|
||||
out.AppVersion = r.AppVersion
|
||||
}
|
||||
if out.GoVersion == "" {
|
||||
out.GoVersion = r.GoVersion
|
||||
}
|
||||
out.Items = append(out.Items, r.Items...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"shop/internal/check"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
|
||||
return &HealthHandler{pool: pool}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
report, err := check.WithDatabase(ctx, h.pool)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
"version": report.AppVersion,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := "ok"
|
||||
code := http.StatusOK
|
||||
if !report.Healthy() {
|
||||
status = "degraded"
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
writeJSON(w, code, map[string]any{
|
||||
"status": status,
|
||||
"app_version": report.AppVersion,
|
||||
"go_version": report.GoVersion,
|
||||
"checks": report.Items,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Version(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
report, _ := check.WithDatabase(ctx, h.pool)
|
||||
writeJSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -63,8 +63,3 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("render home: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Health(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SiteDomain string
|
||||
CaddyEmail string
|
||||
HTTPPort string
|
||||
HTTPSPort string
|
||||
UseDockerDB bool
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBSSLMode string
|
||||
}
|
||||
|
||||
func RunInteractive(root string) (Config, error) {
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
fmt.Println("=== Установщик ShopNova ===")
|
||||
fmt.Println()
|
||||
|
||||
cfg := Config{}
|
||||
|
||||
cfg.SiteDomain = ask(in, "Домен сайта (например shop.example.com, Enter = localhost)", "localhost")
|
||||
cfg.CaddyEmail = ask(in, "Email для Let's Encrypt (Caddy)", "admin@localhost")
|
||||
cfg.HTTPPort = ask(in, "HTTP порт", "80")
|
||||
cfg.HTTPSPort = ask(in, "HTTPS порт", "443")
|
||||
|
||||
useDocker := askYesNo(in, "Использовать PostgreSQL из Docker Compose?", true)
|
||||
cfg.UseDockerDB = useDocker
|
||||
|
||||
if useDocker {
|
||||
cfg.DBHost = "postgres"
|
||||
cfg.DBPort = "5432"
|
||||
cfg.DBSSLMode = "require"
|
||||
fmt.Println("\n--- База данных (контейнер postgres) ---")
|
||||
} else {
|
||||
fmt.Println("\n--- База данных (внешний сервер) ---")
|
||||
cfg.DBHost = ask(in, "Хост БД", "localhost")
|
||||
cfg.DBPort = ask(in, "Порт БД", "5432")
|
||||
cfg.DBSSLMode = ask(in, "SSL mode (disable|require|verify-full)", "require")
|
||||
}
|
||||
|
||||
cfg.DBUser = ask(in, "Пользователь БД", "shop")
|
||||
cfg.DBPassword = askPassword(in, "Пароль БД")
|
||||
cfg.DBName = ask(in, "Имя базы данных", "shopdb")
|
||||
|
||||
if err := WriteFiles(root, cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ Созданы файлы: .env, caddy/Caddyfile")
|
||||
fmt.Println("\nДальше:")
|
||||
fmt.Println(" docker compose up --build -d")
|
||||
if !useLocalDomain(cfg.SiteDomain) {
|
||||
fmt.Printf(" Сайт: https://%s\n", cfg.SiteDomain)
|
||||
} else {
|
||||
fmt.Printf(" Сайт: http://localhost:%s\n", cfg.HTTPPort)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func WriteFiles(root string, cfg Config) error {
|
||||
envPath := filepath.Join(root, ".env")
|
||||
caddyPath := filepath.Join(root, "caddy", "Caddyfile")
|
||||
|
||||
if err := os.WriteFile(envPath, []byte(buildEnv(cfg)), 0o600); err != nil {
|
||||
return fmt.Errorf("write .env: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(caddyPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(caddyPath, []byte(buildCaddyfile(cfg)), 0o644); err != nil {
|
||||
return fmt.Errorf("write Caddyfile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnv(cfg Config) string {
|
||||
dbURL := DatabaseURL(cfg)
|
||||
lines := []string{
|
||||
"# Сгенерировано установщиком ShopNova",
|
||||
fmt.Sprintf("SITE_DOMAIN=%s", cfg.SiteDomain),
|
||||
fmt.Sprintf("CADDY_EMAIL=%s", cfg.CaddyEmail),
|
||||
fmt.Sprintf("HTTP_PORT=%s", cfg.HTTPPort),
|
||||
fmt.Sprintf("HTTPS_PORT=%s", cfg.HTTPSPort),
|
||||
"",
|
||||
fmt.Sprintf("POSTGRES_USER=%s", cfg.DBUser),
|
||||
fmt.Sprintf("POSTGRES_PASSWORD=%s", cfg.DBPassword),
|
||||
fmt.Sprintf("POSTGRES_DB=%s", cfg.DBName),
|
||||
"",
|
||||
fmt.Sprintf("DATABASE_URL=%s", dbURL),
|
||||
"APP_PORT=8080",
|
||||
"",
|
||||
fmt.Sprintf("DB_HOST=%s", cfg.DBHost),
|
||||
fmt.Sprintf("DB_PORT=%s", cfg.DBPort),
|
||||
fmt.Sprintf("DB_SSLMODE=%s", cfg.DBSSLMode),
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func DatabaseURL(cfg Config) string {
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(cfg.DBUser, cfg.DBPassword),
|
||||
Host: fmt.Sprintf("%s:%s", cfg.DBHost, cfg.DBPort),
|
||||
Path: cfg.DBName,
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("sslmode", cfg.DBSSLMode)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func buildCaddyfile(cfg Config) string {
|
||||
email := cfg.CaddyEmail
|
||||
if useLocalDomain(cfg.SiteDomain) {
|
||||
return fmt.Sprintf(`{
|
||||
email %s
|
||||
}
|
||||
|
||||
:80 {
|
||||
encode gzip zstd
|
||||
|
||||
@api path /health /version
|
||||
handle @api {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
handle /static/* {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
}
|
||||
}
|
||||
`, email)
|
||||
}
|
||||
|
||||
domain := strings.TrimSpace(cfg.SiteDomain)
|
||||
return fmt.Sprintf(`{
|
||||
email %s
|
||||
}
|
||||
|
||||
%s {
|
||||
encode gzip zstd
|
||||
|
||||
@api path /health /version
|
||||
handle @api {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
handle /static/* {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
}
|
||||
}
|
||||
|
||||
http://%s {
|
||||
redir https://{host}{uri} permanent
|
||||
}
|
||||
`, email, domain, domain)
|
||||
}
|
||||
|
||||
func useLocalDomain(d string) bool {
|
||||
d = strings.ToLower(strings.TrimSpace(d))
|
||||
return d == "" || d == "localhost" || d == "127.0.0.1" || d == "local"
|
||||
}
|
||||
|
||||
func ask(in *bufio.Reader, prompt, def string) string {
|
||||
if def != "" {
|
||||
fmt.Printf("%s [%s]: ", prompt, def)
|
||||
} else {
|
||||
fmt.Printf("%s: ", prompt)
|
||||
}
|
||||
line, _ := in.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return def
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func askPassword(in *bufio.Reader, prompt string) string {
|
||||
fmt.Printf("%s: ", prompt)
|
||||
line, _ := in.ReadString('\n')
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
func askYesNo(in *bufio.Reader, prompt string, def bool) bool {
|
||||
defStr := "y"
|
||||
if !def {
|
||||
defStr = "n"
|
||||
}
|
||||
for {
|
||||
ans := strings.ToLower(ask(in, prompt+" (y/n)", defStr))
|
||||
switch ans {
|
||||
case "y", "yes", "д", "да":
|
||||
return true
|
||||
case "n", "no", "н", "нет":
|
||||
return false
|
||||
}
|
||||
fmt.Println("Введите y или n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package version
|
||||
|
||||
import "runtime"
|
||||
|
||||
const (
|
||||
AppVersion = "1.0.0"
|
||||
ExpectedPostgresMajor = 17
|
||||
MinGoVersion = "1.22"
|
||||
)
|
||||
|
||||
func GoRuntime() string {
|
||||
return runtime.Version()
|
||||
}
|
||||
Reference in New Issue
Block a user