feat: cli client with spec schema validation

This commit is contained in:
2023-02-28 15:50:35 +01:00
parent 2a828afc89
commit 3310c09320
51 changed files with 1929 additions and 82 deletions

View File

@ -1,5 +1,36 @@
package spec
import "errors"
import (
"strings"
var ErrSchemaUnknown = errors.New("schema unknown")
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
var ErrUnknownSchema = errors.New("unknown schema")
type ValidationError struct {
keyErrors []jsonschema.KeyError
}
func (e *ValidationError) Error() string {
var sb strings.Builder
if _, err := sb.WriteString("validation error: "); err != nil {
panic(errors.WithStack(err))
}
for i, err := range e.keyErrors {
if i != 0 {
if _, err := sb.WriteString(", "); err != nil {
panic(errors.WithStack(err))
}
}
if _, err := sb.WriteString(err.Error()); err != nil {
panic(errors.WithStack(err))
}
}
return sb.String()
}

View File

@ -1,35 +0,0 @@
package spec
const NameGateway Name = "gateway.emissary.cadoles.com"
type GatewayID string
type Gateway struct {
Revision int `json:"revision"`
Gateways map[GatewayID]GatewayEntry `json:"gateways"`
}
type GatewayEntry struct {
Address string `json:"address"`
Target string `json:"target"`
}
func (g *Gateway) SpecName() Name {
return NameGateway
}
func (g *Gateway) SpecRevision() int {
return g.Revision
}
func (g *Gateway) SpecData() any {
return struct {
Gateways map[GatewayID]GatewayEntry
}{Gateways: g.Gateways}
}
func NewGatewaySpec() *Gateway {
return &Gateway{
Gateways: make(map[GatewayID]GatewayEntry),
}
}

View File

@ -0,0 +1,17 @@
package gateway
import (
_ "embed"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
//go:embed schema.json
var schema []byte
func init() {
if err := spec.Register(NameGateway, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gateway.emissary.cadoles.com/spec.json",
"title": "GatewaySpec",
"description": "Emissary 'Gateway' specification",
"type": "object",
"properties": {
"gateways": {
"type": "object",
"patternProperties": {
".*": {
"type": "object",
"properties": {
"address": {
"type": "string"
},
"target": {
"type": "string"
}
},
"required": ["address", "target"],
"additionalProperties": false
}
}
}
},
"required": ["gateways"],
"additionalProperties": false
}

View File

@ -0,0 +1,37 @@
package gateway
import "forge.cadoles.com/Cadoles/emissary/internal/spec"
const NameGateway spec.Name = "gateway.emissary.cadoles.com"
type ID string
type Spec struct {
Revision int `json:"revision"`
Gateways map[ID]GatewayEntry `json:"gateways"`
}
type GatewayEntry struct {
Address string `json:"address"`
Target string `json:"target"`
}
func (s *Spec) SpecName() spec.Name {
return NameGateway
}
func (s *Spec) SpecRevision() int {
return s.Revision
}
func (s *Spec) SpecData() any {
return struct {
Gateways map[ID]GatewayEntry
}{Gateways: s.Gateways}
}
func NewSpec() *Spec {
return &Spec{
Gateways: make(map[ID]GatewayEntry),
}
}

View File

@ -0,0 +1,13 @@
{
"name": "gateway.emissary.cadoles.com",
"data": {
"gateways": {
"cadoles.com": {
"address": ":3003",
"target": "https://www.cadoles.com",
"foo": "bar"
}
}
},
"revision": 0
}

View File

@ -0,0 +1,11 @@
{
"name": "gateway.emissary.cadoles.com",
"data": {
"gateways": {
"cadoles.com": {
"address": ":3003"
}
}
},
"revision": 0
}

View File

@ -0,0 +1,12 @@
{
"name": "gateway.emissary.cadoles.com",
"data": {
"gateways": {
"cadoles.com": {
"address": ":3003",
"target": "https://www.cadoles.com"
}
}
},
"revision": 0
}

View File

@ -0,0 +1,75 @@
package gateway
import (
"context"
"encoding/json"
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
type validatorTestCase struct {
Name string
Source string
ExpectedResult bool
}
var validatorTestCases = []validatorTestCase{
{
Name: "SpecOK",
Source: "testdata/spec-ok.json",
ExpectedResult: true,
},
{
Name: "SpecMissingProp",
Source: "testdata/spec-missing-prop.json",
ExpectedResult: false,
},
{
Name: "SpecAdditionalProp",
Source: "testdata/spec-additional-prop.json",
ExpectedResult: false,
},
}
func TestValidator(t *testing.T) {
t.Parallel()
validator := spec.NewValidator()
if err := validator.Register(NameGateway, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
for _, tc := range validatorTestCases {
func(tc *validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
rawSpec, err := ioutil.ReadFile(tc.Source)
if err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
var spec spec.RawSpec
if err := json.Unmarshal(rawSpec, &spec); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
ctx := context.Background()
result, err := validator.Validate(ctx, &spec)
if e, g := tc.ExpectedResult, result; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)
}
if tc.ExpectedResult && err != nil {
t.Errorf("+%v", errors.WithStack(err))
}
})
}(&tc)
}
}

View File

@ -3,13 +3,13 @@ package spec
type Spec interface {
SpecName() Name
SpecRevision() int
SpecData() any
SpecData() map[string]any
}
type RawSpec struct {
Name Name `json:"name"`
Revision int `json:"revision"`
Data any `json:"data"`
Name Name `json:"name"`
Revision int `json:"revision"`
Data map[string]any `json:"data"`
}
func (s *RawSpec) SpecName() Name {
@ -20,6 +20,6 @@ func (s *RawSpec) SpecRevision() int {
return s.Revision
}
func (s *RawSpec) SpecData() any {
func (s *RawSpec) SpecData() map[string]any {
return s.Data
}

17
internal/spec/uci/init.go Normal file
View File

@ -0,0 +1,17 @@
package uci
import (
_ "embed"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
//go:embed schema.json
var schema []byte
func init() {
if err := spec.Register(NameUCI, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -0,0 +1,97 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://uci.emissary.cadoles.com/spec.json",
"title": "UCISpec",
"description": "Emissary 'UCI' specification",
"type": "object",
"properties": {
"config": {
"type": "object",
"properties": {
"packages": {
"type": "array",
"items": {
"$ref": "#/$defs/package"
}
}
},
"required": ["packages"],
"additionalProperties": false
},
"postImportCommands": {
"type": "array",
"items": {
"type": "object",
"properties": {
"command": {
"type": "string"
},
"args": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["command", "args"],
"additionalProperties": false
}
}
},
"required": ["config", "postImportCommands"],
"additionalProperties": false,
"$defs": {
"package": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"configs": {
"type": "array",
"items": {
"$ref": "#/$defs/config"
}
}
},
"required": ["name", "configs"],
"additionalProperties": false
},
"config": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"section": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"$ref": "#/$defs/option"
}
}
},
"required": ["name", "section", "options"],
"additionalProperties": false
},
"option": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["list", "option"]
},
"name": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["type", "name", "value"],
"additionalProperties": false
}
}
}

