feat: initial commit
This commit is contained in:
71
internal/agent/agent.go
Normal file
71
internal/agent/agent.go
Normal file
@ -0,0 +1,71 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
controllers []Controller
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
func (a *Agent) Run(ctx context.Context) error {
|
||||
state := NewState()
|
||||
|
||||
logger.Info(ctx, "starting reconciliation ticker", logger.F("interval", a.interval))
|
||||
|
||||
ticker := time.NewTicker(a.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
|
||||
logger.Debug(ctx, "state before reconciliation", logger.F("state", state))
|
||||
|
||||
if err := a.Reconcile(ctx, state); err != nil {
|
||||
logger.Error(ctx, "could not reconcile node with state", logger.E(errors.WithStack(err)))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "state after reconciliation", logger.F("state", state))
|
||||
|
||||
case <-ctx.Done():
|
||||
return errors.WithStack(ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Reconcile(ctx context.Context, state *State) error {
|
||||
for _, ctrl := range a.controllers {
|
||||
ctrlCtx := logger.With(ctx, logger.F("controller", ctrl.Name()))
|
||||
|
||||
logger.Debug(
|
||||
ctrlCtx, "executing controller",
|
||||
logger.F("state", state),
|
||||
)
|
||||
|
||||
if err := ctrl.Reconcile(ctrlCtx, state); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func New(funcs ...OptionFunc) *Agent {
|
||||
opt := defaultOption()
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
controllers: opt.Controllers,
|
||||
interval: opt.Interval,
|
||||
}
|
||||
}
|
8
internal/agent/controller.go
Normal file
8
internal/agent/controller.go
Normal file
@ -0,0 +1,8 @@
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
type Controller interface {
|
||||
Name() string
|
||||
Reconcile(ctx context.Context, state *State) error
|
||||
}
|
124
internal/agent/controller/gateway/controller.go
Normal file
124
internal/agent/controller/gateway/controller.go
Normal file
@ -0,0 +1,124 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
proxies map[spec.GatewayID]*ReverseProxy
|
||||
currentSpecRevision int
|
||||
}
|
||||
|
||||
// Name implements node.Controller.
|
||||
func (c *Controller) Name() string {
|
||||
return "gateway-controller"
|
||||
}
|
||||
|
||||
// Reconcile implements node.Controller.
|
||||
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
gatewaySpec := spec.NewGatewaySpec()
|
||||
|
||||
if err := state.GetSpec(spec.NameGateway, gatewaySpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
logger.Info(ctx, "could not find gateway spec, stopping all remaining proxies")
|
||||
|
||||
c.stopAllProxies(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "retrieved spec", logger.F("spec", gatewaySpec.SpecName()), logger.F("revision", gatewaySpec.SpecRevision()))
|
||||
|
||||
if c.currentSpecRevision == gatewaySpec.SpecRevision() {
|
||||
logger.Info(ctx, "spec revision did not change, doing nothing")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
c.updateProxies(ctx, gatewaySpec)
|
||||
|
||||
c.currentSpecRevision = gatewaySpec.SpecRevision()
|
||||
logger.Info(ctx, "updating current spec revision", logger.F("revision", c.currentSpecRevision))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) stopAllProxies(ctx context.Context) {
|
||||
for gatewayID, proxy := range c.proxies {
|
||||
logger.Info(ctx, "stopping proxy", logger.F("gatewayID", gatewayID))
|
||||
|
||||
if err := proxy.Stop(); err != nil {
|
||||
logger.Error(
|
||||
ctx, "error while stopping proxy",
|
||||
logger.F("gatewayID", gatewayID),
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
delete(c.proxies, gatewayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) updateProxies(ctx context.Context, spec *spec.Gateway) {
|
||||
// Stop and remove obsolete gateways
|
||||
for gatewayID, proxy := range c.proxies {
|
||||
if _, exists := spec.Gateways[gatewayID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info(ctx, "stopping proxy", logger.F("gatewayID", gatewayID))
|
||||
|
||||
if err := proxy.Stop(); err != nil {
|
||||
logger.Error(
|
||||
ctx, "error while stopping proxy",
|
||||
logger.F("gatewayID", gatewayID),
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
delete(c.proxies, gatewayID)
|
||||
}
|
||||
}
|
||||
|
||||
// (Re)start gateways
|
||||
for gatewayID, gatewaySpec := range spec.Gateways {
|
||||
proxy, exists := c.proxies[gatewayID]
|
||||
if !exists {
|
||||
proxy = NewReverseProxy()
|
||||
c.proxies[gatewayID] = proxy
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
ctx, "starting proxy",
|
||||
logger.F("gatewayID", gatewayID),
|
||||
logger.F("addr", gatewaySpec.Address),
|
||||
logger.F("target", gatewaySpec.Target),
|
||||
)
|
||||
|
||||
if err := proxy.Start(ctx, gatewaySpec.Address, gatewaySpec.Target); err != nil {
|
||||
logger.Error(
|
||||
ctx, "error while starting proxy",
|
||||
logger.F("gatewayID", gatewayID),
|
||||
logger.E(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
delete(c.proxies, gatewayID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewController() *Controller {
|
||||
return &Controller{
|
||||
proxies: make(map[spec.GatewayID]*ReverseProxy),
|
||||
currentSpecRevision: -1,
|
||||
}
|
||||
}
|
||||
|
||||
var _ agent.Controller = &Controller{}
|
78
internal/agent/controller/gateway/reverse_proxy.go
Normal file
78
internal/agent/controller/gateway/reverse_proxy.go
Normal file
@ -0,0 +1,78 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type ReverseProxy struct {
|
||||
addr string
|
||||
target string
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) Start(ctx context.Context, addr, target string) error {
|
||||
alreadyRunning := p.server != nil && target == p.target && addr == p.target
|
||||
|
||||
if alreadyRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.server != nil {
|
||||
if err := p.Stop(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
}
|
||||
|
||||
url, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(url)
|
||||
|
||||
server.Handler = proxy
|
||||
|
||||
p.server = server
|
||||
p.addr = addr
|
||||
p.target = target
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error(ctx, "error while listening", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
|
||||
if err := p.Stop(); err != nil {
|
||||
logger.Error(ctx, "error while stopping gateway", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) Stop() error {
|
||||
if p.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
p.server = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewReverseProxy() *ReverseProxy {
|
||||
return &ReverseProxy{}
|
||||
}
|
116
internal/agent/controller/openwrt/uci_controller.go
Normal file
116
internal/agent/controller/openwrt/uci_controller.go
Normal file
@ -0,0 +1,116 @@
|
||||
package openwrt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type UCIController struct {
|
||||
binPath string
|
||||
currentSpecRevision int
|
||||
}
|
||||
|
||||
// Name implements node.Controller.
|
||||
func (*UCIController) Name() string {
|
||||
return "uci-controller"
|
||||
}
|
||||
|
||||
// Reconcile implements node.Controller.
|
||||
func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
uciSpec := spec.NewUCISpec()
|
||||
|
||||
if err := state.GetSpec(spec.NameUCI, uciSpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
logger.Info(ctx, "could not find uci spec, doing nothing")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "retrieved spec", logger.F("spec", uciSpec.SpecName()), logger.F("revision", uciSpec.SpecRevision()))
|
||||
|
||||
if c.currentSpecRevision == uciSpec.SpecRevision() {
|
||||
logger.Info(ctx, "spec revision did not change, doing nothing")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.updateConfiguration(ctx, uciSpec); err != nil {
|
||||
logger.Error(ctx, "could not update configuration", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
c.currentSpecRevision = uciSpec.SpecRevision()
|
||||
logger.Info(ctx, "updating current spec revision", logger.F("revision", c.currentSpecRevision))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *UCIController) updateConfiguration(ctx context.Context, spec *spec.UCI) error {
|
||||
logger.Info(ctx, "importing uci config")
|
||||
|
||||
if err := c.importConfig(ctx, spec.Config); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := c.execPostImportCommands(ctx, spec.PostImportCommands); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *UCIController) importConfig(ctx context.Context, uci *uci.UCI) error {
|
||||
cmd := exec.CommandContext(ctx, c.binPath, "import")
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if _, err := buf.WriteString(uci.Export()); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
cmd.Stdin = &buf
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *UCIController) execPostImportCommands(ctx context.Context, commands []*spec.UCIPostImportCommand) error {
|
||||
for _, postImportCmd := range commands {
|
||||
cmd := exec.CommandContext(ctx, postImportCmd.Command, postImportCmd.Args...)
|
||||
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewUCIController(binPath string) *UCIController {
|
||||
return &UCIController{
|
||||
binPath: binPath,
|
||||
currentSpecRevision: -1,
|
||||
}
|
||||
}
|
||||
|
||||
var _ agent.Controller = &UCIController{}
|
218
internal/agent/controller/persistence/controller.go
Normal file
218
internal/agent/controller/persistence/controller.go
Normal file
@ -0,0 +1,218 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
trackedSpecRevisions map[spec.Name]int
|
||||
filename string
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// Name implements node.Controller.
|
||||
func (c *Controller) Name() string {
|
||||
return "persistence-controller"
|
||||
}
|
||||
|
||||
// Reconcile implements node.Controller.
|
||||
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
specs := state.Specs()
|
||||
changed := c.specChanged(state.Specs())
|
||||
|
||||
switch {
|
||||
// If first cycle, load state from file system
|
||||
case !c.loaded:
|
||||
logger.Info(ctx, "first cycle, loading state", logger.F("stateFile", c.filename))
|
||||
|
||||
if err := c.loadState(ctx, state); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
logger.Info(ctx, "state file not found", logger.F("stateFile", c.filename))
|
||||
|
||||
c.loaded = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
c.trackSpecsRevisions(specs)
|
||||
|
||||
c.loaded = true
|
||||
|
||||
return nil
|
||||
|
||||
// If specs did not change, return
|
||||
case !changed:
|
||||
logger.Info(ctx, "no changes detected, doing nothing")
|
||||
|
||||
return nil
|
||||
|
||||
// If specs has changed, save it
|
||||
case changed:
|
||||
logger.Info(ctx, "saving state", logger.F("stateFile", c.filename))
|
||||
|
||||
if err := c.writeState(ctx, state); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
c.trackSpecsRevisions(specs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) specChanged(specs agent.Specs) bool {
|
||||
if len(specs) != len(c.trackedSpecRevisions) {
|
||||
return true
|
||||
}
|
||||
|
||||
for name, spec := range specs {
|
||||
trackedRevision, exists := c.trackedSpecRevisions[name]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
if trackedRevision != spec.SpecRevision() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for trackedSpecName, trackedRevision := range c.trackedSpecRevisions {
|
||||
spec, exists := specs[trackedSpecName]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
if trackedRevision != spec.SpecRevision() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Controller) trackSpecsRevisions(specs agent.Specs) {
|
||||
c.trackedSpecRevisions = make(map[spec.Name]int)
|
||||
|
||||
for name, spec := range specs {
|
||||
c.trackedSpecRevisions[name] = spec.SpecRevision()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) loadState(ctx context.Context, state *agent.State) error {
|
||||
data, err := ioutil.ReadFile(c.filename)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, state); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
|
||||
dir, file := filepath.Split(c.filename)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
|
||||
f, err := ioutil.TempFile(dir, file)
|
||||
if err != nil {
|
||||
return errors.Errorf("cannot create temp file: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(f.Name()); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not remove temporary file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
if errors.Is(err, os.ErrClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not close temporary file", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
name := f.Name()
|
||||
if err := ioutil.WriteFile(name, data, os.ModePerm); err != nil {
|
||||
return errors.Errorf("cannot write data to temporary file %q: %v", name, err)
|
||||
}
|
||||
|
||||
if err := f.Sync(); err != nil {
|
||||
return errors.Errorf("can't flush temporary file %q: %v", name, err)
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return errors.Errorf("can't close temporary file %q: %v", name, err)
|
||||
}
|
||||
|
||||
// get the file mode from the original file and use that for the replacement
|
||||
// file, too.
|
||||
destInfo, err := os.Stat(c.filename)
|
||||
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
// Do nothing
|
||||
|
||||
case err != nil:
|
||||
return errors.WithStack(err)
|
||||
|
||||
default:
|
||||
sourceInfo, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if sourceInfo.Mode() != destInfo.Mode() {
|
||||
if err := os.Chmod(name, destInfo.Mode()); err != nil {
|
||||
return fmt.Errorf("can't set filemode on temporary file %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(name, c.filename); err != nil {
|
||||
return fmt.Errorf("cannot replace %q with temporary file %q: %v", c.filename, name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewController(filename string) *Controller {
|
||||
return &Controller{
|
||||
filename: filename,
|
||||
trackedSpecRevisions: make(map[spec.Name]int),
|
||||
}
|
||||
}
|
||||
|
||||
var _ agent.Controller = &Controller{}
|
116
internal/agent/controller/spec/controller.go
Normal file
116
internal/agent/controller/spec/controller.go
Normal file
@ -0,0 +1,116 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/machineid"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/client"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/server"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
// Name implements node.Controller.
|
||||
func (c *Controller) Name() string {
|
||||
return "spec-controller"
|
||||
}
|
||||
|
||||
// Reconcile implements node.Controller.
|
||||
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
machineID, err := machineid.Get()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
ctx = logger.With(ctx, logger.F("machineID", machineID))
|
||||
|
||||
agent, err := c.client.RegisterAgent(ctx, machineID)
|
||||
isAlreadyRegisteredErr, _ := isAPIError(err, server.ErrCodeAlreadyRegistered)
|
||||
|
||||
switch {
|
||||
case isAlreadyRegisteredErr:
|
||||
agents, _, err := c.client.QueryAgents(
|
||||
ctx,
|
||||
client.WithQueryAgentsLimit(1),
|
||||
client.WithQueryAgentsRemoteID(machineID),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if len(agents) == 0 {
|
||||
logger.Error(ctx, "could not find remote matching agent")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.reconcileAgent(ctx, state, agents[0]); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case agent != nil:
|
||||
if err := c.reconcileAgent(ctx, state, agent); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case err != nil:
|
||||
logger.Error(ctx, "could not contact server", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) reconcileAgent(ctx context.Context, state *agent.State, agent *datastore.Agent) error {
|
||||
ctx = logger.With(ctx, logger.F("agentID", agent.ID))
|
||||
|
||||
if agent.Status != datastore.AgentStatusAccepted {
|
||||
logger.Error(ctx, "unexpected agent status", logger.F("status", agent.Status))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
specs, err := c.client.GetAgentSpecs(ctx, agent.ID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not retrieve agent specs", logger.E(errors.WithStack(err)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ClearSpecs()
|
||||
|
||||
for _, spec := range specs {
|
||||
state.SetSpec(spec)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewController(serverURL string) *Controller {
|
||||
client := client.New(serverURL)
|
||||
|
||||
return &Controller{client}
|
||||
}
|
||||
|
||||
func isAPIError(err error, code api.ErrorCode) (bool, any) {
|
||||
apiError := &api.Error{}
|
||||
if errors.As(err, &apiError) && apiError.Code == code {
|
||||
return true, apiError.Data
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var _ agent.Controller = &Controller{}
|
29
internal/agent/machineid/get.go
Normal file
29
internal/agent/machineid/get.go
Normal file
@ -0,0 +1,29 @@
|
||||
package machineid
|
||||
|
||||
import (
|
||||
"github.com/btcsuite/btcd/btcutil/base58"
|
||||
"github.com/denisbrodbeck/machineid"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const salt = "emissary.cadoles.com"
|
||||
|
||||
func Get() (string, error) {
|
||||
machineID, err := getProtectedMachineID()
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
return machineID, nil
|
||||
}
|
||||
|
||||
func getProtectedMachineID() (string, error) {
|
||||
id, err := machineid.ProtectedID(salt)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
encoded := base58.Encode([]byte(id))
|
||||
|
||||
return encoded, nil
|
||||
}
|
31
internal/agent/option.go
Normal file
31
internal/agent/option.go
Normal file
@ -0,0 +1,31 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
Interval time.Duration
|
||||
Controllers []Controller
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOption() *Option {
|
||||
return &Option{
|
||||
Controllers: make([]Controller, 0),
|
||||
Interval: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func WithControllers(controllers ...Controller) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.Controllers = controllers
|
||||
}
|
||||
}
|
||||
|
||||
func WithInterval(interval time.Duration) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.Interval = interval
|
||||
}
|
||||
}
|
109
internal/agent/state.go
Normal file
109
internal/agent/state.go
Normal file
@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrSpecNotFound = errors.New("spec not found")
|
||||
|
||||
type Specs map[spec.Name]spec.Spec
|
||||
|
||||
type State struct {
|
||||
specs Specs `json:"specs"`
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
specs: make(map[spec.Name]spec.Spec),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) MarshalJSON() ([]byte, error) {
|
||||
state := struct {
|
||||
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
|
||||
}{
|
||||
Specs: func(specs map[spec.Name]spec.Spec) map[spec.Name]*spec.RawSpec {
|
||||
rawSpecs := make(map[spec.Name]*spec.RawSpec)
|
||||
|
||||
for name, sp := range specs {
|
||||
rawSpecs[name] = &spec.RawSpec{
|
||||
Name: sp.SpecName(),
|
||||
Revision: sp.SpecRevision(),
|
||||
Data: sp.SpecData(),
|
||||
}
|
||||
}
|
||||
|
||||
return rawSpecs
|
||||
}(s.specs),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *State) UnmarshalJSON(data []byte) error {
|
||||
state := struct {
|
||||
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s.specs = func(rawSpecs map[spec.Name]*spec.RawSpec) Specs {
|
||||
specs := make(Specs)
|
||||
|
||||
for name, raw := range rawSpecs {
|
||||
specs[name] = spec.Spec(raw)
|
||||
}
|
||||
|
||||
return specs
|
||||
}(state.Specs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *State) Specs() Specs {
|
||||
return s.specs
|
||||
}
|
||||
|
||||
func (s *State) ClearSpecs() *State {
|
||||
s.specs = make(map[spec.Name]spec.Spec)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *State) SetSpec(sp spec.Spec) *State {
|
||||
if s.specs == nil {
|
||||
s.specs = make(map[spec.Name]spec.Spec)
|
||||
}
|
||||
|
||||
s.specs[sp.SpecName()] = sp
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *State) GetSpec(name spec.Name, dest any) error {
|
||||
spec, exists := s.specs[name]
|
||||
if !exists {
|
||||
return errors.WithStack(ErrSpecNotFound)
|
||||
}
|
||||
|
||||
if err := mapstructure.Decode(spec, dest); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := mapstructure.Decode(spec.SpecData(), dest); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
104
internal/client/client.go
Normal file
104
internal/client/client.go
Normal file
@ -0,0 +1,104 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
serverURL string
|
||||
}
|
||||
|
||||
func (c *Client) apiGet(ctx context.Context, path string, result any) error {
|
||||
if err := c.apiDo(ctx, http.MethodGet, path, nil, result); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apiPost(ctx context.Context, path string, payload any, result any) error {
|
||||
if err := c.apiDo(ctx, http.MethodPost, path, payload, result); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apiDo(ctx context.Context, method string, path string, payload any, response any) error {
|
||||
url := c.serverURL + path
|
||||
|
||||
logger.Debug(
|
||||
ctx, "new http request",
|
||||
logger.F("method", method),
|
||||
logger.F("url", url),
|
||||
logger.F("payload", payload),
|
||||
)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
encoder := json.NewEncoder(&buf)
|
||||
|
||||
if err := encoder.Encode(payload); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, &buf)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
res, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer res.Body.Close()
|
||||
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func withResponse[T any]() struct {
|
||||
Data T
|
||||
Error *api.Error
|
||||
} {
|
||||
return struct {
|
||||
Data T
|
||||
Error *api.Error
|
||||
}{}
|
||||
}
|
||||
|
||||
func joinSlice[T any](items []T) string {
|
||||
str := ""
|
||||
|
||||
for idx, item := range items {
|
||||
if idx != 0 {
|
||||
str += ","
|
||||
}
|
||||
|
||||
str += fmt.Sprintf("%v", item)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
func New(serverURL string) *Client {
|
||||
return &Client{
|
||||
serverURL: serverURL,
|
||||
http: &http.Client{},
|
||||
}
|
||||
}
|
27
internal/client/get_agent.go
Normal file
27
internal/client/get_agent.go
Normal file
@ -0,0 +1,27 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) GetAgent(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) {
|
||||
response := withResponse[struct {
|
||||
Agent *datastore.Agent `json:"agent"`
|
||||
}]()
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%d", agentID)
|
||||
|
||||
if err := c.apiGet(ctx, path, &response); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return nil, errors.WithStack(response.Error)
|
||||
}
|
||||
|
||||
return response.Data.Agent, nil
|
||||
}
|
33
internal/client/get_agent_specs.go
Normal file
33
internal/client/get_agent_specs.go
Normal file
@ -0,0 +1,33 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) GetAgentSpecs(ctx context.Context, agentID datastore.AgentID) ([]spec.Spec, error) {
|
||||
response := withResponse[struct {
|
||||
Specs []*spec.RawSpec `json:"specs"`
|
||||
}]()
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID)
|
||||
|
||||
if err := c.apiGet(ctx, path, &response); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return nil, errors.WithStack(response.Error)
|
||||
}
|
||||
|
||||
specs := make([]spec.Spec, 0, len(response.Data.Specs))
|
||||
for _, s := range response.Data.Specs {
|
||||
specs = append(specs, spec.Spec(s))
|
||||
}
|
||||
|
||||
return specs, nil
|
||||
}
|
88
internal/client/query_agents.go
Normal file
88
internal/client/query_agents.go
Normal file
@ -0,0 +1,88 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type QueryAgentsOptionFunc func(*QueryAgentsOptions)
|
||||
|
||||
type QueryAgentsOptions struct {
|
||||
Limit *int
|
||||
Offset *int
|
||||
RemoteIDs []string
|
||||
IDs []datastore.AgentID
|
||||
Statuses []datastore.AgentStatus
|
||||
}
|
||||
|
||||
func WithQueryAgentsLimit(limit int) QueryAgentsOptionFunc {
|
||||
return func(opts *QueryAgentsOptions) {
|
||||
opts.Limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithQueryAgentsOffset(offset int) QueryAgentsOptionFunc {
|
||||
return func(opts *QueryAgentsOptions) {
|
||||
opts.Offset = &offset
|
||||
}
|
||||
}
|
||||
|
||||
func WithQueryAgentsRemoteID(remoteIDs ...string) QueryAgentsOptionFunc {
|
||||
return func(opts *QueryAgentsOptions) {
|
||||
opts.RemoteIDs = remoteIDs
|
||||
}
|
||||
}
|
||||
|
||||
func WithQueryAgentsID(ids ...datastore.AgentID) QueryAgentsOptionFunc {
|
||||
return func(opts *QueryAgentsOptions) {
|
||||
opts.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
func WithQueryAgentsStatus(statuses ...datastore.AgentStatus) QueryAgentsOptionFunc {
|
||||
return func(opts *QueryAgentsOptions) {
|
||||
opts.Statuses = statuses
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) QueryAgents(ctx context.Context, funcs ...QueryAgentsOptionFunc) ([]*datastore.Agent, int, error) {
|
||||
options := &QueryAgentsOptions{}
|
||||
for _, fn := range funcs {
|
||||
fn(options)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
|
||||
if options.IDs != nil && len(options.IDs) > 0 {
|
||||
query.Set("ids", joinSlice(options.IDs))
|
||||
}
|
||||
|
||||
if options.RemoteIDs != nil && len(options.RemoteIDs) > 0 {
|
||||
query.Set("remoteIds", joinSlice(options.RemoteIDs))
|
||||
}
|
||||
|
||||
if options.Statuses != nil && len(options.RemoteIDs) > 0 {
|
||||
query.Set("statuses", joinSlice(options.Statuses))
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agents?%s", query.Encode())
|
||||
|
||||
response := withResponse[struct {
|
||||
Agents []*datastore.Agent `json:"agents"`
|
||||
Total int `json:"total"`
|
||||
}]()
|
||||
|
||||
if err := c.apiGet(ctx, path, &response); err != nil {
|
||||
return nil, 0, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return nil, 0, errors.WithStack(response.Error)
|
||||
}
|
||||
|
||||
return response.Data.Agents, response.Data.Total, nil
|
||||
}
|
30
internal/client/register_agent.go
Normal file
30
internal/client/register_agent.go
Normal file
@ -0,0 +1,30 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (c *Client) RegisterAgent(ctx context.Context, remoteID string) (*datastore.Agent, error) {
|
||||
payload := struct {
|
||||
RemoteID string `json:"remoteId"`
|
||||
}{
|
||||
RemoteID: remoteID,
|
||||
}
|
||||
|
||||
response := withResponse[struct {
|
||||
Agent *datastore.Agent `json:"agent"`
|
||||
}]()
|
||||
|
||||
if err := c.apiPost(ctx, "/api/v1/register", payload, &response); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return nil, errors.WithStack(response.Error)
|
||||
}
|
||||
|
||||
return response.Data.Agent, nil
|
||||
}
|
16
internal/command/agent/openwrt/root.go
Normal file
16
internal/command/agent/openwrt/root.go
Normal file
@ -0,0 +1,16 @@
|
||||
package openwrt
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/agent/openwrt/uci"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "openwrt",
|
||||
Usage: "OpenWRT related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
uci.Root(),
|
||||
},
|
||||
}
|
||||
}
|
15
internal/command/agent/openwrt/uci/root.go
Normal file
15
internal/command/agent/openwrt/uci/root.go
Normal file
@ -0,0 +1,15 @@
|
||||
package uci
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "uci",
|
||||
Usage: "UCI related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
TransformCommand(),
|
||||
},
|
||||
}
|
||||
}
|
107
internal/command/agent/openwrt/uci/transform.go
Normal file
107
internal/command/agent/openwrt/uci/transform.go
Normal file
@ -0,0 +1,107 @@
|
||||
package uci
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
|
||||
"github.com/pkg/errors"
|
||||
_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatJSON = "json"
|
||||
FormatUCI = "uci"
|
||||
)
|
||||
|
||||
func TransformCommand() *cli.Command {
|
||||
flags := common.Flags()
|
||||
|
||||
flags = append(flags,
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Define the source format ('uci' or 'json')",
|
||||
Value: "uci",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "input",
|
||||
Usage: "File to use as input (or '-' for STDIN)",
|
||||
Aliases: []string{"i"},
|
||||
TakesFile: true,
|
||||
Value: "-",
|
||||
},
|
||||
)
|
||||
|
||||
return &cli.Command{
|
||||
Name: "transform",
|
||||
Usage: "Transform UCI configuration from/to JSON",
|
||||
Flags: flags,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
input := ctx.String("input")
|
||||
format := ctx.String("format")
|
||||
|
||||
var reader io.Reader
|
||||
|
||||
switch input {
|
||||
case "-":
|
||||
reader = os.Stdin
|
||||
default:
|
||||
file, err := os.Open(input)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
reader = file
|
||||
}
|
||||
|
||||
var conf *uci.UCI
|
||||
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
decoder := json.NewDecoder(reader)
|
||||
conf = uci.NewUCI()
|
||||
|
||||
if err := decoder.Decode(conf); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
fmt.Print(conf.Export())
|
||||
|
||||
case FormatUCI:
|
||||
data, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
conf, err = uci.Parse(data)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(conf, "", " ")
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
fmt.Print(string(jsonData))
|
||||
|
||||
default:
|
||||
return errors.Errorf("unexpected format '%s'", format)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
17
internal/command/agent/root.go
Normal file
17
internal/command/agent/root.go
Normal file
@ -0,0 +1,17 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/agent/openwrt"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Agent related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
openwrt.Root(),
|
||||
RunCommand(),
|
||||
},
|
||||
}
|
||||
}
|
68
internal/command/agent/run.go
Normal file
68
internal/command/agent/run.go
Normal file
@ -0,0 +1,68 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/gateway"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/openwrt"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/persistence"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/spec"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"github.com/pkg/errors"
|
||||
_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func RunCommand() *cli.Command {
|
||||
flags := common.Flags()
|
||||
|
||||
return &cli.Command{
|
||||
Name: "run",
|
||||
Usage: "Run the emissary agent",
|
||||
Flags: flags,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
logger.SetFormat(logger.Format(conf.Logger.Format))
|
||||
logger.SetLevel(logger.Level(conf.Logger.Level))
|
||||
|
||||
controllers := make([]agent.Controller, 0)
|
||||
|
||||
ctrlConf := conf.Agent.Controllers
|
||||
|
||||
if ctrlConf.Persistence.Enabled {
|
||||
controllers = append(controllers, persistence.NewController(string(ctrlConf.Persistence.StateFile)))
|
||||
}
|
||||
|
||||
if ctrlConf.Spec.Enabled {
|
||||
controllers = append(controllers, spec.NewController(string(ctrlConf.Spec.ServerURL)))
|
||||
}
|
||||
|
||||
if ctrlConf.Gateway.Enabled {
|
||||
controllers = append(controllers, gateway.NewController())
|
||||
}
|
||||
|
||||
if ctrlConf.UCI.Enabled {
|
||||
controllers = append(controllers, openwrt.NewUCIController(
|
||||
string(ctrlConf.UCI.BinPath),
|
||||
))
|
||||
}
|
||||
|
||||
agent := agent.New(
|
||||
agent.WithInterval(time.Duration(conf.Agent.ReconciliationInterval)*time.Second),
|
||||
agent.WithControllers(controllers...),
|
||||
)
|
||||
|
||||
if err := agent.Run(ctx.Context); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
7
internal/command/common/flags.go
Normal file
7
internal/command/common/flags.go
Normal file
@ -0,0 +1,7 @@
|
||||
package common
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Flags() []cli.Flag {
|
||||
return []cli.Flag{}
|
||||
}
|
27
internal/command/common/load_config.go
Normal file
27
internal/command/common/load_config.go
Normal file
@ -0,0 +1,27 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/config"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func LoadConfig(ctx *cli.Context) (*config.Config, error) {
|
||||
configFile := ctx.String("config")
|
||||
|
||||
var (
|
||||
conf *config.Config
|
||||
err error
|
||||
)
|
||||
|
||||
if configFile != "" {
|
||||
conf, err = config.NewFromFile(configFile)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "Could not load config file '%s'", configFile)
|
||||
}
|
||||
} else {
|
||||
conf = config.NewDefault()
|
||||
}
|
||||
|
||||
return conf, nil
|
||||
}
|
37
internal/command/config/dump.go
Normal file
37
internal/command/config/dump.go
Normal file
@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/config"
|
||||
"github.com/pkg/errors"
|
||||
_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func Dump() *cli.Command {
|
||||
flags := common.Flags()
|
||||
|
||||
return &cli.Command{
|
||||
Name: "dump",
|
||||
Usage: "Dump the current configuration",
|
||||
Flags: flags,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
logger.SetFormat(logger.Format(conf.Logger.Format))
|
||||
logger.SetLevel(logger.Level(conf.Logger.Level))
|
||||
|
||||
if err := config.Dump(conf, os.Stdout); err != nil {
|
||||
return errors.Wrap(err, "Could not dump configuration")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
13
internal/command/config/root.go
Normal file
13
internal/command/config/root.go
Normal file
@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "config",
|
||||
Usage: "Config related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
Dump(),
|
||||
},
|
||||
}
|
||||
}
|
107
internal/command/main.go
Normal file
107
internal/command/main.go
Normal file
@ -0,0 +1,107 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Main(buildDate, projectVersion, gitRef, defaultConfigPath string, commands ...*cli.Command) {
|
||||
ctx := context.Background()
|
||||
|
||||
compiled, err := time.Parse(time.RFC3339, buildDate)
|
||||
if err != nil {
|
||||
panic(errors.Wrapf(err, "could not parse build date '%s'", buildDate))
|
||||
}
|
||||
|
||||
app := &cli.App{
|
||||
Version: fmt.Sprintf("%s (%s, %s)", projectVersion, gitRef, buildDate),
|
||||
Compiled: compiled,
|
||||
Name: "emissary",
|
||||
Usage: "Control plane for edge devices",
|
||||
Commands: commands,
|
||||
Before: func(ctx *cli.Context) error {
|
||||
workdir := ctx.String("workdir")
|
||||
// Switch to new working directory if defined
|
||||
if workdir != "" {
|
||||
if err := os.Chdir(workdir); err != nil {
|
||||
return errors.Wrap(err, "could not change working directory")
|
||||
}
|
||||
}
|
||||
|
||||
if err := ctx.Set("projectVersion", projectVersion); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := ctx.Set("gitRef", gitRef); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := ctx.Set("buildDate", buildDate); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "workdir",
|
||||
Value: "",
|
||||
Usage: "The working directory",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "projectVersion",
|
||||
Value: "",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "gitRef",
|
||||
Value: "",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "buildDate",
|
||||
Value: "",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
EnvVars: []string{"EMISSARY_DEBUG"},
|
||||
Value: false,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Aliases: []string{"c"},
|
||||
EnvVars: []string{"EMISSARY_CONFIG"},
|
||||
Value: defaultConfigPath,
|
||||
TakesFile: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
app.ExitErrHandler = func(ctx *cli.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
debug := ctx.Bool("debug")
|
||||
|
||||
if !debug {
|
||||
fmt.Printf("[ERROR] %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("%+v", err)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Sort(cli.FlagsByName(app.Flags))
|
||||
sort.Sort(cli.CommandsByName(app.Commands))
|
||||
|
||||
if err := app.RunContext(ctx, os.Args); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
105
internal/command/server/database/migrate.go
Normal file
105
internal/command/server/database/migrate.go
Normal file
@ -0,0 +1,105 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
MigrateVersionUp = "up"
|
||||
MigrateVersionLatest = "latest"
|
||||
MigrateVersionDown = "down"
|
||||
)
|
||||
|
||||
func MigrateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "migrate",
|
||||
Usage: "Migrate database schema to latest version",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "target",
|
||||
Usage: "Migration target, default to latest",
|
||||
Value: "latest",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "force",
|
||||
Usage: "Force migration to version",
|
||||
Value: -1,
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
migr, err := migrate.New("migrations", driver, dsn)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
version, dirty, err := migr.Version()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
ctx.Context, "current database shema",
|
||||
logger.F("version", version),
|
||||
logger.F("dirty", dirty),
|
||||
)
|
||||
|
||||
target := ctx.String("target")
|
||||
force := ctx.Int("force")
|
||||
|
||||
if force != -1 {
|
||||
logger.Info(ctx.Context, "forcing database schema version", logger.F("version", force))
|
||||
|
||||
if err := migr.Force(force); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
switch target {
|
||||
case "":
|
||||
fallthrough
|
||||
case MigrateVersionLatest:
|
||||
err = migr.Up()
|
||||
case MigrateVersionDown:
|
||||
err = migr.Steps(-1)
|
||||
case MigrateVersionUp:
|
||||
err = migr.Steps(1)
|
||||
default:
|
||||
return errors.Errorf(
|
||||
"unknown migration target: '%s', available: '%s' (default), '%s' or '%s'",
|
||||
target, MigrateVersionLatest, MigrateVersionUp, MigrateVersionDown,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return errors.Wrap(err, "could not apply migration")
|
||||
}
|
||||
|
||||
version, dirty, err = migr.Version()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
ctx.Context, "database shema after migration",
|
||||
logger.F("version", version),
|
||||
logger.F("dirty", dirty),
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
48
internal/command/server/database/ping.go
Normal file
48
internal/command/server/database/ping.go
Normal file
@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func PingCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "ping",
|
||||
Usage: "Test database connectivity",
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
logger.Info(ctx.Context, "connecting to database", logger.F("dsn", conf.Database.DSN))
|
||||
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
db, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error(ctx.Context, "error while closing database connection", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := db.PingContext(ctx.Context); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx.Context, "connection succeeded", logger.F("dsn", conf.Database.DSN))
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
38
internal/command/server/database/reset.go
Normal file
38
internal/command/server/database/reset.go
Normal file
@ -0,0 +1,38 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func ResetCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "reset",
|
||||
Usage: "Reset database",
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
migr, err := migrate.New("migrations", driver, dsn)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := migr.Drop(); err != nil {
|
||||
return errors.Wrap(err, "could not drop tables")
|
||||
}
|
||||
|
||||
logger.Info(ctx.Context, "database schema reinitialized")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
15
internal/command/server/database/root.go
Normal file
15
internal/command/server/database/root.go
Normal file
@ -0,0 +1,15 @@
|
||||
package database
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "database",
|
||||
Usage: "Database related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
MigrateCommand(),
|
||||
PingCommand(),
|
||||
ResetCommand(),
|
||||
},
|
||||
}
|
||||
}
|
19
internal/command/server/root.go
Normal file
19
internal/command/server/root.go
Normal file
@ -0,0 +1,19 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/config"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/server/database"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func Root() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "server",
|
||||
Usage: "Server related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
RunCommand(),
|
||||
database.Root(),
|
||||
config.Root(),
|
||||
},
|
||||
}
|
||||
}
|
54
internal/command/server/run.go
Normal file
54
internal/command/server/run.go
Normal file
@ -0,0 +1,54 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/server"
|
||||
"github.com/pkg/errors"
|
||||
_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
func RunCommand() *cli.Command {
|
||||
flags := common.Flags()
|
||||
|
||||
return &cli.Command{
|
||||
Name: "run",
|
||||
Usage: "Run the emissary server",
|
||||
Flags: flags,
|
||||
Action: func(ctx *cli.Context) error {
|
||||
conf, err := common.LoadConfig(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not load configuration")
|
||||
}
|
||||
|
||||
logger.SetFormat(logger.Format(conf.Logger.Format))
|
||||
logger.SetLevel(logger.Level(conf.Logger.Level))
|
||||
|
||||
srv := server.New(
|
||||
server.WithConfig(conf),
|
||||
)
|
||||
|
||||
addrs, srvErrs := srv.Start(ctx.Context)
|
||||
|
||||
select {
|
||||
case addr := <-addrs:
|
||||
url := fmt.Sprintf("http://%s", addr.String())
|
||||
url = strings.Replace(url, "0.0.0.0", "127.0.0.1", 1)
|
||||
|
||||
logger.Info(ctx.Context, "listening", logger.F("url", url))
|
||||
case err = <-srvErrs:
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err = <-srvErrs; err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
57
internal/config/agent.go
Normal file
57
internal/config/agent.go
Normal file
@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
type AgentConfig struct {
|
||||
ReconciliationInterval InterpolatedInt `yaml:"reconciliationInterval"`
|
||||
Controllers ControllersConfig `yaml:"controllers"`
|
||||
}
|
||||
|
||||
type ControllersConfig struct {
|
||||
Persistence PersistenceControllerConfig `yaml:"persistence"`
|
||||
Spec SpecControllerConfig `yaml:"spec"`
|
||||
Gateway GatewayControllerConfig `yaml:"gateway"`
|
||||
UCI UCIControllerConfig `yaml:"uci"`
|
||||
}
|
||||
|
||||
type PersistenceControllerConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
StateFile InterpolatedString `yaml:"stateFile"`
|
||||
}
|
||||
|
||||
type SpecControllerConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
ServerURL InterpolatedString `yaml:"serverUrl"`
|
||||
}
|
||||
|
||||
type GatewayControllerConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type UCIControllerConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
BinPath InterpolatedString `yaml:"binPath"`
|
||||
ConfigBackupFile InterpolatedString `yaml:"configBackupFile"`
|
||||
}
|
||||
|
||||
func NewDefaultAgentConfig() AgentConfig {
|
||||
return AgentConfig{
|
||||
ReconciliationInterval: 5,
|
||||
Controllers: ControllersConfig{
|
||||
Spec: SpecControllerConfig{
|
||||
Enabled: true,
|
||||
ServerURL: "http://127.0.0.1:3000",
|
||||
},
|
||||
Persistence: PersistenceControllerConfig{
|
||||
Enabled: true,
|
||||
StateFile: "state.json",
|
||||
},
|
||||
Gateway: GatewayControllerConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
UCI: UCIControllerConfig{
|
||||
Enabled: true,
|
||||
ConfigBackupFile: "uci-backup.conf",
|
||||
BinPath: "uci",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
66
internal/config/config.go
Normal file
66
internal/config/config.go
Normal file
@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config definition
|
||||
type Config struct {
|
||||
HTTP HTTPConfig `yaml:"http"`
|
||||
Logger LoggerConfig `yaml:"logger"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
Agent AgentConfig `yaml:"agent"`
|
||||
}
|
||||
|
||||
// NewFromFile retrieves the configuration from the given file
|
||||
func NewFromFile(path string) (*Config, error) {
|
||||
config := NewDefault()
|
||||
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "could not read file '%s'", path)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
return nil, errors.Wrapf(err, "could not unmarshal configuration")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// NewDumpDefault dump the new default configuration
|
||||
func NewDumpDefault() *Config {
|
||||
config := NewDefault()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// NewDefault return new default configuration
|
||||
func NewDefault() *Config {
|
||||
return &Config{
|
||||
HTTP: NewDefaultHTTPConfig(),
|
||||
Logger: NewDefaultLoggerConfig(),
|
||||
Database: NewDefaultDatabaseConfig(),
|
||||
CORS: NewDefaultCORSConfig(),
|
||||
Agent: NewDefaultAgentConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// Dump the given configuration in the given writer
|
||||
func Dump(config *Config, w io.Writer) error {
|
||||
data, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not dump config")
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
19
internal/config/config_test.go
Normal file
19
internal/config/config_test.go
Normal file
@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestConfigLoad(t *testing.T) {
|
||||
filepath := "./testdata/config.yml"
|
||||
|
||||
conf, err := NewFromFile(filepath)
|
||||
if err != nil {
|
||||
t.Fatal(errors.WithStack(err))
|
||||
}
|
||||
|
||||
t.Logf("%s", spew.Sdump(conf))
|
||||
}
|
20
internal/config/cors.go
Normal file
20
internal/config/cors.go
Normal file
@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins InterpolatedStringSlice `yaml:"allowedOrigins"`
|
||||
AllowCredentials InterpolatedBool `yaml:"allowCredentials"`
|
||||
AllowedMethods InterpolatedStringSlice `yaml:"allowMethods"`
|
||||
AllowedHeaders InterpolatedStringSlice `yaml:"allowedHeaders"`
|
||||
Debug InterpolatedBool `yaml:"debug"`
|
||||
}
|
||||
|
||||
// NewDefaultCorsConfig return the default CORS configuration.
|
||||
func NewDefaultCORSConfig() CORSConfig {
|
||||
return CORSConfig{
|
||||
AllowedOrigins: InterpolatedStringSlice{"http://localhost:3001"},
|
||||
AllowCredentials: true,
|
||||
AllowedMethods: InterpolatedStringSlice{"POST", "GET", "PUT", "DELETE"},
|
||||
AllowedHeaders: InterpolatedStringSlice{"Origin", "Accept", "Content-Type", "Authorization", "Sentry-Trace"},
|
||||
Debug: false,
|
||||
}
|
||||
}
|
20
internal/config/database.go
Normal file
20
internal/config/database.go
Normal file
@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
DatabaseDriverPostgres = "postgres"
|
||||
DatabaseDriverSQLite = "sqlite"
|
||||
)
|
||||
|
||||
// DatabaseConfig definition
|
||||
type DatabaseConfig struct {
|
||||
Driver InterpolatedString `yaml:"driver"`
|
||||
DSN InterpolatedString `yaml:"dsn"`
|
||||
}
|
||||
|
||||
// NewDefaultDatabaseConfig return the default database configuration
|
||||
func NewDefaultDatabaseConfig() DatabaseConfig {
|
||||
return DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: "sqlite://emissary.sqlite",
|
||||
}
|
||||
}
|
125
internal/config/environment.go
Normal file
125
internal/config/environment.go
Normal file
@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var reVar = regexp.MustCompile(`^\${(\w+)}$`)
|
||||
|
||||
type InterpolatedString string
|
||||
|
||||
func (is *InterpolatedString) UnmarshalYAML(value *yaml.Node) error {
|
||||
var str string
|
||||
|
||||
if err := value.Decode(&str); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
|
||||
*is = InterpolatedString(os.Getenv(match[1]))
|
||||
} else {
|
||||
*is = InterpolatedString(str)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type InterpolatedInt int
|
||||
|
||||
func (ii *InterpolatedInt) UnmarshalYAML(value *yaml.Node) error {
|
||||
var str string
|
||||
|
||||
if err := value.Decode(&str); err != nil {
|
||||
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
|
||||
}
|
||||
|
||||
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
|
||||
str = os.Getenv(match[1])
|
||||
}
|
||||
|
||||
intVal, err := strconv.ParseInt(str, 10, 32)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not parse int '%v', line '%d'", str, value.Line)
|
||||
}
|
||||
|
||||
*ii = InterpolatedInt(int(intVal))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type InterpolatedBool bool
|
||||
|
||||
func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
|
||||
var str string
|
||||
|
||||
if err := value.Decode(&str); err != nil {
|
||||
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
|
||||
}
|
||||
|
||||
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
|
||||
str = os.Getenv(match[1])
|
||||
}
|
||||
|
||||
boolVal, err := strconv.ParseBool(str)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "could not parse bool '%v', line '%d'", str, value.Line)
|
||||
}
|
||||
|
||||
*ib = InterpolatedBool(boolVal)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type InterpolatedMap map[string]interface{}
|
||||
|
||||
func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
|
||||
var data map[string]interface{}
|
||||
|
||||
if err := value.Decode(&data); err != nil {
|
||||
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
|
||||
}
|
||||
|
||||
for key, value := range data {
|
||||
strVal, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if match := reVar.FindStringSubmatch(strVal); len(match) > 0 {
|
||||
strVal = os.Getenv(match[1])
|
||||
}
|
||||
|
||||
data[key] = strVal
|
||||
}
|
||||
|
||||
*im = data
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type InterpolatedStringSlice []string
|
||||
|
||||
func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error {
|
||||
var data []string
|
||||
|
||||
if err := value.Decode(&data); err != nil {
|
||||
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
|
||||
}
|
||||
|
||||
for index, value := range data {
|
||||
if match := reVar.FindStringSubmatch(value); len(match) > 0 {
|
||||
value = os.Getenv(match[1])
|
||||
}
|
||||
|
||||
data[index] = value
|
||||
}
|
||||
|
||||
*iss = data
|
||||
|
||||
return nil
|
||||
}
|
13
internal/config/http.go
Normal file
13
internal/config/http.go
Normal file
@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
type HTTPConfig struct {
|
||||
Host InterpolatedString `yaml:"host"`
|
||||
Port uint `yaml:"port"`
|
||||
}
|
||||
|
||||
func NewDefaultHTTPConfig() HTTPConfig {
|
||||
return HTTPConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 3000,
|
||||
}
|
||||
}
|
15
internal/config/logger.go
Normal file
15
internal/config/logger.go
Normal file
@ -0,0 +1,15 @@
|
||||
package config
|
||||
|
||||
import "gitlab.com/wpetit/goweb/logger"
|
||||
|
||||
type LoggerConfig struct {
|
||||
Level InterpolatedInt `yaml:"level"`
|
||||
Format InterpolatedString `yaml:"format"`
|
||||
}
|
||||
|
||||
func NewDefaultLoggerConfig() LoggerConfig {
|
||||
return LoggerConfig{
|
||||
Level: InterpolatedInt(logger.LevelInfo),
|
||||
Format: InterpolatedString(logger.FormatHuman),
|
||||
}
|
||||
}
|
6
internal/config/testdata/config.yml
vendored
Normal file
6
internal/config/testdata/config.yml
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
logger:
|
||||
level: 0
|
||||
format: human
|
||||
http:
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
24
internal/datastore/agent.go
Normal file
24
internal/datastore/agent.go
Normal file
@ -0,0 +1,24 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type AgentID int64
|
||||
|
||||
type AgentStatus int
|
||||
|
||||
const (
|
||||
AgentStatusPending AgentStatus = 0
|
||||
AgentStatusAccepted AgentStatus = 1
|
||||
AgentStatusRejected AgentStatus = 2
|
||||
AgentStatusForgotten AgentStatus = 3
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
ID AgentID `json:"id"`
|
||||
RemoteID string `json:"remoteId"`
|
||||
Status AgentStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
67
internal/datastore/agent_repository.go
Normal file
67
internal/datastore/agent_repository.go
Normal file
@ -0,0 +1,67 @@
|
||||
package datastore
|
||||
|
||||
import "context"
|
||||
|
||||
type AgentRepository interface {
|
||||
Create(ctx context.Context, remoteID string, state AgentStatus) (*Agent, error)
|
||||
Get(ctx context.Context, id AgentID) (*Agent, error)
|
||||
Update(ctx context.Context, id AgentID, updates ...AgentUpdateOptionFunc) (*Agent, error)
|
||||
Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error)
|
||||
Delete(ctx context.Context, id AgentID) error
|
||||
|
||||
UpdateSpec(ctx context.Context, id AgentID, name string, revision int, data map[string]any) (*Spec, error)
|
||||
GetSpecs(ctx context.Context, id AgentID) ([]*Spec, error)
|
||||
DeleteSpec(ctx context.Context, id AgentID, name string) error
|
||||
}
|
||||
|
||||
type AgentQueryOptionFunc func(*AgentQueryOptions)
|
||||
|
||||
type AgentQueryOptions struct {
|
||||
Limit *int
|
||||
Offset *int
|
||||
RemoteIDs []string
|
||||
IDs []AgentID
|
||||
Statuses []AgentStatus
|
||||
}
|
||||
|
||||
func WithAgentQueryLimit(limit int) AgentQueryOptionFunc {
|
||||
return func(opts *AgentQueryOptions) {
|
||||
opts.Limit = &limit
|
||||
}
|
||||
}
|
||||
|
||||
func WithAgentQueryOffset(offset int) AgentQueryOptionFunc {
|
||||
return func(opts *AgentQueryOptions) {
|
||||
opts.Offset = &offset
|
||||
}
|
||||
}
|
||||
|
||||
func WithAgentQueryRemoteID(remoteIDs ...string) AgentQueryOptionFunc {
|
||||
return func(opts *AgentQueryOptions) {
|
||||
opts.RemoteIDs = remoteIDs
|
||||
}
|
||||
}
|
||||
|
||||
func WithAgentQueryID(ids ...AgentID) AgentQueryOptionFunc {
|
||||
return func(opts *AgentQueryOptions) {
|
||||
opts.IDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
func WithAgentQueryStatus(statuses ...AgentStatus) AgentQueryOptionFunc {
|
||||
return func(opts *AgentQueryOptions) {
|
||||
opts.Statuses = statuses
|
||||
}
|
||||
}
|
||||
|
||||
type AgentUpdateOptionFunc func(*AgentUpdateOptions)
|
||||
|
||||
type AgentUpdateOptions struct {
|
||||
Status *AgentStatus
|
||||
}
|
||||
|
||||
func WithAgentUpdateStatus(status AgentStatus) AgentUpdateOptionFunc {
|
||||
return func(opts *AgentUpdateOptions) {
|
||||
opts.Status = &status
|
||||
}
|
||||
}
|
9
internal/datastore/error.go
Normal file
9
internal/datastore/error.go
Normal file
@ -0,0 +1,9 @@
|
||||
package datastore
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExist = errors.New("already exist")
|
||||
ErrUnexpectedRevision = errors.New("unexpected revision")
|
||||
)
|
35
internal/datastore/spec.go
Normal file
35
internal/datastore/spec.go
Normal file
@ -0,0 +1,35 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type SpecID int64
|
||||
|
||||
type Spec struct {
|
||||
ID SpecID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Data map[string]any `json:"data"`
|
||||
Revision int `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (s *Spec) SpecName() spec.Name {
|
||||
return spec.Name(s.Name)
|
||||
}
|
||||
|
||||
func (s *Spec) SpecRevision() int {
|
||||
return s.Revision
|
||||
}
|
||||
|
||||
func (s *Spec) SpecData() any {
|
||||
return s.Data
|
||||
}
|
||||
|
||||
func (s *Spec) SpecValid() (bool, error) {
|
||||
return false, errors.WithStack(spec.ErrSchemaUnknown)
|
||||
}
|
393
internal/datastore/sqlite/agent_repository.go
Normal file
393
internal/datastore/sqlite/agent_repository.go
Normal file
@ -0,0 +1,393 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type AgentRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// DeleteSpec implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string) error {
|
||||
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, agentID, name)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSpecs implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) {
|
||||
specs := make([]*datastore.Spec, 0)
|
||||
|
||||
query := `
|
||||
SELECT id, name, revision, data, created_at, updated_at
|
||||
FROM specs
|
||||
WHERE agent_id = $1
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, agentID)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
spec := &datastore.Spec{}
|
||||
|
||||
data := JSONMap{}
|
||||
|
||||
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
spec.Data = data
|
||||
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
spec := &datastore.Spec{}
|
||||
|
||||
err := r.withTx(ctx, func(tx *sql.Tx) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
query := `
|
||||
INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at)
|
||||
VALUES($1, $2, $3, $4, $5, $5)
|
||||
ON CONFLICT (agent_id, name) DO UPDATE SET
|
||||
data = $4, updated_at = $5, revision = specs.revision + 1
|
||||
WHERE revision = $3
|
||||
RETURNING "id", "name", "revision", "data", "created_at", "updated_at"
|
||||
`
|
||||
|
||||
args := []any{agentID, name, revision, JSONMap(data), now}
|
||||
|
||||
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, args...)
|
||||
|
||||
data := JSONMap{}
|
||||
|
||||
err := row.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(datastore.ErrUnexpectedRevision)
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
spec.Data = data
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Query implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQueryOptionFunc) ([]*datastore.Agent, int, error) {
|
||||
options := &datastore.AgentQueryOptions{}
|
||||
for _, fn := range opts {
|
||||
fn(options)
|
||||
}
|
||||
|
||||
agents := make([]*datastore.Agent, 0)
|
||||
count := 0
|
||||
|
||||
err := r.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT id, remote_id, status, created_at, updated_at FROM agents`
|
||||
|
||||
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.IDs != nil && len(options.IDs) > 0 {
|
||||
filters += "id in ("
|
||||
|
||||
filter, newArgs, newParamIndex := inFilter("id", paramIndex, options.RemoteIDs)
|
||||
filters += filter
|
||||
paramIndex = newParamIndex
|
||||
args = append(args, newArgs...)
|
||||
}
|
||||
|
||||
if options.RemoteIDs != nil && len(options.RemoteIDs) > 0 {
|
||||
if filters != "" {
|
||||
filters += " AND "
|
||||
}
|
||||
|
||||
filter, newArgs, newParamIndex := inFilter("remote_id", paramIndex, options.RemoteIDs)
|
||||
filters += filter
|
||||
paramIndex = newParamIndex
|
||||
args = append(args, newArgs...)
|
||||
}
|
||||
|
||||
if options.Statuses != nil && len(options.Statuses) > 0 {
|
||||
if filters != "" {
|
||||
filters += " AND "
|
||||
}
|
||||
|
||||
filter, newArgs, _ := inFilter("status", paramIndex, options.Statuses)
|
||||
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 := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
agent := &datastore.Agent{}
|
||||
|
||||
if err := rows.Scan(&agent.ID, &agent.RemoteID, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
|
||||
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM agents `+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 agents, count, nil
|
||||
}
|
||||
|
||||
// Create implements datastore.AgentRepository
|
||||
func (r *AgentRepository) Create(ctx context.Context, remoteID string, status datastore.AgentStatus) (*datastore.Agent, error) {
|
||||
agent := &datastore.Agent{}
|
||||
|
||||
err := r.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT count(id) FROM agents WHERE remote_id = $1`
|
||||
row := tx.QueryRowContext(ctx, query, remoteID)
|
||||
|
||||
var count int
|
||||
|
||||
if err := row.Scan(&count); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return errors.WithStack(datastore.ErrAlreadyExist)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
query = `
|
||||
INSERT INTO agents (remote_id, status, created_at, updated_at)
|
||||
VALUES($1, $2, $3, $3)
|
||||
RETURNING "id", "remote_id", "status", "created_at", "updated_at"
|
||||
`
|
||||
|
||||
row = tx.QueryRowContext(
|
||||
ctx, query,
|
||||
remoteID, status, now,
|
||||
)
|
||||
|
||||
err := row.Scan(&agent.ID, &agent.RemoteID, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// Delete implements datastore.AgentRepository
|
||||
func (r *AgentRepository) Delete(ctx context.Context, id datastore.AgentID) error {
|
||||
query := `DELETE FROM agents WHERE id = $1`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get implements datastore.AgentRepository
|
||||
func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datastore.Agent, error) {
|
||||
agent := &datastore.Agent{
|
||||
ID: id,
|
||||
}
|
||||
|
||||
err := r.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
SELECT "remote_id", "status", "created_at", "updated_at"
|
||||
FROM agents
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
row := r.db.QueryRowContext(ctx, query, id)
|
||||
|
||||
if err := row.Scan(&agent.RemoteID, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return datastore.ErrNotFound
|
||||
}
|
||||
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// Update implements datastore.AgentRepository
|
||||
func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts ...datastore.AgentUpdateOptionFunc) (*datastore.Agent, error) {
|
||||
options := &datastore.AgentUpdateOptions{}
|
||||
for _, fn := range opts {
|
||||
fn(options)
|
||||
}
|
||||
|
||||
agent := &datastore.Agent{}
|
||||
|
||||
err := r.withTx(ctx, func(tx *sql.Tx) error {
|
||||
query := `
|
||||
UPDATE agents SET updated_at = $2
|
||||
`
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
args := []any{
|
||||
id, now,
|
||||
}
|
||||
index := 3
|
||||
|
||||
if options.Status != nil {
|
||||
query += fmt.Sprintf(`, status = $%d`, index)
|
||||
|
||||
args = append(args, *options.Status)
|
||||
|
||||
index++
|
||||
}
|
||||
|
||||
query += `
|
||||
WHERE id = $1
|
||||
RETURNING "id","remote_id","status","updated_at","created_at"
|
||||
`
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, args...)
|
||||
|
||||
if err := row.Scan(&agent.ID, &agent.RemoteID, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (r *AgentRepository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not rollback transaction", logger.E(errors.WithStack(err)))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAgentRepository(db *sql.DB) *AgentRepository {
|
||||
return &AgentRepository{db}
|
||||
}
|
||||
|
||||
var _ datastore.AgentRepository = &AgentRepository{}
|
||||
|
||||
func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) {
|
||||
args := make([]any, 0, len(items))
|
||||
filter := fmt.Sprintf("%s in (", column)
|
||||
|
||||
for idx, item := range items {
|
||||
if idx != 0 {
|
||||
filter += ","
|
||||
}
|
||||
|
||||
filter += fmt.Sprintf("$%d", paramIndex)
|
||||
paramIndex++
|
||||
|
||||
args = append(args, item)
|
||||
}
|
||||
|
||||
filter += ")"
|
||||
|
||||
return filter, args, paramIndex
|
||||
}
|
42
internal/datastore/sqlite/json.go
Normal file
42
internal/datastore/sqlite/json.go
Normal file
@ -0,0 +1,42 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type JSONMap map[string]any
|
||||
|
||||
func (j *JSONMap) 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); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(j)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
33
internal/migrate/migrate.go
Normal file
33
internal/migrate/migrate.go
Normal file
@ -0,0 +1,33 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Migrate = migrate.Migrate
|
||||
|
||||
var (
|
||||
ErrNilVersion = migrate.ErrNilVersion
|
||||
ErrNoChange = migrate.ErrNoChange
|
||||
)
|
||||
|
||||
func New(migrationDir, driver, dsn string) (*migrate.Migrate, error) {
|
||||
migr, err := migrate.New(
|
||||
fmt.Sprintf("file://%s/%s", migrationDir, driver),
|
||||
dsn,
|
||||
)
|
||||
|
||||
log.Println(migrationDir, driver, dsn)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return migr, nil
|
||||
}
|
52
internal/openwrt/uci/parser.go
Normal file
52
internal/openwrt/uci/parser.go
Normal file
@ -0,0 +1,52 @@
|
||||
package uci
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/alecthomas/participle/v2"
|
||||
"github.com/alecthomas/participle/v2/lexer"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
uciLexer = lexer.MustSimple([]lexer.SimpleRule{
|
||||
{Name: `Ident`, Pattern: `[a-zA-Z][a-zA-Z\d_\-]*`},
|
||||
{Name: `String`, Pattern: `'([^'])*'`},
|
||||
{Name: "whitespace", Pattern: `\s+`},
|
||||
})
|
||||
parser = participle.MustBuild[UCI](
|
||||
participle.Lexer(uciLexer),
|
||||
participle.Unquote("String"),
|
||||
)
|
||||
)
|
||||
|
||||
type Parser struct{}
|
||||
|
||||
func ParseFile(filename string) (*UCI, error) {
|
||||
file, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
uci, err := parser.Parse(filename, file, participle.Trace(os.Stdout))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return uci, nil
|
||||
}
|
||||
|
||||
func Parse(data []byte) (*UCI, error) {
|
||||
uci, err := parser.ParseBytes("", data)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return uci, nil
|
||||
}
|
36
internal/openwrt/uci/parser_test.go
Normal file
36
internal/openwrt/uci/parser_test.go
Normal file
@ -0,0 +1,36 @@
|
||||
package uci
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/andreyvit/diff"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func TestParser(t *testing.T) {
|
||||
data, err := ioutil.ReadFile("./testdata/openwrt_22.03.2_linksys_wrt1900ac-v2_default.conf")
|
||||
if err != nil {
|
||||
t.Fatal(errors.WithStack(err))
|
||||
}
|
||||
|
||||
uci, err := Parse(data)
|
||||
if err != nil {
|
||||
t.Fatal(errors.WithStack(err))
|
||||
}
|
||||
|
||||
exported := uci.Export()
|
||||
|
||||
if e, g := string(data), exported; e != g {
|
||||
t.Errorf("uci.Export(): %s", diff.LineDiff(e, g))
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(
|
||||
"./testdata/exported/openwrt_22.03.2_linksys_wrt1900ac-v2_default.conf",
|
||||
[]byte(exported), os.ModePerm,
|
||||
)
|
||||
if err != nil {
|
||||
t.Error(errors.WithStack(err))
|
||||
}
|
||||
}
|
2
internal/openwrt/uci/testdata/exported/.gitignore
vendored
Normal file
2
internal/openwrt/uci/testdata/exported/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
441
internal/openwrt/uci/testdata/openwrt_22.03.2_linksys_wrt1900ac-v2_default.conf
vendored
Normal file
441
internal/openwrt/uci/testdata/openwrt_22.03.2_linksys_wrt1900ac-v2_default.conf
vendored
Normal file
@ -0,0 +1,441 @@
|
||||
package dhcp
|
||||
|
||||
config dnsmasq
|
||||
option domainneeded '1'
|
||||
option boguspriv '1'
|
||||
option filterwin2k '0'
|
||||
option localise_queries '1'
|
||||
option rebind_protection '1'
|
||||
option rebind_localhost '1'
|
||||
option local '/lan/'
|
||||
option domain 'lan'
|
||||
option expandhosts '1'
|
||||
option nonegcache '0'
|
||||
option authoritative '1'
|
||||
option readethers '1'
|
||||
option leasefile '/tmp/dhcp.leases'
|
||||
option resolvfile '/tmp/resolv.conf.d/resolv.conf.auto'
|
||||
option nonwildcard '1'
|
||||
option localservice '1'
|
||||
option ednspacket_max '1232'
|
||||
|
||||
config dhcp 'lan'
|
||||
option interface 'lan'
|
||||
option start '100'
|
||||
option limit '150'
|
||||
option leasetime '12h'
|
||||
option dhcpv4 'server'
|
||||
option dhcpv6 'server'
|
||||
option ra 'server'
|
||||
option ra_slaac '1'
|
||||
list ra_flags 'managed-config'
|
||||
list ra_flags 'other-config'
|
||||
|
||||
config dhcp 'wan'
|
||||
option interface 'wan'
|
||||
option ignore '1'
|
||||
|
||||
config odhcpd 'odhcpd'
|
||||
option maindhcp '0'
|
||||
option leasefile '/tmp/hosts/odhcpd'
|
||||
option leasetrigger '/usr/sbin/odhcpd-update'
|
||||
option loglevel '4'
|
||||
|
||||
package dropbear
|
||||
|
||||
config dropbear
|
||||
option PasswordAuth 'on'
|
||||
option RootPasswordAuth 'on'
|
||||
option Port '22'
|
||||
|
||||
package firewall
|
||||
|
||||
config defaults
|
||||
option syn_flood '1'
|
||||
option input 'ACCEPT'
|
||||
option output 'ACCEPT'
|
||||
option forward 'REJECT'
|
||||
|
||||
config zone
|
||||
option name 'lan'
|
||||
list network 'lan'
|
||||
option input 'ACCEPT'
|
||||
option output 'ACCEPT'
|
||||
option forward 'ACCEPT'
|
||||
|
||||
config zone
|
||||
option name 'wan'
|
||||
list network 'wan'
|
||||
list network 'wan6'
|
||||
option input 'REJECT'
|
||||
option output 'ACCEPT'
|
||||
option forward 'REJECT'
|
||||
option masq '1'
|
||||
option mtu_fix '1'
|
||||
|
||||
config forwarding
|
||||
option src 'lan'
|
||||
option dest 'wan'
|
||||
|
||||
config rule
|
||||
option name 'Allow-DHCP-Renew'
|
||||
option src 'wan'
|
||||
option proto 'udp'
|
||||
option dest_port '68'
|
||||
option target 'ACCEPT'
|
||||
option family 'ipv4'
|
||||
|
||||
config rule
|
||||
option name 'Allow-Ping'
|
||||
option src 'wan'
|
||||
option proto 'icmp'
|
||||
option icmp_type 'echo-request'
|
||||
option family 'ipv4'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-IGMP'
|
||||
option src 'wan'
|
||||
option proto 'igmp'
|
||||
option family 'ipv4'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-DHCPv6'
|
||||
option src 'wan'
|
||||
option proto 'udp'
|
||||
option dest_port '546'
|
||||
option family 'ipv6'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-MLD'
|
||||
option src 'wan'
|
||||
option proto 'icmp'
|
||||
option src_ip 'fe80::/10'
|
||||
list icmp_type '130/0'
|
||||
list icmp_type '131/0'
|
||||
list icmp_type '132/0'
|
||||
list icmp_type '143/0'
|
||||
option family 'ipv6'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-ICMPv6-Input'
|
||||
option src 'wan'
|
||||
option proto 'icmp'
|
||||
list icmp_type 'echo-request'
|
||||
list icmp_type 'echo-reply'
|
||||
list icmp_type 'destination-unreachable'
|
||||
list icmp_type 'packet-too-big'
|
||||
list icmp_type 'time-exceeded'
|
||||
list icmp_type 'bad-header'
|
||||
list icmp_type 'unknown-header-type'
|
||||
list icmp_type 'router-solicitation'
|
||||
list icmp_type 'neighbour-solicitation'
|
||||
list icmp_type 'router-advertisement'
|
||||
list icmp_type 'neighbour-advertisement'
|
||||
option limit '1000/sec'
|
||||
option family 'ipv6'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-ICMPv6-Forward'
|
||||
option src 'wan'
|
||||
option dest '*'
|
||||
option proto 'icmp'
|
||||
list icmp_type 'echo-request'
|
||||
list icmp_type 'echo-reply'
|
||||
list icmp_type 'destination-unreachable'
|
||||
list icmp_type 'packet-too-big'
|
||||
list icmp_type 'time-exceeded'
|
||||
list icmp_type 'bad-header'
|
||||
list icmp_type 'unknown-header-type'
|
||||
option limit '1000/sec'
|
||||
option family 'ipv6'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-IPSec-ESP'
|
||||
option src 'wan'
|
||||
option dest 'lan'
|
||||
option proto 'esp'
|
||||
option target 'ACCEPT'
|
||||
|
||||
config rule
|
||||
option name 'Allow-ISAKMP'
|
||||
option src 'wan'
|
||||
option dest 'lan'
|
||||
option dest_port '500'
|
||||
option proto 'udp'
|
||||
option target 'ACCEPT'
|
||||
|
||||
package luci
|
||||
|
||||
config core 'main'
|
||||
option lang 'auto'
|
||||
option mediaurlbase '/luci-static/bootstrap'
|
||||
option resourcebase '/luci-static/resources'
|
||||
option ubuspath '/ubus/'
|
||||
|
||||
config extern 'flash_keep'
|
||||
option uci '/etc/config/'
|
||||
option dropbear '/etc/dropbear/'
|
||||
option openvpn '/etc/openvpn/'
|
||||
option passwd '/etc/passwd'
|
||||
option opkg '/etc/opkg.conf'
|
||||
option firewall '/etc/firewall.user'
|
||||
option uploads '/lib/uci/upload/'
|
||||
|
||||
config internal 'languages'
|
||||
|
||||
config internal 'sauth'
|
||||
option sessionpath '/tmp/luci-sessions'
|
||||
option sessiontime '3600'
|
||||
|
||||
config internal 'ccache'
|
||||
option enable '1'
|
||||
|
||||
config internal 'themes'
|
||||
option Bootstrap '/luci-static/bootstrap'
|
||||
option BootstrapDark '/luci-static/bootstrap-dark'
|
||||
option BootstrapLight '/luci-static/bootstrap-light'
|
||||
|
||||
config internal 'apply'
|
||||
option rollback '90'
|
||||
option holdoff '4'
|
||||
option timeout '5'
|
||||
option display '1.5'
|
||||
|
||||
config internal 'diag'
|
||||
option dns 'openwrt.org'
|
||||
option ping 'openwrt.org'
|
||||
option route 'openwrt.org'
|
||||
|
||||
package network
|
||||
|
||||
config interface 'loopback'
|
||||
option device 'lo'
|
||||
option proto 'static'
|
||||
option ipaddr '127.0.0.1'
|
||||
option netmask '255.0.0.0'
|
||||
|
||||
config globals 'globals'
|
||||
option ula_prefix 'fd04:9171:adaa::/48'
|
||||
|
||||
config device
|
||||
option name 'br-lan'
|
||||
option type 'bridge'
|
||||
list ports 'lan1'
|
||||
list ports 'lan2'
|
||||
list ports 'lan3'
|
||||
list ports 'lan4'
|
||||
|
||||
config interface 'lan'
|
||||
option device 'br-lan'
|
||||
option proto 'static'
|
||||
option ipaddr '192.168.1.1'
|
||||
option netmask '255.255.255.0'
|
||||
option ip6assign '60'
|
||||
|
||||
config device
|
||||
option name 'wan'
|
||||
option macaddr '32:23:03:cd:b6:6c'
|
||||
|
||||
config interface 'wan'
|
||||
option device 'wan'
|
||||
option proto 'dhcp'
|
||||
|
||||
config interface 'wan6'
|
||||
option device 'wan'
|
||||
option proto 'dhcpv6'
|
||||
|
||||
package rpcd
|
||||
|
||||
config rpcd
|
||||
option socket '/var/run/ubus/ubus.sock'
|
||||
option timeout '30'
|
||||
|
||||
config login
|
||||
option username 'root'
|
||||
option password '$p$root'
|
||||
list read '*'
|
||||
list write '*'
|
||||
|
||||
package system
|
||||
|
||||
config system
|
||||
option hostname 'OpenWrt'
|
||||
option timezone 'UTC'
|
||||
option ttylogin '0'
|
||||
option log_size '64'
|
||||
option urandom_seed '0'
|
||||
option compat_version '1.1'
|
||||
|
||||
config timeserver 'ntp'
|
||||
option enabled '1'
|
||||
option enable_server '0'
|
||||
list server '0.openwrt.pool.ntp.org'
|
||||
list server '1.openwrt.pool.ntp.org'
|
||||
list server '2.openwrt.pool.ntp.org'
|
||||
list server '3.openwrt.pool.ntp.org'
|
||||
|
||||
config led 'led_wan'
|
||||
option name 'WAN'
|
||||
option sysfs 'pca963x:cobra:white:wan'
|
||||
option trigger 'netdev'
|
||||
option mode 'link tx rx'
|
||||
option dev 'wan'
|
||||
|
||||
config led 'led_usb1'
|
||||
option name 'USB 1'
|
||||
option sysfs 'pca963x:cobra:white:usb2'
|
||||
option trigger 'usbport'
|
||||
list port 'usb1-port1'
|
||||
|
||||
config led 'led_usb2'
|
||||
option name 'USB 2'
|
||||
option sysfs 'pca963x:cobra:white:usb3_1'
|
||||
option trigger 'usbport'
|
||||
list port 'usb2-port1'
|
||||
list port 'usb3-port1'
|
||||
|
||||
config led 'led_usb2_ss'
|
||||
option name 'USB 2 SS'
|
||||
option sysfs 'pca963x:cobra:white:usb3_2'
|
||||
option trigger 'usbport'
|
||||
list port 'usb3-port1'
|
||||
|
||||
package ubootenv
|
||||
|
||||
config ubootenv
|
||||
option dev '/dev/mtd1'
|
||||
option offset '0x0'
|
||||
option envsize '0x20000'
|
||||
option secsize '0x40000'
|
||||
|
||||
package ucitrack
|
||||
|
||||
config network
|
||||
option init 'network'
|
||||
list affects 'dhcp'
|
||||
|
||||
config wireless
|
||||
list affects 'network'
|
||||
|
||||
config firewall
|
||||
option init 'firewall'
|
||||
list affects 'luci-splash'
|
||||
list affects 'qos'
|
||||
list affects 'miniupnpd'
|
||||
|
||||
config olsr
|
||||
option init 'olsrd'
|
||||
|
||||
config dhcp
|
||||
option init 'dnsmasq'
|
||||
list affects 'odhcpd'
|
||||
|
||||
config odhcpd
|
||||
option init 'odhcpd'
|
||||
|
||||
config dropbear
|
||||
option init 'dropbear'
|
||||
|
||||
config httpd
|
||||
option init 'httpd'
|
||||
|
||||
config fstab
|
||||
option exec '/sbin/block mount'
|
||||
|
||||
config qos
|
||||
option init 'qos'
|
||||
|
||||
config system
|
||||
option init 'led'
|
||||
option exec '/etc/init.d/log reload'
|
||||
list affects 'luci_statistics'
|
||||
list affects 'dhcp'
|
||||
|
||||
config luci_splash
|
||||
option init 'luci_splash'
|
||||
|
||||
config upnpd
|
||||
option init 'miniupnpd'
|
||||
|
||||
config ntpclient
|
||||
option init 'ntpclient'
|
||||
|
||||
config samba
|
||||
option init 'samba'
|
||||
|
||||
config tinyproxy
|
||||
option init 'tinyproxy'
|
||||
|
||||
package uhttpd
|
||||
|
||||
config uhttpd 'main'
|
||||
list listen_http '0.0.0.0:80'
|
||||
list listen_http '[::]:80'
|
||||
list listen_https '0.0.0.0:443'
|
||||
list listen_https '[::]:443'
|
||||
option redirect_https '0'
|
||||
option home '/www'
|
||||
option rfc1918_filter '1'
|
||||
option max_requests '3'
|
||||
option max_connections '100'
|
||||
option cert '/etc/uhttpd.crt'
|
||||
option key '/etc/uhttpd.key'
|
||||
option cgi_prefix '/cgi-bin'
|
||||
list lua_prefix '/cgi-bin/luci=/usr/lib/lua/luci/sgi/uhttpd.lua'
|
||||
option script_timeout '60'
|
||||
option network_timeout '30'
|
||||
option http_keepalive '20'
|
||||
option tcp_keepalive '1'
|
||||
option ubus_prefix '/ubus'
|
||||
|
||||
config cert 'defaults'
|
||||
option days '730'
|
||||
option key_type 'ec'
|
||||
option bits '2048'
|
||||
option ec_curve 'P-256'
|
||||
option country 'ZZ'
|
||||
option state 'Somewhere'
|
||||
option location 'Unknown'
|
||||
option commonname 'OpenWrt'
|
||||
|
||||
package wireless
|
||||
|
||||
config wifi-device 'radio0'
|
||||
option type 'mac80211'
|
||||
option path 'soc/soc:pcie/pci0000:00/0000:00:01.0/0000:01:00.0'
|
||||
option channel '36'
|
||||
option band '5g'
|
||||
option htmode 'VHT80'
|
||||
option disabled '1'
|
||||
option country 'FR'
|
||||
|
||||
config wifi-iface 'default_radio0'
|
||||
option device 'radio0'
|
||||
option network 'lan'
|
||||
option mode 'ap'
|
||||
option ssid 'OpenWrt'
|
||||
option encryption 'none'
|
||||
option macaddr '30:23:03:cd:b6:6e'
|
||||
|
||||
config wifi-device 'radio1'
|
||||
option type 'mac80211'
|
||||
option path 'soc/soc:pcie/pci0000:00/0000:00:02.0/0000:02:00.0'
|
||||
option channel '1'
|
||||
option band '2g'
|
||||
option htmode 'HT20'
|
||||
option disabled '1'
|
||||
option country 'FR'
|
||||
|
||||
config wifi-iface 'default_radio1'
|
||||
option device 'radio1'
|
||||
option network 'lan'
|
||||
option mode 'ap'
|
||||
option ssid 'OpenWrt'
|
||||
option encryption 'none'
|
||||
option macaddr '30:23:03:cd:b6:6d'
|
75
internal/openwrt/uci/uci.go
Normal file
75
internal/openwrt/uci/uci.go
Normal file
@ -0,0 +1,75 @@
|
||||
package uci
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UCI struct {
|
||||
Packages []*Package `parser:"@@*" json:"packages"`
|
||||
}
|
||||
|
||||
type Package struct {
|
||||
Name string `parser:"'package' @Ident" json:"name"`
|
||||
Configs []*Config `parser:"@@*" json:"configs"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Name string `parser:"'config' @Ident" json:"name"`
|
||||
Section *string `parser:"@String?" json:"section,omitempty"`
|
||||
Options []Option `parser:"@@*" json:"options"`
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
Type string `parser:"@( 'list' | 'option' )" json:"type"`
|
||||
Name string `parser:"@Ident" json:"name"`
|
||||
Value string `parser:"@String" json:"value"`
|
||||
}
|
||||
|
||||
func (u *UCI) Export() string {
|
||||
var sb strings.Builder
|
||||
|
||||
for pkgIdx, pkg := range u.Packages {
|
||||
if pkgIdx > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("package ")
|
||||
sb.WriteString(pkg.Name)
|
||||
sb.WriteString("\n")
|
||||
|
||||
for _, cfg := range pkg.Configs {
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("config ")
|
||||
sb.WriteString(cfg.Name)
|
||||
|
||||
if cfg.Section != nil {
|
||||
sb.WriteString(" '")
|
||||
sb.WriteString(*cfg.Section)
|
||||
sb.WriteString("'")
|
||||
}
|
||||
|
||||
for _, opt := range cfg.Options {
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("\t")
|
||||
sb.WriteString(opt.Type)
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(opt.Name)
|
||||
sb.WriteString(" '")
|
||||
sb.WriteString(opt.Value)
|
||||
sb.WriteString("'")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func NewUCI() *UCI {
|
||||
return &UCI{
|
||||
Packages: make([]*Package, 0),
|
||||
}
|
||||
}
|
313
internal/server/agent_api.go
Normal file
313
internal/server/agent_api.go
Normal file
@ -0,0 +1,313 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrCodeUnknownError api.ErrorCode = "unknown-error"
|
||||
ErrCodeNotFound api.ErrorCode = "not-found"
|
||||
ErrCodeAlreadyRegistered api.ErrorCode = "already-registered"
|
||||
)
|
||||
|
||||
type registerAgentRequest struct {
|
||||
RemoteID string `json:"remoteId"`
|
||||
}
|
||||
|
||||
func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) {
|
||||
registerAgentReq := ®isterAgentRequest{}
|
||||
if ok := api.Bind(w, r, registerAgentReq); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
agent, err := s.agentRepo.Create(
|
||||
ctx,
|
||||
registerAgentReq.RemoteID,
|
||||
datastore.AgentStatusPending,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrAlreadyExist) {
|
||||
logger.Error(ctx, "agent already registered", logger.F("remoteID", registerAgentReq.RemoteID))
|
||||
api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyRegistered, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not create agent", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusCreated, struct {
|
||||
Agent *datastore.Agent `json:"agent"`
|
||||
}{
|
||||
Agent: agent,
|
||||
})
|
||||
}
|
||||
|
||||
type updateAgentRequest struct {
|
||||
Status *datastore.AgentStatus `json:"status"`
|
||||
}
|
||||
|
||||
func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, ok := getAgentID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
updateAgentReq := &updateAgentRequest{}
|
||||
if ok := api.Bind(w, r, updateAgentReq); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
options := make([]datastore.AgentUpdateOptionFunc, 0)
|
||||
|
||||
if updateAgentReq.Status != nil {
|
||||
options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status))
|
||||
}
|
||||
|
||||
agent, err := s.agentRepo.Update(
|
||||
ctx,
|
||||
datastore.AgentID(agentID),
|
||||
options...,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not update agent", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Agent *datastore.Agent `json:"agent"`
|
||||
}{
|
||||
Agent: agent,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) queryAgents(w http.ResponseWriter, r *http.Request) {
|
||||
limit, ok := getIntQueryParam(w, r, "limit", 10)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
offset, ok := getIntQueryParam(w, r, "offset", 0)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
options := []datastore.AgentQueryOptionFunc{
|
||||
datastore.WithAgentQueryLimit(int(limit)),
|
||||
datastore.WithAgentQueryOffset(int(offset)),
|
||||
}
|
||||
|
||||
ids, ok := getIntSliceValues(w, r, "ids", nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if ids != nil {
|
||||
agentIDs := func(ids []int64) []datastore.AgentID {
|
||||
agentIDs := make([]datastore.AgentID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
agentIDs = append(agentIDs, datastore.AgentID(id))
|
||||
}
|
||||
|
||||
return agentIDs
|
||||
}(ids)
|
||||
|
||||
options = append(options, datastore.WithAgentQueryID(agentIDs...))
|
||||
}
|
||||
|
||||
remoteIDs, ok := getStringSliceValues(w, r, "remoteIds", nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if remoteIDs != nil {
|
||||
options = append(options, datastore.WithAgentQueryRemoteID(remoteIDs...))
|
||||
}
|
||||
|
||||
statuses, ok := getIntSliceValues(w, r, "statuses", nil)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if statuses != nil {
|
||||
agentStatuses := func(statuses []int64) []datastore.AgentStatus {
|
||||
agentStatuses := make([]datastore.AgentStatus, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
agentStatuses = append(agentStatuses, datastore.AgentStatus(status))
|
||||
}
|
||||
|
||||
return agentStatuses
|
||||
}(statuses)
|
||||
|
||||
options = append(options, datastore.WithAgentQueryStatus(agentStatuses...))
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
agents, total, err := s.agentRepo.Query(
|
||||
ctx,
|
||||
options...,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not list agents", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Agents []*datastore.Agent `json:"agents"`
|
||||
Total int `json:"total"`
|
||||
}{
|
||||
Agents: agents,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, ok := getAgentID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
err := s.agentRepo.Delete(
|
||||
ctx,
|
||||
datastore.AgentID(agentID),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not delete agent", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
AgentID datastore.AgentID `json:"agentId"`
|
||||
}{
|
||||
AgentID: datastore.AgentID(agentID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, ok := getAgentID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
agent, err := s.agentRepo.Get(
|
||||
ctx,
|
||||
datastore.AgentID(agentID),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not get agent", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Agent *datastore.Agent `json:"agent"`
|
||||
}{
|
||||
Agent: agent,
|
||||
})
|
||||
}
|
||||
|
||||
func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) {
|
||||
rawAgentID := chi.URLParam(r, "agentID")
|
||||
|
||||
agentID, err := strconv.ParseInt(rawAgentID, 10, 64)
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not parse agent id", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return datastore.AgentID(agentID), true
|
||||
}
|
||||
|
||||
func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) {
|
||||
rawValue := r.URL.Query().Get(param)
|
||||
if rawValue != "" {
|
||||
value, err := strconv.ParseInt(rawValue, 10, 64)
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value, true
|
||||
}
|
||||
|
||||
return defaultValue, true
|
||||
}
|
||||
|
||||
func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) {
|
||||
rawValue := r.URL.Query().Get(param)
|
||||
if rawValue != "" {
|
||||
values := strings.Split(rawValue, ",")
|
||||
|
||||
return values, true
|
||||
}
|
||||
|
||||
return defaultValue, true
|
||||
}
|
||||
|
||||
func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) {
|
||||
rawValue := r.URL.Query().Get(param)
|
||||
|
||||
if rawValue != "" {
|
||||
rawValues := strings.Split(rawValue, ",")
|
||||
values := make([]int64, 0, len(rawValues))
|
||||
|
||||
for _, rv := range rawValues {
|
||||
value, err := strconv.ParseInt(rv, 10, 64)
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
values = append(values, value)
|
||||
}
|
||||
|
||||
return values, true
|
||||
}
|
||||
|
||||
return defaultValue, true
|
||||
}
|
19
internal/server/init.go
Normal file
19
internal/server/init.go
Normal file
@ -0,0 +1,19 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/setup"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (s *Server) initRepositories(ctx context.Context) error {
|
||||
agentRepo, err := setup.NewAgentRepository(ctx, s.conf)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
s.agentRepo = agentRepo
|
||||
|
||||
return nil
|
||||
}
|
21
internal/server/option.go
Normal file
21
internal/server/option.go
Normal file
@ -0,0 +1,21 @@
|
||||
package server
|
||||
|
||||
import "forge.cadoles.com/Cadoles/emissary/internal/config"
|
||||
|
||||
type Option struct {
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
type OptionFunc func(*Option)
|
||||
|
||||
func defaultOption() *Option {
|
||||
return &Option{
|
||||
Config: config.NewDefault(),
|
||||
}
|
||||
}
|
||||
|
||||
func WithConfig(conf *config.Config) OptionFunc {
|
||||
return func(opt *Option) {
|
||||
opt.Config = conf
|
||||
}
|
||||
}
|
118
internal/server/server.go
Normal file
118
internal/server/server.go
Normal file
@ -0,0 +1,118 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/config"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
conf *config.Config
|
||||
agentRepo datastore.AgentRepository
|
||||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
|
||||
errs := make(chan error)
|
||||
addrs := make(chan net.Addr)
|
||||
|
||||
go s.run(ctx, addrs, errs)
|
||||
|
||||
return addrs, errs
|
||||
}
|
||||
|
||||
func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan error) {
|
||||
defer func() {
|
||||
close(errs)
|
||||
close(addrs)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
defer cancel()
|
||||
|
||||
if err := s.initRepositories(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))
|
||||
if err != nil {
|
||||
errs <- errors.WithStack(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
addrs <- listener.Addr()
|
||||
|
||||
defer func() {
|
||||
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
errs <- errors.WithStack(err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
||||
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("%+v", errors.WithStack(err))
|
||||
}
|
||||
}()
|
||||
|
||||
router := chi.NewRouter()
|
||||
|
||||
router.Use(middleware.Logger)
|
||||
|
||||
corsMiddleware := cors.New(cors.Options{
|
||||
AllowedOrigins: s.conf.CORS.AllowedOrigins,
|
||||
AllowedMethods: s.conf.CORS.AllowedMethods,
|
||||
AllowCredentials: bool(s.conf.CORS.AllowCredentials),
|
||||
AllowedHeaders: s.conf.CORS.AllowedHeaders,
|
||||
Debug: bool(s.conf.CORS.Debug),
|
||||
})
|
||||
|
||||
router.Use(corsMiddleware.Handler)
|
||||
|
||||
router.Route("/api/v1", func(r chi.Router) {
|
||||
r.Post("/register", s.registerAgent)
|
||||
|
||||
r.Route("/agents", func(r chi.Router) {
|
||||
r.Get("/", s.queryAgents)
|
||||
r.Get("/{agentID}", s.getAgent)
|
||||
r.Put("/{agentID}", s.updateAgent)
|
||||
r.Delete("/{agentID}", s.deleteAgent)
|
||||
|
||||
r.Get("/{agentID}/specs", s.getAgentSpecs)
|
||||
r.Post("/{agentID}/specs", s.updateSpec)
|
||||
r.Delete("/{agentID}/specs", s.deleteSpec)
|
||||
})
|
||||
})
|
||||
|
||||
logger.Info(ctx, "http server listening")
|
||||
|
||||
if err := http.Serve(listener, router); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
errs <- errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "http server exiting")
|
||||
}
|
||||
|
||||
func New(funcs ...OptionFunc) *Server {
|
||||
opt := defaultOption()
|
||||
for _, fn := range funcs {
|
||||
fn(opt)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
conf: opt.Config,
|
||||
}
|
||||
}
|
141
internal/server/spec_api.go
Normal file
141
internal/server/spec_api.go
Normal file
@ -0,0 +1,141 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision"
|
||||
)
|
||||
|
||||
type updateSpecRequest struct {
|
||||
Name string `json:"name"`
|
||||
Revision int `json:"revision"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func (s *Server) updateSpec(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, ok := getAgentID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
updateSpecReq := &updateSpecRequest{}
|
||||
if ok := api.Bind(w, r, updateSpecReq); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
spec, err := s.agentRepo.UpdateSpec(
|
||||
ctx,
|
||||
datastore.AgentID(agentID),
|
||||
updateSpecReq.Name,
|
||||
updateSpecReq.Revision,
|
||||
updateSpecReq.Data,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrUnexpectedRevision) {
|
||||
api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not update spec", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Spec *datastore.Spec `json:"spec"`
|
||||
}{
|
||||
Spec: spec,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, ok := getAgentID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
specs, err := s.agentRepo.GetSpecs(ctx, agentID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "could not list specs", logger.E(errors.WithStack(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 {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *Server) deleteSpec(w http.ResponseWriter, r *http.Request) {
|
||||
agentID, ok := getAgentID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deleteSpecReq := &deleteSpecRequest{}
|
||||
if ok := api.Bind(w, r, deleteSpecReq); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
err := s.agentRepo.DeleteSpec(
|
||||
ctx,
|
||||
agentID,
|
||||
deleteSpecReq.Name,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, datastore.ErrNotFound) {
|
||||
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error(ctx, "could not delete spec", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
api.DataResponse(w, http.StatusOK, struct {
|
||||
Name string `json:"name"`
|
||||
}{
|
||||
Name: deleteSpecReq.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) {
|
||||
rawSpecID := chi.URLParam(r, "")
|
||||
|
||||
specID, err := strconv.ParseInt(rawSpecID, 10, 64)
|
||||
if err != nil {
|
||||
logger.Error(r.Context(), "could not parse spec id", logger.E(errors.WithStack(err)))
|
||||
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return datastore.SpecID(specID), true
|
||||
}
|
69
internal/setup/repository.go
Normal file
69
internal/setup/repository.go
Normal file
@ -0,0 +1,69 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/config"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore/sqlite"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
postgresPoolOnce sync.Once
|
||||
postgresPoolErr error
|
||||
postgresPool *pgxpool.Pool
|
||||
)
|
||||
|
||||
func openPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
postgresPoolOnce.Do(func() {
|
||||
postgresPool, postgresPoolErr = pgxpool.New(ctx, dsn)
|
||||
})
|
||||
if postgresPoolErr != nil {
|
||||
return nil, errors.WithStack(postgresPoolErr)
|
||||
}
|
||||
|
||||
return postgresPool, nil
|
||||
}
|
||||
|
||||
func NewAgentRepository(ctx context.Context, conf *config.Config) (datastore.AgentRepository, error) {
|
||||
driver := string(conf.Database.Driver)
|
||||
dsn := string(conf.Database.DSN)
|
||||
|
||||
var agentRepository datastore.AgentRepository
|
||||
|
||||
logger.Debug(ctx, "initializing agent 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)
|
||||
}
|
||||
|
||||
agentRepository = sqlite.NewAgentRepository(db)
|
||||
|
||||
default:
|
||||
return nil, errors.Errorf("unsupported database driver '%s'", driver)
|
||||
}
|
||||
|
||||
return agentRepository, nil
|
||||
}
|
5
internal/spec/error.go
Normal file
5
internal/spec/error.go
Normal file
@ -0,0 +1,5 @@
|
||||
package spec
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrSchemaUnknown = errors.New("schema unknown")
|
35
internal/spec/gateway.go
Normal file
35
internal/spec/gateway.go
Normal file
@ -0,0 +1,35 @@
|
||||
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),
|
||||
}
|
||||
}
|
3
internal/spec/name.go
Normal file
3
internal/spec/name.go
Normal file
@ -0,0 +1,3 @@
|
||||
package spec
|
||||
|
||||
type Name string
|
25
internal/spec/spec.go
Normal file
25
internal/spec/spec.go
Normal file
@ -0,0 +1,25 @@
|
||||
package spec
|
||||
|
||||
type Spec interface {
|
||||
SpecName() Name
|
||||
SpecRevision() int
|
||||
SpecData() any
|
||||
}
|
||||
|
||||
type RawSpec struct {
|
||||
Name Name `json:"name"`
|
||||
Revision int `json:"revision"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func (s *RawSpec) SpecName() Name {
|
||||
return s.Name
|
||||
}
|
||||
|
||||
func (s *RawSpec) SpecRevision() int {
|
||||
return s.Revision
|
||||
}
|
||||
|
||||
func (s *RawSpec) SpecData() any {
|
||||
return s.Data
|
||||
}
|
37
internal/spec/uci.go
Normal file
37
internal/spec/uci.go
Normal file
@ -0,0 +1,37 @@
|
||||
package spec
|
||||
|
||||
import "forge.cadoles.com/Cadoles/emissary/internal/openwrt/uci"
|
||||
|
||||
const NameUCI Name = "uci.emissary.cadoles.com"
|
||||
|
||||
type UCI struct {
|
||||
Revision int `json:"revisions"`
|
||||
Config *uci.UCI `json:"config"`
|
||||
PostImportCommands []*UCIPostImportCommand `json:"postImportCommands"`
|
||||
}
|
||||
|
||||
type UCIPostImportCommand struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
|
||||
func (u *UCI) SpecName() Name {
|
||||
return NameUCI
|
||||
}
|
||||
|
||||
func (u *UCI) SpecRevision() int {
|
||||
return u.Revision
|
||||
}
|
||||
|
||||
func (u *UCI) SpecData() any {
|
||||
return struct {
|
||||
Config *uci.UCI `json:"config"`
|
||||
PostImportCommands []*UCIPostImportCommand `json:"postImportCommands"`
|
||||
}{Config: u.Config, PostImportCommands: u.PostImportCommands}
|
||||
}
|
||||
|
||||
func NewUCISpec() *UCI {
|
||||
return &UCI{
|
||||
PostImportCommands: make([]*UCIPostImportCommand, 0),
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user