Compare commits

..

No commits in common. "develop" and "2024.1.12-stable.1306.79f5301" have entirely different histories.

175 changed files with 1649 additions and 5637 deletions

2
.gitignore vendored
View File

@ -10,8 +10,6 @@ dist/
/apps
/server-key.json
/.emissary-token
/.emissary-admin-token
/.emissary-tenant
/out
.mktools/
/CHANGELOG.md

View File

@ -15,13 +15,6 @@ OPENWRT_DEVICE ?= 192.168.1.1
watch: deps ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest )
clean:
rm -f .emissary-*
rm -f emissary.sqlite*
rm -f server-key.json
rm -f agent-key.json
rm -f state.json
.PHONY: test
test: test-go ## Executing tests
@ -129,25 +122,16 @@ gitea-release: .mktools tools/gitea-release/bin/gitea-release.sh goreleaser chan
GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
tools/gitea-release/bin/gitea-release.sh
.emissary-tenant: .emissary-admin-token
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client tenant create --token-file .emissary-admin-token --tenant-label Dev -f json | jq -r '.[0].id' > .emissary-tenant"
.emissary-admin-token:
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role admin --output .emissary-admin-token"
.emissary-token: .emissary-tenant
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token --tenant $(shell cat .emissary-tenant)"
.emissary-token:
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token"
AGENT_ID ?= 1
claim-agent: .emissary-token
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint)"
load-sample-specs: .emissary-token
cat misc/spec-samples/app.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com"
cat misc/spec-samples/proxy.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com"
cat misc/spec-samples/mdns.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com"
cat misc/spec-samples/uci.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com"
load-sample-specs:
cat misc/spec-samples/app.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com
cat misc/spec-samples/proxy.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com
cat misc/spec-samples/mdns.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com
cat misc/spec-samples/uci.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
version: .mktools
@echo $(MKT_PROJECT_VERSION)

View File

@ -6,40 +6,6 @@ Control plane for "edge" (and OpenWRT-based) devices.
> ⚠ Emissary is currently in a very alpha stage ! Expect breaking changes...
## Quickstart
**Dependencies**
- [Go >= 1.21](https://go.dev/)
- `GNU Make`
```shell
# Start server and a local agent
make watch
# In an other terminal
# Create an admin token
make .emissary-admin-token
# Create a new tenant
make .emissary-tenant
# Create a new writer token for this tenant
make .emissary-token
# Claim the agent for your newly created tenant
make claim-agent
# Query your agents
./bin/server client agent query
# Load sample specs for your agent
make load-sample-specs
## Optional: reset your workspace
make clean
```
## Install
### Manually

View File

@ -5,7 +5,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/command"
"forge.cadoles.com/Cadoles/emissary/internal/command/agent"
"forge.cadoles.com/Cadoles/emissary/internal/command/client"
"forge.cadoles.com/Cadoles/emissary/internal/command/api"
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/format"
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/spec"
@ -20,5 +20,5 @@ var (
)
func main() {
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), client.Root())
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), api.Root())
}

View File

@ -4,7 +4,7 @@ import (
"time"
"forge.cadoles.com/Cadoles/emissary/internal/command"
"forge.cadoles.com/Cadoles/emissary/internal/command/client"
"forge.cadoles.com/Cadoles/emissary/internal/command/api"
"forge.cadoles.com/Cadoles/emissary/internal/command/server"
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/format"
@ -21,5 +21,5 @@ var (
)
func main() {
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), client.Root())
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), api.Root())
}

View File

