API d’introspection des définitions de spécifications #23

Merged
wpetit merged 1 commits from spec-def-api into master 2024-03-12 16:25:51 +01:00
69 changed files with 1468 additions and 273 deletions
Showing only changes of commit f612721b4e - Show all commits

View File

@ -20,6 +20,7 @@ clean:
rm -f emissary.sqlite* rm -f emissary.sqlite*
rm -f server-key.json rm -f server-key.json
rm -f agent-key.json rm -f agent-key.json
rm -f state.json
.PHONY: test .PHONY: test
test: test-go ## Executing tests test: test-go ## Executing tests

View File

@ -38,7 +38,7 @@ func (c *Controller) Name() string {
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error { func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
appSpec := spec.NewSpec() appSpec := spec.NewSpec()
if err := state.GetSpec(spec.Name, appSpec); err != nil { if err := state.GetSpec(spec.Name, spec.Version, appSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find app spec") logger.Info(ctx, "could not find app spec")
@ -50,7 +50,12 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", appSpec.SpecName()), logger.F("revision", appSpec.SpecRevision())) logger.Info(
ctx, "retrieved spec",
logger.F("name", appSpec.SpecDefinitionName()),
logger.F("version", appSpec.SpecDefinitionVersion()),
logger.F("revision", appSpec.SpecRevision()),
)
c.updateApps(ctx, appSpec) c.updateApps(ctx, appSpec)

View File

@ -11,7 +11,7 @@ import (
var schema []byte var schema []byte
func init() { func init() {
if err := spec.Register(Name, schema); err != nil { if err := spec.Register(string(Name), Version, schema); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://app.edge.emissary.cadoles.com/spec.json", "$id": "https://app.edge.emissary.cadoles.com/spec.json",
"title": "AppSpec", "title": "AppSpec",
"description": "Emissary 'App' specification", "description": "Emissary 'App' specification",
@ -78,7 +78,9 @@
"type": "string" "type": "string"
} }
}, },
"required": ["defaultUrlTemplate"], "required": [
"defaultUrlTemplate"
],
"additionalProperties": false "additionalProperties": false
}, },
"unexpectedHostRedirect": { "unexpectedHostRedirect": {
@ -94,7 +96,10 @@
"type": "string" "type": "string"
} }
}, },
"required": ["acceptedHostPatterns", "hostTarget"], "required": [
"acceptedHostPatterns",
"hostTarget"
],
"additionalProperties": false "additionalProperties": false
}, },
"auth": { "auth": {
@ -104,7 +109,10 @@
"type": "object", "type": "object",
"properties": { "properties": {
"key": { "key": {
"type": ["object", "string"] "type": [
"object",
"string"
]
}, },
"signingAlgorithm": { "signingAlgorithm": {
"type": "string" "type": "string"

View File

@ -6,7 +6,10 @@ import (
"github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwa"
) )
const Name spec.Name = "app.emissary.cadoles.com" const (
Name string = "app.emissary.cadoles.com"
Version string = "0.0.0"
)
type Spec struct { type Spec struct {
Revision int `json:"revision"` Revision int `json:"revision"`
@ -56,10 +59,14 @@ type AppURLResolving struct {
DefaultURLTemplate string `json:"defaultUrlTemplate"` DefaultURLTemplate string `json:"defaultUrlTemplate"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecDefinitionName() string {
return Name return Name
} }
func (s *Spec) SpecDefinitionVersion() string {
return Version
}
func (s *Spec) SpecRevision() int { func (s *Spec) SpecRevision() int {
return s.Revision return s.Revision
} }

View File

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(Name, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {

View File

@ -33,7 +33,7 @@ func (c *Controller) Name() string {
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error { func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
mdnsSpec := mdns.NewSpec() mdnsSpec := mdns.NewSpec()
if err := state.GetSpec(mdns.Name, mdnsSpec); err != nil { if err := state.GetSpec(mdns.Name, mdns.Version, mdnsSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find mdns spec") logger.Info(ctx, "could not find mdns spec")
@ -45,7 +45,11 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", mdnsSpec.SpecName()), logger.F("revision", mdnsSpec.SpecRevision())) logger.Info(ctx, "retrieved spec",
logger.F("name", mdnsSpec.SpecDefinitionName()),
logger.F("version", mdnsSpec.SpecDefinitionVersion()),
logger.F("revision", mdnsSpec.SpecRevision()),
)
if err := c.updateResponder(ctx, mdnsSpec); err != nil { if err := c.updateResponder(ctx, mdnsSpec); err != nil {
return errors.Wrap(err, "could not update responder") return errors.Wrap(err, "could not update responder")

View File

@ -11,7 +11,7 @@ import (
var schema []byte var schema []byte
func init() { func init() {
if err := spec.Register(Name, schema); err != nil { if err := spec.Register(string(Name), Version, schema); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://mdns.edge.emissary.cadoles.com/spec.json", "$id": "https://mdns.edge.emissary.cadoles.com/spec.json",
"title": "MDNSSpec", "title": "MDNSSpec",
"description": "Emissary 'MDNS' specification", "description": "Emissary 'MDNS' specification",

View File

@ -4,7 +4,10 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
) )
const Name spec.Name = "mdns.emissary.cadoles.com" const (
Name string = "mdns.emissary.cadoles.com"
Version string = "0.0.0"
)
type Spec struct { type Spec struct {
Revision int `json:"revision"` Revision int `json:"revision"`
@ -19,10 +22,14 @@ type Service struct {
Port int `json:"port"` Port int `json:"port"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecDefinitionName() string {
return Name return Name
} }
func (s *Spec) SpecDefinitionVersion() string {
return Version
}
func (s *Spec) SpecRevision() int { func (s *Spec) SpecRevision() int {
return s.Revision return s.Revision
} }

View File

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(Name, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {

View File

@ -11,7 +11,7 @@ import (
var schema []byte var schema []byte
func init() { func init() {
if err := spec.Register(Name, schema); err != nil { if err := spec.Register(string(Name), Version, schema); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://sysupgrade.openwrt.emissary.cadoles.com/spec.json", "$id": "https://sysupgrade.openwrt.emissary.cadoles.com/spec.json",
"title": "SysUpgradeSpec", "title": "SysUpgradeSpec",
"description": "Emissary 'SysUpgrade' specification", "description": "Emissary 'SysUpgrade' specification",
@ -15,6 +15,10 @@
"type": "string" "type": "string"
} }
}, },
"required": ["url", "sha256sum", "version"], "required": [
"url",
"sha256sum",
"version"
],
"additionalProperties": false "additionalProperties": false
} }

View File

@ -4,7 +4,10 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
) )
const Name spec.Name = "sysupgrade.openwrt.emissary.cadoles.com" const (
Name string = "sysupgrade.openwrt.emissary.cadoles.com"
Version string = "0.0.0"
)
type Spec struct { type Spec struct {
Revision int `json:"revision"` Revision int `json:"revision"`
@ -13,10 +16,14 @@ type Spec struct {
Version string `json:"version"` Version string `json:"version"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecDefinitionName() string {
return Name return Name
} }
func (s *Spec) SpecDefinitionVersion() string {
return Version
}
func (s *Spec) SpecRevision() int { func (s *Spec) SpecRevision() int {
return s.Revision return s.Revision
} }

View File

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(Name, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {

View File

@ -31,7 +31,7 @@ func (*SysUpgradeController) Name() string {
func (c *SysUpgradeController) Reconcile(ctx context.Context, state *agent.State) error { func (c *SysUpgradeController) Reconcile(ctx context.Context, state *agent.State) error {
sysSpec := sysupgrade.NewSpec() sysSpec := sysupgrade.NewSpec()
if err := state.GetSpec(sysupgrade.Name, sysSpec); err != nil { if err := state.GetSpec(sysupgrade.Name, sysupgrade.Version, sysSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find sysupgrade spec, doing nothing") logger.Info(ctx, "could not find sysupgrade spec, doing nothing")

View File

@ -27,7 +27,7 @@ func (*UCIController) Name() string {
func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error { func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error {
uciSpec := ucispec.NewSpec() uciSpec := ucispec.NewSpec()
if err := state.GetSpec(ucispec.NameUCI, uciSpec); err != nil { if err := state.GetSpec(ucispec.Name, ucispec.Version, uciSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find uci spec, doing nothing") logger.Info(ctx, "could not find uci spec, doing nothing")
@ -37,7 +37,11 @@ func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", uciSpec.SpecName()), logger.F("revision", uciSpec.SpecRevision())) logger.Info(ctx, "retrieved spec",
logger.F("name", uciSpec.SpecDefinitionName()),
logger.F("version", uciSpec.SpecDefinitionVersion()),
logger.F("revision", uciSpec.SpecRevision()),
)
if c.currentSpecRevision == uciSpec.SpecRevision() { if c.currentSpecRevision == uciSpec.SpecRevision() {
logger.Info(ctx, "spec revision did not change, doing nothing") logger.Info(ctx, "spec revision did not change, doing nothing")

View File

@ -9,13 +9,12 @@ import (
"path/filepath" "path/filepath"
"forge.cadoles.com/Cadoles/emissary/internal/agent" "forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
) )
type Controller struct { type Controller struct {
trackedSpecRevisions map[spec.Name]int trackedSpecRevisions map[string]map[string]int
filename string filename string
loaded bool loaded bool
} }
@ -78,8 +77,14 @@ func (c *Controller) specChanged(specs agent.Specs) bool {
return true return true
} }
for name, spec := range specs { for name, specVersions := range specs {
trackedRevision, exists := c.trackedSpecRevisions[name] trackedSpecs, exists := c.trackedSpecRevisions[name]
if !exists {
return true
}
for version, spec := range specVersions {
trackedRevision, exists := trackedSpecs[version]
if !exists { if !exists {
return true return true
} }
@ -89,25 +94,22 @@ func (c *Controller) specChanged(specs agent.Specs) bool {
} }
} }
for trackedSpecName, trackedRevision := range c.trackedSpecRevisions {
spec, exists := specs[trackedSpecName]
if !exists {
return true
}
if trackedRevision != spec.SpecRevision() {
return true
}
} }
return false return false
} }
func (c *Controller) trackSpecsRevisions(specs agent.Specs) { func (c *Controller) trackSpecsRevisions(specs agent.Specs) {
c.trackedSpecRevisions = make(map[spec.Name]int) c.trackedSpecRevisions = make(map[string]map[string]int)
for name, spec := range specs { for name, specVersions := range specs {
c.trackedSpecRevisions[name] = spec.SpecRevision() if _, exists := c.trackedSpecRevisions[name]; !exists {
c.trackedSpecRevisions[name] = make(map[string]int)
}
for version, spec := range specVersions {
c.trackedSpecRevisions[name][version] = spec.SpecRevision()
}
} }
} }
@ -167,7 +169,7 @@ func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
} }
name := f.Name() name := f.Name()
if err := ioutil.WriteFile(name, data, os.ModePerm); err != nil { if err := os.WriteFile(name, data, os.ModePerm); err != nil {
return errors.Errorf("cannot write data to temporary file %q: %v", name, err) return errors.Errorf("cannot write data to temporary file %q: %v", name, err)
} }
@ -213,7 +215,7 @@ func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
func NewController(filename string) *Controller { func NewController(filename string) *Controller {
return &Controller{ return &Controller{
filename: filename, filename: filename,
trackedSpecRevisions: make(map[spec.Name]int), trackedSpecRevisions: make(map[string]map[string]int),
} }
} }

View File

@ -30,7 +30,7 @@ func (c *Controller) Name() string {
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error { func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
proxySpec := spec.NewSpec() proxySpec := spec.NewSpec()
if err := state.GetSpec(spec.NameProxy, proxySpec); err != nil { if err := state.GetSpec(spec.Name, spec.Version, proxySpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find proxy spec") logger.Info(ctx, "could not find proxy spec")
@ -42,7 +42,12 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", proxySpec.SpecName()), logger.F("revision", proxySpec.SpecRevision())) logger.Info(
ctx, "retrieved spec",
logger.F("name", proxySpec.SpecDefinitionName()),
logger.F("version", proxySpec.SpecDefinitionVersion()),
logger.F("revision", proxySpec.SpecRevision()),
)
c.updateProxies(ctx, proxySpec) c.updateProxies(ctx, proxySpec)

View File

@ -11,7 +11,7 @@ import (
var ErrSpecNotFound = errors.New("spec not found") var ErrSpecNotFound = errors.New("spec not found")
type Specs map[spec.Name]spec.Spec type Specs map[string]map[string]spec.Spec
type State struct { type State struct {
agentID datastore.AgentID agentID datastore.AgentID
@ -20,27 +20,35 @@ type State struct {
func NewState() *State { func NewState() *State {
return &State{ return &State{
specs: make(map[spec.Name]spec.Spec), specs: make(map[string]map[string]spec.Spec),
} }
} }
func (s *State) MarshalJSON() ([]byte, error) { func (s *State) MarshalJSON() ([]byte, error) {
state := struct { state := struct {
ID datastore.AgentID `json:"agentId"` ID datastore.AgentID `json:"agentId"`
Specs map[spec.Name]*spec.RawSpec `json:"specs"` Specs map[string]map[string]*spec.RawSpec `json:"specs"`
}{ }{
ID: s.agentID, ID: s.agentID,
Specs: func(specs map[spec.Name]spec.Spec) map[spec.Name]*spec.RawSpec { Specs: func(specs map[string]map[string]spec.Spec) map[string]map[string]*spec.RawSpec {
rawSpecs := make(map[spec.Name]*spec.RawSpec) rawSpecs := make(map[string]map[string]*spec.RawSpec)
for name, sp := range specs { for name, versions := range specs {
rawSpecs[name] = &spec.RawSpec{ if _, exists := rawSpecs[name]; !exists {
Name: sp.SpecName(), rawSpecs[name] = make(map[string]*spec.RawSpec)
}
for version, sp := range versions {
rawSpecs[name][version] = &spec.RawSpec{
DefinitionName: sp.SpecDefinitionName(),
DefinitionVersion: sp.SpecDefinitionVersion(),
Revision: sp.SpecRevision(), Revision: sp.SpecRevision(),
Data: sp.SpecData(), Data: sp.SpecData(),
} }
} }
}
return rawSpecs return rawSpecs
}(s.specs), }(s.specs),
} }
@ -56,18 +64,23 @@ func (s *State) MarshalJSON() ([]byte, error) {
func (s *State) UnmarshalJSON(data []byte) error { func (s *State) UnmarshalJSON(data []byte) error {
state := struct { state := struct {
AgentID datastore.AgentID `json:"agentId"` AgentID datastore.AgentID `json:"agentId"`
Specs map[spec.Name]*spec.RawSpec `json:"specs"` Specs map[string]map[string]*spec.RawSpec `json:"specs"`
}{} }{}
if err := json.Unmarshal(data, &state); err != nil { if err := json.Unmarshal(data, &state); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
s.specs = func(rawSpecs map[spec.Name]*spec.RawSpec) Specs { s.specs = func(rawSpecs map[string]map[string]*spec.RawSpec) Specs {
specs := make(Specs) specs := make(Specs)
for name, raw := range rawSpecs { for name, versions := range rawSpecs {
specs[name] = spec.Spec(raw) if _, exists := specs[name]; !exists {
specs[name] = make(map[string]spec.Spec)
}
for version, raw := range versions {
specs[name][version] = spec.Spec(raw)
}
} }
return specs return specs
@ -85,23 +98,36 @@ func (s *State) Specs() Specs {
} }
func (s *State) ClearSpecs() *State { func (s *State) ClearSpecs() *State {
s.specs = make(map[spec.Name]spec.Spec) s.specs = make(map[string]map[string]spec.Spec)
return s return s
} }
func (s *State) SetSpec(sp spec.Spec) *State { func (s *State) SetSpec(sp spec.Spec) *State {
if s.specs == nil { if s.specs == nil {
s.specs = make(map[spec.Name]spec.Spec) s.specs = make(map[string]map[string]spec.Spec)
} }
s.specs[sp.SpecName()] = sp name := sp.SpecDefinitionName()
if _, exists := s.specs[name]; !exists {
s.specs[name] = make(map[string]spec.Spec)
}
version := sp.SpecDefinitionVersion()
s.specs[name][version] = sp
return s return s
} }
func (s *State) GetSpec(name spec.Name, dest any) error { func (s *State) GetSpec(name string, version string, dest any) error {
spec, exists := s.specs[name] versions, exists := s.specs[name]
if !exists {
return errors.WithStack(ErrSpecNotFound)
}
spec, exists := versions[version]
if !exists { if !exists {
return errors.WithStack(ErrSpecNotFound) return errors.WithStack(ErrSpecNotFound)
} }

View File

@ -6,7 +6,6 @@ import (
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -23,6 +22,11 @@ func DeleteCommand() *cli.Command {
Name: "spec-name", Name: "spec-name",
Usage: "use `NAME` as specification's name", Usage: "use `NAME` as specification's name",
}, },
&cli.StringFlag{
Name: "spec-version",
Usage: "use `VERSION` as specification's version",
Value: "0.0.0",
},
), ),
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx) baseFlags := clientFlag.GetBaseFlags(ctx)
@ -37,14 +41,19 @@ func DeleteCommand() *cli.Command {
return errors.WithStack(err) return errors.WithStack(err)
} }
specName, err := assertSpecName(ctx) specDefName, err := assertSpecDefName(ctx)
if err != nil {
return errors.WithStack(err)
}
specDefVersion, err := assertSpecDefVersion(ctx)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
client := client.New(baseFlags.ServerURL, client.WithToken(token)) client := client.New(baseFlags.ServerURL, client.WithToken(token))
specName, err = client.DeleteAgentSpec(ctx.Context, agentID, specName) specDefName, specDefVersion, err = client.DeleteAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
if err != nil { if err != nil {
return errors.WithStack(apierr.Wrap(err)) return errors.WithStack(apierr.Wrap(err))
} }
@ -54,9 +63,11 @@ func DeleteCommand() *cli.Command {
} }
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct { if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
Name spec.Name `json:"name"` Name string `json:"name"`
Version string `json:"version"`
}{ }{
Name: specName, Name: specDefName,
Version: specDefVersion,
}); err != nil { }); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }

View File

@ -36,9 +36,7 @@ func GetCommand() *cli.Command {
return errors.WithStack(apierr.Wrap(err)) return errors.WithStack(apierr.Wrap(err))
} }
hints := format.Hints{ hints := specHints(baseFlags.OutputMode)
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil { if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil {
return errors.WithStack(err) return errors.WithStack(err)

View File

@ -0,0 +1,21 @@
package spec
import (
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
)
func specHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("ID", "ID"),
format.NewProp("Revision", "Revision"),
format.NewProp("DefinitionName", "Def. Name"),
format.NewProp("DefinitionVersion", "Def. Version"),
format.NewProp("Data", "Data"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
},
}
}

View File

@ -24,6 +24,11 @@ func UpdateCommand() *cli.Command {
Name: "spec-name", Name: "spec-name",
Usage: "use `NAME` as specification's name", Usage: "use `NAME` as specification's name",
}, },
&cli.StringFlag{
Name: "spec-version",
Usage: "use `VERSION` as specification's version",
Value: "0.0.0",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "spec-data", Name: "spec-data",
Usage: "use `DATA` as specification's data, '-' to read from STDIN", Usage: "use `DATA` as specification's data, '-' to read from STDIN",
@ -44,7 +49,12 @@ func UpdateCommand() *cli.Command {
return errors.WithStack(err) return errors.WithStack(err)
} }
specName, err := assertSpecName(ctx) specDefName, err := assertSpecDefName(ctx)
if err != nil {
return errors.WithStack(err)
}
specDefVersion, err := assertSpecDefVersion(ctx)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -71,7 +81,7 @@ func UpdateCommand() *cli.Command {
var existingSpec spec.Spec var existingSpec spec.Spec
for _, s := range specs { for _, s := range specs {
if s.SpecName() != specName { if s.SpecDefinitionName() != specDefName || s.SpecDefinitionVersion() != specDefVersion {
continue continue
} }
@ -100,23 +110,18 @@ func UpdateCommand() *cli.Command {
} }
rawSpec := &spec.RawSpec{ rawSpec := &spec.RawSpec{
Name: specName, DefinitionName: specDefName,
DefinitionVersion: specDefVersion,
Revision: revision, Revision: revision,
Data: specData, Data: specData,
} }
if err := spec.Validate(ctx.Context, rawSpec); err != nil {
return errors.WithStack(apierr.Wrap(err))
}
spec, err := client.UpdateAgentSpec(ctx.Context, agentID, rawSpec) spec, err := client.UpdateAgentSpec(ctx.Context, agentID, rawSpec)
if err != nil { if err != nil {
return errors.WithStack(apierr.Wrap(err)) return errors.WithStack(apierr.Wrap(err))
} }
hints := format.Hints{ hints := specHints(baseFlags.OutputMode)
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, spec); err != nil { if err := format.Write(baseFlags.Format, os.Stdout, hints, spec); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
@ -127,14 +132,24 @@ func UpdateCommand() *cli.Command {
} }
} }
func assertSpecName(ctx *cli.Context) (spec.Name, error) { func assertSpecDefName(ctx *cli.Context) (string, error) {
specName := ctx.String("spec-name") specDefName := ctx.String("spec-name")
if specName == "" { if specDefName == "" {
return "", errors.New("flag 'spec-name' is required") return "", errors.New("flag 'spec-name' is required")
} }
return spec.Name(specName), nil return specDefName, nil
}
func assertSpecDefVersion(ctx *cli.Context) (string, error) {
specDefVersion := ctx.String("spec-version")
if specDefVersion == "" {
return "", errors.New("flag 'spec-name' is required")
}
return specDefVersion, nil
} }
func assertSpecData(ctx *cli.Context) (map[string]any, error) { func assertSpecData(ctx *cli.Context) (map[string]any, error) {

View File

@ -18,9 +18,9 @@ type AgentRepository interface {
Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error) Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error)
Delete(ctx context.Context, id AgentID) error Delete(ctx context.Context, id AgentID) error
UpdateSpec(ctx context.Context, id AgentID, name string, revision int, data map[string]any) (*Spec, error) UpdateSpec(ctx context.Context, id AgentID, name string, version string, revision int, data map[string]any) (*Spec, error)
GetSpecs(ctx context.Context, id AgentID) ([]*Spec, error) GetSpecs(ctx context.Context, id AgentID) ([]*Spec, error)
DeleteSpec(ctx context.Context, id AgentID, name string) error DeleteSpec(ctx context.Context, id AgentID, name string, version string) error
} }
type AgentQueryOptionFunc func(*AgentQueryOptions) type AgentQueryOptionFunc func(*AgentQueryOptions)

View File

@ -0,0 +1,166 @@
package memory
import (
"context"
"slices"
"sync"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
)
type specDefRecord struct {
Schema []byte
CreatedAt time.Time
UpdatedAt time.Time
}
type SpecDefinitionRepository struct {
definitions map[string]map[string]specDefRecord
mutex sync.RWMutex
}
// Delete implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Delete(ctx context.Context, name string, version string) error {
r.mutex.Lock()
defer r.mutex.Unlock()
versions, exists := r.definitions[name]
if !exists {
return nil
}
delete(versions, version)
r.definitions[name] = versions
return nil
}
// Get implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Get(ctx context.Context, name string, version string) (*datastore.SpecDefinition, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
versions, exists := r.definitions[name]
if !exists {
return nil, errors.WithStack(datastore.ErrNotFound)
}
rec, exists := versions[version]
if !exists {
return nil, errors.WithStack(datastore.ErrNotFound)
}
specDef := datastore.SpecDefinition{
SpecDefinitionHeader: datastore.SpecDefinitionHeader{
Name: name,
Version: version,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
},
Schema: rec.Schema[:],
}
return &specDef, nil
}
// Query implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Query(ctx context.Context, opts ...datastore.SpecDefinitionQueryOptionFunc) ([]datastore.SpecDefinitionHeader, int, error) {
options := &datastore.SpecDefinitionQueryOptions{}
for _, fn := range opts {
fn(options)
}
r.mutex.RLock()
defer r.mutex.RUnlock()
specDefs := make([]datastore.SpecDefinitionHeader, 0)
count := 0
for name, versions := range r.definitions {
for version, rec := range versions {
count++
matches := true
if options.Names != nil && !slices.Contains(options.Names, name) {
matches = false
}
if options.Versions != nil && !slices.Contains(options.Versions, version) {
matches = false
}
if options.Offset != nil && count < *options.Offset {
matches = false
}
if options.Limit != nil && len(specDefs) >= *options.Limit {
matches = false
}
if !matches {
continue
}
specDefs = append(specDefs, datastore.SpecDefinitionHeader{
Name: name,
Version: version,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
})
}
}
return specDefs, count, nil
}
// Upsert implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Upsert(ctx context.Context, name string, version string, schema []byte) (*datastore.SpecDefinition, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
versions, exists := r.definitions[name]
if !exists {
versions = make(map[string]specDefRecord)
}
now := time.Now().UTC()
rec, exists := versions[version]
if !exists {
rec = specDefRecord{
CreatedAt: now,
UpdatedAt: now,
Schema: schema[:],
}
} else {
rec.UpdatedAt = now
rec.Schema = schema
}
versions[version] = rec
r.definitions[name] = versions
specDef := datastore.SpecDefinition{
SpecDefinitionHeader: datastore.SpecDefinitionHeader{
Name: name,
Version: version,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
},
Schema: rec.Schema[:],
}
return &specDef, nil
}
func NewSpecDefinitionRepository() *SpecDefinitionRepository {
return &SpecDefinitionRepository{
definitions: make(map[string]map[string]specDefRecord),
}
}
var _ datastore.SpecDefinitionRepository = &SpecDefinitionRepository{}

View File

@ -0,0 +1,14 @@
package memory
import (
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"gitlab.com/wpetit/goweb/logger"
)
func TestMemorySpecDefinitionRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
repo := NewSpecDefinitionRepository()
testsuite.TestSpecDefinitionRepository(t, repo)
}

View File

@ -2,15 +2,14 @@ package datastore
import ( import (
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
) )
type SpecID int64 type SpecID int64
type Spec struct { type Spec struct {
ID SpecID `json:"id"` ID SpecID `json:"id"`
Name string `json:"name"` DefinitionName string `json:"name"`
DefinitionVersion string `json:"version"`
Data map[string]any `json:"data"` Data map[string]any `json:"data"`
Revision int `json:"revision"` Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
@ -19,8 +18,12 @@ type Spec struct {
AgentID AgentID `json:"agentId"` AgentID AgentID `json:"agentId"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecDefinitionName() string {
return spec.Name(s.Name) return s.DefinitionName
}
func (s *Spec) SpecDefinitionVersion() string {
return s.DefinitionVersion
} }
func (s *Spec) SpecRevision() int { func (s *Spec) SpecRevision() int {

View File

@ -0,0 +1,18 @@
package datastore
import (
"time"
)
type SpecDefinitionHeader struct {
Name string `json:"name"`
Version string `json:"version"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SpecDefinition struct {
SpecDefinitionHeader
Schema []byte `json:"schema"`
}

View File

@ -0,0 +1,46 @@
package datastore
import (
"context"
)
type SpecDefinitionRepository interface {
Upsert(ctx context.Context, name string, version string, schema []byte) (*SpecDefinition, error)
Get(ctx context.Context, name string, version string) (*SpecDefinition, error)
Delete(ctx context.Context, name string, version string) error
Query(ctx context.Context, opts ...SpecDefinitionQueryOptionFunc) ([]SpecDefinitionHeader, int, error)
}
type SpecDefinitionQueryOptionFunc func(*SpecDefinitionQueryOptions)
type SpecDefinitionQueryOptions struct {
Limit *int
Offset *int
Names []string
Versions []string
}
func WithSpecDefinitionQueryLimit(limit int) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Limit = &limit
}
}
func WithSpecDefinitionQueryOffset(offset int) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Offset = &offset
}
}
func WithSpecDefinitionQueryNames(names ...string) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Names = names
}
}
func WithSpecDefinitionQueryVersions(versions ...string) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Versions = versions
}
}

View File

@ -128,7 +128,7 @@ func (r *AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID)
} }
// DeleteSpec implements datastore.AgentRepository. // DeleteSpec implements datastore.AgentRepository.
func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string) error { func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string, version string) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID) exists, err := r.agentExists(ctx, tx, agentID)
if err != nil { if err != nil {
@ -139,9 +139,9 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
return errors.WithStack(datastore.ErrNotFound) return errors.WithStack(datastore.ErrNotFound)
} }
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2` query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2 AND version = $3`
if _, err = tx.ExecContext(ctx, query, agentID, name); err != nil { if _, err = tx.ExecContext(ctx, query, agentID, name, version); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -169,7 +169,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
} }
query := ` query := `
SELECT id, name, revision, data, created_at, updated_at SELECT id, name, version, revision, data, created_at, updated_at, agent_id, tenant_id
FROM specs FROM specs
WHERE agent_id = $1 WHERE agent_id = $1
` `
@ -191,10 +191,14 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
data := JSONMap{} data := JSONMap{}
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil { var tenantID sql.NullString
if err := rows.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &tenantID); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String)
}
spec.Data = data spec.Data = data
specs = append(specs, spec) specs = append(specs, spec)
@ -214,7 +218,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
} }
// UpdateSpec implements datastore.AgentRepository. // UpdateSpec implements datastore.AgentRepository.
func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.AgentID, name string, revision int, data map[string]any) (*datastore.Spec, error) { func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.AgentID, name string, version string, revision int, data map[string]any) (*datastore.Spec, error) {
spec := &datastore.Spec{} spec := &datastore.Spec{}
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
@ -230,23 +234,24 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
now := time.Now().UTC() now := time.Now().UTC()
query := ` query := `
INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at, tenant_id) INSERT INTO specs (agent_id, name, version, revision, data, created_at, updated_at, tenant_id)
VALUES($1, $2, $3, $4, $5, $5, ( SELECT tenant_id FROM agents WHERE id = $1 )) VALUES($1, $2, $3, $4, $5, $6, $6, ( SELECT tenant_id FROM agents WHERE id = $1 ))
ON CONFLICT (agent_id, name) DO UPDATE SET ON CONFLICT (agent_id, name, version) DO UPDATE SET
data = $4, updated_at = $5, revision = specs.revision + 1 data = $5, updated_at = $6, revision = specs.revision + 1, tenant_id = ( SELECT tenant_id FROM agents WHERE id = $1 )
WHERE revision = $3 WHERE revision = $4
RETURNING "id", "name", "revision", "data", "created_at", "updated_at" RETURNING "id", "name", "version", "revision", "data", "created_at", "updated_at", "tenant_id", "agent_id"
` `
args := []any{agentID, name, revision, JSONMap(data), now} args := []any{agentID, name, version, revision, JSONMap(data), now}
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
row := tx.QueryRowContext(ctx, query, args...) row := tx.QueryRowContext(ctx, query, args...)
data := JSONMap{} data := JSONMap{}
var tenantID sql.NullString
err = row.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt) err = row.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &tenantID, &spec.AgentID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrUnexpectedRevision) return errors.WithStack(datastore.ErrUnexpectedRevision)
@ -255,6 +260,10 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
return errors.WithStack(err) return errors.WithStack(err)
} }
if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String)
}
spec.Data = data spec.Data = data
return nil return nil
@ -301,6 +310,10 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
} }
if options.TenantIDs != nil && len(options.TenantIDs) > 0 { if options.TenantIDs != nil && len(options.TenantIDs) > 0 {
if filters != "" {
filters += " AND "
}
filter, newArgs, newParamIndex := inFilter("tenant_id", paramIndex, options.TenantIDs) filter, newArgs, newParamIndex := inFilter("tenant_id", paramIndex, options.TenantIDs)
filters += filter filters += filter
paramIndex = newParamIndex paramIndex = newParamIndex

View File

@ -7,6 +7,42 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
type JSON struct {
value any
}
func (j JSON) Scan(value interface{}) error {
if value == nil {
return nil
}
var data []byte
switch typ := value.(type) {
case []byte:
data = typ
case string:
data = []byte(typ)
default:
return errors.Errorf("unexpected type '%T'", value)
}
if err := json.Unmarshal(data, &j.value); err != nil {
return errors.WithStack(err)
}
return nil
}
func (j JSON) Value() (driver.Value, error) {
data, err := json.Marshal(j.value)
if err != nil {
return nil, errors.WithStack(err)
}
return data, nil
}
type JSONMap map[string]any type JSONMap map[string]any
func (j *JSONMap) Scan(value interface{}) error { func (j *JSONMap) Scan(value interface{}) error {

View File

@ -0,0 +1,219 @@
package sqlite
import (
"context"
"database/sql"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type SpecDefinitionRepository struct {
repository
}
// Delete implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Delete(ctx context.Context, name string, version string) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
if exists, err := r.specDefinitionExists(ctx, tx, name, version); !exists {
return errors.WithStack(err)
}
query := `DELETE FROM spec_definitions WHERE name = $1 AND version = $2`
_, err := tx.ExecContext(ctx, query, name, version)
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
// Get implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Get(ctx context.Context, name string, version string) (*datastore.SpecDefinition, error) {
var specDef datastore.SpecDefinition
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `
SELECT "name", "version", "schema", "created_at", "updated_at"
FROM spec_definitions
WHERE name = $1 AND version = $2
`
row := tx.QueryRowContext(ctx, query, name, version)
if err := row.Scan(&specDef.Name, &specDef.Version, &specDef.Schema, &specDef.CreatedAt, &specDef.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrNotFound)
}
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &specDef, nil
}
// Query implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Query(ctx context.Context, opts ...datastore.SpecDefinitionQueryOptionFunc) ([]datastore.SpecDefinitionHeader, int, error) {
options := &datastore.SpecDefinitionQueryOptions{}
for _, fn := range opts {
fn(options)
}
specDefs := make([]datastore.SpecDefinitionHeader, 0)
count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT name, version, created_at, updated_at FROM spec_definitions`
limit := 10
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
filters := ""
paramIndex := 3
args := []any{offset, limit}
if options.Names != nil && len(options.Names) > 0 {
filter, newArgs, newParamIndex := inFilter("name", paramIndex, options.Names)
filters += filter
paramIndex = newParamIndex
args = append(args, newArgs...)
}
if options.Versions != nil && len(options.Versions) > 0 {
if filters != "" {
filters += " AND "
}
filter, newArgs, _ := inFilter("version", paramIndex, options.Versions)
filters += filter
args = append(args, newArgs...)
}
if filters != "" {
filters = ` WHERE ` + filters
}
query += filters + ` LIMIT $2 OFFSET $1`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not close rows", logger.CapturedE(err))
}
}()
for rows.Next() {
sdh := datastore.SpecDefinitionHeader{}
if err := rows.Scan(&sdh.Name, &sdh.Version, &sdh.CreatedAt, &sdh.UpdatedAt); err != nil {
return errors.WithStack(err)
}
specDefs = append(specDefs, sdh)
}
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
row := tx.QueryRowContext(ctx, `SELECT count(*) FROM spec_definitions `+filters, args...)
if err := row.Scan(&count); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, 0, errors.WithStack(err)
}
return specDefs, count, nil
}
// Upsert implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Upsert(ctx context.Context, name string, version string, schema []byte) (*datastore.SpecDefinition, error) {
var specDef datastore.SpecDefinition
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
now := time.Now().UTC()
query := `
INSERT INTO spec_definitions (name, version, schema, created_at, updated_at)
VALUES($1, $2, $3, $4, $4)
ON CONFLICT(name, version) DO UPDATE SET schema = $3, updated_at = $4
RETURNING "name", "version", "schema", "created_at", "updated_at"
`
row := tx.QueryRowContext(
ctx, query,
name, version, schema, now, now,
)
if err := row.Scan(&specDef.Name, &specDef.Version, &specDef.Schema, &specDef.CreatedAt, &specDef.UpdatedAt); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &specDef, nil
}
func (r *SpecDefinitionRepository) specDefinitionExists(ctx context.Context, tx *sql.Tx, name string, version string) (bool, error) {
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM spec_definitions WHERE name = $1 AND version = $2`, name, version)
var count int
if err := row.Scan(&count); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, errors.WithStack(datastore.ErrNotFound)
}
return false, errors.WithStack(err)
}
if count == 0 {
return false, errors.WithStack(datastore.ErrNotFound)
}
return true, nil
}
func NewSpecDefinitionRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *SpecDefinitionRepository {
return &SpecDefinitionRepository{
repository: repository{db, sqliteBusyRetryMaxAttempts},
}
}
var _ datastore.SpecDefinitionRepository = &SpecDefinitionRepository{}

View File

@ -0,0 +1,46 @@
package sqlite
import (
"database/sql"
"fmt"
"os"
"testing"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "modernc.org/sqlite"
)
func TestSQLiteSpecDefinitionRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
file := "testdata/spec_definition_repository_test.sqlite"
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%+v", errors.WithStack(err))
}
dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
migr, err := migrate.New("../../../migrations", "sqlite", "sqlite://"+dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if err := migr.Up(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
repo := NewSpecDefinitionRepository(db, 5)
testsuite.TestSpecDefinitionRepository(t, repo)
}

View File

@ -15,7 +15,7 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func TestSQLiteTeantRepository(t *testing.T) { func TestSQLiteTenantRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug) logger.SetLevel(logger.LevelDebug)
file := "testdata/tenant_repository_test.sqlite" file := "testdata/tenant_repository_test.sqlite"

View File

@ -50,7 +50,7 @@ var agentRepositoryTestCases = []agentRepositoryTestCase{
var unexistantAgentID datastore.AgentID = 9999 var unexistantAgentID datastore.AgentID = 9999
var specData map[string]any var specData map[string]any
agent, err := repo.UpdateSpec(ctx, unexistantAgentID, string(spec.Name), 0, specData) agent, err := repo.UpdateSpec(ctx, unexistantAgentID, spec.Name, spec.Version, 0, specData)
if err == nil { if err == nil {
return errors.New("error should not be nil") return errors.New("error should not be nil")
} }
@ -71,7 +71,7 @@ var agentRepositoryTestCases = []agentRepositoryTestCase{
Run: func(ctx context.Context, repo datastore.AgentRepository) error { Run: func(ctx context.Context, repo datastore.AgentRepository) error {
var unexistantAgentID datastore.AgentID = 9999 var unexistantAgentID datastore.AgentID = 9999
err := repo.DeleteSpec(ctx, unexistantAgentID, string(spec.Name)) err := repo.DeleteSpec(ctx, unexistantAgentID, spec.Name, spec.Version)
if err == nil { if err == nil {
return errors.New("error should not be nil") return errors.New("error should not be nil")
} }

View File

@ -0,0 +1,14 @@
package testsuite
import (
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
)
func TestSpecDefinitionRepository(t *testing.T, repo datastore.SpecDefinitionRepository) {
t.Run("Cases", func(t *testing.T) {
t.Parallel()
runSpecDefinitionRepositoryTests(t, repo)
})
}

View File

@ -0,0 +1,76 @@
package testsuite
import (
"context"
"reflect"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
)
type specDefinitionRepositoryTestCase struct {
Name string
Skip bool
Run func(ctx context.Context, repo datastore.SpecDefinitionRepository) error
}
var specDefinitionRepositoryTestCases = []specDefinitionRepositoryTestCase{
{
Name: "Create a spec definition",
Run: func(ctx context.Context, repo datastore.SpecDefinitionRepository) error {
schema := []byte("{}")
name := "net.example.foo"
version := "0.0.0"
specDef, err := repo.Upsert(ctx, name, version, schema)
if err != nil {
return errors.WithStack(err)
}
if specDef.CreatedAt.IsZero() {
return errors.Errorf("specDef.CreatedAt should not be zero time")
}
if specDef.UpdatedAt.IsZero() {
return errors.Errorf("specDef.UpdatedAt should not be zero time")
}
if e, g := name, specDef.Name; e != g {
return errors.Errorf("specDef.Name: expected '%v', got '%v'", e, g)
}
if e, g := version, specDef.Version; e != g {
return errors.Errorf("specDef.Name: expected '%v', got '%v'", e, g)
}
if e, g := schema, specDef.Schema; !reflect.DeepEqual(e, g) {
return errors.Errorf("specDef.Schema: expected '%v', got '%v'", e, g)
}
return nil
},
},
}
func runSpecDefinitionRepositoryTests(t *testing.T, repo datastore.SpecDefinitionRepository) {
for _, tc := range specDefinitionRepositoryTestCases {
func(tc specDefinitionRepositoryTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
if tc.Skip {
t.SkipNow()
return
}
ctx := context.Background()
if err := tc.Run(ctx, repo); err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}

View File

@ -9,42 +9,12 @@ import (
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
) )
func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
specs, err := m.agentRepo.GetSpecs(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not list specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"`
}{
Specs: specs,
})
}
type deleteSpecRequest struct { type deleteSpecRequest struct {
Name string `json:"name"` Name string `json:"name" validate:"required"`
Version string `json:"version" validate:"required"`
} }
func (m *Mount) deleteSpec(w http.ResponseWriter, r *http.Request) { func (m *Mount) deleteAgentSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r) agentID, ok := getAgentID(w, r)
if !ok { if !ok {
return return
@ -61,6 +31,7 @@ func (m *Mount) deleteSpec(w http.ResponseWriter, r *http.Request) {
ctx, ctx,
agentID, agentID,
deleteSpecReq.Name, deleteSpecReq.Name,
deleteSpecReq.Version,
) )
if err != nil { if err != nil {
if errors.Is(err, datastore.ErrNotFound) { if errors.Is(err, datastore.ErrNotFound) {
@ -79,7 +50,9 @@ func (m *Mount) deleteSpec(w http.ResponseWriter, r *http.Request) {
api.DataResponse(w, http.StatusOK, struct { api.DataResponse(w, http.StatusOK, struct {
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"`
}{ }{
Name: deleteSpecReq.Name, Name: deleteSpecReq.Name,
Version: deleteSpecReq.Version,
}) })
} }

View File

@ -0,0 +1,41 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
specs, err := m.agentRepo.GetSpecs(ctx, agentID)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not list specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"`
}{
Specs: specs,
})
}

View File

@ -0,0 +1,44 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) getSpecDefinition(w http.ResponseWriter, r *http.Request) {
specDefName, specDefVersion, ok := getSpecDefinitionNameAndVersion(w, r)
if !ok {
return
}
ctx := r.Context()
specDef, err := m.specDefRepo.Get(
ctx,
specDefName,
specDefVersion,
)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not get agent", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
SpecDefinition *datastore.SpecDefinition `json:"specDefinition"`
}{
SpecDefinition: specDef,
})
}

View File

@ -35,20 +35,11 @@ func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool
return datastore.AgentID(agentID), true return datastore.AgentID(agentID), true
} }
func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) { func getSpecDefinitionNameAndVersion(w http.ResponseWriter, r *http.Request) (string, string, bool) {
rawSpecID := chi.URLParam(r, "specID") specDefName := chi.URLParam(r, "specDefName")
specDefVersion := chi.URLParam(r, "specDefVersion")
specID, err := strconv.ParseInt(rawSpecID, 10, 64) return specDefName, specDefVersion, true
if err != nil {
err = errors.WithStack(err)
logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
}
return datastore.SpecID(specID), true
} }
func getTenantID(w http.ResponseWriter, r *http.Request) (datastore.TenantID, bool) { func getTenantID(w http.ResponseWriter, r *http.Request) (datastore.TenantID, bool) {

View File

@ -12,6 +12,7 @@ import (
type Mount struct { type Mount struct {
agentRepo datastore.AgentRepository agentRepo datastore.AgentRepository
tenantRepo datastore.TenantRepository tenantRepo datastore.TenantRepository
specDefRepo datastore.SpecDefinitionRepository
authenticators []auth.Authenticator authenticators []auth.Authenticator
} }
@ -35,8 +36,8 @@ func (m *Mount) Mount(r chi.Router) {
r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent) r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent)
r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs", m.getAgentSpecs) r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs", m.getAgentSpecs)
r.With(assertUserWithWriteAccess).Post("/{agentID}/specs", m.updateSpec) r.With(assertUserWithWriteAccess).Post("/{agentID}/specs", m.updateAgentSpec)
r.With(assertUserWithWriteAccess).Delete("/{agentID}/specs", m.deleteSpec) r.With(assertUserWithWriteAccess).Delete("/{agentID}/specs", m.deleteAgentSpec)
}) })
r.Route("/tenants", func(r chi.Router) { r.Route("/tenants", func(r chi.Router) {
@ -46,6 +47,11 @@ func (m *Mount) Mount(r chi.Router) {
r.With(assertAdminOrTenantWriteAccess).Put("/{tenantID}", m.updateTenant) r.With(assertAdminOrTenantWriteAccess).Put("/{tenantID}", m.updateTenant)
r.With(assertAdminAccess).Delete("/{tenantID}", m.deleteTenant) r.With(assertAdminAccess).Delete("/{tenantID}", m.deleteTenant)
}) })
r.Route("/specs", func(r chi.Router) {
r.With(assertQueryAccess).Get("/", m.querySpecDefinitions)
r.With(assertQueryAccess).Get("/{specDefName}/{specDefVersion}", m.getSpecDefinition)
})
}) })
} }
@ -53,6 +59,6 @@ func (m *Mount) notFound(w http.ResponseWriter, r *http.Request) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
} }
func NewMount(agentRepo datastore.AgentRepository, tenantRepo datastore.TenantRepository, authenticators ...auth.Authenticator) *Mount { func NewMount(agentRepo datastore.AgentRepository, tenantRepo datastore.TenantRepository, specDefRepo datastore.SpecDefinitionRepository, authenticators ...auth.Authenticator) *Mount {
return &Mount{agentRepo, tenantRepo, authenticators} return &Mount{agentRepo, tenantRepo, specDefRepo, authenticators}
} }

