Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 66 additions & 0 deletions docs/sdl-parity.md
Original file line number Diff line number Diff line change
@@ -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
183 changes: 158 additions & 25 deletions go/sdl/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,87 @@ package sdl

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"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"
type parityTestSuite struct {
fixturesInputRoot string
fixturesOutputRoot string
schemasRoot string

manifestSchema *gojsonschema.Schema
groupsSchema *gojsonschema.Schema
}

// newParityTestSuite initializes a test suite with compiled output schemas.
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
}

// 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()

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")
}

// 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)
require.NoError(t, err, "Failed to read expected %s", name)

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) {
testParity(t, "v2.0")
s := newParityTestSuite(t)
s.testParity(t, "v2.0")
}

// TestParityV2_1 runs parity tests for SDL v2.1 fixtures.
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)
// 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)

entries, err := os.ReadDir(inputDir)
require.NoError(t, err)
Expand All @@ -58,8 +119,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)
Expand All @@ -73,7 +134,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)
Expand All @@ -90,26 +151,18 @@ func testParity(t *testing.T, version string) {
groupSpecsBytes, err := json.Marshal(groupSpecs)
require.NoError(t, err)

validateFixtureBytes(t, manifestPath, manifestBytes, "Manifest")
validateFixtureBytes(t, groupSpecsPath, groupSpecsBytes, "GroupSpecs")
s.validateOutputAgainstSchema(t, manifestBytes, groupSpecsBytes)

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 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)
}

// TestInvalidSDLsRejected verifies that all invalid fixtures are rejected by the Go parser.
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) {
Expand All @@ -131,12 +184,48 @@ 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) {
s := newParityTestSuite(t)
semanticDir := filepath.Join(s.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
// 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) {
Expand All @@ -163,3 +252,47 @@ 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 {
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
}

// 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 {
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
}
15 changes: 13 additions & 2 deletions go/sdl/schema_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading