package main import ( "fmt" "log" "math/rand" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "text/template" "time" ) // isAngieEstabOnPort проверяет есть ли активные ESTAB соединения Angie на данном порту func isAngieEstabOnPort(port int) bool { cmd := exec.Command("ss", "-tnap") output, err := cmd.Output() if err != nil { return false } portStr := fmt.Sprintf(":%d", port) for _, line := range strings.Split(string(output), "\n") { fields := strings.Fields(line) if len(fields) < 4 { continue } if fields[0] != "ESTAB" { continue } localAddr := fields[3] if strings.HasSuffix(localAddr, portStr) && strings.Contains(line, "angie") { return true } } return false } // isPortBusyByAnyProcess проверяет занят ли порт процессом который мешает биндингу. // Проверяет только LISTEN сокеты и docker-proxy ESTAB — соединения Angie не блокируют биндинг Docker. func isPortBusyByAnyProcess(port int) bool { cmd := exec.Command("ss", "-tnap") output, err := cmd.Output() if err != nil { return false } portStr := fmt.Sprintf(":%d", port) for _, line := range strings.Split(string(output), "\n") { fields := strings.Fields(line) if len(fields) < 4 { continue } localAddr := fields[3] // Local address:port if !strings.HasSuffix(localAddr, portStr) { continue } state := fields[0] // LISTEN — порт занят, нельзя биндить if state == "LISTEN" { return true } // ESTAB от docker-proxy — старый контейнер ещё не остановился if state == "ESTAB" && strings.Contains(line, "docker-proxy") { return true } // TIME-WAIT — Docker может биндить поверх TIME-WAIT через SO_REUSEADDR // поэтому не блокируем } return false } func recreateContainer(containerFullName, composeFile, containerDir, clientTitle string, resources []ResourceData, resourcePortMap map[int][]PortMapping, containerNum int) { log.Printf(" docker-compose.yml изменился, пересоздаём контейнер %s...", containerFullName) portsToFree := make([]int, 0) for _, res := range resources { if containerPorts, ok := resourcePortMap[res.L7ResourceID]; ok { if containerNum-1 < len(containerPorts) { for _, dockerPort := range containerPorts[containerNum-1].HTTPPorts { portsToFree = append(portsToFree, dockerPort) } for _, dockerPort := range containerPorts[containerNum-1].HTTPSPorts { portsToFree = append(portsToFree, dockerPort) } } } } log.Printf(" → Останавливаем старый контейнер...") cmd := exec.Command("docker-compose", "-f", composeFile, "down") cmd.Dir = containerDir output, err := cmd.CombinedOutput() if err != nil { log.Printf(" ⚠ Ошибка остановки контейнера %s: %v, output: %s", containerFullName, err, string(output)) } log.Printf(" → Ожидаем остановки контейнера...") containerStillExists := false for i := 0; i < 30; i++ { exists, _ := checkContainerStatus(containerFullName) if !exists { break } containerStillExists = true time.Sleep(1 * time.Second) } if containerStillExists { exists, _ := checkContainerStatus(containerFullName) if exists { log.Printf(" → Контейнер не остановился, принудительное удаление...") if err := forceRemoveContainer(containerFullName); err != nil { log.Printf(" ⚠ Не удалось принудительно удалить контейнер: %v", err) return } } } // Reload Angie после остановки контейнера чтобы закрыть keepalive ESTAB соединения log.Printf(" → Reload Angie для закрытия keepalive соединений к остановленному контейнеру...") reloadAngie() // Ждём пока Angie закроет ESTAB соединения к портам старого контейнера log.Printf(" → Ожидаем закрытия ESTAB соединений Angie на портах %v...", portsToFree) for attempt := 0; attempt < 30; attempt++ { angieEstab := false for _, port := range portsToFree { if isAngieEstabOnPort(port) { angieEstab = true break } } if !angieEstab { log.Printf(" ✓ Все ESTAB соединения Angie закрыты (попытка %d)", attempt+1) break } if attempt%5 == 0 && attempt > 0 { log.Printf(" → Angie ещё держит соединения (попытка %d/30)...", attempt+1) } time.Sleep(1 * time.Second) } time.Sleep(1 * time.Second) if len(portsToFree) > 0 { log.Printf(" → Ожидаем освобождения %d портов...", len(portsToFree)) allPortsFree := false for attempt := 0; attempt < 180; attempt++ { portsBusy := 0 busyPortsList := make([]int, 0) for _, port := range portsToFree { if isPortBusyByAnyProcess(port) { portsBusy++ busyPortsList = append(busyPortsList, port) } } if portsBusy == 0 { allPortsFree = true log.Printf(" ✓ Все порты освобождены (попытка %d)", attempt+1) break } if attempt%5 == 0 && attempt > 0 { log.Printf(" → Ещё заняты %d портов: %v (попытка %d/180)", portsBusy, busyPortsList, attempt+1) } time.Sleep(1 * time.Second) } if !allPortsFree { log.Printf(" ⚠ Порты не освободились за 180 секунд!") log.Printf(" → Проверьте вручную: sudo lsof -i :%d", portsToFree[0]) return } } time.Sleep(2 * time.Second) log.Printf(" → Запускаем новый контейнер...") cmd = exec.Command("docker-compose", "-f", composeFile, "up", "-d") cmd.Dir = containerDir output, err = cmd.CombinedOutput() log.Printf(" → Вывод docker-compose: %s", string(output)) if err != nil && strings.Contains(string(output), "address already in use") { log.Printf(" ⚠ Порт всё ещё занят после ожидания освобождения!") log.Printf(" → Проверьте: docker ps -a | grep %s", clientTitle) log.Printf(" → Найдите процесс: sudo lsof -i | grep LISTEN | grep 127.0.0.1") return } time.Sleep(5 * time.Second) _, isRunning := checkContainerStatus(containerFullName) if !isRunning { log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) return } log.Printf(" ✓ Контейнер %s пересоздан с новыми портами", containerFullName) } func startContainer(containerFullName, composeFile, containerDir string) { log.Printf(" Контейнер %s остановлен, запускаем...", containerFullName) cmd := exec.Command("docker-compose", "-f", composeFile, "up", "-d") cmd.Dir = containerDir output, _ := cmd.CombinedOutput() log.Printf(" → Вывод docker-compose: %s", string(output)) time.Sleep(5 * time.Second) _, isRunning := checkContainerStatus(containerFullName) if !isRunning { log.Printf(" ⚠ Контейнер %s не запустился. Попробуйте вручную: cd %s && docker-compose up -d", containerFullName, containerDir) return } log.Printf(" ✓ Контейнер %s запущен", containerFullName) } // getContainerStartedAt возвращает время запуска контейнера func getContainerStartedAt(containerName string) (time.Time, error) { cmd := exec.Command("docker", "inspect", "--format", "{{.State.StartedAt}}", containerName) output, err := cmd.Output() if err != nil { return time.Time{}, fmt.Errorf("ошибка получения времени запуска: %w", err) } startedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(output))) if err != nil { return time.Time{}, fmt.Errorf("ошибка парсинга времени: %w", err) } return startedAt, nil } func checkContainerStatus(containerName string) (exists bool, running bool) { cmd := exec.Command("docker", "ps", "-a", "--filter", fmt.Sprintf("name=^%s$", containerName), "--format", "{{.Names}}") output, err := cmd.Output() if err != nil { return false, false } if strings.TrimSpace(string(output)) == "" { return false, false } cmd = exec.Command("docker", "ps", "--filter", fmt.Sprintf("name=^%s$", containerName), "--format", "{{.Names}}") output, err = cmd.Output() if err != nil { return true, false } running = strings.TrimSpace(string(output)) != "" return true, running } func forceRemoveContainer(containerName string) error { cmd := exec.Command("docker", "stop", "-t", "10", containerName) output, err := cmd.CombinedOutput() if err != nil { log.Printf(" ⚠ Ошибка остановки контейнера: %v, output: %s", err, string(output)) } cmd = exec.Command("docker", "rm", "-f", containerName) output, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf("ошибка удаления контейнера: %w, output: %s", err, string(output)) } return nil } func testNginxConfig(containerName string) error { log.Printf(" Проверка конфигурации nginx в контейнере %s", containerName) cmd := exec.Command("docker", "exec", containerName, "/opt/ptaf/bin/ptaf-nginx", "-t", "-c", "/opt/ptaf/conf/nginx.conf") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("конфигурация невалидна: %w, output: %s", err, string(output)) } log.Printf(" Конфигурация валидна: %s", strings.TrimSpace(string(output))) return nil } func reloadNginx(containerName string) error { log.Printf(" Перезагрузка nginx в контейнере %s", containerName) cmd := exec.Command("docker", "exec", containerName, "/opt/ptaf/bin/ptaf-nginx", "-s", "reload") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ошибка перезагрузки: %w, output: %s", err, string(output)) } log.Printf(" Nginx перезагружен успешно") return nil } func markContainerForDrain(containerName string, isMigration bool) error { timestamp := time.Now().Unix() markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerName) content := fmt.Sprintf("%d,%t", timestamp, isMigration) return os.WriteFile(markerFile, []byte(content), 0644) } func getContainerDrainInfo(containerName string) (timestamp int64, isMigration bool, found bool) { markerFile := fmt.Sprintf("/tmp/ptaf-drain-%s", containerName) data, err := os.ReadFile(markerFile) if err != nil { return 0, false, false } content := strings.TrimSpace(string(data)) parts := strings.Split(content, ",") if len(parts) < 1 { return 0, false, false } ts, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return 0, false, false } migration := false if len(parts) >= 2 { migration = parts[1] == "true" } return ts, migration, true } func markAngieConfigForDrain(clientTitle string, isMigration bool) error { markerFile := fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle) content := fmt.Sprintf("%d", time.Now().Unix()) if isMigration { content += ":migration" } return os.WriteFile(markerFile, []byte(content), 0644) } func getAngieConfigDrainInfo(clientTitle string) (timestamp int64, isMigration bool, found bool) { markerFile := fmt.Sprintf("/tmp/ptaf-angie-drain-%s", clientTitle) data, err := os.ReadFile(markerFile) if err != nil { return 0, false, false } parts := strings.SplitN(strings.TrimSpace(string(data)), ":", 2) ts, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { return 0, false, false } migration := len(parts) == 2 && parts[1] == "migration" return ts, migration, true } func stopAndRemoveContainer(containerName, composeFile, containerDir string) error { log.Printf(" Остановка контейнера %s", containerName) cmd := exec.Command("docker-compose", "-f", composeFile, "down", "-v") cmd.Dir = containerDir output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ошибка остановки контейнера: %w, output: %s", err, string(output)) } os.Remove(fmt.Sprintf("/tmp/ptaf-drain-%s", containerName)) log.Printf(" ✓ Контейнер %s остановлен и удалён", containerName) return nil } func removeContainerFiles(clientTitle string, containerNum int) error { baseDir := filepath.Join("/home/install/ptaf", clientTitle) containerName := fmt.Sprintf("%s-ptaf-agent%03d", clientTitle, containerNum) for _, dir := range []string{ filepath.Join(baseDir, containerName), filepath.Join("/home/install/conf/ptaf-nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum)), filepath.Join("/var/log/ptaf_nginx", clientTitle, fmt.Sprintf("ptaf-agent%03d", containerNum)), } { if _, err := os.Stat(dir); err == nil { log.Printf(" Удаление директории: %s", dir) if err := os.RemoveAll(dir); err != nil { log.Printf(" ⚠ Ошибка удаления %s: %v", dir, err) } else { log.Printf(" ✓ Удалена директория: %s", dir) } } } return nil } func cleanupClientDirectoryIfEmpty(clientTitle string) error { clientConfDir := fmt.Sprintf("/home/install/conf/ptaf-nginx/%s", clientTitle) if _, err := os.Stat(clientConfDir); os.IsNotExist(err) { return nil } entries, err := os.ReadDir(clientConfDir) if err != nil { return fmt.Errorf("ошибка чтения директории %s: %w", clientConfDir, err) } hasContainers := false for _, entry := range entries { if entry.IsDir() && strings.HasPrefix(entry.Name(), "ptaf-agent") { hasContainers = true break } } if !hasContainers { log.Printf(" ✓ У клиента %s не осталось контейнеров, удаляем директории клиента", clientTitle) os.RemoveAll(clientConfDir) ptafDir := fmt.Sprintf("/home/install/ptaf/%s", clientTitle) if _, err := os.Stat(ptafDir); err == nil { os.RemoveAll(ptafDir) } hashFile := fmt.Sprintf("/etc/angie/http.d/angie-ptaf-%s.conf.hash", clientTitle) if _, err := os.Stat(hashFile); err == nil { os.Remove(hashFile) } } return nil } func reloadAngie() { log.Printf(" Перезагрузка Angie...") cmd := exec.Command("systemctl", "reload", "angie") if err := cmd.Run(); err != nil { log.Printf(" ⚠ Ошибка перезагрузки Angie: %v", err) } else { log.Printf(" ✓ Angie перезагружен") } } // generateNginxConf генерирует основной nginx.conf для PTAF func generateNginxConf(filePath string, config Config, ptafConfig string, existingPodIP string, debug bool, ptafFallbackCode string) (bool, error) { var randomIP string if existingPodIP != "" { randomIP = existingPodIP log.Printf(" → Используется существующий POD_IP: %s", randomIP) } else { randomIP = fmt.Sprintf("135.%d.%d.%d", rand.Intn(254)+1, rand.Intn(254)+1, rand.Intn(254)+1) log.Printf(" → Сгенерирован новый POD_IP: %s", randomIP) } pathParts := strings.Split(filePath, "/") clientTitle := "" containerNum := "001" for i, part := range pathParts { if part == "ptaf-nginx" && i+1 < len(pathParts) { clientTitle = pathParts[i+1] } if strings.HasPrefix(part, "ptaf-agent") { containerNum = strings.TrimPrefix(part, "ptaf-agent") } } hostname := fmt.Sprintf("ptaf-%s-agent%s", clientTitle, containerNum) log.Printf(" → clientTitle=%s, containerNum=%s, hostname=%s", clientTitle, containerNum, hostname) // Настройки логирования зависят от режима debug ptafLogLevel := "INFO" debugEnv := "false" errorLogLevel := "notice" if debug { ptafLogLevel = "DEBUG" debugEnv = "true" errorLogLevel = "debug" } content := fmt.Sprintf(`user ptaf; master_process on; worker_processes %d; worker_rlimit_nofile 1048576; daemon off; load_module /opt/ptaf/lib/ngx_wrapper.so; env POD_IP=%s; env PTAF_LOG_LEVEL=%s; env DEBUG=%s; env HOSTNAME=%s; error_log stderr %s; pid /opt/ptaf/logs/nginx.pid; events { worker_connections %d; accept_mutex off; } http { include /opt/ptaf/conf/mime.types; default_type application/octet-stream; log_format waf escape=json '{' '"SID":$http_x_sid,' '"server_name":"$server_name",' '"request_id":"$http_x_request_id",' '"app_name":"$host",' '"proxyed_to":"$upstream_addr",' '"upstream_status":"$upstream_status",' '"upstream_response_length":"$upstream_response_length",' '"upstream_response_time":"$upstream_response_time",' '"upstream_bytes_sent":"$upstream_bytes_sent",' '"server_port":$server_port,' '"server_addr":"$server_addr",' '"server_protocol":"$server_protocol",' '"remote_addr":"$remote_addr",' '"response_status_code":$status,' '"response_body_bytes_sent":$body_bytes_sent,' '"http_x_forwarded_for":"$http_x_forwarded_for",' '"uri":"$uri",' '"http_user_agent":"$http_user_agent",' '"query_string":"$query_string",' '"request_method":"$request_method",' '"request_time":$request_time,' '"@timestamp":"$time_iso8601"' '}'; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; log_format main_upstream '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" $upstream_response_time'; access_log /var/log/nginx/%s_access.log waf; client_max_body_size 0; sendfile on; gzip on; server_names_hash_bucket_size 512; server_tokens off; ptaf_fallback %s; proxy_read_timeout 120s; proxy_send_timeout 120s; send_timeout 60s; keepalive_timeout 80s; keepalive_requests 1000; proxy_buffer_size 64k; proxy_buffers 8 64k; proxy_busy_buffers_size 128k; proxy_connect_timeout 60s; resolver_timeout 30s; geo $dollar { default "$"; } map $http_upgrade $proxy_http_connection { default upgrade; '' ''; } # Map для условного Connection header (используется в server блоках) map $http_upgrade $proxy_connection { default upgrade; '' ''; } # Map для определения backend host из X-Original-Host заголовка map $http_x_original_host $backend_host { "" $host; default $http_x_original_host; } ptaf_config %s; include /opt/ptaf/conf/conf.d/*.conf; } `, config.WorkerProcesses, randomIP, ptafLogLevel, debugEnv, hostname, errorLogLevel, config.WorkerConnections, clientTitle, ptafFallbackCode, ptafConfig) return writeFileIfChanged(filePath, content) } // generateMimeTypes генерирует файл mime.types func generateMimeTypes(filePath string) (bool, error) { content := `types { text/html html htm shtml; text/css css; text/xml xml; image/gif gif; image/jpeg jpeg jpg; application/javascript js; application/atom+xml atom; application/rss+xml rss; text/mathml mml; text/plain txt; text/vnd.sun.j2me.app-descriptor jad; text/vnd.wap.wml wml; text/x-component htc; image/avif avif; image/png png; image/svg+xml svg svgz; image/tiff tif tiff; image/vnd.wap.wbmp wbmp; image/webp webp; image/x-icon ico; image/x-jng jng; image/x-ms-bmp bmp; font/woff woff; font/woff2 woff2; application/java-archive jar war ear; application/json json; application/mac-binhex40 hqx; application/msword doc; application/pdf pdf; application/postscript ps eps ai; application/rtf rtf; application/vnd.apple.mpegurl m3u8; application/vnd.google-earth.kml+xml kml; application/vnd.google-earth.kmz kmz; application/vnd.ms-excel xls; application/vnd.ms-fontobject eot; application/vnd.ms-powerpoint ppt; application/vnd.oasis.opendocument.graphics odg; application/vnd.oasis.opendocument.presentation odp; application/vnd.oasis.opendocument.spreadsheet ods; application/vnd.oasis.opendocument.text odt; application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; application/vnd.wap.wmlc wmlc; application/wasm wasm; application/x-7z-compressed 7z; application/x-cocoa cco; application/x-java-archive-diff jardiff; application/x-java-jnlp-file jnlp; application/x-makeself run; application/x-perl pl pm; application/x-pilot prc pdb; application/x-rar-compressed rar; application/x-redhat-package-manager rpm; application/x-sea sea; application/x-shockwave-flash swf; application/x-stuffit sit; application/x-tcl tcl tk; application/x-x509-ca-cert der pem crt; application/x-xpinstall xpi; application/xhtml+xml xhtml; application/xspf+xml xspf; application/zip zip; application/octet-stream bin exe dll; application/octet-stream deb; application/octet-stream dmg; application/octet-stream iso img; application/octet-stream msi msp msm; audio/midi mid midi kar; audio/mpeg mp3; audio/ogg ogg; audio/x-m4a m4a; audio/x-realaudio ra; video/3gpp 3gpp 3gp; video/mp2t ts; video/mp4 mp4; video/mpeg mpeg mpg; video/quicktime mov; video/webm webm; video/x-flv flv; video/x-m4v m4v; video/x-mng mng; video/x-ms-asf asx asf; video/x-ms-wmv wmv; video/x-msvideo avi; } ` return writeFileIfChanged(filePath, content) } // generateDockerCompose генерирует docker-compose.yml файл func generateDockerCompose(filePath string, config Config, clientTitle string, clientInfo *ClientInfo, containerNum int, resources []ResourceData, portMappings []PortMapping) (bool, error) { type ComposeData struct { ClientTitle string ContainerNum int DockerImage string PTAFConfig string WorkerProcesses int WorkerConnections int FluentBitEnabled bool FluentBitPort int HasMTLS bool Debug bool ShmSize int SortedHTTPPorts []SortedPortMapping SortedHTTPSPorts []SortedPortMapping } hasMTLS := false for _, res := range resources { if res.AppsSettings.LocationNginxCustom.Valid && hasMTLSCertificates(res.AppsSettings.LocationNginxCustom.String) { hasMTLS = true break } if res.AppsSettings.ServerNginxCustom.Valid && hasMTLSCertificates(res.AppsSettings.ServerNginxCustom.String) { hasMTLS = true break } } var allHTTPPorts []SortedPortMapping var allHTTPSPorts []SortedPortMapping for _, pm := range portMappings { var httpKeys []int for customPort := range pm.HTTPPorts { httpKeys = append(httpKeys, customPort) } sort.Ints(httpKeys) for _, customPort := range httpKeys { allHTTPPorts = append(allHTTPPorts, SortedPortMapping{CustomPort: customPort, DockerPort: pm.HTTPPorts[customPort]}) } var httpsKeys []int for customPort := range pm.HTTPSPorts { httpsKeys = append(httpsKeys, customPort) } sort.Ints(httpsKeys) for _, customPort := range httpsKeys { allHTTPSPorts = append(allHTTPSPorts, SortedPortMapping{CustomPort: customPort, DockerPort: pm.HTTPSPorts[customPort]}) } } sort.Slice(allHTTPPorts, func(i, j int) bool { return allHTTPPorts[i].DockerPort < allHTTPPorts[j].DockerPort }) sort.Slice(allHTTPSPorts, func(i, j int) bool { return allHTTPSPorts[i].DockerPort < allHTTPSPorts[j].DockerPort }) dockerImage := config.DockerImage if clientInfo.DockerImage.Valid && clientInfo.DockerImage.String != "" { dockerImage = clientInfo.DockerImage.String } shmSize := clientInfo.ShmSize if shmSize <= 0 { shmSize = 1 } data := ComposeData{ ClientTitle: clientTitle, ContainerNum: containerNum, DockerImage: dockerImage, PTAFConfig: "", WorkerProcesses: config.WorkerProcesses, WorkerConnections: config.WorkerConnections, FluentBitEnabled: false, HasMTLS: hasMTLS, Debug: clientInfo.Debug, ShmSize: shmSize, SortedHTTPPorts: allHTTPPorts, SortedHTTPSPorts: allHTTPSPorts, } if clientInfo.PTAFConfig.Valid { data.PTAFConfig = clientInfo.PTAFConfig.String } if clientInfo.FluentBitPort.Valid { data.FluentBitEnabled = true data.FluentBitPort = int(clientInfo.FluentBitPort.Int64) } tmpl := `services: ptaf-{{.ClientTitle}}-agent{{printf "%03d" .ContainerNum}}: image: '{{.DockerImage}}' restart: unless-stopped shm_size: '{{.ShmSize}}gb' container_name: ptaf_{{.ClientTitle}}_{{printf "%03d" .ContainerNum}} hostname: ptaf_{{.ClientTitle}}_{{printf "%03d" .ContainerNum}} environment: - CONNECTION_STRING={{.PTAFConfig}} - WORKER_PROCESSES={{.WorkerProcesses}} - WORKER_CONNECTIONS={{.WorkerConnections}} - INCLUDE_CUSTOM_CONFIG=True - PTAF_CONFIGURATOR_DISABLED=1 {{- if .FluentBitEnabled}} - EVENT_SENDER_ENABLED=1 - EVENT_SENDER_HOST=172.17.0.1 - EVENT_SENDER_PORT={{.FluentBitPort}} - EVENT_SENDER_TIMEOUT=3 - EVENT_WEB_UI_URL=https://af.ptcloud.ru/events/attacks/%s/general/ {{- end}} {{- if .Debug}} - PTAF_DEBUG=True - ERROR_LOG_LEVEL=debug {{- end}} sysctls: kernel.sem: "32000 1024000000 500 32000" ulimits: nofile: soft: 1048576 hard: 1048576 ports: {{- range .SortedHTTPPorts}} - 127.0.0.1:{{.DockerPort}}:{{.CustomPort}}/tcp {{- end}} {{- range .SortedHTTPSPorts}} - 127.0.0.1:{{.DockerPort}}:{{.CustomPort}}/tcp {{- end}} volumes: - /var/log/ptaf_nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}:/var/log/nginx - /home/install/conf/ptaf-nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}/conf.d:/opt/ptaf/conf/conf.d - /home/install/conf/ptaf-nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}/nginx.conf:/opt/ptaf/conf/nginx.conf - /home/install/conf/ptaf-nginx/{{.ClientTitle}}/ptaf-agent{{printf "%03d" .ContainerNum}}/mime.types:/opt/ptaf/conf/mime.types {{- if .HasMTLS}} - /etc/ssl/mtls:/opt/ptaf/conf/main {{- end}} networks: - ptaf-{{.ClientTitle}}-net logging: driver: "json-file" options: max-size: "100m" max-file: "5" networks: ptaf-{{.ClientTitle}}-net: driver: bridge ` t, err := template.New("compose").Parse(tmpl) if err != nil { return false, fmt.Errorf("ошибка парсинга шаблона: %w", err) } var buf strings.Builder if err = t.Execute(&buf, data); err != nil { return false, fmt.Errorf("ошибка генерации файла: %w", err) } return writeFileIfChanged(filePath, buf.String()) }