feat(bundle): bundle hygiene v2 — manifest spec + signing verifier + docs

Ships marketplace-design-v2 scoped to bundle hygiene (NO consumer UI, NO
install dialog, NO marketplace). 2-3 day cap respected — Approach C narrow
slice. Anti-feature commitment: scope creep → new design doc + /office-hours.

## What ships

### New module ordinis-bundle-spec
- BundleManifest.java — canonical apiVersion: ordinis.io/v1 record
  (Metadata + DictionaryEntry, Jackson-bindable, forward-compat ignoreUnknown)
- ManifestParser.java — YAML → record + structural validate
  (apiVersion/kind enforce, semver regex, bundle-id regex, path traversal
  guard на schemaRef, unique dict names, scope enum)
- BundleSignatureVerifier.java — ed25519 sign/verify (JDK 21 native)
- Zero Spring/JPA/Kafka deps — pure Java + Jackson YAML

### CUOD reference implementation
- ordinis-cuod-bundle/manifest.yaml — 40 dicts canonical entries
- Test fixture pin: cuod-manifest-fixture.yaml parses+validates clean
  (regression guard на каждом CI run)

### Tests
- 12 cases ManifestParser (happy + reject paths)
- 7 cases BundleSignatureVerifier (sign/verify roundtrip, tamper, wrong key)
- 1 case CUOD fixture pin
- Total 20 tests, all green

### Docs
- docs/integration/bundle-format-spec.md — formal spec для Java handoff
- docs/user-guide/bundle-authoring.md — how-to для новых bundle authors

## Why now (N=1 customer)

Per office-hours v2 decision: marketplace UI build = N customers value scale,
but bundle hygiene + sign format = AT N=1 ROI:
- Java team handoff requires documented spec независимо
- Sales demo «here's how customer #2 would ship custom bundle»
- Optionality для marketplace UI когда customer #2 LOI surfaces

## Explicitly NOT shipped (Phase 2 scope if ever)

