feat: agent specifications query and get endpoints
arcad/emissary/pipeline/head This commit looks good Details

This commit is contained in:
wpetit 2024-03-13 16:07:16 +01:00
parent cec5c783fe
commit 85ccf2e1df
15 changed files with 261 additions and 55 deletions

View File

@ -45,17 +45,29 @@ func (c *Controller) reconcileAgent(ctx context.Context, client *client.Client,
return nil return nil
} }
specs, err := client.GetAgentSpecs(ctx, agent.ID) specHeaders, err := client.QueryAgentSpecs(ctx, agent.ID)
if err != nil { if err != nil {
err = errors.WithStack(err) err = errors.WithStack(err)
logger.Error(ctx, "could not retrieve agent specs", logger.CapturedE(err)) logger.Error(ctx, "could not query agent specs", logger.CapturedE(err))
return nil return nil
} }
state.ClearSpecs() state.ClearSpecs()
for _, spec := range specs { for _, sh := range specHeaders {
spec, err := client.GetAgentSpec(ctx, agent.ID, sh.DefinitionName, sh.DefinitionVersion)
if err != nil {
logger.Error(
ctx, "could not retrieve agent spec",
logger.F("specName", sh.DefinitionName),
logger.F("specVersion", sh.DefinitionVersion),
logger.CapturedE(errors.WithStack(err)),
)
continue
}
state.SetSpec(spec) state.SetSpec(spec)
} }

View File

@ -15,8 +15,18 @@ import (
func GetCommand() *cli.Command { func GetCommand() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "get", Name: "get",
Usage: "Get agent specifications", Usage: "Get agent specification",
Flags: agentFlag.WithAgentFlags(), Flags: agentFlag.WithAgentFlags(
&cli.StringFlag{
Name: "spec-name",
Usage: "use `NAME` as specification's name",
},
&cli.StringFlag{
Name: "spec-version",
Usage: "use `VERSION` as specification's version",
Value: "0.0.0",
},
),
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx) baseFlags := clientFlag.GetBaseFlags(ctx)
agentID, err := agentFlag.AssertAgentID(ctx) agentID, err := agentFlag.AssertAgentID(ctx)
@ -29,16 +39,26 @@ func GetCommand() *cli.Command {
return errors.WithStack(apierr.Wrap(err)) return errors.WithStack(apierr.Wrap(err))
} }
specDefName, err := assertSpecDefName(ctx)
if err != nil {
return errors.WithStack(err)
}
specDefVersion, err := assertSpecDefVersion(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token)) client := client.New(baseFlags.ServerURL, client.WithToken(token))
specs, err := client.GetAgentSpecs(ctx.Context, agentID) spec, err := client.GetAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
if err != nil { if err != nil {
return errors.WithStack(apierr.Wrap(err)) return errors.WithStack(apierr.Wrap(err))
} }
hints := specHints(baseFlags.OutputMode) hints := specHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil { if err := format.Write(baseFlags.Format, os.Stdout, hints, spec); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }

View File

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

View File

