From f4746df4a20fe17eb8d62a70eb4dd9db29094e2f Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Wed, 8 Jul 2026 14:50:59 +0300 Subject: [PATCH] =?UTF-8?q?CI/CD:=20ci,=20deploy-ift=20=D0=B8=20deploy-sta?= =?UTF-8?q?ge=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20EventHubDevOps;=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D1=8B=20calendar=20view,=20archive,=20API-?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20traefik-coraza?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/build-and-test.yml | 4 +- .gitea/workflows/ci.yml | 34 ++++++++++ .gitea/workflows/deploy-ift.yml | 65 +++++++++++++++++++ .gitea/workflows/deploy-stage.yml | 28 ++++---- docker/build-images.sh | 3 + docker/docker-compose.swarm.yml | 9 +-- docker/docker-compose.yml | 7 +- docker/traefik/Dockerfile | 11 ++++ src/archive/archive_fetcher.erl | 6 +- src/archive/archive_manager.erl | 46 +++++++++++-- src/handlers/handler_calendar_view.erl | 28 ++++++-- test/api/api_test_runner.erl | 35 +++++++++- test/api/users/user_events_tests.erl | 13 ++-- .../users/user_occurrence_cancel_tests.erl | 5 +- test/api/users/user_search_tests.erl | 6 +- 15 files changed, 247 insertions(+), 53 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/deploy-ift.yml create mode 100644 docker/traefik/Dockerfile diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index f338544..3679d17 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -1,6 +1,6 @@ -# ВРЕМЕННО ОТКЛЮЧЕНО — CI/CD требует донастройки (см. EventHubBack#23). +# Устарело — заменено на ci.yml и deploy-ift.yml / deploy-stage.yml (EventHubDevOps). # Ручной запуск: Actions → Run workflow -name: Test & Deploy to Snapdeploy +name: Test & Deploy to Snapdeploy (legacy) on: workflow_dispatch: # on: diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8d0ea63 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build eventhub image + uses: docker/build-push-action@v4 + with: + context: . + file: docker/Dockerfile + load: true + tags: eventhub:latest + + - name: Build API test image + uses: docker/build-push-action@v4 + with: + context: . + file: docker/ApiTests.Dockerfile + load: true + tags: eventhub-api-tests:latest + + - name: Run API tests (local CT) + run: docker run --rm eventhub-api-tests:latest diff --git a/.gitea/workflows/deploy-ift.yml b/.gitea/workflows/deploy-ift.yml new file mode 100644 index 0000000..2afb582 --- /dev/null +++ b/.gitea/workflows/deploy-ift.yml @@ -0,0 +1,65 @@ +name: Deploy IFT (core) + +on: + push: + branches: [master, main] + workflow_dispatch: + +jobs: + deploy-ift-core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to registry + uses: docker/login-action@v2 + with: + registry: git.sabilin.com + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push eventhub + uses: docker/build-push-action@v4 + with: + context: . + file: docker/Dockerfile + push: true + tags: git.sabilin.com/eventhub/eventhub:ift + + - name: Build and push traefik-coraza + uses: docker/build-push-action@v4 + with: + context: docker/traefik + file: docker/traefik/Dockerfile + push: true + tags: git.sabilin.com/eventhub/traefik-coraza:ift + + - name: Build and push fallback + uses: docker/build-push-action@v4 + with: + context: docker/fallback + file: docker/fallback/Dockerfile + push: true + tags: git.sabilin.com/eventhub/fallback:ift + + - name: Build and push bot-emulator + uses: docker/build-push-action@v4 + with: + context: . + file: test/emulate_users/Dockerfile + push: true + tags: git.sabilin.com/eventhub/bot-emulator-users:ift + + - name: Deploy core on IFT via SSH + uses: appleboy/ssh-action@v0.1.5 + with: + host: ${{ secrets.IFT_SSH_HOST }} + username: ${{ secrets.IFT_SSH_USER }} + key: ${{ secrets.IFT_SSH_PRIVATE_KEY }} + script: | + export REGISTRY_USER='${{ secrets.REGISTRY_USER }}' + export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}' + /opt/eventhub-ift/devops/scripts/deploy-service.sh ift core diff --git a/.gitea/workflows/deploy-stage.yml b/.gitea/workflows/deploy-stage.yml index 53a4719..e9fc34d 100644 --- a/.gitea/workflows/deploy-stage.yml +++ b/.gitea/workflows/deploy-stage.yml @@ -1,15 +1,13 @@ -# ВРЕМЕННО ОТКЛЮЧЕНО — CI/CD требует донастройки. -# Ручной запуск: Actions → Run workflow -name: Deploy to Stage +name: Deploy stage (core) + on: + push: + tags: + - 'v*' workflow_dispatch: -# on: -# push: -# tags: -# - 'v*' jobs: - deploy-stage: + deploy-stage-core: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -17,7 +15,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Login to Gitea Container Registry + - name: Login to registry uses: docker/login-action@v2 with: registry: git.sabilin.com @@ -34,18 +32,18 @@ jobs: - name: Build and push bot-emulator uses: docker/build-push-action@v4 with: - context: ./docker/bot-emulator-users + context: . + file: test/emulate_users/Dockerfile push: true tags: git.sabilin.com/eventhub/bot-emulator-users:stage - - name: Deploy to stage server + - name: Deploy core on stage via SSH uses: appleboy/ssh-action@v0.1.5 with: host: ${{ secrets.STAGE_SSH_HOST }} username: ${{ secrets.STAGE_SSH_USER }} key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }} script: | - cd /opt/eventhub-stage - docker pull git.sabilin.com/eventhub/eventhub:stage - docker pull git.sabilin.com/eventhub/bot-emulator-users:stage - docker stack deploy -c docker-compose.stage.yml eventhub-stage --with-registry-auth \ No newline at end of file + export REGISTRY_USER='${{ secrets.REGISTRY_USER }}' + export REGISTRY_PASSWORD='${{ secrets.REGISTRY_PASSWORD }}' + /opt/eventhub-stage/devops/scripts/deploy-service.sh stage core diff --git a/docker/build-images.sh b/docker/build-images.sh index ceccb31..8117e75 100644 --- a/docker/build-images.sh +++ b/docker/build-images.sh @@ -1,6 +1,9 @@ # Основной образ EventHub (единственный для нод) docker build -t eventhub:latest -f docker/Dockerfile . +# Traefik + Coraza WAF (wasm встроен в образ, без plugins.traefik.io) +docker build -t traefik-coraza:latest -f docker/traefik/Dockerfile docker/traefik + # Сервис заглушка docker build -t fallback:latest -f docker/fallback/Dockerfile docker/fallback diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml index a3d1a79..68028eb 100644 --- a/docker/docker-compose.swarm.yml +++ b/docker/docker-compose.swarm.yml @@ -4,7 +4,7 @@ version: "3.8" services: # ================== Балансировщик ================== traefik: - image: traefik:latest + image: traefik-coraza:latest command: # - "--log.level=DEBUG" - "--api.insecure=true" @@ -21,8 +21,7 @@ services: - "--accesslog=true" - "--accesslog.filepath=/var/log/traefik/access.log" - "--accesslog.format=json" - - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" - - "--experimental.plugins.coraza.version=v0.3.0" + - "--experimental.localPlugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" ports: - "80:80" - "443:443" @@ -32,7 +31,6 @@ services: - "./traefik/certs:/etc/traefik/certs:ro" - "./traefik/dynamic_conf.yml:/etc/traefik/dynamic_conf.yml:ro" - "traefik-logs:/var/log/traefik" - - "traefik-plugins:/plugins-storage" networks: - eventhub-net deploy: @@ -226,5 +224,4 @@ volumes: prometheus-data: grafana-data: traefik-logs: - loglynx-data: - traefik-plugins: \ No newline at end of file + loglynx-data: \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 645a26a..7c432c4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ services: # ================== Балансировщик (HTTPS/WSS, WAF, логи) ================== traefik: - image: traefik:latest + image: traefik-coraza:latest user: "0:1001" # группа docker на хосте command: - "--api.insecure=true" @@ -20,9 +20,8 @@ services: - "--accesslog=true" - "--accesslog.filepath=/var/log/traefik/access.log" - "--accesslog.format=json" - # Coraza WAF - - "--experimental.plugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" - - "--experimental.plugins.coraza.version=v0.2.0" + # Coraza WAF (локальный wasm из образа) + - "--experimental.localPlugins.coraza.modulename=github.com/jcchavezs/coraza-http-wasm-traefik" ports: - "80:80" - "443:443" diff --git a/docker/traefik/Dockerfile b/docker/traefik/Dockerfile new file mode 100644 index 0000000..13abd54 --- /dev/null +++ b/docker/traefik/Dockerfile @@ -0,0 +1,11 @@ +FROM traefik:v3.2.0 + +ARG CORAZA_VERSION=v0.3.0 +ARG CORAZA_MODULE=github.com/jcchavezs/coraza-http-wasm-traefik + +RUN apk add --no-cache curl unzip \ + && mkdir -p /plugins-local/src/github.com/jcchavezs/coraza-http-wasm-traefik \ + && curl -fsSL -o /tmp/coraza.zip \ + "https://github.com/jcchavezs/coraza-http-wasm-traefik/releases/download/${CORAZA_VERSION}/coraza-http-wasm-${CORAZA_VERSION}.zip" \ + && unzip -q /tmp/coraza.zip -d /plugins-local/src/github.com/jcchavezs/coraza-http-wasm-traefik \ + && rm /tmp/coraza.zip diff --git a/src/archive/archive_fetcher.erl b/src/archive/archive_fetcher.erl index 184099b..ba3adc4 100644 --- a/src/archive/archive_fetcher.erl +++ b/src/archive/archive_fetcher.erl @@ -4,9 +4,9 @@ -export([fetch/3]). fetch(CalendarId, Year, Month) -> - Start = {Year, Month, 1, 0, 0, 0}, - End = {Year, Month, 31, 23, 59, 59}, + Start = {{Year, Month, 1}, {0, 0, 0}}, + End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}}, mnesia:dirty_select(event, [{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'}, - [{'>=','$1', Start},{'=<','$1', End}], + [{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}], ['$_']}]). \ No newline at end of file diff --git a/src/archive/archive_manager.erl b/src/archive/archive_manager.erl index c3e68a0..bdc0d85 100644 --- a/src/archive/archive_manager.erl +++ b/src/archive/archive_manager.erl @@ -16,7 +16,7 @@ init([]) -> {ok, #{}}. handle_call({get_node, Day}, _From, State) -> - Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()), + Node = archive_node_name(Day), case maps:find(Node, State) of {ok, _Info} -> % переменная не используется, ок {reply, {ok, Node}, update_access(State, Node)}; @@ -55,14 +55,48 @@ update_access(State, Node) -> State#{Node => Info#{timer => Ref, last_access => erlang:monotonic_time()}}. start_archive_node(Day) -> - Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()), - case slave:start(host(), Node) of + Node = archive_node_name(Day), + case start_archive_peer(Node) of {ok, _} -> - rpc:call(Node, mnesia, start, []), - {ok, Node}; - Error -> Error + case ensure_archive_node_ready(Node) of + ok -> {ok, Node}; + {error, Reason} -> {error, Reason} + end; + {error, Reason} -> + {error, Reason} end. +start_archive_peer(Node) -> + case os:getenv("CLUSTER_MODE") of + "true" -> + case peer:start_link(#{name => Node, host => host()}) of + {ok, _} -> {ok, Node}; + {error, {already_started, _}} -> {ok, Node}; + Error -> Error + end; + _ -> + CookieStr = atom_to_list(erlang:get_cookie()), + case slave:start(host(), Node, "-setcookie " ++ CookieStr) of + {ok, _} -> {ok, Node}; + Error -> Error + end + end. + +ensure_archive_node_ready(Node) -> + case rpc:call(Node, mnesia, start, [], 5000) of + ok -> + case rpc:call(Node, code, ensure_loaded, [archive_fetcher], 5000) of + {module, archive_fetcher} -> ok; + {error, Reason} -> {error, Reason}; + {badrpc, Reason} -> {error, Reason} + end; + {badrpc, Reason} -> {error, Reason}; + Other -> {error, Other} + end. + +archive_node_name(Day) -> + list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()). + stop_archive_node(Node) -> rpc:cast(Node, init, stop, []). diff --git a/src/handlers/handler_calendar_view.erl b/src/handlers/handler_calendar_view.erl index 746ecd0..4f896f1 100644 --- a/src/handlers/handler_calendar_view.erl +++ b/src/handlers/handler_calendar_view.erl @@ -14,6 +14,8 @@ -include("records.hrl"). +-define(ARCHIVE_CALL_TIMEOUT, 3000). + %%% cowboy_handler callback -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. init(Req, Opts) -> @@ -135,10 +137,28 @@ fetch_hot_events(CalendarId, Year, Month) -> ]). %% @private Извлекает архивные события через RPC на архивный узел. +%% При недоступности архива возвращает события из основной Mnesia. -spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}). fetch_archive_events(CalendarId, Year, Month) -> - DayStr = io_lib:format("~4..0B~2..0B", [Year, Month]), - case archive_manager:get_archive_node(lists:flatten(DayStr)) of - {ok, Node} -> rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]); - {error, _} -> [] + DayStr = lists:flatten(io_lib:format("~4..0B~2..0B", [Year, Month])), + case safe_get_archive_node(DayStr) of + {ok, Node} -> + case rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month], + ?ARCHIVE_CALL_TIMEOUT) of + Events when is_list(Events) -> Events; + _ -> fetch_hot_events(CalendarId, Year, Month) + end; + {error, _} -> + fetch_hot_events(CalendarId, Year, Month) + end. + +-spec safe_get_archive_node(string()) -> {ok, node()} | {error, term()}. +safe_get_archive_node(Day) -> + try gen_server:call(archive_manager, {get_node, Day}, ?ARCHIVE_CALL_TIMEOUT) of + {ok, Node} when is_atom(Node) -> {ok, Node}; + {error, Reason} -> {error, Reason}; + Other -> {error, Other} + catch + exit:{timeout, _} -> {error, timeout}; + exit:{noproc, _} -> {error, noproc} end. \ No newline at end of file diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 0c0c10c..62dd2ea 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -41,7 +41,8 @@ client_delete/2, admin_patch/3]). -export([verify_user/2]). --export([future_date_iso8601/0]). +-export([future_date_iso8601/0, iso8601_utc/1, future_month_range_iso8601/0, + next_month_start_iso8601/0, next_month_range_iso8601/0]). %%%=================================================================== %%% Конфигурация окружения (CT_MODE, ...) @@ -283,11 +284,39 @@ future_date() -> -spec future_date_iso8601() -> binary(). future_date_iso8601() -> - Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400, - {{Y,M,D},{H,Min,S}} = calendar:gregorian_seconds_to_datetime(Seconds), + iso8601_utc(future_date()). + +-spec iso8601_utc(calendar:datetime()) -> binary(). +iso8601_utc({{Y, M, D}, {H, Min, S}}) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Y, M, D, H, Min, S])). +-spec future_month_range_iso8601() -> {binary(), binary()}. +future_month_range_iso8601() -> + {{Y, M, _D}, _} = future_date(), + month_range_iso8601(Y, M). + +-spec next_month_start_iso8601() -> binary(). +next_month_start_iso8601() -> + {{Y, M, _}, _} = future_date(), + {NY, NM} = next_month(Y, M), + iso8601_utc({{NY, NM, 1}, {10, 0, 0}}). + +-spec next_month_range_iso8601() -> {binary(), binary()}. +next_month_range_iso8601() -> + {{Y, M, _}, _} = future_date(), + {NY, NM} = next_month(Y, M), + month_range_iso8601(NY, NM). + +month_range_iso8601(Y, M) -> + From = iso8601_utc({{Y, M, 1}, {0, 0, 0}}), + LastDay = calendar:last_day_of_the_month(Y, M), + To = iso8601_utc({{Y, M, LastDay}, {23, 59, 59}}), + {From, To}. + +next_month(Y, 12) -> {Y + 1, 1}; +next_month(Y, M) -> {Y, M + 1}. + -spec register_and_login(binary(), binary()) -> binary(). register_and_login(Email, Password) -> % 1. Регистрируем пользователя diff --git a/test/api/users/user_events_tests.erl b/test/api/users/user_events_tests.erl index fcd6f7a..0f6e9e0 100644 --- a/test/api/users/user_events_tests.erl +++ b/test/api/users/user_events_tests.erl @@ -43,7 +43,9 @@ test_create_single_event(Token, CalId) -> %% @doc GET /v1/calendars/:calendar_id/events?from=...&to=... – список с фильтром. test_list_events_with_dates(Token, CalId) -> ct:pal(" TEST: List events with date range"), - Path = <<"/v1/calendars/", CalId/binary, "/events?from=2026-05-01T00:00:00Z&to=2026-07-01T00:00:00Z">>, + {From, To} = api_test_runner:future_month_range_iso8601(), + Path = <<"/v1/calendars/", CalId/binary, "/events?from=", From/binary, + "&to=", To/binary>>, Events = api_test_runner:client_get(Path, Token), ?assert(is_list(Events)), ?assert(length(Events) >= 1), @@ -58,7 +60,7 @@ test_create_recurring_event(Token, CalId) -> Path = <<"/v1/calendars/", CalId/binary, "/events">>, Body = jsx:encode(#{ title => <<"Weekly Meeting">>, - start_time => api_test_runner:future_date_iso8601(), + start_time => api_test_runner:next_month_start_iso8601(), duration => 60, recurrence => #{ freq => <<"WEEKLY">>, @@ -70,11 +72,12 @@ test_create_recurring_event(Token, CalId) -> #{<<"id">> := RecurringId} = jsx:decode(list_to_binary(RespBody), [return_maps]), ct:pal(" Created recurring event ~s", [RecurringId]), - % Запрашиваем вхождения на месяц - OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z">>, + {From, To} = api_test_runner:next_month_range_iso8601(), + OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=", From/binary, + "&to=", To/binary>>, Occs = api_test_runner:client_get(OccPath, Token), ?assert(is_list(Occs)), - ?assert(length(Occs) >= 4), % минимум 4 недели в июне + ?assert(length(Occs) >= 4), % минимум 4 недели в полном месяце FirstOcc = hd(Occs), ?assert(maps:is_key(<<"start_time">>, FirstOcc)), ct:pal(" OK: ~p occurrences found", [length(Occs)]). \ No newline at end of file diff --git a/test/api/users/user_occurrence_cancel_tests.erl b/test/api/users/user_occurrence_cancel_tests.erl index 2526da5..456a449 100644 --- a/test/api/users/user_occurrence_cancel_tests.erl +++ b/test/api/users/user_occurrence_cancel_tests.erl @@ -36,8 +36,9 @@ test() -> duration => 30, recurrence => #{freq => <<"WEEKLY">>, interval => 1}}), - % Получаем вхождения (ответ – список карт) - OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z">>, + {From, To} = api_test_runner:future_month_range_iso8601(), + OccPath = <<"/v1/events/", RecurringId/binary, "/occurrences?from=", From/binary, + "&to=", To/binary>>, Occurrences = api_test_runner:client_get(OccPath, OwnerToken), ?assert(is_list(Occurrences)), ?assert(length(Occurrences) >= 1), diff --git a/test/api/users/user_search_tests.erl b/test/api/users/user_search_tests.erl index 4b38078..6cfb981 100644 --- a/test/api/users/user_search_tests.erl +++ b/test/api/users/user_search_tests.erl @@ -93,9 +93,9 @@ test_tag_filter(Token) -> test_date_filter(Token) -> ct:pal(" TEST: Search with date range"), - Events = extract_events( - api_test_runner:client_get( - <<"/v1/search?from=2026-06-01T00:00:00Z&to=2026-06-15T23:59:59Z">>, Token)), + {From, To} = api_test_runner:future_month_range_iso8601(), + Path = <<"/v1/search?from=", From/binary, "&to=", To/binary>>, + Events = extract_events(api_test_runner:client_get(Path, Token)), ?assert(is_list(Events)), ?assert(length(Events) >= 1), ct:pal(" OK: ~p events", [length(Events)]).