View File

@ -0,0 +1,67 @@
package api
import (
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
func (m *Mount) querySpecDefinitions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
limit, ok := getIntQueryParam(w, r, "limit", 10)
if !ok {
return
}
offset, ok := getIntQueryParam(w, r, "offset", 0)
if !ok {
return
}
options := []datastore.SpecDefinitionQueryOptionFunc{
datastore.WithSpecDefinitionQueryLimit(int(limit)),
datastore.WithSpecDefinitionQueryOffset(int(offset)),
}
names, ok := getStringSliceValues(w, r, "names", nil)
if !ok {
return
}
if len(names) > 0 {
options = append(options, datastore.WithSpecDefinitionQueryNames(names...))
}
versions, ok := getStringSliceValues(w, r, "versions", nil)
if !ok {
return
}
if len(names) > 0 {
options = append(options, datastore.WithSpecDefinitionQueryVersions(versions...))
}
specDefinitions, total, err := m.specDefRepo.Query(
ctx,
options...,
)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not list spec definitions", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
api.DataResponse(w, http.StatusOK, struct {
SpecDefinitions []datastore.SpecDefinitionHeader `json:"specDefinitions"`
Total int `json:"total"`
}{
SpecDefinitions: specDefinitions,
Total: total,
})
}

View File

@ -14,11 +14,11 @@ const (
ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision" ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision"
) )
type updateSpecRequest struct { type updateAgentSpecRequest struct {
spec.RawSpec spec.RawSpec
} }
func (m *Mount) updateSpec(w http.ResponseWriter, r *http.Request) { func (m *Mount) updateAgentSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r) agentID, ok := getAgentID(w, r)
if !ok { if !ok {
return return
@ -26,12 +26,18 @@ func (m *Mount) updateSpec(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
updateSpecReq := &updateSpecRequest{} updateSpecReq := &updateAgentSpecRequest{}
if ok := api.Bind(w, r, updateSpecReq); !ok { if ok := api.Bind(w, r, updateSpecReq); !ok {
return return
} }
if err := spec.Validate(ctx, updateSpecReq); err != nil { if updateSpecReq.DefinitionVersion == "" {
updateSpecReq.DefinitionVersion = spec.DefaultVersion
}
validator := spec.NewValidator(m.specDefRepo)
if err := validator.Validate(ctx, updateSpecReq); err != nil {
data := struct { data := struct {
Message string `json:"message"` Message string `json:"message"`
}{} }{}
@ -53,7 +59,8 @@ func (m *Mount) updateSpec(w http.ResponseWriter, r *http.Request) {
spec, err := m.agentRepo.UpdateSpec( spec, err := m.agentRepo.UpdateSpec(
ctx, ctx,
datastore.AgentID(agentID), datastore.AgentID(agentID),
string(updateSpecReq.SpecName()), updateSpecReq.SpecDefinitionName(),
updateSpecReq.SpecDefinitionVersion(),
updateSpecReq.SpecRevision(), updateSpecReq.SpecRevision(),
updateSpecReq.SpecData(), updateSpecReq.SpecData(),
) )

View File

@ -4,7 +4,9 @@ import (
"context" "context"
"forge.cadoles.com/Cadoles/emissary/internal/setup" "forge.cadoles.com/Cadoles/emissary/internal/setup"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
) )
func (s *Server) initRepositories(ctx context.Context) error { func (s *Server) initRepositories(ctx context.Context) error {
@ -18,8 +20,35 @@ func (s *Server) initRepositories(ctx context.Context) error {
return errors.WithStack(err) return errors.WithStack(err)
} }
specDefRepo, err := setup.NewSpecDefinitionRepository(ctx, s.conf.Database)
if err != nil {
return errors.WithStack(err)
}
s.agentRepo = agentRepo s.agentRepo = agentRepo
s.tenantRepo = tenantRepo s.tenantRepo = tenantRepo
s.specDefRepo = specDefRepo
return nil
}
func (s *Server) initSpecDefinitions(ctx context.Context) error {
err := spec.Walk(func(name, version string, schema []byte) error {
logger.Debug(
ctx, "updating spec definition",
logger.F("name", name),
logger.F("version", version),
)
if _, err := s.specDefRepo.Upsert(ctx, name, version, schema); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil return nil
} }

View File

@ -31,6 +31,7 @@ type Server struct {
conf config.ServerConfig conf config.ServerConfig
agentRepo datastore.AgentRepository agentRepo datastore.AgentRepository
tenantRepo datastore.TenantRepository tenantRepo datastore.TenantRepository
specDefRepo datastore.SpecDefinitionRepository
} }
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) { func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
@ -57,6 +58,12 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
return return
} }
if err := s.initSpecDefinitions(ctx); err != nil {
errs <- errors.WithStack(err)
return
}
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.conf.HTTP.Host, s.conf.HTTP.Port)) listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.conf.HTTP.Host, s.conf.HTTP.Port))
if err != nil { if err != nil {
errs <- errors.WithStack(err) errs <- errors.WithStack(err)
@ -105,6 +112,7 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
apiMount := api.NewMount( apiMount := api.NewMount(
s.agentRepo, s.agentRepo,
s.tenantRepo, s.tenantRepo,
s.specDefRepo,
userAuth, userAuth,
agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew), agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew),
) )

