goherence/internal/module/external/deploy.go
2026-09-11 10:17:25 +03:00

133 lines
5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package external
import (
"fmt"
"os"
"strings"
"github.com/vladimir/goherence/internal/ssh"
)
// detectRemoteArch определяет "goos/goarch" удалённого хоста через uname,
// чтобы выбрать нужный бинарник модуля из BinPath.
func detectRemoteArch(conn *ssh.Conn) (string, error) {
res, err := conn.Run("uname -s; uname -m")
if err != nil {
return "", fmt.Errorf("detectRemoteArch: %w", err)
}
lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
if len(lines) < 2 {
return "", fmt.Errorf("detectRemoteArch: неожиданный вывод uname: %q", res.Stdout)
}
goos := normalizeOS(lines[0])
goarch := normalizeArch(lines[1])
return goos + "/" + goarch, nil
}
func normalizeOS(uname string) string {
switch strings.TrimSpace(uname) {
case "Linux":
return "linux"
case "Darwin":
return "darwin"
default:
return strings.ToLower(strings.TrimSpace(uname))
}
}
func normalizeArch(uname string) string {
switch strings.TrimSpace(uname) {
case "x86_64", "amd64":
return "amd64"
case "aarch64", "arm64":
return "arm64"
default:
return strings.TrimSpace(uname)
}
}
// resolveBaseDir определяет директорию на хосте для доставленных модулей —
// под домашней директорией подключившегося пользователя, а не под /opt,
// который требует root. Если по какой-то причине $HOME недоступен (пустой
// вывод), откатываемся на /tmp — доступно всегда, но переживает только
// текущую сессию ОС на некоторых системах (tmpfs с очисткой при
// перезагрузке), так что это осознанно резервный, а не основной путь.
func resolveBaseDir(conn *ssh.Conn) (string, error) {
res, err := conn.Run("echo $HOME")
if err != nil {
return "", fmt.Errorf("resolveBaseDir: %w", err)
}
home := strings.TrimSpace(res.Stdout)
if home == "" || home == "$HOME" {
return "/tmp/goherence-modules", nil
}
return home + "/.goherence/modules", nil
}
// remotePath — куда на хосте кладётся конкретная версия бинарника модуля.
// Версия зашита в путь, так что старые версии просто остаются на диске
// как есть (без отдельной чистки в первой версии) — это сознательно
// простое решение вместо retention-политики, которой пока никто не просил.
func remotePath(baseDir string, m *Module) string {
return fmt.Sprintf("%s/%s-%s", baseDir, m.Manifest.Name, m.Manifest.Version)
}
// ensureDeployed проверяет через маркер-файл, лежит ли на хосте уже
// актуальная версия модуля, и копирует бинарник только если нужно.
// Все шаги, которые могут завершиться ошибкой на удалённой стороне
// (mkdir, test), явно проверяют exit code — раньше здесь проверялась
// только транспортная ошибка, и permission denied на mkdir тихо
// протекал в WriteFile, где всплывал уже нечитаемым EOF.
func ensureDeployed(conn *ssh.Conn, m *Module) (string, error) {
arch, err := detectRemoteArch(conn)
if err != nil {
return "", err
}
localBin, ok := m.BinPath[arch]
if !ok {
return "", fmt.Errorf("модуль %s: нет собранного бинарника под архитектуру %s "+
"(доступны: %v) — проверь кросс-компиляцию", m.Manifest.Name, arch, keysOf(m.BinPath))
}
baseDir, err := resolveBaseDir(conn)
if err != nil {
return "", err
}
target := remotePath(baseDir, m)
res, err := conn.Run("test -x " + shQuote(target))
if err == nil && res.ExitCode == 0 {
return target, nil // нужная версия уже на хосте — ничего не копируем
}
data, err := os.ReadFile(localBin)
if err != nil {
return "", fmt.Errorf("читаю локальный бинарник %s: %w", localBin, err)
}
mkdirRes, err := conn.Run("mkdir -p " + shQuote(baseDir))
if err != nil {
return "", fmt.Errorf("mkdir -p %s: %w", baseDir, err)
}
if mkdirRes.ExitCode != 0 {
return "", fmt.Errorf("mkdir -p %s: exit %d: %s", baseDir, mkdirRes.ExitCode, strings.TrimSpace(mkdirRes.Stderr))
}
if err := conn.WriteFile(target, data, "0755"); err != nil {
return "", fmt.Errorf("доставка модуля %s на хост: %w", m.Manifest.Name, err)
}
return target, nil
}
func keysOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}