diff --git a/internal/datastore/sqlite/agent_repository.go b/internal/datastore/sqlite/agent_repository.go index 9857e3d..a1e0c6c 100644 --- a/internal/datastore/sqlite/agent_repository.go +++ b/internal/datastore/sqlite/agent_repository.go @@ -48,7 +48,7 @@ func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantI now := time.Now().UTC() query = ` - UPDATE agents SET tenant_id = $1, updated_at = $2 + UPDATE agents SET tenant_id = $1, updated_at = $2 WHERE id = $3 RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id" ` @@ -56,6 +56,7 @@ func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantI ctx, query, tenantID, now, + agentID, ) metadata := JSONMap{} @@ -85,8 +86,47 @@ func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantI } // Detach implements datastore.AgentRepository. -func (*AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) { - panic("unimplemented") +func (r *AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) { + var agent datastore.Agent + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + now := time.Now().UTC() + + query := ` + UPDATE agents SET tenant_id = null, updated_at = $1 WHERE id = $2 + RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id" + ` + + row := tx.QueryRowContext( + ctx, query, + now, + agentID, + ) + + metadata := JSONMap{} + var rawKeySet []byte + + err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID) + if err != nil { + return errors.WithStack(err) + } + + agent.Metadata = metadata + + keySet, err := jwk.Parse(rawKeySet) + if err != nil { + return errors.WithStack(err) + } + + agent.KeySet = &datastore.SerializableKeySet{keySet} + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &agent, nil } // DeleteSpec implements datastore.AgentRepository. diff --git a/internal/server/agent_api.go b/internal/server/agent_api.go deleted file mode 100644 index e6c4abf..0000000 --- a/internal/server/agent_api.go +++ /dev/null @@ -1,504 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - - "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata" - "forge.cadoles.com/Cadoles/emissary/internal/datastore" - "forge.cadoles.com/Cadoles/emissary/internal/jwk" - "github.com/go-chi/chi/v5" - "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" - ErrCodeInvalidSignature api.ErrorCode = "invalid-signature" - ErrCodeConflict api.ErrorCode = "conflict" - ErrCodeMultipleResults api.ErrorCode = "multiple-results" - ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed" -) - -type registerAgentRequest struct { - KeySet json.RawMessage `json:"keySet" validate:"required"` - Metadata []metadata.Tuple `json:"metadata" validate:"required"` - Thumbprint string `json:"thumbprint" validate:"required"` - Signature string `json:"signature" validate:"required"` -} - -func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) { - registerAgentReq := ®isterAgentRequest{} - if ok := api.Bind(w, r, registerAgentReq); !ok { - return - } - - ctx := r.Context() - - keySet, err := jwk.Parse(registerAgentReq.KeySet) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not parse key set", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint)) - - // Validate that the existing signature validates the request - - validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not validate signature", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - if !validSignature { - logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature)) - api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) - - return - } - - metadata := metadata.FromSorted(registerAgentReq.Metadata) - - agent, err := s.agentRepo.Create( - ctx, - registerAgentReq.Thumbprint, - keySet, - metadata, - ) - if err != nil { - if !errors.Is(err, datastore.ErrAlreadyExist) { - err = errors.WithStack(err) - logger.Error(ctx, "could not create agent", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - agents, _, err := s.agentRepo.Query( - ctx, - datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint), - datastore.WithAgentQueryLimit(1), - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - if len(agents) == 0 { - err = errors.WithStack(err) - logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil) - - return - } - - agentID := agents[0].ID - - agent, err = s.agentRepo.Get(ctx, agentID) - if err != nil { - err = errors.WithStack(err) - logger.Error( - ctx, "could not retrieve agent", - logger.CapturedE(err), logger.F("agentID", agentID), - ) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) - - return - } - - agent, err = s.agentRepo.Update( - ctx, - agents[0].ID, - datastore.WithAgentUpdateKeySet(keySet), - datastore.WithAgentUpdateMetadata(metadata), - datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint), - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not update agent", logger.CapturedE(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" validate:"omitempty,oneof=0 1 2 3"` - Label *string `json:"label" validate:"omitempty"` -} - -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)) - } - - if updateAgentReq.Label != nil { - options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label)) - } - - agent, err := s.agentRepo.Update( - ctx, - datastore.AgentID(agentID), - options..., - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not update agent", logger.CapturedE(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) { - user, ok := assertRequestUser(w, r) - if !ok { - return - } - - 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)), - datastore.WithAgentQueryTenantID(user.Tenant()), - } - - 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...)) - } - - thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil) - if !ok { - return - } - - if thumbprints != nil { - options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...)) - } - - 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 { - err = errors.WithStack(err) - logger.Error(ctx, "could not list agents", logger.CapturedE(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 - } - - if ok := s.assertTenantOwns(w, r, agentID); !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 - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not delete agent", logger.CapturedE(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 - } - - if ok := s.assertTenantOwns(w, r, agentID); !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 - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not get agent", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - Agent *datastore.Agent `json:"agent"` - }{ - Agent: agent, - }) -} - -type claimAgentRequest struct { - Thumbprint string `json:"thumbprint" validate:"required"` -} - -func (s *Server) claimAgent(w http.ResponseWriter, r *http.Request) { - user, ok := assertRequestUser(w, r) - if !ok { - return - } - - ctx := r.Context() - - claimAgentReq := &claimAgentRequest{} - if ok := api.Bind(w, r, claimAgentReq); !ok { - return - } - - results, _, err := s.agentRepo.Query( - ctx, - datastore.WithAgentQueryThumbprints(claimAgentReq.Thumbprint), - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not query agents", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - if len(results) == 0 { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - if len(results) > 1 { - logger.Error(ctx, "multiple results for agent thumbprint", logger.F("agentThumbprint", claimAgentReq.Thumbprint)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeMultipleResults, nil) - - return - } - - agent := results[0] - - if agent.TenantID != nil { - logger.Error(ctx, "agent already claimed", logger.F("agentThumbprint", claimAgentReq.Thumbprint), logger.F("agentID", agent.ID), logger.F("tenantID", agent.TenantID)) - api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyClaimed, nil) - - return - } - - agent, err = s.agentRepo.Attach(ctx, user.Tenant(), agent.ID) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not attach agent", logger.CapturedE(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.CapturedE(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 { - err = errors.WithStack(err) - logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(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 { - err = errors.WithStack(err) - logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) - - return nil, false - } - - values = append(values, value) - } - - return values, true - } - - return defaultValue, true -} diff --git a/internal/server/authorization.go b/internal/server/api/authorization.go similarity index 95% rename from internal/server/authorization.go rename to internal/server/api/authorization.go index c2ef4b2..b8a3fe6 100644 --- a/internal/server/authorization.go +++ b/internal/server/api/authorization.go @@ -1,4 +1,4 @@ -package server +package api import ( "context" @@ -142,7 +142,7 @@ func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) return user, true } -func (s *Server) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentID datastore.AgentID) bool { +func (m *Mount) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentID datastore.AgentID) bool { ctx := r.Context() user, ok := assertRequestUser(w, r) @@ -150,7 +150,7 @@ func (s *Server) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentI return false } - agent, err := s.agentRepo.Get(ctx, agentID) + agent, err := m.agentRepo.Get(ctx, agentID) if err != nil { err = errors.WithStack(err) logger.Error(ctx, "could not get agent", logger.CapturedE(err)) diff --git a/internal/server/api/claim_agent.go b/internal/server/api/claim_agent.go new file mode 100644 index 0000000..bb02ad2 --- /dev/null +++ b/internal/server/api/claim_agent.go @@ -0,0 +1,77 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type claimAgentRequest struct { + Thumbprint string `json:"thumbprint" validate:"required"` +} + +func (m *Mount) claimAgent(w http.ResponseWriter, r *http.Request) { + user, ok := assertRequestUser(w, r) + if !ok { + return + } + + ctx := r.Context() + + claimAgentReq := &claimAgentRequest{} + if ok := api.Bind(w, r, claimAgentReq); !ok { + return + } + + results, _, err := m.agentRepo.Query( + ctx, + datastore.WithAgentQueryThumbprints(claimAgentReq.Thumbprint), + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not query agents", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + if len(results) == 0 { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + if len(results) > 1 { + logger.Error(ctx, "multiple results for agent thumbprint", logger.F("agentThumbprint", claimAgentReq.Thumbprint)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeMultipleResults, nil) + + return + } + + agent := results[0] + + if agent.TenantID != nil { + logger.Error(ctx, "agent already claimed", logger.F("agentThumbprint", claimAgentReq.Thumbprint), logger.F("agentID", agent.ID), logger.F("tenantID", agent.TenantID)) + api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyClaimed, nil) + + return + } + + agent, err = m.agentRepo.Attach(ctx, user.Tenant(), agent.ID) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not attach agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} diff --git a/internal/server/api/delete_agent.go b/internal/server/api/delete_agent.go new file mode 100644 index 0000000..76decf5 --- /dev/null +++ b/internal/server/api/delete_agent.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) deleteAgent(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + if ok := m.assertTenantOwns(w, r, agentID); !ok { + return + } + + ctx := r.Context() + + err := m.agentRepo.Delete( + ctx, + datastore.AgentID(agentID), + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not delete agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + AgentID datastore.AgentID `json:"agentId"` + }{ + AgentID: datastore.AgentID(agentID), + }) +} diff --git a/internal/server/api/get_agent.go b/internal/server/api/get_agent.go new file mode 100644 index 0000000..c1af46a --- /dev/null +++ b/internal/server/api/get_agent.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) getAgent(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + if ok := m.assertTenantOwns(w, r, agentID); !ok { + return + } + + ctx := r.Context() + + agent, err := m.agentRepo.Get( + ctx, + datastore.AgentID(agentID), + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not get agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} diff --git a/internal/server/api/get_specs.go b/internal/server/api/get_specs.go new file mode 100644 index 0000000..cc81625 --- /dev/null +++ b/internal/server/api/get_specs.go @@ -0,0 +1,85 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + ctx := r.Context() + + specs, err := m.agentRepo.GetSpecs(ctx, agentID) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not list specs", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Specs []*datastore.Spec `json:"specs"` + }{ + Specs: specs, + }) +} + +type deleteSpecRequest struct { + Name string `json:"name"` +} + +func (m *Mount) 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 := m.agentRepo.DeleteSpec( + ctx, + agentID, + deleteSpecReq.Name, + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not delete spec", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Name string `json:"name"` + }{ + Name: deleteSpecReq.Name, + }) +} diff --git a/internal/server/api/helper.go b/internal/server/api/helper.go new file mode 100644 index 0000000..0daeafa --- /dev/null +++ b/internal/server/api/helper.go @@ -0,0 +1,107 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/go-chi/chi/v5" + "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" + ErrCodeInvalidSignature api.ErrorCode = "invalid-signature" + ErrCodeConflict api.ErrorCode = "conflict" + ErrCodeMultipleResults api.ErrorCode = "multiple-results" + ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed" +) + +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.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return 0, false + } + + return datastore.AgentID(agentID), true +} + +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 { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return 0, false + } + + return datastore.SpecID(specID), true +} + +func 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 { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(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 { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return nil, false + } + + values = append(values, value) + } + + return values, true + } + + return defaultValue, true +} diff --git a/internal/server/api/mount.go b/internal/server/api/mount.go new file mode 100644 index 0000000..00dfa62 --- /dev/null +++ b/internal/server/api/mount.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/auth" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/go-chi/chi/v5" + "gitlab.com/wpetit/goweb/api" +) + +type Mount struct { + agentRepo datastore.AgentRepository + authenticators []auth.Authenticator +} + +func (m *Mount) Mount(r chi.Router) { + r.NotFound(m.notFound) + + r.Post("/register", m.registerAgent) + + r.Group(func(r chi.Router) { + r.Use(auth.Middleware(m.authenticators...)) + + r.Route("/agents", func(r chi.Router) { + r.Post("/claim", m.claimAgent) + + r.With(assertGlobalReadAccess).Get("/", m.queryAgents) + + r.With(assertAgentReadAccess).Get("/{agentID}", m.getAgent) + r.With(assertAgentWriteAccess).Put("/{agentID}", m.updateAgent) + r.With(assertAgentWriteAccess).Delete("/{agentID}", m.deleteAgent) + + r.With(assertAgentReadAccess).Get("/{agentID}/specs", m.getAgentSpecs) + r.With(assertAgentWriteAccess).Post("/{agentID}/specs", m.updateSpec) + r.With(assertAgentWriteAccess).Delete("/{agentID}/specs", m.deleteSpec) + }) + }) +} + +func (m *Mount) notFound(w http.ResponseWriter, r *http.Request) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) +} + +func NewMount(agentRepo datastore.AgentRepository, authenticators ...auth.Authenticator) *Mount { + return &Mount{agentRepo, authenticators} +} diff --git a/internal/server/api/query_agents.go b/internal/server/api/query_agents.go new file mode 100644 index 0000000..63ab72c --- /dev/null +++ b/internal/server/api/query_agents.go @@ -0,0 +1,100 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) queryAgents(w http.ResponseWriter, r *http.Request) { + user, ok := assertRequestUser(w, r) + if !ok { + return + } + + 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)), + datastore.WithAgentQueryTenantID(user.Tenant()), + } + + 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...)) + } + + thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil) + if !ok { + return + } + + if thumbprints != nil { + options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...)) + } + + 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 := m.agentRepo.Query( + ctx, + options..., + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not list agents", logger.CapturedE(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, + }) +} diff --git a/internal/server/api/register_agent.go b/internal/server/api/register_agent.go new file mode 100644 index 0000000..891355c --- /dev/null +++ b/internal/server/api/register_agent.go @@ -0,0 +1,151 @@ +package api + +import ( + "encoding/json" + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "forge.cadoles.com/Cadoles/emissary/internal/jwk" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type registerAgentRequest struct { + KeySet json.RawMessage `json:"keySet" validate:"required"` + Metadata []metadata.Tuple `json:"metadata" validate:"required"` + Thumbprint string `json:"thumbprint" validate:"required"` + Signature string `json:"signature" validate:"required"` +} + +func (m *Mount) registerAgent(w http.ResponseWriter, r *http.Request) { + registerAgentReq := ®isterAgentRequest{} + if ok := api.Bind(w, r, registerAgentReq); !ok { + return + } + + ctx := r.Context() + + keySet, err := jwk.Parse(registerAgentReq.KeySet) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not parse key set", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint)) + + // Validate that the existing signature validates the request + + validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not validate signature", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + if !validSignature { + logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature)) + api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) + + return + } + + metadata := metadata.FromSorted(registerAgentReq.Metadata) + + agent, err := m.agentRepo.Create( + ctx, + registerAgentReq.Thumbprint, + keySet, + metadata, + ) + if err != nil { + if !errors.Is(err, datastore.ErrAlreadyExist) { + err = errors.WithStack(err) + logger.Error(ctx, "could not create agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + agents, _, err := m.agentRepo.Query( + ctx, + datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint), + datastore.WithAgentQueryLimit(1), + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + if len(agents) == 0 { + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil) + + return + } + + agentID := agents[0].ID + + agent, err = m.agentRepo.Get(ctx, agentID) + if err != nil { + err = errors.WithStack(err) + logger.Error( + ctx, "could not retrieve agent", + logger.CapturedE(err), logger.F("agentID", agentID), + ) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) + + return + } + + if !validSignature { + logger.Error(ctx, "invalid signature") + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil) + + return + } + + agent, err = m.agentRepo.Update( + ctx, + agents[0].ID, + datastore.WithAgentUpdateKeySet(keySet), + datastore.WithAgentUpdateMetadata(metadata), + datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint), + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not update agent", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + } + + api.DataResponse(w, http.StatusCreated, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} diff --git a/internal/server/api/release_agent.go b/internal/server/api/release_agent.go new file mode 100644 index 0000000..ceeda6f --- /dev/null +++ b/internal/server/api/release_agent.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type releaseAgentRequest struct { + AgentID int64 `json:"agentId" validate:"required"` +} + +func (m *Mount) releaseAgent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + releaseAgentReq := &releaseAgentRequest{} + if ok := api.Bind(w, r, releaseAgentReq); !ok { + return + } + + agentID := datastore.AgentID(releaseAgentReq.AgentID) + + if ok := m.assertTenantOwns(w, r, agentID); !ok { + return + } + + agent, err := m.agentRepo.Get(ctx, agentID) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + agent, err = m.agentRepo.Detach(ctx, agent.ID) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not detach agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} diff --git a/internal/server/api/update_agent.go b/internal/server/api/update_agent.go new file mode 100644 index 0000000..559a026 --- /dev/null +++ b/internal/server/api/update_agent.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type updateAgentRequest struct { + Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"` + Label *string `json:"label" validate:"omitempty"` +} + +func (m *Mount) 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)) + } + + if updateAgentReq.Label != nil { + options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label)) + } + + agent, err := m.agentRepo.Update( + ctx, + datastore.AgentID(agentID), + options..., + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not update agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} diff --git a/internal/server/api/update_spec.go b/internal/server/api/update_spec.go new file mode 100644 index 0000000..9a588e5 --- /dev/null +++ b/internal/server/api/update_spec.go @@ -0,0 +1,86 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "forge.cadoles.com/Cadoles/emissary/internal/spec" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +const ( + ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision" +) + +type updateSpecRequest struct { + spec.RawSpec +} + +func (m *Mount) 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 + } + + if err := spec.Validate(ctx, updateSpecReq); err != nil { + data := struct { + Message string `json:"message"` + }{} + + var validationErr *spec.ValidationError + + if errors.As(err, &validationErr) { + data.Message = validationErr.Error() + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not validate spec", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data) + + return + } + + spec, err := m.agentRepo.UpdateSpec( + ctx, + datastore.AgentID(agentID), + string(updateSpecReq.SpecName()), + updateSpecReq.SpecRevision(), + updateSpecReq.SpecData(), + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + if errors.Is(err, datastore.ErrUnexpectedRevision) { + api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not update spec", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Spec *datastore.Spec `json:"spec"` + }{ + Spec: spec, + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 4b138c1..8d2b87e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,12 +10,12 @@ import ( "strings" "time" - "forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth/agent" "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" "forge.cadoles.com/Cadoles/emissary/internal/config" "forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/jwk" + "forge.cadoles.com/Cadoles/emissary/internal/server/api" "github.com/antonmedv/expr" "github.com/antonmedv/expr/vm" "github.com/go-chi/chi/v5" @@ -101,28 +101,13 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e } router.Route("/api/v1", func(r chi.Router) { - r.Post("/register", s.registerAgent) + apiMount := api.NewMount( + s.agentRepo, + thirdPartyAuth, + agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew), + ) - r.Group(func(r chi.Router) { - r.Use(auth.Middleware( - thirdPartyAuth, - agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew), - )) - - r.Route("/agents", func(r chi.Router) { - r.Post("/claim", s.claimAgent) - - r.With(assertGlobalReadAccess).Get("/", s.queryAgents) - - r.With(assertAgentReadAccess).Get("/{agentID}", s.getAgent) - r.With(assertAgentWriteAccess).Put("/{agentID}", s.updateAgent) - r.With(assertAgentWriteAccess).Delete("/{agentID}", s.deleteAgent) - - r.With(assertAgentReadAccess).Get("/{agentID}/specs", s.getAgentSpecs) - r.With(assertAgentWriteAccess).Post("/{agentID}/specs", s.updateSpec) - r.With(assertAgentWriteAccess).Delete("/{agentID}/specs", s.deleteSpec) - }) - }) + apiMount.Mount(r) }) logger.Info(ctx, "http server listening") diff --git a/internal/server/spec_api.go b/internal/server/spec_api.go deleted file mode 100644 index 86b63b1..0000000 --- a/internal/server/spec_api.go +++ /dev/null @@ -1,179 +0,0 @@ -package server - -import ( - "net/http" - "strconv" - - "forge.cadoles.com/Cadoles/emissary/internal/datastore" - "forge.cadoles.com/Cadoles/emissary/internal/spec" - "github.com/go-chi/chi/v5" - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/api" - "gitlab.com/wpetit/goweb/logger" -) - -const ( - ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision" -) - -type updateSpecRequest struct { - spec.RawSpec -} - -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 - } - - if err := spec.Validate(ctx, updateSpecReq); err != nil { - data := struct { - Message string `json:"message"` - }{} - - var validationErr *spec.ValidationError - - if errors.As(err, &validationErr) { - data.Message = validationErr.Error() - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not validate spec", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data) - - return - } - - spec, err := s.agentRepo.UpdateSpec( - ctx, - datastore.AgentID(agentID), - string(updateSpecReq.SpecName()), - updateSpecReq.SpecRevision(), - updateSpecReq.SpecData(), - ) - if err != nil { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - if errors.Is(err, datastore.ErrUnexpectedRevision) { - api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not update spec", logger.CapturedE(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 { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not list specs", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - Specs []*datastore.Spec `json:"specs"` - }{ - Specs: specs, - }) -} - -type deleteSpecRequest struct { - 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 - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not delete spec", logger.CapturedE(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 { - err = errors.WithStack(err) - logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) - - return 0, false - } - - return datastore.SpecID(specID), true -}