Мета заняття: оновлювати сервіс, не перевіряючи щоразу все руками. Тести показують, що саме зламалося, а один скрипт ставить нову версію за кілька секунд.
Замість <логін> підставляєш свій
логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться.
Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні.
У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій
логін і виправляй у кожній команді, яку копіюєш.
Усі ці речі ти вже робив на заняттях 02–08, але окремо ці слова не пояснювали. Далі на сторінці вони трапляються постійно, тому спочатку — що кожне з них означає. Сюди можна повертатися по ходу заняття.
/api/.GET /api/habits віддає
список, POST /api/habits додає запис, DELETE /api/habits/3
видаляє запис із номером 3. Це три різні ендпоінти, хоча адреса в перших двох однакова.@ і стоїть просто над функцією:
@app.get("/api/habits"). Він каже FastAPI, на яке звернення відповідає ця
функція. Список ендпоінтів твого застосунку задають саме декоратори в
main.py.{"id": 1, "title": "Читати"} — це об’єкт із
двома полями, у Python він стає словником; […] — перелік записів, у Python
список, у розмові про JSON його часто називають масивом. Твоє API приймає й віддає саме
JSON.200 — усе гаразд;
201 — запис створено; 401 — ти не увійшов; 403 —
увійшов, але не маєш права на цю дію; 404 — за такою адресою нічого немає;
422 — дані в запиті не пройшли перевірку; 500 — програма впала,
не встигнувши відповісти; 502 — nginx не отримав відповіді від служби./u/bohdan.g9/ — твоїй службі, /u/olya.g9/ —
службі Олі. Твій застосунок про неї нічого не знає.club-api. systemd —
та частина Linux, яка службами керує; команди до неї мають вигляд
systemctl --user restart club-api, де --user означає «моя власна
служба, не загальносистемна».~/.config/systemd/user/club-api.service. Там же записаний твій
порт.git commit створює
коміт у твоїй теці, git push надсилає коміти в репозиторій
~/repo.git, git pull забирає їх звідти назад./opt/club/venv, — тому команди пишуться повним шляхом
/opt/club/venv/bin/python, а не просто python.Ти напишеш п’ять тестів — коротких функцій, які самі звертаються до твого API і звіряють відповідь із очікуваною: список повертається, запис додається, порожня назва відхиляється, сторонній нічого не видаляє, звернення до неіснуючого запису дає 404. Замість того щоб після кожної правки відкривати сторінку й клікати по формі, ти запускатимеш одну команду і за секунду бачитимеш, що зламалося.
Друга частина — файл deploy.sh. У ньому будуть записані всі кроки оновлення
сервісу: забрати коміти, запустити тести, перезапустити службу, перевірити відповідь. Після
цього перехід на нову версію — це одна команда ./deploy.sh. Якщо хоча б один
тест не проходить, скрипт зупиняється й нову версію не ставить.
BUGS.md і перевіряє,
чи справді ці баги закрито. Якщо так — ставить підпис у файлі. Це критерій L08.4, і
зараховує його людина, а не чекер. Хто не встиг закрити баги вдома — доробляє зараз,
до початку теорії.
Твій сервіс уже вміє чимало: показує список, додає запис, відхиляє порожню назву, не дає сторонньому нічого видалити, повертає 404 на неіснуючий запис. Кожну з цих речей ти колись перевірив руками: відкрив сторінку, заповнив форму, натиснув кнопку, подивився результат.
Складність у тому, що перевіряти доводиться не один раз, а після кожної правки. Причому
перевіряти треба не лише те, що ти щойно змінив. Ось як це виглядає насправді. Ти правиш один
рядок у функції, яка готує список, і не помічаєш, що тепер вона повертає записи без поля
id. Кнопка «видалити» бере id саме звідти — і видалення перестає
працювати, хоча ти його не чіпав. У браузері ти дивишся тільки на список, бо список і правив.
Помилку знаходить хтось інший через тиждень.
Повна ручна перевірка всіх п’яти дій займає хвилини три. Правок за заняття буває п’ятнадцять. Ніхто не робить п’ятнадцять разів по три хвилини — тому перевіряють лише змінене, і решта поступово ламається непомітно.
assert 2 + 2 == 4
не робить нічого й виконання йде далі. Рядок assert 2 + 2 == 5 зупиняє функцію
з помилкою AssertionError. Тести складаються переважно з таких рядків.test_*.py, запускає в них усі функції, чиї імена починаються з
test_, і друкує підсумок: скільки функцій пройшло, скільки впало і на якому
саме рядку. Запускати тести по одному руками не треба.Порівняння з тим, що ти вже робив: коли вчитель перевіряє контрольну за ключем із
відповідями, він не розв’язує задачу заново — звіряє твій результат із очікуваним. Рядок
assert робить те саме, тільки за мілісекунди й без участі людини.
Оля виправила помилку в ендпоінті видалення, перевірила видалення в браузері — працює, зробила коміт. Наступного дня виявилося, що зламалося додавання запису: воно перестало працювати саме після тієї правки. Що з переліченого помітило б це ще до коміта?
Тести лежать в окремій теці tests усередині ~/app. Це звичайні
файли Python, тільки з двома правилами іменування: файл має називатися
test_щось.py, а функція всередині — починатися з test_. Функцію з
іншим іменем pytest просто не запустить і навіть не поскаржиться.
Щоб перевірити ендпоінт, тесту треба надіслати HTTP-запит. Можна було б робити це через
мережу, командою на кшталт curl, але тоді тест залежав би від того, чи запущена
служба, чи вільний порт, чи працює nginx. FastAPI дає простіший інструмент.
TestClient)club-api зупинена.owner віддає тестовий клієнт, який уже має куку власника. Тесту не треба
вводити логін і пароль — він одразу пише owner.post(...).Щоб отримати фікстуру, тест згадує її ім’я серед своїх параметрів:
def test_list_returns_200(guest):
response = guest.get("/api/habits")
assert response.status_code == 200Тут guest — не звичайний аргумент, який ти передаєш сам. Ти ніде не викликаєш
test_list_returns_200(...): функцію викликає pytest. Він бачить у параметрах ім’я
guest, знаходить фікстуру з таким іменем у conftest.py, виконує її і
підставляє результат.
Сенс фікстур простий: підготовка перед тестами в усіх п’яти функціях однакова. Замість
чотирьох однакових рядків у кожній із п’яти функцій ти пишеш їх один раз у
conftest.py.
/u/bohdan.g9/
Тестовий клієнт звертається до застосунку напряму, а частину /u/bohdan.g9/
додає nginx уже дорогою через мережу. Усередині твого застосунку такого шляху не існує, там
ендпоінт називається просто /api/habits. Якщо написати в тесті повну адресу з
браузера, отримаєш 404, хоча в браузері та сама адреса відкривається.
Максим написав тест, у якому тестовий клієнт звертається до
/u/maksym.g9/api/habits, і отримав 404. У браузері та сама адреса відкривається
і показує список. У чому причина?
Тест, який додає запис, справді його додає. Якщо тести працюють з тією самою базою, що й сервіс, то після десяти запусків у твоєму списку буде десять записів «Тестовий запис», і на демо-дні їх побачать усі гості.
Є й друга причина, менш очевидна. Тест «запис додається» рахує записи до і після. Якщо база щоразу інша, результат тесту залежить від того, скільки разів ти вже його запускав. Тест, який сьогодні проходить, а завтра ні, без жодної зміни коду, нічого не показує: за його результатом не можна судити про код, тому такий тест швидко перестають запускати.
Тому перед запуском тестів файл бази копіюють у тимчасову теку, і тести працюють з копією. Справжня база при цьому не змінюється, а кожен запуск починається з однакового стану.
Технічно це один рядок. db.py бере шлях до файлу бази зі змінної
DB_PATH. Файл conftest.py робить копію й записує в
DB_PATH шлях до неї — до того, як буде виконано перший запит. Далі всі запити
йдуть у копію, бо db.py дивиться на цю змінну щоразу, коли відкриває з’єднання.
Писати цей код тобі не треба, він уже є в заготовці; треба розуміти, що він там робить.
Данило запустив тести п’ять разів поспіль. Першого разу тест «запис додається» пройшов, а далі почав падати: у списку виявлялося більше записів, ніж він очікував. Як це правильно виправити?
~/repo.git, запустити тести,
перезапустити службу, переконатися, що сервіс відповідає.git pull~/repo.git, тека на цьому самому сервері, яку
ти налаштував на занятті 03. Не GitHub.fastapi,
uvicorn, pyyaml, bcrypt. У нас вони стоять у
спільному оточенні /opt/club/venv, однаковому для всієї групи, тому кроку
встановлення в нашому deploy.sh не буде. У проєктах поза курсом такий крок
зазвичай є: там оточення в кожного своє.deploy.sh.sh означає, що виконує їх
bash, та сама програма, що приймає твої команди в SSH.Зараз ти оновлюєш сервіс руками: чотири-п’ять команд, які треба пам’ятати в правильному порядку. За тиждень порядок забувається, а перед демо-днем його згадують поспіхом і пропускають крок. Тому кроки записують у файл один раз.
Перший змістовний рядок будь-якого скрипта, який щось міняє на сервері, виглядає так:
set -euo pipefailЦе три окремі налаштування, записані разом:
-e — зупинити скрипт на першій команді, що завершилася з помилкою. Без
цього скрипт спокійно перезапустив би службу навіть після падіння тестів: наступний рядок
виконується незалежно від того, чим закінчився попередній.-u — вважати помилкою звертання до змінної, якій не присвоїли значення.
Ловить одруківки в іменах: без нього $VNEV замість $VENV дасть
порожній рядок, і команда виконається зовсім не так, як задумано.-o pipefail — якщо в ланцюжку перша | друга впала перша
команда, увесь ланцюжок вважається невдалим. Без цього bash дивиться лише на останню
команду ланцюжка.0 означає «виконано без помилок»,
будь-яке інше число — помилку. Подивитися код останньої команди можна так:
echo $?. Саме за цим числом bash розуміє, впала команда чи ні. Коли хоч один
тест падає, pytest повертає не 0 — і скрипт із
set -e зупиняється саме на цьому рядку.Порядок рядків у скрипті має значення. Перевірка, яка стоїть після дії, від цієї дії вже не убереже.
У скрипті Софії рядки йдуть у такому порядку: git pull, потім
перезапуск служби, потім тести. Тести впали. Що на цей момент уже сталося на сервері?
Один із критеріїв заняття звучить так: deploy.sh двічі поспіль дає той самий
результат. Це означає: запустив скрипт — він відпрацював до кінця; запустив ще раз одразу — він
знову відпрацював до кінця без помилок, і стан сервера після другого запуску такий самий, як
після першого.
Властивість «повторний запуск нічого не псує й лишає той самий стан» називають ідемпотентністю. Слово довге, ідея проста: команду можна виконати двічі, і другий раз нічого не змінить.
Різниця видна на двох прикладах. git pull, коли нових комітів немає, друкує
Already up to date. і виходить з кодом 0 — стан не змінився. А рядок
echo "готово" >> version.txt при кожному запуску дописує рядок у файл: після
трьох запусків у файлі три рядки, і результат щоразу інший.
Ти стикаєшся з цією різницею щодня. Натиснути «зберегти» двічі — файл лишиться той самий. Натиснути «надіслати» двічі — у чаті буде два однакові повідомлення. Перша дія ідемпотентна, друга ні.
Наш скрипт ідемпотентний: git pull без нових комітів нічого не змінює, тести
щоразу працюють з новою копією бази, перезапуск служби піднімає той самий код, перевірка тільки
читає відповідь. Тому його можна запускати скільки завгодно разів поспіль.
Богдан поправив main.py і не закомітив правку. У
~/repo.git тим часом уже лежав новий коміт, який змінює той самий файл. Богдан
запустив ./deploy.sh — скрипт зупинився на першому кроці з повідомленням
error: Your local changes to the following files would be overwritten by merge.
Що це означає?
У прикладах — таблиця habit і ендпоінти /api/habits із занять 04
і 05. Підставляй свої назви: вони записані у project.yaml у розділах
database і endpoints.
ssh bohdan.g9@91.219.61.4
cd ~/app
git status --short
/opt/club/venv/bin/python -m pytest --version
/opt/club/venv/bin/python -c "import httpx; print('httpx на місці')"
grep -n "api/ping" ~/app/main.py
grep -n -- "--port" ~/.config/systemd/user/club-api.servicegit status --short має відповісти порожнечею. Якщо там є рядки, у тебе
залишилися незакомічені зміни з минулого заняття — закомить їх зараз, інакше вони заважатимуть
на кроках 6 і 11.
Команда python -m pytest --version друкує версію, наприклад
pytest 8.2.0. Пиши її повним шляхом, як у прикладі: короткої команди
pytest у тебе в системі немає, вона є тільки всередині оточення
/opt/club/venv. httpx — бібліотека, якою тестовий клієнт надсилає
запити всередині процесу; без неї TestClient не працює. Обидві вже стоять у
спільному оточенні. Якщо якась із команд відповіла помилкою — скажи викладачеві й не став
нічого через pip: оточення спільне для всієї групи, і прав на нього в тебе
немає.
Перший grep має показати рядок @app.get("/api/ping").
/api/ping — найпростіший ендпоінт твого сервісу: він з’явився в
main.py ще на занятті 02 і повертає {"status": "ok", …}. На кроці 8
скрипт оновлення звертатиметься саме до нього, щоб переконатися, що служба піднялася. Якщо
grep нічого не знайшов, ендпоінта немає — поверни його в main.py
за заняттям 02, інакше критерій L09.3 не зарахується.
Другий grep покаже рядок ExecStart= з твоїм портом. Подивись, як
саме там записано порт: --port 8009 через пропуск чи --port=8009
через знак рівності. Скрипт із кроку 8 розуміє обидва записи, але знати свій варіант
корисно. Якщо рядка з --port у файлі немає взагалі — скажи викладачеві.
.gitignoremkdir -p ~/app/tests
printf '__pycache__/\n*.pyc\n.pytest_cache/\n' > ~/app/.gitignore
cat ~/app/.gitignore
ls -a ~/app-p означає «не скаржитися, якщо тека вже існує». Завдяки цьому команду можна
виконати двічі поспіль без помилки — це та сама ідемпотентність, про яку йшлося в теорії.
Під час запуску Python складає поруч зі своїми файлами теку __pycache__ з
проміжними файлами .pyc, а pytest — теку .pytest_cache. Це
тимчасові файли, у репозиторії вони не потрібні. Файл .gitignore перелічує те,
чого git не має помічати: після нього git status --short не показуватиме зайвих
рядків, а git add tests на кроці 7 не додасть у коміт файли
.pyc. Сам .gitignore ти закомітиш на кроці 7 — доти
git status --short показуватиме рядок ?? .gitignore, і це
нормально.
tests/conftest.pyЦе готова заготовка. Створи файл і встав текст цілком, нічого в ньому не змінюючи.
nano ~/app/tests/conftest.py"""Підготовка для тестів. Готова заготовка — цей файл не редагуємо.
Робить три речі:
1) додає ~/app у список тек, де Python шукає модулі, щоб працював import main;
2) підміняє базу на копію, щоб тести не змінювали справжніх даних;
3) дає дві фікстури: guest — клієнт без входу, owner — клієнт із кукою власника.
"""
import shutil
import sys
import tempfile
from pathlib import Path
import pytest
import yaml
from fastapi.testclient import TestClient
APP_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(APP_DIR))
import db # імпорт стоїть нижче за sys.path.insert навмисно
# --- копія бази -------------------------------------------------------
_tmp_db = Path(tempfile.mkdtemp(prefix="club-tests-")) / "test.db"
shutil.copy(db.DB_PATH, _tmp_db)
db.DB_PATH = _tmp_db # з цієї миті всі запити йдуть у копію
import auth
from main import app
CFG = yaml.safe_load((APP_DIR / "project.yaml").read_text(encoding="utf-8"))
@pytest.fixture
def guest():
"""Клієнт без входу — так твій сервіс бачить стороння людина."""
with TestClient(app) as client:
yield client
@pytest.fixture
def owner():
"""Клієнт із кукою власника — пароль у тесті вводити не треба."""
table = CFG["auth"]["table"]
rows = db.query(f"SELECT login FROM {table} LIMIT 1")
assert rows, "у таблиці власника немає жодного рядка, дивись заняття 07"
with TestClient(app) as client:
client.cookies.set(auth.COOKIE_NAME, auth.make_session(rows[0]["login"]))
yield clientРозберемо, що тут відбувається.
sys.path.insert(0, str(APP_DIR)) додає теку ~/app у список тек,
де Python шукає модулі. Без цього рядка import main усередині тестів не
спрацював би: тести лежать у tests/, а main.py — на теку вище.
Три рядки з _tmp_db — те, про що йшлося в теорії.
tempfile.mkdtemp() створює тимчасову теку з випадковим іменем,
shutil.copy кладе туди копію файлу бази, а присвоєння
db.DB_PATH = _tmp_db перемикає всі подальші запити на цю копію. Справжнього
файлу бази тести не відкривають узагалі. Лежить він у теці ~/data/, а ім’я
db.py бере з project.yaml, розділ database, поле
file: у заготовці там app.db, тобто повний шлях —
~/data/app.db.
yield client усередині фікстури означає: віддати клієнт тестові, дочекатися,
поки тест закінчиться, і лише потім закрити його. Рядки після yield виконуються
після тесту — тут це робить сам with.
У фікстурі owner назва таблиці підставляється в текст запиту через f-рядок —
рядок із літерою f перед лапками, у якому вираз у фігурних дужках замінюється
своїм значенням: f"SELECT login FROM {table}" при
table = "owner" перетворюється на SELECT login FROM owner. Назву
таблиці не можна передати параметром, як передають значення: параметром передаються дані, а
назва таблиці — частина самого тексту запиту. Небезпеки тут немає, бо
назва береться з твого ж project.yaml, а не з поля вводу на сторінці —
порівняй із тим, що розбирали на занятті 08.
auth.make_session і auth.COOKIE_NAME — з файлу
auth.py, який ти отримав на занятті 07. Фікстура бере логін власника з бази й
сама складає куку, тому запит на /api/login у тестах не потрібен.
nano ~/app/tests/test_api.py"""Тести API. Запуск із ~/app:
/opt/club/venv/bin/python -m pytest -q tests
"""
LIST = "/api/habits" # підстав свою адресу з project.yaml, розділ endpoints
def test_list_returns_200(guest):
"""Список відкривається без входу і повертає список записів."""
response = guest.get(LIST)
assert response.status_code == 200
assert isinstance(response.json(), list)Адреса винесена в змінну LIST угорі файлу: вона потрібна в чотирьох тестах із
п’яти, і виправляти її потім доведеться в одному місці, а не в чотирьох.
response.json() розбирає JSON з відповіді й повертає звичайні об’єкти Python.
isinstance(response.json(), list) перевіряє, що це саме список записів, а не,
наприклад, словник з описом помилки.
Запусти:
cd ~/app
/opt/club/venv/bin/python -m pytest -q testsОчікуваний вивід:
. [100%]
1 passed in 0.42sКрапка — один пройдений тест. -q (від quiet) друкує коротко: без цього ключа
pytest виводить рядок на кожен тест і шапку з версіями. Запускай саме
python -m pytest, а не просто pytest: перший варіант додає поточну
теку в шлях пошуку модулів, і імпорти працюють передбачувано.
Дописуй у той самий файл, під першим тестом.
def test_owner_creates_item(owner):
"""Власник додає запис — він з'являється у списку."""
before = len(owner.get(LIST).json())
response = owner.post(LIST, json={"title": "Тестовий запис"})
assert response.status_code in (200, 201)
after = owner.get(LIST).json()
assert len(after) == before + 1
assert after[0]["title"] == "Тестовий запис"
def test_empty_title_rejected(owner):
"""Порожня назва — це помилка в даних, тобто 422, а не 500."""
response = owner.post(LIST, json={"title": ""})
assert response.status_code == 422
def test_guest_cannot_delete(guest, owner):
"""Сторонній не видаляє чужий запис."""
owner.post(LIST, json={"title": "Не видаляти"})
item_id = owner.get(LIST).json()[0]["id"]
response = guest.delete(f"{LIST}/{item_id}")
assert response.status_code == 401
def test_delete_missing_gives_404(owner):
"""Видалення неіснуючого запису — 404."""
response = owner.delete(f"{LIST}/999999")
assert response.status_code == 404Кілька деталей у цьому коді.
after[0] — перший запис у списку. Це працює, якщо в тебе стоїть
ORDER BY id DESC, тобто найновіший запис іде першим. Якщо сортування зростаюче,
бери after[-1] — останній елемент списку.
status_code in (200, 201) — бо додавання може повертати і 200, і 201: обидва
означають успіх. Якщо в тебе в декораторі стоїть status_code=201, залиш у тесті
тільки 201, так перевірка буде суворіша.
== 401 у test_guest_cannot_delete — це «ти не увійшов». Частина
сервісів у такій ситуації віддає 403, тобто «увійшов, але не маєш права». Подивись у
main.py, яке число повертає твій DELETE сторонньому, і постав у
тесті саме його.
У test_guest_cannot_delete запис створює owner, а видаляє його
намагається guest. Обидві фікстури працюють з однією копією бази, тому запис,
доданий одним клієнтом, видно другому.
Число 999999 в останньому тесті — свідомо завелике: такого
id у твоїй базі немає й найближчим часом не буде.
Запусти всі п’ять:
/opt/club/venv/bin/python -m pytest -q tests..... [100%]
5 passed in 0.71sТест, який жодного разу не падав, нічого не гарантує: він може взагалі нічого не перевіряти. Зламай код навмисно.
nano ~/app/main.pyУ функції, що повертає список, додай у кінець запиту LIMIT 1. У прикладі
таблиця називається habit; у тебе назва своя, тому цей рядок не копіюй, а допиши
LIMIT 1 у свій власний запит:
return query("SELECT * FROM habit ORDER BY id DESC LIMIT 1")Тепер список завжди повертає один запис. Запусти тести:
/opt/club/venv/bin/python -m pytest -q tests.F... [100%]
=================================== FAILURES ===================================
___________________________ test_owner_creates_item ____________________________
def test_owner_creates_item(owner):
before = len(owner.get(LIST).json())
response = owner.post(LIST, json={"title": "Тестовий запис"})
assert response.status_code in (200, 201)
after = owner.get(LIST).json()
> assert len(after) == before + 1
E assert 1 == 2
tests/test_api.py:21: AssertionError
1 failed, 4 passed in 0.68sЧитається так: F на місці другого тесту — він упав. Далі pytest друкує саму
функцію, позначає стрілкою рядок, на якому спрацював assert, і в рядку з
E показує, що саме не збіглося: очікувалося 2 записи, повернувся 1.
Передостанній рядок — файл і номер рядка, на якому спрацював assert; останній —
підсумок: скільки тестів упало, скільки пройшло.
Ти дізнався про поломку за секунду, не відкриваючи браузер, і одразу знаєш, у якій перевірці справа.
Поверни код на місце:
git checkout -- main.py
/opt/club/venv/bin/python -m pytest -q testsgit checkout -- main.py повертає файл до стану останнього коміта. Усі
незбережені зміни в ньому зникають — саме цього ми зараз і хочемо. Тести знову мають дати
5 passed.
cd ~/app
git add .gitignore tests
git status --short
git commit -m "тести: п’ять перевірок API"
git push origin mastergit status --short перед комітом показує, що саме в нього потрапить: тільки
.gitignore і файли з tests/. Якщо там видно
tests/__pycache__/ — .gitignore з кроку 2 не створився або лежить
не в ~/app.
Тести — такий самий код проєкту, як main.py, і мають лежати в репозиторії.
git push надсилає коміти у ~/repo.git. Це потрібно й для критерію
заняття 03 «репозиторій містить ті самі коміти», і для наступного кроку: скрипт починається
з git pull, і йому має бути звідки забирати.
deploy.shФайл deploy.sh у тебе вже є з першого дня, але в заготовці він лише
намічений: там є звертання до змінної VENV, якій ніхто не присвоїв значення, і
крок встановлення залежностей, непотрібний у нашому спільному оточенні. Перепиши файл
цілком.
nano ~/app/deploy.sh#!/usr/bin/env bash
# Викочування нової версії. Заняття 09.
# Запуск: ./deploy.sh
set -euo pipefail
VENV=/opt/club/venv
UNIT="$HOME/.config/systemd/user/club-api.service"
PORT=$(grep -oP '(?<=--port[ =])\d+' "$UNIT" || true)
if [ -z "$PORT" ]; then
echo "ПОМИЛКА: у $UNIT немає рядка з --port"
exit 1
fi
cd "$(dirname "$0")"
echo "==> 1/4 забираємо коміти з ~/repo.git"
git pull --ff-only origin master
echo "==> 2/4 тести"
"$VENV/bin/python" -m pytest -q tests
echo "==> 3/4 перезапуск служби"
systemctl --user restart club-api || {
echo "ПОМИЛКА: служба не перезапустилася"
echo "Дивись: journalctl --user -u club-api -n 30"
exit 1
}
sleep 2
echo "==> 4/4 перевірка, що сервіс відповідає"
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/api/ping" || true)
if [ "$code" = "200" ]; then
echo "ГОТОВО: /api/ping відповів 200"
else
echo "ПОМИЛКА: /api/ping відповів $code"
echo "Дивись: journalctl --user -u club-api -n 30"
exit 1
fiЩо робить кожен рядок:
#!/usr/bin/env bash — перший рядок файлу каже системі, якою програмою його
виконувати. Без нього довелося б щоразу писати bash deploy.sh.set -euo pipefail — три налаштування з теорії: зупинятися на першій
помилці, не терпіти незаданих змінних, помічати падіння всередині ланцюжка команд.PORT=$(...) — запис $( ) виконує команду й підставляє її
вивід. grep -oP '(?<=--port[ =])\d+' дістає з unit-файлу число, що стоїть
після --port; запис [ =] означає «далі або пропуск, або знак
рівності», бо в unit-файлах трапляються обидва варіанти. Порт у кожного учня свій, тому
вписувати його числом у скрипт не можна: скрипт має працювати й після того, як порт
зміниться.|| true після grep і перевірка if [ -z "$PORT" ]
(-z означає «рядок порожній») потрібні через set -e: якщо
grep нічого не знайде, він поверне не нуль і скрипт зупиниться ще до першого
echo — ти побачив би порожній екран без жодного пояснення. Тепер натомість
буде рядок про те, що в unit-файлі немає --port.cd "$(dirname "$0")" — переходить у теку, де лежить сам скрипт.
$0 — це шлях до самого файлу, dirname відрізає від нього ім’я
файлу. Завдяки цьому рядку скрипт можна запускати з будь-якої теки.git pull --ff-only origin master — забирає коміти.
--ff-only означає «тільки якщо їх можна просто дописати в кінець моєї
історії». Якщо історії розійшлися, скрипт зупиниться з помилкою, і ти розберешся з цим
окремо, а не посеред викочування."$VENV/bin/python" -m pytest -q tests — тести. Якщо хоч один упав,
pytest поверне код виходу, відмінний від нуля, і через set -e
скрипт зупиниться саме тут. Наступні рядки не виконаються, служба не перезапуститься.systemctl --user restart club-api || { … exit 1; } — якщо служба не
піднялася, systemctl поверне не нуль і виконається те, що у фігурних дужках:
два echo з підказкою і вихід з ознакою помилки. Без цієї гілки скрипт через
set -e обірвався б тут-таки, не сказавши, куди дивитися.sleep 2 — пауза на дві секунди. Службі треба трохи часу, щоб піднятися й
почати приймати запити; без паузи перевірка прийшла б раніше, ніж сервіс готовий, і скрипт
сказав би про помилку там, де її немає.curl -s -o /dev/null -w "%{http_code}" — -s прибирає
індикатор завантаження, -o /dev/null викидає тіло відповіді,
-w "%{http_code}" друкує тільки код стану. У змінній code
залишиться три цифри, наприклад 200. || true у кінці рядка
потрібне на випадок, коли служба зовсім не відповідає: тоді curl завершується
з помилкою і скрипт із set -e обірвався б, не дійшовши до підказки про
journalctl. З || true у code потрапить
000 — так curl позначає, що з’єднання взагалі не встановилося.exit 1 — завершити скрипт із ознакою помилки. Той, хто запустив скрипт,
побачить це в echo $?, а чекер — у коді виходу.chmod +x ~/app/deploy.sh
cd ~/app
./deploy.shchmod +x ставить файлу ознаку «його можна запускати». Без неї bash відповість
Permission denied, навіть якщо всередині все правильно.
./ перед іменем означає «файл у поточній теці». Без крапки зі скісною рискою
bash шукатиме команду deploy.sh серед системних програм і не знайде.
Очікуваний вивід:
==> 1/4 забираємо коміти з ~/repo.git
From /home/bohdan.g9/repo
* branch master -> FETCH_HEAD
Already up to date.
==> 2/4 тести
..... [100%]
5 passed in 0.71s
==> 3/4 перезапуск служби
==> 4/4 перевірка, що сервіс відповідає
ГОТОВО: /api/ping відповів 200./deploy.sh
echo $?Вивід має бути такий самий, а echo $? має надрукувати 0. Це і є
критерій L09.2: скрипт двічі поспіль дає той самий результат.
Служба перезапускається обидва рази, і це нормально: вона піднімається з тим самим кодом, тому стан сервера після другого запуску не відрізняється від стану після першого. Зміни накопичував би скрипт, який щоразу щось дописує чи створює наново, — у нашому такого немає.
Це головна перевірка сьогоднішнього заняття. Зламай код ще раз, тим самим способом, і не комітуй зміну.
nano ~/app/main.pyЗнову додай LIMIT 1 у запит списку й запусти скрипт:
./deploy.sh
echo $?==> 1/4 забираємо коміти з ~/repo.git
From /home/bohdan.g9/repo
* branch master -> FETCH_HEAD
Already up to date.
==> 2/4 тести
.F... [100%]
...
1 failed, 4 passed in 0.66s
1Скрипт зупинився на другому кроці. Рядків «перезапуск служби» і «перевірка» немає взагалі —
до них справа не дійшла. echo $? надрукував 1, тобто помилку.
git pull тут не зачепив твою зміну, бо нових комітів у ~/repo.git
немає: він написав Already up to date. і нічого не переписував.
Тепер переконайся, що сервіс усе ще працює зі старим, справним кодом:
curl -s http://127.0.0.1/u/bohdan.g9/api/ping
curl -s http://127.0.0.1/u/bohdan.g9/api/habitsСписок має повертати всі записи, а не один. Зламаний код лежить у файлі на диску, але служба досі виконує ту версію, з якою її запустили, — і саме тому користувачі нічого не помітили.
Поверни код і викоти нормально:
git checkout -- main.py
./deploy.shcd ~/app
git add deploy.sh tests
git status --short
git commit -m "деплой: тести перед перезапуском, перевірка ping після"
git push origin master
git -C ~/repo.git log --oneline -1git status --short перед комітом показує, що саме потрапить у коміт. Якщо там
видно main.py — ти не повернув зміну з кроку 11, вернись і виконай
git checkout -- main.py. Тек __pycache__ і
.pytest_cache у списку бути не повинно: їх не показує git через
.gitignore з кроку 2.
Остання команда друкує верхній коміт у ~/repo.git. Він має збігатися з тим,
що показує git log --oneline -1 у ~/app.
Три критерії заняття 09, усі три перевіряє чекер. Він запускається за розкладом, тому
відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав команду. Команди чекер
виконує в тебе в ~/app ті самі, що й ти на практиці, тому все, що він побачить, ти
можеш побачити сам заздалегідь.
| Код | Критерій | Що це означає | Не зараховано — що робити |
|---|---|---|---|
| L09.1 | pytest без помилок, щонайменше 5 тестів | Чекер заходить у ~/app і виконує
/opt/club/venv/bin/python -m pytest -q tests. Дивиться дві речі: код виходу
має бути 0, тобто жоден тест не впав, і зібраних тестів має бути щонайменше п’ять.
Тести, які pytest не зміг навіть зібрати через помилку в імпортах, не рахуються. |
Виконай ту саму команду сам і читай перший рядок помилки, а не
останній. ModuleNotFoundError: No module named 'main' — запуск не з
~/app або conftest.py лежить не в tests/.
fixture 'owner' not found — у conftest.py немає фікстури з таким
іменем або сам файл названо інакше. Рядок collected 4 items означає, що
тестів менше п’яти: перевір, що імена всіх функцій починаються з test_ —
функцію з іншим іменем pytest не бачить і мовчки пропускає. |
| L09.2 | deploy.sh двічі поспіль дає той самий результат | Чекер запускає ./deploy.sh двічі одне за одним і дивиться, що обидва
рази код виходу 0 і вивід однаковий за змістом. Другий запуск нічого не має ламати:
git pull без нових комітів завершується нормально, тести проходять знову,
служба піднімається. |
Перевір сам: ./deploy.sh; echo $? — і одразу ще раз. Обидва рази має бути
0. Якщо скрипт зупинився, дивись, на якому кроці й з яким повідомленням.
Your local changes would be overwritten на кроці 1 — у ~/app є
незакомічені зміни, а в репозиторії лежить коміт, який зачіпає ті самі файли: закомить
зміни або відклади їх командою git stash. Permission denied —
chmod +x deploy.sh. unbound variable — у скрипті використана
змінна, якій не присвоїли значення; звір свій файл із кроком 8 практики. |
| L09.3 | після деплою сервіс відповідає 200 | Одразу після запусків скрипта чекер звертається до /api/ping тією самою
публічною адресою, що й браузер — http://91.219.61.4/u/bohdan.g9/api/ping, —
і чекає код 200. Заразом він дивиться, чи є така перевірка в самому скрипті: деплой, який
не перевіряє результат, лишає сервіс непрацездатним, а дізнаєшся ти про це від
користувачів. |
Перевір із самого сервера — там ту саму адресу зручно писати через
127.0.0.1, запит однаково проходить через nginx:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/ping.
Якщо 502 або порожньо — служба не піднялася:
journalctl --user -u club-api -n 30, найчастіше там SyntaxError
або ImportError з main.py. Якщо код зламаний, поверни останню
справну версію: git checkout -- main.py, потім
systemctl --user restart club-api. Якщо 404 — найімовірніше, у
main.py немає ендпоінта /api/ping: перевір командою
grep із кроку 1. Друга причина 404 — помилка в адресі, звір логін
посимвольно. Якщо в скрипті немає кроку 4 з curl, додай його: без нього
критерій не зарахується, навіть коли сервіс відповідає. |
Основне. Додати шостий тест — на функцію, яку ти писав сам. Порядок такий: спершу пишеш тест і запускаєш його, і він має впасти. Тільки після цього дописуєш код, щоб тест проходив.
Порядок саме такий не просто так. Тест, який пройшов з першого разу, міг взагалі нічого не
перевіряти: помилка в адресі, забутий assert, перевірка, що завжди правдива.
Побачивши, що тест спершу падає, а після правки коду проходить, ти точно знаєш, що він реагує
на код. Такий спосіб роботи називають розробкою через тести.
Що перевіряти — на твій вибір. Приклади для таблиці звичок, підстав своє:
days приймає лише числа від 1 до 7, решта дає 422;UNIQUE;GET одного запису за неіснуючим id дає 404.Зроби це двома комітами, щоб обидва кроки лишилися в історії:
git add tests
git commit -m "тест: довга назва має давати 422 (поки падає)"
# правиш main.py, домагаєшся 6 passed
git add main.py
git commit -m "перевірка довжини назви в моделі NewItem"
git push origin masterНаприкінці — перевір, що звична команда все ще працює від початку до кінця:
cd ~/app
./deploy.sh
echo $?Додатково. Допиши в README.md розділ «Як оновити» з двома
рядками: команда ./deploy.sh і одне речення про те, що станеться, якщо тести
впадуть. Через місяць це читатимеш ти сам, і воно заощадить тобі кілька хвилин.
pytest не бачить твого застосунку. Перевір дві речі: файл лежить там, де треба, і команда запускається з правильної теки.
ls ~/app/tests/conftest.py
cd ~/app
/opt/club/venv/bin/python -m pytest -q testsЯкщо ls відповів No such file or directory, файл із фікстурами
опинився не в tests/ — перенеси його. Запускай саме
python -m pytest: варіант без -m не додає поточну теку в шлях пошуку
модулів, і імпорти працюють по-різному залежно від того, звідки ти запустив команду.
Три причини, у порядку частоти. Перша: файл із фікстурами названо інакше — ім’я має бути
точно conftest.py, це не довільна назва, pytest шукає саме її. Друга: у самому
conftest.py сталася помилка, і pytest не зміг його виконати; тоді вище, у блоці
ERRORS, буде видно справжню причину — читай її, а не рядок про фікстуру. Третя:
одруківка в імені параметра — ownre замість owner.
Найчастіша помилка всередині conftest.py — KeyError: 'table'.
Вона означає, що в project.yaml у розділі auth немає самого поля
table. Якщо поле є, але порожнє, помилка буде інша: у запит підставиться
None, і SQLite поскаржиться на неправильний запит. В обох випадках впиши в це поле
назву своєї таблиці власника із заняття 07.
500 означає, що код упав сам, не встигши нічого відповісти. 422 віддає FastAPI, коли дані не
пройшли перевірку моделі — тобто коли в моделі стоїть обмеження, наприклад
title: str = Field(min_length=1). Якщо ж перевірка написана руками, рядком на
кшталт if not title: raise Exception(...), вийде саме 500.
Подивись, як описана модель NewItem у main.py, і порівняй із
заняттям 05. Після правки перезапускати службу для тестів не треба — тестовий клієнт бере
код напряму з файлу.
Перше — файлу не поставили ознаку «можна запускати»: chmod +x ~/app/deploy.sh.
Друге — ти набрав deploy.sh без ./, і bash шукав таку команду серед
системних програм.
Окремий випадок — повідомлення bad interpreter: /usr/bin/env bash^M.
^M — це другий символ кінця рядка: у Windows кінець рядка позначається двома
символами, а в Linux одним. У файл він потрапляє, якщо текст скрипта набирали в
Windows-редакторі й перенесли на сервер разом із цими символами. Якщо ти створював
deploy.sh через nano прямо на сервері, такого не станеться. Прибрати
їх:
sed -i 's/\r$//' ~/app/deploy.sh
./deploy.shУ ~/app є правки, яких немає в жодному коміті, і git не хоче їх затерти
комітами, що приходять. Подивись, що це за файли:
git status --shortДалі одне з двох. Якщо правки потрібні — закомить їх:
git add -A
git commit -m "опис того, що змінив"
./deploy.shЯкщо це залишки експериментів — відклади їх убік:
git stash
./deploy.shgit stash прибирає незакомічені зміни в окреме сховище, і робоча тека стає
чистою. Повернути їх потім можна командою git stash pop, а подивитися список
відкладеного — git stash list.