@ -0,0 +1,48 @@
package spec
import (
"os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func QueryCommand() *cli.Command {
return &cli.Command{
Name: "query",
Usage: "Query agent specifications",
Flags: agentFlag.WithAgentFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
agentID, err := agentFlag.AssertAgentID(ctx)
if err != nil {
return errors.WithStack(err)
}
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
specs, err := client.QueryAgentSpecs(ctx.Context, agentID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := specHeaderHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -10,6 +10,7 @@ func Root() *cli.Command {
Usage: "Specifications related commands", Usage: "Specifications related commands",
Subcommands: []*cli.Command{ Subcommands: []*cli.Command{
GetCommand(), GetCommand(),
QueryCommand(),
UpdateCommand(), UpdateCommand(),
DeleteCommand(), DeleteCommand(),
}, },

View File

@ -12,6 +12,7 @@ import (
jsonpatch "github.com/evanphx/json-patch/v5" jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/cli/format" "gitlab.com/wpetit/goweb/cli/format"
) )
@ -73,21 +74,12 @@ func UpdateCommand() *cli.Command {
client := client.New(baseFlags.ServerURL, client.WithToken(token)) client := client.New(baseFlags.ServerURL, client.WithToken(token))
specs, err := client.GetAgentSpecs(ctx.Context, agentID) existingSpec, err := client.GetAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
if err != nil { if err != nil {
return errors.WithStack(apierr.Wrap(err)) var apiErr api.Error
} if !errors.As(err, &apiErr) || apiErr.Code != api.ErrCodeNotFound {
return errors.WithStack(apierr.Wrap(err))
var existingSpec spec.Spec
for _, s := range specs {
if s.SpecDefinitionName() != specDefName || s.SpecDefinitionVersion() != specDefVersion {
continue
} }
existingSpec = s
break
} }
revision := 0 revision := 0

View File

@ -19,7 +19,8 @@ type AgentRepository interface {
Delete(ctx context.Context, id AgentID) error Delete(ctx context.Context, id AgentID) error
UpdateSpec(ctx context.Context, id AgentID, name string, version string, revision int, data map[string]any) (*Spec, error) UpdateSpec(ctx context.Context, id AgentID, name string, version string, revision int, data map[string]any) (*Spec, error)
GetSpecs(ctx context.Context, id AgentID) ([]*Spec, error) QuerySpecs(ctx context.Context, id AgentID) ([]*SpecHeader, error)
GetSpec(ctx context.Context, id AgentID, name string, version string) (*Spec, error)
DeleteSpec(ctx context.Context, id AgentID, name string, version string) error DeleteSpec(ctx context.Context, id AgentID, name string, version string) error
} }

View File

@ -6,16 +6,21 @@ import (
type SpecID int64 type SpecID int64
type SpecHeader struct {
ID SpecID `json:"id"`
DefinitionName string `json:"name"`
DefinitionVersion string `json:"version"`
Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
TenantID TenantID `json:"tenantId"`
AgentID AgentID `json:"agentId"`
}
type Spec struct { type Spec struct {
ID SpecID `json:"id"` SpecHeader
DefinitionName string `json:"name"`
DefinitionVersion string `json:"version"` Data map[string]any `json:"data"`
Data map[string]any `json:"data"`
Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
TenantID TenantID `json:"tenantId"`
AgentID AgentID `json:"agentId"`
} }
func (s *Spec) SpecDefinitionName() string { func (s *Spec) SpecDefinitionName() string {

View File

@ -154,9 +154,44 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
return nil return nil
} }
// GetSpec implements datastore.AgentRepository.
func (r *AgentRepository) GetSpec(ctx context.Context, agentID datastore.AgentID, name string, version string) (*datastore.Spec, error) {
var spec datastore.Spec
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID)
if err != nil {
return errors.WithStack(err)
}
if !exists {
return errors.WithStack(datastore.ErrNotFound)
}
query := `SELECT id, name, version, revision, data, created_at, updated_at, agent_id, tenant_id FROM specs WHERE agent_id = $1 AND name = $2 AND version = $3`
row := tx.QueryRowContext(ctx, query, agentID, name, version)
var data JSONMap
if err := row.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &spec.TenantID); err != nil {
return errors.WithStack(err)
}
spec.Data = data
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &spec, nil
}
// GetSpecs implements datastore.AgentRepository. // GetSpecs implements datastore.AgentRepository.
func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) { func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.SpecHeader, error) {
specs := make([]*datastore.Spec, 0) specs := make([]*datastore.SpecHeader, 0)
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID) exists, err := r.agentExists(ctx, tx, agentID)
@ -169,7 +204,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
} }
query := ` query := `
SELECT id, name, version, revision, data, created_at, updated_at, agent_id, tenant_id SELECT id, name, version, revision, created_at, updated_at, agent_id, tenant_id
FROM specs FROM specs
WHERE agent_id = $1 WHERE agent_id = $1
` `
@ -187,19 +222,16 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
}() }()
for rows.Next() { for rows.Next() {
spec := &datastore.Spec{} spec := &datastore.SpecHeader{}
data := JSONMap{}
var tenantID sql.NullString var tenantID sql.NullString
if err := rows.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &tenantID); err != nil { if err := rows.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &tenantID); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
if tenantID.Valid { if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String) spec.TenantID = datastore.TenantID(tenantID.String)
} }
spec.Data = data
specs = append(specs, spec) specs = append(specs, spec)
} }

View File

