|
1 | | -name: Deploy Dev |
| 1 | +name: DEV default project name inspection |
2 | 2 |
|
3 | 3 | on: |
4 | | - push: |
5 | | - branches: |
6 | | - - dev |
7 | 4 | workflow_dispatch: |
8 | 5 |
|
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 |
170 | 8 |
|
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 |
176 | 12 |
|
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