Skip to content

Commit cc9557f

Browse files
committed
ops(dev): inspect project names and migration plan read-only
1 parent 02e8a38 commit cc9557f

1 file changed

Lines changed: 61 additions & 317 deletions

File tree

‎.github/workflows/dev-ci.yml‎

Lines changed: 61 additions & 317 deletions
Original file line numberDiff line numberDiff line change
@@ -1,323 +1,67 @@
1-
name: Deploy Dev
1+
name: DEV default project name inspection
22

33
on:
4-
push:
5-
branches:
6-
- dev
74
workflow_dispatch:
85

9-
jobs:
10-
deploy:
11-
name: Deploy
12-
runs-on: ubuntu-latest
13-
steps:
14-
- name: Deploy to server
15-
uses: garygrossgarten/github-action-ssh@release
16-
with:
17-
host: ${{ secrets.DEV_SERVER_HOST }}
18-
username: ${{ secrets.DEV_SERVER_USER }}
19-
password: ${{ secrets.DEV_SERVER_PASSWORD }}
20-
command: |
21-
set -eu
22-
23-
cd /root/api
24-
git fetch --all --tags --prune
25-
git branch -r --contains "${{ github.sha }}" | grep -q 'origin/dev'
26-
git checkout --detach "${{ github.sha }}"
27-
git reset --hard "${{ github.sha }}"
28-
test "$(git rev-parse HEAD)" = "${{ github.sha }}"
29-
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
30-
echo "Tracked working tree is dirty after reset" >&2
31-
git status --short --untracked-files=no >&2
32-
exit 1
33-
fi
34-
35-
expected_serializer_blob="$(git rev-parse HEAD:vacancy/serializers.py)"
36-
actual_serializer_blob="$(git hash-object vacancy/serializers.py)"
37-
if [ "$actual_serializer_blob" != "$expected_serializer_blob" ]; then
38-
echo "Server serializers.py does not match HEAD blob" >&2
39-
exit 1
40-
fi
41-
42-
echo "Deploy source HEAD: $(git rev-parse HEAD)"
43-
echo "vacancy/serializers.py blob: ${actual_serializer_blob}"
44-
45-
export IMAGE_TAG="${{ github.sha }}"
46-
SOURCE_IMAGE="procollab-dev-api:${IMAGE_TAG}"
47-
export DEPLOY_IMAGE="$SOURCE_IMAGE"
48-
49-
rm -f .env
50-
touch .env
51-
52-
echo "DJANGO_SECRET_KEY=${{ secrets.DEV_DJANGO_SECRET_KEY }}" >> .env
53-
54-
echo "DATABASE_NAME=${{ secrets.DEV_DATABASE_NAME }}" >> .env
55-
echo "DATABASE_PASSWORD=${{ secrets.DEV_DATABASE_PASSWORD }}" >> .env
56-
echo "DATABASE_USER=${{ secrets.DEV_DATABASE_USER }}" >> .env
57-
echo "DATABASE_HOST=${{ secrets.DEV_DATABASE_HOST }}" >> .env
58-
echo "DATABASE_PORT=${{ secrets.DEV_DATABASE_PORT }}" >> .env
59-
60-
echo "SELECTEL_ACCOUNT_ID=${{ secrets.SELECTEL_ACCOUNT_ID }}" >> .env
61-
echo "SELECTEL_CONTAINER_NAME=${{ secrets.SELECTEL_CONTAINER_NAME }}" >> .env
62-
echo "SELECTEL_CONTAINER_PASSWORD=${{ secrets.SELECTEL_CONTAINER_PASSWORD }}" >> .env
63-
echo "SELECTEL_CONTAINER_USERNAME=${{ secrets.SELECTEL_CONTAINER_USERNAME }}" >> .env
64-
65-
echo "EMAIL_USER=${{ secrets.EMAIL_USER }}" >> .env
66-
echo "UNISENDER_GO_API_KEY=${{ secrets.UNISENDER_GO_API_KEY }}" >> .env
67-
68-
chmod 600 .env
69-
70-
docker compose -f docker-compose.dev-ci.yml config >/dev/null
71-
docker compose -f docker-compose.dev-ci.yml build web
72-
73-
BUILT_IMAGE_ID="$(
74-
docker image inspect \
75-
-f '{{.Id}}' \
76-
"$SOURCE_IMAGE"
77-
)"
78-
79-
echo "Built image: ${SOURCE_IMAGE}"
80-
echo "Built image ID: ${BUILT_IMAGE_ID}"
81-
82-
case "$BUILT_IMAGE_ID" in
83-
sha256:*) ;;
84-
*)
85-
echo "Invalid built image ID: ${BUILT_IMAGE_ID}" >&2
86-
exit 1
87-
;;
88-
esac
89-
90-
BUILT_IMAGE_SHORT="$(
91-
printf '%s' "${BUILT_IMAGE_ID#sha256:}" |
92-
cut -c1-12
93-
)"
94-
RUNTIME_IMAGE_TAG="${IMAGE_TAG}-${BUILT_IMAGE_SHORT}"
95-
RUNTIME_IMAGE="procollab-dev-api:${RUNTIME_IMAGE_TAG}"
96-
97-
docker image rm "$RUNTIME_IMAGE" >/dev/null 2>&1 || true
98-
docker tag "$BUILT_IMAGE_ID" "$RUNTIME_IMAGE"
99-
100-
RUNTIME_IMAGE_ID="$(
101-
docker image inspect -f '{{.Id}}' "$RUNTIME_IMAGE"
102-
)"
103-
test "$RUNTIME_IMAGE_ID" = "$BUILT_IMAGE_ID"
104-
105-
export DEPLOY_IMAGE="$RUNTIME_IMAGE"
106-
107-
echo "Pinned runtime image: ${RUNTIME_IMAGE}"
108-
echo "Pinned runtime image ID: ${RUNTIME_IMAGE_ID}"
109-
docker compose -f docker-compose.dev-ci.yml config >/dev/null
110-
docker compose -f docker-compose.dev-ci.yml config --format json |
111-
docker run --rm -i \
112-
--entrypoint python \
113-
"$RUNTIME_IMAGE" \
114-
-c '
115-
import json
116-
import sys
117-
118-
expected = sys.argv[1]
119-
services = json.load(sys.stdin)["services"]
120-
for service_name in ("web", "celerys"):
121-
actual = services[service_name]["image"]
122-
print(f"Compose {service_name} image: {actual}")
123-
if actual != expected:
124-
raise SystemExit(
125-
f"Compose {service_name} does not use {expected}"
126-
)
127-
' "$RUNTIME_IMAGE"
128-
129-
BUILT_SERIALIZER_SHA="$(
130-
docker run --rm \
131-
--entrypoint sha256sum \
132-
"$RUNTIME_IMAGE" \
133-
/procollab/vacancy/serializers.py |
134-
awk '{print $1}'
135-
)"
136-
test -n "$BUILT_SERIALIZER_SHA"
137-
echo "Built vacancy/serializers.py SHA-256: ${BUILT_SERIALIZER_SHA}"
138-
139-
docker compose -f docker-compose.dev-ci.yml run --rm -T web python - <<'PY'
140-
import os
141-
142-
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings")
143-
144-
import django
145-
146-
django.setup()
147-
148-
from vacancy.serializers import (
149-
ProjectVacancyCreateListSerializer,
150-
VacancyDetailSerializer,
151-
)
152-
153-
create_fields = ProjectVacancyCreateListSerializer.Meta.fields
154-
detail_fields = VacancyDetailSerializer.Meta.fields
155-
156-
print("Compose run create serializer fields:", create_fields)
157-
print("Compose run detail serializer fields:", detail_fields)
158-
159-
if "city" not in create_fields:
160-
raise SystemExit("Compose run create serializer uses stale image")
161-
162-
if "city" not in detail_fields:
163-
raise SystemExit("Compose run detail serializer uses stale image")
164-
PY
165-
166-
docker compose -f docker-compose.dev-ci.yml run --rm web \
167-
python manage.py migrate
168-
docker compose -f docker-compose.dev-ci.yml run --rm web \
169-
python manage.py showmigrations vacancy
6+
permissions:
7+
contents: read
1708

