feat: initial commit

This commit is contained in:
2023-02-02 10:55:24 +01:00
commit a567e47421
92 changed files with 7300 additions and 0 deletions

View 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 := &registerAgentRequest{}
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
View 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
View 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
View 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
View 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
}