@ -1,7 +1,6 @@
# Documentation
- (FR) - [Vue d'ensemble](./others/fr/overview.md)
- (FR) - [Authentification et autorisation](./others/fr/auth.md)
- (FR) - [Introduction](./fr/introduction.md)
## Tutorials
@ -10,13 +9,15 @@
- (FR) - [Déployer une configuration UCI personnalisée sur un agent](./tutorials/fr/deploy-uci-configuration.md)
- (FR) - [Démarrer un agent avec Docker](./tutorials/fr/docker-agent.md)
## References
### Specifications
- [Schema `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json)
- [Schema `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json)
- [Schema `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json)
- [Schema `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json)
- [Schema `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json)
- [Schéma `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json)
- [Schéma `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json)
- [Schéma `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json)
- [Schéma `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json)
- [Schéma `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json)
### Configuration

View File

@ -1,4 +1,4 @@
# Vue d'ensemble
# Introduction
"Emissary" est un programme entrant dans la catégorie des outils de gestion et déploiement de configuration.

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,27 +0,0 @@
# Authentification et autorisation
## Authentification
Emissary utilise des [**JSON Web Token**](https://fr.wikipedia.org/wiki/JSON_Web_Token) (JWT) afin d'authentifier les appels à son API REST.
L'implémentation est compatible avec tout serveur d'authentification exposant une URL proposant un [**JSON Web Key Set**](https://www.ory.sh/docs/hydra/jwks#the-role-of-well-knownjwksjson).
La plupart des serveurs OpenID Connect exposent un point d'entrée du type [`/.well-known/jwks.json`](https://www.ory.sh/docs/hydra/jwks#the-role-of-well-knownjwksjson) remplissant ce rôle.
Emissary est également en capacité à fonctionner en mode autonome en générant des JWTs signés par une clé privée locale.
## Ségrégation des ressources
Emissary suit une stratégie ["multitenant"](https://fr.wikipedia.org/wiki/Multitenant) de séparer les ressources par organisation.
Un utilisateur est obligatoirement associé à un `tenant`` et ne peut opérer que sur les ressources associées à celui ci.
## Autorisation
Au sein d'un `tenant`, un utilisateur peut avoir un des rôles suivants:
- `writer` - Autorisé à visualiser et modifier les ressources;
- `reader` - Autorisé à visualiser les ressources.
Un rôle spécial `admin` permet la création et la suppression de `tenants`.

View File

@ -80,13 +80,13 @@ Via la spécification [`uci.emissary.cadoles.com`](../../../internal/spec/uci/sc
AGENT_THUMBPRINT="<empreinte agent>"
# Récupérer l'identifiant de l'agent
AGENT_ID=$(emissary client agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id')
AGENT_ID=$(emissary api agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id')
```
2. Assigner la spécification à l'agent UCI:
```bash
cat my-uci-spec.json | emissary client agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
```
**Bravo, vous avez déployé des spécifications UCI sur votre agent !**
@ -112,7 +112,7 @@ En intervenant directement sur notre spécification, il est possible de modifier
2. Mettre à jour la configuration de l'agent:
```bash
cat my-uci-spec.json | emissary client agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
```
3. Sur l'agent, après quelques secondes (par défaut, la fréquence de mise à jour est de 1 fois par minute) l'agent devrait avoir son `hostname` mis à jour:

View File

@ -80,31 +80,15 @@
5. Créer un jeton d'administration:
```shell
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role admin -o "$HOME/.config/emissary/admin-token"
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --subject $(whoami)
```
> **Note** Le jeton sera stocké dans le répertoire `$HOME/.config/emissary`.
6. Créer un nouveau `tenant`:
6. Vérifier l'authentification sur l'API:
```shell
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml client tenant create --tenant-label "My Tenant" -o wide --token-file "$HOME/.config/emissary/admin-token"
```
Noter la valeur de l'UUID (de la forme `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) affiché dans la colonne `ID`. Il sera identifié comme `$EMISSARY_TENANT` dans les étapes suivantes.
7. Créer un jeton d'authentification pour ce nouveau tenant:
```shell
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --tenant $EMISSARY_TENANT
```
> **Note** Le jeton sera stocké dans le fichier `$HOME/.config/emissary/auth-token`. Il sera le jeton utilisé par défaut par le CLI Emissary.
8. Vérifier l'authentification sur l'API:
```shell
emissary client agent query
emissary api agent query
```
Une réponse équivalente à la suivante devrait s'afficher:
@ -144,18 +128,10 @@
Thu May 25 18:48:51 2023 daemon.info emissary[2202]: 2023-05-25 18:48:51.680 [INFO] <./internal/agent/controller/openwrt/sysupgrade_controller.go:36> (*SysUpgradeController).Reconcile could not find sysupgrade spec, doing nothing {"controller": "sysupgrade-controller"}
```
2. Récupérer le `thumbprint` de votre agent:
```
emissary agent show-thumbprint
```
Noter la valeur de la chaîne de caractères affichée. Elle sera identifiée comme `$AGENT_THUMBPRINT` dans les étapes suivantes.
3. Sur le serveur, "réclamer" votre agent:
3. Sur le serveur, vérifier que l'agent a pu s'enregistrer:
```shell
emissary client agent claim --agent-thumbprint $AGENT_THUMBPRINT
emissary api agent query
```
Un message de ce type devrait s'afficher:
@ -168,12 +144,12 @@
+----+-------+-----------------------------------+--------+-----------------------------------+-----------------------------------+
```
Noter la valeur de l'identifiant affiché dans la colonne `ID`. Il sera identifié comme `$AGENT_ID` dans les étapes suivantes.
Noter l'identifiant associé à l'agent.
4. Mettre à jour le statut de l'agent afin qu'il soit en capacité à récupérer ses spécifications:
```
emissary client agent update --agent-id $AGENT_ID --status 1
emissary api agent update --agent-id <agent_id> --status 1
```
**Bravo, vous avez appairé votre premier agent et son serveur Emissary !**

6
go.mod
View File

@ -28,7 +28,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/qri-io/jsonschema v0.2.1
github.com/urfave/cli/v2 v2.26.0
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.21.0
)
@ -78,7 +78,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@ -123,4 +123,4 @@ require (
)
// replace forge.cadoles.com/arcad/edge => ../edge
// replace gitlab.com/wpetit/goweb => ../goweb
replace github.com/allegro/bigcache/v3 v3.1.0 => github.com/Bornholm/bigcache v0.0.0-20231201111725-1ddf51584cad

4
go.sum
View File

@ -702,8 +702,6 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@ -1319,8 +1317,6 @@ github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxt
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY=
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88 h1:dsyRrmhp7fl/YaY1YIzz7lm9qfIFI5KpKNbXwuhTULA=
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88/go.mod h1:bg+TN16Rq2ygLQbB4VDSHQFNouAEzcy3AAutStehllA=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=

View File

@ -38,7 +38,6 @@ func (a *Agent) Run(ctx context.Context) error {
client := client.New(a.serverURL, client.WithToken(token))
ctx = withClient(ctx, client)
ctx = withThumbprint(ctx, a.thumbprint)
tick := func() {
logger.Debug(ctx, "registering agent")

View File

@ -11,7 +11,6 @@ type contextKey string
const (
contextKeyClient contextKey = "client"
contextKeyThumbprint contextKey = "thumbprint"
)
func withClient(ctx context.Context, client *client.Client) context.Context {
@ -26,16 +25,3 @@ func Client(ctx context.Context) *client.Client {
return client
}
func withThumbprint(ctx context.Context, thumbprint string) context.Context {
return context.WithValue(ctx, contextKeyThumbprint, thumbprint)
}
func Thumbprint(ctx context.Context) string {
thumbprint, ok := ctx.Value(contextKeyThumbprint).(string)
if !ok {
panic(errors.New("could not retrieve thumbprint from context"))
}
return thumbprint
}

View File

@ -38,7 +38,7 @@ func (c *Controller) Name() string {
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
appSpec := spec.NewSpec()
if err := state.GetSpec(spec.Name, spec.Version, appSpec); err != nil {
if err := state.GetSpec(spec.Name, appSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find app spec")
@ -50,12 +50,7 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err)
}
logger.Info(
ctx, "retrieved spec",
logger.F("name", appSpec.SpecDefinitionName()),
logger.F("version", appSpec.SpecDefinitionVersion()),
logger.F("revision", appSpec.SpecRevision()),
)
logger.Info(ctx, "retrieved spec", logger.F("spec", appSpec.SpecName()), logger.F("revision", appSpec.SpecRevision()))
c.updateApps(ctx, appSpec)

View File

@ -11,7 +11,7 @@ import (
var schema []byte
func init() {
if err := spec.Register(string(Name), Version, schema); err != nil {
if err := spec.Register(Name, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -1,5 +1,5 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://app.edge.emissary.cadoles.com/spec.json",
"title": "AppSpec",
"description": "Emissary 'App' specification",
@ -78,9 +78,7 @@
"type": "string"
}
},
"required": [
"defaultUrlTemplate"
],
"required": ["defaultUrlTemplate"],
"additionalProperties": false
},
"unexpectedHostRedirect": {
@ -96,10 +94,7 @@
"type": "string"
}
},
"required": [
"acceptedHostPatterns",
"hostTarget"
],
"required": ["acceptedHostPatterns", "hostTarget"],
"additionalProperties": false
},
"auth": {
@ -109,10 +104,7 @@
"type": "object",
"properties": {
"key": {
"type": [
"object",
"string"
]
"type": ["object", "string"]
},
"signingAlgorithm": {
"type": "string"

View File

@ -6,10 +6,7 @@ import (
"github.com/lestrrat-go/jwx/v2/jwa"
)
const (
Name string = "app.emissary.cadoles.com"
Version string = "0.0.0"
)
const Name spec.Name = "app.emissary.cadoles.com"
type Spec struct {
Revision int `json:"revision"`
@ -59,14 +56,10 @@ type AppURLResolving struct {
DefaultURLTemplate string `json:"defaultUrlTemplate"`
}
func (s *Spec) SpecDefinitionName() string {
func (s *Spec) SpecName() spec.Name {
return Name
}
func (s *Spec) SpecDefinitionVersion() string {
return Version
}
func (s *Spec) SpecRevision() int {
return s.Revision
}

View File

@ -6,7 +6,6 @@ import (
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
@ -28,15 +27,11 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
validator := spec.NewValidator()
if err := validator.Register(Name, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases {
func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {

View File

@ -33,7 +33,7 @@ func (c *Controller) Name() string {
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
mdnsSpec := mdns.NewSpec()
if err := state.GetSpec(mdns.Name, mdns.Version, mdnsSpec); err != nil {
if err := state.GetSpec(mdns.Name, mdnsSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find mdns spec")
@ -45,11 +45,7 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err)
}
logger.Info(ctx, "retrieved spec",
logger.F("name", mdnsSpec.SpecDefinitionName()),
logger.F("version", mdnsSpec.SpecDefinitionVersion()),
logger.F("revision", mdnsSpec.SpecRevision()),
)
logger.Info(ctx, "retrieved spec", logger.F("spec", mdnsSpec.SpecName()), logger.F("revision", mdnsSpec.SpecRevision()))
if err := c.updateResponder(ctx, mdnsSpec); err != nil {
return errors.Wrap(err, "could not update responder")

View File

@ -11,7 +11,7 @@ import (
var schema []byte
func init() {
if err := spec.Register(string(Name), Version, schema); err != nil {
if err := spec.Register(Name, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -1,5 +1,5 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mdns.edge.emissary.cadoles.com/spec.json",
"title": "MDNSSpec",
"description": "Emissary 'MDNS' specification",

View File

@ -4,10 +4,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/spec"
)
const (
Name string = "mdns.emissary.cadoles.com"
Version string = "0.0.0"
)
const Name spec.Name = "mdns.emissary.cadoles.com"
type Spec struct {
Revision int `json:"revision"`
@ -22,14 +19,10 @@ type Service struct {
Port int `json:"port"`
}
func (s *Spec) SpecDefinitionName() string {
func (s *Spec) SpecName() spec.Name {
return Name
}
func (s *Spec) SpecDefinitionVersion() string {
return Version
}
func (s *Spec) SpecRevision() int {
return s.Revision
}

View File

@ -6,7 +6,6 @@ import (
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
@ -28,15 +27,11 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
validator := spec.NewValidator()
if err := validator.Register(Name, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases {
func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {

View File

@ -11,7 +11,7 @@ import (
var schema []byte
func init() {
if err := spec.Register(string(Name), Version, schema); err != nil {
if err := spec.Register(Name, schema); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -1,5 +1,5 @@
{
"$schema": "https://json-schema.org/draft/2019-09/schema",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://sysupgrade.openwrt.emissary.cadoles.com/spec.json",
"title": "SysUpgradeSpec",
"description": "Emissary 'SysUpgrade' specification",
@ -15,10 +15,6 @@
"type": "string"
}
},
"required": [
"url",
"sha256sum",
"version"
],
"required": ["url", "sha256sum", "version"],
"additionalProperties": false
}

View File

@ -4,10 +4,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/spec"
)
const (
Name string = "sysupgrade.openwrt.emissary.cadoles.com"
Version string = "0.0.0"
)
const Name spec.Name = "sysupgrade.openwrt.emissary.cadoles.com"
type Spec struct {
Revision int `json:"revision"`
@ -16,14 +13,10 @@ type Spec struct {
Version string `json:"version"`
}
func (s *Spec) SpecDefinitionName() string {
func (s *Spec) SpecName() spec.Name {
return Name
}
func (s *Spec) SpecDefinitionVersion() string {
return Version
}
func (s *Spec) SpecRevision() int {
return s.Revision
}

View File

@ -6,7 +6,6 @@ import (
"io/ioutil"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
)
@ -28,15 +27,11 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
validator := spec.NewValidator()
if err := validator.Register(Name, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err))
}
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases {
func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) {

View File

@ -31,7 +31,7 @@ func (*SysUpgradeController) Name() string {
func (c *SysUpgradeController) Reconcile(ctx context.Context, state *agent.State) error {
sysSpec := sysupgrade.NewSpec()
if err := state.GetSpec(sysupgrade.Name, sysupgrade.Version, sysSpec); err != nil {
if err := state.GetSpec(sysupgrade.Name, sysSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find sysupgrade spec, doing nothing")

View File

@ -27,7 +27,7 @@ func (*UCIController) Name() string {
func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error {
uciSpec := ucispec.NewSpec()
if err := state.GetSpec(ucispec.Name, ucispec.Version, uciSpec); err != nil {
if err := state.GetSpec(ucispec.NameUCI, uciSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find uci spec, doing nothing")
@ -37,11 +37,7 @@ func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error
return errors.WithStack(err)
}
logger.Info(ctx, "retrieved spec",
logger.F("name", uciSpec.SpecDefinitionName()),
logger.F("version", uciSpec.SpecDefinitionVersion()),
logger.F("revision", uciSpec.SpecRevision()),
)
logger.Info(ctx, "retrieved spec", logger.F("spec", uciSpec.SpecName()), logger.F("revision", uciSpec.SpecRevision()))
if c.currentSpecRevision == uciSpec.SpecRevision() {
logger.Info(ctx, "spec revision did not change, doing nothing")

View File

@ -9,12 +9,13 @@ import (
"path/filepath"
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Controller struct {
trackedSpecRevisions map[string]map[string]int
trackedSpecRevisions map[spec.Name]int
filename string
loaded bool
}
@ -77,14 +78,8 @@ func (c *Controller) specChanged(specs agent.Specs) bool {
return true
}
for name, specVersions := range specs {
trackedSpecs, exists := c.trackedSpecRevisions[name]
if !exists {
return true
}
for version, spec := range specVersions {
trackedRevision, exists := trackedSpecs[version]
for name, spec := range specs {
trackedRevision, exists := c.trackedSpecRevisions[name]
if !exists {
return true
}
@ -94,22 +89,25 @@ func (c *Controller) specChanged(specs agent.Specs) bool {
}
}
for trackedSpecName, trackedRevision := range c.trackedSpecRevisions {
spec, exists := specs[trackedSpecName]
if !exists {
return true
}
if trackedRevision != spec.SpecRevision() {
return true
}
}
return false
}
func (c *Controller) trackSpecsRevisions(specs agent.Specs) {
c.trackedSpecRevisions = make(map[string]map[string]int)
c.trackedSpecRevisions = make(map[spec.Name]int)
for name, specVersions := range specs {
if _, exists := c.trackedSpecRevisions[name]; !exists {
c.trackedSpecRevisions[name] = make(map[string]int)
}
for version, spec := range specVersions {
c.trackedSpecRevisions[name][version] = spec.SpecRevision()
}
for name, spec := range specs {
c.trackedSpecRevisions[name] = spec.SpecRevision()
}
}
@ -169,7 +167,7 @@ func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
}
name := f.Name()
if err := os.WriteFile(name, data, os.ModePerm); err != nil {
if err := ioutil.WriteFile(name, data, os.ModePerm); err != nil {
return errors.Errorf("cannot write data to temporary file %q: %v", name, err)
}
@ -215,7 +213,7 @@ func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
func NewController(filename string) *Controller {
return &Controller{
filename: filename,
trackedSpecRevisions: make(map[string]map[string]int),
trackedSpecRevisions: make(map[spec.Name]int),
}
}

View File

@ -30,7 +30,7 @@ func (c *Controller) Name() string {
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
proxySpec := spec.NewSpec()
if err := state.GetSpec(spec.Name, spec.Version, proxySpec); err != nil {
if err := state.GetSpec(spec.NameProxy, proxySpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find proxy spec")
@ -42,12 +42,7 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err)
}
logger.Info(
ctx, "retrieved spec",
logger.F("name", proxySpec.SpecDefinitionName()),
logger.F("version", proxySpec.SpecDefinitionVersion()),
logger.F("revision", proxySpec.SpecRevision()),
)
logger.Info(ctx, "retrieved spec", logger.F("spec", proxySpec.SpecName()), logger.F("revision", proxySpec.SpecRevision()))
c.updateProxies(ctx, proxySpec)

View File

@ -45,29 +45,17 @@ func (c *Controller) reconcileAgent(ctx context.Context, client *client.Client,
return nil
}
specHeaders, err := client.QueryAgentSpecs(ctx, agent.ID)
specs, err := client.GetAgentSpecs(ctx, agent.ID)
if err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not query agent specs", logger.CapturedE(err))
logger.Error(ctx, "could not retrieve agent specs", logger.CapturedE(err))
return nil
}
state.ClearSpecs()
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
}
for _, spec := range specs {
state.SetSpec(spec)
}

View File

@ -1,138 +0,0 @@
package status
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type Status struct {
Agent *datastore.Agent
Connected bool
Claimed bool
Thumbprint string
ServerURL string
ClaimURL string
AgentURL string
AgentVersion string
}
type Controller struct {
status *atomic.Value
server *atomic.Value
addr string
claimURL string
agentURL string
agentVersion string
}
// Name implements node.Controller.
func (c *Controller) Name() string {
return "status-controller"
}
// Reconcile implements node.Controller.
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
cl := agent.Client(ctx)
thumbprint := agent.Thumbprint(ctx)
connected := true
agent, err := cl.GetAgent(ctx, state.AgentID())
if err != nil {
logger.Error(ctx, "could not get agent", logger.E(errors.WithStack(err)))
var apiErr *api.Error
if errors.As(err, &apiErr) {
switch apiErr.Code {
case api.ErrCodeForbidden:
// Contact is ok but agent may be not claimed yet
default:
connected = false
}
} else {
connected = false
}
}
claimed := agent != nil && agent.TenantID != nil
var agentID datastore.AgentID
if agent != nil {
agentID = agent.ID
}
c.status.Store(Status{
Agent: agent,
Connected: connected,
Claimed: claimed,
Thumbprint: thumbprint,
ServerURL: cl.ServerURL(),
ClaimURL: fmt.Sprintf(c.claimURL, thumbprint),
AgentURL: fmt.Sprintf(c.agentURL, agentID),
AgentVersion: c.agentVersion,
})
if err := c.startServer(ctx); err != nil {
return errors.WithStack(err)
}
return nil
}
func (c *Controller) startServer(ctx context.Context) error {
server := c.getServer()
if server != nil {
return nil
}
server = &http.Server{
Addr: c.addr,
Handler: &Handler{
status: c.status,
},
}
go func() {
defer c.setServer(nil)
if err := server.ListenAndServe(); err != nil {
logger.Error(ctx, "could not start server", logger.E(errors.WithStack(err)))
}
}()
c.setServer(server)
return nil
}
func (c *Controller) setServer(s *http.Server) {
c.server.Store(s)
}
func (c *Controller) getServer() *http.Server {
server, ok := c.server.Load().(*http.Server)
if !ok {
return nil
}
return server
}
func NewController(addr string, claimURL string, agentURL string, agentVersion string) *Controller {
return &Controller{
addr: addr,
claimURL: claimURL,
agentURL: agentURL,
agentVersion: agentVersion,
status: &atomic.Value{},
server: &atomic.Value{},
}
}
var _ agent.Controller = &Controller{}

View File

@ -1,74 +0,0 @@
package status
import (
"embed"
"html/template"
"io/fs"
"net/http"
"sync"
"sync/atomic"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
//go:embed templates/*.gotpl
var templates embed.FS
//go:embed public/*
var public embed.FS
type Handler struct {
status *atomic.Value
public http.Handler
templates *template.Template
init sync.Once
initErr error
}
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.init.Do(func() {
root, err := fs.Sub(public, "public")
if err != nil {
h.initErr = errors.WithStack(err)
return
}
h.public = http.FileServer(http.FS(root))
tmpl, err := template.ParseFS(templates, "templates/*.gotpl")
if err != nil {
h.initErr = errors.WithStack(err)
return
}
h.templates = tmpl
})
if h.initErr != nil {
logger.Error(r.Context(), "could not initialize handler", logger.E(h.initErr))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
switch r.URL.Path {
case "/":
h.serveIndex(w, r)
default:
h.public.ServeHTTP(w, r)
}
}
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
data := h.status.Load()
if err := h.templates.ExecuteTemplate(w, "index.html.gotpl", data); err != nil {
logger.Error(r.Context(), "could not render template", logger.E(errors.WithStack(err)))
return
}
}
var _ http.Handler = &Handler{}

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,145 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="logo.png">
<title>Status | Emissary Agent</title>
<link rel="stylesheet" href="bulma-0.9.4.min.css">
<style>
body {
background-color: #f7f7f7f7;
}
.logo {
left: 50%;
position: absolute;
margin-left: -40px;
width: 100px;
margin-top: -120px
}
.card {
position:relative;
padding-top: 70px;
margin-top: 70px;
}
#qrcode {
display: flex;
flex-direction: row;
justify-content: center;
}
</style>
{{if or .Connected ( not .Claimed ) }}
<script type="text/javascript" src="qrcode.min.js"></script>
{{ end }}
</head>
<body>
<section class="section">
<div class="container">
<div class="column">
<div class="has-text-centered">
<h1 class="title is-size-1 ">Emissary</h1>
<h2 class="subtitle is-size-4">Agent Status</h2>
</div>
<div class="box card">
<img class="logo" src="logo.png" />
<div class="overflow:hidden">
<div class="level is-mobile" style="margin-top:-50px">
<div class="level-left">
<div class="level-item is-size-4-tablet is-size-7-mobile">
<strong class="mr-2">Connected:</strong>{{if .Connected }}<span class="has-text-success">✔</span>{{ else }}<span class="has-text-danger">✕</span>{{ end }}
</div>
</div>
<div class="level-right">
<div class="level-item is-size-4-tablet is-size-7-mobile">
<strong class="mr-2">Claimed:</strong>{{if .Claimed }}<span class="has-text-success">✔</span>{{ else }}<span class="has-text-warning">✕</span>{{ end }}
</div>
</div>
</div>
{{ if and .Connected ( not .Claimed ) }}
<h3 class="is-size-3 mt-4">Claim your agent</h3>
<p class="has-text-centered">
You can claim your agent by clicking the following link:<br />
<a class="button is-link is-medium mt-3" href="{{ .ClaimURL }}" target="_blank" rel="nofollow">Claim me</a><br />
</p>
<p class="has-text-centered mt-3">
You can also scan the following QRCode:
<div id="qrcode" class="mt-3" data-claim-url="{{ .ClaimURL }}"></div>
<script type="text/javascript">
(function() {
const qrCodeElement = document.getElementById("qrcode");
const claimUrl = qrCodeElement.dataset.claimUrl;
new QRCode(qrCodeElement, claimUrl);
}())
</script>
</p>
{{ end }}
{{ if and .Connected .Claimed }}
<h3 class="is-size-3 mt-4">Manage your agent</h3>
<p class="has-text-centered">
You can manage your agent by clicking the following link:<br />
<a class="button is-link is-medium mt-3" href="{{ .AgentURL }}" target="_blank" rel="nofollow">Manage me</a><br />
</p>
<p class="has-text-centered mt-3">
You can also scan the following QRCode:
<div id="qrcode" class="mt-3" data-agent-url="{{ .AgentURL }}"></div>
<script type="text/javascript">
(function() {
const qrCodeElement = document.getElementById("qrcode");
const agentUrl = qrCodeElement.dataset.agentUrl;
new QRCode(qrCodeElement, agentUrl);
}())
</script>
</p>
{{ end }}
<h3 class="is-size-3 mt-4">Informations</h3>
<div class="table-container">
<table class="table is-fullwidth">
<thead>
<tr>
<th>Attribute</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Thumbprint</td>
<td><code>{{ .Thumbprint }}</code></td>
</tr>
<tr>
<td>Agent ID</td>
<td><code>{{ if .Agent }}{{ .Agent.ID }}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Agent Label</td>
<td><code>{{ with .Agent }}{{ if .Label }}{{ .Label }}{{ else }}empty{{end}}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Last server contact</td>
<td><code>{{ if .Agent }}{{ .Agent.ContactedAt }}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Server URL</td>
<td><code>{{ .ServerURL }}</code></td>
</tr>
<tr>
<td>Claim URL</td>
<td><code>{{ .ClaimURL }}</code></td>
</tr>
<tr>
<td>Agent URL</td>
<td><code>{{ if .Agent }}{{ .AgentURL }}{{ else }}unknown{{end}}</code></td>
</tr>
<tr>
<td>Agent version</td>
<td><code>{{ .AgentVersion }}</code></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
</body>
</html>

View File

@ -11,7 +11,7 @@ import (
var ErrSpecNotFound = errors.New("spec not found")
type Specs map[string]map[string]spec.Spec
type Specs map[spec.Name]spec.Spec
type State struct {
agentID datastore.AgentID
@ -20,35 +20,27 @@ type State struct {
func NewState() *State {
return &State{
specs: make(map[string]map[string]spec.Spec),
specs: make(map[spec.Name]spec.Spec),
}
}
func (s *State) MarshalJSON() ([]byte, error) {
state := struct {
ID datastore.AgentID `json:"agentId"`
Specs map[string]map[string]*spec.RawSpec `json:"specs"`
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
}{
ID: s.agentID,
Specs: func(specs map[string]map[string]spec.Spec) map[string]map[string]*spec.RawSpec {
rawSpecs := make(map[string]map[string]*spec.RawSpec)
Specs: func(specs map[spec.Name]spec.Spec) map[spec.Name]*spec.RawSpec {
rawSpecs := make(map[spec.Name]*spec.RawSpec)
for name, versions := range specs {
if _, exists := rawSpecs[name]; !exists {
rawSpecs[name] = make(map[string]*spec.RawSpec)
}
for version, sp := range versions {
rawSpecs[name][version] = &spec.RawSpec{
DefinitionName: sp.SpecDefinitionName(),
DefinitionVersion: sp.SpecDefinitionVersion(),
for name, sp := range specs {
rawSpecs[name] = &spec.RawSpec{
Name: sp.SpecName(),
Revision: sp.SpecRevision(),
Data: sp.SpecData(),
}
}
}
return rawSpecs
}(s.specs),
}
@ -64,23 +56,18 @@ func (s *State) MarshalJSON() ([]byte, error) {
func (s *State) UnmarshalJSON(data []byte) error {
state := struct {
AgentID datastore.AgentID `json:"agentId"`
Specs map[string]map[string]*spec.RawSpec `json:"specs"`
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
}{}
if err := json.Unmarshal(data, &state); err != nil {
return errors.WithStack(err)
}
s.specs = func(rawSpecs map[string]map[string]*spec.RawSpec) Specs {
s.specs = func(rawSpecs map[spec.Name]*spec.RawSpec) Specs {
specs := make(Specs)
for name, versions := range rawSpecs {
if _, exists := specs[name]; !exists {
specs[name] = make(map[string]spec.Spec)
}
for version, raw := range versions {
specs[name][version] = spec.Spec(raw)
}
for name, raw := range rawSpecs {
specs[name] = spec.Spec(raw)
}
return specs
@ -98,36 +85,23 @@ func (s *State) Specs() Specs {
}
func (s *State) ClearSpecs() *State {
s.specs = make(map[string]map[string]spec.Spec)
s.specs = make(map[spec.Name]spec.Spec)
return s
}
func (s *State) SetSpec(sp spec.Spec) *State {
if s.specs == nil {
s.specs = make(map[string]map[string]spec.Spec)
s.specs = make(map[spec.Name]spec.Spec)
}
name := sp.SpecDefinitionName()
if _, exists := s.specs[name]; !exists {
s.specs[name] = make(map[string]spec.Spec)
}
version := sp.SpecDefinitionVersion()
s.specs[name][version] = sp
s.specs[sp.SpecName()] = sp
return s
}
func (s *State) GetSpec(name string, version string, dest any) error {
versions, exists := s.specs[name]
if !exists {
return errors.WithStack(ErrSpecNotFound)
}
spec, exists := versions[version]
func (s *State) GetSpec(name spec.Name, dest any) error {
spec, exists := s.specs[name]
if !exists {
return errors.WithStack(ErrSpecNotFound)
}

View File

@ -11,7 +11,6 @@ import (
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const DefaultAcceptableSkew = 5 * time.Minute
@ -35,19 +34,17 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
token, err := jwt.Parse([]byte(rawToken), jwt.WithVerify(false))
if err != nil {
logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.WithStack(err)
}
rawThumbprint, exists := token.Get(keyThumbprint)
if !exists {
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.Errorf("could not find '%s' claim", keyThumbprint)
}
thumbrint, ok := rawThumbprint.(string)
if !ok {
logger.Debug(ctx, "unexpected claim value", logger.F("claim", rawThumbprint), logger.F("value", rawThumbprint))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.Errorf("unexpected '%s' claim value: '%v'", keyThumbprint, rawThumbprint)
}
agents, _, err := a.repo.Query(
@ -60,8 +57,7 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
}
if len(agents) != 1 {
logger.Debug(ctx, "unexpected number of found agents", logger.F("total", len(agents)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.Errorf("unexpected number of found agents: '%d'", len(agents))
}
agent, err := a.repo.Get(
@ -79,15 +75,14 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
jwt.WithAcceptableSkew(a.acceptableSkew),
)
if err != nil {
logger.Error(ctx, "could not parse jwt", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.WithStack(err)
}
contactedAt := time.Now().UTC()
contactedAt := time.Now()
agent, err = a.repo.Update(ctx, agent.ID, datastore.WithAgentUpdateContactedAt(contactedAt))
if err != nil {
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.WithStack(err)
}
user := &User{

View File

@ -1,7 +1,6 @@
package agent
import (
"encoding/json"
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
@ -17,31 +16,8 @@ func (u *User) Subject() string {
return fmt.Sprintf("agent-%d", u.agent.ID)
}
// Subject implements auth.User
func (u *User) Tenant() datastore.TenantID {
if u.agent.TenantID == nil {
return ""
}
return *u.agent.TenantID
}
func (u *User) Agent() *datastore.Agent {
return u.agent
}
func (u *User) MarshalJSON() ([]byte, error) {
type user struct {
Subject string `json:"subject"`
Tenant string `json:"tenant"`
}
jsonUser := user{
Subject: u.Subject(),
Tenant: string(u.Tenant()),
}
return json.Marshal(jsonUser)
}
var _ auth.User = &User{}

View File

@ -1,8 +0,0 @@
package auth
import "github.com/pkg/errors"
var (
ErrUnauthenticated = errors.New("unauthenticated")
ErrUnauthorized = errors.New(("unauthorized"))
)

View File

@ -4,7 +4,6 @@ import (
"context"
"net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
@ -30,9 +29,10 @@ func CtxUser(ctx context.Context) (User, error) {
return user, nil
}
var ErrUnauthenticated = errors.New("unauthenticated")
type User interface {
Subject() string
Tenant() datastore.TenantID
}
type Authenticator interface {
@ -49,12 +49,11 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
err error
)
var errs []error
for _, auth := range authenticators {
user, err = auth.Authenticate(ctx, r)
if err != nil {
errs = append(errs, errors.WithStack(err))
logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err)))
continue
}
@ -64,23 +63,10 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
}
if user == nil {
hasUnauthorized, hasUnauthenticated, hasUnknown := checkErrors(errs)
switch {
case hasUnauthorized && !hasUnknown:
api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil)
return
case hasUnauthenticated && !hasUnknown:
api.ErrorResponse(w, http.StatusUnauthorized, api.ErrCodeUnauthorized, nil)
return
case hasUnknown:
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
default:
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil)
return
}
}
ctx = logger.With(ctx, logger.F("user", user.Subject()))
ctx = context.WithValue(ctx, contextKeyUser, user)
@ -91,22 +77,3 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
return http.HandlerFunc(fn)
}
}
func checkErrors(errs []error) (isUnauthorized bool, isUnauthenticated bool, isUnknown bool) {
isUnauthenticated = false
isUnauthorized = false
isUnknown = false
for _, e := range errs {
switch {
case errors.Is(e, ErrUnauthorized):
isUnauthorized = true
case errors.Is(e, ErrUnauthenticated):
isUnauthenticated = true
default:
isUnknown = true
}
}
return
}

View File

@ -1,4 +1,4 @@
package user
package thirdparty
import (
"context"
@ -7,11 +7,9 @@ import (
"time"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const DefaultAcceptableSkew = 5 * time.Minute
@ -19,13 +17,11 @@ const DefaultAcceptableSkew = 5 * time.Minute
type (
GetKeySet func(context.Context) (jwk.Set, error)
GetTokenRole func(context.Context, jwt.Token) (string, error)
GetTokenTenant func(context.Context, jwt.Token) (string, error)
)
type Authenticator struct {
getKeySet GetKeySet
getTokenRole GetTokenRole
getTokenTenant GetTokenTenant
acceptableSkew time.Duration
}
@ -48,45 +44,29 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew)
if err != nil {
logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.WithStack(err)
}
rawRole, err := a.getTokenRole(ctx, token)
if err != nil {
logger.Debug(ctx, "could not retrieve token role", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.WithStack(err)
}
if !isValidRole(rawRole) {
return nil, errors.WithStack(auth.ErrUnauthorized)
}
rawTenantID, err := a.getTokenTenant(ctx, token)
if err != nil {
logger.Debug(ctx, "could not retrieve token tenant", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
}
tenantID, err := datastore.ParseTenantID(rawTenantID)
if err != nil {
logger.Debug(ctx, "could not retrieve token tenant", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
return nil, errors.Errorf("invalid role '%s'", rawRole)
}
user := &User{
subject: token.Subject(),
role: Role(rawRole),
tenantID: tenantID,
}
return user, nil
}
func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, getTokenTenant GetTokenTenant, acceptableSkew time.Duration) *Authenticator {
func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, acceptableSkew time.Duration) *Authenticator {
return &Authenticator{
getTokenRole: getTokenRole,
getTokenTenant: getTokenTenant,
getKeySet: getKeySet,
acceptableSkew: acceptableSkew,
}

View File

@ -1,10 +1,9 @@
package user
package thirdparty
import (
"context"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
@ -27,12 +26,9 @@ func parseToken(ctx context.Context, keys jwk.Set, rawToken string, acceptableSk
return token, nil
}
const (
DefaultRoleKey string = "role"
DefaultTenantKey string = "tenant"
)
const DefaultRoleKey string = "role"
func GenerateToken(ctx context.Context, key jwk.Key, tenant datastore.TenantID, subject string, role Role) (string, error) {
func GenerateToken(ctx context.Context, key jwk.Key, subject string, role Role) (string, error) {
token := jwt.New()
if err := token.Set(jwt.SubjectKey, subject); err != nil {
@ -43,10 +39,6 @@ func GenerateToken(ctx context.Context, key jwk.Key, tenant datastore.TenantID,
return "", errors.WithStack(err)
}
if err := token.Set(DefaultTenantKey, tenant); err != nil {
return "", errors.WithStack(err)
}
now := time.Now().UTC()
if err := token.Set(jwt.NotBeforeKey, now); err != nil {

32
internal/auth/thirdparty/user.go vendored Normal file
View File

@ -0,0 +1,32 @@
package thirdparty
import "forge.cadoles.com/Cadoles/emissary/internal/auth"
type Role string
const (
RoleWriter Role = "writer"
RoleReader Role = "reader"
)
func isValidRole(r string) bool {
rr := Role(r)
return rr == RoleWriter || rr == RoleReader
}
type User struct {
subject string
role Role
}
// Subject implements auth.User
func (u *User) Subject() string {
return u.subject
}
func (u *User) Role() Role {
return u.role
}
var _ auth.User = &User{}

View File

@ -1,60 +0,0 @@
package user
import (
"encoding/json"
"forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
)
type Role string
const (
RoleWriter Role = "writer"
RoleReader Role = "reader"
RoleAdmin Role = "admin"
)
func isValidRole(r string) bool {
rr := Role(r)
return rr == RoleWriter || rr == RoleReader || rr == RoleAdmin
}
type User struct {
subject string
tenantID datastore.TenantID
role Role
}
// Subject implements auth.User
func (u *User) Subject() string {
return u.subject
}
// Tenant implements auth.User
func (u *User) Tenant() datastore.TenantID {
return u.tenantID
}
func (u *User) Role() Role {
return u.role
}
func (u *User) MarshalJSON() ([]byte, error) {
type user struct {
Subject string `json:"subject"`
Tenant string `json:"tenant"`
Role string `json:"role"`
}
jsonUser := user{
Subject: u.Subject(),
Tenant: string(u.Tenant()),
Role: string(u.Role()),
}
return json.Marshal(jsonUser)
}
var _ auth.User = &User{}

View File

@ -10,7 +10,6 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/persistence"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/proxy"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/spec"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/status"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/shell"
@ -95,15 +94,6 @@ func RunCommand() *cli.Command {
))
}
if ctrlConf.Status.Enabled {
controllers = append(controllers, status.NewController(
string(ctrlConf.Status.Address),
string(ctrlConf.Status.ClaimURL),
string(ctrlConf.Status.AgentURL),
string(ctx.String("projectVersion")),
))
}
key, err := jwk.LoadOrGenerate(string(conf.Agent.PrivateKeyPath), jwk.DefaultKeySize)
if err != nil {
return errors.WithStack(err)
@ -114,10 +104,6 @@ func RunCommand() *cli.Command {
return errors.WithStack(err)
}
logger.SetLevel(logger.LevelInfo)
logger.Info(ctx.Context, "agent thumbprint", logger.F("thumbprint", thumbprint))
logger.SetLevel(logger.Level(conf.Logger.Level))
collectors := createShellCollectors(&conf.Agent)
collectors = append(collectors, buildinfo.NewCollector())

View File

@ -3,12 +3,12 @@ package agent
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func CountCommand() *cli.Command {

View File

@ -3,14 +3,14 @@ package agent
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"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func DeleteCommand() *cli.Command {

View File

@ -3,7 +3,7 @@ package flag
import (
"errors"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/urfave/cli/v2"
)

View File

@ -3,13 +3,13 @@ package agent
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"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func GetCommand() *cli.Command {

View File

@ -3,13 +3,13 @@ package agent
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"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 {

View File

@ -1,7 +1,7 @@
package agent
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/spec"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/spec"
"github.com/urfave/cli/v2"
)
@ -15,7 +15,6 @@ func Root() *cli.Command {
UpdateCommand(),
GetCommand(),
DeleteCommand(),
ClaimCommand(),
spec.Root(),
},
}

View File

@ -3,13 +3,14 @@ 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"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func DeleteCommand() *cli.Command {
@ -22,11 +23,6 @@ func DeleteCommand() *cli.Command {
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)
@ -41,19 +37,14 @@ func DeleteCommand() *cli.Command {
return errors.WithStack(err)
}
specDefName, err := assertSpecDefName(ctx)
if err != nil {
return errors.WithStack(err)
}
specDefVersion, err := assertSpecDefVersion(ctx)
specName, err := assertSpecName(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
specDefName, specDefVersion, err = client.DeleteAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
specName, err = client.DeleteAgentSpec(ctx.Context, agentID, specName)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
@ -63,11 +54,9 @@ func DeleteCommand() *cli.Command {
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
Name string `json:"name"`
Version string `json:"version"`
Name spec.Name `json:"name"`
}{
Name: specDefName,
Version: specDefVersion,
Name: specName,
}); err != nil {
return errors.WithStack(err)
}

View File

@ -3,19 +3,19 @@ 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"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"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 {
func GetCommand() *cli.Command {
return &cli.Command{
Name: "query",
Usage: "Query agent specifications",
Name: "get",
Usage: "Get agent specifications",
Flags: agentFlag.WithAgentFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
@ -31,12 +31,14 @@ func QueryCommand() *cli.Command {
client := client.New(baseFlags.ServerURL, client.WithToken(token))
specs, err := client.QueryAgentSpecs(ctx.Context, agentID)
specs, err := client.GetAgentSpecs(ctx.Context, agentID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := specHeaderHints(baseFlags.OutputMode)
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil {
return errors.WithStack(err)

View File

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

View File

@ -4,16 +4,15 @@ import (
"encoding/json"
"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"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
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"
)
func UpdateCommand() *cli.Command {
@ -25,11 +24,6 @@ func UpdateCommand() *cli.Command {
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",
},
&cli.StringFlag{
Name: "spec-data",
Usage: "use `DATA` as specification's data, '-' to read from STDIN",
@ -50,12 +44,7 @@ func UpdateCommand() *cli.Command {
return errors.WithStack(err)
}
specDefName, err := assertSpecDefName(ctx)
if err != nil {
return errors.WithStack(err)
}
specDefVersion, err := assertSpecDefVersion(ctx)
specName, err := assertSpecName(ctx)
if err != nil {
return errors.WithStack(err)
}
@ -74,12 +63,21 @@ func UpdateCommand() *cli.Command {
client := client.New(baseFlags.ServerURL, client.WithToken(token))
existingSpec, err := client.GetAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
specs, err := client.GetAgentSpecs(ctx.Context, agentID)
if err != nil {
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.SpecName() != specName {
continue
}
existingSpec = s
break
}
revision := 0
@ -102,18 +100,23 @@ func UpdateCommand() *cli.Command {
}
rawSpec := &spec.RawSpec{
DefinitionName: specDefName,
DefinitionVersion: specDefVersion,
Name: specName,
Revision: revision,
Data: specData,
}
if err := spec.Validate(ctx.Context, rawSpec); err != nil {
return errors.WithStack(apierr.Wrap(err))
}
spec, err := client.UpdateAgentSpec(ctx.Context, agentID, rawSpec)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := specHints(baseFlags.OutputMode)
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, spec); err != nil {
return errors.WithStack(err)
@ -124,24 +127,14 @@ func UpdateCommand() *cli.Command {
}
}
func assertSpecDefName(ctx *cli.Context) (string, error) {
specDefName := ctx.String("spec-name")
func assertSpecName(ctx *cli.Context) (spec.Name, error) {
specName := ctx.String("spec-name")
if specDefName == "" {
if specName == "" {
return "", errors.New("flag 'spec-name' is required")
}
return specDefName, nil
}
func assertSpecDefVersion(ctx *cli.Context) (string, error) {
specDefVersion := ctx.String("spec-version")
if specDefVersion == "" {
return "", errors.New("flag 'spec-name' is required")
}
return specDefVersion, nil
return spec.Name(specName), nil
}
func assertSpecData(ctx *cli.Context) (map[string]any, error) {

View File

@ -3,13 +3,13 @@ package agent
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"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func UpdateCommand() *cli.Command {

View File

@ -1,9 +1,6 @@
package agent
import (
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
)
import "forge.cadoles.com/Cadoles/emissary/internal/format"
func agentHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
@ -13,8 +10,8 @@ func agentHints(outputMode format.OutputMode) format.Hints {
format.NewProp("Label", "Label"),
format.NewProp("Thumbprint", "Thumbprint"),
format.NewProp("Status", "Status"),
format.NewProp("ContactedAt", "ContactedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("ContactedAt", "ContactedAt"),
format.NewProp("UpdatedAt", "UpdatedAt"),
},
}
}

View File

@ -5,10 +5,10 @@ import (
"os"
"strings"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/format/table"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
)
const (

View File

@ -0,0 +1,16 @@
package api
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/api/agent"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "api",
Usage: "API related commands",
Subcommands: []*cli.Command{
agent.Root(),
},
}
}

View File

@ -1,51 +0,0 @@
package agent
import (
"os"
"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 ClaimCommand() *cli.Command {
return &cli.Command{
Name: "claim",
Usage: "Claim agent",
Flags: clientFlag.ComposeFlags(
&cli.StringFlag{
Name: "agent-thumbprint",
Value: "",
Required: true,
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
agentThumbprint := ctx.String("agent-thumbprint")
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.ClaimAgent(ctx.Context, agentThumbprint)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := agentHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -1,68 +0,0 @@
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 GetCommand() *cli.Command {
return &cli.Command{
Name: "get",
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)
if err != nil {
return errors.WithStack(err)
}
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
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))
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, spec); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -1,35 +0,0 @@
package spec
import (
"gitlab.com/wpetit/goweb/cli/format"
"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("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)),
},
}
}

View File

@ -1,18 +0,0 @@
package client
import (
"forge.cadoles.com/Cadoles/emissary/internal/command/client/agent"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant"
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "client",
Usage: "API client related commands",
Subcommands: []*cli.Command{
agent.Root(),
tenant.Root(),
},
}
}

View File

@ -1,51 +0,0 @@
package tenant
import (
"os"
"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 CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create tenant",
Flags: clientFlag.ComposeFlags(
&cli.StringFlag{
Name: "tenant-label",
Usage: "Set `TENANT_LABEL` to targeted tenant",
Value: "",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantLabel := ctx.String("tenant-label")
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.CreateTenant(ctx.Context, tenantLabel)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -1,56 +0,0 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func DeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete tenant",
Flags: tenantFlag.WithTenantFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantID, err := tenantFlag.AssertTenantID(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
tenantID, err = client.DeleteTenant(ctx.Context, tenantID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := format.Hints{
OutputMode: baseFlags.OutputMode,
}
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
ID datastore.TenantID `json:"id"`
}{
ID: tenantID,
}); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -1,37 +0,0 @@
package flag
import (
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
func WithTenantFlags(flags ...cli.Flag) []cli.Flag {
baseFlags := clientFlag.ComposeFlags(
&cli.StringFlag{
Name: "tenant-id",
Usage: "use `TENANT_ID` as targeted tenant",
Value: "",
},
)
flags = append(flags, baseFlags...)
return flags
}
func AssertTenantID(ctx *cli.Context) (datastore.TenantID, error) {
rawTenantID := ctx.String("tenant-id")
if rawTenantID == "" {
return "", errors.New("flag 'tenant-id' is required")
}
tenantID, err := datastore.ParseTenantID(rawTenantID)
if err != nil {
return "", errors.WithStack(err)
}
return tenantID, nil
}

View File

@ -1,49 +0,0 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func GetCommand() *cli.Command {
return &cli.Command{
Name: "get",
Usage: "Get tenant",
Flags: tenantFlag.WithTenantFlags(),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantID, err := tenantFlag.AssertTenantID(ctx)
if err != nil {
return errors.WithStack(err)
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.GetTenant(ctx.Context, tenantID)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -1,18 +0,0 @@
package tenant
import (
"gitlab.com/wpetit/goweb/cli/format"
"gitlab.com/wpetit/goweb/cli/format/table"
)
func tenantHints(outputMode format.OutputMode) format.Hints {
return format.Hints{
OutputMode: outputMode,
Props: []format.Prop{
format.NewProp("ID", "ID", table.WithCompactModeMaxColumnWidth(8)),
format.NewProp("Label", "Label"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
},
}
}

View File

@ -1,63 +0,0 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"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 tenants",
Flags: clientFlag.ComposeFlags(
&cli.Int64SliceFlag{
Name: "ids",
Usage: "use `IDS` as query filter",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
options := make([]client.QueryTenantsOptionFunc, 0)
rawIDs := ctx.StringSlice("ids")
if rawIDs != nil {
tenantIDs := func(ids []string) []datastore.TenantID {
tenantIDs := make([]datastore.TenantID, len(ids))
for i, id := range ids {
tenantIDs[i] = datastore.TenantID(id)
}
return tenantIDs
}(rawIDs)
options = append(options, client.WithQueryTenantsID(tenantIDs...))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
tenants, _, err := client.QueryTenants(ctx.Context, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(tenants)...); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -1,19 +0,0 @@
package tenant
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "tenant",
Usage: "Tenants related commands",
Subcommands: []*cli.Command{
CreateCommand(),
GetCommand(),
UpdateCommand(),
DeleteCommand(),
QueryCommand(),
},
}
}

View File

@ -1,62 +0,0 @@
package tenant
import (
"os"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag"
"forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
)
func UpdateCommand() *cli.Command {
return &cli.Command{
Name: "update",
Usage: "Update tenant",
Flags: tenantFlag.WithTenantFlags(
&cli.StringFlag{
Name: "tenant-label",
Usage: "Set `TENANT_LABEL` to targeted tenant",
Value: "",
},
),
Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx)
token, err := clientFlag.GetToken(baseFlags)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
tenantID, err := tenantFlag.AssertTenantID(ctx)
if err != nil {
return errors.WithStack(err)
}
options := make([]client.UpdateTenantOptionFunc, 0)
label := ctx.String("tenant-label")
if label != "" {
options = append(options, client.WithTenantLabel(label))
}
client := client.New(baseFlags.ServerURL, client.WithToken(token))
agent, err := client.UpdateTenant(ctx.Context, tenantID, options...)
if err != nil {
return errors.WithStack(apierr.Wrap(err))
}
hints := tenantHints(baseFlags.OutputMode)
if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -5,12 +5,11 @@ import (
"os"
"path/filepath"
"forge.cadoles.com/Cadoles/emissary/internal/auth/user"
"forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/google/uuid"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
@ -22,18 +21,13 @@ func CreateTokenCommand() *cli.Command {
Flags: []cli.Flag{
&cli.StringFlag{
Name: "role",
Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []user.Role{user.RoleReader, user.RoleWriter, user.RoleAdmin}),
Value: string(user.RoleReader),
Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []thirdparty.Role{thirdparty.RoleReader, thirdparty.RoleWriter}),
Value: string(thirdparty.RoleReader),
},
&cli.StringFlag{
Name: "subject",
Usage: "associate `SUBJECT` to the token",
Value: fmt.Sprintf("user-%s", uuid.New().String()),
},
&cli.StringFlag{
Name: "tenant",
Usage: "associate `TENANT` to the token",
Value: "00000000-0000-0000-0000-000000000000",
Value: fmt.Sprintf("user-%s", shortuuid.New()),
},
&cli.StringFlag{
Name: "output",
@ -50,7 +44,6 @@ func CreateTokenCommand() *cli.Command {
}
subject := ctx.String("subject")
tenant := ctx.String("tenant")
role := ctx.String("role")
output := ctx.String("output")
@ -64,7 +57,7 @@ func CreateTokenCommand() *cli.Command {
return errors.WithStack(err)
}
token, err := user.GenerateToken(ctx.Context, key, datastore.TenantID(tenant), subject, user.Role(role))
token, err := thirdparty.GenerateToken(ctx.Context, key, subject, thirdparty.Role(role))
if err != nil {
return errors.WithStack(err)
}

View File

@ -24,7 +24,6 @@ type ControllersConfig struct {
App AppControllerConfig `yaml:"app"`
SysUpgrade SysUpgradeControllerConfig `yaml:"sysupgrade"`
MDNS MDNSControllerConfig `yaml:"mdns"`
Status StatusControllerConfig `yaml:"status"`
}
type PersistenceControllerConfig struct {
@ -61,13 +60,6 @@ type MDNSControllerConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
}
type StatusControllerConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Address InterpolatedString `yaml:"address"`
ClaimURL InterpolatedString `yaml:"claimURL"`
AgentURL InterpolatedString `yaml:"agentURL"`
}
func NewDefaultAgentConfig() AgentConfig {
return AgentConfig{
ServerURL: "http://127.0.0.1:3000",
@ -102,12 +94,6 @@ func NewDefaultAgentConfig() AgentConfig {
MDNS: MDNSControllerConfig{
Enabled: true,
},
Status: StatusControllerConfig{
Enabled: true,
Address: ":42521",
ClaimURL: "http://localhost:3001/claim/%s",
AgentURL: "http://localhost:3001/agents/%v",
},
},
Collectors: []ShellCollectorConfig{
{

View File

@ -3,7 +3,7 @@ package config
import (
"fmt"
"forge.cadoles.com/Cadoles/emissary/internal/auth/user"
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty"
)
type ServerConfig struct {
@ -26,7 +26,6 @@ type AuthConfig struct {
Local *LocalAuthConfig `yaml:"local"`
Remote *RemoteAuthConfig `yaml:"remote"`
RoleExtractionRules []string `yaml:"roleExtractionRules"`
TenantExtractionRules []string `yaml:"tenantExtractionRules"`
}
func NewDefaultAuthConfig() AuthConfig {
@ -36,10 +35,7 @@ func NewDefaultAuthConfig() AuthConfig {
},
Remote: nil,
RoleExtractionRules: []string{
fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultRoleKey, user.DefaultRoleKey),
},
TenantExtractionRules: []string{
fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultTenantKey, user.DefaultTenantKey),
fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", thirdparty.DefaultRoleKey, thirdparty.DefaultRoleKey),
},
}
}

View File

@ -29,7 +29,6 @@ type Agent struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ContactedAt *time.Time `json:"contactedAt,omitempty"`
TenantID *TenantID `json:"tenantId"`
}
type SerializableKeySet struct {

View File

@ -9,19 +9,14 @@ import (
type AgentRepository interface {
Create(ctx context.Context, thumbprint string, keySet jwk.Set, metadata map[string]any) (*Agent, error)
Attach(ctx context.Context, tenantID TenantID, agentID AgentID) (*Agent, error)
Detach(ctx context.Context, agentID AgentID) (*Agent, error)
Get(ctx context.Context, id AgentID) (*Agent, error)
Update(ctx context.Context, id AgentID, updates ...AgentUpdateOptionFunc) (*Agent, error)
Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error)
Delete(ctx context.Context, id AgentID) error
UpdateSpec(ctx context.Context, id AgentID, name string, version string, revision int, data map[string]any) (*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
UpdateSpec(ctx context.Context, id AgentID, name string, revision int, data map[string]any) (*Spec, error)
GetSpecs(ctx context.Context, id AgentID) ([]*Spec, error)
DeleteSpec(ctx context.Context, id AgentID, name string) error
}
type AgentQueryOptionFunc func(*AgentQueryOptions)
@ -30,7 +25,6 @@ type AgentQueryOptions struct {
Limit *int
Offset *int
IDs []AgentID
TenantIDs []TenantID
Thumbprints []string
Metadata *map[string]any
Statuses []AgentStatus
@ -60,12 +54,6 @@ func WithAgentQueryID(ids ...AgentID) AgentQueryOptionFunc {
}
}
func WithAgentQueryTenantID(ids ...TenantID) AgentQueryOptionFunc {
return func(opts *AgentQueryOptions) {
opts.TenantIDs = ids
}
}
func WithAgentQueryStatus(statuses ...AgentStatus) AgentQueryOptionFunc {
return func(opts *AgentQueryOptions) {
opts.Statuses = statuses
@ -87,13 +75,6 @@ type AgentUpdateOptions struct {
Metadata *map[string]any
KeySet *jwk.Set
Thumbprint *string
TenantID *TenantID
}
func WithAgentUpdateTenant(id TenantID) AgentUpdateOptionFunc {
return func(opts *AgentUpdateOptions) {
opts.TenantID = &id
}
}
func WithAgentUpdateStatus(status AgentStatus) AgentUpdateOptionFunc {

View File

@ -6,5 +6,4 @@ var (
ErrNotFound = errors.New("not found")
ErrAlreadyExist = errors.New("already exist")
ErrUnexpectedRevision = errors.New("unexpected revision")
ErrAlreadyAttached = errors.New("already attached")
)

View File

@ -1,166 +0,0 @@
package memory
import (
"context"
"slices"
"sync"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
)
type specDefRecord struct {
Schema []byte
CreatedAt time.Time
UpdatedAt time.Time
}
type SpecDefinitionRepository struct {
definitions map[string]map[string]specDefRecord
mutex sync.RWMutex
}
// Delete implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Delete(ctx context.Context, name string, version string) error {
r.mutex.Lock()
defer r.mutex.Unlock()
versions, exists := r.definitions[name]
if !exists {
return nil
}
delete(versions, version)
r.definitions[name] = versions
return nil
}
// Get implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Get(ctx context.Context, name string, version string) (*datastore.SpecDefinition, error) {
r.mutex.RLock()
defer r.mutex.RUnlock()
versions, exists := r.definitions[name]
if !exists {
return nil, errors.WithStack(datastore.ErrNotFound)
}
rec, exists := versions[version]
if !exists {
return nil, errors.WithStack(datastore.ErrNotFound)
}
specDef := datastore.SpecDefinition{
SpecDefinitionHeader: datastore.SpecDefinitionHeader{
Name: name,
Version: version,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
},
Schema: rec.Schema[:],
}
return &specDef, nil
}
// Query implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Query(ctx context.Context, opts ...datastore.SpecDefinitionQueryOptionFunc) ([]datastore.SpecDefinitionHeader, int, error) {
options := &datastore.SpecDefinitionQueryOptions{}
for _, fn := range opts {
fn(options)
}
r.mutex.RLock()
defer r.mutex.RUnlock()
specDefs := make([]datastore.SpecDefinitionHeader, 0)
count := 0
for name, versions := range r.definitions {
for version, rec := range versions {
count++
matches := true
if options.Names != nil && !slices.Contains(options.Names, name) {
matches = false
}
if options.Versions != nil && !slices.Contains(options.Versions, version) {
matches = false
}
if options.Offset != nil && count < *options.Offset {
matches = false
}
if options.Limit != nil && len(specDefs) >= *options.Limit {
matches = false
}
if !matches {
continue
}
specDefs = append(specDefs, datastore.SpecDefinitionHeader{
Name: name,
Version: version,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
})
}
}
return specDefs, count, nil
}
// Upsert implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Upsert(ctx context.Context, name string, version string, schema []byte) (*datastore.SpecDefinition, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
versions, exists := r.definitions[name]
if !exists {
versions = make(map[string]specDefRecord)
}
now := time.Now().UTC()
rec, exists := versions[version]
if !exists {
rec = specDefRecord{
CreatedAt: now,
UpdatedAt: now,
Schema: schema[:],
}
} else {
rec.UpdatedAt = now
rec.Schema = schema
}
versions[version] = rec
r.definitions[name] = versions
specDef := datastore.SpecDefinition{
SpecDefinitionHeader: datastore.SpecDefinitionHeader{
Name: name,
Version: version,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
},
Schema: rec.Schema[:],
}
return &specDef, nil
}
func NewSpecDefinitionRepository() *SpecDefinitionRepository {
return &SpecDefinitionRepository{
definitions: make(map[string]map[string]specDefRecord),
}
}
var _ datastore.SpecDefinitionRepository = &SpecDefinitionRepository{}

View File

@ -1,14 +0,0 @@
package memory
import (
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"gitlab.com/wpetit/goweb/logger"
)
func TestMemorySpecDefinitionRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
repo := NewSpecDefinitionRepository()
testsuite.TestSpecDefinitionRepository(t, repo)
}

View File

@ -2,33 +2,23 @@ package datastore
import (
"time"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
)
type SpecID int64
type SpecHeader struct {
type Spec struct {
ID SpecID `json:"id"`
DefinitionName string `json:"name"`
DefinitionVersion string `json:"version"`
Name string `json:"name"`
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"`
}
type Spec struct {
SpecHeader
Data map[string]any `json:"data"`
}
func (s *Spec) SpecDefinitionName() string {
return s.DefinitionName
}
func (s *Spec) SpecDefinitionVersion() string {
return s.DefinitionVersion
func (s *Spec) SpecName() spec.Name {
return spec.Name(s.Name)
}
func (s *Spec) SpecRevision() int {

View File

@ -1,18 +0,0 @@
package datastore
import (
"time"
)
type SpecDefinitionHeader struct {
Name string `json:"name"`
Version string `json:"version"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SpecDefinition struct {
SpecDefinitionHeader
Schema []byte `json:"schema"`
}

View File

@ -1,46 +0,0 @@
package datastore
import (
"context"
)
type SpecDefinitionRepository interface {
Upsert(ctx context.Context, name string, version string, schema []byte) (*SpecDefinition, error)
Get(ctx context.Context, name string, version string) (*SpecDefinition, error)
Delete(ctx context.Context, name string, version string) error
Query(ctx context.Context, opts ...SpecDefinitionQueryOptionFunc) ([]SpecDefinitionHeader, int, error)
}
type SpecDefinitionQueryOptionFunc func(*SpecDefinitionQueryOptions)
type SpecDefinitionQueryOptions struct {
Limit *int
Offset *int
Names []string
Versions []string
}
func WithSpecDefinitionQueryLimit(limit int) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Limit = &limit
}
}
func WithSpecDefinitionQueryOffset(offset int) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Offset = &offset
}
}
func WithSpecDefinitionQueryNames(names ...string) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Names = names
}
}
func WithSpecDefinitionQueryVersions(versions ...string) SpecDefinitionQueryOptionFunc {
return func(opts *SpecDefinitionQueryOptions) {
opts.Versions = versions
}
}

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
@ -15,120 +16,12 @@ import (
)
type AgentRepository struct {
repository
}
// Attach implements datastore.AgentRepository.
func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantID, agentID datastore.AgentID) (*datastore.Agent, error) {
var agent datastore.Agent
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT count(id), tenant_id FROM agents WHERE id = $1`
row := tx.QueryRowContext(ctx, query, agentID)
var (
count int
attachedTenantID *datastore.TenantID
)
if err := row.Scan(&count, &attachedTenantID); err != nil {
return errors.WithStack(err)
}
if count == 0 {
return errors.WithStack(datastore.ErrNotFound)
}
if attachedTenantID != nil {
return errors.WithStack(datastore.ErrAlreadyAttached)
}
now := time.Now().UTC()
query = `
UPDATE agents SET tenant_id = $1, updated_at = $2 WHERE id = $3
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
`
row = tx.QueryRowContext(
ctx, query,
tenantID,
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
}
// Detach implements datastore.AgentRepository.
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
db *sql.DB
sqliteBusyRetryMaxAttempts int
}
// DeleteSpec implements datastore.AgentRepository.
func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string, version string) error {
func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID)
if err != nil {
@ -139,9 +32,9 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
return errors.WithStack(datastore.ErrNotFound)
}
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2 AND version = $3`
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2`
if _, err = tx.ExecContext(ctx, query, agentID, name, version); err != nil {
if _, err = tx.ExecContext(ctx, query, agentID, name); err != nil {
return errors.WithStack(err)
}
@ -154,44 +47,9 @@ 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) QuerySpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.SpecHeader, error) {
specs := make([]*datastore.SpecHeader, 0)
func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) {
specs := make([]*datastore.Spec, 0)
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID)
@ -204,7 +62,7 @@ func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.Agen
}
query := `
SELECT id, name, version, revision, created_at, updated_at, agent_id, tenant_id
SELECT id, name, revision, data, created_at, updated_at
FROM specs
WHERE agent_id = $1
`
@ -222,16 +80,15 @@ func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.Agen
}()
for rows.Next() {
spec := &datastore.SpecHeader{}
spec := &datastore.Spec{}
var tenantID sql.NullString
if err := rows.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &tenantID); err != nil {
data := JSONMap{}
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil {
return errors.WithStack(err)
}
if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String)
}
spec.Data = data
specs = append(specs, spec)
}
@ -250,7 +107,7 @@ func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.Agen
}
// UpdateSpec implements datastore.AgentRepository.
func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.AgentID, name string, version string, revision int, data map[string]any) (*datastore.Spec, error) {
func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.AgentID, name string, revision int, data map[string]any) (*datastore.Spec, error) {
spec := &datastore.Spec{}
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
@ -266,24 +123,23 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
now := time.Now().UTC()
query := `
INSERT INTO specs (agent_id, name, version, revision, data, created_at, updated_at, tenant_id)
VALUES($1, $2, $3, $4, $5, $6, $6, ( SELECT tenant_id FROM agents WHERE id = $1 ))
ON CONFLICT (agent_id, name, version) DO UPDATE SET
data = $5, updated_at = $6, revision = specs.revision + 1, tenant_id = ( SELECT tenant_id FROM agents WHERE id = $1 )
WHERE revision = $4
RETURNING "id", "name", "version", "revision", "data", "created_at", "updated_at", "tenant_id", "agent_id"
INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at)
VALUES($1, $2, $3, $4, $5, $5)
ON CONFLICT (agent_id, name) DO UPDATE SET
data = $4, updated_at = $5, revision = specs.revision + 1
WHERE revision = $3
RETURNING "id", "name", "revision", "data", "created_at", "updated_at"
`
args := []any{agentID, name, version, revision, JSONMap(data), now}
args := []any{agentID, name, revision, JSONMap(data), now}
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
row := tx.QueryRowContext(ctx, query, args...)
data := JSONMap{}
var tenantID sql.NullString
err = row.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &tenantID, &spec.AgentID)
err = row.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrUnexpectedRevision)
@ -292,10 +148,6 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
return errors.WithStack(err)
}
if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String)
}
spec.Data = data
return nil
@ -318,7 +170,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at, tenant_id FROM agents`
query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at FROM agents`
limit := 10
if options.Limit != nil {
@ -341,17 +193,6 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
args = append(args, newArgs...)
}
if options.TenantIDs != nil && len(options.TenantIDs) > 0 {
if filters != "" {
filters += " AND "
}
filter, newArgs, newParamIndex := inFilter("tenant_id", paramIndex, options.TenantIDs)
filters += filter
paramIndex = newParamIndex
args = append(args, newArgs...)
}
if options.Thumbprints != nil && len(options.Thumbprints) > 0 {
if filters != "" {
filters += " AND "
@ -399,7 +240,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
metadata := JSONMap{}
contactedAt := sql.NullTime{}
if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil {
return errors.WithStack(err)
}
@ -452,7 +293,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
query = `
INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at)
VALUES($1, $2, $3, $4, $5, $5)
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at"
`
rawKeySet, err := json.Marshal(keySet)
@ -467,7 +308,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
metadata := JSONMap{}
err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt)
if err != nil {
return errors.WithStack(err)
}
@ -522,7 +363,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `
SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id"
SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at"
FROM agents
WHERE id = $1
`
@ -533,7 +374,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
contactedAt := sql.NullTime{}
var rawKeySet []byte
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return datastore.ErrNotFound
}
@ -635,7 +476,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
query += `
WHERE id = $1
RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id"
RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at"
`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
@ -646,7 +487,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
contactedAt := sql.NullTime{}
var rawKeySet []byte
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return datastore.ErrNotFound
}
@ -695,8 +536,109 @@ func (r *AgentRepository) agentExists(ctx context.Context, tx *sql.Tx, agentID d
return true, nil
}
func (r *AgentRepository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error {
attempts := 0
max := r.sqliteBusyRetryMaxAttempts
ctx = logger.With(ctx, logger.F("max", max))
var err error
for {
ctx = logger.With(ctx)
if attempts >= max {
logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts))
return errors.Wrapf(err, "transaction failed after %d attempts", max)
}
err = r.withTx(ctx, fn)
if err != nil {
if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") {
return errors.WithStack(err)
}
err = errors.WithStack(err)
logger.Warn(ctx, "database is busy", logger.E(err))
wait := time.Duration(8<<(attempts+1)) * time.Millisecond
logger.Debug(
ctx, "database is busy, waiting before retrying transaction",
logger.F("wait", wait.String()),
logger.F("attempts", attempts),
)
timer := time.NewTimer(wait)
select {
case <-timer.C:
attempts++
continue
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
}
return nil
}
}
func (r *AgentRepository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
if errors.Is(err, sql.ErrTxDone) {
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err))
}
}()
if err := fn(tx); err != nil {
return errors.WithStack(err)
}
if err := tx.Commit(); err != nil {
return errors.WithStack(err)
}
return nil
}
func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentRepository {
return &AgentRepository{repository{db, sqliteBusyRetryMaxAttempts}}
return &AgentRepository{db, sqliteBusyRetryMaxAttempts}
}
var _ datastore.AgentRepository = &AgentRepository{}
func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) {
args := make([]any, 0, len(items))
filter := fmt.Sprintf("%s in (", column)
for idx, item := range items {
if idx != 0 {
filter += ","
}
filter += fmt.Sprintf("$%d", paramIndex)
paramIndex++
args = append(args, item)
}
filter += ")"
return filter, args, paramIndex
}

View File

@ -7,42 +7,6 @@ import (
"github.com/pkg/errors"
)
type JSON struct {
value any
}
func (j JSON) Scan(value interface{}) error {
if value == nil {
return nil
}
var data []byte
switch typ := value.(type) {
case []byte:
data = typ
case string:
data = []byte(typ)
default:
return errors.Errorf("unexpected type '%T'", value)
}
if err := json.Unmarshal(data, &j.value); err != nil {
return errors.WithStack(err)
}
return nil
}
func (j JSON) Value() (driver.Value, error) {
data, err := json.Marshal(j.value)
if err != nil {
return nil, errors.WithStack(err)
}
return data, nil
}
type JSONMap map[string]any
func (j *JSONMap) Scan(value interface{}) error {

View File

@ -1,97 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"strings"
"time"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type repository struct {
db *sql.DB
sqliteBusyRetryMaxAttempts int
}
func (r *repository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error {
attempts := 0
max := r.sqliteBusyRetryMaxAttempts
ctx = logger.With(ctx, logger.F("max", max))
var err error
for {
ctx = logger.With(ctx)
if attempts >= max {
logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts))
return errors.Wrapf(err, "transaction failed after %d attempts", max)
}
err = r.withTx(ctx, fn)
if err != nil {
if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") {
return errors.WithStack(err)
}
err = errors.WithStack(err)
logger.Warn(ctx, "database is busy", logger.E(err))
wait := time.Duration(8<<(attempts+1)) * time.Millisecond
logger.Debug(
ctx, "database is busy, waiting before retrying transaction",
logger.F("wait", wait.String()),
logger.F("attempts", attempts),
)
timer := time.NewTimer(wait)
select {
case <-timer.C:
attempts++
continue
case <-ctx.Done():
if err := ctx.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
}
return nil
}
}
func (r *repository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
if errors.Is(err, sql.ErrTxDone) {
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err))
}
}()
if err := fn(tx); err != nil {
return errors.WithStack(err)
}
if err := tx.Commit(); err != nil {
return errors.WithStack(err)
}
return nil
}

View File

@ -1,219 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type SpecDefinitionRepository struct {
repository
}
// Delete implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Delete(ctx context.Context, name string, version string) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
if exists, err := r.specDefinitionExists(ctx, tx, name, version); !exists {
return errors.WithStack(err)
}
query := `DELETE FROM spec_definitions WHERE name = $1 AND version = $2`
_, err := tx.ExecContext(ctx, query, name, version)
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
// Get implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Get(ctx context.Context, name string, version string) (*datastore.SpecDefinition, error) {
var specDef datastore.SpecDefinition
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `
SELECT "name", "version", "schema", "created_at", "updated_at"
FROM spec_definitions
WHERE name = $1 AND version = $2
`
row := tx.QueryRowContext(ctx, query, name, version)
if err := row.Scan(&specDef.Name, &specDef.Version, &specDef.Schema, &specDef.CreatedAt, &specDef.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrNotFound)
}
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &specDef, nil
}
// Query implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Query(ctx context.Context, opts ...datastore.SpecDefinitionQueryOptionFunc) ([]datastore.SpecDefinitionHeader, int, error) {
options := &datastore.SpecDefinitionQueryOptions{}
for _, fn := range opts {
fn(options)
}
specDefs := make([]datastore.SpecDefinitionHeader, 0)
count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT name, version, created_at, updated_at FROM spec_definitions`
limit := 10
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
filters := ""
paramIndex := 3
args := []any{offset, limit}
if options.Names != nil && len(options.Names) > 0 {
filter, newArgs, newParamIndex := inFilter("name", paramIndex, options.Names)
filters += filter
paramIndex = newParamIndex
args = append(args, newArgs...)
}
if options.Versions != nil && len(options.Versions) > 0 {
if filters != "" {
filters += " AND "
}
filter, newArgs, _ := inFilter("version", paramIndex, options.Versions)
filters += filter
args = append(args, newArgs...)
}
if filters != "" {
filters = ` WHERE ` + filters
}
query += filters + ` LIMIT $2 OFFSET $1`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not close rows", logger.CapturedE(err))
}
}()
for rows.Next() {
sdh := datastore.SpecDefinitionHeader{}
if err := rows.Scan(&sdh.Name, &sdh.Version, &sdh.CreatedAt, &sdh.UpdatedAt); err != nil {
return errors.WithStack(err)
}
specDefs = append(specDefs, sdh)
}
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
row := tx.QueryRowContext(ctx, `SELECT count(*) FROM spec_definitions `+filters, args...)
if err := row.Scan(&count); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, 0, errors.WithStack(err)
}
return specDefs, count, nil
}
// Upsert implements datastore.SpecDefinitionRepository.
func (r *SpecDefinitionRepository) Upsert(ctx context.Context, name string, version string, schema []byte) (*datastore.SpecDefinition, error) {
var specDef datastore.SpecDefinition
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
now := time.Now().UTC()
query := `
INSERT INTO spec_definitions (name, version, schema, created_at, updated_at)
VALUES($1, $2, $3, $4, $4)
ON CONFLICT(name, version) DO UPDATE SET schema = $3, updated_at = $4
RETURNING "name", "version", "schema", "created_at", "updated_at"
`
row := tx.QueryRowContext(
ctx, query,
name, version, schema, now, now,
)
if err := row.Scan(&specDef.Name, &specDef.Version, &specDef.Schema, &specDef.CreatedAt, &specDef.UpdatedAt); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &specDef, nil
}
func (r *SpecDefinitionRepository) specDefinitionExists(ctx context.Context, tx *sql.Tx, name string, version string) (bool, error) {
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM spec_definitions WHERE name = $1 AND version = $2`, name, version)
var count int
if err := row.Scan(&count); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, errors.WithStack(datastore.ErrNotFound)
}
return false, errors.WithStack(err)
}
if count == 0 {
return false, errors.WithStack(datastore.ErrNotFound)
}
return true, nil
}
func NewSpecDefinitionRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *SpecDefinitionRepository {
return &SpecDefinitionRepository{
repository: repository{db, sqliteBusyRetryMaxAttempts},
}
}
var _ datastore.SpecDefinitionRepository = &SpecDefinitionRepository{}

View File

@ -1,46 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"os"
"testing"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "modernc.org/sqlite"
)
func TestSQLiteSpecDefinitionRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
file := "testdata/spec_definition_repository_test.sqlite"
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%+v", errors.WithStack(err))
}
dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
migr, err := migrate.New("../../../migrations", "sqlite", "sqlite://"+dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if err := migr.Up(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
repo := NewSpecDefinitionRepository(db, 5)
testsuite.TestSpecDefinitionRepository(t, repo)
}

View File

@ -1,23 +0,0 @@
package sqlite
import "fmt"
func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) {
args := make([]any, 0, len(items))
filter := fmt.Sprintf("%s in (", column)
for idx, item := range items {
if idx != 0 {
filter += ","
}
filter += fmt.Sprintf("$%d", paramIndex)
paramIndex++
args = append(args, item)
}
filter += ")"
return filter, args, paramIndex
}

View File

@ -1,284 +0,0 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type TenantRepository struct {
repository
}
// Query implements datastore.TenantRepository.
func (r *TenantRepository) Query(ctx context.Context, opts ...datastore.TenantQueryOptionFunc) ([]*datastore.Tenant, int, error) {
options := &datastore.TenantQueryOptions{}
for _, fn := range opts {
fn(options)
}
tenants := make([]*datastore.Tenant, 0)
count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT id, label, created_at, updated_at FROM tenants`
limit := 10
if options.Limit != nil {
limit = *options.Limit
}
offset := 0
if options.Offset != nil {
offset = *options.Offset
}
filters := ""
paramIndex := 3
args := []any{offset, limit}
if options.IDs != nil && len(options.IDs) > 0 {
filter, newArgs, newParamIndex := inFilter("id", paramIndex, options.IDs)
filters += filter
paramIndex = newParamIndex
args = append(args, newArgs...)
}
if filters != "" {
filters = ` WHERE ` + filters
}
query += filters + ` LIMIT $2 OFFSET $1`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
err = errors.WithStack(err)
logger.Error(ctx, "could not close rows", logger.CapturedE(err))
}
}()
for rows.Next() {
tenant := &datastore.Tenant{}
if err := rows.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
return errors.WithStack(err)
}
tenants = append(tenants, tenant)
}
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM tenants `+filters, args...)
if err := row.Scan(&count); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, 0, errors.WithStack(err)
}
return tenants, count, nil
}
// Create implements datastore.TenantRepository.
func (r *TenantRepository) Create(ctx context.Context, label string) (*datastore.Tenant, error) {
var tenant datastore.Tenant
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
now := time.Now().UTC()
query := `
INSERT INTO tenants (id, label, created_at, updated_at)
VALUES($1, $2, $3, $3)
RETURNING "id", "label", "created_at", "updated_at"
`
tenantID := datastore.NewTenantID()
row := tx.QueryRowContext(
ctx, query,
tenantID, label, now,
)
if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &tenant, nil
}
// Delete implements datastore.TenantRepository.
func (r *TenantRepository) Delete(ctx context.Context, id datastore.TenantID) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
if exists, err := r.tenantExists(ctx, tx, id); !exists {
return errors.WithStack(err)
}
query := `DELETE FROM tenants WHERE id = $1`
_, err := tx.ExecContext(ctx, query, id)
if err != nil {
return errors.WithStack(err)
}
query = `DELETE FROM agents WHERE tenant_id = $1`
_, err = tx.ExecContext(ctx, query, id)
if err != nil {
return errors.WithStack(err)
}
query = `DELETE FROM specs WHERE tenant_id = $1`
_, err = tx.ExecContext(ctx, query, id)
if err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
// Get implements datastore.TenantRepository.
func (r *TenantRepository) Get(ctx context.Context, id datastore.TenantID) (*datastore.Tenant, error) {
var tenant datastore.Tenant
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `
SELECT "id", "label", "created_at", "updated_at"
FROM tenants
WHERE id = $1
`
row := tx.QueryRowContext(ctx, query, id)
if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrNotFound)
}
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &tenant, nil
}
// Update implements datastore.TenantRepository.
func (r *TenantRepository) Update(ctx context.Context, id datastore.TenantID, updates ...datastore.TenantUpdateOptionFunc) (*datastore.Tenant, error) {
options := &datastore.TenantUpdateOptions{}
for _, fn := range updates {
fn(options)
}
var tenant datastore.Tenant
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
if exists, err := r.tenantExists(ctx, tx, id); !exists {
return errors.WithStack(err)
}
query := `
UPDATE tenants SET updated_at = $1
`
args := []any{id}
index := 2
if options.Label != nil {
query += fmt.Sprintf(`, label = $%d`, index)
args = append(args, *options.Label)
index++
}
updated := options.Label != nil
if updated {
now := time.Now().UTC()
query += fmt.Sprintf(`, updated_at = $%d`, index)
args = append(args, now)
index++
}
query += `
WHERE id = $1
RETURNING "id", "label", "created_at", "updated_at"
`
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
row := tx.QueryRowContext(ctx, query, args...)
if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrNotFound)
}
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &tenant, nil
}
func (r *TenantRepository) tenantExists(ctx context.Context, tx *sql.Tx, tenantID datastore.TenantID) (bool, error) {
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM tenants WHERE id = $1`, tenantID)
var count int
if err := row.Scan(&count); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, errors.WithStack(datastore.ErrNotFound)
}
return false, errors.WithStack(err)
}
if count == 0 {
return false, errors.WithStack(datastore.ErrNotFound)
}
return true, nil
}
func NewTenantRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *TenantRepository {
return &TenantRepository{
repository: repository{db, sqliteBusyRetryMaxAttempts},
}
}
var _ datastore.TenantRepository = &TenantRepository{}

View File

@ -1,46 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"os"
"testing"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "modernc.org/sqlite"
)
func TestSQLiteTenantRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
file := "testdata/tenant_repository_test.sqlite"
if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%+v", errors.WithStack(err))
}
dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds())
migr, err := migrate.New("../../../migrations", "sqlite", "sqlite://"+dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if err := migr.Up(); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
repo := NewTenantRepository(db, 5)
testsuite.TestTenantRepository(t, repo)
}

View File

@ -1,32 +0,0 @@
package datastore
import (
"time"
"github.com/google/uuid"
"github.com/pkg/errors"
)
const DefaultTenantID TenantID = "00000000-0000-0000-0000-000000000000"
type TenantID string
func NewTenantID() TenantID {
return TenantID(uuid.New().String())
}
func ParseTenantID(raw string) (TenantID, error) {
uuid, err := uuid.Parse(raw)
if err != nil {
return "", errors.WithStack(err)
}
return TenantID(uuid.String()), nil
}
type Tenant struct {
ID TenantID `json:"id"`
Label string `json:"label"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@ -1,50 +0,0 @@
package datastore
import "context"
type TenantRepository interface {
Create(ctx context.Context, label string) (*Tenant, error)
Get(ctx context.Context, id TenantID) (*Tenant, error)
Update(ctx context.Context, id TenantID, updates ...TenantUpdateOptionFunc) (*Tenant, error)
Delete(ctx context.Context, id TenantID) error
Query(ctx context.Context, opts ...TenantQueryOptionFunc) ([]*Tenant, int, error)
}
type TenantUpdateOptionFunc func(*TenantUpdateOptions)
type TenantUpdateOptions struct {
Label *string
}
func WithTenantUpdateLabel(label string) TenantUpdateOptionFunc {
return func(opts *TenantUpdateOptions) {
opts.Label = &label
}
}
type TenantQueryOptionFunc func(*TenantQueryOptions)
type TenantQueryOptions struct {
Limit *int
Offset *int
IDs []TenantID
}
func WithTenantQueryLimit(limit int) TenantQueryOptionFunc {
return func(opts *TenantQueryOptions) {
opts.Limit = &limit
}
}
func WithTenantQueryOffset(offset int) TenantQueryOptionFunc {
return func(opts *TenantQueryOptions) {
opts.Offset = &offset
}
}
func WithTenantQueryID(ids ...TenantID) TenantQueryOptionFunc {
return func(opts *TenantQueryOptions) {
opts.IDs = ids
}
}

Some files were not shown because too many files have changed in this diff Show More