Compare commits

...

7 Commits

Author SHA1 Message Date
456c1f022c feat(release-signing): add Ed25519 multi-key release signing contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 10:27:21 +03:00
34b457d654 Add git sync check contract 2026-03-01 22:34:30 +03:00
472c8a6918 Add no-hardcoded-vendors contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:21:13 +03:00
91a1cc182d Add identifier normalization contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:07:19 +03:00
af4d0f353b Add batch file upload ADR contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 18:22:42 +03:00
66c38f5a60 Add app binary contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:45:53 +03:00
e020c9b234 Add testing policy contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:33:52 +03:00
7 changed files with 643 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
# Contract: Application Binary
Version: 1.0
## Purpose
Правила сборки, упаковки ресурсов и первого запуска Go-приложений.
---
## Бинарник
Бинарник самодостаточен — все ресурсы встроены через `//go:embed`:
- HTML-шаблоны
- Статика (JS, CSS, иконки)
- Шаблон конфиг-файла (`config.template.yaml`)
- Миграции БД
Никаких внешних папок рядом с бинарником не требуется для запуска.
---
## Конфиг-файл
Создаётся автоматически при первом запуске, если не существует.
### Расположение
| Режим приложения | Путь |
|---|---|
| Однопользовательское | `~/.config/<appname>/config.yaml` |
| Серверное / многопользовательское | `/etc/<appname>/config.yaml` или рядом с бинарником |
Приложение само определяет путь и создаёт директорию если её нет.
### Содержимое
Конфиг хранит:
- Настройки приложения (порт, язык, таймауты, feature flags)
- Параметры подключения к централизованной СУБД (host, port, user, password, dbname)
Конфиг **не хранит**:
- Данные пользователя
- Кеш или состояние
- Что-либо что относится к SQLite (см. ниже)
### Шаблон
Шаблон конфига встроен в бинарник. При создании файла шаблон копируется в целевой путь.
Шаблон содержит все ключи с комментариями и дефолтными значениями.
```yaml
# <appname> configuration
# Generated on first run. Edit as needed.
server:
port: 8080
database:
host: localhost
port: 5432
user: ""
password: ""
dbname: ""
# ... остальные настройки
```
---
## SQLite (однопользовательский режим)
Если приложение использует локальную SQLite:
- Файл хранится рядом с конфигом: `~/.config/<appname>/<appname>.db`
- Путь к файлу не выносится в конфиг — приложение вычисляет его из пути конфига
- SQLite **не хранит** параметры подключения к централизованной СУБД — только локальные данные приложения
---
## Первый запуск — алгоритм
```
Старт приложения
├── Конфиг существует? → Нет → создать директорию → скопировать шаблон → сообщить пользователю путь
│ → завершить с кодом 0
│ (пользователь заполняет конфиг)
└── Конфиг существует? → Да → валидировать → запустить приложение
```
При первом создании конфига приложение **не запускается** — выводит сообщение:
```
Config created: ~/.config/<appname>/config.yaml
Edit the file and restart the application.
```
---
## Сборка
Финальный бинарник собирается без CGO если это возможно (для SQLite — с CGO):
```
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/<appname> ./cmd/<appname>
```
С SQLite:
```
CGO_ENABLED=1 go build -ldflags="-s -w" -o bin/<appname> ./cmd/<appname>
```
Бинарник не зависит от рабочей директории запуска.

View File

