Добавить установщик, проверку версий и инструкцию деплоя на сервер.
Интерактивная настройка домена и БД, эндпоинты /health и /version, скрипты install/check для Linux и Windows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+12
-5
@@ -1,10 +1,17 @@
|
|||||||
|
# Скопируйте в .env или запустите: go run ./cmd/install
|
||||||
|
|
||||||
|
SITE_DOMAIN=localhost
|
||||||
|
CADDY_EMAIL=admin@localhost
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
|
||||||
POSTGRES_USER=shop
|
POSTGRES_USER=shop
|
||||||
POSTGRES_PASSWORD=shop_secret_change_me
|
POSTGRES_PASSWORD=shop_secret_change_me
|
||||||
POSTGRES_DB=shopdb
|
POSTGRES_DB=shopdb
|
||||||
|
|
||||||
HTTP_PORT=80
|
DATABASE_URL=postgres://shop:shop_secret_change_me@postgres:5432/shopdb?sslmode=require
|
||||||
HTTPS_PORT=443
|
APP_PORT=8080
|
||||||
|
|
||||||
# Для HTTPS с Let's Encrypt в Caddyfile
|
DB_HOST=postgres
|
||||||
# SITE_DOMAIN=shop.example.com
|
DB_PORT=5432
|
||||||
# CADDY_EMAIL=you@example.com
|
DB_SSLMODE=require
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,8 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /app/server ./cmd/server
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
|
||||||
|
-o /app/server ./cmd/server
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,87 @@
|
|||||||
|
|
||||||
Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose.
|
Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose.
|
||||||
|
|
||||||
## Стек
|
Репозиторий: https://git.evilfox.cc/test/shop3.git
|
||||||
|
|
||||||
- Go 1.22, `pgx/v5`
|
## Быстрая установка на сервере
|
||||||
- PostgreSQL 17 (SSL)
|
|
||||||
- Caddy 2
|
|
||||||
- Docker Compose
|
|
||||||
|
|
||||||
## Запуск
|
Требования: **Git**, **Docker**, **Docker Compose** (плагин `docker compose`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
# 1. Клонировать
|
||||||
|
git clone https://git.evilfox.cc/test/shop3.git
|
||||||
|
cd shop3
|
||||||
|
|
||||||
|
# 2. Установщик (домен + база данных → .env и caddy/Caddyfile)
|
||||||
|
chmod +x install.sh check.sh
|
||||||
|
./install.sh
|
||||||
|
|
||||||
|
# 3. Проверка версий
|
||||||
|
./check.sh
|
||||||
|
|
||||||
|
# 4. Запуск
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Сайт: http://localhost
|
Одной цепочкой (после клона введите ответы установщика):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.evilfox.cc/test/shop3.git && cd shop3 && chmod +x install.sh check.sh && ./install.sh && ./check.sh && docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
С Go на сервере вместо `install.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/install
|
||||||
|
go run ./cmd/check
|
||||||
|
```
|
||||||
|
|
||||||
|
Без Go — установщик сам запустится в контейнере `golang:1.22-alpine`.
|
||||||
|
|
||||||
|
### Обновление на сервере
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd shop3
|
||||||
|
git pull
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Полезные команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # статус контейнеров
|
||||||
|
docker compose logs -f # логи
|
||||||
|
curl -s http://localhost/health | jq
|
||||||
|
curl -s http://localhost/version | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Сайт: `http://localhost` или `https://ваш-домен` (если указали в установщике).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка на Windows (локально)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://git.evilfox.cc/test/shop3.git
|
||||||
|
cd shop3
|
||||||
|
.\install.ps1
|
||||||
|
.\check.ps1
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка версий
|
||||||
|
|
||||||
|
Проверяет Go, Docker, Docker Compose и PostgreSQL (**ожидается 17.x**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./check.sh
|
||||||
|
# или: go run ./cmd/check
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска сервера:
|
||||||
|
|
||||||
|
- `GET /health` — статус и проверки
|
||||||
|
- `GET /version` — версии приложения, Go и PostgreSQL
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
|
|
||||||
@@ -24,4 +90,4 @@ docker compose up --build -d
|
|||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменная `DATABASE_URL` обязательна (см. `.env.example`).
|
`DATABASE_URL` задаётся в `.env` (см. `.env.example` или установщик).
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
:80 {
|
:80 {
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
@health path /health
|
@api path /health /version
|
||||||
handle @health {
|
handle @api {
|
||||||
reverse_proxy app:8080
|
reverse_proxy app:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Проверка версий: Go, Docker, PostgreSQL
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
go run ./cmd/check
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
if command -v go >/dev/null 2>&1; then
|
||||||
|
go run ./cmd/check
|
||||||
|
else
|
||||||
|
docker run --rm -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/check
|
||||||
|
fi
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shop/internal/check"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
loadDotEnv()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
report := check.AppInfo()
|
||||||
|
report.Items = append(report.Items, check.ToolVersions(ctx)...)
|
||||||
|
|
||||||
|
dbURL := os.Getenv("DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
report.Items = append(report.Items, check.Item{
|
||||||
|
Name: "database",
|
||||||
|
Status: check.StatusWarn,
|
||||||
|
Detail: "DATABASE_URL не задан — запустите: go run ./cmd/install",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pool, err := pgxpool.New(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
report.Items = append(report.Items, check.Item{
|
||||||
|
Name: "database",
|
||||||
|
Status: check.StatusError,
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
defer pool.Close()
|
||||||
|
dbItems, err := check.Database(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
report.Items = append(report.Items, check.Item{
|
||||||
|
Name: "database",
|
||||||
|
Status: check.StatusError,
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
report.Items = append(report.Items, dbItems...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(report)
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
printSummary(report)
|
||||||
|
|
||||||
|
if !report.Healthy() {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printSummary(r check.Report) {
|
||||||
|
fmt.Printf("ShopNova %s | %s\n\n", r.AppVersion, r.GoVersion)
|
||||||
|
for _, it := range r.Items {
|
||||||
|
mark := "✓"
|
||||||
|
switch it.Status {
|
||||||
|
case check.StatusWarn:
|
||||||
|
mark = "!"
|
||||||
|
case check.StatusError:
|
||||||
|
mark = "✗"
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %s %-18s %s", mark, it.Name+":", it.Detail)
|
||||||
|
if it.Expected != "" {
|
||||||
|
line += " (ожидается " + it.Expected + ")"
|
||||||
|
}
|
||||||
|
fmt.Println(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDotEnv() {
|
||||||
|
root, _ := os.Getwd()
|
||||||
|
path := filepath.Join(root, ".env")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range splitLines(string(data)) {
|
||||||
|
line = trimComment(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := splitKV(line)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if os.Getenv(k) == "" {
|
||||||
|
_ = os.Setenv(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
var lines []string
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '\n' {
|
||||||
|
lines = append(lines, s[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(s) {
|
||||||
|
lines = append(lines, s[start:])
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimComment(s string) string {
|
||||||
|
if i := indexByte(s, '#'); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\r') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && s[0] == ' ' {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitKV(s string) (string, string, bool) {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == '=' {
|
||||||
|
return s[:i], s[i+1:], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(s string, c byte) int {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"shop/internal/setup"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "docker-compose.yml")); os.IsNotExist(err) {
|
||||||
|
fmt.Fprintln(os.Stderr, "запустите установщик из корня проекта (где docker-compose.yml)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := setup.RunInteractive(root); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "установка не завершена: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -11,10 +11,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"shop/internal/check"
|
||||||
"shop/internal/config"
|
"shop/internal/config"
|
||||||
"shop/internal/database"
|
"shop/internal/database"
|
||||||
"shop/internal/handlers"
|
"shop/internal/handlers"
|
||||||
"shop/internal/repository"
|
"shop/internal/repository"
|
||||||
|
"shop/internal/version"
|
||||||
"shop/internal/web"
|
"shop/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +33,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
|
startupReport, err := check.WithDatabase(ctx, pool)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("version check: %v", err)
|
||||||
|
}
|
||||||
|
for _, it := range startupReport.Items {
|
||||||
|
if it.Name == "postgresql" && it.Status == check.StatusWarn {
|
||||||
|
log.Printf("warning: %s — %s", it.Name, it.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("ShopNova %s | Go %s | PostgreSQL check OK", version.AppVersion, version.GoRuntime())
|
||||||
|
|
||||||
tmpl, err := loadTemplates()
|
tmpl, err := loadTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("templates: %v", err)
|
log.Fatalf("templates: %v", err)
|
||||||
@@ -38,6 +51,7 @@ func main() {
|
|||||||
|
|
||||||
products := repository.NewProductRepository(pool)
|
products := repository.NewProductRepository(pool)
|
||||||
home := handlers.NewHomeHandler(products, tmpl)
|
home := handlers.NewHomeHandler(products, tmpl)
|
||||||
|
health := handlers.NewHealthHandler(pool)
|
||||||
|
|
||||||
staticSub, err := fs.Sub(web.StaticFS, "static")
|
staticSub, err := fs.Sub(web.StaticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,7 +60,8 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||||
mux.Handle("GET /health", http.HandlerFunc(handlers.Health))
|
mux.HandleFunc("GET /health", health.Health)
|
||||||
|
mux.HandleFunc("GET /version", health.Version)
|
||||||
mux.Handle("GET /", home)
|
mux.Handle("GET /", home)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ services:
|
|||||||
container_name: shop-caddy
|
container_name: shop-caddy
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
|
environment:
|
||||||
|
SITE_DOMAIN: ${SITE_DOMAIN:-localhost}
|
||||||
|
CADDY_EMAIL: ${CADDY_EMAIL:-admin@localhost}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-80}:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
- "${HTTPS_PORT:-443}:443"
|
- "${HTTPS_PORT:-443}:443"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Интерактивная установка: домен, база данных, .env, Caddyfile
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
|
go run ./cmd/install
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
if command -v go >/dev/null 2>&1; then
|
||||||
|
go run ./cmd/install
|
||||||
|
else
|
||||||
|
echo "Go не найден — запуск через Docker..."
|
||||||
|
docker run --rm -it -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/install
|
||||||
|
fi
|
||||||
@@ -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)
|
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