From 5c8a6e58c0cac4b494353b0be385ad94ac5f2d04 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Wed, 1 Apr 2026 14:54:05 +0200 Subject: [PATCH 01/12] feat(sdl): align Go and TS SDL implementation --- .github/workflows/tests.yaml | 1 + docs/sdl-parity.md | 66 ++++ go/sdl/parity_test.go | 111 +++++++ go/sdl/schema_validator.go | 15 +- go/sdl/sdl.go | 55 +++ go/sdl/strict_test.go | 90 +++++ make/test.mk | 9 +- specs/sdl/README.md | 35 ++ specs/sdl/groups-output.schema.yaml | 257 ++++++++++++++ specs/sdl/manifest-output.schema.yaml | 314 ++++++++++++++++++ .../duplicate-mount-path.yaml | 49 +++ .../endpoint-not-used.yaml | 36 ++ .../missing-compute-profile.yaml | 33 ++ .../v2.0/gpu-basic/groups.json | 65 ++++ .../v2.0/http-options/groups.json | 59 ++++ .../v2.0/ip-endpoint/groups.json | 71 ++++ .../v2.0/multiple-services/groups.json | 130 ++++++++ .../v2.0/persistent-storage/groups.json | 70 ++++ .../v2.0/placement/groups.json | 59 ++++ .../v2.0/port-ranges/groups.json | 60 ++++ .../output-fixtures/v2.0/pricing/groups.json | 102 ++++++ .../output-fixtures/v2.0/simple/groups.json | 67 ++++ .../v2.0/storage-classes/groups.json | 87 +++++ .../v2.1/credentials/groups.json | 59 ++++ .../v2.1/ip-endpoint/groups.json | 114 +++++++ ts/src/sdl/SDL/parity.spec.ts | 142 ++++++++ ts/src/sdl/manifest/generateManifest.ts | 12 +- .../sdl/manifest/generateManifestVersion.ts | 26 +- ts/src/sdl/manifest/manifestUtils.ts | 3 +- ts/test/functional/sdl-parity.spec.ts | 119 ++++++- 30 files changed, 2296 insertions(+), 20 deletions(-) create mode 100644 docs/sdl-parity.md create mode 100644 go/sdl/strict_test.go create mode 100644 specs/sdl/README.md create mode 100644 specs/sdl/groups-output.schema.yaml create mode 100644 specs/sdl/manifest-output.schema.yaml create mode 100644 testdata/sdl/input/semantic-only-invalid/duplicate-mount-path.yaml create mode 100644 testdata/sdl/input/semantic-only-invalid/endpoint-not-used.yaml create mode 100644 testdata/sdl/input/semantic-only-invalid/missing-compute-profile.yaml create mode 100644 testdata/sdl/output-fixtures/v2.0/gpu-basic/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/http-options/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/ip-endpoint/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/multiple-services/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/persistent-storage/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/placement/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/port-ranges/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/pricing/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/simple/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.0/storage-classes/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.1/credentials/groups.json create mode 100644 testdata/sdl/output-fixtures/v2.1/ip-endpoint/groups.json create mode 100644 ts/src/sdl/SDL/parity.spec.ts diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 1e747a61..de3e7604 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -72,4 +72,5 @@ jobs: - uses: actions/setup-go@v5 with: go-version: "${{ env.GOVERSION }}" + cache-dependency-path: go/sdl/go.sum - run: make test-sdl-parity diff --git a/docs/sdl-parity.md b/docs/sdl-parity.md new file mode 100644 index 00000000..69dd1dc2 --- /dev/null +++ b/docs/sdl-parity.md @@ -0,0 +1,66 @@ +# ADR: SDL Parity Between Go and TypeScript + +## Context + +Akash Network maintains two independent SDL (Stack Definition Language) parsers: + +- **Go** (`go/sdl/`) — used by the blockchain node and provider software +- **TypeScript** (`ts/src/sdl/`) — used by Console and other web-based tools + +Both must produce identical outputs from the same SDL input. Without enforced parity, the two implementations can silently diverge, causing deployment failures or inconsistent behavior between frontend and backend. + +## Decision + +### 1. Single Schema as Source of Truth + +The file `go/sdl/sdl-input.schema.yaml` is the **sole canonical definition** of valid SDL input for both languages. + +- All input validation rules (types, enums, patterns, constraints) live in this schema +- The Go parser embeds this schema via `//go:embed` +- The TS validator is generated from this schema via `compile-json-schema-to-ts.ts` +- No hand-maintained validation logic should duplicate what the schema already enforces + +### 2. Shared Test Fixtures + +Test fixtures under `testdata/sdl/` are the parity harness: + +``` +testdata/sdl/ +├── input/ # SDL input YAML files +│ ├── v2.0/ # Valid v2.0 fixtures (each dir: input.yaml) +│ ├── v2.1/ # Valid v2.1 fixtures +│ ├── invalid/ # Rejected by both schema and parsers +│ ├── schema-only-invalid/ # Schema rejects, Go parser accepts +│ └── semantic-only-invalid/ # Schema accepts, parser rejects +└── output-fixtures/ # Expected outputs (generated from Go parser) + ├── v2.0/ # manifest.json + group-specs.json per fixture + └── v2.1/ +``` + +- **Inputs** are written by hand to cover SDL features and edge cases +- **Outputs** are generated by the Go parser (`make generate-sdl-fixtures`) and committed +- Both Go and TS parity tests compare their generated output against these committed fixtures + +### 3. Parity Test Requirements + +Any SDL-related PR **must**: + +1. Pass `make test-sdl-parity` (runs both Go and TS parity tests) +2. Update fixtures if parser output changes (`make generate-sdl-fixtures`) +3. Add new fixtures for new SDL features or edge cases +4. Keep the TS generated validator in sync with the schema (CI drift check) + +### 4. Strict vs Lenient Parsing + +- `ReadFile` / `Read` (Go) — lenient, does not enforce schema (backward compatible) +- `ReadFileStrict` / `ReadStrict` (Go) — strict, validates schema first, then semantic rules +- `generateManifest` (TS) — always validates schema (via `validateSDL`) + +New code should prefer the strict APIs. The lenient APIs exist for backward compatibility with existing consumers. + +## Consequences + +- Schema changes require updating both Go and TS test suites +- TS validator drift is caught by CI +- Parity regressions are caught before merge +- Edge cases are documented as test fixtures, not just code comments diff --git a/go/sdl/parity_test.go b/go/sdl/parity_test.go index aaa9761f..ba135f77 100644 --- a/go/sdl/parity_test.go +++ b/go/sdl/parity_test.go @@ -27,15 +27,30 @@ package sdl import ( "encoding/json" + "fmt" "os" "path/filepath" + "sync" "testing" "github.com/stretchr/testify/require" + "github.com/xeipuuv/gojsonschema" + "gopkg.in/yaml.v3" ) const fixturesInputRoot = "../../testdata/sdl/input" const fixturesOutputRoot = "../../testdata/sdl/output-fixtures" +const schemasRoot = "../../specs/sdl" // Output schemas for tests + +var ( + manifestSchema *gojsonschema.Schema + manifestSchemaOnce sync.Once + manifestSchemaErr error + + groupsSchema *gojsonschema.Schema + groupsSchemaOnce sync.Once + groupsSchemaErr error +) func TestParityV2_0(t *testing.T) { testParity(t, "v2.0") @@ -90,6 +105,8 @@ func testParity(t *testing.T, version string) { groupSpecsBytes, err := json.Marshal(groupSpecs) require.NoError(t, err) + validateOutputAgainstSchema(t, manifestBytes, groupSpecsBytes) + validateFixtureBytes(t, manifestPath, manifestBytes, "Manifest") validateFixtureBytes(t, groupSpecsPath, groupSpecsBytes, "GroupSpecs") }) @@ -101,6 +118,24 @@ func validateInputSchema(t *testing.T, inputBytes []byte) { require.NoError(t, err, "Input schema validation failed") } +func validateOutputAgainstSchema(t *testing.T, manifestBytes []byte, groupSpecsBytes []byte) { + manifestSchemaOnce.Do(func() { + manifestSchema, manifestSchemaErr = compileSchemaFromPath(filepath.Join(schemasRoot, "manifest-output.schema.yaml")) + }) + require.NoError(t, manifestSchemaErr, "Failed to compile manifest schema") + + err := validateDataAgainstCompiledSchema(manifestBytes, manifestSchema) + require.NoError(t, err, "Manifest schema validation failed") + + groupsSchemaOnce.Do(func() { + groupsSchema, groupsSchemaErr = compileSchemaFromPath(filepath.Join(schemasRoot, "groups-output.schema.yaml")) + }) + require.NoError(t, groupsSchemaErr, "Failed to compile groups schema") + + err = validateDataAgainstCompiledSchema(groupSpecsBytes, groupsSchema) + require.NoError(t, err, "Groups schema validation failed") +} + func validateFixtureBytes(t *testing.T, expectedPath string, actualBytes []byte, name string) { expectedBytes, err := os.ReadFile(expectedPath) require.NoError(t, err, "Failed to read expected %s", name) @@ -131,6 +166,40 @@ func TestInvalidSDLsRejected(t *testing.T) { } } +// TestSemanticOnlyInvalid tests SDL files that pass schema validation but are +// rejected by both Go and TS parsers due to semantic constraints not expressible +// in JSON Schema (e.g., unused endpoints, cross-reference errors, duplicate mounts). +func TestSemanticOnlyInvalid(t *testing.T) { + semanticDir := filepath.Join(fixturesInputRoot, "semantic-only-invalid") + + entries, err := os.ReadDir(semanticDir) + if os.IsNotExist(err) { + t.Skip("Semantic-only invalid fixtures directory does not exist yet") + return + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + fixturePath := filepath.Join(semanticDir, entry.Name()) + t.Run(entry.Name(), func(t *testing.T) { + inputBytes, err := os.ReadFile(fixturePath) + require.NoError(t, err) + + // Schema should accept (structurally valid) + schemaErr := validateInputAgainstSchema(inputBytes) + require.NoError(t, schemaErr, "Schema should accept this input (semantic-only invalid)") + + // Go parser should reject (semantically invalid) + _, goErr := ReadFile(fixturePath) + require.Error(t, goErr, "Go parser should reject this input (semantic validation)") + }) + } +} + // TestSchemaOnlyValidations tests SDL files that are rejected by the JSON schema // but accepted by the Go parser. These represent validations that exist only in // the schema layer (e.g., string length limits, enum value constraints) but are @@ -163,3 +232,45 @@ func TestSchemaOnlyValidations(t *testing.T) { }) } } + +func compileSchemaFromPath(schemaPath string) (*gojsonschema.Schema, error) { + schemaBytes, err := os.ReadFile(schemaPath) + if err != nil { + return nil, fmt.Errorf("failed to read schema file: %w", err) + } + + var schemaData any + if err := yaml.Unmarshal(schemaBytes, &schemaData); err != nil { + return nil, fmt.Errorf("failed to parse YAML schema: %w", err) + } + + jsonBytes, err := json.Marshal(schemaData) + if err != nil { + return nil, fmt.Errorf("failed to convert schema to JSON: %w", err) + } + + schemaLoader := gojsonschema.NewSchemaLoader() + schema, err := schemaLoader.Compile(gojsonschema.NewBytesLoader(jsonBytes)) + if err != nil { + return nil, fmt.Errorf("failed to compile schema: %w", err) + } + + return schema, nil +} + +func validateDataAgainstCompiledSchema(data []byte, schema *gojsonschema.Schema) error { + result, err := schema.Validate(gojsonschema.NewBytesLoader(data)) + if err != nil { + return fmt.Errorf("failed to validate against schema: %w", err) + } + + if !result.Valid() { + var errors []string + for _, desc := range result.Errors() { + errors = append(errors, desc.String()) + } + return fmt.Errorf("schema validation failed: %v", errors) + } + + return nil +} diff --git a/go/sdl/schema_validator.go b/go/sdl/schema_validator.go index 5a33ea3b..069d0a08 100644 --- a/go/sdl/schema_validator.go +++ b/go/sdl/schema_validator.go @@ -138,14 +138,25 @@ func checkSchemaValidationResult(schemaErr error, goValidationErr error) { } func (sv *SchemaValidator) checkSchemaValidationResult(schemaErr error, goValidationErr error) { + logger := sv.logger.Load().(loggerHolder).Logger + if schemaErr == nil && goValidationErr != nil { - logger := sv.logger.Load().(loggerHolder).Logger + // Schema passed but Go rejected — semantic validation not covered by schema logger.Warn( - "SDL schema validation mismatch", + "SDL schema validation mismatch: schema passed, Go rejected", "schema_validation", "passed", "go_error", goValidationErr.Error(), ) } + + if schemaErr != nil && goValidationErr == nil { + // Schema rejected but Go accepted — schema is stricter than Go parser + logger.Warn( + "SDL schema validation mismatch: schema rejected, Go accepted", + "schema_error", schemaErr.Error(), + "go_validation", "passed", + ) + } } func sanitizeSchemaRefs(node any) error { diff --git a/go/sdl/sdl.go b/go/sdl/sdl.go index 2317eb1d..12b5c0e0 100644 --- a/go/sdl/sdl.go +++ b/go/sdl/sdl.go @@ -164,3 +164,58 @@ func (s *sdl) validate() error { return s.data.validate() } + +// ReadFileStrict reads from given path and returns SDL instance with strict +// schema enforcement. Unlike ReadFile, it returns an error immediately if the +// input fails schema validation against sdl-input.schema.yaml. +func ReadFileStrict(path string) (SDL, error) { + buf, err := os.ReadFile(path) //nolint: gosec + if err != nil { + return nil, err + } + return ReadStrict(buf) +} + +// ReadStrict reads buffer data and returns SDL instance with strict schema +// enforcement. It validates against sdl-input.schema.yaml first and returns +// an error immediately on schema failure, then continues with existing Go +// semantic validation. +func ReadStrict(buf []byte) (SDL, error) { + if err := validateInputAgainstSchema(buf); err != nil { + return nil, fmt.Errorf("strict schema validation failed: %w", err) + } + + obj := &sdl{} + if err := yaml.Unmarshal(buf, obj); err != nil { + return nil, err + } + + if err := obj.validate(); err != nil { + return nil, err + } + + dgroups, err := obj.DeploymentGroups() + if err != nil { + return nil, err + } + + vgroups := make([]dtypes.GroupSpec, 0, len(dgroups)) + for _, dgroup := range dgroups { + vgroups = append(vgroups, dgroup) + } + + if err = dtypes.ValidateDeploymentGroups(vgroups); err != nil { + return nil, err + } + + m, err := obj.Manifest() + if err != nil { + return nil, err + } + + if err = m.Validate(); err != nil { + return nil, err + } + + return obj, nil +} diff --git a/go/sdl/strict_test.go b/go/sdl/strict_test.go new file mode 100644 index 00000000..8548ab2f --- /dev/null +++ b/go/sdl/strict_test.go @@ -0,0 +1,90 @@ +package sdl + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestReadStrictValidInputs verifies that ReadFileStrict accepts all valid fixtures. +func TestReadStrictValidInputs(t *testing.T) { + for _, version := range []string{"v2.0", "v2.1"} { + inputDir := filepath.Join(fixturesInputRoot, version) + + entries, err := os.ReadDir(inputDir) + require.NoError(t, err) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + fixtureName := entry.Name() + inputPath := filepath.Join(inputDir, fixtureName, "input.yaml") + + t.Run(version+"/"+fixtureName, func(t *testing.T) { + sdl, err := ReadFileStrict(inputPath) + require.NoError(t, err, "ReadFileStrict should accept valid fixture %s", fixtureName) + require.NotNil(t, sdl) + }) + } + } +} + +// TestReadStrictRejectsInvalid verifies that ReadStrict rejects all invalid fixtures. +func TestReadStrictRejectsInvalid(t *testing.T) { + invalidDir := filepath.Join(fixturesInputRoot, "invalid") + + entries, err := os.ReadDir(invalidDir) + if os.IsNotExist(err) { + t.Skip("Invalid fixtures directory does not exist yet") + return + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + fixturePath := filepath.Join(invalidDir, entry.Name()) + t.Run(entry.Name(), func(t *testing.T) { + _, err := ReadFileStrict(fixturePath) + require.Error(t, err, "ReadFileStrict should reject invalid fixture") + }) + } +} + +// TestReadStrictRejectsSchemaOnlyInvalid verifies that ReadStrict rejects +// inputs that the schema rejects but the lenient Go parser accepts. +// This is the key difference from ReadFile. +func TestReadStrictRejectsSchemaOnlyInvalid(t *testing.T) { + schemaOnlyDir := filepath.Join(fixturesInputRoot, "schema-only-invalid") + + entries, err := os.ReadDir(schemaOnlyDir) + if os.IsNotExist(err) { + t.Skip("Schema-only invalid fixtures directory does not exist yet") + return + } + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + fixturePath := filepath.Join(schemaOnlyDir, entry.Name()) + t.Run(entry.Name(), func(t *testing.T) { + // Lenient ReadFile should accept these + _, lenientErr := ReadFile(fixturePath) + require.NoError(t, lenientErr, "ReadFile (lenient) should accept this input") + + // Strict ReadFileStrict should reject these + _, strictErr := ReadFileStrict(fixturePath) + require.Error(t, strictErr, "ReadFileStrict should reject schema-only-invalid input") + require.Contains(t, strictErr.Error(), "strict schema validation failed") + }) + } +} diff --git a/make/test.mk b/make/test.mk index 7f31a81f..d9c1db7b 100644 --- a/make/test.mk +++ b/make/test.mk @@ -55,9 +55,14 @@ generate-sdl-fixtures: ## Regenerate manifest.json and group-specs.json. Run onl .PHONY: test-sdl-parity test-sdl-parity: $(AKASH_TS_NODE_MODULES) ## Run SDL parity tests for Go and TypeScript (uses committed fixtures) + @echo "Running TS schema drift check..." + @cd ts && npm run compile:validators + @git diff --exit-code ts/src/sdl/validateSDL/validateSDLInput.ts || \ + (echo "ERROR: TS generated validator is out of sync with Go schema. Run 'cd ts && npm run compile:validators' and commit." && exit 1) + @echo "" @echo "Running Go SDL parity and validation tests..." - @cd go/sdl && go test -v -run "TestParity|TestInvalidSDLsRejected|TestSchemaValidation" + @cd go/sdl && go test -v -run "TestParity|TestInvalidSDLsRejected|TestSchemaValidation|TestReadStrict|TestSemanticOnlyInvalid" @echo "" @echo "Running TypeScript SDL parity tests..." - @echo " (includes: v2.0 fixtures, v2.1 fixtures, and invalid SDL rejection)" + @echo " (includes: v2.0 fixtures, v2.1 fixtures, invalid SDL rejection, schema-only-invalid)" @cd ts && npm run test:sdk-compatibility diff --git a/specs/sdl/README.md b/specs/sdl/README.md new file mode 100644 index 00000000..7fae9677 --- /dev/null +++ b/specs/sdl/README.md @@ -0,0 +1,35 @@ +# SDL Schema Documentation + +## Schema Files + +**`sdl-input.schema.yaml`** (`go/sdl/`) +- Validates user YAML input +- Embedded in Go binary for runtime validation (logs warnings, doesn't block) +- Enforces stricter rules than Go parser (email length, denom pattern, GPU vendor, version enum) + +**`manifest-output.schema.yaml`** (`specs/sdl/`) +- Validates generated manifest JSON + +**`groups-output.schema.yaml`** (`specs/sdl/`) +- Validates generated deployment groups JSON + +## Validation Capabilities + +`sdl-input.schema.yaml` validates: +- **Types & Constraints**: Required fields, enums, string patterns, min/max, minLength +- **Patterns**: Endpoint names (`^[a-z]+[-_\da-z]+$`), denom (`^(uakt|ibc/.*)$`) +- **Conditionals**: RAM storage → persistent=false, IP endpoint → global=true +- **Strict Rules**: Email ≥5 chars, password ≥6 chars, version ∈ {2.0, 2.1}, GPU vendor (nvidia only) + +## Validation Limitations + +Schema validates structure only. **Go/TS parsers handle:** +- Cross-references (deployment → profiles, params.storage → compute.storage) +- Semantic constraints (unused endpoints, port collisions, mount uniqueness) +- Parser-level checks (count ≥ 1, unknown fields — TS validates, Go rejects during unmarshal) + +## Test Fixtures + +`testdata/sdl/input/invalid/` — Both schema and Go parser reject +`testdata/sdl/input/schema-only-invalid/` — Schema rejects, Go parser accepts (stricter rules) +`testdata/sdl/input/v2.0/`, `v2.1/` — Valid fixtures for parity tests (Go ↔ TS output comparison) diff --git a/specs/sdl/groups-output.schema.yaml b/specs/sdl/groups-output.schema.yaml new file mode 100644 index 00000000..0b6895c7 --- /dev/null +++ b/specs/sdl/groups-output.schema.yaml @@ -0,0 +1,257 @@ +items: + additionalProperties: false + properties: + name: + type: string + requirements: + additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + signed_by: + additionalProperties: false + properties: + all_of: + anyOf: + - items: + type: string + type: array + - type: 'null' + any_of: + anyOf: + - items: + type: string + type: array + - type: 'null' + type: object + required: + - signed_by + type: object + resources: + anyOf: + - items: + additionalProperties: false + properties: + count: + type: number + price: + additionalProperties: false + properties: + amount: + anyOf: + - type: string + - type: 'null' + denom: + anyOf: + - type: string + - type: 'null' + required: + - amount + type: object + prices: + anyOf: + - items: + additionalProperties: false + properties: + amount: + anyOf: + - type: string + - type: 'null' + denom: + anyOf: + - type: string + - type: 'null' + required: + - amount + - denom + type: object + type: array + - type: 'null' + resource: + additionalProperties: false + properties: + cpu: + anyOf: + - additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + units: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - units + type: object + - type: 'null' + endpoints: + anyOf: + - items: + additionalProperties: false + properties: + kind: + anyOf: + - type: number + - type: 'null' + sequence_number: + type: number + required: + - sequence_number + type: object + type: array + - type: 'null' + gpu: + anyOf: + - additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + units: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - units + type: object + - type: 'null' + id: + type: number + memory: + anyOf: + - additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + size: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - size + type: object + - type: 'null' + storage: + anyOf: + - items: + additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + name: + type: string + size: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - name + - size + type: object + type: array + - type: 'null' + required: + - id + type: object + anyOf: + - required: + - resource + - count + - price + - required: + - resource + - count + - prices + type: object + type: array + - type: 'null' + required: + - name + - requirements + type: object +type: array diff --git a/specs/sdl/manifest-output.schema.yaml b/specs/sdl/manifest-output.schema.yaml new file mode 100644 index 00000000..797ec291 --- /dev/null +++ b/specs/sdl/manifest-output.schema.yaml @@ -0,0 +1,314 @@ +items: + additionalProperties: false + properties: + name: + type: string + services: + anyOf: + - items: + additionalProperties: false + properties: + args: + anyOf: + - items: + type: string + type: array + - type: 'null' + command: + anyOf: + - items: + type: string + type: array + - type: 'null' + count: + type: number + credentials: + $ref: '#/$defs/credentials' + env: + anyOf: + - items: + type: string + type: array + - type: 'null' + expose: + anyOf: + - items: + additionalProperties: false + properties: + endpointSequenceNumber: + type: number + externalPort: + type: number + global: + type: boolean + hosts: + anyOf: + - items: + type: string + type: array + - type: 'null' + httpOptions: + additionalProperties: false + properties: + maxBodySize: + type: number + nextCases: + anyOf: + - items: + type: string + type: array + - type: 'null' + nextTimeout: + type: number + nextTries: + type: number + readTimeout: + type: number + sendTimeout: + type: number + required: + - maxBodySize + - readTimeout + - sendTimeout + - nextTries + - nextTimeout + type: object + ip: + type: string + port: + type: number + proto: + type: string + service: + type: string + required: + - port + - externalPort + - proto + - service + - global + - httpOptions + - ip + - endpointSequenceNumber + type: object + type: array + - type: 'null' + image: + type: string + name: + type: string + params: + anyOf: + - additionalProperties: false + properties: + credentials: + $ref: '#/$defs/credentials' + storage: + anyOf: + - items: + additionalProperties: false + properties: + mount: + type: string + name: + type: string + readOnly: + type: boolean + required: + - name + - mount + - readOnly + type: object + type: array + - type: 'null' + type: object + - type: 'null' + resources: + additionalProperties: false + properties: + cpu: + anyOf: + - additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + units: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - units + type: object + - type: 'null' + endpoints: + anyOf: + - items: + additionalProperties: false + properties: + kind: + anyOf: + - type: number + - type: 'null' + sequence_number: + type: number + required: + - sequence_number + type: object + type: array + - type: 'null' + gpu: + anyOf: + - additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + units: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - units + type: object + - type: 'null' + id: + type: number + memory: + anyOf: + - additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + size: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - size + type: object + - type: 'null' + storage: + anyOf: + - items: + additionalProperties: false + properties: + attributes: + anyOf: + - items: + additionalProperties: false + properties: + key: + anyOf: + - type: string + - type: 'null' + value: + anyOf: + - type: string + - type: 'null' + type: object + type: array + - type: 'null' + name: + type: string + size: + additionalProperties: false + properties: + val: + type: string + required: + - val + type: object + required: + - name + - size + type: object + type: array + - type: 'null' + required: + - id + type: object + required: + - name + - image + - resources + - count + type: object + type: array + - type: 'null' + required: + - name + type: object +type: array +$defs: + credentials: + anyOf: + - additionalProperties: false + properties: + email: + type: string + host: + type: string + password: + type: string + username: + type: string + required: + - host + - email + - username + - password + type: object + - type: 'null' diff --git a/testdata/sdl/input/semantic-only-invalid/duplicate-mount-path.yaml b/testdata/sdl/input/semantic-only-invalid/duplicate-mount-path.yaml new file mode 100644 index 00000000..748d561b --- /dev/null +++ b/testdata/sdl/input/semantic-only-invalid/duplicate-mount-path.yaml @@ -0,0 +1,49 @@ +# yaml-language-server: $schema=../../../../go/sdl/sdl-input.schema.yaml +# Structurally valid per schema, but semantically invalid: +# Two persistent volumes mounted at the same path +--- +version: "2.0" +services: + web: + image: nginx + expose: + - port: 80 + to: + - global: true + params: + storage: + data1: + mount: /data + data2: + mount: /data +profiles: + compute: + web: + resources: + cpu: + units: 100m + memory: + size: 512Mi + storage: + - size: 1Gi + - name: data1 + size: 5Gi + attributes: + persistent: true + class: beta2 + - name: data2 + size: 5Gi + attributes: + persistent: true + class: beta2 + placement: + akash: + pricing: + web: + denom: uakt + amount: 100 +deployment: + web: + akash: + profile: web + count: 1 diff --git a/testdata/sdl/input/semantic-only-invalid/endpoint-not-used.yaml b/testdata/sdl/input/semantic-only-invalid/endpoint-not-used.yaml new file mode 100644 index 00000000..7b88351f --- /dev/null +++ b/testdata/sdl/input/semantic-only-invalid/endpoint-not-used.yaml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=../../../../go/sdl/sdl-input.schema.yaml +# Structurally valid per schema, but semantically invalid: +# endpoint declared but never referenced in any service expose +--- +version: "2.0" +services: + web: + image: nginx + expose: + - port: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: 100m + memory: + size: 512Mi + storage: + size: 1Gi + placement: + akash: + pricing: + web: + denom: uakt + amount: 100 +deployment: + web: + akash: + profile: web + count: 1 +endpoints: + myendpoint: + kind: ip diff --git a/testdata/sdl/input/semantic-only-invalid/missing-compute-profile.yaml b/testdata/sdl/input/semantic-only-invalid/missing-compute-profile.yaml new file mode 100644 index 00000000..2ba52718 --- /dev/null +++ b/testdata/sdl/input/semantic-only-invalid/missing-compute-profile.yaml @@ -0,0 +1,33 @@ +# yaml-language-server: $schema=../../../../go/sdl/sdl-input.schema.yaml +# Structurally valid per schema, but semantically invalid: +# Deployment references a compute profile that doesn't exist +--- +version: "2.0" +services: + web: + image: nginx + expose: + - port: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: 100m + memory: + size: 512Mi + storage: + size: 1Gi + placement: + akash: + pricing: + web: + denom: uakt + amount: 100 +deployment: + web: + akash: + profile: nonexistent + count: 1 diff --git a/testdata/sdl/output-fixtures/v2.0/gpu-basic/groups.json b/testdata/sdl/output-fixtures/v2.0/gpu-basic/groups.json new file mode 100644 index 00000000..af9be965 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/gpu-basic/groups.json @@ -0,0 +1,65 @@ +[ + { + "name": "westcoast", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "host", + "value": "akash" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "1000" + } + }, + "memory": { + "size": { + "val": "2147483648" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "10737418240" + } + } + ], + "gpu": { + "units": { + "val": "1" + }, + "attributes": [ + { + "key": "vendor/nvidia/model/rtx3080", + "value": "true" + } + ] + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "1000.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/http-options/groups.json b/testdata/sdl/output-fixtures/v2.0/http-options/groups.json new file mode 100644 index 00000000..ed12b2dd --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/http-options/groups.json @@ -0,0 +1,59 @@ +[ + { + "name": "akash", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "host", + "value": "akash" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "500" + } + }, + "memory": { + "size": { + "val": "536870912" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "150.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/ip-endpoint/groups.json b/testdata/sdl/output-fixtures/v2.0/ip-endpoint/groups.json new file mode 100644 index 00000000..7ab0c45b --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/ip-endpoint/groups.json @@ -0,0 +1,71 @@ +[ + { + "name": "akash", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "host", + "value": "akash" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "250" + } + }, + "memory": { + "size": { + "val": "536870912" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + }, + { + "kind": 2, + "sequence_number": 1 + }, + { + "kind": 1, + "sequence_number": 0 + }, + { + "kind": 2, + "sequence_number": 2 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "200.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/multiple-services/groups.json b/testdata/sdl/output-fixtures/v2.0/multiple-services/groups.json new file mode 100644 index 00000000..51095c83 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/multiple-services/groups.json @@ -0,0 +1,130 @@ +[ + { + "name": "datacenter", + "requirements": { + "signed_by": { + "all_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ], + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63", + "akash18qa2a2ltfyvkyj0ggj3hkvuj6twzyumuaru9s4" + ] + }, + "attributes": [ + { + "key": "region", + "value": "us-east" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "500" + } + }, + "memory": { + "size": { + "val": "1073741824" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "2147483648" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [] + }, + "count": 3, + "price": { + "denom": "uakt", + "amount": "300.000000000000000000" + } + }, + { + "resource": { + "id": 2, + "cpu": { + "units": { + "val": "1000" + } + }, + "memory": { + "size": { + "val": "2147483648" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "10737418240" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "500.000000000000000000" + } + }, + { + "resource": { + "id": 3, + "cpu": { + "units": { + "val": "100" + } + }, + "memory": { + "size": { + "val": "268435456" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "536870912" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 2, + "price": { + "denom": "uakt", + "amount": "100.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/persistent-storage/groups.json b/testdata/sdl/output-fixtures/v2.0/persistent-storage/groups.json new file mode 100644 index 00000000..eed7fffb --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/persistent-storage/groups.json @@ -0,0 +1,70 @@ +[ + { + "name": "datacenter", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "region", + "value": "us-west" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "500" + } + }, + "memory": { + "size": { + "val": "1073741824" + } + }, + "storage": [ + { + "name": "data", + "size": { + "val": "53687091200" + }, + "attributes": [ + { + "key": "class", + "value": "beta2" + }, + { + "key": "persistent", + "value": "true" + } + ] + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "kind": 1, + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "500.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/placement/groups.json b/testdata/sdl/output-fixtures/v2.0/placement/groups.json new file mode 100644 index 00000000..f524c6d2 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/placement/groups.json @@ -0,0 +1,59 @@ +[ + { + "name": "us-west", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1vz375dkt0c60annyp6mkzeejfq0qpyevhseu05" + ] + }, + "attributes": [ + { + "key": "region", + "value": "us-west" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "500" + } + }, + "memory": { + "size": { + "val": "536870912" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "536870912" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "1000.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/port-ranges/groups.json b/testdata/sdl/output-fixtures/v2.0/port-ranges/groups.json new file mode 100644 index 00000000..eb773e60 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/port-ranges/groups.json @@ -0,0 +1,60 @@ +[ + { + "name": "akash", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": null + }, + "attributes": null + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "1000" + } + }, + "memory": { + "size": { + "val": "2147483648" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + }, + { + "kind": 1, + "sequence_number": 0 + }, + { + "kind": 1, + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "2000.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/pricing/groups.json b/testdata/sdl/output-fixtures/v2.0/pricing/groups.json new file mode 100644 index 00000000..b077af83 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/pricing/groups.json @@ -0,0 +1,102 @@ +[ + { + "name": "provider-a", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": null + }, + "attributes": null + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "1000" + } + }, + "memory": { + "size": { + "val": "1073741824" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "5000.000000000000000000" + } + } + ] + }, + { + "name": "provider-b", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": null + }, + "attributes": null + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "1000" + } + }, + "memory": { + "size": { + "val": "1073741824" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "3000.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/simple/groups.json b/testdata/sdl/output-fixtures/v2.0/simple/groups.json new file mode 100644 index 00000000..833dd05d --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/simple/groups.json @@ -0,0 +1,67 @@ +[ + { + "name": "westcoast", + "requirements": { + "signed_by": { + "all_of": [ + "3", + "4" + ], + "any_of": [ + "1", + "2" + ] + }, + "attributes": [ + { + "key": "region", + "value": "us-west" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "100" + } + }, + "memory": { + "size": { + "val": "134217728" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + }, + { + "kind": 1, + "sequence_number": 0 + } + ] + }, + "count": 2, + "price": { + "denom": "uakt", + "amount": "50.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.0/storage-classes/groups.json b/testdata/sdl/output-fixtures/v2.0/storage-classes/groups.json new file mode 100644 index 00000000..6281c282 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.0/storage-classes/groups.json @@ -0,0 +1,87 @@ +[ + { + "name": "datacenter", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "region", + "value": "us-central" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "1000" + } + }, + "memory": { + "size": { + "val": "2147483648" + } + }, + "storage": [ + { + "name": "cache", + "size": { + "val": "1073741824" + }, + "attributes": [ + { + "key": "persistent", + "value": "false" + } + ] + }, + { + "name": "logs", + "size": { + "val": "5368709120" + } + }, + { + "name": "data", + "size": { + "val": "21474836480" + }, + "attributes": [ + { + "key": "class", + "value": "beta3" + }, + { + "key": "persistent", + "value": "true" + } + ] + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "400.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.1/credentials/groups.json b/testdata/sdl/output-fixtures/v2.1/credentials/groups.json new file mode 100644 index 00000000..40bfa950 --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.1/credentials/groups.json @@ -0,0 +1,59 @@ +[ + { + "name": "akash", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "host", + "value": "akash" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "500" + } + }, + "memory": { + "size": { + "val": "1073741824" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "5368709120" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "250.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/testdata/sdl/output-fixtures/v2.1/ip-endpoint/groups.json b/testdata/sdl/output-fixtures/v2.1/ip-endpoint/groups.json new file mode 100644 index 00000000..f7a3f9bc --- /dev/null +++ b/testdata/sdl/output-fixtures/v2.1/ip-endpoint/groups.json @@ -0,0 +1,114 @@ +[ + { + "name": "akash", + "requirements": { + "signed_by": { + "all_of": null, + "any_of": [ + "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63" + ] + }, + "attributes": [ + { + "key": "host", + "value": "akash" + } + ] + }, + "resources": [ + { + "resource": { + "id": 1, + "cpu": { + "units": { + "val": "250" + } + }, + "memory": { + "size": { + "val": "536870912" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "1073741824" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "kind": 1, + "sequence_number": 0 + }, + { + "kind": 2, + "sequence_number": 3 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "200.000000000000000000" + } + }, + { + "resource": { + "id": 2, + "cpu": { + "units": { + "val": "100" + } + }, + "memory": { + "size": { + "val": "268435456" + } + }, + "storage": [ + { + "name": "default", + "size": { + "val": "536870912" + } + } + ], + "gpu": { + "units": { + "val": "0" + } + }, + "endpoints": [ + { + "sequence_number": 0 + }, + { + "kind": 2, + "sequence_number": 1 + }, + { + "kind": 1, + "sequence_number": 0 + }, + { + "kind": 2, + "sequence_number": 2 + } + ] + }, + "count": 1, + "price": { + "denom": "uakt", + "amount": "150.000000000000000000" + } + } + ] + } +] \ No newline at end of file diff --git a/ts/src/sdl/SDL/parity.spec.ts b/ts/src/sdl/SDL/parity.spec.ts new file mode 100644 index 00000000..eff45895 --- /dev/null +++ b/ts/src/sdl/SDL/parity.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "@jest/globals"; +import type { ErrorObject, ValidateFunction } from "ajv"; +import AjvModule from "ajv"; +import fs from "fs"; +import { load } from "js-yaml"; +import path from "path"; + +import { SDL } from "./SDL.ts"; + +const fixturesInputRoot = path.join(__dirname, "../../../../testdata/sdl/input"); +const fixturesOutputRoot = path.join(__dirname, "../../../../testdata/sdl/output-fixtures"); +const schemasRoot = path.join(__dirname, "../../../../specs/sdl"); +const inputSchemaPath = path.join(__dirname, "../../../../go/sdl/sdl-input.schema.yaml"); +// @ts-expect-error - AjvModule has non-standard export, cast needed for instantiation +const ajv: { compile: (schema: Record) => ValidateFunction } = new (AjvModule as unknown as new (options?: { allErrors?: boolean }) => typeof AjvModule)({ allErrors: true }); + +interface Fixture { + name: string; + inputPath: string; + manifestPath: string; + groupsPath: string; +} + +const schemaCache = new Map(); + +function compileSchema(schemaPath: string): ValidateFunction { + const cached = schemaCache.get(schemaPath); + if (cached) { + return cached; + } + + const schemaContent = fs.readFileSync(schemaPath, "utf8"); + const schema = load(schemaContent); + const validator = ajv.compile(schema as Record); + schemaCache.set(schemaPath, validator); + return validator; +} + +function validateAgainstSchema(name: string, data: unknown, schemaPath: string): void { + const validate = compileSchema(schemaPath); + const valid = validate(data); + + if (!valid && validate.errors) { + const errors = validate.errors.map((err: ErrorObject) => { + const errorPath = err.instancePath || "(root)"; + return `${errorPath}: ${err.message} [${err.keyword}]`; + }); + throw new Error(`${name} validation failed. Errors: ${JSON.stringify(errors, null, 2)}`); + } +} + +function loadFixtures(version: string): Fixture[] { + const inputVersionDir = path.join(fixturesInputRoot, version); + + if (!fs.existsSync(inputVersionDir)) { + throw new Error(`Fixtures directory ${inputVersionDir} does not exist`); + } + + const entries = fs.readdirSync(inputVersionDir, { withFileTypes: true }); + + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const fixtureName = entry.name; + const inputPath = path.join(inputVersionDir, fixtureName, "input.yaml"); + const manifestPath = path.join(fixturesOutputRoot, version, fixtureName, "manifest.json"); + const groupsPath = path.join(fixturesOutputRoot, version, fixtureName, "groups.json"); + + if (!fs.existsSync(manifestPath)) { + throw new Error(`manifest.json not generated for ${fixtureName} (run: make generate-sdl-fixtures)`); + } + + if (!fs.existsSync(groupsPath)) { + throw new Error(`groups.json not generated for ${fixtureName} (run: make generate-sdl-fixtures)`); + } + + return { + name: fixtureName, + inputPath, + manifestPath, + groupsPath, + }; + }); +} + +function validateSchemas(inputBytes: string, version: "beta2" | "beta3") { + const inputYAML = load(inputBytes); + validateAgainstSchema("input", inputYAML, inputSchemaPath); + + const sdl = SDL.fromString(inputBytes, version); + const manifest = sdl.v3Manifest(false); + const groups = sdl.v3Groups(); + + validateAgainstSchema("manifest", manifest, path.join(schemasRoot, "manifest-output.schema.yaml")); + validateAgainstSchema("groups", groups, path.join(schemasRoot, "groups-output.schema.yaml")); + + return { sdl, manifest, groups }; +} + +function validateFixtures(fixture: Fixture, version: "beta2" | "beta3") { + const inputBytes = fs.readFileSync(fixture.inputPath, "utf8"); + const { manifest: actualManifest, groups: actualGroups } = validateSchemas(inputBytes, version); + + const expectedManifest = JSON.parse(fs.readFileSync(fixture.manifestPath, "utf8")); + const expectedGroups = JSON.parse(fs.readFileSync(fixture.groupsPath, "utf8")); + + expect(actualManifest).toEqual(expectedManifest); + expect(actualGroups).toEqual(expectedGroups); +} + +describe("SDL Parity Tests", () => { + describe("v2.0", () => { + loadFixtures("v2.0").forEach((fixture) => { + it(fixture.name, () => validateFixtures(fixture, "beta2")); + }); + }); + + describe("v2.1", () => { + loadFixtures("v2.1").forEach((fixture) => { + it(fixture.name, () => validateFixtures(fixture, "beta3")); + }); + }); + + describe("invalid SDLs rejected", () => { + const invalidDir = path.join(fixturesInputRoot, "invalid"); + + if (!fs.existsSync(invalidDir)) { + it.skip("invalid fixtures directory not found", () => {}); + return; + } + + fs.readdirSync(invalidDir) + .filter((f) => f.endsWith(".yaml")) + .forEach((filename) => { + it(filename, () => { + const fixturePath = path.join(invalidDir, filename); + const input = fs.readFileSync(fixturePath, "utf8"); + expect(() => SDL.fromString(input, "beta3")).toThrow(); + }); + }); + }); +}); diff --git a/ts/src/sdl/manifest/generateManifest.ts b/ts/src/sdl/manifest/generateManifest.ts index a57e163b..fc4a6e44 100644 --- a/ts/src/sdl/manifest/generateManifest.ts +++ b/ts/src/sdl/manifest/generateManifest.ts @@ -130,18 +130,14 @@ export function generateManifest(sdl: SDLInput, networkId: NetworkId = MAINNET_I group.dgroup.resources[location].resource!.endpoints.push( ...buildServiceEndpoints(service, endpointSequenceNumbers), ); + // Sort after appending, matching Go's groupBuilder_v2_1.go behavior + group.dgroup.resources[location].resource!.endpoints.sort( + (a, b) => a.sequenceNumber - b.sequenceNumber, + ); } } } - for (const group of groupsMap.values()) { - for (const resourceUnit of group.dgroup.resources) { - resourceUnit.resource!.endpoints.sort( - (a, b) => a.kind - b.kind || a.sequenceNumber - b.sequenceNumber, - ); - } - } - const sortedGroupNames = [...groupsMap.keys()].sort(); let groups: Group[] | undefined; let groupSpecs: GroupSpec[] | undefined; diff --git a/ts/src/sdl/manifest/generateManifestVersion.ts b/ts/src/sdl/manifest/generateManifestVersion.ts index a63fb6ab..74890311 100644 --- a/ts/src/sdl/manifest/generateManifestVersion.ts +++ b/ts/src/sdl/manifest/generateManifestVersion.ts @@ -4,7 +4,7 @@ import type { GenerateManifestOkResult, Manifest } from "./generateManifest.ts"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); -const NULLABLE_MANIFEST_KEYS = new Set(["command", "args", "env", "hosts"]); +const NULLABLE_MANIFEST_KEYS = new Set(["command", "args", "env", "hosts", "allOf", "anyOf"]); const OMITTED_MANIFEST_KEYS = new Set(["kind", "attributes"]); export async function generateManifestVersion(manifest: Manifest): Promise { @@ -37,14 +37,36 @@ function manifestReplacer(this: unknown, key: string | number, value: unknown): return null; } + // Format price amount as LegacyDec (18 decimal places) to match Go output + if (key === "amount" && typeof this === "object" && this && Object.hasOwn(this, "denom") && typeof value === "string") { + const s = value; + if (!s) return "0.000000000000000000"; + if (s.includes(".")) { + const [, frac] = s.split("."); + const pad = 18 - (frac?.length ?? 0); + return pad > 0 ? s + "0".repeat(pad) : s; + } + return `${s}.${"0".repeat(18)}`; + } + if (OMITTED_MANIFEST_KEYS.has(key) && ((Array.isArray(value) && value.length === 0) || value === 0)) { + // In requirements context (group-specs), empty attributes should be null, not omitted + if (key === "attributes" && typeof this === "object" && this && Object.hasOwn(this, "signedBy")) { + return null; + } return undefined; } return value; } -const MANIFEST_VERSION_FIELD_MAPPING: Record = { quantity: "size", sequenceNumber: "sequence_number" }; +const MANIFEST_VERSION_FIELD_MAPPING: Record = { + quantity: "size", + sequenceNumber: "sequence_number", + signedBy: "signed_by", + allOf: "all_of", + anyOf: "any_of", +}; const MANIFEST_VERSION_FIELD_REGEX = new RegExp(`"(${Object.keys(MANIFEST_VERSION_FIELD_MAPPING).join("|")})":`, "g"); function renameFields(jsonStr: string): string { MANIFEST_VERSION_FIELD_REGEX.lastIndex = 0; // reset regex state diff --git a/ts/src/sdl/manifest/manifestUtils.ts b/ts/src/sdl/manifest/manifestUtils.ts index ff5aa687..65ae16d1 100644 --- a/ts/src/sdl/manifest/manifestUtils.ts +++ b/ts/src/sdl/manifest/manifestUtils.ts @@ -133,7 +133,8 @@ export function buildServiceEndpoints( sequenceNumber: endpointSequenceNumbers[to.ip] ?? 0, }); - return [defaultEp, leasedEp]; + // Match Go's GetEndpoints(): LEASED_IP first, then sort by sequenceNumber + return [leasedEp, defaultEp].sort((a, b) => a.sequenceNumber - b.sequenceNumber); }), ); } diff --git a/ts/test/functional/sdl-parity.spec.ts b/ts/test/functional/sdl-parity.spec.ts index f1e8263a..f0e2a800 100644 --- a/ts/test/functional/sdl-parity.spec.ts +++ b/ts/test/functional/sdl-parity.spec.ts @@ -14,13 +14,16 @@ const PROJECT_ROOT = path.join(__dirname, "../../.."); const FIXTURES_INPUT_ROOT = path.join(PROJECT_ROOT, "testdata/sdl/input"); const FIXTURES_OUTPUT_ROOT = path.join(PROJECT_ROOT, "testdata/sdl/output-fixtures"); const INPUT_SCHEMA_PATH = path.join(PROJECT_ROOT, "go/sdl/sdl-input.schema.yaml"); +const MANIFEST_OUTPUT_SCHEMA_PATH = path.join(PROJECT_ROOT, "specs/sdl/manifest-output.schema.yaml"); +const GROUPS_OUTPUT_SCHEMA_PATH = path.join(PROJECT_ROOT, "specs/sdl/groups-output.schema.yaml"); describe("SDL Parity Tests", () => { describe("v2.0", () => { loadFixtures("v2.0").forEach((fixture) => { it(fixture.name, () => { - const { manifest, expectedManifest } = setup(fixture); + const { manifest, expectedManifest, groupSpecs, expectedGroupSpecs } = setup(fixture); expect(manifest).toEqual(expectedManifest); + expect(groupSpecs).toEqual(expectedGroupSpecs); }); }); }); @@ -28,8 +31,9 @@ describe("SDL Parity Tests", () => { describe("v2.1", () => { loadFixtures("v2.1").forEach((fixture) => { it(fixture.name, () => { - const { manifest, expectedManifest } = setup(fixture); + const { manifest, expectedManifest, groupSpecs, expectedGroupSpecs } = setup(fixture); expect(manifest).toEqual(expectedManifest); + expect(groupSpecs).toEqual(expectedGroupSpecs); }); }); }); @@ -45,7 +49,7 @@ describe("SDL Parity Tests", () => { } fs.globSync("*.yaml", { cwd: invalidDir }).forEach((filename) => { - it(filename, () => { + it(filename, () => { const fixturePath = path.join(invalidDir, filename); const input = fs.readFileSync(fixturePath, "utf8"); const sdl: SDLInput = yaml.raw(input); @@ -55,6 +59,96 @@ describe("SDL Parity Tests", () => { }); }); + describe("semantic-only-invalid SDLs", () => { + const semanticInvalidDir = path.join(FIXTURES_INPUT_ROOT, "semantic-only-invalid"); + + if (!fs.existsSync(semanticInvalidDir)) { + it("semantic-only-invalid fixtures directory must exist", () => { + throw new Error(`Semantic-only-invalid fixtures directory not found: ${semanticInvalidDir}`); + }); + return; + } + + fs.globSync("*.yaml", { cwd: semanticInvalidDir }).forEach((filename) => { + it(`schema accepts: ${filename}`, () => { + const fixturePath = path.join(semanticInvalidDir, filename); + const input = fs.readFileSync(fixturePath, "utf8"); + const inputYAML: SDLInput = yaml.raw(input); + + const schemaValidator = compileSchema(INPUT_SCHEMA_PATH); + const schemaValid = schemaValidator(inputYAML); + expect(schemaValid).toBe(true); + }); + + it(`parser rejects: ${filename}`, () => { + const fixturePath = path.join(semanticInvalidDir, filename); + const input = fs.readFileSync(fixturePath, "utf8"); + const inputYAML: SDLInput = yaml.raw(input); + + const result = generateManifest(inputYAML); + expect(result.ok).toBe(false); + }); + }); + }); + + describe("schema-only-invalid SDLs", () => { + const schemaOnlyInvalidDir = path.join(FIXTURES_INPUT_ROOT, "schema-only-invalid"); + + if (!fs.existsSync(schemaOnlyInvalidDir)) { + it("schema-only-invalid fixtures directory must exist", () => { + throw new Error(`Schema-only-invalid fixtures directory not found: ${schemaOnlyInvalidDir}`); + }); + return; + } + + fs.globSync("*.yaml", { cwd: schemaOnlyInvalidDir }).forEach((filename) => { + it(`schema rejects: ${filename}`, () => { + const fixturePath = path.join(schemaOnlyInvalidDir, filename); + const input = fs.readFileSync(fixturePath, "utf8"); + const inputYAML: SDLInput = yaml.raw(input); + + const schemaValidator = compileSchema(INPUT_SCHEMA_PATH); + const schemaValid = schemaValidator(inputYAML); + expect(schemaValid).toBe(false); + }); + + it(`generateManifest also rejects: ${filename}`, () => { + const fixturePath = path.join(schemaOnlyInvalidDir, filename); + const input = fs.readFileSync(fixturePath, "utf8"); + const inputYAML: SDLInput = yaml.raw(input); + + const result = generateManifest(inputYAML); + expect(result.ok).toBe(false); + }); + }); + }); + + describe("canonical byte-level equality", () => { + const canonicalFixtures = [ + { version: "v2.0", name: "simple" }, + { version: "v2.0", name: "ip-endpoint" }, + { version: "v2.1", name: "credentials" }, + ]; + + canonicalFixtures.forEach(({ version, name }) => { + it(`${version}/${name} manifest canonical JSON matches`, () => { + const inputPath = path.join(FIXTURES_INPUT_ROOT, version, name, "input.yaml"); + const manifestPath = path.join(FIXTURES_OUTPUT_ROOT, version, name, "manifest.json"); + + const rawSDL = fs.readFileSync(inputPath, "utf8"); + const untrustedSDL: SDLInput = yaml.raw(rawSDL); + const result = generateManifest(untrustedSDL); + if (!result.ok) throw new Error(`generateManifest failed`); + + const canonicalTS = manifestToSortedJSON(result.value.groups); + const goFixture = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const canonicalGo = manifestToSortedJSON(goFixture); + + expect(canonicalTS).toBe(canonicalGo); + }); + }); + }); + function setup(fixture: Fixture) { const rawSDL = fs.readFileSync(fixture.inputPath, "utf8"); const untrustedSDL: SDLInput = yaml.raw(rawSDL); @@ -67,10 +161,18 @@ describe("SDL Parity Tests", () => { const manifest = JSON.parse(manifestToSortedJSON(result.value.groups), normalizeManifestJSON); const expectedManifest = JSON.parse(fs.readFileSync(fixture.manifestPath, "utf8")); + const groupSpecs = JSON.parse(manifestToSortedJSON(result.value.groupSpecs)); + const expectedGroupSpecs = JSON.parse(fs.readFileSync(fixture.groupSpecsPath, "utf8")); + + validateAgainstSchema("manifest output", manifest, MANIFEST_OUTPUT_SCHEMA_PATH); + validateAgainstSchema("groups output", groupSpecs, GROUPS_OUTPUT_SCHEMA_PATH); + return { manifest, - expectedManifest - } + expectedManifest, + groupSpecs, + expectedGroupSpecs, + }; } }); @@ -89,15 +191,21 @@ function loadFixtures(version: string): Fixture[] { const fixtureName = entry.name; const inputPath = path.join(inputVersionDir, fixtureName, "input.yaml"); const manifestPath = path.join(FIXTURES_OUTPUT_ROOT, version, fixtureName, "manifest.json"); + const groupSpecsPath = path.join(FIXTURES_OUTPUT_ROOT, version, fixtureName, "group-specs.json"); if (!fs.existsSync(manifestPath)) { throw new Error(`manifest.json not generated for ${fixtureName} (run: make generate-sdl-fixtures)`); } + if (!fs.existsSync(groupSpecsPath)) { + throw new Error(`group-specs.json not generated for ${fixtureName} (run: make generate-sdl-fixtures)`); + } + return { name: fixtureName, inputPath, manifestPath, + groupSpecsPath, }; }); } @@ -149,4 +257,5 @@ interface Fixture { name: string; inputPath: string; manifestPath: string; + groupSpecsPath: string; } From f68dcd22b9c9041fae96806cbd2f49caa454a2ac Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Wed, 1 Apr 2026 15:13:29 +0200 Subject: [PATCH 02/12] feat(sdl): align Go and TS SDL implementation: linter fix --- ts/test/functional/sdl-parity.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ts/test/functional/sdl-parity.spec.ts b/ts/test/functional/sdl-parity.spec.ts index f0e2a800..2c735578 100644 --- a/ts/test/functional/sdl-parity.spec.ts +++ b/ts/test/functional/sdl-parity.spec.ts @@ -1,8 +1,9 @@ +import fs from "node:fs"; +import path from "node:path"; + import { describe, expect, it } from "@jest/globals"; import type { ErrorObject, ValidateFunction } from "ajv"; import { Ajv } from "ajv"; -import fs from "node:fs"; -import path from "node:path"; import { LegacyDec } from "../../src/encoding/customTypes/LegacyDec.ts"; import { generateManifest } from "../../src/sdl/manifest/generateManifest.ts"; @@ -240,9 +241,9 @@ function compileSchema(schemaPath: string): ValidateFunction { } function normalizeManifestJSON(this: unknown, key: string, value: unknown): unknown { - if (typeof this !== 'object' || this === null) return value; + if (typeof this !== "object" || this === null) return value; - if (key === "amount" && 'denom' in this && this.denom !== undefined) { + if (key === "amount" && "denom" in this && this.denom !== undefined) { return LegacyDec.encode(value as string); } From 3e51e32e17a9000f4ce30ea4aa8372eb98c6ee02 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Wed, 1 Apr 2026 15:21:11 +0200 Subject: [PATCH 03/12] feat(sdl): align Go and TS SDL implementation: small refactor --- go/sdl/parity_test.go | 122 +++++++++++++++++++++++------------------- go/sdl/strict_test.go | 10 ++-- 2 files changed, 74 insertions(+), 58 deletions(-) diff --git a/go/sdl/parity_test.go b/go/sdl/parity_test.go index ba135f77..9aab135b 100644 --- a/go/sdl/parity_test.go +++ b/go/sdl/parity_test.go @@ -30,7 +30,6 @@ import ( "fmt" "os" "path/filepath" - "sync" "testing" "github.com/stretchr/testify/require" @@ -38,30 +37,70 @@ import ( "gopkg.in/yaml.v3" ) -const fixturesInputRoot = "../../testdata/sdl/input" -const fixturesOutputRoot = "../../testdata/sdl/output-fixtures" -const schemasRoot = "../../specs/sdl" // Output schemas for tests +type parityTestSuite struct { + fixturesInputRoot string + fixturesOutputRoot string + schemasRoot string -var ( - manifestSchema *gojsonschema.Schema - manifestSchemaOnce sync.Once - manifestSchemaErr error + manifestSchema *gojsonschema.Schema + groupsSchema *gojsonschema.Schema +} - groupsSchema *gojsonschema.Schema - groupsSchemaOnce sync.Once - groupsSchemaErr error -) +func newParityTestSuite(t *testing.T) *parityTestSuite { + t.Helper() + + s := &parityTestSuite{ + fixturesInputRoot: "../../testdata/sdl/input", + fixturesOutputRoot: "../../testdata/sdl/output-fixtures", + schemasRoot: "../../specs/sdl", + } + + var err error + s.manifestSchema, err = compileSchemaFromPath(filepath.Join(s.schemasRoot, "manifest-output.schema.yaml")) + require.NoError(t, err, "Failed to compile manifest output schema") + + s.groupsSchema, err = compileSchemaFromPath(filepath.Join(s.schemasRoot, "groups-output.schema.yaml")) + require.NoError(t, err, "Failed to compile groups output schema") + + return s +} + +func (s *parityTestSuite) validateInputSchema(t *testing.T, inputBytes []byte) { + t.Helper() + err := validateInputAgainstSchema(inputBytes) + require.NoError(t, err, "Input schema validation failed") +} + +func (s *parityTestSuite) validateOutputAgainstSchema(t *testing.T, manifestBytes []byte, groupSpecsBytes []byte) { + t.Helper() + + err := validateDataAgainstCompiledSchema(manifestBytes, s.manifestSchema) + require.NoError(t, err, "Manifest schema validation failed") + + err = validateDataAgainstCompiledSchema(groupSpecsBytes, s.groupsSchema) + require.NoError(t, err, "Groups schema validation failed") +} + +func (s *parityTestSuite) validateFixtureBytes(t *testing.T, expectedPath string, actualBytes []byte, name string) { + t.Helper() + expectedBytes, err := os.ReadFile(expectedPath) + require.NoError(t, err, "Failed to read expected %s", name) + + require.JSONEq(t, string(expectedBytes), string(actualBytes), "%s does not match expected output", name) +} func TestParityV2_0(t *testing.T) { - testParity(t, "v2.0") + s := newParityTestSuite(t) + s.testParity(t, "v2.0") } func TestParityV2_1(t *testing.T) { - testParity(t, "v2.1") + s := newParityTestSuite(t) + s.testParity(t, "v2.1") } -func testParity(t *testing.T, version string) { - inputDir := filepath.Join(fixturesInputRoot, version) +func (s *parityTestSuite) testParity(t *testing.T, version string) { + inputDir := filepath.Join(s.fixturesInputRoot, version) entries, err := os.ReadDir(inputDir) require.NoError(t, err) @@ -73,8 +112,8 @@ func testParity(t *testing.T, version string) { fixtureName := entry.Name() inputPath := filepath.Join(inputDir, fixtureName, "input.yaml") - manifestPath := filepath.Join(fixturesOutputRoot, version, fixtureName, "manifest.json") - groupSpecsPath := filepath.Join(fixturesOutputRoot, version, fixtureName, "group-specs.json") + manifestPath := filepath.Join(s.fixturesOutputRoot, version, fixtureName, "manifest.json") + groupSpecsPath := filepath.Join(s.fixturesOutputRoot, version, fixtureName, "group-specs.json") if _, err := os.Stat(manifestPath); os.IsNotExist(err) { t.Fatalf("manifest.json not generated for %s (run: make generate-sdl-fixtures)", fixtureName) @@ -88,7 +127,7 @@ func testParity(t *testing.T, version string) { inputBytes, err := os.ReadFile(inputPath) require.NoError(t, err) - validateInputSchema(t, inputBytes) + s.validateInputSchema(t, inputBytes) sdl, err := ReadFile(inputPath) require.NoError(t, err) @@ -105,46 +144,17 @@ func testParity(t *testing.T, version string) { groupSpecsBytes, err := json.Marshal(groupSpecs) require.NoError(t, err) - validateOutputAgainstSchema(t, manifestBytes, groupSpecsBytes) + s.validateOutputAgainstSchema(t, manifestBytes, groupSpecsBytes) - validateFixtureBytes(t, manifestPath, manifestBytes, "Manifest") - validateFixtureBytes(t, groupSpecsPath, groupSpecsBytes, "GroupSpecs") + s.validateFixtureBytes(t, manifestPath, manifestBytes, "Manifest") + s.validateFixtureBytes(t, groupSpecsPath, groupSpecsBytes, "GroupSpecs") }) } } -func validateInputSchema(t *testing.T, inputBytes []byte) { - err := validateInputAgainstSchema(inputBytes) - require.NoError(t, err, "Input schema validation failed") -} - -func validateOutputAgainstSchema(t *testing.T, manifestBytes []byte, groupSpecsBytes []byte) { - manifestSchemaOnce.Do(func() { - manifestSchema, manifestSchemaErr = compileSchemaFromPath(filepath.Join(schemasRoot, "manifest-output.schema.yaml")) - }) - require.NoError(t, manifestSchemaErr, "Failed to compile manifest schema") - - err := validateDataAgainstCompiledSchema(manifestBytes, manifestSchema) - require.NoError(t, err, "Manifest schema validation failed") - - groupsSchemaOnce.Do(func() { - groupsSchema, groupsSchemaErr = compileSchemaFromPath(filepath.Join(schemasRoot, "groups-output.schema.yaml")) - }) - require.NoError(t, groupsSchemaErr, "Failed to compile groups schema") - - err = validateDataAgainstCompiledSchema(groupSpecsBytes, groupsSchema) - require.NoError(t, err, "Groups schema validation failed") -} - -func validateFixtureBytes(t *testing.T, expectedPath string, actualBytes []byte, name string) { - expectedBytes, err := os.ReadFile(expectedPath) - require.NoError(t, err, "Failed to read expected %s", name) - - require.JSONEq(t, string(expectedBytes), string(actualBytes), "%s does not match expected output", name) -} - func TestInvalidSDLsRejected(t *testing.T) { - invalidDir := filepath.Join(fixturesInputRoot, "invalid") + s := newParityTestSuite(t) + invalidDir := filepath.Join(s.fixturesInputRoot, "invalid") entries, err := os.ReadDir(invalidDir) if os.IsNotExist(err) { @@ -170,7 +180,8 @@ func TestInvalidSDLsRejected(t *testing.T) { // rejected by both Go and TS parsers due to semantic constraints not expressible // in JSON Schema (e.g., unused endpoints, cross-reference errors, duplicate mounts). func TestSemanticOnlyInvalid(t *testing.T) { - semanticDir := filepath.Join(fixturesInputRoot, "semantic-only-invalid") + s := newParityTestSuite(t) + semanticDir := filepath.Join(s.fixturesInputRoot, "semantic-only-invalid") entries, err := os.ReadDir(semanticDir) if os.IsNotExist(err) { @@ -205,7 +216,8 @@ func TestSemanticOnlyInvalid(t *testing.T) { // the schema layer (e.g., string length limits, enum value constraints) but are // not enforced in the Go validation logic. func TestSchemaOnlyValidations(t *testing.T) { - schemaOnlyDir := filepath.Join(fixturesInputRoot, "schema-only-invalid") + s := newParityTestSuite(t) + schemaOnlyDir := filepath.Join(s.fixturesInputRoot, "schema-only-invalid") entries, err := os.ReadDir(schemaOnlyDir) if os.IsNotExist(err) { diff --git a/go/sdl/strict_test.go b/go/sdl/strict_test.go index 8548ab2f..1b7c072a 100644 --- a/go/sdl/strict_test.go +++ b/go/sdl/strict_test.go @@ -10,8 +10,10 @@ import ( // TestReadStrictValidInputs verifies that ReadFileStrict accepts all valid fixtures. func TestReadStrictValidInputs(t *testing.T) { + s := newParityTestSuite(t) + for _, version := range []string{"v2.0", "v2.1"} { - inputDir := filepath.Join(fixturesInputRoot, version) + inputDir := filepath.Join(s.fixturesInputRoot, version) entries, err := os.ReadDir(inputDir) require.NoError(t, err) @@ -35,7 +37,8 @@ func TestReadStrictValidInputs(t *testing.T) { // TestReadStrictRejectsInvalid verifies that ReadStrict rejects all invalid fixtures. func TestReadStrictRejectsInvalid(t *testing.T) { - invalidDir := filepath.Join(fixturesInputRoot, "invalid") + s := newParityTestSuite(t) + invalidDir := filepath.Join(s.fixturesInputRoot, "invalid") entries, err := os.ReadDir(invalidDir) if os.IsNotExist(err) { @@ -61,7 +64,8 @@ func TestReadStrictRejectsInvalid(t *testing.T) { // inputs that the schema rejects but the lenient Go parser accepts. // This is the key difference from ReadFile. func TestReadStrictRejectsSchemaOnlyInvalid(t *testing.T) { - schemaOnlyDir := filepath.Join(fixturesInputRoot, "schema-only-invalid") + s := newParityTestSuite(t) + schemaOnlyDir := filepath.Join(s.fixturesInputRoot, "schema-only-invalid") entries, err := os.ReadDir(schemaOnlyDir) if os.IsNotExist(err) { From f1aeb1dd416649239b2944ed7706fe5054e31b00 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Wed, 1 Apr 2026 15:36:05 +0200 Subject: [PATCH 04/12] feat(sdl): align Go and TS SDL implementation: docstring improvements --- go/sdl/parity_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/go/sdl/parity_test.go b/go/sdl/parity_test.go index 9aab135b..b4e760d7 100644 --- a/go/sdl/parity_test.go +++ b/go/sdl/parity_test.go @@ -46,6 +46,7 @@ type parityTestSuite struct { groupsSchema *gojsonschema.Schema } +// newParityTestSuite initializes a test suite with compiled output schemas. func newParityTestSuite(t *testing.T) *parityTestSuite { t.Helper() @@ -65,12 +66,14 @@ func newParityTestSuite(t *testing.T) *parityTestSuite { return s } +// validateInputSchema validates raw YAML bytes against the embedded SDL input schema. func (s *parityTestSuite) validateInputSchema(t *testing.T, inputBytes []byte) { t.Helper() err := validateInputAgainstSchema(inputBytes) require.NoError(t, err, "Input schema validation failed") } +// validateOutputAgainstSchema validates manifest and group-specs JSON against output schemas. func (s *parityTestSuite) validateOutputAgainstSchema(t *testing.T, manifestBytes []byte, groupSpecsBytes []byte) { t.Helper() @@ -81,6 +84,7 @@ func (s *parityTestSuite) validateOutputAgainstSchema(t *testing.T, manifestByte require.NoError(t, err, "Groups schema validation failed") } +// validateFixtureBytes compares generated JSON output against a committed fixture file. func (s *parityTestSuite) validateFixtureBytes(t *testing.T, expectedPath string, actualBytes []byte, name string) { t.Helper() expectedBytes, err := os.ReadFile(expectedPath) @@ -89,16 +93,19 @@ func (s *parityTestSuite) validateFixtureBytes(t *testing.T, expectedPath string require.JSONEq(t, string(expectedBytes), string(actualBytes), "%s does not match expected output", name) } +// TestParityV2_0 runs parity tests for SDL v2.0 fixtures. func TestParityV2_0(t *testing.T) { s := newParityTestSuite(t) s.testParity(t, "v2.0") } +// TestParityV2_1 runs parity tests for SDL v2.1 fixtures. func TestParityV2_1(t *testing.T) { s := newParityTestSuite(t) s.testParity(t, "v2.1") } +// testParity validates all fixtures for a given SDL version against Go parser output. func (s *parityTestSuite) testParity(t *testing.T, version string) { inputDir := filepath.Join(s.fixturesInputRoot, version) @@ -152,6 +159,7 @@ func (s *parityTestSuite) testParity(t *testing.T, version string) { } } +// TestInvalidSDLsRejected verifies that all invalid fixtures are rejected by the Go parser. func TestInvalidSDLsRejected(t *testing.T) { s := newParityTestSuite(t) invalidDir := filepath.Join(s.fixturesInputRoot, "invalid") @@ -245,6 +253,7 @@ func TestSchemaOnlyValidations(t *testing.T) { } } +// compileSchemaFromPath loads a YAML JSON Schema file and compiles it for validation. func compileSchemaFromPath(schemaPath string) (*gojsonschema.Schema, error) { schemaBytes, err := os.ReadFile(schemaPath) if err != nil { @@ -270,6 +279,7 @@ func compileSchemaFromPath(schemaPath string) (*gojsonschema.Schema, error) { return schema, nil } +// validateDataAgainstCompiledSchema validates JSON bytes against a pre-compiled schema. func validateDataAgainstCompiledSchema(data []byte, schema *gojsonschema.Schema) error { result, err := schema.Validate(gojsonschema.NewBytesLoader(data)) if err != nil { From 3c170ab0afc322543b21a4359c490e308283ef39 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Wed, 1 Apr 2026 15:40:48 +0200 Subject: [PATCH 05/12] feat(sdl): align Go and TS SDL implementation: short refactor --- docs/sdl-parity.md | 2 +- go/sdl/sdl.go | 47 ++++++++++++++++----------------------------- specs/sdl/README.md | 2 +- 3 files changed, 19 insertions(+), 32 deletions(-) diff --git a/docs/sdl-parity.md b/docs/sdl-parity.md index 69dd1dc2..00c8a058 100644 --- a/docs/sdl-parity.md +++ b/docs/sdl-parity.md @@ -48,7 +48,7 @@ Any SDL-related PR **must**: 1. Pass `make test-sdl-parity` (runs both Go and TS parity tests) 2. Update fixtures if parser output changes (`make generate-sdl-fixtures`) 3. Add new fixtures for new SDL features or edge cases -4. Keep the TS generated validator in sync with the schema (CI drift check) +4. Keep the TS-generated validator in sync with the schema (CI drift check) ### 4. Strict vs Lenient Parsing diff --git a/go/sdl/sdl.go b/go/sdl/sdl.go index 12b5c0e0..c97385a9 100644 --- a/go/sdl/sdl.go +++ b/go/sdl/sdl.go @@ -102,13 +102,23 @@ func Read(buf []byte) (sdlObj SDL, err error) { return nil, err } - if err = obj.validate(); err != nil { + if err = runPostUnmarshalValidation(obj); err != nil { return nil, err } + return obj, nil +} + +// runPostUnmarshalValidation runs semantic validation, deployment group validation, +// and manifest validation on an unmarshalled SDL object. +func runPostUnmarshalValidation(obj *sdl) error { + if err := obj.validate(); err != nil { + return err + } + dgroups, err := obj.DeploymentGroups() if err != nil { - return nil, err + return err } vgroups := make([]dtypes.GroupSpec, 0, len(dgroups)) @@ -117,19 +127,19 @@ func Read(buf []byte) (sdlObj SDL, err error) { } if err = dtypes.ValidateDeploymentGroups(vgroups); err != nil { - return nil, err + return err } m, err := obj.Manifest() if err != nil { - return nil, err + return err } if err = m.Validate(); err != nil { - return nil, err + return err } - return obj, nil + return nil } // Version creates the deterministic Deployment Version hash from the SDL. @@ -190,30 +200,7 @@ func ReadStrict(buf []byte) (SDL, error) { return nil, err } - if err := obj.validate(); err != nil { - return nil, err - } - - dgroups, err := obj.DeploymentGroups() - if err != nil { - return nil, err - } - - vgroups := make([]dtypes.GroupSpec, 0, len(dgroups)) - for _, dgroup := range dgroups { - vgroups = append(vgroups, dgroup) - } - - if err = dtypes.ValidateDeploymentGroups(vgroups); err != nil { - return nil, err - } - - m, err := obj.Manifest() - if err != nil { - return nil, err - } - - if err = m.Validate(); err != nil { + if err := runPostUnmarshalValidation(obj); err != nil { return nil, err } diff --git a/specs/sdl/README.md b/specs/sdl/README.md index 7fae9677..bf88f379 100644 --- a/specs/sdl/README.md +++ b/specs/sdl/README.md @@ -26,7 +26,7 @@ Schema validates structure only. **Go/TS parsers handle:** - Cross-references (deployment → profiles, params.storage → compute.storage) - Semantic constraints (unused endpoints, port collisions, mount uniqueness) -- Parser-level checks (count ≥ 1, unknown fields — TS validates, Go rejects during unmarshal) +- Parser-level checks (count ≥ 1, unknown fields — TS raises validation errors, Go rejects during unmarshal) ## Test Fixtures From f7b6cb0ab352e5e684877abb8a8684e806f5fc13 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Wed, 1 Apr 2026 16:50:40 +0200 Subject: [PATCH 06/12] feat(sdl): align Go and TS SDL implementation: resolve conflict --- ts/src/sdl/SDL/parity.spec.ts | 142 ---------------------------------- 1 file changed, 142 deletions(-) delete mode 100644 ts/src/sdl/SDL/parity.spec.ts diff --git a/ts/src/sdl/SDL/parity.spec.ts b/ts/src/sdl/SDL/parity.spec.ts deleted file mode 100644 index eff45895..00000000 --- a/ts/src/sdl/SDL/parity.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from "@jest/globals"; -import type { ErrorObject, ValidateFunction } from "ajv"; -import AjvModule from "ajv"; -import fs from "fs"; -import { load } from "js-yaml"; -import path from "path"; - -import { SDL } from "./SDL.ts"; - -const fixturesInputRoot = path.join(__dirname, "../../../../testdata/sdl/input"); -const fixturesOutputRoot = path.join(__dirname, "../../../../testdata/sdl/output-fixtures"); -const schemasRoot = path.join(__dirname, "../../../../specs/sdl"); -const inputSchemaPath = path.join(__dirname, "../../../../go/sdl/sdl-input.schema.yaml"); -// @ts-expect-error - AjvModule has non-standard export, cast needed for instantiation -const ajv: { compile: (schema: Record) => ValidateFunction } = new (AjvModule as unknown as new (options?: { allErrors?: boolean }) => typeof AjvModule)({ allErrors: true }); - -interface Fixture { - name: string; - inputPath: string; - manifestPath: string; - groupsPath: string; -} - -const schemaCache = new Map(); - -function compileSchema(schemaPath: string): ValidateFunction { - const cached = schemaCache.get(schemaPath); - if (cached) { - return cached; - } - - const schemaContent = fs.readFileSync(schemaPath, "utf8"); - const schema = load(schemaContent); - const validator = ajv.compile(schema as Record); - schemaCache.set(schemaPath, validator); - return validator; -} - -function validateAgainstSchema(name: string, data: unknown, schemaPath: string): void { - const validate = compileSchema(schemaPath); - const valid = validate(data); - - if (!valid && validate.errors) { - const errors = validate.errors.map((err: ErrorObject) => { - const errorPath = err.instancePath || "(root)"; - return `${errorPath}: ${err.message} [${err.keyword}]`; - }); - throw new Error(`${name} validation failed. Errors: ${JSON.stringify(errors, null, 2)}`); - } -} - -function loadFixtures(version: string): Fixture[] { - const inputVersionDir = path.join(fixturesInputRoot, version); - - if (!fs.existsSync(inputVersionDir)) { - throw new Error(`Fixtures directory ${inputVersionDir} does not exist`); - } - - const entries = fs.readdirSync(inputVersionDir, { withFileTypes: true }); - - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => { - const fixtureName = entry.name; - const inputPath = path.join(inputVersionDir, fixtureName, "input.yaml"); - const manifestPath = path.join(fixturesOutputRoot, version, fixtureName, "manifest.json"); - const groupsPath = path.join(fixturesOutputRoot, version, fixtureName, "groups.json"); - - if (!fs.existsSync(manifestPath)) { - throw new Error(`manifest.json not generated for ${fixtureName} (run: make generate-sdl-fixtures)`); - } - - if (!fs.existsSync(groupsPath)) { - throw new Error(`groups.json not generated for ${fixtureName} (run: make generate-sdl-fixtures)`); - } - - return { - name: fixtureName, - inputPath, - manifestPath, - groupsPath, - }; - }); -} - -function validateSchemas(inputBytes: string, version: "beta2" | "beta3") { - const inputYAML = load(inputBytes); - validateAgainstSchema("input", inputYAML, inputSchemaPath); - - const sdl = SDL.fromString(inputBytes, version); - const manifest = sdl.v3Manifest(false); - const groups = sdl.v3Groups(); - - validateAgainstSchema("manifest", manifest, path.join(schemasRoot, "manifest-output.schema.yaml")); - validateAgainstSchema("groups", groups, path.join(schemasRoot, "groups-output.schema.yaml")); - - return { sdl, manifest, groups }; -} - -function validateFixtures(fixture: Fixture, version: "beta2" | "beta3") { - const inputBytes = fs.readFileSync(fixture.inputPath, "utf8"); - const { manifest: actualManifest, groups: actualGroups } = validateSchemas(inputBytes, version); - - const expectedManifest = JSON.parse(fs.readFileSync(fixture.manifestPath, "utf8")); - const expectedGroups = JSON.parse(fs.readFileSync(fixture.groupsPath, "utf8")); - - expect(actualManifest).toEqual(expectedManifest); - expect(actualGroups).toEqual(expectedGroups); -} - -describe("SDL Parity Tests", () => { - describe("v2.0", () => { - loadFixtures("v2.0").forEach((fixture) => { - it(fixture.name, () => validateFixtures(fixture, "beta2")); - }); - }); - - describe("v2.1", () => { - loadFixtures("v2.1").forEach((fixture) => { - it(fixture.name, () => validateFixtures(fixture, "beta3")); - }); - }); - - describe("invalid SDLs rejected", () => { - const invalidDir = path.join(fixturesInputRoot, "invalid"); - - if (!fs.existsSync(invalidDir)) { - it.skip("invalid fixtures directory not found", () => {}); - return; - } - - fs.readdirSync(invalidDir) - .filter((f) => f.endsWith(".yaml")) - .forEach((filename) => { - it(filename, () => { - const fixturePath = path.join(invalidDir, filename); - const input = fs.readFileSync(fixturePath, "utf8"); - expect(() => SDL.fromString(input, "beta3")).toThrow(); - }); - }); - }); -}); From 252406670e9bc383f2fecd325dfb5e4ae44b82b8 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Thu, 2 Apr 2026 18:41:32 +0200 Subject: [PATCH 07/12] fix: review comments --- go/sdl/sdl.go | 20 +++++++++---------- .../sdl/manifest/generateManifestVersion.ts | 19 ++++++++++-------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/go/sdl/sdl.go b/go/sdl/sdl.go index c97385a9..c7669adb 100644 --- a/go/sdl/sdl.go +++ b/go/sdl/sdl.go @@ -102,23 +102,23 @@ func Read(buf []byte) (sdlObj SDL, err error) { return nil, err } - if err = runPostUnmarshalValidation(obj); err != nil { + if err = validateSDL(obj); err != nil { return nil, err } return obj, nil } -// runPostUnmarshalValidation runs semantic validation, deployment group validation, -// and manifest validation on an unmarshalled SDL object. -func runPostUnmarshalValidation(obj *sdl) error { +// validateSDL runs semantic validation, deployment group validation, +// and manifest validation on an SDL instance. +func validateSDL(obj *sdl) error { if err := obj.validate(); err != nil { - return err + return fmt.Errorf("sdl validation: %w", err) } dgroups, err := obj.DeploymentGroups() if err != nil { - return err + return fmt.Errorf("deployment groups: %w", err) } vgroups := make([]dtypes.GroupSpec, 0, len(dgroups)) @@ -127,16 +127,16 @@ func runPostUnmarshalValidation(obj *sdl) error { } if err = dtypes.ValidateDeploymentGroups(vgroups); err != nil { - return err + return fmt.Errorf("validate deployment groups: %w", err) } m, err := obj.Manifest() if err != nil { - return err + return fmt.Errorf("manifest: %w", err) } if err = m.Validate(); err != nil { - return err + return fmt.Errorf("validate manifest: %w", err) } return nil @@ -200,7 +200,7 @@ func ReadStrict(buf []byte) (SDL, error) { return nil, err } - if err := runPostUnmarshalValidation(obj); err != nil { + if err := validateSDL(obj); err != nil { return nil, err } diff --git a/ts/src/sdl/manifest/generateManifestVersion.ts b/ts/src/sdl/manifest/generateManifestVersion.ts index 74890311..8b6b600d 100644 --- a/ts/src/sdl/manifest/generateManifestVersion.ts +++ b/ts/src/sdl/manifest/generateManifestVersion.ts @@ -39,14 +39,7 @@ function manifestReplacer(this: unknown, key: string | number, value: unknown): // Format price amount as LegacyDec (18 decimal places) to match Go output if (key === "amount" && typeof this === "object" && this && Object.hasOwn(this, "denom") && typeof value === "string") { - const s = value; - if (!s) return "0.000000000000000000"; - if (s.includes(".")) { - const [, frac] = s.split("."); - const pad = 18 - (frac?.length ?? 0); - return pad > 0 ? s + "0".repeat(pad) : s; - } - return `${s}.${"0".repeat(18)}`; + return formatLegacyDec(value); } if (OMITTED_MANIFEST_KEYS.has(key) && ((Array.isArray(value) && value.length === 0) || value === 0)) { @@ -60,6 +53,16 @@ function manifestReplacer(this: unknown, key: string | number, value: unknown): return value; } +function formatLegacyDec(s: string): string { + if (!s) return "0.000000000000000000"; + if (s.includes(".")) { + const [, frac] = s.split("."); + const pad = 18 - (frac?.length ?? 0); + return pad > 0 ? s + "0".repeat(pad) : s; + } + return `${s}.${"0".repeat(18)}`; +} + const MANIFEST_VERSION_FIELD_MAPPING: Record = { quantity: "size", sequenceNumber: "sequence_number", From e59040add76fc5e1da7c268d7cb75da9547140c4 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Thu, 2 Apr 2026 19:13:00 +0200 Subject: [PATCH 08/12] fix: review comments --- ts/src/sdl/manifest/generateManifestVersion.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/src/sdl/manifest/generateManifestVersion.ts b/ts/src/sdl/manifest/generateManifestVersion.ts index 8b6b600d..0f4f6aa7 100644 --- a/ts/src/sdl/manifest/generateManifestVersion.ts +++ b/ts/src/sdl/manifest/generateManifestVersion.ts @@ -38,8 +38,8 @@ function manifestReplacer(this: unknown, key: string | number, value: unknown): } // Format price amount as LegacyDec (18 decimal places) to match Go output - if (key === "amount" && typeof this === "object" && this && Object.hasOwn(this, "denom") && typeof value === "string") { - return formatLegacyDec(value); + if (key === "amount" && typeof this === "object" && this && Object.hasOwn(this, "denom") && (typeof value === "string" || typeof value === "number")) { + return formatLegacyDec(String(value)); } if (OMITTED_MANIFEST_KEYS.has(key) && ((Array.isArray(value) && value.length === 0) || value === 0)) { From 6bfbce85e551073c0aaa2428514b21290539db7e Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Thu, 2 Apr 2026 20:06:05 +0200 Subject: [PATCH 09/12] fix: review comments --- ts/src/sdl/manifest/generateManifestVersion.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ts/src/sdl/manifest/generateManifestVersion.ts b/ts/src/sdl/manifest/generateManifestVersion.ts index 0f4f6aa7..de3d6b5b 100644 --- a/ts/src/sdl/manifest/generateManifestVersion.ts +++ b/ts/src/sdl/manifest/generateManifestVersion.ts @@ -55,10 +55,17 @@ function manifestReplacer(this: unknown, key: string | number, value: unknown): function formatLegacyDec(s: string): string { if (!s) return "0.000000000000000000"; + + // Normalize scientific notation (e.g. "1e-7") to plain decimal + if (s.includes("e") || s.includes("E")) { + s = Number(s).toFixed(18); + } + if (s.includes(".")) { - const [, frac] = s.split("."); - const pad = 18 - (frac?.length ?? 0); - return pad > 0 ? s + "0".repeat(pad) : s; + const [int, frac = ""] = s.split("."); + const truncated = frac.slice(0, 18); + const pad = 18 - truncated.length; + return pad > 0 ? `${int}.${truncated}${"0".repeat(pad)}` : `${int}.${truncated}`; } return `${s}.${"0".repeat(18)}`; } From 353540c7416896ccc8e355b0af46fce1d694ae4c Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Thu, 23 Apr 2026 10:41:28 +0200 Subject: [PATCH 10/12] fix: comments --- .../sdl/manifest/generateManifestVersion.ts | 23 ++++++++----------- ts/src/sdl/manifest/manifestUtils.ts | 3 +-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/ts/src/sdl/manifest/generateManifestVersion.ts b/ts/src/sdl/manifest/generateManifestVersion.ts index de3d6b5b..028e219f 100644 --- a/ts/src/sdl/manifest/generateManifestVersion.ts +++ b/ts/src/sdl/manifest/generateManifestVersion.ts @@ -1,5 +1,6 @@ import { default as stableStringify } from "json-stable-stringify"; +import { LegacyDec } from "../../encoding/customTypes/LegacyDec.ts"; import type { GenerateManifestOkResult, Manifest } from "./generateManifest.ts"; const decoder = new TextDecoder(); @@ -53,21 +54,17 @@ function manifestReplacer(this: unknown, key: string | number, value: unknown): return value; } +const LEGACY_DEC_PRECISION = 18; + function formatLegacyDec(s: string): string { if (!s) return "0.000000000000000000"; - - // Normalize scientific notation (e.g. "1e-7") to plain decimal - if (s.includes("e") || s.includes("E")) { - s = Number(s).toFixed(18); - } - - if (s.includes(".")) { - const [int, frac = ""] = s.split("."); - const truncated = frac.slice(0, 18); - const pad = 18 - truncated.length; - return pad > 0 ? `${int}.${truncated}${"0".repeat(pad)}` : `${int}.${truncated}`; - } - return `${s}.${"0".repeat(18)}`; + const atomics = LegacyDec.encode(s); + const sign = atomics.startsWith("-") ? "-" : ""; + const abs = sign ? atomics.slice(1) : atomics; + const padded = abs.padStart(LEGACY_DEC_PRECISION + 1, "0"); + const intPart = padded.slice(0, -LEGACY_DEC_PRECISION); + const fracPart = padded.slice(-LEGACY_DEC_PRECISION); + return `${sign}${intPart}.${fracPart}`; } const MANIFEST_VERSION_FIELD_MAPPING: Record = { diff --git a/ts/src/sdl/manifest/manifestUtils.ts b/ts/src/sdl/manifest/manifestUtils.ts index 65ae16d1..ff5aa687 100644 --- a/ts/src/sdl/manifest/manifestUtils.ts +++ b/ts/src/sdl/manifest/manifestUtils.ts @@ -133,8 +133,7 @@ export function buildServiceEndpoints( sequenceNumber: endpointSequenceNumbers[to.ip] ?? 0, }); - // Match Go's GetEndpoints(): LEASED_IP first, then sort by sequenceNumber - return [leasedEp, defaultEp].sort((a, b) => a.sequenceNumber - b.sequenceNumber); + return [defaultEp, leasedEp]; }), ); } From 430e4b0e5d07e1ea784e57618f25c3c7f8b19e14 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Thu, 23 Apr 2026 10:45:40 +0200 Subject: [PATCH 11/12] fix: comments --- ts/src/sdl/manifest/generateManifest.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ts/src/sdl/manifest/generateManifest.ts b/ts/src/sdl/manifest/generateManifest.ts index fc4a6e44..461873b6 100644 --- a/ts/src/sdl/manifest/generateManifest.ts +++ b/ts/src/sdl/manifest/generateManifest.ts @@ -130,14 +130,16 @@ export function generateManifest(sdl: SDLInput, networkId: NetworkId = MAINNET_I group.dgroup.resources[location].resource!.endpoints.push( ...buildServiceEndpoints(service, endpointSequenceNumbers), ); - // Sort after appending, matching Go's groupBuilder_v2_1.go behavior - group.dgroup.resources[location].resource!.endpoints.sort( - (a, b) => a.sequenceNumber - b.sequenceNumber, - ); } } } + for (const { dgroup } of groupsMap.values()) { + for (const resourceUnit of dgroup.resources) { + resourceUnit.resource!.endpoints.sort((a, b) => a.sequenceNumber - b.sequenceNumber); + } + } + const sortedGroupNames = [...groupsMap.keys()].sort(); let groups: Group[] | undefined; let groupSpecs: GroupSpec[] | undefined; From 6a125c665f1cc4155479056fe8efbbe6af2509e0 Mon Sep 17 00:00:00 2001 From: Vyacheslav Pryimak Date: Thu, 23 Apr 2026 11:21:56 +0200 Subject: [PATCH 12/12] fix: more tests added --- ts/src/sdl/manifest/generateManifest.ts | 9 +-- .../manifest/generateManifestVersion.spec.ts | 63 ++++++++++++++++++- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/ts/src/sdl/manifest/generateManifest.ts b/ts/src/sdl/manifest/generateManifest.ts index 461873b6..95e14125 100644 --- a/ts/src/sdl/manifest/generateManifest.ts +++ b/ts/src/sdl/manifest/generateManifest.ts @@ -130,16 +130,13 @@ export function generateManifest(sdl: SDLInput, networkId: NetworkId = MAINNET_I group.dgroup.resources[location].resource!.endpoints.push( ...buildServiceEndpoints(service, endpointSequenceNumbers), ); + group.dgroup.resources[location].resource!.endpoints.sort( + (a, b) => a.sequenceNumber - b.sequenceNumber, + ); } } } - for (const { dgroup } of groupsMap.values()) { - for (const resourceUnit of dgroup.resources) { - resourceUnit.resource!.endpoints.sort((a, b) => a.sequenceNumber - b.sequenceNumber); - } - } - const sortedGroupNames = [...groupsMap.keys()].sort(); let groups: Group[] | undefined; let groupSpecs: GroupSpec[] | undefined; diff --git a/ts/src/sdl/manifest/generateManifestVersion.spec.ts b/ts/src/sdl/manifest/generateManifestVersion.spec.ts index b1bb888f..72c1c638 100644 --- a/ts/src/sdl/manifest/generateManifestVersion.spec.ts +++ b/ts/src/sdl/manifest/generateManifestVersion.spec.ts @@ -621,6 +621,57 @@ describe(generateManifestVersion.name, () => { }); }); + describe("LegacyDec amount formatting", () => { + it("formats integer amount with 18 decimal places", () => { + const obj = [{ price: { denom: "uakt", amount: "1000" } }]; + const json = manifestToSortedJSON(obj as never); + + expect(json).toContain('"1000.000000000000000000"'); + }); + + it("formats decimal string amount to 18 places", () => { + const obj = [{ price: { denom: "uakt", amount: "1.5" } }]; + const json = manifestToSortedJSON(obj as never); + + expect(json).toContain('"1.500000000000000000"'); + }); + + it("formats zero amount", () => { + const obj = [{ price: { denom: "uakt", amount: "0" } }]; + const json = manifestToSortedJSON(obj as never); + + expect(json).toContain('"0.000000000000000000"'); + }); + + it("formats small decimal amount", () => { + const obj = [{ price: { denom: "uakt", amount: "0.001" } }]; + const json = manifestToSortedJSON(obj as never); + + expect(json).toContain('"0.001000000000000000"'); + }); + + it("formats numeric amount passed directly to replacer", () => { + const obj = [{ price: { denom: "uakt", amount: 100 } }]; + const json = manifestToSortedJSON(obj as never); + + expect(json).toContain('"100.000000000000000000"'); + }); + + it("formats numeric decimal amount passed directly to replacer", () => { + const obj = [{ price: { denom: "uakt", amount: 2.5 } }]; + const json = manifestToSortedJSON(obj as never); + + expect(json).toContain('"2.500000000000000000"'); + }); + + it("formats amount in groupSpecs through full pipeline", () => { + const { groupSpecs } = setupWithGroupSpecs({ amount: 5000 }); + const json = manifestToSortedJSON(groupSpecs); + + expect(json).toContain('"5000.000000000000000000"'); + }); + }); + function setup(options: { sdl?: SDLInput; image?: string; @@ -658,6 +709,13 @@ describe(generateManifestVersion.name, () => { } } + function setupWithGroupSpecs(options: { amount?: number | string } = {}) { + const sdl = createBasicSdl({ amount: options.amount }); + const result = generateManifest(sdl); + assertBuildResult(result); + return { groupSpecs: result.value.groupSpecs }; + } + function createBasicSdl(options: { image?: string; command?: string[]; @@ -670,8 +728,9 @@ describe(generateManifestVersion.name, () => { to?: Array<{ global?: boolean }>; accept?: string[]; }>; + amount?: number | string; } = {}): SDLInput { - const { image = "nginx", command, args, env, credentials, expose } = options; + const { image = "nginx", command, args, env, credentials, expose, amount = 1000 } = options; return { version: "2.0", @@ -704,7 +763,7 @@ describe(generateManifestVersion.name, () => { placement: { dcloud: { pricing: { - web: { denom: "uakt", amount: 1000 }, + web: { denom: "uakt", amount }, }, }, },