View File

@ -1,10 +1,13 @@
package spec
package uci
import "forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
import (
"forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
)
const NameUCI Name = "uci.emissary.cadoles.com"
const NameUCI spec.Name = "uci.emissary.cadoles.com"
type UCI struct {
type Spec struct {
Revision int `json:"revisions"`
Config *uci.UCI `json:"config"`
PostImportCommands []*UCIPostImportCommand `json:"postImportCommands"`
@ -15,23 +18,23 @@ type UCIPostImportCommand struct {
Args []string `json:"args"`
}
func (u *UCI) SpecName() Name {
func (s *Spec) SpecName() spec.Name {
return NameUCI
}
func (u *UCI) SpecRevision() int {
return u.Revision
func (s *Spec) SpecRevision() int {
return s.Revision
}
func (u *UCI) SpecData() any {
func (s *Spec) SpecData() any {
return struct {
Config *uci.UCI `json:"config"`
PostImportCommands []*UCIPostImportCommand `json:"postImportCommands"`
}{Config: u.Config, PostImportCommands: u.PostImportCommands}
}{Config: s.Config, PostImportCommands: s.PostImportCommands}
}
func NewUCISpec() *UCI {
return &UCI{
func NewSpec() *Spec {
return &Spec{
PostImportCommands: make([]*UCIPostImportCommand, 0),
}
}

View File

@ -0,0 +1,162 @@
{
"name": "uci.emissary.cadoles.com",
"data": {
"config": {
"packages": [
{
"name": "uhttpd",
"configs": [
{
"name": "uhttpd",
"section": "main",
"options": [
{
"type": "list",
"name": "listen_http",
"value": "0.0.0.0:8080"
},
{
"type": "list",
"name": "listen_http",
"value": "[::]:8080"
},
{
"type": "list",
"name": "listen_https",
"value": "0.0.0.0:8443"
},
{
"type": "list",
"name": "listen_https",
"value": "[::]:8443"
},
{
"type": "option",
"name": "redirect_https",
"value": "0"
},
{
"type": "option",
"name": "home",
"value": "/www"
},
{
"type": "option",
"name": "rfc1918_filter",
"value": "1"
},
{
"type": "option",
"name": "max_requests",
"value": "3"
},
{
"type": "option",
"name": "max_connections",
"value": "100"
},
{
"type": "option",
"name": "cert",
"value": "/etc/uhttpd.crt"
},
{
"type": "option",
"name": "key",
"value": "/etc/uhttpd.key"
},
{
"type": "option",
"name": "cgi_prefix",
"value": "/cgi-bin"
},
{
"type": "list",
"name": "lua_prefix",
"value": "/cgi-bin/luci=/usr/lib/lua/luci/sgi/uhttpd.lua"
},
{
"type": "option",
"name": "script_timeout",
"value": "60"
},
{
"type": "option",
"name": "network_timeout",
"value": "30"
},
{
"type": "option",
"name": "http_keepalive",
"value": "20"
},
{
"type": "option",
"name": "tcp_keepalive",
"value": "1"
},
{
"type": "option",
"name": "ubus_prefix"
}
]
},
{
"name": "cert",
"section": "defaults",
"options": [
{
"type": "option",
"name": "days",
"value": "730"
},
{
"type": "option",
"name": "key_type",
"value": "ec"
},
{
"type": "option",
"name": "bits",
"value": "2048"
},
{
"type": "option",
"name": "ec_curve",
"value": "P-256"
},
{
"type": "option",
"name": "country",
"value": "ZZ"
},
{
"type": "option",
"name": "state",
"value": "Somewhere"
},
{
"type": "option",
"name": "location",
"value": "Unknown"
},
{
"type": "option",
"name": "commonname",
"value": "OpenWrt"
}
]
}
]
}
]
},
"postImportCommands": [
{
"command": "reload_config",
"args": []
}
]
},
"revision": 0
}

163
internal/spec/uci/testdata/spec-ok.json vendored Normal file
View File

@ -0,0 +1,163 @@
{
"name": "uci.emissary.cadoles.com",
"data": {
"config": {
"packages": [
{
"name": "uhttpd",
"configs": [
{
"name": "uhttpd",
"section": "main",
"options": [
{
"type": "list",
"name": "listen_http",
"value": "0.0.0.0:8080"
},
{
"type": "list",
"name": "listen_http",
"value": "[::]:8080"
},
{
"type": "list",
"name": "listen_https",
"value": "0.0.0.0:8443"
},
{
"type": "list",
"name": "listen_https",
"value": "[::]:8443"
},
{
"type": "option",
"name": "redirect_https",
"value": "0"
},
{
"type": "option",
"name": "home",
"value": "/www"
},
{
"type": "option",
"name": "rfc1918_filter",
"value": "1"
},
{
"type": "option",
"name": "max_requests",
"value": "3"
},
{
"type": "option",
"name": "max_connections",
"value": "100"
},
{
"type": "option",
"name": "cert",
"value": "/etc/uhttpd.crt"
},
{
"type": "option",
"name": "key",
"value": "/etc/uhttpd.key"
},
{
"type": "option",
"name": "cgi_prefix",
"value": "/cgi-bin"
},
{
"type": "list",
"name": "lua_prefix",
"value": "/cgi-bin/luci=/usr/lib/lua/luci/sgi/uhttpd.lua"
},
{
"type": "option",
"name": "script_timeout",
"value": "60"
},
{
"type": "option",
"name": "network_timeout",
"value": "30"
},
{
"type": "option",
"name": "http_keepalive",
"value": "20"
},
{
"type": "option",
"name": "tcp_keepalive",
"value": "1"
},
{
"type": "option",
"name": "ubus_prefix",
"value": "/ubus"
}
]
},
{
"name": "cert",
"section": "defaults",
"options": [
{
"type": "option",
"name": "days",
"value": "730"
},
{
"type": "option",
"name": "key_type",
"value": "ec"
},
{
"type": "option",
"name": "bits",
"value": "2048"
},
{
"type": "option",
"name": "ec_curve",
"value": "P-256"
},
{
"type": "option",
"name": "country",
"value": "ZZ"
},
{
"type": "option",
"name": "state",
"value": "Somewhere"
},
{
"type": "option",
"name": "location",
"value": "Unknown"
},
{
"type": "option",
"name": "commonname",
"value": "OpenWrt"
}
]
}
]
}
]
},
"postImportCommands": [
{
"command": "reload_config",
"args": []
}
]
},
"revision": 0
}

View File

@ -0,0 +1,70 @@
package uci
import (
"context"
"encoding/json"
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
type validatorTestCase struct {
Name string
Source string
ExpectedResult bool
}
var validatorTestCases = []validatorTestCase{
{
Name: "SpecOK",
Source: "testdata/spec-ok.json",
ExpectedResult: true,
},
{
Name: "SpecMissingProp",
Source: "testdata/spec-missing-prop.json",
ExpectedResult: false,
},
}
func TestValidator(t *testing.T) {
t.Parallel()
validator := spec.NewValidator()
if err := validator.Register(NameUCI, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
for _, tc := range validatorTestCases {
func(tc *validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
rawSpec, err := ioutil.ReadFile(tc.Source)
if err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
var spec spec.RawSpec
if err := json.Unmarshal(rawSpec, &spec); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
ctx := context.Background()
result, err := validator.Validate(ctx, &spec)
if e, g := tc.ExpectedResult, result; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)
}
if tc.ExpectedResult && err != nil {
t.Errorf("+%v", errors.WithStack(err))
}
})
}(&tc)
}
}

View File

@ -0,0 +1,63 @@
package spec
import (
"context"
"encoding/json"
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
type Validator struct {
schemas map[Name]*jsonschema.Schema
}
func (v *Validator) Register(name Name, rawSchema []byte) error {
schema := &jsonschema.Schema{}
if err := json.Unmarshal(rawSchema, schema); err != nil {
return errors.Wrapf(err, "could not register spec shema '%s'", name)
}
v.schemas[name] = schema
return nil
}
func (v *Validator) Validate(ctx context.Context, spec Spec) (bool, error) {
schema, exists := v.schemas[spec.SpecName()]
if !exists {
return false, errors.WithStack(ErrUnknownSchema)
}
state := schema.Validate(ctx, spec.SpecData())
if !state.IsValid() {
return false, errors.WithStack(&ValidationError{*state.Errs})
}
return true, nil
}
func NewValidator() *Validator {
return &Validator{
schemas: make(map[Name]*jsonschema.Schema),
}
}
var defaultValidator = NewValidator()
func Register(name Name, rawSchema []byte) error {
if err := defaultValidator.Register(name, rawSchema); err != nil {
return errors.WithStack(err)
}
return nil
}
func Validate(ctx context.Context, spec Spec) (bool, error) {
ok, err := defaultValidator.Validate(ctx, spec)
if err != nil {
return ok, errors.WithStack(err)
}
return ok, nil
}