diff --git a/internal/agent/controller/spec/controller.go b/internal/agent/controller/spec/controller.go index 2d64942..1553536 100644 --- a/internal/agent/controller/spec/controller.go +++ b/internal/agent/controller/spec/controller.go @@ -45,17 +45,29 @@ func (c *Controller) reconcileAgent(ctx context.Context, client *client.Client, return nil } - specs, err := client.GetAgentSpecs(ctx, agent.ID) + specHeaders, err := client.QueryAgentSpecs(ctx, agent.ID) if err != nil { 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 } 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) } diff --git a/internal/command/client/agent/spec/get.go b/internal/command/client/agent/spec/get.go index 7dc0aa0..03723a2 100644 --- a/internal/command/client/agent/spec/get.go +++ b/internal/command/client/agent/spec/get.go @@ -15,8 +15,18 @@ import ( func GetCommand() *cli.Command { return &cli.Command{ Name: "get", - Usage: "Get agent specifications", - Flags: agentFlag.WithAgentFlags(), + Usage: "Get agent specification", + 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 { baseFlags := clientFlag.GetBaseFlags(ctx) agentID, err := agentFlag.AssertAgentID(ctx) @@ -29,16 +39,26 @@ func GetCommand() *cli.Command { 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)) - specs, err := client.GetAgentSpecs(ctx.Context, agentID) + spec, err := client.GetAgentSpec(ctx.Context, agentID, specDefName, specDefVersion) if err != nil { return errors.WithStack(apierr.Wrap(err)) } 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) } diff --git a/internal/command/client/agent/spec/hints.go b/internal/command/client/agent/spec/hints.go index 3b47ef9..062d023 100644 --- a/internal/command/client/agent/spec/hints.go +++ b/internal/command/client/agent/spec/hints.go @@ -5,14 +5,28 @@ import ( "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 { return format.Hints{ OutputMode: outputMode, Props: []format.Prop{ format.NewProp("ID", "ID"), - format.NewProp("Revision", "Revision"), format.NewProp("DefinitionName", "Def. Name"), format.NewProp("DefinitionVersion", "Def. Version"), + format.NewProp("Revision", "Revision"), format.NewProp("Data", "Data"), format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)), format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)), diff --git a/internal/command/client/agent/spec/query.go b/internal/command/client/agent/spec/query.go new file mode 100644 index 0000000..a43c81a --- /dev/null +++ b/internal/command/client/agent/spec/query.go @@ -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 + }, + } +} diff --git a/internal/command/client/agent/spec/root.go b/internal/command/client/agent/spec/root.go index 1f0c386..d31c494 100644 --- a/internal/command/client/agent/spec/root.go +++ b/internal/command/client/agent/spec/root.go @@ -10,6 +10,7 @@ func Root() *cli.Command { Usage: "Specifications related commands", Subcommands: []*cli.Command{ GetCommand(), + QueryCommand(), UpdateCommand(), DeleteCommand(), }, diff --git a/internal/command/client/agent/spec/update.go b/internal/command/client/agent/spec/update.go index 026cc57..d27402b 100644 --- a/internal/command/client/agent/spec/update.go +++ b/internal/command/client/agent/spec/update.go @@ -12,6 +12,7 @@ import ( jsonpatch "github.com/evanphx/json-patch/v5" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/cli/format" ) @@ -73,21 +74,12 @@ func UpdateCommand() *cli.Command { 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 { - return errors.WithStack(apierr.Wrap(err)) - } - - var existingSpec spec.Spec - - for _, s := range specs { - if s.SpecDefinitionName() != specDefName || s.SpecDefinitionVersion() != specDefVersion { - continue + var apiErr api.Error + if !errors.As(err, &apiErr) || apiErr.Code != api.ErrCodeNotFound { + return errors.WithStack(apierr.Wrap(err)) } - - existingSpec = s - - break } revision := 0 diff --git a/internal/datastore/agent_repository.go b/internal/datastore/agent_repository.go index 59c943e..5cb6525 100644 --- a/internal/datastore/agent_repository.go +++ b/internal/datastore/agent_repository.go @@ -19,7 +19,8 @@ type AgentRepository interface { 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) - 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 } diff --git a/internal/datastore/spec.go b/internal/datastore/spec.go index c04c426..bd3468e 100644 --- a/internal/datastore/spec.go +++ b/internal/datastore/spec.go @@ -6,16 +6,21 @@ import ( 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 { - ID SpecID `json:"id"` - DefinitionName string `json:"name"` - DefinitionVersion string `json:"version"` - 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"` + SpecHeader + + Data map[string]any `json:"data"` } func (s *Spec) SpecDefinitionName() string { diff --git a/internal/datastore/sqlite/agent_repository.go b/internal/datastore/sqlite/agent_repository.go index 73e9407..ca0d243 100644 --- a/internal/datastore/sqlite/agent_repository.go +++ b/internal/datastore/sqlite/agent_repository.go @@ -154,9 +154,44 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen 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. -func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) { - specs := make([]*datastore.Spec, 0) +func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.SpecHeader, error) { + specs := make([]*datastore.SpecHeader, 0) err := r.withTxRetry(ctx, func(tx *sql.Tx) error { exists, err := r.agentExists(ctx, tx, agentID) @@ -169,7 +204,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI } 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 WHERE agent_id = $1 ` @@ -187,19 +222,16 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI }() for rows.Next() { - spec := &datastore.Spec{} - - data := JSONMap{} + spec := &datastore.SpecHeader{} 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) } if tenantID.Valid { spec.TenantID = datastore.TenantID(tenantID.String) } - spec.Data = data specs = append(specs, spec) } diff --git a/internal/datastore/testsuite/agent_repository_cases.go b/internal/datastore/testsuite/agent_repository_cases.go index 0364542..703345f 100644 --- a/internal/datastore/testsuite/agent_repository_cases.go +++ b/internal/datastore/testsuite/agent_repository_cases.go @@ -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 { var unexistantAgentID datastore.AgentID = 9999 - specs, err := repo.GetSpecs(ctx, unexistantAgentID) + specs, err := repo.QuerySpecs(ctx, unexistantAgentID) if err == nil { return errors.New("error should not be nil") } diff --git a/internal/server/api/get_agent_specs.go b/internal/server/api/get_agent_spec.go similarity index 67% rename from internal/server/api/get_agent_specs.go rename to internal/server/api/get_agent_spec.go index fce7a56..e7f3a51 100644 --- a/internal/server/api/get_agent_specs.go +++ b/internal/server/api/get_agent_spec.go @@ -4,20 +4,24 @@ import ( "net/http" "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" ) -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) if !ok { return } + specName := chi.URLParam(r, "specName") + specVersion := chi.URLParam(r, "specVersion") + ctx := r.Context() - specs, err := m.agentRepo.GetSpecs(ctx, agentID) + spec, err := m.agentRepo.GetSpec(ctx, agentID, specName, specVersion) if err != nil { if errors.Is(err, datastore.ErrNotFound) { 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 { - Specs []*datastore.Spec `json:"specs"` + Spec *datastore.Spec `json:"spec"` }{ - Specs: specs, + Spec: spec, }) } diff --git a/internal/server/api/mount.go b/internal/server/api/mount.go index 178fcf7..3610ab4 100644 --- a/internal/server/api/mount.go +++ b/internal/server/api/mount.go @@ -35,7 +35,8 @@ func (m *Mount) Mount(r chi.Router) { r.With(assertAgentOrUserWithWriteAccess).Put("/{agentID}", m.updateAgent) 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).Delete("/{agentID}/specs", m.deleteAgentSpec) }) diff --git a/internal/server/api/query_agent_specs.go b/internal/server/api/query_agent_specs.go new file mode 100644 index 0000000..d4282a2 --- /dev/null +++ b/internal/server/api/query_agent_specs.go @@ -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, + }) +} diff --git a/pkg/client/get_agent_spec.go b/pkg/client/get_agent_spec.go new file mode 100644 index 0000000..c97b038 --- /dev/null +++ b/pkg/client/get_agent_spec.go @@ -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 +} diff --git a/pkg/client/get_agent_specs.go b/pkg/client/query_agent_specs.go similarity index 53% rename from pkg/client/get_agent_specs.go rename to pkg/client/query_agent_specs.go index a855375..b29797c 100644 --- a/pkg/client/get_agent_specs.go +++ b/pkg/client/query_agent_specs.go @@ -5,13 +5,12 @@ import ( "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 AgentID, funcs ...OptionFunc) ([]Spec, error) { +func (c *Client) QueryAgentSpecs(ctx context.Context, agentID AgentID, funcs ...OptionFunc) ([]*datastore.SpecHeader, error) { response := withResponse[struct { - Specs []*datastore.Spec `json:"specs"` + Specs []*datastore.SpecHeader `json:"specs"` }]() 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) } - specs := make([]spec.Spec, 0, len(response.Data.Specs)) - for _, s := range response.Data.Specs { - specs = append(specs, spec.Spec(s)) - } - - return specs, nil + return response.Data.Specs, nil }