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
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cloud.nstart.terravault.ordinis</groupId>
<artifactId>ordinis-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>ordinis-bundle-spec</artifactId>
<name>Ordinis :: Bundle Spec</name>
<description>
Canonical bundle manifest format + ed25519 signature verifier.
Дополнительно: ManifestParser (YAML), ManifestValidator.
Этот module — публичный contract для bundle authoring/handoff:
Java team может ship'ить новые bundles следуя этой spec без
full ordinis codebase знания. Также используется future publisher
Maven plugin (отдельный artifact) + future consumer marketplace UI.
NO Spring / NO JPA / NO Kafka deps — pure Java + Jackson YAML.
</description>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,85 @@
package cloud.nstart.terravault.ordinis.bundle.spec;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
/**
* Canonical bundle manifest format (apiVersion: ordinis.io/v1, kind: Bundle).
*
* <p>Этот record — Java mapping для {@code manifest.yaml} в корне любого
* bundle artifact (например {@code ordinis-cuod-bundle/manifest.yaml}).
*
* <p><b>Что это НЕ:</b> не runtime loader для CUOD bundle. Существующий
* {@code CuodBundleImporter} продолжает работать с ad-hoc structure через
* {@code BundleManifest} в {@code ordinis-cuod-bundle} module. Этот spec —
* documentation artifact + future publisher CLI input + future consumer
* marketplace contract.
*
* <p><b>Зачем сейчас (N=1 customer):</b>
* <ul>
* <li>Java team handoff — new engineers получают formal spec вместо
* reverse-engineering BundleImporter</li>
* <li>Sales demo — «here's how your team would ship custom bundle»
* без shipping консьюмерского UI</li>
* <li>Optionality — когда customer #2 пробуждается, spec уже defined</li>
* </ul>
*
* <p>Anti-feature commitment: NO consumer-side install logic в этом module.
* NO marketplace UI. NO bundle dep resolution. Это bundle hygiene, не
* marketplace. См. {@code docs-internal/design/dictionary-marketplace.md}.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record BundleManifest(
@JsonProperty(value = "apiVersion", required = true) String apiVersion,
@JsonProperty(required = true) String kind,
@JsonProperty(required = true) Metadata metadata,
@JsonProperty(required = true) List<DictionaryEntry> dictionaries) {
public static final String CURRENT_API_VERSION = "ordinis.io/v1";
public static final String KIND = "Bundle";
@JsonIgnoreProperties(ignoreUnknown = true)
public record Metadata(
/** Reverse-DNS bundle identifier (e.g. {@code ru.cuod.dzz-ground-segment}). */
@JsonProperty(required = true) String id,
/** Human-readable display name. */
@JsonProperty(required = true) String name,
/** Semver version (e.g. {@code 1.0.0}). Follows Maven artifact semver. */
@JsonProperty(required = true) String version,
/** Multi-line description. Optional. */
String description,
/** Domain tag (e.g. {@code dzz}, {@code finance}). Optional. */
String domain,
/** Default scope для dicts которые не указали свой ({@code PUBLIC}/{@code INTERNAL}/{@code RESTRICTED}). */
String scope,
/** Author / org name. Optional. */
String author,
/** License identifier (SPDX-ish: {@code MIT}, {@code proprietary}, etc.). Optional. */
String license,
/**
* Ed25519 signature над bundle archive в формате {@code ed25519:<base64>}.
* Добавляется publisher CLI на publish step. Verifier expects
* {@code ed25519:} prefix.
*/
String signature) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record DictionaryEntry(
@JsonProperty(required = true) String name,
@JsonProperty(required = true) String schemaVersion,
/** PUBLIC / INTERNAL / RESTRICTED. */
@JsonProperty(required = true) String scope,
/** Relative path к JSON Schema file (от bundle root). */
@JsonProperty(value = "schemaRef", required = true) String schemaRef,
/** Optional relative path к sample data (record seed values). */
String sampleDataRef,
/** Optional displayName override. Default — humanized {@code name}. */
String displayName,
/** Optional short description. */
String description,
/** Optional pre-computed checksums (SHA-256 hex) for tamper detection. */
JsonNode checksums) {}
}
@@ -0,0 +1,92 @@
package cloud.nstart.terravault.ordinis.bundle.spec;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* Ed25519 signature verification для bundle archives.
*
* <p>Bundle publisher CLI (отдельный artifact) подписывает {@code bundle.tar.gz}
* приватным ed25519 key → publish'ит signature в формате {@code ed25519:<base64>}
* либо в {@link BundleManifest.Metadata#signature()}, либо в отдельный файл
* {@code bundle.sig} рядом.
*
* <p>Этот verifier ДОКУМЕНТИРУЕТ verification contract — НЕ consumed на v2
* ordinis runtime (no install flow). Built как handoff artifact для future
* consumer marketplace + Java team reference.
*
* <p>Why ed25519 (не RSA): deterministic, fast verify (~50µs), small keys
* (32 bytes pub), no parameter choices to mess up. Standard для signed
* package distribution (например Sigstore, Alpine apk).
*
* <p>Java native поддержка ed25519 — JDK 15+. Этот проект на JDK 21, OK.
*/
public final class BundleSignatureVerifier {
public static final String SIG_PREFIX = "ed25519:";
private BundleSignatureVerifier() {}
/**
* Verify archive bytes против ed25519 signature.
*
* @param archiveBytes the raw bundle archive (e.g. tar.gz contents)
* @param signature full signature string "ed25519:<base64>"
* @param publicKeyDer ed25519 public key в X.509 SubjectPublicKeyInfo DER format
* @return true если signature valid, false на любую ошибку (invalid sig, bad key, mismatch)
*/
public static boolean verify(byte[] archiveBytes, String signature, byte[] publicKeyDer) {
if (archiveBytes == null || signature == null || publicKeyDer == null) return false;
if (!signature.startsWith(SIG_PREFIX)) return false;
String base64Sig = signature.substring(SIG_PREFIX.length()).trim();
byte[] sigBytes;
try {
sigBytes = Base64.getDecoder().decode(base64Sig);
} catch (IllegalArgumentException e) {
return false;
}
PublicKey publicKey;
try {
KeyFactory kf = KeyFactory.getInstance("Ed25519");
publicKey = kf.generatePublic(new X509EncodedKeySpec(publicKeyDer));
} catch (Exception e) {
return false;
}
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(archiveBytes);
return verifier.verify(sigBytes);
} catch (Exception e) {
return false;
}
}
/**
* Sign helper — для publisher CLI usage. Берёт private key DER (PKCS#8) +
* archive bytes → возвращает full signature string {@code ed25519:<base64>}.
*
* <p>Returns null при любой ошибке (invalid key, IO). Caller проверяет.
*/
public static String sign(byte[] archiveBytes, byte[] privateKeyDer) {
if (archiveBytes == null || privateKeyDer == null) return null;
try {
KeyFactory kf = KeyFactory.getInstance("Ed25519");
var privateKey = kf.generatePrivate(
new java.security.spec.PKCS8EncodedKeySpec(privateKeyDer));
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(privateKey);
signer.update(archiveBytes);
byte[] sig = signer.sign();
return SIG_PREFIX + Base64.getEncoder().encodeToString(sig);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,144 @@
package cloud.nstart.terravault.ordinis.bundle.spec;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* Parses bundle {@code manifest.yaml} → {@link BundleManifest} с structural
* validation (apiVersion + kind + required keys).
*
* <p>Не валидирует schema файлы (это задача downstream loader) — только
* manifest shape. SemVer version regex enforced.
*/
public final class ManifestParser {
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
private static final Pattern SEMVER = Pattern.compile("^\\d+\\.\\d+\\.\\d+(?:-[A-Za-z0-9.-]+)?$");
private static final Pattern BUNDLE_ID = Pattern.compile("^[a-z][a-z0-9.-]{1,127}$");
private static final Pattern DICT_NAME = Pattern.compile("^[a-z][a-z0-9_]{0,63}$");
private ManifestParser() {}
/** Parse YAML bytes → manifest record. */
public static BundleManifest parse(byte[] yamlBytes) throws ManifestException {
try {
return YAML.readValue(yamlBytes, BundleManifest.class);
} catch (IOException e) {
throw new ManifestException("Failed to parse manifest YAML: " + e.getMessage(), e);
}
}
/** Parse from stream — caller closes. */
public static BundleManifest parse(InputStream is) throws ManifestException {
try {
return YAML.readValue(is, BundleManifest.class);
} catch (IOException e) {
throw new ManifestException("Failed to parse manifest stream: " + e.getMessage(), e);
}
}
/** Parse + structural validate в одну операцию. Returns list of all issues. */
public static List<String> parseAndValidate(byte[] yamlBytes) {
BundleManifest m;
try {
m = parse(yamlBytes);
} catch (ManifestException e) {
return List.of(e.getMessage());
}
return validate(m);
}
/** Structural validate — без I/O или schema parse. */
public static List<String> validate(BundleManifest m) {
List<String> errors = new ArrayList<>();
if (m == null) {
errors.add("manifest is null");
return errors;
}
if (!BundleManifest.CURRENT_API_VERSION.equals(m.apiVersion())) {
errors.add("apiVersion должен быть '" + BundleManifest.CURRENT_API_VERSION
+ "', got: " + m.apiVersion());
}
if (!BundleManifest.KIND.equals(m.kind())) {
errors.add("kind должен быть '" + BundleManifest.KIND + "', got: " + m.kind());
}
if (m.metadata() == null) {
errors.add("metadata is required");
} else {
validateMetadata(m.metadata(), errors);
}
if (m.dictionaries() == null || m.dictionaries().isEmpty()) {
errors.add("dictionaries: at least one entry required");
} else {
var seenNames = new java.util.HashSet<String>();
for (int i = 0; i < m.dictionaries().size(); i++) {
validateDictionary(m.dictionaries().get(i), i, seenNames, errors);
}
}
return errors;
}
private static void validateMetadata(BundleManifest.Metadata md, List<String> errors) {
if (md.id() == null || !BUNDLE_ID.matcher(md.id()).matches()) {
errors.add("metadata.id: must match " + BUNDLE_ID + ", got: " + md.id());
}
if (md.name() == null || md.name().isBlank()) {
errors.add("metadata.name: required");
}
if (md.version() == null || !SEMVER.matcher(md.version()).matches()) {
errors.add("metadata.version: must be semver (X.Y.Z[-suffix]), got: " + md.version());
}
if (md.scope() != null && !isValidScope(md.scope())) {
errors.add("metadata.scope: must be PUBLIC/INTERNAL/RESTRICTED, got: " + md.scope());
}
if (md.signature() != null && !md.signature().isBlank()
&& !md.signature().startsWith("ed25519:")) {
errors.add("metadata.signature: must be ed25519:<base64>, got prefix: "
+ md.signature().split(":", 2)[0]);
}
}
private static void validateDictionary(
BundleManifest.DictionaryEntry d, int idx,
java.util.Set<String> seenNames, List<String> errors) {
String ctx = "dictionaries[" + idx + "]";
if (d.name() == null || !DICT_NAME.matcher(d.name()).matches()) {
errors.add(ctx + ".name: must match " + DICT_NAME + ", got: " + d.name());
} else if (!seenNames.add(d.name())) {
errors.add(ctx + ".name: duplicate '" + d.name() + "'");
}
if (d.schemaVersion() == null || !SEMVER.matcher(d.schemaVersion()).matches()) {
errors.add(ctx + ".schemaVersion: must be semver, got: " + d.schemaVersion());
}
if (d.scope() == null || !isValidScope(d.scope())) {
errors.add(ctx + ".scope: must be PUBLIC/INTERNAL/RESTRICTED, got: " + d.scope());
}
if (d.schemaRef() == null || d.schemaRef().isBlank()) {
errors.add(ctx + ".schemaRef: required");
} else if (d.schemaRef().contains("..") || d.schemaRef().startsWith("/")) {
// Path traversal guard — schemaRef должен быть bundle-relative.
errors.add(ctx + ".schemaRef: must be bundle-relative path без '..', got: " + d.schemaRef());
}
}
private static boolean isValidScope(String s) {
return "PUBLIC".equals(s) || "INTERNAL".equals(s) || "RESTRICTED".equals(s);
}
/** UTF-8 helper — for string-based tests + REPL usage. */
public static BundleManifest parseString(String yaml) throws ManifestException {
return parse(yaml.getBytes(StandardCharsets.UTF_8));
}
public static class ManifestException extends Exception {
public ManifestException(String message) { super(message); }
public ManifestException(String message, Throwable cause) { super(message, cause); }
}
}
@@ -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