@ -84,11 +84,11 @@ var agentRepositoryTestCases = []agentRepositoryTestCase{
}, },
}, },
{ {
Name: "Try to get specs of an unexistant agent", Name: "Try to query specs of an unexistant agent",
Run: func(ctx context.Context, repo datastore.AgentRepository) error { Run: func(ctx context.Context, repo datastore.AgentRepository) error {
var unexistantAgentID datastore.AgentID = 9999 var unexistantAgentID datastore.AgentID = 9999
specs, err := repo.GetSpecs(ctx, unexistantAgentID) specs, err := repo.QuerySpecs(ctx, unexistantAgentID)
if err == nil { if err == nil {
return errors.New("error should not be nil") return errors.New("error should not be nil")
} }

View File

@ -4,20 +4,24 @@ import (
"net/http" "net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/go-chi/chi/v5"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
) )
func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) { func (m *Mount) getAgentSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r) agentID, ok := getAgentID(w, r)
if !ok { if !ok {
return return
} }
specName := chi.URLParam(r, "specName")
specVersion := chi.URLParam(r, "specVersion")
ctx := r.Context() ctx := r.Context()
specs, err := m.agentRepo.GetSpecs(ctx, agentID) spec, err := m.agentRepo.GetSpec(ctx, agentID, specName, specVersion)
if err != nil { if err != nil {
if errors.Is(err, datastore.ErrNotFound) { if errors.Is(err, datastore.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil)
@ -34,8 +38,8 @@ func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) {
} }
api.DataResponse(w, http.StatusOK, struct { api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"` Spec *datastore.Spec `json:"spec"`
}{ }{
Specs: specs, Spec: spec,
}) })
} }

View File

@ -35,7 +35,8 @@ func (m *Mount) Mount(r chi.Router) {
r.With(assertAgentOrUserWithWriteAccess).Put("/{agentID}", m.updateAgent) r.With(assertAgentOrUserWithWriteAccess).Put("/{agentID}", m.updateAgent)
r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent) r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent)
r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs", m.getAgentSpecs) r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs", m.queryAgentSpec)
r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs/{specName}/{specVersion}", m.getAgentSpec)
r.With(assertUserWithWriteAccess).Post("/{agentID}/specs", m.updateAgentSpec) r.With(assertUserWithWriteAccess).Post("/{agentID}/specs", m.updateAgentSpec)
r.With(assertUserWithWriteAccess).Delete("/{agentID}/specs", m.deleteAgentSpec) r.With(assertUserWithWriteAccess).Delete("/{agentID}/specs", m.deleteAgentSpec)
}) })

View File

@ -0,0 +1,55 @@
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) queryAgentSpec(w http.ResponseWriter, r *http.Request) {
agentID, ok := getAgentID(w, r)
if !ok {
return
}
ctx := r.Context()
specHeaders, err := m.agentRepo.QuerySpecs(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 query specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
return
}
// Retro-compatibility mode: return full specs temporarily
specs := make([]*datastore.Spec, 0, len(specHeaders))
for _, sh := range specHeaders {
spec, err := m.agentRepo.GetSpec(ctx, agentID, sh.DefinitionName, sh.DefinitionVersion)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not query specs", logger.CapturedE(err))
api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil)
}
specs = append(specs, spec)
}
api.DataResponse(w, http.StatusOK, struct {
Specs []*datastore.Spec `json:"specs"`
}{
Specs: specs,
})
}

View File

@ -0,0 +1,27 @@
package client
import (
"context"
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
)
func (c *Client) GetAgentSpec(ctx context.Context, agentID AgentID, specName string, specVersion string, funcs ...OptionFunc) (*datastore.Spec, error) {
response := withResponse[struct {
Spec *datastore.Spec `json:"spec"`
}]()
path := fmt.Sprintf("/api/v1/agents/%d/specs/%s/%s", agentID, specName, specVersion)
if err := c.apiGet(ctx, path, &response, funcs...); err != nil {
return nil, errors.WithStack(err)
}
if response.Error != nil {
return nil, errors.WithStack(response.Error)
}
return response.Data.Spec, nil
}

View File

@ -5,13 +5,12 @@ import (
"fmt" "fmt"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func (c *Client) GetAgentSpecs(ctx context.Context, agentID AgentID, funcs ...OptionFunc) ([]Spec, error) { func (c *Client) QueryAgentSpecs(ctx context.Context, agentID AgentID, funcs ...OptionFunc) ([]*datastore.SpecHeader, error) {
response := withResponse[struct { response := withResponse[struct {
Specs []*datastore.Spec `json:"specs"` Specs []*datastore.SpecHeader `json:"specs"`
}]() }]()
path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID) path := fmt.Sprintf("/api/v1/agents/%d/specs", agentID)
@ -24,10 +23,5 @@ func (c *Client) GetAgentSpecs(ctx context.Context, agentID AgentID, funcs ...Op
return nil, errors.WithStack(response.Error) return nil, errors.WithStack(response.Error)
} }
specs := make([]spec.Spec, 0, len(response.Data.Specs)) return response.Data.Specs, nil
for _, s := range response.Data.Specs {
specs = append(specs, spec.Spec(s))
}
return specs, nil
} }