- Publisher Maven plugin (separate artifact when actual publish needed)
- Browse catalog UI / install dialog (no customer #2 with use case)
- Dep resolution / topological install
- Multi-tenant Nexus namespacing
- Bundle uninstall flow
This commit is contained in:
Zimin A.N.
2026-05-17 10:39:07 +03:00
parent f93751629b
commit 8adfd39bff
12 changed files with 1302 additions and 0 deletions
@@ -0,0 +1,86 @@
package cloud.nstart.terravault.ordinis.bundle.spec;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import static org.assertj.core.api.Assertions.assertThat;
class BundleSignatureVerifierTest {
/** Generate ephemeral ed25519 keypair → sign → verify round-trip. */
@Test
void roundtrip_sign_then_verify_ok() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
kpg.initialize(255, new SecureRandom());
KeyPair kp = kpg.generateKeyPair();
byte[] archive = "fake bundle bytes for testing".getBytes();
String signature = BundleSignatureVerifier.sign(archive, kp.getPrivate().getEncoded());
assertThat(signature).isNotNull();
assertThat(signature).startsWith("ed25519:");
boolean valid = BundleSignatureVerifier.verify(
archive, signature, kp.getPublic().getEncoded());
assertThat(valid).isTrue();
}
@Test
void verify_fails_on_tampered_archive() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
byte[] archive = "original".getBytes();
String sig = BundleSignatureVerifier.sign(archive, kp.getPrivate().getEncoded());
boolean valid = BundleSignatureVerifier.verify(
"tampered".getBytes(), sig, kp.getPublic().getEncoded());
assertThat(valid).isFalse();
}
@Test
void verify_fails_with_wrong_pubkey() throws Exception {
KeyPair signing = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
byte[] archive = "test".getBytes();
String sig = BundleSignatureVerifier.sign(archive, signing.getPrivate().getEncoded());
boolean valid = BundleSignatureVerifier.verify(
archive, sig, other.getPublic().getEncoded());
assertThat(valid).isFalse();
}
@Test
void verify_rejects_missing_prefix() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
byte[] archive = "test".getBytes();
// Sign а потом strip prefix чтобы simulate wrong format
String sig = BundleSignatureVerifier.sign(archive, kp.getPrivate().getEncoded());
String malformed = sig.substring("ed25519:".length());
assertThat(BundleSignatureVerifier.verify(archive, malformed, kp.getPublic().getEncoded()))
.isFalse();
}
@Test
void verify_rejects_invalid_base64() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
assertThat(BundleSignatureVerifier.verify(
"test".getBytes(), "ed25519:!!! not base64 !!!", kp.getPublic().getEncoded()))
.isFalse();
}
@Test
void verify_rejects_null_inputs() {
assertThat(BundleSignatureVerifier.verify(null, "ed25519:abc", new byte[]{})).isFalse();
assertThat(BundleSignatureVerifier.verify(new byte[]{}, null, new byte[]{})).isFalse();
assertThat(BundleSignatureVerifier.verify(new byte[]{}, "ed25519:abc", null)).isFalse();
}
@Test
void sign_returns_null_on_invalid_key() {
assertThat(BundleSignatureVerifier.sign("test".getBytes(), "not a real key".getBytes()))
.isNull();
}
}
@@ -0,0 +1,33 @@
package cloud.nstart.terravault.ordinis.bundle.spec;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Pin test: the actual CUOD bundle manifest must parse и validate clean.
* Fixture file копируется из {@code ordinis-cuod-bundle/manifest.yaml} (build
* step pre-commit, см. README). Если manifest добавляет invalid entry — этот
* test fail'нет на CI.
*
* <p>Подтверждает что spec самодостаточен для production CUOD bundle (40 dicts).
*/
class CuodManifestFixtureTest {
@Test
void cuod_fixture_parses_and_validates_clean() throws Exception {
try (InputStream is = getClass().getClassLoader()
.getResourceAsStream("cuod-manifest-fixture.yaml")) {
assertThat(is).as("cuod-manifest-fixture.yaml must exist в test resources").isNotNull();
BundleManifest m = ManifestParser.parse(is);
assertThat(m.apiVersion()).isEqualTo("ordinis.io/v1");
assertThat(m.metadata().id()).isEqualTo("ru.cuod.dzz-ground-segment");
assertThat(m.dictionaries()).hasSizeGreaterThanOrEqualTo(40);
List<String> errors = ManifestParser.validate(m);
assertThat(errors).as("validation errors").isEmpty();
}
}
}
@@ -0,0 +1,127 @@
package cloud.nstart.terravault.ordinis.bundle.spec;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ManifestParserTest {
private static final String VALID_MANIFEST = """
apiVersion: ordinis.io/v1
kind: Bundle
metadata:
id: ru.cuod.dzz-ground-segment
name: ЦУОД ДЗЗ — наземный сегмент
version: 1.0.0
domain: dzz
scope: PUBLIC
author: ЦУОД team
license: proprietary
dictionaries:
- name: spacecraft
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: dictionaries/spacecraft/schema.json
- name: satellite_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: dictionaries/satellite_type/schema.json
""";
@Test
void parses_canonical_manifest() throws Exception {
var m = ManifestParser.parseString(VALID_MANIFEST);
assertThat(m.apiVersion()).isEqualTo("ordinis.io/v1");
assertThat(m.kind()).isEqualTo("Bundle");
assertThat(m.metadata().id()).isEqualTo("ru.cuod.dzz-ground-segment");
assertThat(m.metadata().version()).isEqualTo("1.0.0");
assertThat(m.dictionaries()).hasSize(2);
assertThat(m.dictionaries().get(0).name()).isEqualTo("spacecraft");
}
@Test
void validate_clean_manifest_returns_no_errors() throws Exception {
var m = ManifestParser.parseString(VALID_MANIFEST);
assertThat(ManifestParser.validate(m)).isEmpty();
}
@Test
void rejects_wrong_apiVersion() throws Exception {
var bad = VALID_MANIFEST.replace("ordinis.io/v1", "ordinis.io/v0");
var errors = ManifestParser.parseAndValidate(bad.getBytes());
assertThat(errors).anyMatch(e -> e.contains("apiVersion"));
}
@Test
void rejects_bad_semver() throws Exception {
var bad = VALID_MANIFEST.replace("version: 1.0.0", "version: v1.0");
var errors = ManifestParser.parseAndValidate(bad.getBytes());
assertThat(errors).anyMatch(e -> e.contains("metadata.version"));
}
@Test
void rejects_path_traversal_in_schemaRef() throws Exception {
var bad = VALID_MANIFEST.replace(
"dictionaries/spacecraft/schema.json",
"../../../etc/passwd");
var errors = ManifestParser.parseAndValidate(bad.getBytes());
assertThat(errors).anyMatch(e -> e.contains("schemaRef"));
}
@Test
void rejects_duplicate_dict_name() throws Exception {
var bad = VALID_MANIFEST.replace("name: satellite_type", "name: spacecraft");
var errors = ManifestParser.parseAndValidate(bad.getBytes());
assertThat(errors).anyMatch(e -> e.contains("duplicate"));
}
@Test
void rejects_invalid_scope() throws Exception {
var bad = VALID_MANIFEST.replaceFirst("scope: PUBLIC", "scope: SUPER_SECRET");
var errors = ManifestParser.parseAndValidate(bad.getBytes());
assertThat(errors).anyMatch(e -> e.contains("scope"));
}
@Test
void rejects_invalid_bundle_id() throws Exception {
var bad = VALID_MANIFEST.replace("ru.cuod.dzz-ground-segment", "RU.CUOD!");
var errors = ManifestParser.parseAndValidate(bad.getBytes());
assertThat(errors).anyMatch(e -> e.contains("metadata.id"));
}
@Test
void rejects_signature_without_ed25519_prefix() throws Exception {
var withBadSig = VALID_MANIFEST + " signature: rsa:abc123\n";
var errors = ManifestParser.parseAndValidate(withBadSig.getBytes());
assertThat(errors).anyMatch(e -> e.contains("signature"));
}
@Test
void unknown_fields_ignored() throws Exception {
var withExtra = VALID_MANIFEST + "extraField: someValue\n";
var m = ManifestParser.parseString(withExtra);
assertThat(m.metadata().id()).isEqualTo("ru.cuod.dzz-ground-segment");
}
@Test
void empty_dictionaries_list_rejected() throws Exception {
var empty = """
apiVersion: ordinis.io/v1
kind: Bundle
metadata:
id: x.test
name: Test
version: 1.0.0
dictionaries: []
""";
var errors = ManifestParser.parseAndValidate(empty.getBytes());
assertThat(errors).anyMatch(e -> e.contains("dictionaries"));
}
@Test
void malformed_yaml_throws() {
assertThatThrownBy(() -> ManifestParser.parseString("not: valid: yaml: deeply: nested:bad:"))
.isInstanceOf(ManifestParser.ManifestException.class);
}
}
@@ -0,0 +1,181 @@
# Bundle manifest spec — apiVersion: ordinis.io/v1, kind: Bundle
# Canonical bundle authoring artifact. См. docs-internal/design/dictionary-marketplace.md
# Парсинг через cloud.nstart.terravault.ordinis.bundle.spec.ManifestParser.
apiVersion: ordinis.io/v1
kind: Bundle
metadata:
id: ru.cuod.dzz-ground-segment
name: ЦУОД ДЗЗ — наземный сегмент
version: 1.0.0
description: |
40 справочников для управления наземным сегментом ДЗЗ:
космические аппараты, типы КА, наземные станции, антенны,
частотные диапазоны, операторы, оборудование, миссии и т.д.
domain: dzz
scope: PUBLIC
author: ЦУОД team
license: proprietary
# signature заполняется ordinis-bundle-publisher CLI на publish step.
dictionaries:
- name: acquisition_mode
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/acquisition_mode.schema.json
- name: antenna
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/antenna.schema.json
- name: atmospheric_correction
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/atmospheric_correction.schema.json
- name: climatic_zone
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/climatic_zone.schema.json
- name: compression_algorithm
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/compression_algorithm.schema.json
- name: consumer
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/consumer.schema.json
- name: consumer_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/consumer_type.schema.json
- name: contract_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/contract_type.schema.json
- name: coordinate_system
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/coordinate_system.schema.json
- name: country
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/country.schema.json
- name: coverage_zone
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/coverage_zone.schema.json
- name: data_format
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/data_format.schema.json
- name: deliverable_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/deliverable_type.schema.json
- name: frequency_band
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/frequency_band.schema.json
- name: geometric_correction
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/geometric_correction.schema.json
- name: ground_station
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/ground_station.schema.json
- name: imaging_task_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/imaging_task_type.schema.json
- name: inclination_class
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/inclination_class.schema.json
- name: industry_sector
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/industry_sector.schema.json
- name: instrument
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/instrument.schema.json
- name: land_cover_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/land_cover_type.schema.json
- name: launch_site
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/launch_site.schema.json
- name: launch_vehicle
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/launch_vehicle.schema.json
- name: license_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/license_type.schema.json
- name: map_projection
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/map_projection.schema.json
- name: mission
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/mission.schema.json
- name: modulation_scheme
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/modulation_scheme.schema.json
- name: operator
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/operator.schema.json
- name: orbit_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/orbit_type.schema.json
- name: orbital_regime
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/orbital_regime.schema.json
- name: polarization
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/polarization.schema.json
- name: priority_class
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/priority_class.schema.json
- name: processing_level
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/processing_level.schema.json
- name: qa_flag
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/qa_flag.schema.json
- name: resolution_class
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/resolution_class.schema.json
- name: satellite_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/satellite_type.schema.json
- name: sensor_type
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/sensor_type.schema.json
- name: spacecraft
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/spacecraft.schema.json
- name: spacecraft_status
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/spacecraft_status.schema.json
- name: spectral_band
schemaVersion: 1.0.0
scope: PUBLIC
schemaRef: src/main/resources/bundles/cuod/spectral_band.schema.json