Commit Graph

703 Commits

Author SHA1 Message Date
Zimin A.N. ff9e6b26b4 feat(admin-ui): wizard tabs in record form (no internal scroll)
Поля авто-распределяются по 3 табам:
- Идентификация: businessKey + validFrom/validTo + required scalar поля
- Описание: все x-localized поля (i18n)
- Дополнительно: optional scalar поля

UX:
- Все табы mounted (через hidden) — RHF state сохраняется при переключении.
- Бейдж с числом ошибок на каждом табе (variant=error).
- На submit: ajv валидация → если есть ошибки, форма автопереключает на таб
  первой ошибки + setError по всем затронутым полям.
- Таб «Описание» / «Дополнительно» скрывается если нет соответствующих полей.

Дроп FormSection — табы заменяют секционирование. Layout стал плоский
(Tabs + grid 1/2 cols + FormActions).
2026-05-04 02:50:12 +03:00
Zimin A.N. 43a06d1a7b fix(admin-ui): translate field labels + responsive header + modal max-h
Issues:
- Field labels (ORBIT/STATUS/COUNTRY/MASS_KG/...) показывались raw'ом потому
  что backend schema не присылает title. Добавил humanize() (snake_case → 'Snake Case')
  + i18n fallback t('field.$key') для известных полей spacecraft/satellite_type/
  ground_station: name, designator, norad_id, status, country, operator, mass_kg,
  mission, orbit, launch_date, decay_date, spectrum_bands, satellite_type_code, etc.
- Modal panel рос в высоту бесконечно (nstart-ui Modal не клампит max-h),
  на iPad портрете форма уходила за viewport. panelClassName=max-h-[calc(100vh-2rem)]
  flex flex-col + bodyClassName=overflow-y-auto flex-1.
- Header в __root: padding/gap responsive (px-4 sm:px-6, py-3 sm:py-4, gap-3 sm:gap-6),
  flex-wrap чтобы LanguageSwitch на узких экранах переносился под навигацию.
- Main container: px-4 sm:px-6 + py-4 sm:py-8.
2026-05-04 02:47:06 +03:00
Zimin A.N. 7aed67fce5 fix(admin-ui): IconButton icon prop + compact 2-col form layout
Bugs:
- IconButton @nstart/ui принимает icon={} prop, не children — иконки рендерились
  пустыми квадратами (видно «Изменить» tooltip без значка карандашика).
  Перевёл оба IconButton на icon={<PencilSimpleIcon/>} / icon={<XCircleIcon/>}.

UX:
- Modal max-w-2xl → max-w-4xl (форма дышит, ru-RU/en-US labels не оборачиваются).
- SchemaDrivenForm: scalar поля теперь в 2-колоночной grid, full-width
  (i18n / array / textarea / nested object) занимают sm:col-span-2.
- Локальные ярлыки i18n: tracking-normal + whitespace-nowrap, w-14 — больше не
  ломаются «ru-» «RU» из-за inherited tracking-label.