View File

@ -104,3 +104,40 @@ func NewTenantRepository(ctx context.Context, conf config.DatabaseConfig) (datas
return tenantRepository, nil return tenantRepository, nil
} }
func NewSpecDefinitionRepository(ctx context.Context, conf config.DatabaseConfig) (datastore.SpecDefinitionRepository, error) {
driver := string(conf.Driver)
dsn := string(conf.DSN)
var specDefRepository datastore.SpecDefinitionRepository
logger.Debug(ctx, "initializing spec definition repository", logger.F("driver", driver), logger.F("dsn", dsn))
switch driver {
case config.DatabaseDriverPostgres:
// TODO
// pool, err := openPostgresPool(ctx, dsn)
// if err != nil {
// return nil, errors.WithStack(err)
// }
// entryRepository = postgres.NewEntryRepository(pool)
case config.DatabaseDriverSQLite:
url, err := url.Parse(dsn)
if err != nil {
return nil, errors.WithStack(err)
}
db, err := sql.Open(driver, url.Host+url.Path)
if err != nil {
return nil, errors.WithStack(err)
}
specDefRepository = sqlite.NewSpecDefinitionRepository(db, 5)
default:
return nil, errors.Errorf("unsupported database driver '%s'", driver)
}
return specDefRepository, nil
}

