diff --git a/article/struct-exaple.md b/article/struct-exaple.md new file mode 100644 index 0000000..46c9158 --- /dev/null +++ b/article/struct-exaple.md @@ -0,0 +1,1884 @@ +# Настройка Gitea Actions для Go проекта: Полное руководство + +```metadata +id: 4 +readTime: 15-20 минут +date: 2025-07-27 18:00 +author: Direct-Dev(aka Антон Кузнецов) +level: Средний +tags: #gitea #gitea-actions #ci-cd #go #docker #devops #automation #k3s +version: 1.0.0 +``` + +## Содержание + +1. [Введение](#введение) +2. [Подготовка проекта](#подготовка-проекта) +3. [Настройка Gitea Actions](#настройка-gitea-actions) +4. [Создание workflow файла](#создание-workflow-файла) +5. [Настройка секретов](#настройка-секретов) +6. [Тестирование и запуск](#тестирование-и-запуск) +7. [Мониторинг и отладка](#мониторинг-и-отладка) +8. [Заключение](#заключение) +A. [Инфраструктура](#инфраструктура) +B. [Установка Gitea в кластере K3s](#установка-gitea-в-кластере-k3s) +C. [Настройка Gitea Runner в LXC контейнере](#настройка-gitea-runner-в-lxc-контейнере) + +## Введение + +Gitea Actions — это встроенная система непрерывной интеграции и развертывания (CI/CD) в Gitea, которая позволяет автоматизировать процессы сборки, тестирования и развертывания ваших проектов. Большой плюс этой системы в том, что она достаточна не требовательна к ресурсам и может быть развернута в собственном изолированном окружении. + +В этой статье мы рассмотрим как работать с данной системой, на примере настройки Gitea Actions для Go проекта с автоматической сборкой мультиплатформенных бинарников, созданием Docker образов и публикацией релизов. + +### Итак, что мы будем делать + +- Настроим автоматическую сборку Go приложения для разных платформ +- Создадим Docker образы для Linux AMD64 и ARM64 +- Настроим публикацию в Docker Hub +- Автоматизируем создание релизов с бинарниками +- Сделаем коммит в отдельную ветку - которую можно связать с CD системой (flux/ArgoCD) +- Настроим триггеры на основе Git тегов + +## Подготовка проекта + +### Структура проекта + +Собственно функциональная часть проекта Go не блещет оригинальностью и имеет следующую простую структуру: + +``` text +hello_gitea/ +├── .gitea/ # Конфигурация Gitea Actions + └── workflows/ + └── build.yaml # Workflow для сборки +├── main.go # Основной код приложения +├── go.mod # Зависимости Go +├── go.sum # Хеши зависимостей +├── Dockerfile # Docker образ +├── Dockerfile.builder # Docker образ для образа-билдера проекта +├── scripts/ # Вспомогательные скрипты + └── release-interactive.sh # Скрипт для пушинга релиза +├── README.md # Документация +└── .gitignore # Исключения Git +``` + +### Анализ кода + +Для упрощения востприятия не применяются сложные архитектурные паттерны и концепции - нам надо просто минимальное Go приложение. Тем не менее это работоспособный http api сервер, который можно расширить парой тройкой эндпойнтов и использовать в других проектах как заглушку или тестовый api. + +Основная задача статьи не в написании api сервера на Go, а в автоматизации процесса его сборки. + +Наше приложение — это простой REST API сервер на Go с использованием Gin framework: + +```go +package main + +import ( + "net/http" + "os" + "github.com/gin-gonic/gin" +) + +const version = "1.0.0" + +func main() { + gin.SetMode(gin.ReleaseMode) + r := gin.Default() + + // CORS middleware + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + }) + + // Endpoints + r.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "version": version, + }) + }) + + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "Hello, World!", + "version": version, + }) + }) + + // API group + api := r.Group("/api/v1") + { + api.GET("/info", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "service": "hello-api", + "version": version, + "status": "running", + }) + }) + + api.POST("/echo", func(c *gin.Context) { + var request struct { + Message string `json:"message"` + } + + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid JSON", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "echo": request.Message, + "version": version, + }) + }) + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + r.Run(":" + port) +} +``` + +### Dockerfile + +Помимо того, что мы будем собирать бинарники для разных платформ, мы также настроим сборку docker image в котором будем +запускать наш сервер api - это может быть полезным для развертывания нашего приложения, если мы настроим такое - например +через ArgoCD или flux (впрочем это тема отдельнйо статьи). + +Итак для контейнеризации используем многоэтапную сборку, что как вещают мудрецы является якобы полезным и правильным. +Не будем спорить поэтому вот: + +```dockerfile +# Build stage +FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder + +RUN apk --no-cache add git ca-certificates +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG TARGETPLATFORM +ARG BUILDPLATFORM +ARG TARGETOS +ARG TARGETARCH + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o hello-api main.go + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates jq + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app +COPY --from=builder /app/hello-api . +RUN chown appuser:appgroup hello-api + +USER appuser +EXPOSE 8080 +CMD ["./hello-api"] +``` + +## Настройка Gitea Actions + +### Включение Actions в Gitea + +1. **Проверьте версию Gitea** + + Gitea Actions доступны начиная с версии 1.17.0. Убедитесь, что ваш сервер Gitea поддерживает Actions. + +2. **Включите Actions в настройках репозитория** + + - Перейдите в настройки репозитория + - Найдите раздел "Actions" + - Включите "Enable Actions" + +### Создание токена доступа + +1. **Создайте токен для Actions** + + - Перейдите в настройки профиля → "Applications" + - Создайте новый токен с правами на репозиторий + - Скопируйте токен (он понадобится позже) + +## Создание workflow файла + +### Структура директории + +Создайте директорию `.gitea/workflows/` в корне вашего проекта: + +```bash +mkdir -p .gitea/workflows +``` + +### Основной workflow файл + +Создайте файл `.gitea/workflows/build.yaml`: + +```yaml +name: Conditional Release Build +on: + push: + tags: + - v* + +jobs: + debug-conditions: + runs-on: ubuntu-latest + steps: + - name: Show build conditions + run: | + echo "Build conditions:" + echo " BUILD_CREATE_RELEASE: ${{ vars.BUILD_CREATE_RELEASE == 'true' }}" + echo " BUILD_CREATE_DOCKER_IMAGE: ${{ vars.BUILD_CREATE_DOCKER_IMAGE == 'true' }}" + echo " BUILD_UPDATE_RELEASE_BRANCH: ${{ vars.BUILD_UPDATE_RELEASE_BRANCH == 'true' }}" + + create-release: + runs-on: ubuntu-latest + if: ${{ vars.BUILD_CREATE_RELEASE == 'true' }} + container: + image: ${{ secrets.DOCKERHUB_USERNAME }}/my-build-golang-runner:latest + steps: + - name: Checkout repository + run: | + git clone https://oauth2:${{ secrets.GITEATOKEN }}@direct-dev.ru/gitea/GiteaAdmin/hello_gitea.git hello_gitea + cd hello_gitea + git checkout ${{ github.ref }} + + - name: Setup Go + run: | + git --version + go version + jq --version + + - name: Build all binaries + run: | + cd hello_gitea + + # Проверяем, изменились ли зависимости + if [ ! -f go.sum ] || ! go mod verify >/dev/null 2>&1; then + echo "Dependencies changed, downloading..." + go mod download + fi + + mkdir -p bin + echo "Building for all platforms..." + + # Build for all platforms using direct go build commands + echo "Building for linux amd64..." + GOOS=linux GOARCH=amd64 go build -o bin/hello-api-linux-amd64 main.go + + echo "Building for linux arm64..." + GOOS=linux GOARCH=arm64 go build -o bin/hello-api-linux-arm64 main.go + + echo "Building for windows amd64..." + GOOS=windows GOARCH=amd64 go build -o bin/hello-api-windows-amd64.exe main.go + + echo "Building for darwin amd64..." + GOOS=darwin GOARCH=amd64 go build -o bin/hello-api-darwin-amd64 main.go + + echo "Building for darwin arm64..." + GOOS=darwin GOARCH=arm64 go build -o bin/hello-api-darwin-arm64 main.go + + # Create archives + echo "Creating archives..." + cd bin + + # Create archives with correct file names + tar -czf hello-api-linux-amd64.tar.gz hello-api-linux-amd64 + tar -czf hello-api-linux-arm64.tar.gz hello-api-linux-arm64 + tar -czf hello-api-windows-amd64.tar.gz hello-api-windows-amd64.exe + tar -czf hello-api-darwin-amd64.tar.gz hello-api-darwin-amd64 + tar -czf hello-api-darwin-arm64.tar.gz hello-api-darwin-arm64 + + echo "Listing bin directory..." + ls -la + + - name: Create Release + run: | + cd hello_gitea + # Create release using Gitea API + echo "Creating release..." + curl -X POST \ + -H "Authorization: token ${{ secrets.GITEATOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{ + "tag_name": "${{ github.ref_name }}", + "name": "Release ${{ github.ref_name }}", + "body": "Automated release with multi-platform binaries and Docker image", + "draft": false, + "prerelease": false + }' \ + "https://direct-dev.ru/gitea/api/v1/repos/GiteaAdmin/hello_gitea/releases" + + echo "Getting release id..." + RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITEATOKEN }}" \ + "https://direct-dev.ru/gitea/api/v1/repos/GiteaAdmin/hello_gitea/releases/tags/${{ github.ref_name }}" | \ + jq -r '.id') + + # Upload all binaries + echo "Uploading assets..." + for file in bin/*.tar.gz; do + echo "Uploading $file..." + curl -X POST \ + -H "Authorization: token ${{ secrets.GITEATOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @$file \ + "https://direct-dev.ru/gitea/api/v1/repos/GiteaAdmin/hello_gitea/releases/$RELEASE_ID/assets?name=$(basename $file)" + done + + echo "Release created successfully" + + create-docker-image: + runs-on: ubuntu-latest + # needs: create-release + if: ${{ vars.BUILD_CREATE_DOCKER_IMAGE == 'true' }} + container: + image: docker:28.3.2-dind + steps: + - name: Checkout repository + run: | + apk add --no-cache git + + echo "=== GitHub Variables ===" + echo "github.ref = ${{ github.ref }}" + echo "github.ref_name = ${{ github.ref_name }}" + echo "github.sha = ${{ github.sha }}" + echo "github.repository = ${{ github.repository }}" + echo "========================" + git clone https://oauth2:${{ secrets.GITEATOKEN }}@direct-dev.ru/gitea/GiteaAdmin/hello_gitea.git hello_gitea + cd hello_gitea + git checkout ${{ github.ref }} + + - name: Setup Docker Buildx + run: | + # Docker is already installed in docker:dind image + docker --version + echo "Setting up Docker Buildx for multi-platform builds..." + # Setup Docker Buildx for multi-platform builds + docker buildx create --name go-buildx --use + docker buildx inspect --bootstrap + + - name: Login to Docker Hub + run: | + echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Build multi-platform Docker images + run: | + cd hello_gitea + echo "Building multi-platform images using buildx..." + # Build multi-platform images using buildx + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/hello-api:${{ github.ref_name }} \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/hello-api:latest \ + --push \ + . + echo "Multi-platform images built successfully" + + - name: Remove buildx + run: | + echo "Removing buildx..." + docker buildx rm go-buildx + + update-to-release-branch: + runs-on: ubuntu-latest + # needs: create-docker-image + if: ${{ vars.BUILD_UPDATE_RELEASE_BRANCH == 'true' }} + container: + image: docker:28.3.2-dind + steps: + - name: Create Release Branch + run: | + apk add --no-cache git + git clone https://oauth2:${{ secrets.GITEATOKEN }}@direct-dev.ru/gitea/GiteaAdmin/hello_gitea.git hello_gitea + cd hello_gitea + + git config user.email "info@direct-dev.ru" + git config user.name "Direct-Dev-Robot" + + if git ls-remote --heads origin release | grep -q release; then + git checkout release + git pull origin release + else + git checkout -b release + fi + + git reset --hard ${{ github.ref_name }} + git push origin release --force +``` + +### Разберемся как работает workflow + +**Триггеры:** + +- Workflow запускается при пуше в Gitea тега, начинающегося с `v*` (например, `v1.1.29`) + +**Jobs:** + +у всех jobs имеется условный оператор выполнения +`if: ${{ vars.BUILD_UPDATE_RELEASE_BRANCH == 'true' }}` +то есть для того, чтобы тот или иной job запустился нужно установить соответствующую переменную в настройках репозитория в Gitea `https://direct-dev.ru/gitea/GiteaAdmin/hello_gitea/settings/actions/variables` + +1 **create-release:** + +- Эта таска запускается первой +- Управляется переменной BUILD_CREATE_RELEASE +- Использует контейнер собранный на базе golang:1.24 для сборки бинарников (image: ${{ secrets.DOCKERHUB_USERNAME }}/my-build-golang-runner:latest) +- Собирает бинарники для всех платформ (Linux, Windows, macOS) +- Создает архивы с бинарниками +- Создает релиз с именем текущей версии через Gitea API +- Загружает бинарники как assets релиза + +для работы данного job используется кастомный образ - если бы мы использовали просто golang:1.24, то при каждом запуске данного job необходимо было бы скачивать jq и устанавливаться в контейнере (возможно со временем, что-то еще потребовалось бы ...) + +```yaml + - name: Setup Go container + run: | + # Install jq for JSON parsing + apt-get update && apt-get install -y jq + git --version + go version + jq --version +``` + +**решение в лоб:** применено, чтобы убрать эту повторяющуюся работу при каждом выполнении workflow - сделан свой образ - "${DOCKERHUB_USERNAME}"/my-build-golang-runner:latest + +``` Dockerfile +# базовый образ +FROM golang:1.24 + +# Устанавливаем пакеты (одинаково работают на amd64/arm64) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git \ + ca-certificates \ + jq && \ + rm -rf /var/lib/apt/lists/* + +# Создаем рабочую директорию +WORKDIR /app + +# Копируем файлы зависимостей +COPY go.mod go.sum ./ + +# Предварительно загружаем все зависимости +RUN go mod download && go mod verify + +# Устанавливаем переменные окружения +ENV GOPATH=/go +ENV PATH=$PATH:/go/bin:/usr/local/bin + +# (Опционально) Можно добавить команду по умолчанию +CMD ["bash"] +``` + +Сначала собрал его и запушил (желательно с мультиплатформенностью) на каком-то локальном АРМ (не раннере) + +```bash +#!/bin/bash + +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag ${DOCKERHUB_USERNAME:-defaultdockeruser}/my-build-golang-runner:latest \ + --push \ + -f Dockerfile_for_runner_image \ + . +``` + +решение рабочее, но надо всегда иметь под рукой комп с buildx - лучше автоматизировать все и сделать задачу для сборки на раннере `.gitea/workflows/build-builder.yaml` + +```yaml +name: Build Builder Docker Image +on: + push: + tags: + - builder-* + +jobs: + create-builder-docker-image: + runs-on: ubuntu-latest + container: + image: docker:28.3.2-dind + steps: + - name: Checkout repository + run: | + # Install git + apk add --no-cache git + + echo "=== GitHub Variables ===" + echo "github.ref = ${{ github.ref }}" + echo "github.ref_name = ${{ github.ref_name }}" + echo "github.sha = ${{ github.sha }}" + echo "github.repository = ${{ github.repository }}" + echo "========================" + echo "Cloning..." + git clone https://oauth2:${{ secrets.GITEATOKEN }}@direct-dev.ru/gitea/GiteaAdmin/hello_gitea.git hello_gitea + cd hello_gitea + echo "Checkout to ${{ github.ref }} ..." + git checkout ${{ github.ref }} + + - name: Setup Docker Buildx + run: | + # Docker is already installed in docker:dind image + echo "look at docker version" + docker --version + # Setup Docker Buildx for multi-platform builds + echo "setup buildx" + docker buildx create --name builder-builx --use + docker buildx inspect --bootstrap + + - name: Login to Docker Hub + run: | + echo "login to docker hub ..." + echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin + + - name: Build multi-platform Docker images + run: | + cd hello_gitea + echo "Build multi-platform images using buildx ..." + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/my-build-golang-runner:${{ github.ref_name }} \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/my-build-golang-runner:latest \ + --push \ + -f Dockerfile.builder \ + . + + - name: Remove buildx + run: | + docker buildx rm builder-builx +``` + +эта задача будет запущена на ранере при пуше тега с префиксом `builder-` + +2 **create-docker-image:** - создание образов docker с нашим проектом + +- Управляется переменной BUILD_CREATE_DOCKER_IMAGE +- Используем Docker-in-Docker контейнер (image: docker:28.3.2-dind) +- Настраиваем Docker Buildx для мультиплатформенной сборки (docker buildx create --use docker buildx inspect --bootstrap) +- Авторизуемся в Docker Hub (echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin) +- Собираем образы для Linux AMD64 и ARM64 (команда docker buildx build \ ...) +- Публикуем образы с тегом версии и `latest` (опция --push команды docker buildx build ...) + +чтобы авторизация сработала на докерхабе надо внести секреты DOCKERHUB_TOKEN, DOCKERHUB_USERNAME или вцелом для всего инстанса gitea в настройках инстанса или в настройках конкретного репозитория +я использовал докерхаб, но можно заморочиться и настроить работу с приватным репозиторием ... + +Уже после ряда тестовых релизо выяснил, что запуски workflow `build.yaml` да собственно как и `build-builder.yaml` порождают на хосте раннера зависшие докер контейнеры buildx + +```text + +СONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +2a9ad63c5a31 moby/buildkit:buildx-stable-1 "buildkitd --allow-i…" 11 seconds ago Up 10 seconds buildx_buildkit_test-buldx0 +843eee192570 moby/buildkit:buildx-stable-1 "buildkitd --allow-i…" 22 minutes ago Up 22 minutes buildx_buildkit_modest_haibt0 + +``` + +Это происходит потому, что раннер запускает докер в докере контейнер также +docker run --privileged -it --rm -v /var/run/docker.sock:/var/run/docker.sock docker:28.3.2-dind sh +то есть пробрасывает хостовый сокет докер демона в контейнер и когда там выполняется `docker buildx create --use` контейнер запускается на хосте а не внутри docker:28.3.2-dind + +Со временем это может превратиться в утечку ресурсов + +Решения как минимум два - добавить параметр --name - `docker buildx create --name go-buildx --use` - тогда подхватится существующий контейнер или создастся новый. Второй способ удалять buildx `docker buildx rm --name go-buildx` + +3 **update-to-release-branch:** - создание ветки release в том случае если ее нет и вставка туда нашего коммита - далее по комиту в этой ветке можно настроить систему развертывания (например ArgoCD) + +- Управляется переменной BUILD_UPDATE_RELEASE_BRANCH +- можно добавить зависимость от create-docker-image если нужно `needs: create-docker-image` +- Используем Docker-in-Docker контейнер (image: docker:28.3.2-dind) - там уже есть git и образ скачан +- Пушим наш тег в ветку release (если ее нет она создастся) + +## Настройка секретов + +### Необходимые секреты + +В настройках репозитория (или инстанса) → "Secrets and variables" → "Actions" добавьте следующие секреты: + +1. **GITEATOKEN** + - Токен доступа к Gitea API + - Используется для клонирования репозитория и создания релизов + - Лучше задать на уровне репозитория `https://direct-dev.ru/gitea/GiteaAdmin/hello_gitea/settings/actions/secrets` + +2. **DOCKERHUB_USERNAME** + - Ваше имя пользователя в Docker Hub + - Используется для публикации образов + - Можно задать на уровне инстанса `https://direct-dev.ru/gitea/user/settings/actions/secrets` + +3. **DOCKERHUB_TOKEN** + - Токен доступа к Docker Hub + - Используется для авторизации в Docker Hub + - Можно задать на уровне инстанса `https://direct-dev.ru/gitea/user/settings/actions/secrets` + +### Создание токенов + +**Gitea Token:** + +1. Перейдите в настройки профиля → "Applications" +2. Создайте новый токен с правами на репозиторий +3. Скопируйте токен +4. В разделе Actions репозитория создайте секрет уровня репозитория - я создал с именем GITEATOKEN (такой не даст сделать: GITEA_TOKEN) + +**Docker Hub Token:** + +1. Войдите в Docker Hub +2. Перейдите в Account Settings → Security +3. Создайте новый Access Token +4. Скопируйте токен +5. В разделе Actions настроек инстанса gitea создайте секреты уровня инстанса (если много пользователей работает в gitea, то можно внести в уровень репозитория) я создал с именами DOCKERHUB_USERNAME, DOCKERHUB_TOKEN + +## Тестирование и запуск + +### Локальное тестирование + +1 **Проверим синтаксис workflow:** + +ну если мы в ide, то наверное все хорошо и уже автоматом отформатировано ... +но тем не менее ... + + ```bash + # Убедитесь, что YAML синтаксис корректен + yamllint .gitea/workflows/build.yaml + ``` + +2 **Протестируем сборку локально:** + +пока ручное тестирование + + ```bash + # Сборка для текущей платформы + go build -o hello-api main.go + + # Сборка для других платформ + GOOS=linux GOARCH=amd64 go build -o hello-api-linux-amd64 main.go + GOOS=linux GOARCH=arm64 go build -o hello-api-linux-arm64 main.go + ``` + +3 **Тестирование Docker образа:** + + ```bash + # Сборка образа + docker build -t hello-api:test . + + # Запуск контейнера + docker run -p 8080:8080 hello-api:test + + # Тестирование API + curl http://localhost:8080/healthz + ``` + +тут надо погонять curl по эндпойнтам, убедиться что приложение работает (пока тоже вручную) + +### Запуск Actions + +1 **Создаем тег вручную или скриптом (`scripts/release-interactive.sh`):** + + ```bash + + git tag v1.1.20 + git push origin v1.1.20 + + # текст скрипта - запуск можно сделать через make + #release-interactive: + # @./scripts/release-interactive.sh + + #!/bin/bash + + # Интерактивный скрипт для автоматизации релиза + # Использование: ./scripts/release-interactive.sh [version] + # Если версия не указана, скрипт запросит её интерактивно + + set -e # Остановить выполнение при ошибке + + # Функция для получения версии интерактивно + get_version_interactive() { + echo "🚀 Создание нового релиза" + echo "" + + # Показываем текущую версию + CURRENT_VERSION=$(grep 'const version = "' main.go | sed 's/const version = "\([^"]*\)"/\1/') + echo "📋 Текущая версия: $CURRENT_VERSION" + echo "" + + # Запрашиваем новую версию + read -r -p "Введите новую версию (формат X.Y.Z): " VERSION + + # Проверяем, что версия не пустая + if [ -z "$VERSION" ]; then + echo "❌ Версия не может быть пустой" + exit 1 + fi + + # Проверяем формат версии + if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Неверный формат версии. Используйте формат X.Y.Z (например, 1.0.25)" + exit 1 + fi + + # Подтверждение + echo "" + echo "📝 Подтверждение:" + echo " Текущая версия: $CURRENT_VERSION" + echo " Новая версия: $VERSION" + echo "" + read -r -p "Продолжить? (y/N): " CONFIRM + + if [[ ! $CONFIRM =~ ^[Yy]$ ]]; then + echo "❌ Релиз отменен" + exit 0 + fi + } + + # Проверяем, передана ли версия как аргумент + if [ $# -eq 0 ]; then + # Версия не указана, запрашиваем интерактивно + get_version_interactive + else + # Версия указана как аргумент + VERSION=$1 + + # Проверка формата версии + if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Ошибка: Неверный формат версии. Используйте формат X.Y.Z (например, 1.0.25)" + exit 1 + fi + fi + + echo "🚀 Начинаем релиз версии v$VERSION..." + + # Проверяем, что мы в git репозитории + if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "Ошибка: Не найден git репозиторий" + exit 1 + fi + + # Проверяем, что нет незакоммиченных изменений + # if ! git diff-index --quiet HEAD --; then + # echo "Ошибка: Есть незакоммиченные изменения. Сначала закоммитьте их." + # exit 1 + # fi + + # Обновляем версию в main.go + echo "📝 Обновляем версию в main.go..." + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed -i '' "s/const version = \"[^\"]*\"/const version = \"$VERSION\"/" main.go + else + # Linux + sed -i "s/const version = \"[^\"]*\"/const version = \"$VERSION\"/" main.go + fi + + # Проверяем, что изменение применилось + if ! grep -q "const version = \"$VERSION\"" main.go; then + echo "Ошибка: Не удалось обновить версию в main.go" + exit 1 + fi + + echo "✅ Версия обновлена в main.go" + + # Обновляем версию в makefile + echo "📝 Обновляем версию в makefile..." + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed -i '' "s/^VERSION=.*/VERSION=$VERSION/" makefile + else + # Linux + sed -i "s/^VERSION=.*/VERSION=$VERSION/" makefile + fi + + # Проверяем, что изменение применилось + if ! grep -q "^VERSION=$VERSION" makefile; then + echo "Ошибка: Не удалось обновить версию в makefile" + exit 1 + fi + + echo "✅ Версия обновлена в makefile" + + # Выполняем git команды + echo "📦 Добавляем изменения в git..." + git add . + + echo "💾 Создаем коммит..." + git commit -m "Release v$VERSION" + + echo "🏷️ Создаем тег..." + git tag -a "v$VERSION" -m "Release v$VERSION" + + echo "🚀 Отправляем изменения и теги..." + git push + git push --tags + + echo "🎉 Релиз v$VERSION успешно завершен!" + echo "📋 Выполненные действия:" + echo " - Обновлена версия в main.go" + echo " - Обновлена версия в makefile" + echo " - Создан коммит с сообщением 'Release v$VERSION'" + echo " - Создан тег v$VERSION" + echo " - Изменения отправлены в удаленный репозиторий" + + ``` + +2 **Мониторинг выполнения:** + +- Переходим в репозиторий → "Actions" +- Находим запущенный workflow +- Отслеживаем выполнение каждого job + +### Ожидаемый результат + +После успешного выполнения: + +1 **Релиз** будет создан в Gitea с бинарниками: + +- `hello-api-linux-amd64.tar.gz` +- `hello-api-linux-arm64.tar.gz` +- `hello-api-windows-amd64.tar.gz` +- `hello-api-darwin-amd64.tar.gz` +- `hello-api-darwin-arm64.tar.gz` + +2 **Docker образы** будут опубликованы в Docker Hub: + +- `username/hello-api:v1.1.20` +- `username/hello-api:latest` + +3 **В ветке relese** появится новый коммит - на него можно, например, настроить деплой ArgoCD/flux в кластере k3s + +## Мониторинг и отладка + +### Просмотр логов + +1 **В Gitea:** + +- Переходим в репозиторий → "Actions" +- Выберем workflow +- Нажмем на job для просмотра логов + +2 **Отладка ошибок:** + +- Проверим правильность секретов +- Убедимся в корректности путей к репозиторию +- Проверим права доступа токенов + +### Частые проблемы + +1 **Ошибка авторизации в Docker Hub:** + +- Проверим правильность `DOCKERHUB_TOKEN` +- Убедимся, что токен не истек + +2 **Ошибка создания релиза:** + +- Проверим права токена `GITEATOKEN` +- Убедимся, что тег не существует + +3 **Ошибка сборки:** + +- Проверим зависимости в `go.mod` +- Убедимся в корректности Dockerfile + +## Заключение + +В этой статье мы рассмотрели полный процесс настройки Gitea Actions для Go проекта. Мы создали автоматизированный pipeline, который: + +- ✅ Собирает мультиплатформенные бинарники +- ✅ Создает Docker образы для разных архитектур +- ✅ Публикует образы в Docker Hub +- ✅ Создает релизы с бинарниками +- ✅ Запускается автоматически при создании тегов + +### Преимущества такого подхода + +1. **Автоматизация:** Минимизация ручной работы +2. **Консистентность:** Одинаковые условия сборки +3. **Мультиплатформенность:** Поддержка разных ОС и архитектур +4. **Безопасность:** Использование секретов для токенов +5. **Масштабируемость:** Легко добавить новые платформы или этапы + +### Следующие шаги + +1. тестирование в workflow +2. автоматическое развертывание +3. уведомления о результатах сборки +4. мониторинг и алерты + +Gitea Actions предоставляет мощные возможности для автоматизации процессов разработки, и с правильной настройкой вы можете значительно упростить процесс доставки вашего ПО. + +--- + +## Инфраструктура + +### Архитектура системы + +Этот и последующие разделы описывают инфраструктуру, на которой я проверял все описанное выше. +Если у вас используются другие подходы, то можете пропустить чтение этих разделов +или ознакомиться для общего развития. + +Итак инфраструктура состоит из следующих компонентов: + +``` text +Proxmox VE (ARM64) +├── K3s Cluster + ├── Master Node + └── Gitea Server (Helm Chart) + └── Worker Nodes +└── LXC Containers + └── Gitea Runner Container + ├── Docker Engine + ├── Go Toolchain + └── Build Tools +``` + +### Требования к системе + +**Proxmox VE:** + +- ARM64 архитектура (orangepi 5 Plus) +- Минимум 8GB RAM (16Gb) +- 100GB свободного места (1 Gb) +- Поддержка LXC контейнеров (+) + +**K3s Cluster:** + +- Kubernetes 1.24+ +- Helm 3.8+ +- Ingress Controller (Traefik) +- Persistent Storage (NFS) + +**LXC Container:** + +- Ubuntu 22.04 LTS +- 4GB RAM +- 20GB дискового пространства +- Docker Engine + +## Установка Gitea в кластере K3s + +### Анализ существующего кластера + +Текущая конфигурация: + +**Узлы кластера:** + +```bash +kubectl get nodes -o wide +NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +k3s-control-01 Ready control-plane,etcd,master 544d v1.33.2+k3s1 10.xxx.x.2 10.xx.x.2 Ubuntu 20.04.6 LTS 5.10.160-rockchip-rk3588 containerd://2.0.5-k3s1 +k3s-control-02 Ready control-plane,etcd,master 544d v1.33.2+k3s1 10.xxx.x.3 10.xx.x.3 Ubuntu 20.04.6 LTS 5.10.160-rockchip-rk3588 containerd://2.0.5-k3s1 +k3s-control-03 Ready control-plane,etcd,master 544d v1.33.2+k3s1 10.xxx.x.4 10.xx.x.4 Ubuntu 20.04.6 LTS 6.1.31-sun50iw9 containerd://2.0.5-k3s1 +``` + +**Существующие компоненты:** + +- Kubernetes v1.33.2+k3s1 +- 3 узла control-plane с etcd +- Traefik Ingress Controller +- NFS Storage Class для persistent storage +- Cert-Manager для SSL сертификатов +- Gitea уже установлен и работает + +### Проверка существующей установки Gitea + +```bash +# Проверка подов Gitea +kubectl get pods -n gitea +NAME READY STATUS RESTARTS AGE +gitea-798c56b58f-bsp2h 1/1 Running 0 30h +gitea-postgres-0 2/2 Running 10 (10d ago) 357d +gitea-valkey-cluster-0 1/1 Running 0 30h +gitea-valkey-cluster-1 1/1 Running 0 30h +gitea-valkey-cluster-2 1/1 Running 0 30h + +# Проверка сервисов +kubectl get svc -n gitea +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +gitea-http ClusterIP None 3000/TCP 543d +gitea-postgres ClusterIP 10.xx.xx.xx 5432/TCP 543d +gitea-ssh ClusterIP None 22/TCP 543d +gitea-valkey-cluster ClusterIP 10.xx.xx.xx 6379/TCP 30h +gitea-valkey-cluster-headless ClusterIP None 6379/TCP,16379/TCP 30h + +# Проверка persistent storage +kubectl get pvc -n gitea +NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE +gitea-shared-storage Bound pvc-c869aea8-7b49-4b40-9b86-1a7fbe9b2ce8 10Gi RWO nfs 543d +pgdata-gitea-postgres-0 Bound pvc-c3405a46-594d-401f-91ae-e7398b3c5cc3 15Gi RWO nfs 543d +valkey-data-gitea-valkey-cluster-0 Bound pvc-a927baf5-b8f8-4c0b-b12f-68fbc63162d9 8Gi RWO nfs 30h +valkey-data-gitea-valkey-cluster-1 Bound pvc-b43a399b-fdf4-4c27-baf4-7056ecc4143b 8Gi RWO nfs 30h +valkey-data-gitea-valkey-cluster-2 Bound pvc-368f0ab9-7851-423f-afb9-443287c0c728 8Gi RWO nfs 30h +``` + +### Анализ конфигурации + +**Gitea версия:** 1.24.3 (rootless) +**База данных:** PostgreSQL с persistent storage (15Gi) +**Кэш:** Valkey cluster (Redis) с 3 репликами +**Storage:** NFS Storage Class +**Ingress:** Traefik + +### Настройка доступа к Gitea + +Доступ к gitea лучше сделать через ingress или на худой конец через port-forward: + +1 **Временный доступ через port-forward:** + +```bash +# Доступ к Gitea через port-forward +kubectl port-forward -n gitea svc/gitea-http 3000:3000 + +# В другом терминале проверьте доступ +curl http://localhost:3000 +``` + +2 **Создание IngressRoute для постоянного доступа:** + +```yaml +# gitea-ingressroute.yaml +# image: docker.io/traefik:v3.0.0 поэтому используется абстракция +# apiVersion: traefik.io/v1alpha1 +# kind: IngressRoute + +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + annotations: + cert-manager.io/cluster-issuer: letsencrypt-dns-cloudflare + creationTimestamp: "2024-05-24T03:50:33Z" + name: gitea-https-route + namespace: gitea +spec: + entryPoints: + - websecure + routes: + - kind: Rule + match: Host(`direct-dev.ru`) && PathPrefix(`/gitea`) + middlewares: + - name: strip-gitea-prefix + services: + - name: gitea-http + port: 3000 + tls: + secretName: le-root-direct-dev-ru +``` + +```bash +# Применение ingressroute +KUBECONFIG=~/.kube/config_hlab kubectl apply -f gitea-ingress.yaml +``` + +Таким образом мой инстанс gitea доступен с внешнего адреса + +в /data/gitea/conf/app.ini + +[server] +ROOT_URL = +DOMAIN = direct-dev.ru + +### Конфигурация Gitea + +Gitea установлен через Helm. + +**Версия:** Gitea 1.24.3 (rootless) +**Архитектура:** + +- PostgreSQL для базы данных (развернут отдельно ) +- Valkey cluster (Redis) для кэширования +- NFS Storage Class для persistent storage + +```bash +helm repo add gitea-charts https://dl.gitea.io/charts/ +helm repo update + +kubectl create namespace gitea + +helm install gitea gitea-charts/gitea -n gitea +``` + +установка с кастомными значениями параметров + +``` bash +helm show values gitea-charts/gitea > gitea-values.yaml +#Отредактируйте файл (например, database или service). +helm install gitea gitea-charts/gitea -n gitea -f gitea-values.yaml +``` + +Обновление + +```bash +helm repo update +helm upgrade gitea gitea-charts/gitea -n gitea -f gitea-values.yaml +``` + +**Проверка конфигурации:** + +```bash +# Проверка Helm релиза +helm list -n gitea + +# Просмотр конфигурации +helm get values gitea -n gitea +``` + +```yaml +USER-SUPPLIED VALUES: +gitea: + admin: + existingSecret: xxxxxx + config: + cache: + ADAPTER: memory + database: + DB_TYPE: postgres + HOST: gitea-postgres.gitea.svc.cluster.local:5432 + NAME: gitea + PASSWD: xxxxxxx + SCHEMA: gitea + USER: xxxx + indexer: + ISSUE_INDEXER_TYPE: bleve + REPO_INDEXER_ENABLED: true + queue: + TYPE: level + server: + APP_DATA_PATH: /data + DOMAIN: direct-dev.ru + ENABLE_PPROF: false + HTTP_PORT: 3000 + PROTOCOL: http + ROOT_URL: https://direct-dev.ru/gitea + SSH_DOMAIN: git.k3s-cluster-01.direct-dev.ru + SSH_LISTEN_PORT: 2222 + SSH_PORT: 22 + service: + DISABLE_REGISTRATION: true + SHOW_REGISTRATION_BUTTON: false + session: + PROVIDER: db +persistence: + enabled: true + storageClass: nfs +postgresql: + enabled: false +postgresql-ha: + enabled: false +redis-cluster: + enabled: false +``` + +Как видите я организовал доступ через внешний IP к URL `https://direct-dev.ru/gitea`, то есть через префикс роута, опыт работы с actions показал, что лучше бы организовать было через поддомен третьего уровня: что то типа `https://gitea.direct-dev.ru` - в этом случае всякие разные предопределенные jobs типа checkout@v3 должны клонирование отрабатывать нормально. + +``` bash + +# Проверка секретов +kubectl get secrets -n gitea +``` + +### Настройка Actions в существующем инстансе Gitea + +1 **Проверка включения Actions:** + +```bash +# Проверка конфигурации Actions +kubectl exec -n gitea deployment/gitea -c gitea -- cat /data/gitea/conf/app.ini" +``` + +2 **Включение Actions через веб-интерфейс:** + +- Откройте Gitea через port-forward или ingress +- Перейдите в Site Administration → Actions +- Включите "Enable Actions" +- Настройте "Default Actions URL" (например, ) + +3 **Проверка работы Actions:** + +```bash +# Проверка логов Gitea на предмет ошибок Actions +kubectl logs -n gitea deployment/gitea | grep -i action +``` + +### Настройка DNS и SSL + +Инструкция по установке Cert-Manager не приводится - инструкций вагон и маленькая тележка - +нет смысла повторяться: + +```bash +# Проверка Cert-Manager +kubectl get pods -n cert-manager + +# Проверка ClusterIssuer +kubectl get clusterissuer +``` + +1. **Настройка DNS записи:** + +```text +Добавить A-запись в DNS (у меня сloudflare) +gitea.your-domain.com -> внешний (белый) Ip - за ним пробросы портов если нужно ... +чтобы в итоге запрос поступил на traefik +``` + +2 **Создание ClusterIssuer (если не существует):** + +```bash +# Создание ClusterIssuer для Let's Encrypt - лучше через dns resolver +cat < /dev/null + +# Установка Docker +apt update +apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + +# Добавление пользователя в группу docker +usermod -aG docker $USER + +# Запуск Docker +systemctl enable docker +systemctl start docker + +# Проверка установки +docker --version +``` + +3 **Установка Go:** + +```bash +# Скачивание Go для ARM64 +wget https://go.dev/dl/go1.21.0.linux-arm64.tar.gz + +# Распаковка +tar -C /usr/local -xzf go1.21.0.linux-arm64.tar.gz + +# Настройка переменных окружения +echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc +echo 'export GOPATH=$HOME/go' >> ~/.bashrc +echo 'export GOROOT=/usr/local/go' >> ~/.bashrc +source ~/.bashrc + +# Проверка установки +go version +# Должно показать: go version go1.21.0 linux/arm64 + +# Создание рабочей директории +mkdir -p $HOME/go/{bin,src,pkg} +``` + +4 **Установка дополнительных инструментов:** + +```bash +# Установка build tools +apt install -y build-essential + +# Установка jq для JSON обработки +apt install -y jq + +# Установка yamllint для проверки YAML +apt install -y yamllint + +# Установка Docker Buildx +docker buildx version +``` + +### Установка Gitea Runner + +1. **Скачивание Gitea Runner:** + +```bash +# Определение архитектуры +ARCH=$(uname -m) +case $ARCH in + x86_64) ARCH=amd64 ;; + aarch64) ARCH=arm64 ;; + armv7l) ARCH=armv7 ;; +esac + +# Скачивание последней версии +# visit https://dl.gitea.com/act_runner/ copy link to version and arch you need + +wget https://dl.gitea.com/act_runner/0.2.12/act_runner-0.2.12-linux-arm64 + +# Переименование и установка +mv act_runner-0.2.12-linux-arm64 /usr/local/bin/act_runner +chmod +x /usr/local/bin/act_runner + +# Проверка установки +act_runner --version +``` + +2 **Создание пользователя для runner:** + +```bash +# Создание пользователя +useradd -m -s /bin/bash gitea-runner +usermod -aG docker gitea-runner + +# Создание директории для конфигурации +mkdir -p /opt/gitea-runner +chown gitea-runner:gitea-runner /opt/gitea-runner +``` + +3 **Настройка конфигурации runner:** + +```bash +# Переключение на пользователя runner +su - gitea-runner + +# Создание конфигурационного файла +cat > /opt/gitea-runner/config.yaml < config.yaml` to generate a config file. + +log: + # The level of logging, can be trace, debug, info, warn, error, fatal + level: info + +runner: + # Where to store the registration result. + file: .runner + # Execute how many tasks concurrently at the same time. + capacity: 1 + # Extra environment variables to run jobs. + envs: + A_TEST_ENV_NAME_1: a_test_env_value_1 + A_TEST_ENV_NAME_2: a_test_env_value_2 + + # Extra environment variables to run jobs from a file. + # It will be ignored if it's empty or the file doesn't exist. + env_file: .env + + # The timeout for a job to be finished. + # Please note that the Gitea instance also has a timeout (3h by default) for the job. + # So the job could be stopped by the Gitea instance if it's timeout is shorter than this. + timeout: 3h + + # The timeout for the runner to wait for running jobs to finish when shutting down. + # Any running jobs that haven't finished after this timeout will be cancelled. + shutdown_timeout: 0s + + # Whether skip verifying the TLS certificate of the Gitea instance. + insecure: true + + # The timeout for fetching the job from the Gitea instance. + fetch_timeout: 5s + + # The interval for fetching the job from the Gitea instance. + fetch_interval: 2s + + # The labels of a runner are used to determine which jobs the runner can run, and how to run them. + # Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + # Find more images provided by Gitea at https://gitea.com/docker.gitea.com/runner-images . + # If it's empty when registering, it will ask for inputting labels. + # If it's empty when execute `daemon`, will use labels in `.runner` file. + labels: + - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest" + - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04" + - "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04" + +cache: + # Enable cache server to use actions/cache. + enabled: true + # The directory to store the cache data. + # If it's empty, the cache data will be stored in $HOME/.cache/actcache. + dir: "" + # The host of the cache server. + # It's not for the address to listen, but the address to connect from job containers. + # So 0.0.0.0 is a bad choice, leave it empty to detect automatically. + host: "" + # The port of the cache server. + # 0 means to use a random available port. + port: 0 + # The external cache server URL. Valid only when enable is true. + # If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself. + # The URL should generally end with "/". + external_server: "" + +container: + # Specifies the network to which the container will connect. + # Could be host, bridge or the name of a custom network. + # If it's empty, act_runner will create a network automatically. + network: "" + # Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker). + privileged: false + # And other options to be used when the container is started (eg, --add-host=my.gitea.url:host-gateway). + options: + # The parent directory of a job's working directory. + # NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically. + # If the path starts with '/', the '/' will be trimmed. + # For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir + # If it's empty, /workspace will be used. + workdir_parent: + # Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob + # You can specify multiple volumes. If the sequence is empty, no volumes can be mounted. + # For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to: + # valid_volumes: + # - data + # - /src/*.json + # If you want to allow any volume, please use the following configuration: + # valid_volumes: + # - '**' + valid_volumes: [] + # overrides the docker client host with the specified one. + # If it's empty, act_runner will find an available docker host automatically. + # If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers. + # If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work. + docker_host: "" + # Pull docker image(s) even if already present + force_pull: true + # Rebuild docker image(s) even if already present + force_rebuild: false + +host: + # The parent directory of a job's working directory. + # If it's empty, $HOME/.cache/act/ will be used. + workdir_parent: +EOF +``` + +### Регистрация Runner в Gitea + +1. **Получение токена регистрации:** + +- Войдите в веб-интерфейс Gitea +- Перейдите в Settings → Actions → Runners +- Нажмите "New Runner" +- Скопируйте токен регистрации + +2 **Регистрация runner:** + +```bash +# В контейнере LXC под пользователем gitea-runner +act_runner register \ + --instance https://gitea.your-domain.com \ + --token YOUR_REGISTRATION_TOKEN \ + --name "k3s-runner-arm64" \ + --labels "ubuntu-latest:docker://node:18,ubuntu-22.04:docker://node:18,self-hosted:docker://golang:1.21,arm64:docker://golang:1.21" \ + --no-interactive + +``` + +3 **Создание systemd сервиса:** + +```bash +# Создание файла сервиса +cat > /etc/systemd/system/gitea-runner.service < /opt/gitea-runner/monitor.sh <<'EOF' +#!/bin/bash + +LOG_FILE="/opt/gitea-runner/logs/monitor.log" +RUNNER_STATUS=$(systemctl is-active gitea-runner) + +echo "$(date): Runner status: $RUNNER_STATUS" >> $LOG_FILE + +if [ "$RUNNER_STATUS" != "active" ]; then + echo "$(date): Restarting gitea-runner service" >> $LOG_FILE + systemctl restart gitea-runner +fi + +# Проверка свободного места +DISK_USAGE=$(df /opt/gitea-runner | tail -1 | awk '{print $5}' | sed 's/%//') +if [ $DISK_USAGE -gt 80 ]; then + echo "$(date): High disk usage: ${DISK_USAGE}%" >> $LOG_FILE + # Очистка старых логов и кэша + find /opt/gitea-runner/logs -name "*.log" -mtime +7 -delete + docker system prune -f +fi +EOF + +chmod +x /opt/gitea-runner/monitor.sh +``` + +2 **Настройка cron для мониторинга:** + +```bash +# Добавление в crontab +echo "*/5 * * * * /opt/gitea-runner/monitor.sh" | crontab - +``` + +### Тестирование Runner + +1. **Проверка подключения:** + +```bash +# Проверка статуса runner в Gitea +curl -H "Authorization: token YOUR_GITEA_TOKEN" \ + https://gitea.your-domain.com/api/v1/actions/runners +``` + +2 **Создание тестового workflow:** + +```yaml +# .gitea/workflows/test-runner.yaml +name: Test Runner +# on: +# push: +# branches: [main] +on: + push: + tags: + - v* + +jobs: + test: + runs-on: self-hosted + steps: + - name: Checkout + run: | + git clone https://oauth2:${{ secrets.GITEATOKEN }}@gitea.your-domain.com/your-username/your-repo.git + cd your-repo + + - name: Test Go + run: | + go version + go mod download + go build -o test-app main.go + + - name: Test Docker + run: | + docker --version + docker run hello-world + + - name: Test ARM64 Build + run: | + # Тестирование сборки для ARM64 + GOOS=linux GOARCH=arm64 go build -o test-app-arm64 main.go + file test-app-arm64 + # Должно показать: ELF 64-bit LSB executable, ARM aarch64 +``` + +### Оптимизация производительности + +1. **Настройка Docker daemon:** + +```bash +# Создание daemon.json +cat > /etc/docker/daemon.json < /etc/systemd/system/gitea-runner.service < /opt/gitea-runner/backup.sh <<'EOF' +#!/bin/bash + +BACKUP_DIR="/opt/gitea-runner/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +mkdir -p $BACKUP_DIR + +# Резервное копирование конфигурации +tar -czf $BACKUP_DIR/config_$DATE.tar.gz /opt/gitea-runner/config.yaml + +# Резервное копирование логов +tar -czf $BACKUP_DIR/logs_$DATE.tar.gz /opt/gitea-runner/logs/ + +# Удаление старых резервных копий (старше 30 дней) +find $BACKUP_DIR -name "*.tar.gz" -mtime +30 -delete + +echo "Backup completed: $DATE" +EOF + +chmod +x /opt/gitea-runner/backup.sh + +# Добавление в crontab (ежедневно в 2:00) +echo "0 2 * * * /opt/gitea-runner/backup.sh" | crontab - +``` + +## Работа с кластером + +### Основные команды для работы с кластером + +```bash +# Установка переменной окружения для работы с кластером +export KUBECONFIG=~/.kube/{configfile} + +# Проверка состояния кластера +kubectl get nodes -o wide +kubectl get pods -A + +# Работа с Gitea +kubectl get pods -n gitea +kubectl logs -n gitea deployment/gitea +kubectl port-forward -n gitea svc/gitea-http 3000:3000 + +# Проверка storage +kubectl get pvc -n gitea +kubectl get storageclass + +# Проверка ingress и сервисов +kubectl get svc -n default | grep traefik +kubectl get crds +kubectl get ingressroutes.traefik.io -A + +# Проверка сертификатов +kubectl get certificates -A -o wide +``` + +### Мониторинг и обслуживание + +```bash +# Проверка ресурсов кластера +kubectl top nodes +kubectl top pods -n gitea + +# Проверка событий +kubectl get events -n gitea --sort-by='.lastTimestamp' + +# Резервное копирование Gitea +kubectl exec -n gitea deployment/gitea -- gitea dump -c /data/gitea/conf/app.ini + +# Обновление Gitea +helm repo update +helm upgrade gitea gitea-charts/gitea -n gitea --values gitea-values.yaml +``` + +### Устранение неполадок + +```bash +# Проверка логов Gitea +kubectl logs -n gitea deployment/gitea -f + +# Проверка логов PostgreSQL +kubectl logs -n gitea statefulset/gitea-postgres -c postgres + +# Проверка логов Valkey +kubectl logs -n gitea statefulset/gitea-valkey-cluster -c valkey + +# Проверка сетевой связности +kubectl exec -n gitea deployment/gitea -- ping gitea-postgres +kubectl exec -n gitea deployment/gitea -- ping gitea-valkey-cluster +``` diff --git a/ui/src/app/knock/knock-page.component.html b/ui/src/app/knock/knock-page.component.html index 07ca8f5..67a200c 100644 --- a/ui/src/app/knock/knock-page.component.html +++ b/ui/src/app/knock/knock-page.component.html @@ -1,65 +1,8 @@
- - -
-