171-
docker compose -f docker-compose.dev-ci.yml rm -sf web celerys || true
172-
docker compose -f docker-compose.dev-ci.yml up \
173-
-d \
174-
--no-build \
175-
--force-recreate
9+
concurrency:
10+
group: dev-default-project-name-readonly-20261004
11+
cancel-in-progress: false
17612

177-
for service in web celerys; do
178-
container="$(docker compose -f docker-compose.dev-ci.yml ps -q "$service")"
179-
if [ -z "$container" ]; then
180-
echo "Service ${service} has no running container" >&2
181-
docker compose -f docker-compose.dev-ci.yml ps >&2 || true
182-
exit 1
183-
fi
184-
185-
configured_image="$(docker inspect -f '{{.Config.Image}}' "$container")"
186-
actual_image_id="$(docker inspect -f '{{.Image}}' "$container")"
187-
echo "Service ${service}: container=${container} reference=${configured_image} image_id=${actual_image_id}"
188-
if [ "$actual_image_id" != "$BUILT_IMAGE_ID" ]; then
189-
echo "Service ${service} uses wrong image ID" >&2
190-
echo "Expected: ${BUILT_IMAGE_ID}" >&2
191-
echo "Actual: ${actual_image_id}" >&2
192-
echo "Reference: ${configured_image}" >&2
193-
exit 1
194-
fi
195-
done
196-
197-
RUNNING_SERIALIZER_SHA="$(
198-
docker compose -f docker-compose.dev-ci.yml exec -T web \
199-
sha256sum /procollab/vacancy/serializers.py |
200-
awk '{print $1}'
201-
)"
202-
echo "Running vacancy/serializers.py SHA-256: ${RUNNING_SERIALIZER_SHA}"
203-
if [ "$RUNNING_SERIALIZER_SHA" != "$BUILT_SERIALIZER_SHA" ]; then
204-
echo "Running serializer source does not match built image" >&2
205-
echo "Expected: ${BUILT_SERIALIZER_SHA}" >&2
206-
echo "Actual: ${RUNNING_SERIALIZER_SHA}" >&2
207-
exit 1
208-
fi
209-
210-
docker compose -f docker-compose.dev-ci.yml exec -T web python - <<'PY'
211-
import os
212-
213-
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings")
214-
215-
import django
216-
217-
django.setup()
218-
219-
from django.db import connection
220-
from django.db.migrations.recorder import MigrationRecorder
221-
222-
applied = {
223-
(migration.app, migration.name)
224-
for migration in MigrationRecorder(connection).migration_qs
225-
}
226-
227-
if ("vacancy", "0010_vacancy_city") not in applied:
228-
raise SystemExit("vacancy.0010_vacancy_city is not applied")
229-
230-
print("vacancy.0010_vacancy_city applied")
231-
PY
232-
233-
docker compose -f docker-compose.dev-ci.yml exec -T web python - <<'PY'
234-
import os
235-
236-
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings")
237-
238-
import django
239-
240-
django.setup()
241-
242-
from vacancy.serializers import (
243-
ProjectVacancyCreateListSerializer,
244-
VacancyDetailSerializer,
245-
)
246-
247-
create_fields = set(ProjectVacancyCreateListSerializer().fields.keys())
248-
detail_fields = set(VacancyDetailSerializer().fields.keys())
249-
250-
print("Runtime create serializer fields:", sorted(create_fields))
251-
print("Runtime detail serializer fields:", sorted(detail_fields))
252-
253-
if "city" not in create_fields:
254-
raise SystemExit("Runtime create serializer does not contain city")
255-
256-
if "city" not in detail_fields:
257-
raise SystemExit("Runtime detail serializer does not contain city")
258-
259-
print("Runtime serializers contain city")
260-
PY
261-
262-
install -d /etc/nginx/procollab/includes &&
263-
install -m 644 deploy/nginx/host/includes/proxy_app.inc /etc/nginx/procollab/includes/proxy_app.inc &&
264-
install -m 644 deploy/nginx/host/dev/dev.procollab.ru /etc/nginx/sites-available/dev.procollab.ru &&
265-
ln -sfn /etc/nginx/sites-available/dev.procollab.ru /etc/nginx/sites-enabled/dev.procollab.ru &&
266-
if [ "$(id -u)" -eq 0 ]; then
267-
nginx -t &&
268-
systemctl reload nginx
269-
else
270-
sudo nginx -t &&
271-
sudo systemctl reload nginx
272-
fi &&
273-
274-
for attempt in $(seq 1 24); do
275-
admin_status="$(curl -s -o /dev/null -w '%{http_code}' https://dev.procollab.ru/admin/login/ || true)" &&
276-
277-
if [ "$admin_status" = "200" ]; then
278-
echo "Smoke check passed on attempt ${attempt}" &&
279-
break
280-
fi
281-
282-
sleep 5
283-
done &&
284-
285-
if [ "$admin_status" != "200" ]; then
286-
echo "Smoke check failed: /admin/login/=${admin_status}" >&2 &&
287-
exit 1
288-
fi &&
289-
290-
docker compose -f docker-compose.dev-ci.yml ps
291-
292-
celery_status=""
293-
celery_ping=""
294-
celery_container=""
295-
for attempt in $(seq 1 12); do
296-
celery_container="$(docker compose -f docker-compose.dev-ci.yml ps -q celerys 2>/dev/null || true)"
297-
if [ -n "$celery_container" ]; then
298-
celery_status="$(docker inspect -f '{{.State.Status}}' "$celery_container" 2>/dev/null || true)"
299-
else
300-
celery_status="missing"
301-
fi
302-
303-
echo "Celery check attempt ${attempt}: container=${celery_container:-missing} status=${celery_status}"
304-
if [ "$celery_status" = "running" ]; then
305-
celery_ping="$(docker compose -f docker-compose.dev-ci.yml exec -T celerys sh -lc 'celery -A procollab inspect ping --timeout=15' 2>&1 || true)"
306-
printf '%s\n' "$celery_ping"
307-
if printf '%s\n' "$celery_ping" | grep -q 'pong'; then
308-
echo "Celery check passed on attempt ${attempt}"
309-
break
310-
fi
311-
fi
312-
313-
sleep 5
314-
done
315-
316-
if [ "$celery_status" != "running" ] || ! printf '%s\n' "$celery_ping" | grep -q 'pong'; then
317-
echo "Celery check failed: status=${celery_status}" >&2
318-
docker compose -f docker-compose.dev-ci.yml ps >&2 || true
319-
docker compose -f docker-compose.dev-ci.yml logs --tail=200 celerys >&2 || true
320-
docker compose -f docker-compose.dev-ci.yml logs --tail=100 redis >&2 || true
321-
docker compose -f docker-compose.dev-ci.yml logs --tail=100 web >&2 || true
322-
exit 1
323-
fi
13+
jobs:
14+
inspect:
15+
if: github.ref == 'refs/heads/ops/dev-default-project-name-check-20261004'
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 5
18+
steps:
19+
- name: Inspect DEV runtime and migration plan without changing data
20+
uses: garygrossgarten/github-action-ssh@release
21+
with:
22+
host: ${{ secrets.DEV_SERVER_HOST }}
23+
username: ${{ secrets.DEV_SERVER_USER }}
24+
password: ${{ secrets.DEV_SERVER_PASSWORD }}
25+
command: |
26+
bash -se 2>&1 <<'DEV_INSPECT'
27+
set -Eeuo pipefail
28+
repo=/root/api
29+
test "$(readlink -f "$repo")" = "$repo"
30+
grep -Eq 'server_name[[:space:]]+dev\.procollab\.ru;' /etc/nginx/sites-enabled/dev.procollab.ru
31+
source_sha=$(git --no-optional-locks -C "$repo" rev-parse HEAD)
32+
[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]]
33+
printf 'DEV_SOURCE_SHA=%s\n' "$source_sha"
34+
df -h /
35+
for service in web celerys; do
36+
container=$(docker ps -q --no-trunc --filter "label=com.docker.compose.project.working_dir=$repo" --filter "label=com.docker.compose.service=$service")
37+
[[ "$container" =~ ^[0-9a-f]{64}$ ]]
38+
printf 'DEV_SERVICE=%s\n' "$service"
39+
docker inspect -f '{"image_reference":{{json .Config.Image}},"image_id":{{json .Image}},"status":{{json .State.Status}},"restart_count":{{.RestartCount}}}' "$container"
40+
done
41+
image="procollab-dev-api:${source_sha}"
42+
docker image inspect -f 'DEV_SOURCE_IMAGE={{.Id}}' "$image"
43+
docker run --rm -i --read-only --network host --env-file "$repo/.env" --entrypoint python "$image" - <<'PY'
44+
import os
45+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'procollab.settings')
46+
import django
47+
django.setup()
48+
from django.db import connection
49+
from django.db.migrations.executor import MigrationExecutor
50+
from projects.models import Project
51+
executor = MigrationExecutor(connection)
52+
executor.loader.check_consistent_history(connection)
53+
print('DEV_MIGRATION_HISTORY_CONSISTENT=True', flush=True)
54+
plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
55+
print('DEV_PENDING_MIGRATIONS=' + repr([(m.app_label, m.name, backwards) for m, backwards in plan]), flush=True)
56+
applied = ('projects', '0034_default_project_name') in executor.loader.applied_migrations
57+
print('DEV_DEFAULT_NAME_MIGRATION_APPLIED=' + str(applied), flush=True)
58+
print('DEV_MODEL_NAME_DEFAULT=' + repr(Project._meta.get_field('name').get_default()), flush=True)
59+
from django.db.models import Q
60+
pattern = '^[\t\n\v\f\r\x1c-\x1f \x85\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*$'
61+
blanks = Project.objects.filter(Q(name__isnull=True) | Q(name__regex=pattern)).count()
62+
defaults = Project.objects.filter(name='Проект без названия').count()
63+
print('DEV_BLANK_PROJECT_NAME_COUNT=' + str(blanks), flush=True)
64+
print('DEV_DEFAULT_PROJECT_NAME_COUNT=' + str(defaults), flush=True)
65+
PY
66+
printf 'DEV_READONLY_INSPECTION_COMPLETE=true\n'
67+
DEV_INSPECT

0 commit comments

Comments
 (0)