@@ -0,0 +1,101 @@
# Contract: Batch File Upload
Version: 1.0
## Purpose
ADR: стратегия загрузки большого числа файлов через multipart-запросы
без переработки серверного pipeline и без скрытых лимитов.
---
## ADR
**Дата:** 2026-03-01
**Статус:** Accepted
### Контекст
Клиент должен загрузить список файлов на сервер для обработки.
Загрузка всех файлов одним multipart-запросом упирается в скрытые лимиты:
количество parts, размер тела запроса (413), таймауты соединения.
Переработка серверного pipeline под стриминговую загрузку — отдельная дорогостоящая задача.
### Решение
Клиент делит список файлов на батчи фиксированного размера и отправляет каждый батч
отдельным multipart-запросом.
- Размер батча определяется константой `MAX_FILES_PER_BATCH` (выбирается проектом).
- Батчи считаются по **числу файлов**, не только по байтам.
- Обработка батчей **последовательная** по умолчанию.
Параллельная обработка допускается только если явно оговорена и задокументирована.
- Каждый батч производит отдельный downloadable артефакт,
либо агрегируется на финальном шаге — решение принимается на уровне проекта.
### Последствия
**Плюсы:**
- Избегаем скрытых лимитов на количество multipart parts
- Снижаем риск таймаутов и ошибок 413
- Не требует немедленной переработки серверного parser pipeline
**Минусы:**
- Больше round-trips (N батчей = N запросов)
- Несколько выходных файлов если артефакты не агрегируются
- Более долгий end-to-end UX для пользователя
---
## Правила реализации
### Константа батча
```go
const MaxFilesPerBatch = 1000 // выбирается проектом
```
Константа объявляется явно — не хардкодится inline.
### Именование артефактов
Результаты батчей именуются с суффиксом `_partN`:
```
report_part1.csv
report_part2.csv
report_part3.csv
```
Или агрегируются в единый файл на финальном шаге — тогда суффикс не нужен.
### Обработка ошибок
- Ошибку превышения лимита parts (`multipart: too many parts`) **не маскировать** под ошибку размера.
- Логировать первичную причину с явным указанием типа лимита.
- Клиент при получении ошибки лимита должен уменьшить батч и повторить, либо сообщить пользователю.
```go
// Правильно
log.Error("batch upload failed: multipart parts limit exceeded", "batch", batchNum, "files", len(batch))
// Неправильно
log.Error("batch upload failed: file too large") // маскирует причину
```
### UI
UI обязан показывать пользователю:
- Общее количество батчей (проходов): `Шаг 2 из 5`
- Прогресс текущего батча: прогресс-бар или процент
- Финальный статус каждого батча до начала следующего
Пользователь не должен видеть технический термин "батч" — использовать "шаг" или "проход".
---
## Связанные контракты
- `go-background-tasks` — каждый батч запускается как фоновая задача
- `import-export` — именование и доставка артефактов

View File

@@ -0,0 +1,22 @@
# Git Sync Check
## Rule
Before starting any work on a task, check whether the remote repository has commits that are not yet present locally.
## Required Steps
1. Run `git fetch` to update remote-tracking refs without merging.
2. Check for upstream commits: `git log HEAD..@{u} --oneline`.
3. If the output is non-empty (there are new remote commits):
- **Stop immediately. Do not make any changes.**
- Inform the user that the remote has new commits and ask how to proceed (e.g., pull, rebase, or ignore).
4. If the output is empty, proceed with the task normally.
## Rationale
Working on an outdated local state risks merge conflicts, duplicate work, and overwriting changes made by other contributors. Checking remote state first keeps the working tree aligned and prevents avoidable conflicts.
## Exceptions
- Offline environments where `git fetch` is not possible: notify the user that the check could not be performed before proceeding.

View File

@@ -0,0 +1,93 @@
# Contract: Identifier Normalization
Version: 1.0
## Purpose
Правила хранения и сравнения идентификаторов оборудования:
серийные номера, вендоры, версии прошивок, партномера, артикулы.
---
## Правило
Оригинальное значение **сохраняется как пришло** — регистр не меняется.
Все сравнения, поиск и дедупликация выполняются **без учёта регистра**.
```
Пришло: "SN-001-ABC" → хранится: "SN-001-ABC"
Пришло: "sn-001-abc" → это тот же объект, не дубликат
Пришло: "Sn-001-Abc" → то же самое
```
---
## Применяется к полям
- Серийный номер (`serial_number`, `serial`)
- Вендор / производитель (`vendor`, `manufacturer`)
- Версия прошивки (`firmware_version`, `fw_version`)
- Партномер (`part_number`, `part_no`)
- Артикул (`article`, `sku`)
---
## Реализация
### Go — сравнение
```go
import "strings"
func SameIdentifier(a, b string) bool {
return strings.EqualFold(a, b)
}
```
### Go — дедупликация
```go
func deduplicateBySerial(items []Device) []Device {
seen := make(map[string]struct{})
result := items[:0]
for _, item := range items {
key := strings.ToLower(item.SerialNumber)
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
result = append(result, item)
}
}
return result
}
```
Ключ в map — всегда `strings.ToLower(value)`. Сам объект сохраняется с оригинальным значением.
### SQL — поиск и уникальность
Поиск:
```sql
SELECT * FROM devices WHERE LOWER(serial_number) = LOWER(?);
```
Уникальный индекс (MySQL / MariaDB):
```sql
-- Collation ci обеспечивает case-insensitive уникальность
ALTER TABLE devices MODIFY serial_number VARCHAR(255)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE devices ADD UNIQUE INDEX uniq_serial (serial_number);
```
SQLite:
```sql
CREATE UNIQUE INDEX uniq_serial ON devices (LOWER(serial_number));
```
---
## Что не делать
- Не приводить значение к нижнему или верхнему регистру перед сохранением.
- Не считать `"SN-001"` и `"sn-001"` разными объектами.
- Не использовать `==` для сравнения идентификаторов в Go — только `strings.EqualFold`.

