Compare commits

...

19 Commits

Author SHA1 Message Date
85ccf2e1df feat: agent specifications query and get endpoints
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-13 16:07:16 +01:00
cec5c783fe fix(migration): disable foreign keys for spec version migration
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-13 09:21:50 +01:00
b5c36f1f17 Merge pull request 'API d’introspection des définitions de spécifications' (#23) from spec-def-api into master
All checks were successful
arcad/emissary/pipeline/head This commit looks good
Reviewed-on: #23
2024-03-12 16:25:49 +01:00
f612721b4e feat: add spec definition api with versioning
All checks were successful
arcad/emissary/pipeline/head This commit looks good
arcad/emissary/pipeline/pr-master This commit looks good
2024-03-12 16:22:35 +01:00
0b34b485da feat(server): assert agent is accepted for api operations
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-04 19:03:17 +01:00
ab08d30d2a feat(server): allow registering renewal for forgotten agents
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-04 18:52:19 +01:00
f6ffb68c43 feat(client): show response body on json parsing error
Some checks reported errors
arcad/emissary/pipeline/head Something is wrong with the build of this commit
2024-03-04 18:51:36 +01:00
4a1a434556 fix(migrations): disable foreign keys for migrating tenants
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-04 09:09:44 +01:00
76718722cc feat(server): add /api/v1/session endpoint
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2024-03-03 18:40:56 +01:00
8f2131338d Merge pull request 'Page de statut + enrôlement sur l'agent' (#22) from issue-21 into master
All checks were successful
arcad/emissary/pipeline/head This commit looks good
Reviewed-on: #22
2024-03-01 11:41:44 +01:00
56558d7241 feat(agent): add status controller 2024-03-01 11:19:03 +01:00
eee7e60a86 Merge pull request 'Resources segregation by tenant' (#20) from tenant into master
Some checks failed
arcad/emissary/pipeline/head There was a failure building this commit
Reviewed-on: #20
2024-02-29 15:33:29 +01:00
954597d241 feat: tenants querying
All checks were successful
arcad/emissary/pipeline/pr-master This commit looks good
2024-02-27 17:01:24 +01:00
e0cde4519f doc: update documentation with latest changes 2024-02-27 16:24:40 +01:00
8438c4bc1a feat: add delete tenant command 2024-02-27 15:30:21 +01:00
df1a586d38 doc: add quickstart to readme 2024-02-27 15:15:25 +01:00
c851a1f51b feat(client): tenant management commands 2024-02-27 14:14:30 +01:00
15a0bf6ecc feat: refactor api mount 2024-02-27 09:56:15 +01:00
ca4211daef feat: resources segregation by tenant
All checks were successful
arcad/emissary/pipeline/head This commit looks good
arcad/emissary/pipeline/pr-master This commit looks good
2024-02-26 18:20:40 +01:00
175 changed files with 5638 additions and 1650 deletions

2
.gitignore vendored
View File

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

View File

@ -15,6 +15,13 @@ OPENWRT_DEVICE ?= 192.168.1.1
watch: deps ## Watching updated files - live reload 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 ) ( 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 .PHONY: test
test: test-go ## Executing tests test: test-go ## Executing tests
@ -122,16 +129,25 @@ gitea-release: .mktools tools/gitea-release/bin/gitea-release.sh goreleaser chan
GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \ GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
tools/gitea-release/bin/gitea-release.sh tools/gitea-release/bin/gitea-release.sh
.emissary-token: .emissary-tenant: .emissary-admin-token
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-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)"
AGENT_ID ?= 1 AGENT_ID ?= 1
load-sample-specs: claim-agent: .emissary-token
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 $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint)"
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 load-sample-specs: .emissary-token
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 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"
version: .mktools version: .mktools
@echo $(MKT_PROJECT_VERSION) @echo $(MKT_PROJECT_VERSION)

View File

@ -6,6 +6,40 @@ Control plane for "edge" (and OpenWRT-based) devices.
> ⚠ Emissary is currently in a very alpha stage ! Expect breaking changes... > ⚠ 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 ## Install
### Manually ### Manually

View File

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

View File

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

View File

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

27
doc/others/fr/auth.md Normal file
View File

@ -0,0 +1,27 @@
# 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

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

@ -80,13 +80,13 @@ Via la spécification [`uci.emissary.cadoles.com`](../../../internal/spec/uci/sc
AGENT_THUMBPRINT="<empreinte agent>" AGENT_THUMBPRINT="<empreinte agent>"
# Récupérer l'identifiant de l'agent # Récupérer l'identifiant de l'agent
AGENT_ID=$(emissary api agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id') AGENT_ID=$(emissary client agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id')
``` ```
2. Assigner la spécification à l'agent UCI: 2. Assigner la spécification à l'agent UCI:
```bash ```bash
cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com cat my-uci-spec.json | emissary client 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 !** **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: 2. Mettre à jour la configuration de l'agent:
```bash ```bash
cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com cat my-uci-spec.json | emissary client 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: 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,15 +80,31 @@
5. Créer un jeton d'administration: 5. Créer un jeton d'administration:
```shell ```shell
sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --subject $(whoami) sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role admin -o "$HOME/.config/emissary/admin-token"
``` ```
> **Note** Le jeton sera stocké dans le répertoire `$HOME/.config/emissary`. > **Note** Le jeton sera stocké dans le répertoire `$HOME/.config/emissary`.
6. Vérifier l'authentification sur l'API: 6. Créer un nouveau `tenant`:
```shell ```shell
emissary api agent query 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
``` ```
Une réponse équivalente à la suivante devrait s'afficher: Une réponse équivalente à la suivante devrait s'afficher:
@ -128,10 +144,18 @@
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"} 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"}
``` ```
3. Sur le serveur, vérifier que l'agent a pu s'enregistrer: 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:
```shell ```shell
emissary api agent query emissary client agent claim --agent-thumbprint $AGENT_THUMBPRINT
``` ```
Un message de ce type devrait s'afficher: Un message de ce type devrait s'afficher:
@ -144,12 +168,12 @@
+----+-------+-----------------------------------+--------+-----------------------------------+-----------------------------------+ +----+-------+-----------------------------------+--------+-----------------------------------+-----------------------------------+
``` ```
Noter l'identifiant associé à l'agent. Noter la valeur de l'identifiant affiché dans la colonne `ID`. Il sera identifié comme `$AGENT_ID` dans les étapes suivantes.
4. Mettre à jour le statut de l'agent afin qu'il soit en capacité à récupérer ses spécifications: 4. Mettre à jour le statut de l'agent afin qu'il soit en capacité à récupérer ses spécifications:
``` ```
emissary api agent update --agent-id <agent_id> --status 1 emissary client agent update --agent-id $AGENT_ID --status 1
``` ```
**Bravo, vous avez appairé votre premier agent et son serveur Emissary !** **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/pkg/errors v0.9.1
github.com/qri-io/jsonschema v0.2.1 github.com/qri-io/jsonschema v0.2.1
github.com/urfave/cli/v2 v2.26.0 github.com/urfave/cli/v2 v2.26.0
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.21.0 modernc.org/sqlite v1.21.0
) )
@ -78,7 +78,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@ -123,4 +123,4 @@ require (
) )
// replace forge.cadoles.com/arcad/edge => ../edge // replace forge.cadoles.com/arcad/edge => ../edge
replace github.com/allegro/bigcache/v3 v3.1.0 => github.com/Bornholm/bigcache v0.0.0-20231201111725-1ddf51584cad // replace gitlab.com/wpetit/goweb => ../goweb

4
go.sum
View File

@ -702,6 +702,8 @@ 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.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 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.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.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@ -1317,6 +1319,8 @@ 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/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 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA=
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY= 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.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/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= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=

View File

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

View File

@ -11,6 +11,7 @@ type contextKey string
const ( const (
contextKeyClient contextKey = "client" contextKeyClient contextKey = "client"
contextKeyThumbprint contextKey = "thumbprint"
) )
func withClient(ctx context.Context, client *client.Client) context.Context { func withClient(ctx context.Context, client *client.Client) context.Context {
@ -25,3 +26,16 @@ func Client(ctx context.Context) *client.Client {
return 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 { func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
appSpec := spec.NewSpec() appSpec := spec.NewSpec()
if err := state.GetSpec(spec.Name, appSpec); err != nil { if err := state.GetSpec(spec.Name, spec.Version, appSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find app spec") logger.Info(ctx, "could not find app spec")
@ -50,7 +50,12 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", appSpec.SpecName()), logger.F("revision", appSpec.SpecRevision())) logger.Info(
ctx, "retrieved spec",
logger.F("name", appSpec.SpecDefinitionName()),
logger.F("version", appSpec.SpecDefinitionVersion()),
logger.F("revision", appSpec.SpecRevision()),
)
c.updateApps(ctx, appSpec) c.updateApps(ctx, appSpec)

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(Name, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { 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 { func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
mdnsSpec := mdns.NewSpec() mdnsSpec := mdns.NewSpec()
if err := state.GetSpec(mdns.Name, mdnsSpec); err != nil { if err := state.GetSpec(mdns.Name, mdns.Version, mdnsSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find mdns spec") logger.Info(ctx, "could not find mdns spec")
@ -45,7 +45,11 @@ func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", mdnsSpec.SpecName()), logger.F("revision", mdnsSpec.SpecRevision())) logger.Info(ctx, "retrieved spec",
logger.F("name", mdnsSpec.SpecDefinitionName()),
logger.F("version", mdnsSpec.SpecDefinitionVersion()),
logger.F("revision", mdnsSpec.SpecRevision()),
)
if err := c.updateResponder(ctx, mdnsSpec); err != nil { if err := c.updateResponder(ctx, mdnsSpec); err != nil {
return errors.Wrap(err, "could not update responder") return errors.Wrap(err, "could not update responder")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import (
"io/ioutil" "io/ioutil"
"testing" "testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
"forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/internal/spec"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
func TestValidator(t *testing.T) { func TestValidator(t *testing.T) {
t.Parallel() t.Parallel()
validator := spec.NewValidator() ctx := context.Background()
if err := validator.Register(Name, schema); err != nil {
repo := memory.NewSpecDefinitionRepository()
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
t.Fatalf("+%v", errors.WithStack(err)) t.Fatalf("+%v", errors.WithStack(err))
} }
validator := spec.NewValidator(repo)
for _, tc := range validatorTestCases { for _, tc := range validatorTestCases {
func(tc validatorTestCase) { func(tc validatorTestCase) {
t.Run(tc.Name, func(t *testing.T) { 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 { func (c *SysUpgradeController) Reconcile(ctx context.Context, state *agent.State) error {
sysSpec := sysupgrade.NewSpec() sysSpec := sysupgrade.NewSpec()
if err := state.GetSpec(sysupgrade.Name, sysSpec); err != nil { if err := state.GetSpec(sysupgrade.Name, sysupgrade.Version, sysSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find sysupgrade spec, doing nothing") 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 { func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error {
uciSpec := ucispec.NewSpec() uciSpec := ucispec.NewSpec()
if err := state.GetSpec(ucispec.NameUCI, uciSpec); err != nil { if err := state.GetSpec(ucispec.Name, ucispec.Version, uciSpec); err != nil {
if errors.Is(err, agent.ErrSpecNotFound) { if errors.Is(err, agent.ErrSpecNotFound) {
logger.Info(ctx, "could not find uci spec, doing nothing") logger.Info(ctx, "could not find uci spec, doing nothing")
@ -37,7 +37,11 @@ func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error
return errors.WithStack(err) return errors.WithStack(err)
} }
logger.Info(ctx, "retrieved spec", logger.F("spec", uciSpec.SpecName()), logger.F("revision", uciSpec.SpecRevision())) logger.Info(ctx, "retrieved spec",
logger.F("name", uciSpec.SpecDefinitionName()),
logger.F("version", uciSpec.SpecDefinitionVersion()),
logger.F("revision", uciSpec.SpecRevision()),
)
if c.currentSpecRevision == uciSpec.SpecRevision() { if c.currentSpecRevision == uciSpec.SpecRevision() {
logger.Info(ctx, "spec revision did not change, doing nothing") logger.Info(ctx, "spec revision did not change, doing nothing")

View File

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

View File

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

View File

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

View File

@ -0,0 +1,138 @@
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

@ -0,0 +1,74 @@
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.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"encoding/json"
"fmt" "fmt"
"forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth"
@ -16,8 +17,31 @@ func (u *User) Subject() string {
return fmt.Sprintf("agent-%d", u.agent.ID) 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 { func (u *User) Agent() *datastore.Agent {
return u.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{} var _ auth.User = &User{}

8
internal/auth/error.go Normal file
View File

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

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
@ -29,10 +30,9 @@ func CtxUser(ctx context.Context) (User, error) {
return user, nil return user, nil
} }
var ErrUnauthenticated = errors.New("unauthenticated")
type User interface { type User interface {
Subject() string Subject() string
Tenant() datastore.TenantID
} }
type Authenticator interface { type Authenticator interface {
@ -49,11 +49,12 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
err error err error
) )
var errs []error
for _, auth := range authenticators { for _, auth := range authenticators {
user, err = auth.Authenticate(ctx, r) user, err = auth.Authenticate(ctx, r)
if err != nil { if err != nil {
logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err))) errs = append(errs, errors.WithStack(err))
continue continue
} }
@ -63,9 +64,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
} }
if user == nil { if user == nil {
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil) hasUnauthorized, hasUnauthenticated, hasUnknown := checkErrors(errs)
switch {
case hasUnauthorized && !hasUnknown:
api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil)
return 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 = logger.With(ctx, logger.F("user", user.Subject()))
@ -77,3 +91,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
return http.HandlerFunc(fn) 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,32 +0,0 @@
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,4 +1,4 @@
package thirdparty package user
import ( import (
"context" "context"
@ -7,9 +7,11 @@ import (
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk" "forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/lestrrat-go/jwx/v2/jwt" "github.com/lestrrat-go/jwx/v2/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
) )
const DefaultAcceptableSkew = 5 * time.Minute const DefaultAcceptableSkew = 5 * time.Minute
@ -17,11 +19,13 @@ const DefaultAcceptableSkew = 5 * time.Minute
type ( type (
GetKeySet func(context.Context) (jwk.Set, error) GetKeySet func(context.Context) (jwk.Set, error)
GetTokenRole func(context.Context, jwt.Token) (string, error) GetTokenRole func(context.Context, jwt.Token) (string, error)
GetTokenTenant func(context.Context, jwt.Token) (string, error)
) )
type Authenticator struct { type Authenticator struct {
getKeySet GetKeySet getKeySet GetKeySet
getTokenRole GetTokenRole getTokenRole GetTokenRole
getTokenTenant GetTokenTenant
acceptableSkew time.Duration acceptableSkew time.Duration
} }
@ -44,29 +48,45 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew) token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew)
if err != nil { if err != nil {
return nil, errors.WithStack(err) logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
rawRole, err := a.getTokenRole(ctx, token) rawRole, err := a.getTokenRole(ctx, token)
if err != nil { if err != nil {
return nil, errors.WithStack(err) logger.Debug(ctx, "could not retrieve token role", logger.CapturedE(errors.WithStack(err)))
return nil, errors.WithStack(auth.ErrUnauthenticated)
} }
if !isValidRole(rawRole) { if !isValidRole(rawRole) {
return nil, errors.Errorf("invalid role '%s'", 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)
} }
user := &User{ user := &User{
subject: token.Subject(), subject: token.Subject(),
role: Role(rawRole), role: Role(rawRole),
tenantID: tenantID,
} }
return user, nil return user, nil
} }
func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, acceptableSkew time.Duration) *Authenticator { func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, getTokenTenant GetTokenTenant, acceptableSkew time.Duration) *Authenticator {
return &Authenticator{ return &Authenticator{
getTokenRole: getTokenRole, getTokenRole: getTokenRole,
getTokenTenant: getTokenTenant,
getKeySet: getKeySet, getKeySet: getKeySet,
acceptableSkew: acceptableSkew, acceptableSkew: acceptableSkew,
} }

View File

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

View File

@ -0,0 +1,60 @@
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,6 +10,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/persistence" "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/proxy"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/spec" "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"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo" "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo"
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/shell" "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/shell"
@ -94,6 +95,15 @@ 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) key, err := jwk.LoadOrGenerate(string(conf.Agent.PrivateKeyPath), jwk.DefaultKeySize)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
@ -104,6 +114,10 @@ func RunCommand() *cli.Command {
return errors.WithStack(err) 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 := createShellCollectors(&conf.Agent)
collectors = append(collectors, buildinfo.NewCollector()) collectors = append(collectors, buildinfo.NewCollector())

View File

@ -1,16 +0,0 @@
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

@ -0,0 +1,51 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,14 +3,13 @@ package spec
import ( import (
"os" "os"
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag"
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr"
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
"forge.cadoles.com/Cadoles/emissary/internal/format"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
"forge.cadoles.com/Cadoles/emissary/pkg/client" "forge.cadoles.com/Cadoles/emissary/pkg/client"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/cli/format"
) )
func DeleteCommand() *cli.Command { func DeleteCommand() *cli.Command {
@ -23,6 +22,11 @@ func DeleteCommand() *cli.Command {
Name: "spec-name", Name: "spec-name",
Usage: "use `NAME` as specification's name", Usage: "use `NAME` as specification's name",
}, },
&cli.StringFlag{
Name: "spec-version",
Usage: "use `VERSION` as specification's version",
Value: "0.0.0",
},
), ),
Action: func(ctx *cli.Context) error { Action: func(ctx *cli.Context) error {
baseFlags := clientFlag.GetBaseFlags(ctx) baseFlags := clientFlag.GetBaseFlags(ctx)
@ -37,14 +41,19 @@ func DeleteCommand() *cli.Command {
return errors.WithStack(err) return errors.WithStack(err)
} }
specName, err := assertSpecName(ctx) specDefName, err := assertSpecDefName(ctx)
if err != nil {
return errors.WithStack(err)
}
specDefVersion, err := assertSpecDefVersion(ctx)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
client := client.New(baseFlags.ServerURL, client.WithToken(token)) client := client.New(baseFlags.ServerURL, client.WithToken(token))
specName, err = client.DeleteAgentSpec(ctx.Context, agentID, specName) specDefName, specDefVersion, err = client.DeleteAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
if err != nil { if err != nil {
return errors.WithStack(apierr.Wrap(err)) return errors.WithStack(apierr.Wrap(err))
} }
@ -54,9 +63,11 @@ func DeleteCommand() *cli.Command {
} }
if err := format.Write(baseFlags.Format, os.Stdout, hints, struct { if err := format.Write(baseFlags.Format, os.Stdout, hints, struct {
Name spec.Name `json:"name"` Name string `json:"name"`
Version string `json:"version"`
}{ }{
Name: specName, Name: specDefName,
Version: specDefVersion,
}); err != nil { }); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }

View File

@ -0,0 +1,68 @@
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

@ -0,0 +1,35 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,56 @@
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

@ -0,0 +1,37 @@
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

@ -0,0 +1,49 @@
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

@ -0,0 +1,18 @@
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

@ -0,0 +1,63 @@
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

@ -0,0 +1,19 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,166 @@
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

@ -0,0 +1,14 @@
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,23 +2,33 @@ package datastore
import ( import (
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/spec"
) )
type SpecID int64 type SpecID int64
type Spec struct { type SpecHeader struct {
ID SpecID `json:"id"` ID SpecID `json:"id"`
Name string `json:"name"` DefinitionName string `json:"name"`
Data map[string]any `json:"data"` DefinitionVersion string `json:"version"`
Revision int `json:"revision"` Revision int `json:"revision"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
TenantID TenantID `json:"tenantId"`
AgentID AgentID `json:"agentId"`
} }
func (s *Spec) SpecName() spec.Name { type Spec struct {
return spec.Name(s.Name) 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) SpecRevision() int { func (s *Spec) SpecRevision() int {

View File

@ -0,0 +1,18 @@
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

@ -0,0 +1,46 @@
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,7 +5,6 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/datastore"
@ -16,12 +15,120 @@ import (
) )
type AgentRepository struct { type AgentRepository struct {
db *sql.DB repository
sqliteBusyRetryMaxAttempts int }
// 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
} }
// DeleteSpec implements datastore.AgentRepository. // DeleteSpec implements datastore.AgentRepository.
func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string) error { func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string, version string) error {
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID) exists, err := r.agentExists(ctx, tx, agentID)
if err != nil { if err != nil {
@ -32,9 +139,9 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
return errors.WithStack(datastore.ErrNotFound) return errors.WithStack(datastore.ErrNotFound)
} }
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2` query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2 AND version = $3`
if _, err = tx.ExecContext(ctx, query, agentID, name); err != nil { if _, err = tx.ExecContext(ctx, query, agentID, name, version); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -47,9 +154,44 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
return nil return nil
} }
// GetSpec implements datastore.AgentRepository.
func (r *AgentRepository) GetSpec(ctx context.Context, agentID datastore.AgentID, name string, version string) (*datastore.Spec, error) {
var spec datastore.Spec
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID)
if err != nil {
return errors.WithStack(err)
}
if !exists {
return errors.WithStack(datastore.ErrNotFound)
}
query := `SELECT id, name, version, revision, data, created_at, updated_at, agent_id, tenant_id FROM specs WHERE agent_id = $1 AND name = $2 AND version = $3`
row := tx.QueryRowContext(ctx, query, agentID, name, version)
var data JSONMap
if err := row.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &spec.TenantID); err != nil {
return errors.WithStack(err)
}
spec.Data = data
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return &spec, nil
}
// GetSpecs implements datastore.AgentRepository. // GetSpecs implements datastore.AgentRepository.
func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) { func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.SpecHeader, error) {
specs := make([]*datastore.Spec, 0) specs := make([]*datastore.SpecHeader, 0)
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID) exists, err := r.agentExists(ctx, tx, agentID)
@ -62,7 +204,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
} }
query := ` query := `
SELECT id, name, revision, data, created_at, updated_at SELECT id, name, version, revision, created_at, updated_at, agent_id, tenant_id
FROM specs FROM specs
WHERE agent_id = $1 WHERE agent_id = $1
` `
@ -80,15 +222,16 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
}() }()
for rows.Next() { for rows.Next() {
spec := &datastore.Spec{} spec := &datastore.SpecHeader{}
data := JSONMap{} var tenantID sql.NullString
if err := rows.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &tenantID); err != nil {
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
spec.Data = data if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String)
}
specs = append(specs, spec) specs = append(specs, spec)
} }
@ -107,7 +250,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
} }
// UpdateSpec implements datastore.AgentRepository. // UpdateSpec implements datastore.AgentRepository.
func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.AgentID, name string, revision int, data map[string]any) (*datastore.Spec, error) { func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.AgentID, name string, version string, revision int, data map[string]any) (*datastore.Spec, error) {
spec := &datastore.Spec{} spec := &datastore.Spec{}
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
@ -123,23 +266,24 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
now := time.Now().UTC() now := time.Now().UTC()
query := ` query := `
INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at) INSERT INTO specs (agent_id, name, version, revision, data, created_at, updated_at, tenant_id)
VALUES($1, $2, $3, $4, $5, $5) VALUES($1, $2, $3, $4, $5, $6, $6, ( SELECT tenant_id FROM agents WHERE id = $1 ))
ON CONFLICT (agent_id, name) DO UPDATE SET ON CONFLICT (agent_id, name, version) DO UPDATE SET
data = $4, updated_at = $5, revision = specs.revision + 1 data = $5, updated_at = $6, revision = specs.revision + 1, tenant_id = ( SELECT tenant_id FROM agents WHERE id = $1 )
WHERE revision = $3 WHERE revision = $4
RETURNING "id", "name", "revision", "data", "created_at", "updated_at" RETURNING "id", "name", "version", "revision", "data", "created_at", "updated_at", "tenant_id", "agent_id"
` `
args := []any{agentID, name, revision, JSONMap(data), now} args := []any{agentID, name, version, revision, JSONMap(data), now}
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
row := tx.QueryRowContext(ctx, query, args...) row := tx.QueryRowContext(ctx, query, args...)
data := JSONMap{} data := JSONMap{}
var tenantID sql.NullString
err = row.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt) err = row.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &tenantID, &spec.AgentID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrUnexpectedRevision) return errors.WithStack(datastore.ErrUnexpectedRevision)
@ -148,6 +292,10 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
return errors.WithStack(err) return errors.WithStack(err)
} }
if tenantID.Valid {
spec.TenantID = datastore.TenantID(tenantID.String)
}
spec.Data = data spec.Data = data
return nil return nil
@ -170,7 +318,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
count := 0 count := 0
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at FROM agents` query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at, tenant_id FROM agents`
limit := 10 limit := 10
if options.Limit != nil { if options.Limit != nil {
@ -193,6 +341,17 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
args = append(args, newArgs...) 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 options.Thumbprints != nil && len(options.Thumbprints) > 0 {
if filters != "" { if filters != "" {
filters += " AND " filters += " AND "
@ -240,7 +399,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
metadata := JSONMap{} metadata := JSONMap{}
contactedAt := sql.NullTime{} contactedAt := sql.NullTime{}
if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -293,7 +452,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
query = ` query = `
INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at) INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at)
VALUES($1, $2, $3, $4, $5, $5) VALUES($1, $2, $3, $4, $5, $5)
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at" RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
` `
rawKeySet, err := json.Marshal(keySet) rawKeySet, err := json.Marshal(keySet)
@ -308,7 +467,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
metadata := JSONMap{} metadata := JSONMap{}
err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt) err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -363,7 +522,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
err := r.withTxRetry(ctx, func(tx *sql.Tx) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
query := ` query := `
SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at" SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id"
FROM agents FROM agents
WHERE id = $1 WHERE id = $1
` `
@ -374,7 +533,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
contactedAt := sql.NullTime{} contactedAt := sql.NullTime{}
var rawKeySet []byte var rawKeySet []byte
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return datastore.ErrNotFound return datastore.ErrNotFound
} }
@ -476,7 +635,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
query += ` query += `
WHERE id = $1 WHERE id = $1
RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at" RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id"
` `
logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args))
@ -487,7 +646,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
contactedAt := sql.NullTime{} contactedAt := sql.NullTime{}
var rawKeySet []byte var rawKeySet []byte
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return datastore.ErrNotFound return datastore.ErrNotFound
} }
@ -536,109 +695,8 @@ func (r *AgentRepository) agentExists(ctx context.Context, tx *sql.Tx, agentID d
return true, nil 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 { func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentRepository {
return &AgentRepository{db, sqliteBusyRetryMaxAttempts} return &AgentRepository{repository{db, sqliteBusyRetryMaxAttempts}}
} }
var _ datastore.AgentRepository = &AgentRepository{} 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,6 +7,42 @@ import (
"github.com/pkg/errors" "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 type JSONMap map[string]any
func (j *JSONMap) Scan(value interface{}) error { func (j *JSONMap) Scan(value interface{}) error {

View File

@ -0,0 +1,97 @@
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

@ -0,0 +1,219 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,284 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,32 @@
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

@ -0,0 +1,50 @@
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