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, "'", `'\''`) + "'" }