Port Knocker

- -
- - -
-
-
+
-
- - -
- Invalid password - Password is required -
-
- -
- - -
- -
+
-
+
-
- - -
- -
- +
+
-
- - -
- -
- - -
- - -
-
- - - {{ selectedFileName }} - - - - -
-
- -
-
- -
- -
-
- -
- -
- -
-
-
@@ -231,87 +51,15 @@ [mode]="executing ? 'indeterminate' : 'determinate'" >
- Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s + Elapsed: {{ elapsedMs / 1000 | number : '1.1-1' }}s
-
- Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s - - ({{ lastRunTime | date : "short" }}) - +
+ {{ result }} +
+
+ {{ error }}
- - - -
-
-

✅ Успешно выполнено

-
{{ result }}
-
-
-

❌ Ошибка

-
{{
-        error
-      }}
-
-
- Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s -
- Завершено: {{ lastRunTime | date : "short" }} -
-
- - - -
- - - -
-

- Эта версия работает в любом браузере, но файлы загружаются/скачиваются - через стандартные диалоги браузера. -

-
-

- 💡 Доступна расширенная версия! -

-

- Ваш браузер поддерживает прямое редактирование файлов на диске. -

- -
-
-
diff --git a/ui/src/app/knock/knock-page.component.ts b/ui/src/app/knock/knock-page.component.ts index bd7fd1d..e69f814 100644 --- a/ui/src/app/knock/knock-page.component.ts +++ b/ui/src/app/knock/knock-page.component.ts @@ -1,30 +1,22 @@ -import { Component, inject, Input } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; -import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { InputTextModule } from 'primeng/inputtext'; -import { PasswordModule } from 'primeng/password'; -import { DropdownModule } from 'primeng/dropdown'; import { CheckboxModule } from 'primeng/checkbox'; -import { InputTextareaModule } from 'primeng/inputtextarea'; import { ButtonModule } from 'primeng/button'; import { CardModule } from 'primeng/card'; import { DividerModule } from 'primeng/divider'; -import { FileUploadModule } from 'primeng/fileupload'; import { ProgressBarModule } from 'primeng/progressbar'; -import { DialogModule } from 'primeng/dialog'; -import * as yaml from 'js-yaml'; -import { environment } from '../../environments/environment'; @Component({ selector: 'app-knock-page', standalone: true, imports: [ - CommonModule, RouterModule, ReactiveFormsModule, FormsModule, - InputTextModule, PasswordModule, DropdownModule, CheckboxModule, - InputTextareaModule, ButtonModule, CardModule, DividerModule, - FileUploadModule, ProgressBarModule, DialogModule + CommonModule, RouterModule, ReactiveFormsModule, + InputTextModule, CheckboxModule, ButtonModule, CardModule, + DividerModule, ProgressBarModule ], templateUrl: './knock-page.component.html', styleUrls: ['./knock-page.component.scss'] @@ -33,352 +25,50 @@ export class KnockPageComponent { private readonly http = inject(HttpClient); private readonly fb = inject(FormBuilder); - @Input() enableFSA = false; // Включает File System Access API функциональность - @Input() canUseFSA = false; // Доступна ли FSA версия - - // cardHeader = 'Port Knocker GUI'; - cardHeader = ''; - animatedTitle = 'Knock Knock Knock on the heaven\'s door ...'; - showInfoDialog = false; - isAnimating = false; - showResultDialog = false; - executing = false; + elapsedMs = 0; private timerId: any = null; private startTs = 0; - elapsedMs = 0; - lastRunTime: Date | null = null; - wrongPass = false; - selectedFileName: string | null = null; - private fileHandle: any = null; // FileSystemFileHandle - private isSyncing = false; + result: string | null = null; error: string | null = null; form = this.fb.group({ - password: ['', Validators.required], - mode: ['inline', Validators.required], - targets: ['tcp:127.0.0.1:22'], - delay: ['1s'], - verbose: [true], - waitConnection: [false], - gateway: [''], - configYAML: [''], - serverFilePath: [''] + targets: ['tcp:127.0.0.1:22', Validators.required], + delay: ['1s', Validators.required], + waitConnection: [false] }); - constructor() { - // Загружаем сохраненное состояние из localStorage - this.loadStateFromLocalStorage(); - - // Запускаем анимацию заголовка - this.startTitleAnimation(); - - // Сбрасываем индикатор неверного пароля при изменении поля - this.form.get('password')?.valueChanges.subscribe(() => { - this.wrongPass = false; - }); - - // React on YAML text changes: extract path and sync to serverFilePath - this.form.get('configYAML')?.valueChanges.subscribe((val) => { - if (this.isSyncing) return; - if (this.isInlineMode() || this.isYamlEncrypted()) return; - try { - const p = this.extractPathFromYaml(String(val ?? '')); - const currentPath = this.form.value.serverFilePath || ''; - if (p && p !== currentPath) { - this.isSyncing = true; - this.form.patchValue({ serverFilePath: p }); - this.isSyncing = false; - } - } catch {} - }); - - // React on serverFilePath changes: update YAML path field - this.form.get('serverFilePath')?.valueChanges.subscribe((newPath) => { - if (this.isSyncing) return; - this.onServerPathChange(newPath || ''); - }); - - // Подписка на изменение режима для автоматического преобразования - this.setupModeConversion(); - - // Подписки на изменения полей для автосохранения в localStorage - this.setupAutoSave(); - - // File System Access API detection (для обратной совместимости) - // Логика FSA теперь находится в отдельных компонентах - } - - private authHeader(pass: string) { - // Basic auth с пользователем "knocker" - const token = btoa(`knocker:${pass}`); - return { Authorization: `Basic ${token}` }; - } - execute() { this.error = null; this.result = null; - this.wrongPass = false; if (this.form.invalid) return; + const v = this.form.value; const body: any = { targets: v.targets, delay: v.delay, - verbose: v.verbose, - waitConnection: v.waitConnection, - gateway: v.gateway, + waitConnection: v.waitConnection }; - if (v.mode === 'yaml') { - body.config_yaml = v.configYAML; - delete body.targets; - delete body.delay; - } + this.executing = true; this.startTimer(); - this.http.post('/api/v1/knock-actions/execute', body, { - headers: this.authHeader(v.password || '') - }).subscribe({ + + // Без обязательного Basic-Auth: заголовок не добавляется, если пароль не требуется + this.http.post('/api/v1/knock-actions/execute', body).subscribe({ next: () => { this.executing = false; this.stopTimer(); - this.lastRunTime = new Date(); - this.result = `Done in ${(this.elapsedMs/1000).toFixed(2)}s`; - this.showResultDialog = true; + this.result = `Done in ${(this.elapsedMs / 1000).toFixed(2)}s`; }, error: (e: HttpErrorResponse) => { this.executing = false; this.stopTimer(); - if (e.status === 401) { - this.wrongPass = true; - } this.error = (e.error?.error) || e.message; - this.showResultDialog = true; } }); } - encrypt() { - this.error = null; - this.result = null; - const v = this.form.value; - if (this.isInlineMode() || this.isYamlEncrypted() || !v.password || this.wrongPass) { - return; - } - - // Проверяем есть ли path в YAML самом - const pathFromYaml = this.getPathFromYaml(v.configYAML || ''); - const serverFilePath = (this.form.value.serverFilePath || '').trim(); - - let url: string; - let body: any; - - if (pathFromYaml) { - // Если path в YAML - используем /encrypt, сервер сам найдет path в YAML - url = '/api/v1/knock-actions/encrypt'; - body = { yaml: v.configYAML }; - } else if (serverFilePath) { - // Если path только в serverFilePath - используем /encrypt-file - url = '/api/v1/knock-actions/encrypt-file'; - body = { path: serverFilePath }; - } else { - // Нет пути - обычное шифрование содержимого - url = '/api/v1/knock-actions/encrypt'; - body = { yaml: v.configYAML }; - } - - this.http.post(url, body, { - headers: this.authHeader(v.password || '') - }).subscribe({ - next: async (res: any) => { - const encrypted: string = res.encrypted || ''; - - // Всегда обновляем YAML поле зашифрованным содержимым - this.form.patchValue({ configYAML: encrypted }); - - if (pathFromYaml) { - this.result = `Encrypted (YAML path: ${pathFromYaml})`; - // НЕ сохраняем файл клиентом - сервер уже записал по path из YAML - } else if (serverFilePath) { - this.result = `Encrypted (server path: ${serverFilePath})`; - // НЕ сохраняем файл клиентом - сервер записал по serverFilePath - } else { - this.result = 'Encrypted'; - // Только сохраняем в файл если НЕТ серверного пути - await this.saveBackToFileIfPossible(encrypted, this.selectedFileName); - } - }, - error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message - }); - } - - decrypt() { - this.error = null; - this.result = null; - const v = this.form.value; - if (this.isInlineMode() || !this.isYamlEncrypted() || !v.password || this.wrongPass) { - return; - } - - // Для зашифрованного YAML поле serverFilePath недоступно - используем только decrypt - const url = '/api/v1/knock-actions/decrypt'; - const body = { encrypted: v.configYAML as string }; - - this.http.post(url, body, { - headers: this.authHeader(v.password || '') - }).subscribe({ - next: async (res: any) => { - const plain: string = res.yaml || ''; - this.form.patchValue({ configYAML: plain }); - this.result = 'Decrypted'; - - // Извлекаем path из расшифрованного YAML и обновляем serverFilePath - const pathFromDecrypted = this.getPathFromYaml(plain); - if (pathFromDecrypted) { - this.isSyncing = true; - this.form.patchValue({ serverFilePath: pathFromDecrypted }); - this.isSyncing = false; - this.result += ` (found path: ${pathFromDecrypted})`; - } - - // НЕ делаем download - сервер уже обработал файл согласно path в YAML - }, - error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message - }); - } - - onFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (!input.files || input.files.length === 0) return; - const file = input.files[0]; - this.selectedFileName = file.name; - const reader = new FileReader(); - reader.onload = () => { - const text = String(reader.result || ''); - this.form.patchValue({ configYAML: text, mode: 'yaml' }); - // Sync path from YAML into serverFilePath - const p = this.extractPathFromYaml(text); - if (p) { - this.isSyncing = true; - this.form.patchValue({ serverFilePath: p }); - this.isSyncing = false; - } - }; - reader.readAsText(file); - this.fileHandle = null; // обычная загрузка не даёт handle на запись - } - - downloadYaml() { - const yaml = this.form.value.configYAML || ''; - this.triggerDownload('config.yaml', yaml); - } - - downloadResult() { - const content = this.result || this.form.value.configYAML || ''; - const name = (content || '').startsWith('ENCRYPTED:') ? 'config.encrypted' : 'config.yaml'; - this.triggerDownload(name, content); - } - - onFileUpload(event: any) { - const files: File[] = event?.files || event?.currentFiles || []; - if (!files.length) return; - const file = files[0]; - this.selectedFileName = file.name; - const reader = new FileReader(); - reader.onload = () => { - const text = String(reader.result || ''); - this.form.patchValue({ configYAML: text, mode: 'yaml' }); - const p = this.extractPathFromYaml(text); - if (!p) { - return; - } - this.isSyncing = true; - this.form.patchValue({ serverFilePath: p }); - this.isSyncing = false; - }; - reader.readAsText(file); - this.fileHandle = null; - } - - async openFileWithWriteAccess() { - try { - const w: any = window as any; - if (!w || typeof w.showOpenFilePicker !== 'function') { - this.error = 'File System Access API is not supported by this browser.'; - return; - } - const [handle] = await w.showOpenFilePicker({ - types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }] - }); - this.fileHandle = handle; - const file = await handle.getFile(); - this.selectedFileName = file.name; - const text = await file.text(); - this.form.patchValue({ configYAML: text, mode: 'yaml' }); - const p = this.extractPathFromYaml(text); - if (p) { - this.isSyncing = true; - this.form.patchValue({ serverFilePath: p }); - this.isSyncing = false; - } - this.result = `Opened: ${file.name}`; - this.error = null; - } catch (e: any) { - // user cancelled or error - } - } - - // Helpers for UI state - isInlineMode(): boolean { - return (this.form.value.mode === 'inline'); - } - isYamlEncrypted(): boolean { - const s = (this.form.value.configYAML || '').toString().trim(); - return s.startsWith('ENCRYPTED:'); - } - - private triggerDownload(filename: string, text: string) { - const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - } - - private async saveBackToFileIfPossible(content: string, filename: string | null) { - try { - const w: any = window as any; - if (this.fileHandle && typeof this.fileHandle.createWritable === 'function') { - const writable = await this.fileHandle.createWritable(); - await writable.write(content); - await writable.close(); - return; - } - if (w && typeof w.showSaveFilePicker === 'function') { - const handle = await w.showSaveFilePicker({ - suggestedName: filename || 'config.yaml', - types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }] - }); - const writable = await handle.createWritable(); - await writable.write(content); - await writable.close(); - return; - } else if (filename) { - this.triggerDownload(filename, content); - } else { - this.triggerDownload('config.yaml', content); - } - } catch { - if (filename) { - this.triggerDownload(filename, content); - } else { - this.triggerDownload('config.yaml', content); - } - } - } - private startTimer() { this.elapsedMs = 0; this.startTs = Date.now(); @@ -401,380 +91,6 @@ export class KnockPageComponent { this.timerId = null; } } - - // YAML path helpers - private getPathFromYaml(text: string): string { - return this.extractPathFromYaml(text); - } - - private extractPathFromYaml(text: string): string { - try { - const doc: any = yaml.load(text); - if (doc && typeof doc === 'object' && typeof doc.path === 'string') { - return doc.path; - } - } catch {} - return ''; - } - - onServerPathChange(newPath: string) { - if (this.isInlineMode() || this.isYamlEncrypted()) return; - environment.log('onServerPathChange', newPath); - const current = String(this.form.value.configYAML || ''); - try { - const doc: any = current.trim() ? yaml.load(current) : {}; - if (!doc || typeof doc !== 'object') return; - (doc as any).path = newPath || ''; - this.isSyncing = true; - this.form.patchValue({ configYAML: yaml.dump(doc, { lineWidth: 120 }) }, { emitEvent: true }); - this.isSyncing = false; - } catch {} - } - - // LocalStorage functionality - private readonly STORAGE_KEY = 'knocker-ui-state'; - - private loadStateFromLocalStorage() { - try { - const saved = localStorage.getItem(this.STORAGE_KEY); - if (!saved) return; - - const state = JSON.parse(saved); - environment.log('Loading saved state:', state); - - // Применяем сохраненные значения к форме - const patchData: any = {}; - - if (state.mode !== undefined) patchData.mode = state.mode; - if (state.targets !== undefined) patchData.targets = state.targets; - if (state.delay !== undefined) patchData.delay = state.delay; - if (state.verbose !== undefined) patchData.verbose = state.verbose; - if (state.waitConnection !== undefined) patchData.waitConnection = state.waitConnection; - if (state.configYAML !== undefined) patchData.configYAML = state.configYAML; - - if (Object.keys(patchData).length > 0) { - this.form.patchValue(patchData); - - // Если загружен YAML, извлекаем path и устанавливаем в serverFilePath - if (state.configYAML) { - const pathFromYaml = this.getPathFromYaml(state.configYAML); - if (pathFromYaml) { - this.isSyncing = true; - this.form.patchValue({ serverFilePath: pathFromYaml }); - this.isSyncing = false; - environment.log('Extracted path from loaded YAML:', pathFromYaml); - } - } - } - } catch (e) { - console.warn('Failed to load state from localStorage:', e); - } - } - - private saveStateToLocalStorage() { - try { - const formValue = this.form.value; - const state = { - mode: formValue.mode, - targets: formValue.targets, - delay: formValue.delay, - verbose: formValue.verbose, - waitConnection: formValue.waitConnection, - configYAML: formValue.configYAML - }; - - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state)); - environment.log('State saved to localStorage:', state); - } catch (e) { - console.warn('Failed to save state to localStorage:', e); - } - } - - private setupAutoSave() { - // Подписываемся на изменения нужных полей - const fieldsToWatch = ['mode', 'targets', 'delay', 'verbose', 'waitConnection', 'configYAML']; - - fieldsToWatch.forEach(fieldName => { - this.form.get(fieldName)?.valueChanges.subscribe(() => { - // Небольшая задержка, чтобы не сохранять на каждое нажатие клавиши - setTimeout(() => this.saveStateToLocalStorage(), 300); - }); - }); - } - - // Автоматическое преобразование между режимами - private setupModeConversion() { - let previousMode = this.form.value.mode; - - this.form.get('mode')?.valueChanges.subscribe((newMode) => { - if (this.isSyncing) return; - - environment.log(`Mode changed from ${previousMode} to ${newMode}`); - - if (previousMode === 'inline' && newMode === 'yaml') { - this.handleModeChangeToYaml(); - } else if (previousMode === 'yaml' && newMode === 'inline') { - this.handleModeChangeToInline(); - } - - previousMode = newMode; - }); - } - - private handleModeChangeToYaml() { - try { - // Проверяем есть ли сохраненный YAML в localStorage - const savedYaml = this.getSavedConfigYAML(); - - if (savedYaml?.trim()) { - // Используем сохраненный YAML - environment.log('Using saved YAML from localStorage'); - this.isSyncing = true; - this.form.patchValue({ configYAML: savedYaml }); - this.isSyncing = false; - } else { - // Конвертируем из inline - environment.log('Converting inline to YAML'); - this.convertInlineToYaml(); - } - - // После установки YAML (из localStorage или конвертации) извлекаем path - setTimeout(() => this.extractAndSetServerPath(), 100); - - } catch (e) { - console.warn('Failed to handle mode change to YAML:', e); - } - } - - private handleModeChangeToInline() { - try { - // Проверяем есть ли сохраненные inline значения в localStorage - const savedTargets = this.getSavedTargets(); - - if (savedTargets && savedTargets.trim()) { - // Используем сохраненные inline значения - environment.log('Using saved inline targets from localStorage'); - this.isSyncing = true; - this.form.patchValue({ targets: savedTargets }); - this.isSyncing = false; - } else { - // Конвертируем из YAML - environment.log('Converting YAML to inline'); - this.convertYamlToInline(); - } - - } catch (e) { - console.warn('Failed to handle mode change to inline:', e); - } - } - - private getSavedConfigYAML(): string | null { - try { - const saved = localStorage.getItem(this.STORAGE_KEY); - if (!saved) return null; - const state = JSON.parse(saved); - return state.configYAML || null; - } catch { - return null; - } - } - - private getSavedTargets(): string | null { - try { - const saved = localStorage.getItem(this.STORAGE_KEY); - if (!saved) return null; - const state = JSON.parse(saved); - return state.targets || null; - } catch { - return null; - } - } - - private extractAndSetServerPath() { - try { - const yamlContent = this.form.value.configYAML || ''; - if (!yamlContent.trim()) return; - - const config: any = yaml.load(yamlContent); - if (config && typeof config === 'object' && config.path) { - environment.log('Extracted path from YAML:', config.path); - this.isSyncing = true; - this.form.patchValue({ serverFilePath: config.path }); - this.isSyncing = false; - } - } catch (e) { - console.warn('Failed to extract path from YAML:', e); - } - } - - private convertInlineToYaml() { - try { - const formValue = this.form.value; - const targets = (formValue.targets || '').split(';').filter(t => t.trim()); - - // Создаем YAML конфигурацию из inline параметров - const config: any = { - targets: targets.map(target => { - const [protocol, address] = target.trim().split(':'); - const [host, port] = address ? address.split(':') : ['', '']; - - return { - protocol: protocol || 'tcp', - host: host || '127.0.0.1', - ports: [parseInt(port) || 22], - wait_connection: formValue.waitConnection || false - }; - }), - delay: formValue.delay || '1s' - }; - - const yamlString = yaml.dump(config, { lineWidth: 120 }); - - this.isSyncing = true; - this.form.patchValue({ configYAML: yamlString }); - this.isSyncing = false; - - environment.log('Converted inline to YAML:', config); - } catch (e) { - console.warn('Failed to convert inline to YAML:', e); - } - } - - private convertYamlToInline() { - try { - const yamlContent = this.form.value.configYAML || ''; - if (!yamlContent.trim()) { - // Если YAML пустой, используем значения по умолчанию - this.isSyncing = true; - this.form.patchValue({ - targets: 'tcp:127.0.0.1:22', - delay: '1s', - waitConnection: false - }); - this.isSyncing = false; - return; - } - - const config: any = yaml.load(yamlContent); - if (!config || !config.targets || !Array.isArray(config.targets)) { - console.warn('Invalid YAML structure for conversion'); - return; - } - - // Конвертируем targets в строку - создаем отдельную строку для каждого порта - const targetStrings: string[] = []; - config.targets.forEach((target: any) => { - const protocol = target.protocol || 'tcp'; - const host = target.host || '127.0.0.1'; - // Поддерживаем как ports (массив), так и port (единственное число) для обратной совместимости - const ports = target.ports || [target.port] || [22]; - - if (Array.isArray(ports)) { - // Создаем отдельную строку для каждого порта - ports.forEach(port => { - targetStrings.push(`${protocol}:${host}:${port}`); - }); - } else { - targetStrings.push(`${protocol}:${host}:${ports}`); - } - }); - - const targetsString = targetStrings.join(';'); - const delay = config.delay || '1s'; - - // Берем wait_connection из первой цели (если есть) - const waitConnection = config.targets[0]?.wait_connection || false; - - this.isSyncing = true; - this.form.patchValue({ - targets: targetsString, - delay: delay, - waitConnection: waitConnection - }); - this.isSyncing = false; - - environment.log('Converted YAML to inline:', { targets: targetsString, delay, waitConnection }); - } catch (e) { - console.warn('Failed to convert YAML to inline:', e); - } - } - - // Публичный метод для очистки сохраненного состояния (опционально) - clearSavedState() { - try { - localStorage.removeItem(this.STORAGE_KEY); - environment.log('Saved state cleared from localStorage'); - - // Сбрасываем форму к значениям по умолчанию - this.form.patchValue({ - mode: 'inline', - targets: 'tcp:127.0.0.1:22', - delay: '1s', - verbose: true, - waitConnection: false, - configYAML: '' - }); - } catch (e) { - console.warn('Failed to clear saved state:', e); - } - } - - // Анимация заголовка - private startTitleAnimation() { - if (this.isAnimating) return; - this.isAnimating = true; - - // Первая анимация: по буквам - this.animateByLetters(); - } - - private animateByLetters() { - let currentIndex = 0; - const letters = this.animatedTitle.split(''); - - const interval = setInterval(() => { - if (currentIndex < letters.length) { - this.cardHeader = letters.slice(0, currentIndex + 1).join(''); - currentIndex++; - } else { - clearInterval(interval); - // Ждем 2 секунды и начинаем исчезать - setTimeout(() => { - this.fadeOutTitle(); - }, 2000); - } - }, 100); // 100ms между буквами - } - - private fadeOutTitle() { - let opacity = 1; - const fadeInterval = setInterval(() => { - opacity -= 0.1; - if (opacity <= 0) { - clearInterval(fadeInterval); - this.cardHeader = ''; - // Ждем 1 секунду и начинаем анимацию по словам - setTimeout(() => { - this.animateByWords(); - }, 1000); - } - }, 50); - } - - private animateByWords() { - const words = this.animatedTitle.split(' '); - let currentIndex = 0; - - const interval = setInterval(() => { - if (currentIndex < words.length) { - this.cardHeader = words.slice(0, currentIndex + 1).join(' '); - currentIndex++; - } else { - clearInterval(interval); - this.isAnimating = false; - } - }, 300); // 300ms между словами - } }