View File

@@ -0,0 +1,79 @@
# Contract: No Hardcoded Vendors or Models
Version: 1.0
## Purpose
Запрет на хардкод названий вендоров, моделей и партномеров в коде.
---
## Правило
Названия вендоров, моделей, серий оборудования и партномеров **не появляются в коде**.
Они приходят из данных: БД, конфига, входного документа, справочника.
---
## Что запрещено
```go
// Запрещено
if device.Vendor == "Dell" { ... }
if strings.Contains(model, "PowerEdge") { ... }
switch vendor {
case "HP", "HPE", "Hewlett Packard": ...
}
```
```go
// Запрещено — список вендоров в коде
var knownVendors = []string{"Dell", "HP", "Cisco", "Lenovo"}
```
---
## Что делать вместо
Логика определяется по полям из данных, не по названию вендора:
```go
// Правильно — смотрим на возможности объекта, не на имя вендора
if device.HasIPMI { ... }
if device.ParserType == "redfish" { ... }
```
Если нужен маппинг — он живёт в конфиге или справочной таблице БД, не в коде:
```yaml
# config.yaml
vendor_parsers:
dell: redfish
hp: ilo
cisco: ucs
```
```sql
-- справочник в БД
SELECT parser_type FROM vendor_registry WHERE LOWER(vendor) = LOWER(?);
```
---
## Исключения
Допускается упоминание вендора **только** в:
- Названиях пакетов/директорий парсеров: `internal/parser/vendors/dell/`
- Комментариях и документации
- Тестовых фикстурах (XML/JSON с реальными данными оборудования)
В этих местах название вендора — идентификатор модуля, не условие в логике.
---
## Почему
Хардкод вендора делает код хрупким: новый вендор требует правок в коде, а не в данных.
Опечатка в строке (`"HPE"` vs `"HP"`) создаёт незаметный баг.
Регистр не контролируется (см. `identifier-normalization` contract).

View File

@@ -0,0 +1,149 @@
# Contract: Release Signing
Version: 1.0
## Purpose
Ed25519 asymmetric signing for Go release binaries.
Guarantees that a binary accepted by a running application was produced by a trusted developer.
Applies to any Go binary that is distributed or supports self-update.
---
## Key Management
Public keys are stored in the centralized keys repository: `git.mchus.pro/mchus/keys`
```
keys/
developers/
<name>.pub ← raw Ed25519 public key, base64-encoded, one line per developer
scripts/
keygen.sh ← generates keypair
sign-release.sh ← signs a binary
verify-signature.sh ← verifies locally
```
Public keys are safe to commit. Private keys stay on each developer's machine — never committed, never shared.
**Adding a developer:** add their `.pub` file → commit → rebuild affected releases.
**Removing a developer:** delete their `.pub` file → commit → rebuild releases.
Previously signed binaries with their key remain valid (already distributed), but they cannot sign new releases.
---
## Multi-Key Trust Model
A binary is accepted if its signature verifies against **any** of the embedded trusted public keys.
This mirrors the SSH `authorized_keys` model.
- One developer signs a release with their private key → produces one `.sig` file.
- The binary trusts all active developers — any of them can make a valid release.
- Signature format: raw 64-byte Ed25519 signature (not PEM, not armored).
---
## Embedding Keys at Build Time
Public keys are injected via `-ldflags` at release build time — not hardcoded at compile time.
This allows adding/removing developers without changing application source code.
```go
// internal/updater/trust.go
// trustedKeysRaw is injected at build time via -ldflags.
// Format: base64(key1):base64(key2):...
// Empty string = dev build, updates disabled.
var trustedKeysRaw string
func trustedKeys() ([]ed25519.PublicKey, error) {
if trustedKeysRaw == "" {
return nil, fmt.Errorf("dev build: trusted keys not embedded, updates disabled")
}
var keys []ed25519.PublicKey
for _, enc := range strings.Split(trustedKeysRaw, ":") {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
if err != nil || len(b) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid trusted key: %w", err)
}
keys = append(keys, ed25519.PublicKey(b))
}
return keys, nil
}
```
Release build script injects all current developer keys:
```sh
# scripts/build-release.sh
KEYS=$(paste -sd: /path/to/keys/developers/*.pub)
go build \
-ldflags "-s -w -X <module>/internal/updater.trustedKeysRaw=${KEYS}" \
-o dist/<binary>-linux-amd64 \
./cmd/<binary>
```
Dev build (no `-ldflags` injection): `trustedKeysRaw` is empty → updates disabled, binary works normally.
---
## Signature Verification (stdlib only, no external tools)
Use `crypto/ed25519` from Go standard library. No third-party dependencies.
```go
// internal/updater/trust.go
func verifySignature(binaryPath, sigPath string) error {
keys, err := trustedKeys()
if err != nil {
return err // dev build or misconfiguration
}
data, err := os.ReadFile(binaryPath)
if err != nil {
return fmt.Errorf("read binary: %w", err)
}
sig, err := os.ReadFile(sigPath)
if err != nil {
return fmt.Errorf("read signature: %w", err)
}
for _, key := range keys {
if ed25519.Verify(key, data, sig) {
return nil // any trusted key accepts → pass
}
}
return fmt.Errorf("signature verification failed: no trusted key matched")
}
```
Rejection behavior: log as WARNING, continue with current binary. Never crash, never block operation.
---
## Release Asset Convention
Every release must attach two files to the Gitea release:
```
<binary>-linux-amd64 ← the binary
<binary>-linux-amd64.sig ← raw 64-byte Ed25519 signature
```
Signing:
```sh
sh keys/scripts/sign-release.sh <developer-name> dist/<binary>-linux-amd64
```
Both files are uploaded to the Gitea release as downloadable assets.
---
## Rules
- Never hardcode public keys as string literals in source code — always use ldflags injection.
- Never commit private keys (`.key` files) anywhere.
- A binary built without ldflags injection must work normally — it just cannot perform verified updates.
- Signature verification failure must be a silent logged warning, not a crash or user-visible error.
- Use `crypto/ed25519` (stdlib) only — no external signing libraries.
- `.sig` file contains raw 64 bytes (not base64, not PEM). Produced by `openssl pkeyutl -sign -rawin`.

