Showcase update - f20be312
Quote Bot CI/CD Pipeline / check (push) Waiting to run
Quote Bot CI/CD Pipeline / Security Scan (push) Waiting to run
Quote Bot CI/CD Pipeline / Build and push to Docker Hub (push) Blocked by required conditions
Quote Bot CI/CD Pipeline / Deploy to VPS via Ansible (push) Blocked by required conditions

This commit is contained in:
GitLab CI/CD
2026-09-13 10:32:45 +00:00
commit 63c6a83e88
20 changed files with 673 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.github/
.git/
.gitignore
+4
View File
@@ -0,0 +1,4 @@
TOKEN=your_token_here
ADMIN_IDS=123456789,987654321
+96
View File
@@ -0,0 +1,96 @@
name: Quote Bot CI/CD Pipeline
on:
push:
branches:
- main
jobs:
check-syntax:
name: check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Syntax Check
run: |
pip install pyflakes
pyflakes src/
security-scan:
name: Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run OWASP Noir
id: noir
uses: owasp-noir/noir@v1.1.0
with:
base_path: 'src'
- name: Display results
run: echo '${{ steps.noir.outputs.endpoints }}' | jq .
- name: Run Bandit (Python SAST)
run: |
pip install bandit
bandit -r src/
build-and-push:
name: Build and push to Docker Hub
needs: [security-scan, check-syntax]
runs-on: ubuntu-latest
steps:
- name: Скачиваем код бота
uses: actions/checkout@v4
- name: Логинимся в Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Сборка и пуш Docker образа
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: leor156/quote-bot:latest
#terraform infrastructure-check and infrastructure-deployment:
#name: check infrastructure
#needs: [build-and-push]
#runs-on: ubuntu-latest
#steps:
#- uses: actions/checkout@v4
#- name: Terraform init
#run: terraform init
#- name: Terraform plan
#run: terraform plan
#- name: Terraform apply
#run: terraform apply -auto-approve
deploy:
name: Deploy to VPS via Ansible
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Скачиваем код репозитория (чтобы получить файл deploy.yml)
uses: actions/checkout@v4
- name: Запускаем Ansible плейбук
uses: dawidd6/action-ansible-playbook@v2
with:
playbook: auto-deployment/A_deploy.yml
key: ${{ secrets.SERVER_SSH_KEY }}
options: |
-i ${{ secrets.SERVER_HOST }},
-u ${{ secrets.SERVER_USER }}
--extra-vars "TOKEN=${{ secrets.TG_TOKEN_QUOTE }} ADMIN_IDS=${{ secrets.ADMIN_ID_TG }}"
- name: Notifier
uses: ethyaan/tgate-action@v1.0.0
if: always()
with:
token: ${{ secrets.TG_TOKEN_ALERT }}
to: ${{ secrets.ADMIN_ID_TG }}
thread_id: ${{ secrets.threadid }}
disable_web_page_preview: false
disable_notification: false
+22
View File
@@ -0,0 +1,22 @@
.env
__pycache__/
*.pyc
*.pyo
# Terraform
authorized_key.json
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
*.tfvars
*.tfvars.json
# IDE
.idea/
.vscode/
*.swp
# Python venv
venv/
.venv/
+125
View File
@@ -0,0 +1,125 @@
default:
tags:
- universal
stages:
- tests
- build-and-push
- deploy
tests_job1:
stage: tests
image: python:3.11-slim
variables:
PYTHONPATH: "."
before_script:
- pip install --no-cache-dir -r requirements.txt
- pip install pytest
script:
- pytest tests/
push_job:
resource_group: production
stage: build-and-push
image:
name: gcr.io/kaniko-project/executor:v1.23.0-debug
entrypoint: [""]
before_script:
- mkdir -p /kaniko/.docker
- cp "$DOCKER_AUTH_CONFIG" /kaniko/.docker/config.json
script:
- >
/kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${DOCKERHUB_USER}/quote-bot:${CI_COMMIT_SHORT_SHA}"
--destination "${DOCKERHUB_USER}/quote-bot:latest"
deploy_to_server:
resource_group: production
stage: deploy
image: ghcr.io/ansible/creator-ee:v0.22.0
variables:
ANSIBLE_HOST_KEY_CHECKING: "False"
before_script:
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- echo "$ANSIBLE_PRIVATE_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ansible-galaxy collection install community.docker
script:
- cd auto-deployment/
- ansible-playbook -i inventory.ini A_deploy.yml
--extra-vars "image_tag=$CI_COMMIT_SHORT_SHA"
environment:
name: production
url: http://$BOT_SERVER_IP
on_stop: stop_production
stop_production:
stage: deploy
image: ghcr.io/ansible/creator-ee:v0.22.0
variables:
ANSIBLE_HOST_KEY_CHECKING: "False"
before_script:
- ansible-galaxy collection install community.docker
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- echo "$ANSIBLE_PRIVATE_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
script:
- cd auto-deployment/
- ansible-playbook -i inventory.ini A_deploy.yml
--extra-vars "container_state=absent"
when: manual
environment:
name: production
action: stop
variables:
MSG_SUCCESS: "✅ <b>Успех!</b> Пайплайн проекта <b>$CI_PROJECT_NAME</b> завершен.\nВетка: $CI_COMMIT_REF_NAME\nКоммит: $CI_COMMIT_MESSAGE"
MSG_FAILURE: "❌ <b>Ошибка!</b> Пайплайн проекта <b>$CI_PROJECT_NAME</b> упал.\nВетка: $CI_COMMIT_REF_NAME\n<a href='$CI_PIPELINE_URL'>Смотреть логи пайплайна</a>"
.notify_telegram:
stage: .post
tags:
- tg
image: curlimages/curl:latest
script:
- |
curl -s -X POST "https://api.telegram.org/bot$ALERT_TG_BOT/sendMessage" \
-d chat_id="$ADMIN_ID_TG" \
-d text="$MESSAGE" \
-d parse_mode="HTML" \
-d disable_web_page_preview="true"
notify_success:
extends: .notify_telegram
variables:
MESSAGE: $MSG_SUCCESS
when: on_success
notify_failure:
extends: .notify_telegram
variables:
MESSAGE: $MSG_FAILURE
when: on_failure
mirror_to_gitea:
image:
name: alpine/git:latest
entrypoint: [""]
stage: .post
variables:
GIT_DEPTH: 1
script:
- git config user.name "GitLab CI/CD"
- git config user.email "ci@leor156.online"
- git checkout --orphan showcase
- git add -A
- git commit -m "Showcase update - ${CI_COMMIT_SHORT_SHA}"
- git remote add gitea https://leo:$GITEA_TOKEN@gitea.leor156.online/leo/quote_bot_auto-deployment.git
- git push gitea showcase:main --force
when: on_success
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY data/ ./data/
CMD ["python", "-m", "src.bot"]
+1
View File
@@ -0,0 +1 @@
### Цитатный бот
+39
View File
@@ -0,0 +1,39 @@
# deploy.yml
- name: Автоматический деплой Telegram-бота
hosts: all
vars:
image_name: "leor156/quote-bot:{{ image_tag | default('latest') }}"
container_name: "quote-bot"
container_state: "started"
tasks:
- name: Убеждаемся, что установлена библиотека Docker для Python
apt:
name: python3-docker
state: present
update_cache: yes
- name: Скачиваем образ бота с Docker Hub
community.docker.docker_image:
name: "{{ image_name }}"
source: pull
- name: Запускаем контейнер
community.docker.docker_container:
name: "{{ container_name }}"
image: "{{ image_name }}"
state: "{{ container_state }}"
restart_policy: unless-stopped
pull: always
env:
TOKEN: "{{ lookup('env', 'BOT_TOKEN') }}"
ADMIN_IDS: "{{ lookup('env', 'ADMIN_ID_TG') }}"
- name: Чистка
community.docker.docker_prune:
images: yes
images_filters:
dangling: true
+2
View File
@@ -0,0 +1,2 @@
[server-bot]
quote-bot-prod ansible_host="{{ lookup('env', 'BOT_SERVER_IP') }}" ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519
+44
View File
@@ -0,0 +1,44 @@
resource "yandex_compute_filesystem" "shared_disk" {
name = "my-shared-30gb-disk"
type = "network-hdd"
size = 30
zone = "ru-central1-d"
}
data "yandex_compute_image" "ubuntu" {
family = "ubuntu-2404-lts"
}
resource "yandex_compute_instance" "servers" {
count = 2
name = "server-${count.index + 1}"
platform_id = "standard-v3"
zone = "ru-central1-d"
resources {
cores = 2
memory = 2
}
# Указываем загрузочный диск с Ubuntu
boot_disk {
initialize_params {
image_id = data.yandex_compute_image.ubuntu.id
size = 10 # Размер загрузочного диска
}
}
# Подключаем наш общий диск на 30 ГБ
filesystem {
filesystem_id = yandex_compute_filesystem.shared_disk.id
device_name = "shared-disk"
}
# Подключаем сервер к подсети (которая лежит в файле network.tf)
network_interface {
subnet_id = yandex_vpc_subnet.subnet.id
# Даем белый IP только первому серверу
nat = count.index == 0 ? true : false
}
}
+10
View File
@@ -0,0 +1,10 @@
resource "yandex_vpc_network" "network" {
name = "my-network"
}
resource "yandex_vpc_subnet" "subnet" {
name = "my-internal-subnet"
v4_cidr_blocks = ["10.0.0.0/24"]
zone = "ru-central1-d"
network_id = yandex_vpc_network.network.id
}
+8
View File
@@ -0,0 +1,8 @@
output "public_ip_server_1" {
value = yandex_compute_instance.servers[0].network_interface.0.nat_ip_address
description = "Белый IP первого сервера"
}
output "internal_ips" {
value = yandex_compute_instance.servers[*].network_interface.0.ip_address
description = "Внутренние IP адреса обоих серверов"
}
+14
View File
@@ -0,0 +1,14 @@
terraform {
required_providers {
yandex = {
source = "yandex-cloud/yandex"
}
}
required_version = ">= 0.13"
}
provider "yandex" {
service_account_key_file = "authorized_key.json"
cloud_id = "b1gl9hdpdr35i83uh5t5"
folder_id = "b1gbb5kej5tlskp8i5oq"
zone = "ru-central1-a"
}
+76
View File
@@ -0,0 +1,76 @@
variable "zone" {
type = string
default = "ru-central1-d"
}
variable "network" {
type = string
default = "ya-network"
}
variable "subnet" {
type = string
default = "ya-network"
}
variable "subnet_v4_cidr_blocks" {
type = list(string)
default = ["192.168.10.0/24"]
}
variable "nat" {
type = bool
default = true
}
variable "image_family" {
type = string
default = "ubuntu-2404-lts"
}
variable "name" {
type = string
}
variable "cores" {
type = number
default = 2
}
variable "memory" {
type = number
default = 4
}
variable "disk_size" {
type = number
default = 50
}
variable "disk_type" {
type = string
default = "network-nvme"
}
variable "user_name" {
default = ""
type = string
}
variable "user_pass" {
default = ""
type = string
}
variable "admin_pass" {
default = ""
type = string
}
variable "timeout_create" {
default = "10m"
}
variable "timeout_delete" {
default = "10m"
}
+5
View File
@@ -0,0 +1,5 @@
Учиться — значит открывать то, что ты уже знал. Делать — значит демонстрировать, что ты это знаешь. Обучать — значит напоминать другим, что они знают это так же хорошо, как и ты.
Жизнь — это то, что случается, пока ты строишь другие планы.
Единственный способ делать великую работу — любить то, что делаешь.
Код, написанный наспех, работает ровно столько, сколько тебе нужно для демонстрации, — и ни секунды больше.
Лучший способ предсказать будущее — изобрести его.
+1
View File
@@ -0,0 +1 @@
pyTelegramBotAPI==4.23.0
+91
View File
@@ -0,0 +1,91 @@
import logging
from telebot import TeleBot
from telebot.types import Message
from .config import TOKEN, ADMIN_IDS
from .quotes import get_random_quote, add_quote, count_quotes
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
bot = TeleBot(TOKEN)
# ─── Хэндлеры ────────────────────────────────────────────────────────────────
@bot.message_handler(commands=["start"])
def cmd_start(message: Message):
name = message.from_user.first_name
bot.reply_to(
message,
f"Привет, {name}! 👋\n\n"
"Я бот с цитатами. Вот что я умею:\n"
"/quote — случайная цитата\n"
"/stats — сколько цитат в базе\n"
"/add <текст> — добавить цитату (только для администратора)",
)
logging.info(
f"[/start] user_id={message.from_user.id} ({message.from_user.first_name})"
)
@bot.message_handler(commands=["quote"])
def cmd_quote(message: Message):
quote = get_random_quote()
if quote is None:
bot.reply_to(
message,
"😔 Цитат пока нет. Попросите администратора добавить хотя бы одну!",
)
else:
bot.reply_to(message, f"💬 {quote}")
logging.info(f"[/quote] user_id={message.from_user.id}")
@bot.message_handler(commands=["stats"])
def cmd_stats(message: Message):
total = count_quotes()
bot.reply_to(message, f"📚 В базе {total} цитат.")
logging.info(f"[/stats] user_id={message.from_user.id}, total={total}")
@bot.message_handler(commands=["add"])
def cmd_add(message: Message):
# Проверяем права
if message.from_user.id not in ADMIN_IDS:
bot.reply_to(message, "🚫 У вас нет прав для добавления цитат.")
logging.warning(
f"[/add] Unauthorized attempt by user_id={message.from_user.id}")
return
# Извлекаем текст после команды /add
parts = message.text.split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
bot.reply_to(
message, "✏️ Укажите текст цитаты. Пример:\n/add Жизнь прекрасна!")
return
quote_text = parts[1].strip()
added = add_quote(quote_text)
if added:
bot.reply_to(message, f"✅ Цитата добавлена:\n\n💬 {quote_text}")
logging.info(
f"[/add] Added new quote by user_id={message.from_user.id}")
else:
bot.reply_to(message, "⚠️ Такая цитата уже есть в базе.")
# ─── Точка входа ─────────────────────────────────────────────────────────────
def main():
logging.info("Бот запущен в режиме polling...")
bot.infinity_polling()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
import os
TOKEN = os.environ.get("TOKEN")
if not TOKEN:
raise ValueError("Переменная окружения TOKEN не задана!")
admin_ids_raw = os.environ.get("ADMIN_IDS", "")
ADMIN_IDS = {int(i) for i in admin_ids_raw.split(",") if i.strip()}
+41
View File
@@ -0,0 +1,41 @@
import random
from pathlib import Path
DATA_FILE = Path(__file__).parent.parent / "data" / "quotes.txt"
def _read_all() -> list[str]:
"""Читает все цитаты из файла. Возвращает пустой список, если файл пуст или не существует."""
if not DATA_FILE.exists():
return []
lines = DATA_FILE.read_text(encoding="utf-8").splitlines()
return [line for line in lines if line.strip()]
def get_random_quote() -> str | None:
"""Возвращает случайную цитату или None, если их нет."""
quotes = _read_all()
if not quotes:
return None
return random.choice(quotes) # nosec
def add_quote(text: str) -> bool:
"""
Добавляет новую цитату в файл.
Возвращает False, если такая цитата уже существует, иначе True.
"""
quotes = _read_all()
if text in quotes:
return False
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with DATA_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
return True
def count_quotes() -> int:
"""Возвращает количество цитат в базе."""
return len(_read_all())
# Секретная строка.
+67
View File
@@ -0,0 +1,67 @@
import unittest
from pathlib import Path
from unittest.mock import patch
import tempfile
import os
# Подменяем DATA_FILE на временный файл перед импортом модуля
class TestQuotes(unittest.TestCase):
def setUp(self):
"""Создаем временный файл для каждого теста."""
self.tmp = tempfile.NamedTemporaryFile(mode="w",
suffix=".txt",
delete=False,
encoding="utf-8")
self.tmp.close()
# Патчим путь к файлу в модуле quotes
self.patcher = patch("src.quotes.DATA_FILE", Path(self.tmp.name))
self.patcher.start()
def tearDown(self):
"""Удаляем временный файл после каждого теста."""
self.patcher.stop()
os.unlink(self.tmp.name)
def test_empty_file_returns_none(self):
from src.quotes import get_random_quote
self.assertIsNone(get_random_quote())
def test_empty_file_count_is_zero(self):
from src.quotes import count_quotes
self.assertEqual(count_quotes(), 0)
def test_add_and_count(self):
from src.quotes import add_quote, count_quotes
add_quote("Первая цитата")
add_quote("Вторая цитата")
self.assertEqual(count_quotes(), 2)
def test_add_returns_true_for_new_quote(self):
from src.quotes import add_quote
result = add_quote("Новая цитата")
self.assertTrue(result)
def test_add_returns_false_for_duplicate(self):
from src.quotes import add_quote
add_quote("Дубликат")
result = add_quote("Дубликат")
self.assertFalse(result)
def test_get_random_returns_string(self):
from src.quotes import add_quote, get_random_quote
add_quote("Тестовая цитата")
quote = get_random_quote()
self.assertIsInstance(quote, str)
self.assertEqual(quote, "Тестовая цитата")
if __name__ == "__main__":
unittest.main()