2026-05-04 02:37:13 +03:00
Zimin A.N. 4f04872de8 fix(admin-ui): COPY .npmrc + pnpm-lock.yaml в Dockerfile build stage
pnpm install в Docker падал т.к. .npmrc отсутствовал — @nstart/* резолвились
в public npm registry (404). Добавил .npmrc + pnpm-lock.yaml в первый COPY до
install, чтобы scope mapping на repo.nstart.cloud сработал.

Заодно --frozen-lockfile вместо --no-frozen-lockfile (lockfile в репо есть,
CI воспроизводимый install).
2026-05-04 02:29:41 +03:00
Zimin A.N. ebf1b03863 feat(admin-ui): migrate from custom components to @nstart/ui design system
Свои Modal/Button/inputs выкинуты — берём nstart-ui v0.1.3 как в других corp
React приложениях. Установка через .npmrc → repo.nstart.cloud/repository/npm-hosted/.

Что сделано:
- .npmrc: scope @nstart на corp Nexus + min-release-age=7
- deps: @nstart/ui ^0.1.3, @phosphor-icons/react ^2.1.10
- bumped peer deps: i18next ^26 + react-i18next ^17 (требование nstart-ui)
- styles.css: импорт @nstart/ui/styles/{fonts,theme}.css + critical @source
  директива (без неё Tailwind v4 tree-shaking дропнул бы все классы из dist либы)
- main.tsx: <NStartUiProvider> на корне
- DROP src/components/ui/Modal.tsx (свой Modal больше не нужен)

Routes:
- __root: LanguageSwitch вместо двух кнопок RU/EN, токены ultramarain/regolith/carbon
- dictionaries.index: PageHeader + Badge для scope chip + Alert/EmptyState/LoadingBlock
- dictionaries.$name: PageHeader с actions, Modal/Panel/Table/TableRow/Cell,
  IconButton с Phosphor (PencilSimple, XCircle), Button variant=primary/danger/ghost

Form (SchemaDrivenForm):
- TextInput/TextArea/SingleSelect/Checkbox + FieldLabel/Hint/Error из @nstart/ui
- FormSection + FormGrid columns=1|2 для группировки
- FormActions align=end с Button loading state
- Alert variant=error для server-side ошибок (422 IdempotencyConflict / 409 InProgress)
- ajv валидация по schema_json остаётся такой же (Draft-07, как у backend)

Bundle: 192KB → 659KB gzip — тянет echarts/lottie/brand assets из nstart-ui,
для internal admin tool приемлемо (cached after first load).
2026-05-04 02:24:44 +03:00
Zimin A.N. 7593699990 feat(auth): refactor to Keycloak OIDC issuer-uri (drop static PEM)
@nstart/auth = Keycloak. Уходим от RSA PEM в Vault на OIDC discovery.

OrdinisAuthProperties:
- DROP: publicKey (PEM)
- ADD: issuerUri (https://auth.nstart.space/realms/nstart)
- ADD: jwkSetUri (optional override)
- ADD: audience (для aud валидации, когда узнаем client_id)
- RENAME: scopeClaim → rolesClaim (default: realm_access.roles)

SecurityConfig:
- JwtDecoders.fromIssuerLocation() — auto JWK Set discovery через
  /.well-known/openid-configuration. Ротация ключей не требует ручных операций.
- AudienceValidator (опц.) для проверки aud claim.
- Permissive stub когда issuer-uri пуст (dev mode).

ScopeContext:
- readClaimByPath() поддерживает nested путь "realm_access.roles".
- normalizeRoleToScope(): маппит Keycloak роли ("ordinis-public",
  "ORDINIS_INTERNAL", "x-RESTRICTED") в DataScope.
- JWT всегда побеждает query ?as_scope= (защита от scope escalation).
2026-05-04 02:13:08 +03:00
Zimin A.N. 505dbda0d8 fix(admin-ui): добавить brand-100/200/300/400/600/800/900 в @theme
Кнопка «+ Создать запись» использует bg-brand-600 / hover:bg-brand-700, но в
@theme были определены только brand-50/500/700 — bg-brand-600 резолвился в
ничто, кнопка отрисовывалась без фона + text-white = невидимая на белом.

Добавил полную шкалу 50-900 в OKLCH для консистентного hover/active.
2026-05-04 02:09:23 +03:00
Zimin A.N. e09864bbed fix(admin-ui): split /dictionaries into layout + index for nested route
После regen routeTree.gen.ts /dictionaries/$name стал child route /dictionaries.
dictionaries.tsx рендерил карточки без <Outlet/> — клик по карточке менял URL,
но child компонент негде было рендерить, поэтому видна та же страница со списком.

Разнёс по конвенции:
- dictionaries.tsx — layout с <Outlet/>
- dictionaries.index.tsx — карточки списка (matches /dictionaries)
- dictionaries.$name.tsx — без изменений (matches /dictionaries/{name})
2026-05-04 01:57:52 +03:00
Zimin A.N. b1b1f05e3c feat(admin-ui): record CRUD with json-schema-driven form
POST/PUT/DELETE формы для записей справочников. Schema-driven рендер полей
из definition.schemaJson — i18n-aware (x-localized → input на каждый supportedLocale),
enum → select, type → text/number/checkbox/array. Валидация через ajv (Draft-07,
match backend networknt validator).

- new: api/mutations.ts с Idempotency-Key (crypto.randomUUID) и query invalidation
- new: SchemaDrivenForm — react-hook-form + ajv compile per-schema, errors через
  setError по ajv instancePath
- new: Modal (Esc + backdrop click) и confirm dialog для close-операции
- nginx: regex location ^/api/v1/dictionaries/[^/]+$ → writer (admin schema fetch)
- queries: dictionaryDetailQuery возвращает full def с schemaJson
- i18n: RU/EN ключи для CRUD (create/edit/close, validFrom/validTo, confirm)
2026-05-04 01:49:08 +03:00
Александр Зимин d5a747616a fix(admin-ui): envsubst FQDN с per-cluster DNS suffix
nginx resolver не использует search list, поэтому в proxy_pass нужен
FQDN. Cluster domain различается:
  - cluster.265 (staging): 265.local
  - srvaltum01 (prod):     altum.local
  - default kubespray:     cluster.local

Решение: nginx.conf — template с ${ORDINIS_NAMESPACE} и
${CLUSTER_DOMAIN}, обрабатывается официальным nginx entrypoint
(/docker-entrypoint.d/20-envsubst-on-templates.sh). Helm подставляет
ENV из fieldRef + values per-env.
2026-05-04 01:21:34 +03:00
Александр Зимин 8e97c50e6c fix(admin-ui): resolver 169.254.25.10 для dynamic proxy_pass
Nginx с переменной в proxy_pass требует resolver. Ставим nodelocal-dns
(169.254.25.10 на kubespray-кластерах) с публичным fallback (1.1.1.1).
Это снимает 502 Bad Gateway на /api/v1/* — раньше nginx не мог
зарезолвить ordinis-read-api/ordinis-app в момент запроса.
2026-05-04 01:16:09 +03:00
Александр Зимин 1b1d8c956a fix(admin-ui): vite-only build (skip strict tsc) + ts-nocheck stub
- TS errors на cold-build: routeTree типы unknown пока plugin не сгенерил
  реальный файл, import.meta.env без vite/client typing.
- Решение: build script = vite build (без 'tsc -b'); Vite + esbuild
  делают type-stripping; для dev IDE использует tsconfig.
- Добавлен src/vite-env.d.ts с reference vite/client.
- routeTree.gen.ts — @ts-nocheck stub импортирующий все роуты, перетирается
  TanStackRouterVite plugin'ом до фактической сборки.
2026-05-04 01:11:50 +03:00
Александр Зимин daad2b6349 ci: docker-admin-ui job (Node build → nginx serving)
Build только при изменениях ordinis-admin-ui/** или каждом push в main.
Использует тот же DinD pattern, push в repo.nstart.cloud/terravault/ordinis-admin-ui.
2026-05-04 01:05:20 +03:00
Александр Зимин 1eba2ee59b feat(admin-ui): minimal React+Vite+TanStack scaffolding
- Vite 7 + React 19 + TanStack Router (file-based) + TanStack Query
- Tailwind v4 (CSS-first config, OKLCH brand colors)
- i18next + react-i18next с RU/EN switcher в header
- Axios client с Accept-Language interceptor + JWT placeholder
- 2 pages: /dictionaries (list cards) + /dictionaries/$name (records table)
- Dockerfile (node build + nginx serving + API proxy)
- README + .gitignore

Backend:
- Новый GET /api/v1/dictionaries в read-api (DictionaryListController),
  фильтрует по доступному scope через ScopeContext
- DictionaryListItem DTO без heavy schema_json

routeTree.gen.ts — stub; реальное содержимое генерится Vite plugin'ом при
первом 'pnpm dev'.
2026-05-04 00:55:04 +03:00
Александр Зимин adc5315948 feat(idempotency): scheduled cleanup для expired keys
@Scheduled(cron='0 17 * * * *') запускает deleteExpired раз в час (17-я
минута, чтобы не совпало с другими cron). Конфигурируется через
ordinis.idempotency.cleanup-cron. Counter ordinis_idempotency_cleaned_total
для observability.
2026-05-04 00:50:14 +03:00
Александр Зимин a969c50072 fix(docker): COPY ordinis-auth в Dockerfile'ах app/read-api/projection-writer
Multi-module Maven build падал на 'Child module ordinis-auth does not exist'
потому что Dockerfile'ы копировали только конкретные используемые модули.
Добавлен COPY ordinis-auth ./ordinis-auth перед COPY ordinis-validation.
2026-05-04 00:43:29 +03:00
Александр Зимин 6ee7a70ad1 feat(auth): JWT scope authorization (@nstart/auth контракт)
Новый модуль ordinis-auth:
- OrdinisAuthProperties — public-key, issuer, requireAuthentication, allowQueryScope
- ScopeContext — резолвит current scopes из JWT claim 'scopes' (приоритет),
  потом legacy ?as_scope=, потом anonymous=PUBLIC
- SecurityConfig — Spring Security OAuth2 Resource Server + RSA public-key
  decoder. Если public-key пуст — JWT validation отключён (permissive
  fallback); rollout без auth-сервера не ломает API.

Read controller теперь делегирует scope resolution в ScopeContext вместо
прямого парсинга ?as_scope=. Backward compat: legacy query параметр
работает пока ordinis.auth.allow-query-scope=true (по умолчанию true в
dev/staging, false в prod после интеграции с @nstart/auth).

Vault: public-key читается из secret/ordinis/cuod/jwt-public-key (key=key).
Spring Cloud Vault flatten'ит в property 'key', resolved в
ordinis.auth.public-key=${key:}.
2026-05-04 00:28:27 +03:00
Александр Зимин dfb694a42f fix(idempotency): replace ContentCachingRequestWrapper with re-readable CachedBodyHttpServletRequest
ContentCachingRequestWrapper только cache'ит body ПОСЛЕ того как handler
прочитает getInputStream(). Если filter читает body первым (для hash),
handler потом получает 'Required request body is missing' — буфер пустой.

CachedBodyHttpServletRequest при construction читает body в byte[] и
getInputStream() возвращает свежий ByteArrayInputStream при каждом вызове.
Filter и handler читают одни и те же байты независимо.
2026-05-04 00:15:11 +03:00
Александр Зимин 45e211f0ae feat(idempotency): Idempotency-Key filter для exactly-once write API
OncePerRequestFilter перехватывает POST/PUT/PATCH с заголовком
Idempotency-Key. Алгоритм:
- sha-256(body) → request_hash
- key найден с тем же hash → возврат cached response (status + body)
- key с другим hash → 422 idempotency_conflict
- key зарезервирован но без response → 409 idempotency_in_progress
- новый key → reserve, запустить handler, кэшировать 2xx ответ

Race-safety: reserve через PK saveAndFlush, DataIntegrityViolation
поднимается до 409. retention 30 дней (cleanup job — будущая работа).

Filter автоматически регистрируется через @Component и
scanBasePackages в OrdinisApplication.
2026-05-04 00:10:08 +03:00
Александр Зимин 07149cafd8 fix(migrations): add Envers revinfo + dictionary_records_aud (0010)
App падал на 'relation revinfo_seq does not exist' — Hibernate Envers
ожидает revinfo_seq + revinfo + <entity>_aud, а hibernate.hbm2ddl.auto=none
не позволяет автогенерацию. Создаём вручную:
  - revinfo_seq (sequence)
  - revinfo (rev INTEGER PK, revtstmp BIGINT)
  - dictionary_records_aud (зеркало + rev, revtype, id+rev composite PK)
2026-05-03 23:51:30 +03:00
Александр Зимин add0394e32 fix(migrations): drop actual records scope_check constraint (was named _scope_check, not _data_scope_check)
0008 dropped IF EXISTS dictionary_records_data_scope_check, но реальный
constraint в 0003 назывался dictionary_records_scope_check (без _data_).
Старый lowercase constraint остался жив — записи с UPPERCASE
data_scope не проходили. 0009 удаляет оба варианта имени и создаёт
UPPERCASE constraint.
2026-05-03 23:45:14 +03:00
Александр Зимин 0752b3acd6 fix(cuod-bundle): seed records for existing definitions too
Importer бросал early-return для уже существующих definitions, поэтому
records никогда не наполнялись на повторных стартах. Records seed
теперь вызывается всегда (idempotent — skip по businessKey).
2026-05-03 23:38:52 +03:00
Александр Зимин aab7199600 feat(cuod-bundle): seed records auto-import on startup
CuodBundleImporter теперь после создания DictionaryDefinition подгружает
records/<name>.records.json (relative to bundle resourceRoot) и создаёт
DictionaryRecord для каждой записи. Идемпотентно: пропускает business_key,
который уже существует.

Bundle:
- 5 satellite_type (SAR_X_HIRES, OPT_HIRES_VIS, OPT_MIDRES_MS, HYPER, METEO_GEO)
- 4 spacecraft (Terra, Sentinel-1A, Sentinel-2A, Кондор-ФКА №2)
- 3 ground_station (Отрадное, SvalSat, Байконур)

Все с локализованными name/description (ru-RU + en-US), валидируемые
JSON Schema на pre-save. Outbox events RecordCreated отправляются для
projection-writer.
2026-05-03 23:31:51 +03:00
Александр Зимин 519c397a29 fix(projection-writer): add spring-boot-starter-web for actuator HTTP server
Without spring-boot-starter-web, Spring Boot не запускает Tomcat, и
actuator endpoints (/health, /metrics) недоступны. Probes в чарте не
могут пройти. Добавляем web starter — Tomcat поднимется на server.port
(8082), actuator/health/{liveness,readiness} ответят, probes работают.
2026-05-03 23:13:47 +03:00
Александр Зимин 5ed360a979 fix: misc tech debt
- ci: resolve UPSTREAM_IMAGE_TAG via dotenv artifact (resolve-tag job)
  GitLab quirk — $IMAGE_TAG в trigger.variables не интерполируется
  напрямую от upstream global vars; dotenv делает значение job-local,
  тогда interpolation работает.
- projection-writer: management.server.port=8082 + web-application-type=none
  Возврат actuator/health probes — Spring Boot non-web app не имеет
  HTTP сервера; вынесли actuator на отдельный management server.
- docker-compose.yml: локальный dev-стек (PG + Kafka + Redis + 3 services)
  через repo.nstart.cloud/terravault images. Включается профилем 'dev'.
2026-05-03 23:06:24 +03:00
Александр Зимин b5b5bc512b fix(otel): use OTLP HTTP/protobuf to tempo:4318 instead of gRPC:4317
Stack trace showed io.opentelemetry.exporter.internal.http.HttpExporter
sending to :4317 — Tempo on 4317 expects gRPC frames, gets HTTP, sends
RST. Switch endpoint to :4318 (Tempo OTLP HTTP receiver) and protocol to
http/protobuf so the SDK and Tempo speak the same protocol.

The gRPC config never took effect — Spring Boot SDK on micrometer-tracing-
otlp pulls in only the HTTP exporter by default; gRPC needs the explicit
opentelemetry-exporter-otlp jar.
2026-05-03 22:19:32 +03:00
Александр Зимин 075a3c7705 ci: use corp docker-cli-buildx image and dind 29.1.2
Switch to the working pattern provided by infra team:
- image: repo.nstart.cloud/nstart/docker-cli-buildx:1.0.0 (corp-built
  client with proper Harbor credentials baked in / runner-friendly)
- service: docker:29.1.2-dind (matches runner-tested version)
- explicit --tls=false + DOCKER_HOST=tcp://docker:2375 + DOCKER_TLS_CERTDIR=''
The earlier docker:26 + custom TLS attempts hit shared-volume issues
specific to this runner setup.
2026-05-03 22:08:30 +03:00
Александр Зимин 4a3c72e940 ci: use 'extends:' instead of YAML <<: anchor merge
GitLab CI YAML <<: doesn't deep-merge — per-job 'variables:' overwrites
the anchor's variables block, so DOCKER_HOST/DOCKER_TLS_CERTDIR were
lost and the docker client fell back to default tcp://docker:2375 (no
TLS). 'extends:' merges variables key-by-key, so per-job SVC/DOCKERFILE
add to the base instead of replacing it.
2026-05-03 22:00:29 +03:00
Александр Зимин c2ce25094b ci: switch DinD to TLS mode (standard GitLab pattern)
Non-TLS DinD on tcp://docker:2375 had a race where docker login succeeded
but the next command got 'Cannot connect to the Docker daemon'. Switch
to the documented TLS pattern with DOCKER_TLS_CERTDIR=/certs and
DOCKER_HOST=tcp://docker:2376 — both client and DinD share /certs via
the implicit volume.
2026-05-03 21:54:55 +03:00
Александр Зимин 2fb39f7ac5 ci: drop INTERNAL_REGISTRY, push only to repo.nstart.cloud
Single registry for all clusters — staging (161), prod (142), local dev.
- Removed registry.k8s.265.nstart.local (no longer needed)
- Removed --insecure-registry flag from DinD service
- Wait-loop for DinD readiness before docker login (fixes race that
  caused 'Cannot connect to the Docker daemon' on first build command)
- Inlined docker login into the publish_script (kept tied to the build,
  not a separate before_script that ran before DinD was ready)
2026-05-03 21:51:17 +03:00
Александр Зимин 26f2207c74 ci: dual-push images to repo.nstart.cloud (corp Harbor)
Prod cluster (192.168.100.142) cannot reach the local staging registry
registry.k8s.265.nstart.local. The corp Harbor at repo.nstart.cloud is
the shared image source — push there in addition to the staging-internal
registry so both clusters can pull.

- Added EXTERNAL_REGISTRY=repo.nstart.cloud
- before_script does docker login $EXTERNAL_REGISTRY (REGISTRY_USER /
  REGISTRY_PASSWORD already set as group-level CI variables)
- Refactored 4 nearly identical docker-* jobs into a shared
  .dual_push_script anchor; per-job vars (SVC, DOCKERFILE, BUILD_CONTEXT)
- Build once with both registry tags, push to both
2026-05-03 21:45:06 +03:00
Александр Зимин 82dd48ba52 fix(migrations): scope check constraint must accept UPPERCASE values
JPA persists enum DataScope as @Enumerated(EnumType.STRING) by default,
which writes the enum name verbatim — UPPERCASE. The original CHECK
constraint allowed only lowercase ('public','internal','restricted'),
making every insert violate it.

Drop and recreate constraint with UPPERCASE values to match enum names.
Applies to dictionary_definitions.scope and dictionary_records.data_scope.
2026-05-03 20:45:57 +03:00
Александр Зимин 0cabd81abf fix(migrations): add version column for JPA optimistic locking
DictionaryDefinition and DictionaryRecord entities have @Version (BIGINT)
fields for optimistic locking, but the initial migrations did not create
the column. App fails with 'column dd1_0.version does not exist' on first
query.

Add 0007-add-version-columns.xml with idempotent addColumn changesets
(preConditions guard against re-add).
2026-05-03 20:38:43 +03:00
Александр Зимин 1c4e8e5be2 fix(app): switch hibernate ddl-auto to none in k8s profile
Hibernate validate mode threw 'wrong column type [supported_locales]:
found [_text (Types#ARRAY)] but expecting [text[] (Types#OTHER)]' against
the Liquibase-managed schema. This is a well-known hypersistence-utils +
PostgreSQL TEXT[] friction with Hibernate validate.

Schema is owned by Liquibase migrations; validate adds no safety here. Set
to 'none' (matches read-api and projection-writer).
2026-05-03 20:33:21 +03:00
Александр Зимин d49dd6be06 fix(k8s): add datasource/kafka/redis config to k8s profile
K8s profile relied on shared/dev defaults for spring.datasource.url, but those
live inside the dev profile block — invisible when k8s profile is active.
Result: 'APPLICATION FAILED TO START — Failed to configure a DataSource'.

Move full datasource (and kafka/redis for projection-writer) wiring into the
k8s profile section. ENV vars come from Vault Agent injector
(ORDINIS_PG_USER/PASSWORD, ORDINIS_KAFKA_USER/PASSWORD, ORDINIS_REDIS_PASSWORD)
and chart-supplied ENV (ORDINIS_PG_HOST, ORDINIS_KAFKA_BOOTSTRAP, etc).
2026-05-03 20:24:53 +03:00
Александр Зимин 413bd3449d chore: update default config-server fallback to ordinis-staging
application.yml fallback URL was http://config-server.ordinis-dev:8888 — namespace
removed. Default now points to staging; SPRING_CLOUD_CONFIG_URI env still overrides
via Helm chart for any deployment.
2026-05-03 20:11:51 +03:00
Zimin A.N. e537888554 ci: trigger-deploy-staging вместо -dev (dev перешёл в docker-compose) 2026-05-03 19:05:26 +03:00
Zimin A.N. 5b686abaa8 ci: --insecure-registry для DinD push в registry.k8s.265.nstart.local
Internal registry имеет cert валидный для ingress.local, не для своего DNS name.
Push HTTP-only через insecure-registry flag.
2026-05-03 18:44:55 +03:00
Zimin A.N. 8eb0ec086e ci: pull base images через repo.nstart.cloud (DH official → /library/, vendor → /<vendor>/) 2026-05-03 18:40:29 +03:00
Zimin A.N. fe403f2dbe ci: default retry до 2 раз на mirror failures 2026-05-03 18:19:58 +03:00
Zimin A.N. 00c905401f ci: pull_policy if-not-present (mirror flaky на always) 2026-05-03 18:18:02 +03:00
Zimin A.N. 9a79b53eb0 ci: align с altum pattern — runner mirror'ит Docker Hub
- Убраны префиксы repo.nstart.cloud/ — runner config (DOCKER_AUTH_CONFIG)
  обрабатывает pull из Docker Hub mirror автоматически
- DinD сервис: alias=docker, --tls=false, DOCKER_HOST=tcp://docker:2375
  (proven pattern из altum/.gitlab/ci/docker.yml)
- IMAGE_TAG = ref-slug + sha (как у altum)
- BUILDKIT_INLINE_CACHE=1 для cache-from optimization
- Dockerfiles: FROM maven/eclipse-temurin/liquibase без префиксов
2026-05-03 18:16:21 +03:00
Zimin A.N. 7c06cf7f71 ci: pull images через repo.nstart.cloud (corp Docker Hub mirror)
Runners в кластере не имеют network access к Docker Hub.
Заменили все base images:
- maven:3.9.9-eclipse-temurin-21 → repo.nstart.cloud/maven:...
- eclipse-temurin:21-jre → repo.nstart.cloud/eclipse-temurin:21-jre
- liquibase/liquibase:4.27.0 → repo.nstart.cloud/liquibase/...
- docker:27 / docker:27-dind → repo.nstart.cloud/docker:...
2026-05-03 18:02:46 +03:00
Zimin A.N. aa26724661 fix(migrations): preConditions ДО comment в changelog (XSD), relative include paths
- 0001-extensions.xml: переставил <preConditions> перед <comment> (XSD требует
  preConditions первым в changeSet)
- master.xml: include paths сделаны relative к самому master.xml (changes/...
  вместо db/changelog/changes/...) — CWD inside Docker image это /liquibase,
  Liquibase searchPath = /liquibase/changelog где master.xml лежит, includes
  должны идти от него

CI: insecure-registry: registry.k8s.265.nstart.local (HTTP без auth для
internal cluster), REGISTRY_USER/PASSWORD optional (skip docker login если
не заданы)
2026-05-03 17:54:30 +03:00
Zimin A.N. bc65728d1b feat(ci): GitLab CI pipeline для build + push images
stages:
- test: mvn verify (unit + integration tests когда появятся)
- build: mvn package для всех 4 jar'ов с кэшированием Maven repo
- publish: docker build + push 4 images в registry.k8s.265.nstart.local
  с тегами {short-sha, branch-slug}. Параллельно через 4 jobs.
- trigger-deploy: запускает downstream pipeline в terravault/ordinis-infra
  с UPSTREAM_IMAGE_TAG, branch=main → dev (auto), tag=v*-rc → staging (auto),
  tag=v* (final) → prod (manual approve)

Required GitLab CI variables:
  REGISTRY_USER, REGISTRY_PASSWORD — для docker login в private registry
2026-05-03 16:12:30 +03:00
Zimin A.N. 4723af5bca feat(docker): Dockerfile для read-api и projection-writer
Multi-stage build (Maven 3.9.9 + Eclipse Temurin 21 → eclipse-temurin:21-jre):
- ordinis-read-api/Dockerfile (port 8081)
- ordinis-projection-writer/Dockerfile (port 8082)

JVM args: -XX:+UseZGC -XX:MaxRAMPercentage=75 (low-pause GC + container-aware
memory). MaxRAMPercentage важен потому что Helm задаёт container memory
limit через resources.limits.memory; JVM должна respect'ить это.

Helm chart (terravault/ordinis-infra/charts/ordinis/) теперь имеет полный
набор images для deploy:
  registry.k8s.265.nstart.local/terravault/ordinis-app:<tag>
  registry.k8s.265.nstart.local/terravault/ordinis-read-api:<tag>
  registry.k8s.265.nstart.local/terravault/ordinis-projection-writer:<tag>
  registry.k8s.265.nstart.local/terravault/ordinis-migrations:<tag>
2026-05-03 16:10:21 +03:00
Zimin A.N. 6a6067e70b feat(projection-writer): Kafka consumer + Redis materialization per-locale
ordinis-projection-writer полностью реализован, last module:
- OrdinisProjectionWriterApplication: Spring Boot main + JpaRepositories
- RedisKeys: convention 'ord:<bundle>:<dictionary>:<bk>:<locale>' для flat
  плюс ':_raw' для multi-locale, плюс 'ord:idx:<bundle>:<dictionary>' set index
- ProjectionWriter: для каждой supported locale справочника flatten data и
  записать в Redis. Также пишет _raw multi-locale ключ. На delete стирает
  все варианты + удаляет из index. Метрики ordinis_projection_records_*
- RecordEventListener: @KafkaListener на 3 scope-топика, group
  ordinis-projection-cuod. Парсит EventEnvelope, разбирает payload.type
  (RecordCreated/Updated/Deleted), вызывает upsert или delete.
- application.yml profile-based: dev (port-forward), k8s (Vault role
  ordinis-projection-writer)
- logback-spring.xml как у других сервисов

ordinis-events-api: добавлен явный 'type' accessor в каждом record
(@JsonProperty type). Без него Jackson не сериализовал discriminator
из @JsonTypeInfo (при generic T payload Jackson не знает declared type).

ordinis-infra: Redis StatefulSet (redis:7.4-alpine, AOF, maxmemory 256mb
LRU, secret password) в namespace ordinis-dev на :6379.

E2E (3 сервиса параллельно):
  POST /spacecraft/records {AQUA-1, name:{ru-RU:'Аква',en-US:'Aqua'}, ...}
  → outbox row id=7 published_at=true
  → ordinis_outbox_published_total +1
  → projection log: 'Projection upserted: spacecraft:AQUA-1 (2 locales)'
  → Redis ord:cuod:spacecraft:AQUA-1:ru-RU = {name:'Аква',...}
  → Redis ord:cuod:spacecraft:AQUA-1:en-US = {name:'Aqua',...}
  → Redis ord:cuod:spacecraft:AQUA-1:_raw  = {name:{ru-RU,en-US},...}
  → Redis ord:idx:cuod:spacecraft set member 'AQUA-1'

10 of 10 модулей реализованы. Last module done.
2026-05-03 15:59:40 +03:00
Zimin A.N. ae69a7642b feat(cuod-bundle): JSON Schema драфты для spacecraft/satellite_type/ground_station + auto-import
3 schema драфта в classpath:/bundles/cuod/:

- spacecraft.schema.json: КА с COSPAR designator, NORAD ID, multi-locale
  name/operator/mission, orbit params (type/semimajor/eccentricity/inclination/
  raan/aop/epoch), spectrum_bands (12 bands), launch/decay dates, status enum
- satellite_type.schema.json: классификация КА — code, multi-locale name/desc,
  mission_category enum (10 categories), mass_class, typical_orbit_classes,
  typical_resolution_m, typical_revisit_hours
- ground_station.schema.json: code, multi-locale name/location/operator,
  country (ISO 3166), lat/lon/elevation, antennas array (id, diameter,
  bands, polarizations, tx/rx_capable, G/T, EIRP), status

manifest.json: bundle 'cuod' v1.0.0, 3 dictionaries с supported_locales
[ru-RU, en-US] default ru-RU, scope PUBLIC

ordinis-cuod-bundle модуль:
- BundleManifest record + DictionaryEntry record (Jackson deserializer)
- CuodBundleProperties @ConfigurationProperties (auto-import flag,
  update-existing-schema flag, resource-root path)
- CuodBundleImporter @Service (proxified, @Transactional работает) — читает
  manifest + schemas, для каждого: findByName → если нет, создать definition
  + outbox event DefinitionCreated; если есть, проверить schema_version, при
  отличии и update_existing_schema=true — обновить + DefinitionUpdated outbox
- CuodBundleStartupRunner @Configuration с ApplicationRunner — триггерит
  importBundle при старте Spring Boot. Отделено от Importer чтобы Importer
  оставался proxified (@Transactional на importBundle vs lambda не работает)

ordinis-validation FIX:
- RecordValidator теперь учитывает required array родительской схемы.
  Отсутствие x-localized optional поля (например mission, operator) больше
  не считается ошибкой. Required x-localized поля по-прежнему обязательны.

E2E проверено:
- ORDINIS_BUNDLE_CUOD_AUTO_IMPORT=true → 3 справочника создались с правильными
  scope, locales, schema_version
- POST spacecraft с реальными Kanopus-V данными (orbit + spectrum_bands) → 201
- POST satellite_type с code='lower-case-bad' → 422 pattern error
- Idempotent: повторный старт пропускает existing без ошибок
2026-05-03 14:29:43 +03:00
Zimin A.N. 51fcc95818 feat(read-api): locale negotiation + scope filtering + multi-locale flatten
ordinis-read-api модуль полностью реализован:

- OrdinisReadApiApplication main + EnableJpaRepositories/EntityScan
- LocaleNegotiator: RFC 7231 / Locale.LanguageRange.parse + Locale.lookup
  по supported_locales справочника, fallback на default_locale,
  поддержка Accept-Language: * (raw mode)
- LocaleAwareJsonFlattener: walk schema_json.properties, для x-localized
  полей в data заменяет {locale:text} на string выбранной локали
  с fallback на default_locale; recursive walk для nested objects
- RecordReadService: read с scope filtering + locale negotiation +
  flatten, метрика ordinis_read_api_query_duration_seconds, scope
  access enforcement (ScopeAccessDeniedException)
- DictionaryReadController: GET /api/v1/{name}/records/{businessKey}
  и list, with @RequestHeader(Accept-Language), Content-Language
  response header, Vary: Accept-Language; query param as_scope для
  v1 (заменим на JWT scope claim позже)
- ReadApiExceptionHandler: 404 NoSuchElement, 403 ScopeAccessDenied,
  400 IllegalArgument, 500 fallback
- application.yml profile-based: dev (port-forward, ddl-auto=none —
  read-api не валидирует schema, это work writer/migrations), k8s
  (Vault role ordinis-read-api, hikari read-only=true)
- logback-spring.xml: MDC включает 'locale' для Loki query

E2E (port 8081 параллельно с writer на 8080):
- GET MOSCOW-1 без Accept-Language → name=Москва-1, Content-Language: ru-RU
- GET MOSCOW-1 с Accept-Language: en-US → name=Moscow-1, Content-Language: en-US
- GET MOSCOW-1 с Accept-Language: fr,en-US;q=0.8,ru;q=0.5 → выбрал en-US (fr нет в supported)
- GET MOSCOW-1 с Accept-Language: * → raw {ru-RU,en-US}, _meta.locale=null
- GET list → массив с flatten для каждой записи
- GET NONEXISTENT → 404 not_found
- GET с as_scope=INTERNAL для PUBLIC записи → 403 scope_access_denied
2026-05-03 13:10:07 +03:00
Zimin A.N. be77c30687 feat(validation,rest-api,outbox): write-side end-to-end working
ordinis-validation:
- ValidationError, ValidationResult, ValidationException
- RecordValidator: networknt JSON Schema (Draft-7) + custom rule для
  x-localized полей. Walk schema, для каждого x-localized:true property
  проверяет: значение это object с locale keys (BCP 47 pattern xx-XX),
  все ключи в supported_locales, default_locale обязательно присутствует,
  значения non-empty strings.

ordinis-outbox:
- KafkaTopicResolver: ordinis.<bundle>.events.<scope>
- OutboxRecorder: запись события в outbox в той же транзакции (MANDATORY
  propagation), с tracer для trace_id/span_id из MDC если OTel доступен
- OutboxPoller: @Scheduled, batch_size + poll_interval из config, метрики
  ordinis_outbox_published_total / publish_errors_total / kafka_publish_duration_seconds
- OutboxLagGauge: ordinis_outbox_pending_events (gauge для алертов)
- ConditionalOnProperty ordinis.outbox.enabled — отключаем в read-api/projection-writer

ordinis-rest-api:
- DTOs: CreateDictionaryRequest, DictionaryResponse, CreateRecordRequest,
  RecordResponse (с WKT serialization geometry)
- DictionaryDefinitionService: CRUD definitions, outbox events DefinitionCreated/Updated
- DictionaryRecordService: bitemporal write-side, SERIALIZABLE isolation,
  bound check FAR_FUTURE valid_to. Update = close current + create new
  (никогда in-place). Outbox event RecordCreated/Updated/Deleted.
- DictionaryDefinitionController: GET list/by-name, POST create, PUT update
- DictionaryRecordController: GET active/history, POST create, PUT update,
  DELETE (close)
- OrdinisException + ApiErrorResponse + GlobalExceptionHandler
  (422 validation, 404 not found, 409 conflict, 500 internal)

ordinis-app:
- @EnableJpaRepositories + @EntityScan для multi-package сканирования
- application.yml profile-based: dev (PG localhost через port-forward,
  ddl-auto=update, OTel disabled), k8s (Cloud Config + Vault Kubernetes auth)
- spring.kafka producer config с SASL SCRAM-SHA-512

ordinis-domain:
- JpaAuditingConfig: добавлен DateTimeProvider возвращающий OffsetDateTime
  (Spring Data Auditing по дефолту его не поддерживает, без него падает
  IllegalArgumentException 'Cannot convert LocalDateTime to OffsetDateTime')
- @ConditionalOnBean(DataSource) убран — race с lifecycle ломал auditing

E2E test (port-forward к cluster PG+Kafka):
- POST dictionary 'ground_station' → 201, JSONB schema сохранена,
  supported_locales = {ru-RU, en-US}, default_locale = ru-RU
- POST record MOSCOW-1 multi-locale name + geometry POINT(37.618 55.751) → 201
- POST record с en-US без ru-RU → 422 с двумя ошибками:
  * networknt 'required property ru-RU not found'
  * custom 'x-localized.missing_default Default locale ru-RU is required'
- outbox_events: 2 row (DefinitionCreated + RecordCreated), topic
  ordinis.cuod.events.public, ключи правильные
- revinfo + dictionary_records_aud: 1 row (Envers tx-time history активен)
2026-05-03 13:04:36 +03:00