root 69OlskpkFgLy842XEYolaCdbwH4eBpIR/t7NNLqHN2U=
udalov.kirill k.udalov@symfio.de Zo+BvGGWiGja1,N^XqckrZO%#)
stratyuk.igor stratyuk.igor@symfio.de ]b4/%<;i1%–z_W&2)=0{bRszdI
stasiuk.vlad vlad@spinic.de u6tYZpbU!z5wB>Pz#u~NNr(jY
nikita.levin nikita.levin@symfio.de fv~(MN/4Zo2$OU
repman-bot repman-bot@symfio.de gIBzNMVMp1i48UPcelhaGRyBSM
Runner токен glrt-UMKWxCH7dmkfFp0XNgd5bm86MQp0OjEKdTozCw.01.1212remlw
Токен Frontend project-access-token for sy-dealers-frontend glpat-Z7fNeCwsTlIHyMmWJANtcG86MQp1OnAH.01.0w0lhpdxe
Требования к серверу:
mkdir -p /srv/docker/gitlab-ce
cd /srv/docker/gitlab-ce
# Создаем переменную окружения, чтобы GitLab знал свой адрес
export GITLAB_HOME=/srv/docker/gitlab-ce
nano docker-compose.yml
version: '3.6'
services:
web:
image: 'gitlab/gitlab-ce:latest'
restart: always
# Заменияем на свой домен или IP
hostname: 'gitlab.symfio.net'
container_name: gitlab-ce
# environment:
ports:
- '8080:80'
- '443:443'
- '9090:9090'
- '5005:5005'
- '22:22'
volumes:
- './gitlab/config:/etc/gitlab'
- './gitlab/data:/var/opt/gitlab'
- './gitlab/logs:/var/log/gitlab'
shm_size: '256m'
gitlab-runner:
image: gitlab/gitlab-runner:latest
restart: always
container_name: gitlab-runner
volumes:
# Пробрасываем сокет докера, чтобы Runner мог создавать контейнеры (сборка)
- /var/run/docker.sock:/var/run/docker.sock
# Папка для конфига, чтобы регистрация не слетала после рестарта
- ./gitlab-runner-config:/etc/gitlab-runner
depends_on:
- web # Ждать запуска GitLab (опционально)
Порт 22 на сервере обычно занят вашим SSH-доступом к серверу. Поэтому мы говорим GitLab слушать SSH на порту 2222. Когда будете клонировать репозитории, адрес будет выглядеть так: ssh://git@gitlab.example.com:2222/group/project.git 9090 - порт для Grafana 10024 - порт для SSH, чтобы работать с проектами из репозитория 5005
Делаем config (лучше отдельно, хотя короткий можно и в docker-compose.yml прописать)
mkdir -p gitlab/config
nano gitlab/config/gitlab.rb
# Заменяем на свой домен или IP
# Если нужен HTTPS, поменяйте на https:// и настраиваем сертификаты, но для старта http проще
external_url 'https://gitlab.symfio.net'
gitlab_rails['gitlab_shell_ssh_port'] = 22
prometheus_monitoring['enable'] = true
prometheus['listen_address'] = '0.0.0.0:9090'
node_exporter['enable'] = true
gitlab_rails['monitoring_whitelist'] = ['0.0.0.0/0']
gitlab_rails['registry_enabled'] = true
registry_external_url 'https://gitlab.symfio.net:5005'
# Auth
# Отключаем регистрацию
gitlab_rails['gitlab_signup_enabled'] = false
# И вход по паролю
gitlab_rails['gitlab_signin_enabled'] = false
gitlab_rails['password_authentication_enabled_for_web'] = false
gitlab_rails['password_authentication_enabled_for_git'] = false
# Включаем аутентификацию по Keycloak
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_auto_create_users'] = true
gitlab_rails['omniauth_block_auto_created_users'] = false
# Автоматически связываем аккаунты
gitlab_rails['omniauth_auto_link_user'] = ['openid_connect']
gitlab_rails['omniauth_auto_link_ldap_user'] = true
# Админы из Keycloak группы
gitlab_rails['omniauth_admin_groups'] = ['gitlab-admins', 'Administrators']
# Внешние пользователи (ограниченные права)
gitlab_rails['omniauth_external_groups'] = ['gitlab-external']
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
# Имя кнопки
label: "Login with Keycloak",
icon: "https://www.keycloak.org/resources/images/icon.svg",
args: {
name: "openid_connect",
scope: ["openid", "profile", "email", "groups"],
response_type: "code",
# Ссылка на свой реалм (GitLab сам скачет конфиги по этой ссылке)
issuer: "https://oauth.spinic.net/realms/symfio-infrastructure",
discovery: true,
# Поле уникального ID в GitLab (preferred_username - логин, email и sub - UUID Kycloak)
uid_field: "preferred_username",
# Защищает от перехвата authorization code
pkce: true,
# Безопаснее basic (query - передает в в URL параметрах (?client_id=), body - в теле POST, basic в хедерах)
client_auth_method: "basic",
client_options: {
# Client ID из Keycloak
identifier: "gitlab",
secret: "8ZdUixsSns3mrYg2ca9Jciy9oSpYJfYU",
redirect_uri: "https://gitlab.symfio.net/users/auth/openid_connect/callback"
}
}
}
]
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "mail.symfio.de"
gitlab_rails['smtp_port'] = 465
gitlab_rails['smtp_user_name'] = "gitlab@symfio.de"
gitlab_rails['smtp_password'] = "h0l$a{CXN7=U?K%?d!9:darIbx"
gitlab_rails['smtp_domain'] = "symfio.de"
gitlab_rails['smtp_authentication'] = "login"
gitlab_rails['smtp_enable_starttls_auto'] = false
gitlab_rails['smtp_tls'] = true
gitlab_rails['gitlab_email_from'] = "gitlab@symfio.de"
docker-compose up -d
docker logs -f gitlab-ce
# GitLab запускается долго. Первый запуск может занять от 3 до 10 минут, пока проинициализируется база данных.
# Должно появиться
# GitLab Reconfigured!
\
При первой установке GitLab генерирует временный пароль для пользователя root. Он хранится в файле внутри контейнера (или в примонтированной папке) ровно 24 часа
# Просмотр пароля
docker exec -it gitlab-ce grep 'Password:' /etc/gitlab/initial_root_password
После входа нужно зайти в настройки профиля и сменить пароль на свой
По умолчанию в свежем GitLab включена свободная регистрация. В левом верхнем углу нажмите Menu -> Admin (значок гаечного ключа, иногда нужно найти "Admin Area"). Далее Settings -> General -> Sign-up restrictions Галочка Sign-up enabled
gitlab_rails['gitlab_signup_enabled'] = false
ssh-keygen -t ed25519 -C "stratyuk.igor" -f ~/.ssh/id_stratyuk.igor
cat ~/.ssh/id_stratyuk.igor.pub
nano ~/.ssh/config
Host gitlab.ivstech.org
IdentityFile ~/.ssh/id_stratyuk.igor
# добавить ssh-ключ публичный не заходя в пользователя
curl --request POST --header "PRIVATE-TOKEN: glpat-K1KpSpcT6zHdV_lYHCEGeW86MQp1OjEH.01.0w0m1dnbn" --data "title=LaptopKey" --data-urlencode "key=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP3vovG0aeGEb+MhL2V0j43R/zquYpcU6xs7Kh2w6amh stratyuk.igor" "https://gitlab.symfio.net/api/v4/users/3/keys"
Сменить пароль у пользователя, не заходя в него (система идиотская - только пользователь может сменить пароль и ssh-ключ публичный). Ждем секунд 30 до появления запроса консоли Rails
docker exec -it gitlab-ce gitlab-rails console
user = User.find_by_username('stratyuk.igor')
user.password = '6q<SwE7xh~Np$(M'
user.password_confirmation = '6q<SwE7xh~Np$(M'
user.save!
exit
git clone ssh://git@gitlab.ivstech.org:10026/root/my-test-project-load.git
Пример конфигурации с upstream на несколько доменов nano /etc/nginx/sites-enabled/proxy
# Upstream для Proxmox Web UI
upstream proxmox_backend {
server 127.0.0.1:8006;
}
# Upstream для gitlab виртуалки
upstream gitlab_backend {
server 192.168.1.7:443; # Внутренний IP виртуалки с GitLab
}
# Proxmox VE Web UI
server {
listen 443 ssl;
# http2 on;
server_name pve.ivstech.org;
ssl_certificate /etc/letsencrypt/live/pve.ivstech.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pve.ivstech.org/privkey.pem;
location / {
proxy_pass https://proxmox_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Proxmox WebSocket support
proxy_buffering off;
client_max_body_size 0;
proxy_connect_timeout 3600s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
send_timeout 3600s;
}
}
server {
listen 443 ssl;
# http2 on;
server_name gitlab.ivstech.org;
ssl_certificate /etc/letsencrypt/live/gitlab.ivstech.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitlab.ivstech.org/privkey.pem;
location / {
proxy_pass https://gitlab_backend;
proxy_http_version 1.1;
proxy_set_header Host gitlab.ivstech.org; # важно: GitLab должен видеть gitlab.ivstech.org
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
client_max_body_size 50M;
proxy_read_timeout 300s;
# proxy_ssl_verify off; # игнорировать self-signed сертификат Exchange
proxy_ssl_session_reuse on;
}
}
# HTTP → HTTPS redirection
server {
listen 80;
server_name pve.ivstech.org gitlab.ivstech.org;
return 301 https://$host$request_uri;
}
nginx -t
nginx -s reload
systemctl enable --now certbot-renew.timer
certbot certonly --nginx --register-unsafely-without-email -d gitlab.symfio.net
# потом в следующие разы
certbot certonly --nginx -d gitlab.ivstech.org
# Скачиваем из старого места
# флаг --mirror — он скачивает "голый" репозиторий (базу данных git), а не просто файлы
git clone --mirror git@repo.symfio.de:sy-cars-co-za.git
cd sy-dealers-frontend-v1
# Отправляем в новое место
git push --no-verify --mirror ssh://git@gitlab.symfio.net:10024/parsers/sy-cars-co-za.git
Готово! Все ветки, теги и история коммитов теперь в GitLab. Папку ваш-репозиторий.git на компьютере можно удалить.
Settings -> General -> Import and Export Settings
Создаем Personal Access Token в GitLab
ssh git@repo.symfio.de info | grep '^ R' | awk '{print $NF}' > repos.txt
nano migrate.sh
#!/bin/bash
# --- НАСТРОЙКИ ---
GITLAB_URL="https://gitlab.ivstech.org"
GITLAB_SSH="ssh://git@gitlab.ivstech.org:10026" # Ваш порт SSH
TOKEN="ВАШ_API_ТОКЕН"
GITOLITE_URL="git@repo.symfio.de"
# -----------------
for REPO in $(cat repos.txt); do
echo "--- Миграция репозитория: $REPO ---"
# 1. Создаем проект в GitLab через API
# Если в Gitolite имя group/repo, GitLab создаст его в соответствующем пространстве
# Но для простоты здесь создаем всё в личном пространстве админа
REPO_NAME=$(basename $REPO)
echo "Создание проекта в GitLab..."
curl --header "PRIVATE-TOKEN: $TOKEN" \
-X POST "$GITLAB_URL/api/v4/projects?name=$REPO_NAME&visibility=private"
# 2. Клонируем зеркало из Gitolite
echo "Клонирование из Gitolite..."
git clone --mirror "$GITOLITE_URL:$REPO.git" temp_repo.git
# 3. Пушим зеркало в GitLab
echo "Пуш в GitLab..."
cd temp_repo.git
git push --mirror "$GITLAB_SSH/root/$REPO_NAME.git"
# 4. Удаляем временную папку
cd ..
rm -rf temp_repo.git
echo "--- Готово! ---"
done
Скрипт сохранит всё: ветки, теги, историю коммитов при скорости: ~100 проектов переедут за 10-20 минут Группы: Если в Gitolite проекты лежали в папках (например, firmware/driver.git), то нужно сначала создать группу firmware в GitLab, иначе скрипт создаст всё в кучу. API создания проектов позволяет указывать namespace_id. Большие файлы (LFS): Если в проектах есть файлы больше 100Мб, то в GitLab в настройках нужно разрешить соответствующий лимит загрузки.
Создаем новый раннер в проекте, копируем его токен. В интерфейсе GitLab зайти в Admin Area -> CI/CD -> Runners (запоминаем, дальше нельзя будет узнать).
docker exec -it gitlab-runner gitlab-runner register --url "https://gitlab.symfio.net" --token "glrt-j5I33vs-iI0R8xVAtx-Xg286MQpwOjIKdDozCnU6MQ8.01.170dwkkvw"
# Если висит стадия и нет логов в веб-интерфейсе, то смотрим логи контейнера Runner
docker logs -f gitlab-runner --tail 50
url и name - просто соглашаемся, хотя name, если нужно можно и поменять
Enter an executor: kubernetes, custom, shell, ssh, virtualbox, docker-windows, docker+machine, docker-autoscaler, instance, parallels, docker:
docker
Enter the default Docker image (for example, ruby:3.3):
alpine:latest
Редактируем конфиг Runner'а после регистрации
nano gitlab-runner-config/config.toml
concurrent = 1
check_interval = 0
shutdown_timeout = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "docker-runner"
url = "https://gitlab.ivstech.org"
id = 2
token = "glrt-j5I33vs-iI0R8xVAtx-Xg286MQpwOjIKdDozCnU6MQ8.01.170dwkkvw"
token_obtained_at = 2026-02-10T10:42:21Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "docker"
clone_url = "https://gitlab.ivstech.org"
[runners.cache]
MaxUploadedArchiveSize = 0
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]
[runners.docker]
tls_verify = false
image = "docker:latest"
privileged = true
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache"]
shm_size = 0
network_mtu = 0
network_mode = "gitlab-ce_default"
или с тэгом и конкуренцией
concurrent = 2
check_interval = 0
connection_max_age = "15m0s"
shutdown_timeout = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "bf6a59acb52e"
tags = ["ai-review"]
request_concurrency = 4
url = "https://gitlab.symfio.net"
id = 1
token = "glrt-UMKWxCH7dmkfFp0XNgd5bm86MQp0OjEKdTozCw.01.1212remlw"
token_obtained_at = 2026-03-10T10:15:30Z
token_expires_at = 0001-01-01T00:00:00Z
executor = "docker"
[runners.cache]
MaxUploadedArchiveSize = 0
[runners.cache.s3]
[runners.cache.gcs]
[runners.cache.azure]
[runners.docker]
tls_verify = false
image = "alpine:latest"
privileged = false
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
volumes = ["/cache"]
shm_size = 0
network_mtu = 0
допускается несколько Runner'ов. Также, если создается instance-runner, а не под проект, то нужно в проекте в Settings -> CI/CD -> Runners -> Insctance включить их использование
Ложится в корень .gitlab-ci.yml (описывает все стадии) nano .gitlab-ci.yml
stages:
- build
- deploy
- manual_run
variables:
INTERNAL_REGISTRY: "gitlab-ce:5005"
IMAGE_TAG: "$INTERNAL_REGISTRY/root/my-test-project-load:$CI_COMMIT_SHORT_SHA"
LATEST_TAG: "$INTERNAL_REGISTRY/root/my-test-project-load:latest"
DOCKER_TLS_CERTDIR: ""
build_image:
stage: build
image: docker:20.10.16
services:
- name: docker:20.10.16-dind
command: ["--insecure-registry=gitlab-ce:5005"]
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $INTERNAL_REGISTRY
script:
- echo "Building Docker image..."
- docker build -t $IMAGE_TAG -f devops/docker/Dockerfile .
- docker push $IMAGE_TAG
- docker tag $IMAGE_TAG $LATEST_TAG
- docker push $LATEST_TAG
deploy_migrations:
stage: deploy
image: docker:20.10.16
services:
- name: docker:20.10.16-dind
command: ["--insecure-registry=gitlab-ce:5005"]
script:
- apk add --no-cache docker-compose
- echo "Running migrations..."
- docker-compose run --rm --no-deps carscoza_benoni php --help
only:
- master
parser_job:
stage: manual_run
image: docker:20.10.16
services:
- name: docker:20.10.16-dind
command: ["--insecure-registry=gitlab-ce:5005"]
when: manual
variables:
SELLER_ID: "7324"
LIMIT: "100"
WEBSITE: "benoni.fynatic-cars.co.za"
ENV_VHOST: "prod"
script:
- apk add --no-cache docker-compose
- 'echo "Starting parser for $WEBSITE (Seller: $SELLER_ID)..."'
- docker-compose up -d
- sleep 10
- docker-compose exec -T carscoza_benoni php app/carscoza_get_vehicles.php --host=$WEBSITE --sellerId=$SELLER_ID --vhost=$ENV_VHOST
after_script:
- apk add --no-cache docker-compose
- docker-compose down --remove-orphans
Автоматически не мерджится (философия GitLab). Нужно создавать запрос. Варианты:
auto_merge_to_master:
stage: deploy
image: bitnami/git
variables:
GIT_STRATEGY: none
script:
# Настраиваем Git
- git config --global user.email "ci-bot@gitlab.com"
- git config --global user.name "CI Bot"
# Клонируем текущую ветку и мастер
- git clone https://oauth2:${CI_PUSH_TOKEN}@gitlab.ivstech.org/root/my-test-project-load.git temp_repo
- cd temp_repo
- git checkout master
- git pull origin master
# Мержим то, что сейчас собралось (наш коммит)
- git merge $CI_COMMIT_SHA
# Пушим в мастер
- git push origin master
only:
- branches
except:
- master
Конфиг добавляем после GITLAB_OMNIBUS_CONFIG: | В конфиге учтен гибридный режим, чтобы некоторые пользователи могли аутентифицироваться и в GitLab и в Keycloak. icon - можно сменить на любую другую
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_block_auto_created_users'] = false
# Автоматически связывать аккаунты по Email
gitlab_rails['omniauth_auto_link_user'] = ['openid_connect']
gitlab_rails['omniauth_auto_link_ldap_user'] = true
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
label: "Keycloak", # Имя на кнопке входа
icon: "https://www.keycloak.org/resources/images/logo.svg",
args: {
name: "openid_connect",
scope: ["openid", "profile", "email"],
response_type: "code",
# Ссылка на твой реалм (GitLab сам скачет конфиги по этой ссылке)
issuer: "https://oauth.spinic.net/realms/symfio-infrastructure",
discovery: true,
client_auth_method: "query",
client_options: {
identifier: "gitlab", # Client ID из Keycloak
secret: "8ZdUixsSns3mrYg2ca9Jciy9oSpYJfYU",
redirect_uri: "https://gitlab.symfio.net/users/auth/openid_connect/callback"
}
}
}
]
Где посмотреть issuer (смотрим поле issuer и вставляем эту ссылку) https://oauth-admin.spinic.net/realms/symfio-infrastructure/.well-known/openid-configuration
Делаем контейнер, скрипт и конфиг. Скрипт будет синхронизировать всех пользователей из групп, которые указываем, админов из групп, которые указали, external делать пользователей в соответствующей группе, а также блочить, если такого пользователя нет или уже нет. Protected users не трогает - их отдельно указываем. Синхронизация по логину
mkdir gitlab-keycloak-sync nano docker-compose.yml
version: '3.8'
services:
gitlab-keycloak-sync:
image: node:18-alpine
container_name: gitlab-keycloak-sync
working_dir: /app
volumes:
- ./config.json:/app/config.json:ro
- ./sync.js:/app/sync.js:ro
command: >
sh -c "npm install axios && node /app/sync.js"
restart: unless-stopped
nano config.json
{
"port": 8080,
"syncInterval": "2m",
"gitlab": {
"api": "https://gitlab.symfio.net/api/v4",
"privateToken": "glpat-ihp0XSOgT3yZ2ZyWnqbOjG86MQp1OjkH.01.0w0p90d98"
},
"keycloak": {
"url": "https://oauth.spinic.net",
"realm": "symfio-infrastructure",
"username": "gitlab-keycloak-sync",
"password": "x&4E?%k7lLl)ce}4S{sO09($[>"
},
"orphanedUsersAction": "block",
"protectedUsers": [
"root",
"Administrator",
"gitlab-keycloak-sync",
"project_4_bot_ab5744109de7c019b6242a4079cb602f",
"ghost",
"alert-bot",
"GitLabDuo"
],
"groupMappings": [
{"keycloak": "Administrators", "gitlab": "Administrators", "admin": true, "accessLevel": 50},
{"keycloak": "gitlab-admins", "gitlab": "Administrators", "admin": true, "accessLevel": 50},
{"keycloak": "Backend", "gitlab": "Backend", "accessLevel": 30},
{"keycloak": "Frontend", "gitlab": "Frontend", "accessLevel": 30},
{"keycloak": "Support", "gitlab": "Support", "accessLevel": 30},
{"keycloak": "Tests", "gitlab": "Tests", "accessLevel": 30},
{"keycloak": "External", "gitlab": "External", "external": true, "accessLevel": 20},
{"keycloak": "gitlab-external", "gitlab": "External", "external": true, "accessLevel": 20}
],
"systemUsers": [
{"username": "jenkins", "accessLevel": 30}
]
}
nano sync.js
const axios = require('axios');
const config = require('./config.json');
const keycloakApi = axios.create({
baseURL: `${config.keycloak.url}/admin/realms/${config.keycloak.realm}`,
});
const gitlabApi = axios.create({
baseURL: config.gitlab.api,
headers: { 'PRIVATE-TOKEN': config.gitlab.privateToken }
});
let keycloakToken = null;
// Хранилище для отслеживания кто должен быть admin/external
const adminUsers = new Set();
const externalUsers = new Set();
// ============== Keycloak Functions ==============
async function getKeycloakToken() {
const response = await axios.post(
`${config.keycloak.url}/realms/${config.keycloak.realm}/protocol/openid-connect/token`,
new URLSearchParams({
client_id: 'admin-cli',
username: config.keycloak.username,
password: config.keycloak.password,
grant_type: 'password'
})
);
keycloakToken = response.data.access_token;
keycloakApi.defaults.headers.Authorization = `Bearer ${keycloakToken}`;
console.log('Keycloak token obtained');
}
async function getKeycloakGroupMembers(groupName) {
try {
const groupsResponse = await keycloakApi.get('/groups?search=' + encodeURIComponent(groupName) +
'&briefRepresentation=false');
const findGroup = (groups, name) => {
for (const g of groups) {
if (g.name === name) return g;
if (g.subGroups && g.subGroups.length > 0) {
const found = findGroup(g.subGroups, name);
if (found) return found;
}
}
return null;
};
const group = findGroup(groupsResponse.data, groupName);
if (!group) {
return [];
}
const membersResponse = await keycloakApi.get(`/groups/${group.id}/members`);
return membersResponse.data;
} catch (e) {
console.log(` Error getting Keycloak group ${groupName}:`, e.message);
return [];
}
}
async function getAllKeycloakUsers() {
try {
const users = [];
let first = 0;
const max = 100;
while (true) {
const response = await keycloakApi.get(`/users?first=${first}&max=${max}`);
if (response.data.length === 0) break;
users.push(...response.data);
first += max;
}
return users;
} catch (e) {
console.log('Error getting Keycloak users:', e.message);
return [];
}
}
// ============== GitLab Functions ==============
async function getOrCreateGitlabGroup(groupName) {
try {
const response = await gitlabApi.get(`/groups?search=${encodeURIComponent(groupName)}`);
const found = response.data.find(g =>
g.name.toLowerCase() === groupName.toLowerCase() ||
g.path.toLowerCase() === groupName.toLowerCase()
);
if (found) {
return found;
}
console.log(` Creating GitLab group: ${groupName}`);
const createResponse = await gitlabApi.post('/groups', {
name: groupName,
path: groupName.toLowerCase().replace(/\s+/g, '-'),
visibility: 'internal'
});
return createResponse.data;
} catch (e) {
console.log(` Error with GitLab group ${groupName}:`, e.response?.data?.message || e.message);
return null;
}
}
async function getGitlabUser(email) {
try {
// Извлекаем username из email (часть до @)
const username = email.split('@')[0];
// Поиск по точному username
const response = await gitlabApi.get(`/users?username=${encodeURIComponent(username)}`);
if (response.data.length > 0) {
return response.data[0];
}
// Fallback - поиск по email
const searchResponse = await gitlabApi.get(`/users?search=${encodeURIComponent(email)}`);
return searchResponse.data.find(u =>
u.email?.toLowerCase() === email.toLowerCase() ||
u.username?.toLowerCase() === username.toLowerCase()
);
} catch (e) {
return null;
}
}
async function getGitlabUserByUsername(username) {
try {
const response = await gitlabApi.get(`/users?username=${encodeURIComponent(username)}`);
return response.data[0] || null;
} catch (e) {
return null;
}
}
async function getAllGitlabUsers() {
try {
const users = [];
let page = 1;
while (true) {
const response = await gitlabApi.get(`/users?per_page=100&page=${page}`);
if (response.data.length === 0) break;
users.push(...response.data);
page++;
}
return users;
} catch (e) {
console.log('Error getting GitLab users:', e.message);
return [];
}
}
async function getGitlabGroupMembers(groupId) {
try {
const response = await gitlabApi.get(`/groups/${groupId}/members`);
return response.data;
} catch (e) {
return [];
}
}
async function setGitlabUserAdmin(userId, isAdmin, currentUser) {
// Проверяем текущий статус
if (currentUser && currentUser.is_admin === isAdmin) {
return false;
}
try {
await gitlabApi.put(`/users/${userId}`, { admin: isAdmin });
console.log(` ${isAdmin ? 'Added' : 'Removed'} admin for user ${userId}`);
return true;
} catch (e) {
console.log(` Error setting admin for user ${userId}:`, e.response?.data?.message || e.message);
return false;
}
}
async function setGitlabUserExternal(userId, isExternal, currentUser) {
// Проверяем текущий статус
if (currentUser && currentUser.external === isExternal) {
return false;
}
try {
await gitlabApi.put(`/users/${userId}`, { external: isExternal });
console.log(` ${isExternal ? 'Set' : 'Removed'} external for user ${userId}`);
return true;
} catch (e) {
console.log(` Error setting external for user ${userId}:`, e.response?.data?.message || e.message);
return false;
}
}
async function blockGitlabUser(userId) {
try {
await gitlabApi.post(`/users/${userId}/block`);
console.log(` Blocked user ${userId}`);
return true;
} catch (e) {
if (e.response?.data?.message?.includes('already blocked')) {
return false;
}
console.log(` Error blocking user ${userId}:`, e.response?.data?.message || e.message);
return false;
}
}
async function unblockGitlabUser(userId) {
try {
await gitlabApi.post(`/users/${userId}/unblock`);
console.log(` Unblocked user ${userId}`);
return true;
} catch (e) {
return false;
}
}
async function addUserToGitlabGroup(groupId, userId, accessLevel = 30) {
try {
await gitlabApi.post(`/groups/${groupId}/members`, {
user_id: userId,
access_level: accessLevel
});
return true;
} catch (e) {
if (e.response?.status === 409 || e.response?.data?.message?.includes('already exists')) {
try {
await gitlabApi.put(`/groups/${groupId}/members/${userId}`, {
access_level: accessLevel
});
} catch (e2) {
// Ignore
}
return false;
} else {
console.log(` Error adding user to group:`, e.response?.data?.message || e.message);
return false;
}
}
}
async function removeUserFromGitlabGroup(groupId, userId) {
try {
await gitlabApi.delete(`/groups/${groupId}/members/${userId}`);
return true;
} catch (e) {
return false;
}
}
// ============== Sync Functions ==============
async function syncGroup(mapping) {
console.log(`Syncing: ${mapping.keycloak} → ${mapping.gitlab}`);
const kcMembers = await getKeycloakGroupMembers(mapping.keycloak);
if (kcMembers.length === 0) {
console.log(` No members in Keycloak group ${mapping.keycloak}`);
}
const gitlabGroup = await getOrCreateGitlabGroup(mapping.gitlab);
if (!gitlabGroup) {
console.log(` Failed to get/create GitLab group: ${mapping.gitlab}`);
return;
}
console.log(` GitLab group ID: ${gitlabGroup.id}, Members in Keycloak: ${kcMembers.length}`);
const accessLevel = mapping.accessLevel || 30;
// Получить текущих членов группы GitLab
const glMembers = await getGitlabGroupMembers(gitlabGroup.id);
const glMemberIds = new Set(glMembers.map(m => m.id));
const kcUserIds = new Set();
// Обработка членов Keycloak
for (const kcMember of kcMembers) {
if (!kcMember.username) {
console.log(` Skipping user without username`);
continue;
}
// Используем Keycloak username напрямую для поиска в GitLab
const glUser = await getGitlabUserByUsername(kcMember.username);
if (!glUser) {
console.log(` User not in GitLab: ${kcMember.username} (${kcMember.email || 'no email'})`);
continue;
}
kcUserIds.add(glUser.id);
// Отслеживаем кто должен быть admin/external
if (mapping.admin) {
adminUsers.add(glUser.id);
}
if (mapping.external) {
externalUsers.add(glUser.id);
}
// Добавить в группу если не член
if (!glMemberIds.has(glUser.id)) {
console.log(` Adding to group: ${kcMember.username}`);
await addUserToGitlabGroup(gitlabGroup.id, glUser.id, accessLevel);
}
}
// Удалить тех, кого нет в Keycloak группе
const protectedUsernames = (config.protectedUsers || []).map(u => u.toLowerCase());
const systemUsernames = (config.systemUsers || []).map(u => u.username.toLowerCase());
const skipUsernames = new Set([...protectedUsernames, ...systemUsernames]);
for (const glMember of glMembers) {
// Не удалять Owner (access_level 50)
if (glMember.access_level >= 50) continue;
// Не удалять защищённых и системных пользователей
if (skipUsernames.has(glMember.username?.toLowerCase())) continue;
// Если нет в Keycloak группе — удалить из GitLab группы
if (!kcUserIds.has(glMember.id)) {
console.log(` Removing from group: ${glMember.username || glMember.email}`);
await removeUserFromGitlabGroup(gitlabGroup.id, glMember.id);
}
}
}
async function syncUserFlags() {
console.log('Syncing user flags (admin/external)...');
const glUsers = await getAllGitlabUsers();
let adminChanged = 0;
let externalChanged = 0;
// Собираем всех защищённых пользователей
const protectedUsernames = (config.protectedUsers || []).map(u => u.toLowerCase());
const systemUsernames = (config.systemUsers || []).map(u => u.username.toLowerCase());
const skipUsernames = new Set([...protectedUsernames, ...systemUsernames, 'root']);
for (const glUser of glUsers) {
// Пропустить защищённых и системных пользователей
if (skipUsernames.has(glUser.username?.toLowerCase())) continue;
// Пропустить если не видим текущий статус (нет прав)
if (glUser.is_admin === null || glUser.is_admin === undefined) {
continue;
}
// Admin flag
const shouldBeAdmin = adminUsers.has(glUser.id);
if (glUser.is_admin !== shouldBeAdmin) {
const changed = await setGitlabUserAdmin(glUser.id, shouldBeAdmin, glUser);
if (changed) adminChanged++;
}
// External flag
const shouldBeExternal = externalUsers.has(glUser.id);
if (glUser.external !== shouldBeExternal) {
const changed = await setGitlabUserExternal(glUser.id, shouldBeExternal, glUser);
if (changed) externalChanged++;
}
}
console.log(` Admin changes: ${adminChanged}, External changes: ${externalChanged}`);
}
async function syncDisabledUsers() {
console.log('Syncing disabled/deleted users...');
const kcUsers = await getAllKeycloakUsers();
// Используем username как основной идентификатор
const kcEnabledUsernames = new Set(kcUsers.filter(u => u.enabled).map(u => u.username?.toLowerCase()).filter(Boolean));
const kcDisabledUsernames = new Set(kcUsers.filter(u => !u.enabled).map(u => u.username?.toLowerCase()).filter(Boolean));
const glUsers = await getAllGitlabUsers();
let blockedCount = 0;
let unblockedCount = 0;
// Собираем всех защищённых пользователей
const protectedUsernames = (config.protectedUsers || []).map(u => u.toLowerCase());
const systemUsernames = (config.systemUsers || []).map(u => u.username.toLowerCase());
const skipUsernames = new Set([...protectedUsernames, ...systemUsernames, 'root']);
// Действие для пользователей, которых нет в Keycloak
const orphanedAction = config.orphanedUsersAction || 'ignore';
for (const glUser of glUsers) {
// Пропустить защищённых и системных
if (skipUsernames.has(glUser.username?.toLowerCase())) continue;
const username = glUser.username?.toLowerCase();
if (!username) continue;
// Проверяем по username
const isEnabledInKC = kcEnabledUsernames.has(username);
const isDisabledInKC = kcDisabledUsernames.has(username);
const existsInKC = isEnabledInKC || isDisabledInKC;
// Если в Keycloak отключен — заблокировать в GitLab
if (isDisabledInKC) {
if (glUser.state === 'active') {
console.log(` Blocking disabled KC user: ${glUser.username}`);
await blockGitlabUser(glUser.id);
blockedCount++;
}
}
// Если в Keycloak активен — разблокировать в GitLab
else if (isEnabledInKC) {
if (glUser.state === 'blocked') {
await unblockGitlabUser(glUser.id);
unblockedCount++;
}
}
// Если вообще нет в Keycloak — действие по конфигу
else if (!existsInKC && orphanedAction === 'block') {
if (glUser.state === 'active') {
console.log(` Blocking orphaned user (not in KC): ${glUser.username}`);
await blockGitlabUser(glUser.id);
blockedCount++;
}
}
}
console.log(` Blocked: ${blockedCount}, Unblocked: ${unblockedCount}`);
}
async function shareAdminsWithAllGroups() {
console.log('Sharing Administrators with all groups...');
const adminsGroup = await getOrCreateGitlabGroup('Administrators');
if (!adminsGroup) {
console.log(' Administrators group not found');
return;
}
console.log(` Administrators group ID: ${adminsGroup.id}`);
let page = 1;
let sharedCount = 0;
let alreadyCount = 0;
while (true) {
const response = await gitlabApi.get(`/groups?per_page=100&page=${page}`);
if (response.data.length === 0) break;
for (const group of response.data) {
if (group.id === adminsGroup.id) continue;
try {
await gitlabApi.post(`/groups/${group.id}/share`, {
group_id: adminsGroup.id,
group_access: 50 // Owner
});
console.log(` ✓ Shared with: ${group.name}`);
sharedCount++;
} catch (e) {
if (e.response?.status === 409 ||
e.response?.data?.message?.includes('already') ||
e.response?.data?.error?.includes('already')) {
alreadyCount++;
} else {
console.log(` ✗ Error sharing with ${group.name}:`, e.response?.data?.message || e.response?.data?.error || e.message);
}
}
}
page++;
}
console.log(` Shared: ${sharedCount}, Already shared: ${alreadyCount}`);
}
async function addSystemUsersToAllGroups() {
if (!config.systemUsers || config.systemUsers.length === 0) return;
console.log('Adding system users to all groups...');
const excludeGroups = ['administrators'];
for (const sysUser of config.systemUsers) {
const glUser = await getGitlabUserByUsername(sysUser.username);
if (!glUser) {
console.log(` System user not found: ${sysUser.username}`);
continue;
}
console.log(` Processing: ${sysUser.username} (ID: ${glUser.id})`);
let page = 1;
let addedCount = 0;
let alreadyCount = 0;
let skippedCount = 0;
while (true) {
const response = await gitlabApi.get(`/groups?per_page=100&page=${page}`);
if (response.data.length === 0) break;
for (const group of response.data) {
if (excludeGroups.includes(group.name.toLowerCase()) || excludeGroups.includes(group.path.toLowerCase())) {
skippedCount++;
continue;
}
try {
await gitlabApi.post(`/groups/${group.id}/members`, {
user_id: glUser.id,
access_level: sysUser.accessLevel || 20 // Reporter по умолчанию
});
addedCount++;
} catch (e) {
if (e.response?.status === 409 || e.response?.data?.message?.includes('already')) {
alreadyCount++;
} else {
console.log(` ✗ Error adding to ${group.name}:`, e.response?.data?.message || e.message);
}
}
}
page++;
}
console.log(` Added: ${addedCount}, Already: ${alreadyCount}, Skipped: ${skippedCount}`);
}
}
// ============== Main Sync ==============
async function sync() {
console.log('========================================');
console.log('Starting sync...');
console.log(`Time: ${new Date().toISOString()}`);
console.log(`Keycloak: ${config.keycloak.url}/realms/${config.keycloak.realm}`);
console.log(`GitLab: ${config.gitlab.api}`);
console.log('');
// Очищаем сеты для нового цикла
adminUsers.clear();
externalUsers.clear();
try {
await getKeycloakToken();
// Синхронизация групп
for (const mapping of config.groupMappings) {
await syncGroup(mapping);
console.log('');
}
// Синхронизация флагов admin/external
await syncUserFlags();
console.log('');
// Синхронизация заблокированных/удалённых
await syncDisabledUsers();
console.log('');
// Расшарить Administrators
await shareAdminsWithAllGroups();
console.log('');
// Добавить системных пользователей
await addSystemUsersToAllGroups();
console.log('');
console.log('Sync complete!');
} catch (err) {
console.error('Sync error:', err.message);
}
}
// ============== Run ==============
// Первый запуск
sync();
// Планировщик
if (config.syncInterval) {
const match = config.syncInterval.match(/^(\d+)([smh])$/);
if (match) {
const value = parseInt(match[1]);
const unit = match[2];
const multiplier = { s: 1000, m: 60000, h: 3600000 };
const interval = value * multiplier[unit];
console.log(`Scheduling sync every ${config.syncInterval}`);
setInterval(() => sync(), interval);
}
}
Настраиваются в личном профиле Notifications. Нужна прописанная почта и верифицированная почта. В настройках напротив нужной группы указываем Custom и выбираем какие сообщения нам нужны, чтобы они приходили
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "mail.symfio.de"
gitlab_rails['smtp_port'] = 465
gitlab_rails['smtp_user_name'] = "gitlab@symfio.de"
gitlab_rails['smtp_password'] = "h0l$a{CXN7=U?K%?d!9:darIbx"
gitlab_rails['smtp_domain'] = "symfio.de"
gitlab_rails['smtp_authentication'] = "login"
gitlab_rails['smtp_enable_starttls_auto'] = false
gitlab_rails['smtp_tls'] = true
gitlab_rails['gitlab_email_from'] = "gitlab@symfio.de"
живут годами.
Есть два способа связать их: Webhook (GitLab толкает Jenkins) и Polling (Jenkins опрашивает GitLab).
Инструкция: Как подружить GitLab и Jenkins
Шаг 1. Настройка Jenkins (Прием звонка)
Шаг 2. Настройка GitLab (Звонок другу)
Шаг 3. Тест
Как Jenkins узнает статус сборки? (Обратная связь)
Чтобы в GitLab возле коммита появлялась зеленая галочка (или красный крестик) от Дженкинса:
Итог:Вы пушите в GitLab -> GitLab дергает Jenkins -> Jenkins собирает -> Jenkins рапортует в GitLab "Успех/Провал".Вы видите красивые зеленые галочки прямо в GitLab, но всю грязную работу делает старый Jenkins.
Если в случае с Webhook инициатором выступает GitLab (он «звонит» Дженкинсу), то в случае с Polling инициаторомявляется Jenkins. Он с определенной периодичностью (например, каждую минуту) заходит в GitLab и спрашивает: «Ну что,появились новые коммиты?»
Этот способ — спасение, если ваш Jenkins находится за глухим фаерволом, VPN или NAT, и GitLab физически не можетотправить ему запрос.
Пошаговая настройка Polling в Jenkins
Шаг 1. Настройка доступа к кодуВ настройках вашей Job (задачи) в Jenkins:
\
Шаг 2. Включение опроса (Polling)6. Листайте вниз до раздела Build Triggers (Триггеры сборки).7. Поставьте галочку Poll SCM.8. Появится поле Schedule. Сюда нужно вписать расписание в формате cron.
Шаг 3. Настройка расписанияФормат состоит из 5 полей: минуты часы день месяц день_недели.
H заставляет Jenkins немного смещать время опроса, чтобы не перегружать сервер, если у вас 100 проектовопрашиваются одновременно).Плюсы и минусы Polling
Плюсы:
Минусы:
Когда использовать?
Важный нюанс: Если вы настроите и Webhook, и Polling одновременно, Jenkins будет срабатывать и от того, и от другого.Обычно оставляют что-то одно. Так как вы уже «намучились» с сетью и портами, Polling — это самый спокойный вариант,который точно будет работать.
На хосте Jenkins делаем sudo -u jenkins sh -c "ssh-keyscan -p 22 gitlab.symfio.net >> /var/lib/jenkins/.ssh/known_hosts"
Ссылка будет такой, если 22 порт git@gitlab.symfio.net:/parsers/sy-cars-co-za.git или ssh://git@gitlab.symfio.net/parsers/sy-cars-co-za.git И такой, если 10024 ssh://git@gitlab.symfio.net:10024/parsers/sy-cars-co-za.git