View File

@ -6,7 +6,11 @@ import (
) )
func Equals(a Spec, b Spec) (bool, error) { func Equals(a Spec, b Spec) (bool, error) {
if a.SpecName() != b.SpecName() { if a.SpecDefinitionName() != b.SpecDefinitionName() {
return false, nil
}
if a.SpecDefinitionVersion() != b.SpecDefinitionVersion() {
return false, nil return false, nil
} }

View File

@ -1,3 +0,0 @@
package spec
type Name string

View File

@ -11,7 +11,7 @@ import (
var schema []byte var schema []byte
func init() { func init() {
if err := spec.Register(NameProxy, schema); err != nil { if err := spec.Register(string(Name), Version, schema); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://proxy.emissary.cadoles.com/spec.json", "$id": "https://proxy.emissary.cadoles.com/spec.json",
"title": "ProxySpec", "title": "ProxySpec",
"description": "Emissary 'Proxy' specification", "description": "Emissary 'Proxy' specification",
@ -26,16 +26,24 @@
"type": "string" "type": "string"
} }
}, },
"required": ["hostPattern", "target"] "required": [
"hostPattern",
"target"
]
} }
} }
}, },
"required": ["address", "mappings"], "required": [
"address",
"mappings"
],
"additionalProperties": false "additionalProperties": false
} }
} }
} }
}, },
"required": ["proxies"], "required": [
"proxies"
],
"additionalProperties": false "additionalProperties": false
} }

