GameOver1337 / GitClone Public
К репозиторию
8.3 KB
internal/services/build.go
8.3 KB
1package services
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "sync"
13 "time"
14)
15
16// BuildService управляет автоматической сборкой проектов.
17type BuildService struct {
18 BuildsDir string // корень директорий сборки и логов
19 Git *GitService
20
21 mu sync.Mutex
22 locks map[string]*sync.Mutex
23}
24
25func NewBuildService(buildsDir string, git *GitService) *BuildService {
26 return &BuildService{
27 BuildsDir: buildsDir,
28 Git: git,
29 locks: make(map[string]*sync.Mutex),
30 }
31}
32
33// LockRepo сериализует сборки одного репозитория (общая рабочая директория).
34func (s *BuildService) LockRepo(repoName string) func() {
35 s.mu.Lock()
36 lock, ok := s.locks[repoName]
37 if !ok {
38 lock = &sync.Mutex{}
39 s.locks[repoName] = lock
40 }
41 s.mu.Unlock()
42
43 lock.Lock()
44 return lock.Unlock
45}
46
47// DetectBuildType определяет тип сборки по файлам в директории.
48func DetectBuildType(repoDir string) string {
49 files, err := os.ReadDir(repoDir)
50 if err != nil {
51 return ""
52 }
53 for _, f := range files {
54 if f.IsDir() {
55 continue
56 }
57 switch f.Name() {
58 case "Dockerfile":
59 return "docker"
60 case "Makefile", "makefile", "GNUmakefile":
61 return "make"
62 case "go.mod":
63 return "go"
64 case "package.json":
65 return "npm"
66 case "requirements.txt", "setup.py", "pyproject.toml":
67 return "python"
68 case "Cargo.toml":
69 return "rust"
70 case "pom.xml":
71 return "maven"
72 }
73 }
74 return ""
75}
76
77// LogPath возвращает путь лог-файла для конкретной сборки.
78func (s *BuildService) LogPath(buildID int) string {
79 return filepath.Join(s.BuildsDir, fmt.Sprintf("build-%d.log", buildID))
80}
81
82// WorkDir — рабочая копия репозитория для сборки.
83func (s *BuildService) WorkDir(repoName string) string {
84 return filepath.Join(s.BuildsDir, "work", filepath.FromSlash(repoName))
85}
86
87// Build выполняет сборку проекта в рабочей директории, пишет лог в logPath.
88// Возвращает true при успехе.
89func (s *BuildService) Build(repoDir, buildType, logPath string) bool {
90 logFile, err := os.Create(logPath)
91 if err != nil {
92 return false
93 }
94 defer logFile.Close()
95
96 fmt.Fprintf(logFile, "=== build started ===\ntype: %s\ndir: %s\n\n", buildType, repoDir)
97
98 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
99 defer cancel()
100
101 // У сервисов (systemd и т.п.) часто минимальный PATH, а go/npm/make
102 // установлены в /usr/local/go/bin или /usr/local/bin.
103 env := buildEnv()
104 run := func(name string, args ...string) *exec.Cmd {
105 c := exec.CommandContext(ctx, lookPathWithEnv(name, env), args...)
106 c.Env = env
107 return c
108 }
109
110 var cmd *exec.Cmd
111 switch buildType {
112 case "docker":
113 cmd = run("docker", "build", "-t", "repo-build", ".")
114 case "make":
115 cmd = run("make")
116 case "go":
117 cmd = run("go", "build", "./...")
118 case "npm":
119 installArgs := []string{"install"}
120 if _, err := os.Stat(filepath.Join(repoDir, "package-lock.json")); err == nil {
121 installArgs = []string{"ci"}
122 }
123 install := run("npm", installArgs...)
124 install.Dir = repoDir
125 install.Stdout, install.Stderr = logFile, logFile
126 if err := install.Run(); err != nil {
127 fmt.Fprintf(logFile, "\n=== npm install FAILED: %v ===\n", err)
128 return false
129 }
130 cmd = run("npm", "run", "build")
131 case "python":
132 cmd = run("python", "-m", "compileall", ".")
133 case "rust":
134 cmd = run("cargo", "build")
135 case "maven":
136 cmd = run("mvn", "package", "-DskipTests")
137 default:
138 fmt.Fprintf(logFile, "unsupported build type: %s\n", buildType)
139 return false
140 }
141
142 cmd.Dir = repoDir
143 cmd.Stdout = logFile
144 cmd.Stderr = logFile
145
146 if err := cmd.Run(); err != nil {
147 if ctx.Err() == context.DeadlineExceeded {
148 fmt.Fprintf(logFile, "\n=== build TIMEOUT after %s ===\n", 20*time.Minute)
149 } else {
150 fmt.Fprintf(logFile, "\n=== build FAILED: %v ===\n", err)
151 if strings.Contains(err.Error(), "executable file not found") {
152 fmt.Fprintf(logFile, "Подсказка: инструмент не найден в PATH. Установите его или добавьте каталог в PATH сервиса.\n")
153 }
154 }
155 return false
156 }
157 fmt.Fprintf(logFile, "\n=== build SUCCESS ===\n")
158 return true
159}
160
161// RunBuildForRepo полный цикл сборки: обновить копию → детект → собрать.
162// Возвращает (buildType, success).
163func (s *BuildService) RunBuildForRepo(repoName, branch string, buildID int) (string, bool) {
164 workDir := s.WorkDir(repoName)
165 if err := s.Git.CloneToWorking(repoName, branch, workDir); err != nil {
166 s.writeLog(buildID, fmt.Sprintf("clone failed: %v\n", err))
167 return "", false
168 }
169
170 buildType := DetectBuildType(workDir)
171 if buildType == "" {
172 s.writeLog(buildID, "no supported build system detected (need go.mod / package.json / Makefile / Dockerfile / requirements.txt / Cargo.toml / pom.xml)\n")
173 return "", false
174 }
175
176 ok := s.Build(workDir, buildType, s.LogPath(buildID))
177 return buildType, ok
178}
179
180func (s *BuildService) writeLog(buildID int, text string) {
181 f, err := os.Create(s.LogPath(buildID))
182 if err != nil {
183 return
184 }
185 defer f.Close()
186 _, _ = f.WriteString(text)
187}
188
189// buildEnv дополняет PATH каталогами, куда обычно ставят go/npm/make.
190// Это важно для systemd-сервисов с минимальным окружением.
191func buildEnv() []string {
192 env := os.Environ()
193 sep := string(os.PathListSeparator)
194
195 path := ""
196 pathIndex := -1
197 for i, e := range env {
198 if strings.HasPrefix(strings.ToUpper(e), "PATH=") {
199 path = e[len("PATH="):]
200 pathIndex = i
201 break
202 }
203 }
204
205 seen := map[string]bool{}
206 parts := filepath.SplitList(path)
207 for _, dir := range parts {
208 seen[dir] = true
209 }
210
211 var extra []string
212 if goroot := os.Getenv("GOROOT"); goroot != "" {
213 extra = append(extra, filepath.Join(goroot, "bin"))
214 }
215 extra = append(extra,
216 "/usr/local/go/bin",
217 "/usr/lib/go/bin",
218 "/opt/go/bin",
219 "/snap/bin",
220 "/usr/local/bin",
221 "/usr/bin",
222 "/bin",
223 )
224 // Debian ставит Go в /usr/lib/go-<версия>/bin.
225 if matches, err := filepath.Glob("/usr/lib/go-*/bin"); err == nil {
226 extra = append(extra, matches...)
227 }
228 if home, err := os.UserHomeDir(); err == nil {
229 extra = append(extra,
230 filepath.Join(home, "go", "bin"),
231 filepath.Join(home, ".local", "bin"),
232 )
233 }
234 if runtime.GOOS == "windows" {
235 extra = append(extra, `C:\Program Files\Go\bin`)
236 }
237
238 for _, dir := range extra {
239 if dir != "" && !seen[dir] {
240 parts = append(parts, dir)
241 seen[dir] = true
242 }
243 }
244
245 newPath := "PATH=" + strings.Join(parts, sep)
246 if pathIndex >= 0 {
247 env[pathIndex] = newPath
248 } else {
249 env = append(env, newPath)
250 }
251 return env
252}
253
254// lookPathWithEnv ищет исполняемый файл в PATH из переданного окружения.
255func lookPathWithEnv(name string, env []string) string {
256 if p, err := exec.LookPath(name); err == nil {
257 return p
258 }
259
260 path := ""
261 for _, e := range env {
262 if strings.HasPrefix(strings.ToUpper(e), "PATH=") {
263 path = e[len("PATH="):]
264 break
265 }
266 }
267
268 exts := []string{""}
269 if runtime.GOOS == "windows" {
270 exts = []string{".exe", ".cmd", ".bat", ""}
271 }
272 for _, dir := range filepath.SplitList(path) {
273 for _, ext := range exts {
274 p := filepath.Join(dir, name+ext)
275 if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
276 return p
277 }
278 }
279 }
280 return name
281}
282
283// ReadLog возвращает содержимое лог-файла сборки (только последние 200 КБ, без загрузки всего файла).
284func (s *BuildService) ReadLog(buildID int) string {
285 path := s.LogPath(buildID)
286 f, err := os.Open(path)
287 if err != nil {
288 return "(лог недоступен)"
289 }
290 defer f.Close()
291
292 const maxLog = 200 * 1024
293 offset := int64(0)
294 if fi, err := f.Stat(); err == nil && fi.Size() > maxLog {
295 offset = fi.Size() - maxLog
296 }
297 if offset > 0 {
298 if _, err := f.Seek(offset, io.SeekStart); err != nil {
299 // fallback to start
300 f.Seek(0, io.SeekStart)
301 offset = 0
302 }
303 }
304
305 data, err := io.ReadAll(f)
306 if err != nil {
307 return "(лог недоступен)"
308 }
309 if offset > 0 {
310 return "...(обрезано начало)...\n" + strings.TrimLeft(string(data), "\n")
311 }
312 return string(data)
313}