View File

@@ -0,0 +1,82 @@
# Contract: Testing Policy
Version: 1.0
## Purpose
Определяет когда писать тесты, когда не писать, и как их поддерживать.
Применяется ко всем проектам на Go. Агенты следуют этим правилам самостоятельно, без запроса подтверждения.
---
## Когда тест обязателен
Тест пишется всегда, когда код делает нетривиальное преобразование данных или реализует бизнес-логику:
- **Парсеры** — любой код, читающий внешний формат (XML, JSON, CSV, бинарный)
- **Трансформации** — конвертация единиц, нормализация, маппинг полей
- **Бизнес-правила** — расчёты, фильтрация, агрегация, приоритизация
- **Граничные случаи** — пустой ввод, нулевые значения, переполнение, отсутствующие поля
- **Регрессии** — если баг был найден, тест фиксирует его до исправления
Тест пишется в том же коммите, что и функциональность. Функциональность без теста (там где он обязателен) — неполный коммит.
---
## Когда тест не нужен
Тест не пишется на код, где он не даёт ценности:
- Геттеры и сеттеры: `func (s *Server) Port() int { return s.port }`
- Конфиг-структуры и константы
- Тривиальный клей: передача параметров, инициализация, dependency wiring
- Логирование и форматирование вывода
- HTTP-хендлеры без бизнес-логики (только роутинг и вызов сервиса)
---
## Структура теста
Использовать стандартный Go `testing`. Табличные тесты (`[]struct{ ... }`) — когда случаев больше двух.
```go
func TestParseGPUSensor(t *testing.T) {
tests := []struct {
name string
xml string
want int
}{
{"normal", `<VALUE>290</VALUE>`, 29},
{"zero", `<VALUE>0</VALUE>`, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseGPUTemp(tt.xml)
if got != tt.want {
t.Fatalf("got %d, want %d", got, tt.want)
}
})
}
}
```
Фикстуры (XML, JSON, бинарные данные) — инлайн-константы или файлы в `testdata/`.
Не использовать реальные сетевые вызовы и реальную БД в юнит-тестах.
---
## Мейнтейнс
- Сломанный тест — чинится или удаляется в том же коммите где сломался.
- Закомментированный тест — не допускается. Если тест неактуален — удалить.
- Тест, проверяющий удалённую функциональность — удалить вместе с функциональностью.
---
## Инструкция для агентов (Codex, Claude)
1. При добавлении функциональности — проверь по списку выше, попадает ли код в категорию "обязателен".
2. Если да — напиши тест в том же коммите, без запроса подтверждения.
3. Если нет — не пиши тест, не упоминай его отсутствие.
4. При удалении функциональности — удали соответствующие тесты.
5. При обнаружении закомментированных или сломанных тестов — удали или почини.