View File

@ -2,7 +2,10 @@ package proxy
import "forge.cadoles.com/Cadoles/emissary/internal/spec" import "forge.cadoles.com/Cadoles/emissary/internal/spec"
const NameProxy spec.Name = "proxy.emissary.cadoles.com" const (
Name string = "proxy.emissary.cadoles.com"
Version = "0.0.0"
)
type ID string type ID string
@ -21,8 +24,12 @@ type ProxyMapping struct {
Target string `json:"target"` Target string `json:"target"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecDefinitionName() string {
return NameProxy return Name
}
func (s *Spec) SpecDefinitionVersion() string {
return Version
} }
func (s *Spec) SpecRevision() int { func (s *Spec) SpecRevision() int {

View File

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -37,11 +38,15 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(NameProxy, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {

66
internal/spec/registry.go Normal file
View File

@ -0,0 +1,66 @@
package spec
import (
"encoding/json"
"github.com/pkg/errors"
"github.com/qri-io/jsonschema"
)
type Registry struct {
definitions map[string]map[string][]byte
}
func (r *Registry) Register(name string, version string, schema []byte) error {
// Assert that provided schema is valid
if err := json.Unmarshal(schema, &jsonschema.Schema{}); err != nil {
return errors.WithStack(err)
}
specDefinitions, exists := r.definitions[name]
if !exists {
specDefinitions = make(map[string][]byte)
}
specDefinitions[version] = schema
r.definitions[name] = specDefinitions
return nil
}
func (r *Registry) Walk(fn func(name string, version string, schema []byte) error) error {
for name, specDefinitions := range r.definitions {
for version, schema := range specDefinitions {
if err := fn(name, version, schema); err != nil {
return errors.WithStack(err)
}
}
}
return nil
}
func NewRegistry() *Registry {
return &Registry{
definitions: make(map[string]map[string][]byte),
}
}
var defaultRegistry = NewRegistry()
func Register(name string, version string, schema []byte) error {
if err := defaultRegistry.Register(name, version, schema); err != nil {
return errors.WithStack(err)
}
return nil
}
func Walk(fn func(name string, version string, schema []byte) error) error {
if err := defaultRegistry.Walk(fn); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -1,19 +1,27 @@
package spec package spec
const DefaultVersion = "0.0.0"
type Spec interface { type Spec interface {
SpecName() Name SpecDefinitionName() string
SpecDefinitionVersion() string
SpecRevision() int SpecRevision() int
SpecData() map[string]any SpecData() map[string]any
} }
type RawSpec struct { type RawSpec struct {
Name Name `json:"name"` DefinitionName string `json:"name"`
DefinitionVersion string `json:"version"`
Revision int `json:"revision"` Revision int `json:"revision"`
Data map[string]any `json:"data"` Data map[string]any `json:"data"`
} }
func (s *RawSpec) SpecName() Name { func (s *RawSpec) SpecDefinitionName() string {
return s.Name return s.DefinitionName
}
func (s *RawSpec) SpecDefinitionVersion() string {
return s.DefinitionVersion
} }
func (s *RawSpec) SpecRevision() int { func (s *RawSpec) SpecRevision() int {

View File

@ -11,7 +11,7 @@ import (
var schema []byte var schema []byte
func init() { func init() {
if err := spec.Register(NameUCI, schema); err != nil { if err := spec.Register(string(Name), Version, schema); err != nil {
panic(errors.WithStack(err)) panic(errors.WithStack(err))
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2019-09/schema",
"$id": "https://uci.emissary.cadoles.com/spec.json", "$id": "https://uci.emissary.cadoles.com/spec.json",
"title": "UCISpec", "title": "UCISpec",
"description": "Emissary 'UCI' specification", "description": "Emissary 'UCI' specification",
@ -15,7 +15,9 @@
} }
} }
}, },
"required": ["packages"], "required": [
"packages"
],
"additionalProperties": false "additionalProperties": false
}, },
"postImportCommands": { "postImportCommands": {
@ -33,12 +35,18 @@
} }
} }
}, },
"required": ["command", "args"], "required": [
"command",
"args"
],
"additionalProperties": false "additionalProperties": false
} }
} }
}, },
"required": ["config", "postImportCommands"], "required": [
"config",
"postImportCommands"
],
"additionalProperties": false, "additionalProperties": false,
"$defs": { "$defs": {
"package": { "package": {
@ -54,7 +62,10 @@
} }
} }
}, },
"required": ["name", "configs"], "required": [
"name",
"configs"
],
"additionalProperties": false "additionalProperties": false
}, },
"config": { "config": {
@ -74,11 +85,15 @@
"$ref": "#/$defs/option" "$ref": "#/$defs/option"
} }
}, },
{ "type": "null" } {
"type": "null"
}
] ]
} }
}, },
"required": ["name"], "required": [
"name"
],
"additionalProperties": false "additionalProperties": false
}, },
"option": { "option": {
@ -86,7 +101,10 @@
"properties": { "properties": {
"type": { "type": {
"type": "string", "type": "string",
"enum": ["list", "option"] "enum": [
"list",
"option"
]
}, },
"name": { "name": {
"type": "string" "type": "string"
@ -95,7 +113,11 @@
"type": "string" "type": "string"
} }
}, },
"required": ["type", "name", "value"], "required": [
"type",
"name",
"value"
],
"additionalProperties": false "additionalProperties": false
} }
} }

View File

@ -5,7 +5,10 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
) )
const NameUCI spec.Name = "uci.emissary.cadoles.com" const (
Name string = "uci.emissary.cadoles.com"
Version string = "0.0.0"
)
type Spec struct { type Spec struct {
Revision int `json:"revision"` Revision int `json:"revision"`
@ -18,8 +21,12 @@ type UCIPostImportCommand struct {
Args []string `json:"args"` Args []string `json:"args"`
} }
func (s *Spec) SpecName() spec.Name { func (s *Spec) SpecDefinitionName() string {
return NameUCI return Name
}
func (s *Spec) SpecDefinitionVersion() string {
return Version
} }
func (s *Spec) SpecRevision() int { func (s *Spec) SpecRevision() int {

View File

@ -3,9 +3,10 @@ package uci
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io/ioutil" "os"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -32,17 +33,21 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(NameUCI, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { t.Run(tc.Name, func(t *testing.T) {
t.Parallel() t.Parallel()
rawSpec, err := ioutil.ReadFile(tc.Source) rawSpec, err := os.ReadFile(tc.Source)
if err != nil { if err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }

View File

@ -4,31 +4,37 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/qri-io/jsonschema" "github.com/qri-io/jsonschema"
) )
type Validator struct { type Validator struct {
schemas map[Name]*jsonschema.Schema repo datastore.SpecDefinitionRepository
}
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) error { func (v *Validator) Validate(ctx context.Context, spec Spec) error {
schema, exists := v.schemas[spec.SpecName()] name := spec.SpecDefinitionName()
if !exists {
version := spec.SpecDefinitionVersion()
if version == "" {
version = DefaultVersion
}
specDef, err := v.repo.Get(ctx, name, version)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
return errors.WithStack(ErrUnknownSchema) return errors.WithStack(ErrUnknownSchema)
} }
return errors.WithStack(err)
}
schema := &jsonschema.Schema{}
if err := json.Unmarshal(specDef.Schema, schema); err != nil {
return errors.WithStack(err)
}
state := schema.Validate(ctx, map[string]any(spec.SpecData())) state := schema.Validate(ctx, map[string]any(spec.SpecData()))
if !state.IsValid() { if !state.IsValid() {
return errors.WithStack(&ValidationError{*state.Errs}) return errors.WithStack(&ValidationError{*state.Errs})
@ -37,26 +43,8 @@ func (v *Validator) Validate(ctx context.Context, spec Spec) error {
return nil return nil
} }
func NewValidator() *Validator { func NewValidator(repo datastore.SpecDefinitionRepository) *Validator {
return &Validator{ return &Validator{
schemas: make(map[Name]*jsonschema.Schema), repo: repo,
} }
} }
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) error {
if err := defaultValidator.Validate(ctx, spec); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -0,0 +1 @@
DROP TABLE spec_definitions;

View File

@ -0,0 +1,9 @@
CREATE TABLE
spec_definitions (
name TEXT NOT NULL,
version TEXT NOT NULL,
schema TEXT NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
UNIQUE (name, version)
);

View File

@ -0,0 +1,35 @@
ALTER TABLE specs
RENAME TO _specs;
CREATE TABLE
specs (
id INTEGER PRIMARY KEY,
agent_id INTEGER,
tenant_id TEXT,
name TEXT NOT NULL,
revision INTEGER DEFAULT 0,
data TEXT,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants (id),
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE,
UNIQUE (agent_id, name) ON CONFLICT REPLACE
);
INSERT INTO
specs
SELECT
id,
agent_id,
tenant_id,
name,
revision,
data,
created_at,
updated_at
FROM
_specs;
DROP TABLE _specs;
---

View File

@ -0,0 +1,36 @@
-- Add unique constraint on name/version to specs
ALTER TABLE specs
RENAME TO _specs;
CREATE TABLE
specs (
id INTEGER PRIMARY KEY,
agent_id INTEGER,
tenant_id TEXT,
name TEXT NOT NULL,
version TEXT NOT NULL,
revision INTEGER DEFAULT 0,
data TEXT,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants (id),
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE,
UNIQUE (agent_id, name, version) ON CONFLICT REPLACE
);
INSERT INTO
specs
SELECT
id,
agent_id,
tenant_id,
name,
"0.0.0",
revision,
data,
created_at,
updated_at
FROM
_specs;
DROP TABLE _specs;

View File

@ -9,7 +9,6 @@ import (
type ( type (
Spec = spec.Spec Spec = spec.Spec
SpecName = spec.Name
) )
type ( type (

View File

@ -4,30 +4,32 @@ import (
"context" "context"
"fmt" "fmt"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func (c *Client) DeleteAgentSpec(ctx context.Context, agentID AgentID, name SpecName, funcs ...OptionFunc) (SpecName, error) { func (c *Client) DeleteAgentSpec(ctx context.Context, agentID AgentID, name string, version string, funcs ...OptionFunc) (string, string, error) {
payload := struct { payload := struct {
Name spec.Name `json:"name"` Name string `json:"name"`
Version string `json:"version"`
}{ }{
Name: name, Name: name,
Version: version,
} }
response := withResponse[struct { response := withResponse[struct {
Name spec.Name `json:"name"` Name string `json:"name"`
Version string `json:"version"`
}]() }]()
path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID) path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID)
if err := c.apiDelete(ctx, path, payload, &response, funcs...); err != nil { if err := c.apiDelete(ctx, path, payload, &response, funcs...); err != nil {
return "", errors.WithStack(err) return "", "", errors.WithStack(err)
} }
if response.Error != nil { if response.Error != nil {
return "", errors.WithStack(response.Error) return "", "", errors.WithStack(response.Error)
} }
return response.Data.Name, nil return response.Data.Name, response.Data.Version, nil
} }

View File

@ -4,13 +4,14 @@ import (
"context" "context"
"fmt" "fmt"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func (c *Client) GetAgentSpecs(ctx context.Context, agentID AgentID, funcs ...OptionFunc) ([]Spec, error) { func (c *Client) GetAgentSpecs(ctx context.Context, agentID AgentID, funcs ...OptionFunc) ([]Spec, error) {
response := withResponse[struct { response := withResponse[struct {
Specs []*spec.RawSpec `json:"specs"` Specs []*datastore.Spec `json:"specs"`
}]() }]()
path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID) path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID)

View File

@ -6,17 +6,18 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata" "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func (c *Client) UpdateAgentSpec(ctx context.Context, agentID AgentID, spc Spec, funcs ...OptionFunc) (Spec, error) { func (c *Client) UpdateAgentSpec(ctx context.Context, agentID AgentID, spc Spec, funcs ...OptionFunc) (Spec, error) {
payload := struct { payload := struct {
Name spec.Name `json:"name"` Name string `json:"name"`
Version string `json:"version"`
Revision int `json:"revision"` Revision int `json:"revision"`
Data metadata.Metadata `json:"data"` Data metadata.Metadata `json:"data"`
}{ }{
Name: spc.SpecName(), Name: spc.SpecDefinitionName(),
Version: spc.SpecDefinitionVersion(),
Revision: spc.SpecRevision(), Revision: spc.SpecRevision(),
Data: spc.SpecData(), Data: spc.SpecData(),
} }