Compare commits
19 Commits
2024.1.12-
...
develop
Author | SHA1 | Date | |
---|---|---|---|
85ccf2e1df | |||
cec5c783fe | |||
b5c36f1f17 | |||
f612721b4e | |||
0b34b485da | |||
ab08d30d2a | |||
f6ffb68c43 | |||
4a1a434556 | |||
76718722cc | |||
8f2131338d | |||
56558d7241 | |||
eee7e60a86 | |||
954597d241 | |||
e0cde4519f | |||
8438c4bc1a | |||
df1a586d38 | |||
c851a1f51b | |||
15a0bf6ecc | |||
ca4211daef |
2
.gitignore
vendored
2
.gitignore
vendored
@ -10,6 +10,8 @@ dist/
|
||||
/apps
|
||||
/server-key.json
|
||||
/.emissary-token
|
||||
/.emissary-admin-token
|
||||
/.emissary-tenant
|
||||
/out
|
||||
.mktools/
|
||||
/CHANGELOG.md
|
||||
|
30
Makefile
30
Makefile
@ -15,6 +15,13 @@ OPENWRT_DEVICE ?= 192.168.1.1
|
||||
watch: deps ## Watching updated files - live reload
|
||||
( set -o allexport && source .env && set +o allexport && go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest )
|
||||
|
||||
clean:
|
||||
rm -f .emissary-*
|
||||
rm -f emissary.sqlite*
|
||||
rm -f server-key.json
|
||||
rm -f agent-key.json
|
||||
rm -f state.json
|
||||
|
||||
.PHONY: test
|
||||
test: test-go ## Executing tests
|
||||
|
||||
@ -122,16 +129,25 @@ gitea-release: .mktools tools/gitea-release/bin/gitea-release.sh goreleaser chan
|
||||
GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
|
||||
tools/gitea-release/bin/gitea-release.sh
|
||||
|
||||
.emissary-token:
|
||||
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token"
|
||||
.emissary-tenant: .emissary-admin-token
|
||||
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client tenant create --token-file .emissary-admin-token --tenant-label Dev -f json | jq -r '.[0].id' > .emissary-tenant"
|
||||
|
||||
.emissary-admin-token:
|
||||
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role admin --output .emissary-admin-token"
|
||||
|
||||
.emissary-token: .emissary-tenant
|
||||
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token --tenant $(shell cat .emissary-tenant)"
|
||||
|
||||
AGENT_ID ?= 1
|
||||
|
||||
load-sample-specs:
|
||||
cat misc/spec-samples/app.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com
|
||||
cat misc/spec-samples/proxy.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com
|
||||
cat misc/spec-samples/mdns.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com
|
||||
cat misc/spec-samples/uci.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com
|
||||
claim-agent: .emissary-token
|
||||
$(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint)"
|
||||
|
||||
load-sample-specs: .emissary-token
|
||||
cat misc/spec-samples/app.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com"
|
||||
cat misc/spec-samples/proxy.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com"
|
||||
cat misc/spec-samples/mdns.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com"
|
||||
cat misc/spec-samples/uci.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com"
|
||||
|
||||
version: .mktools
|
||||
@echo $(MKT_PROJECT_VERSION)
|
||||
|
34
README.md
34
README.md
@ -6,6 +6,40 @@ Control plane for "edge" (and OpenWRT-based) devices.
|
||||
|
||||
> ⚠ Emissary is currently in a very alpha stage ! Expect breaking changes...
|
||||
|
||||
## Quickstart
|
||||
|
||||
**Dependencies**
|
||||
- [Go >= 1.21](https://go.dev/)
|
||||
- `GNU Make`
|
||||
|
||||
```shell
|
||||
# Start server and a local agent
|
||||
make watch
|
||||
|
||||
# In an other terminal
|
||||
|
||||
# Create an admin token
|
||||
make .emissary-admin-token
|
||||
|
||||
# Create a new tenant
|
||||
make .emissary-tenant
|
||||
|
||||
# Create a new writer token for this tenant
|
||||
make .emissary-token
|
||||
|
||||
# Claim the agent for your newly created tenant
|
||||
make claim-agent
|
||||
|
||||
# Query your agents
|
||||
./bin/server client agent query
|
||||
|
||||
# Load sample specs for your agent
|
||||
make load-sample-specs
|
||||
|
||||
## Optional: reset your workspace
|
||||
make clean
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
### Manually
|
||||
|
@ -5,7 +5,7 @@ import (
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/client"
|
||||
|
||||
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/format"
|
||||
_ "forge.cadoles.com/Cadoles/emissary/internal/imports/spec"
|
||||
@ -20,5 +20,5 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), api.Root())
|
||||
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), client.Root())
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/imports/format"
|
||||
@ -21,5 +21,5 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), api.Root())
|
||||
command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), client.Root())
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
# Documentation
|
||||
|
||||
- (FR) - [Introduction](./fr/introduction.md)
|
||||
- (FR) - [Vue d'ensemble](./others/fr/overview.md)
|
||||
- (FR) - [Authentification et autorisation](./others/fr/auth.md)
|
||||
|
||||
## Tutorials
|
||||
|
||||
@ -9,15 +10,13 @@
|
||||
- (FR) - [Déployer une configuration UCI personnalisée sur un agent](./tutorials/fr/deploy-uci-configuration.md)
|
||||
- (FR) - [Démarrer un agent avec Docker](./tutorials/fr/docker-agent.md)
|
||||
|
||||
## References
|
||||
|
||||
### Specifications
|
||||
|
||||
- [Schéma `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json)
|
||||
- [Schéma `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json)
|
||||
- [Schéma `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json)
|
||||
- [Schéma `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json)
|
||||
- [Schéma `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json)
|
||||
- [Schema `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json)
|
||||
- [Schema `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json)
|
||||
- [Schema `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json)
|
||||
- [Schema `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json)
|
||||
- [Schema `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json)
|
||||
|
||||
### Configuration
|
||||
|
||||
|
27
doc/others/fr/auth.md
Normal file
27
doc/others/fr/auth.md
Normal 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`.
|
||||
|
@ -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.
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@ -80,13 +80,13 @@ Via la spécification [`uci.emissary.cadoles.com`](../../../internal/spec/uci/sc
|
||||
AGENT_THUMBPRINT="<empreinte agent>"
|
||||
|
||||
# Récupérer l'identifiant de l'agent
|
||||
AGENT_ID=$(emissary 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:
|
||||
|
||||
```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 !**
|
||||
@ -112,7 +112,7 @@ En intervenant directement sur notre spécification, il est possible de modifier
|
||||
2. Mettre à jour la configuration de l'agent:
|
||||
|
||||
```bash
|
||||
cat my-uci-spec.json | emissary 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:
|
||||
|
@ -80,15 +80,31 @@
|
||||
5. Créer un jeton d'administration:
|
||||
|
||||
```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`.
|
||||
|
||||
6. Vérifier l'authentification sur l'API:
|
||||
6. Créer un nouveau `tenant`:
|
||||
|
||||
```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:
|
||||
@ -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"}
|
||||
```
|
||||
|
||||
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
|
||||
emissary api agent query
|
||||
emissary client agent claim --agent-thumbprint $AGENT_THUMBPRINT
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
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 !**
|
||||
|
6
go.mod
6
go.mod
@ -28,7 +28,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/qri-io/jsonschema v0.2.1
|
||||
github.com/urfave/cli/v2 v2.26.0
|
||||
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07
|
||||
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.21.0
|
||||
)
|
||||
@ -78,7 +78,7 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@ -123,4 +123,4 @@ require (
|
||||
)
|
||||
|
||||
// replace forge.cadoles.com/arcad/edge => ../edge
|
||||
replace 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
4
go.sum
@ -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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
||||
@ -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/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88 h1:dsyRrmhp7fl/YaY1YIzz7lm9qfIFI5KpKNbXwuhTULA=
|
||||
gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88/go.mod h1:bg+TN16Rq2ygLQbB4VDSHQFNouAEzcy3AAutStehllA=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
|
@ -38,6 +38,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
client := client.New(a.serverURL, client.WithToken(token))
|
||||
|
||||
ctx = withClient(ctx, client)
|
||||
ctx = withThumbprint(ctx, a.thumbprint)
|
||||
|
||||
tick := func() {
|
||||
logger.Debug(ctx, "registering agent")
|
||||
|
@ -10,7 +10,8 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
contextKeyClient contextKey = "client"
|
||||
contextKeyClient contextKey = "client"
|
||||
contextKeyThumbprint contextKey = "thumbprint"
|
||||
)
|
||||
|
||||
func withClient(ctx context.Context, client *client.Client) context.Context {
|
||||
@ -25,3 +26,16 @@ func Client(ctx context.Context) *client.Client {
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func withThumbprint(ctx context.Context, thumbprint string) context.Context {
|
||||
return context.WithValue(ctx, contextKeyThumbprint, thumbprint)
|
||||
}
|
||||
|
||||
func Thumbprint(ctx context.Context) string {
|
||||
thumbprint, ok := ctx.Value(contextKeyThumbprint).(string)
|
||||
if !ok {
|
||||
panic(errors.New("could not retrieve thumbprint from context"))
|
||||
}
|
||||
|
||||
return thumbprint
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ func (c *Controller) Name() string {
|
||||
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
appSpec := spec.NewSpec()
|
||||
|
||||
if err := state.GetSpec(spec.Name, appSpec); err != nil {
|
||||
if err := state.GetSpec(spec.Name, spec.Version, appSpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
var schema []byte
|
||||
|
||||
func init() {
|
||||
if err := spec.Register(Name, schema); err != nil {
|
||||
if err := spec.Register(string(Name), Version, schema); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
"title": "AppSpec",
|
||||
"description": "Emissary 'App' specification",
|
||||
@ -78,7 +78,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["defaultUrlTemplate"],
|
||||
"required": [
|
||||
"defaultUrlTemplate"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"unexpectedHostRedirect": {
|
||||
@ -94,7 +96,10 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["acceptedHostPatterns", "hostTarget"],
|
||||
"required": [
|
||||
"acceptedHostPatterns",
|
||||
"hostTarget"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"auth": {
|
||||
@ -104,7 +109,10 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": ["object", "string"]
|
||||
"type": [
|
||||
"object",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"signingAlgorithm": {
|
||||
"type": "string"
|
||||
|
@ -6,7 +6,10 @@ import (
|
||||
"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 {
|
||||
Revision int `json:"revision"`
|
||||
@ -56,10 +59,14 @@ type AppURLResolving struct {
|
||||
DefaultURLTemplate string `json:"defaultUrlTemplate"`
|
||||
}
|
||||
|
||||
func (s *Spec) SpecName() spec.Name {
|
||||
func (s *Spec) SpecDefinitionName() string {
|
||||
return Name
|
||||
}
|
||||
|
||||
func (s *Spec) SpecDefinitionVersion() string {
|
||||
return Version
|
||||
}
|
||||
|
||||
func (s *Spec) SpecRevision() int {
|
||||
return s.Revision
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
|
||||
func TestValidator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := spec.NewValidator()
|
||||
if err := validator.Register(Name, schema); err != nil {
|
||||
ctx := context.Background()
|
||||
|
||||
repo := memory.NewSpecDefinitionRepository()
|
||||
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
|
||||
t.Fatalf("+%v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
validator := spec.NewValidator(repo)
|
||||
|
||||
for _, tc := range validatorTestCases {
|
||||
func(tc validatorTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
|
@ -33,7 +33,7 @@ func (c *Controller) Name() string {
|
||||
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
mdnsSpec := mdns.NewSpec()
|
||||
|
||||
if err := state.GetSpec(mdns.Name, mdnsSpec); err != nil {
|
||||
if err := state.GetSpec(mdns.Name, mdns.Version, mdnsSpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return errors.Wrap(err, "could not update responder")
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
var schema []byte
|
||||
|
||||
func init() {
|
||||
if err := spec.Register(Name, schema); err != nil {
|
||||
if err := spec.Register(string(Name), Version, schema); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
"title": "MDNSSpec",
|
||||
"description": "Emissary 'MDNS' specification",
|
||||
|
@ -4,7 +4,10 @@ import (
|
||||
"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 {
|
||||
Revision int `json:"revision"`
|
||||
@ -19,10 +22,14 @@ type Service struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
func (s *Spec) SpecName() spec.Name {
|
||||
func (s *Spec) SpecDefinitionName() string {
|
||||
return Name
|
||||
}
|
||||
|
||||
func (s *Spec) SpecDefinitionVersion() string {
|
||||
return Version
|
||||
}
|
||||
|
||||
func (s *Spec) SpecRevision() int {
|
||||
return s.Revision
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
|
||||
func TestValidator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := spec.NewValidator()
|
||||
if err := validator.Register(Name, schema); err != nil {
|
||||
ctx := context.Background()
|
||||
|
||||
repo := memory.NewSpecDefinitionRepository()
|
||||
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
|
||||
t.Fatalf("+%v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
validator := spec.NewValidator(repo)
|
||||
|
||||
for _, tc := range validatorTestCases {
|
||||
func(tc validatorTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
|
@ -11,7 +11,7 @@ import (
|
||||
var schema []byte
|
||||
|
||||
func init() {
|
||||
if err := spec.Register(Name, schema); err != nil {
|
||||
if err := spec.Register(string(Name), Version, schema); err != nil {
|
||||
panic(errors.WithStack(err))
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
"title": "SysUpgradeSpec",
|
||||
"description": "Emissary 'SysUpgrade' specification",
|
||||
@ -15,6 +15,10 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["url", "sha256sum", "version"],
|
||||
"required": [
|
||||
"url",
|
||||
"sha256sum",
|
||||
"version"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
@ -4,7 +4,10 @@ import (
|
||||
"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 {
|
||||
Revision int `json:"revision"`
|
||||
@ -13,10 +16,14 @@ type Spec struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Spec) SpecName() spec.Name {
|
||||
func (s *Spec) SpecDefinitionName() string {
|
||||
return Name
|
||||
}
|
||||
|
||||
func (s *Spec) SpecDefinitionVersion() string {
|
||||
return Version
|
||||
}
|
||||
|
||||
func (s *Spec) SpecRevision() int {
|
||||
return s.Revision
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore/memory"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -27,11 +28,15 @@ var validatorTestCases = []validatorTestCase{
|
||||
func TestValidator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := spec.NewValidator()
|
||||
if err := validator.Register(Name, schema); err != nil {
|
||||
ctx := context.Background()
|
||||
|
||||
repo := memory.NewSpecDefinitionRepository()
|
||||
if _, err := repo.Upsert(ctx, Name, Version, schema); err != nil {
|
||||
t.Fatalf("+%v", errors.WithStack(err))
|
||||
}
|
||||
|
||||
validator := spec.NewValidator(repo)
|
||||
|
||||
for _, tc := range validatorTestCases {
|
||||
func(tc validatorTestCase) {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
|
@ -31,7 +31,7 @@ func (*SysUpgradeController) Name() string {
|
||||
func (c *SysUpgradeController) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
sysSpec := sysupgrade.NewSpec()
|
||||
|
||||
if err := state.GetSpec(sysupgrade.Name, sysSpec); err != nil {
|
||||
if err := state.GetSpec(sysupgrade.Name, sysupgrade.Version, sysSpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
logger.Info(ctx, "could not find sysupgrade spec, doing nothing")
|
||||
|
||||
|
@ -27,7 +27,7 @@ func (*UCIController) Name() string {
|
||||
func (c *UCIController) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
uciSpec := ucispec.NewSpec()
|
||||
|
||||
if err := state.GetSpec(ucispec.NameUCI, uciSpec); err != nil {
|
||||
if err := state.GetSpec(ucispec.Name, ucispec.Version, uciSpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
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)
|
||||
}
|
||||
|
||||
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() {
|
||||
logger.Info(ctx, "spec revision did not change, doing nothing")
|
||||
|
@ -9,13 +9,12 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
trackedSpecRevisions map[spec.Name]int
|
||||
trackedSpecRevisions map[string]map[string]int
|
||||
filename string
|
||||
loaded bool
|
||||
}
|
||||
@ -78,36 +77,39 @@ func (c *Controller) specChanged(specs agent.Specs) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
for name, spec := range specs {
|
||||
trackedRevision, exists := c.trackedSpecRevisions[name]
|
||||
for name, specVersions := range specs {
|
||||
trackedSpecs, exists := c.trackedSpecRevisions[name]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
if trackedRevision != spec.SpecRevision() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for version, spec := range specVersions {
|
||||
trackedRevision, exists := trackedSpecs[version]
|
||||
if !exists {
|
||||
return true
|
||||
}
|
||||
|
||||
for trackedSpecName, trackedRevision := range c.trackedSpecRevisions {
|
||||
spec, exists := specs[trackedSpecName]
|
||||
if !exists {
|
||||
return true
|
||||
if trackedRevision != spec.SpecRevision() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if trackedRevision != spec.SpecRevision() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
c.trackedSpecRevisions[name] = spec.SpecRevision()
|
||||
for name, specVersions := range specs {
|
||||
if _, exists := c.trackedSpecRevisions[name]; !exists {
|
||||
c.trackedSpecRevisions[name] = make(map[string]int)
|
||||
}
|
||||
|
||||
for version, spec := range specVersions {
|
||||
c.trackedSpecRevisions[name][version] = spec.SpecRevision()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,7 +169,7 @@ func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -213,7 +215,7 @@ func (c *Controller) writeState(ctx context.Context, state *agent.State) error {
|
||||
func NewController(filename string) *Controller {
|
||||
return &Controller{
|
||||
filename: filename,
|
||||
trackedSpecRevisions: make(map[spec.Name]int),
|
||||
trackedSpecRevisions: make(map[string]map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,7 @@ func (c *Controller) Name() string {
|
||||
func (c *Controller) Reconcile(ctx context.Context, state *agent.State) error {
|
||||
proxySpec := spec.NewSpec()
|
||||
|
||||
if err := state.GetSpec(spec.NameProxy, proxySpec); err != nil {
|
||||
if err := state.GetSpec(spec.Name, spec.Version, proxySpec); err != nil {
|
||||
if errors.Is(err, agent.ErrSpecNotFound) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
@ -45,17 +45,29 @@ func (c *Controller) reconcileAgent(ctx context.Context, client *client.Client,
|
||||
return nil
|
||||
}
|
||||
|
||||
specs, err := client.GetAgentSpecs(ctx, agent.ID)
|
||||
specHeaders, err := client.QueryAgentSpecs(ctx, agent.ID)
|
||||
if err != nil {
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "could not retrieve agent specs", logger.CapturedE(err))
|
||||
logger.Error(ctx, "could not query agent specs", logger.CapturedE(err))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ClearSpecs()
|
||||
|
||||
for _, spec := range specs {
|
||||
for _, sh := range specHeaders {
|
||||
spec, err := client.GetAgentSpec(ctx, agent.ID, sh.DefinitionName, sh.DefinitionVersion)
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
ctx, "could not retrieve agent spec",
|
||||
logger.F("specName", sh.DefinitionName),
|
||||
logger.F("specVersion", sh.DefinitionVersion),
|
||||
logger.CapturedE(errors.WithStack(err)),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
state.SetSpec(spec)
|
||||
}
|
||||
|
||||
|
138
internal/agent/controller/status/controller.go
Normal file
138
internal/agent/controller/status/controller.go
Normal 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{}
|
74
internal/agent/controller/status/handler.go
Normal file
74
internal/agent/controller/status/handler.go
Normal 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{}
|
1
internal/agent/controller/status/public/bulma-0.9.4.min.css
vendored
Normal file
1
internal/agent/controller/status/public/bulma-0.9.4.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
internal/agent/controller/status/public/logo.png
Normal file
BIN
internal/agent/controller/status/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
2
internal/agent/controller/status/public/qrcode.min.js
vendored
Normal file
2
internal/agent/controller/status/public/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
145
internal/agent/controller/status/templates/index.html.gotpl
Normal file
145
internal/agent/controller/status/templates/index.html.gotpl
Normal 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>
|
@ -11,7 +11,7 @@ import (
|
||||
|
||||
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 {
|
||||
agentID datastore.AgentID
|
||||
@ -20,25 +20,33 @@ type State struct {
|
||||
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
specs: make(map[spec.Name]spec.Spec),
|
||||
specs: make(map[string]map[string]spec.Spec),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) MarshalJSON() ([]byte, error) {
|
||||
state := struct {
|
||||
ID datastore.AgentID `json:"agentId"`
|
||||
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
|
||||
ID datastore.AgentID `json:"agentId"`
|
||||
Specs map[string]map[string]*spec.RawSpec `json:"specs"`
|
||||
}{
|
||||
ID: s.agentID,
|
||||
Specs: func(specs map[spec.Name]spec.Spec) map[spec.Name]*spec.RawSpec {
|
||||
rawSpecs := make(map[spec.Name]*spec.RawSpec)
|
||||
Specs: func(specs map[string]map[string]spec.Spec) map[string]map[string]*spec.RawSpec {
|
||||
rawSpecs := make(map[string]map[string]*spec.RawSpec)
|
||||
|
||||
for name, sp := range specs {
|
||||
rawSpecs[name] = &spec.RawSpec{
|
||||
Name: sp.SpecName(),
|
||||
Revision: sp.SpecRevision(),
|
||||
Data: sp.SpecData(),
|
||||
for name, versions := range specs {
|
||||
if _, exists := rawSpecs[name]; !exists {
|
||||
rawSpecs[name] = make(map[string]*spec.RawSpec)
|
||||
}
|
||||
|
||||
for version, sp := range versions {
|
||||
rawSpecs[name][version] = &spec.RawSpec{
|
||||
DefinitionName: sp.SpecDefinitionName(),
|
||||
DefinitionVersion: sp.SpecDefinitionVersion(),
|
||||
Revision: sp.SpecRevision(),
|
||||
Data: sp.SpecData(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return rawSpecs
|
||||
@ -55,19 +63,24 @@ func (s *State) MarshalJSON() ([]byte, error) {
|
||||
|
||||
func (s *State) UnmarshalJSON(data []byte) error {
|
||||
state := struct {
|
||||
AgentID datastore.AgentID `json:"agentId"`
|
||||
Specs map[spec.Name]*spec.RawSpec `json:"specs"`
|
||||
AgentID datastore.AgentID `json:"agentId"`
|
||||
Specs map[string]map[string]*spec.RawSpec `json:"specs"`
|
||||
}{}
|
||||
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
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)
|
||||
|
||||
for name, raw := range rawSpecs {
|
||||
specs[name] = spec.Spec(raw)
|
||||
for name, versions := range rawSpecs {
|
||||
if _, exists := specs[name]; !exists {
|
||||
specs[name] = make(map[string]spec.Spec)
|
||||
}
|
||||
for version, raw := range versions {
|
||||
specs[name][version] = spec.Spec(raw)
|
||||
}
|
||||
}
|
||||
|
||||
return specs
|
||||
@ -85,23 +98,36 @@ func (s *State) Specs() Specs {
|
||||
}
|
||||
|
||||
func (s *State) ClearSpecs() *State {
|
||||
s.specs = make(map[spec.Name]spec.Spec)
|
||||
s.specs = make(map[string]map[string]spec.Spec)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *State) SetSpec(sp spec.Spec) *State {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *State) GetSpec(name spec.Name, dest any) error {
|
||||
spec, exists := s.specs[name]
|
||||
func (s *State) GetSpec(name string, version string, dest any) error {
|
||||
versions, exists := s.specs[name]
|
||||
if !exists {
|
||||
return errors.WithStack(ErrSpecNotFound)
|
||||
}
|
||||
|
||||
spec, exists := versions[version]
|
||||
if !exists {
|
||||
return errors.WithStack(ErrSpecNotFound)
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v2/jws"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const DefaultAcceptableSkew = 5 * time.Minute
|
||||
@ -34,17 +35,19 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
|
||||
|
||||
token, err := jwt.Parse([]byte(rawToken), jwt.WithVerify(false))
|
||||
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)
|
||||
if !exists {
|
||||
return nil, errors.Errorf("could not find '%s' claim", keyThumbprint)
|
||||
return nil, errors.WithStack(auth.ErrUnauthenticated)
|
||||
}
|
||||
|
||||
thumbrint, ok := rawThumbprint.(string)
|
||||
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(
|
||||
@ -57,7 +60,8 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
|
||||
}
|
||||
|
||||
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(
|
||||
@ -75,14 +79,15 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
|
||||
jwt.WithAcceptableSkew(a.acceptableSkew),
|
||||
)
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
return nil, errors.WithStack(auth.ErrUnauthenticated)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
|
@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/auth"
|
||||
@ -16,8 +17,31 @@ func (u *User) Subject() string {
|
||||
return fmt.Sprintf("agent-%d", u.agent.ID)
|
||||
}
|
||||
|
||||
// Subject implements auth.User
|
||||
func (u *User) Tenant() datastore.TenantID {
|
||||
if u.agent.TenantID == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *u.agent.TenantID
|
||||
}
|
||||
|
||||
func (u *User) Agent() *datastore.Agent {
|
||||
return u.agent
|
||||
}
|
||||
|
||||
func (u *User) MarshalJSON() ([]byte, error) {
|
||||
type user struct {
|
||||
Subject string `json:"subject"`
|
||||
Tenant string `json:"tenant"`
|
||||
}
|
||||
|
||||
jsonUser := user{
|
||||
Subject: u.Subject(),
|
||||
Tenant: string(u.Tenant()),
|
||||
}
|
||||
|
||||
return json.Marshal(jsonUser)
|
||||
}
|
||||
|
||||
var _ auth.User = &User{}
|
||||
|
8
internal/auth/error.go
Normal file
8
internal/auth/error.go
Normal file
@ -0,0 +1,8 @@
|
||||
package auth
|
||||
|
||||
import "github.com/pkg/errors"
|
||||
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New(("unauthorized"))
|
||||
)
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
@ -29,10 +30,9 @@ func CtxUser(ctx context.Context) (User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
var ErrUnauthenticated = errors.New("unauthenticated")
|
||||
|
||||
type User interface {
|
||||
Subject() string
|
||||
Tenant() datastore.TenantID
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
@ -49,11 +49,12 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
|
||||
err error
|
||||
)
|
||||
|
||||
var errs []error
|
||||
|
||||
for _, auth := range authenticators {
|
||||
user, err = auth.Authenticate(ctx, r)
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err)))
|
||||
|
||||
errs = append(errs, errors.WithStack(err))
|
||||
continue
|
||||
}
|
||||
|
||||
@ -63,9 +64,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil)
|
||||
hasUnauthorized, hasUnauthenticated, hasUnknown := checkErrors(errs)
|
||||
|
||||
return
|
||||
switch {
|
||||
case hasUnauthorized && !hasUnknown:
|
||||
api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil)
|
||||
return
|
||||
case hasUnauthenticated && !hasUnknown:
|
||||
api.ErrorResponse(w, http.StatusUnauthorized, api.ErrCodeUnauthorized, nil)
|
||||
return
|
||||
case hasUnknown:
|
||||
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
|
||||
return
|
||||
default:
|
||||
api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx = logger.With(ctx, logger.F("user", user.Subject()))
|
||||
@ -77,3 +91,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
func checkErrors(errs []error) (isUnauthorized bool, isUnauthenticated bool, isUnknown bool) {
|
||||
isUnauthenticated = false
|
||||
isUnauthorized = false
|
||||
isUnknown = false
|
||||
|
||||
for _, e := range errs {
|
||||
switch {
|
||||
case errors.Is(e, ErrUnauthorized):
|
||||
isUnauthorized = true
|
||||
case errors.Is(e, ErrUnauthenticated):
|
||||
isUnauthenticated = true
|
||||
default:
|
||||
isUnknown = true
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
32
internal/auth/thirdparty/user.go
vendored
32
internal/auth/thirdparty/user.go
vendored
@ -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{}
|
@ -1,4 +1,4 @@
|
||||
package thirdparty
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -7,21 +7,25 @@ import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/auth"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/pkg/errors"
|
||||
"gitlab.com/wpetit/goweb/logger"
|
||||
)
|
||||
|
||||
const DefaultAcceptableSkew = 5 * time.Minute
|
||||
|
||||
type (
|
||||
GetKeySet func(context.Context) (jwk.Set, error)
|
||||
GetTokenRole func(context.Context, jwt.Token) (string, error)
|
||||
GetKeySet func(context.Context) (jwk.Set, error)
|
||||
GetTokenRole func(context.Context, jwt.Token) (string, error)
|
||||
GetTokenTenant func(context.Context, jwt.Token) (string, error)
|
||||
)
|
||||
|
||||
type Authenticator struct {
|
||||
getKeySet GetKeySet
|
||||
getTokenRole GetTokenRole
|
||||
getTokenTenant GetTokenTenant
|
||||
acceptableSkew time.Duration
|
||||
}
|
||||
|
||||
@ -44,29 +48,45 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth
|
||||
|
||||
token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew)
|
||||
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)
|
||||
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) {
|
||||
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{
|
||||
subject: token.Subject(),
|
||||
role: Role(rawRole),
|
||||
subject: token.Subject(),
|
||||
role: Role(rawRole),
|
||||
tenantID: tenantID,
|
||||
}
|
||||
|
||||
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{
|
||||
getTokenRole: getTokenRole,
|
||||
getTokenTenant: getTokenTenant,
|
||||
getKeySet: getKeySet,
|
||||
acceptableSkew: acceptableSkew,
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
package thirdparty
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
|
||||
"github.com/lestrrat-go/jwx/v2/jwa"
|
||||
"github.com/lestrrat-go/jwx/v2/jws"
|
||||
@ -26,9 +27,12 @@ func parseToken(ctx context.Context, keys jwk.Set, rawToken string, acceptableSk
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if err := token.Set(DefaultTenantKey, tenant); err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
if err := token.Set(jwt.NotBeforeKey, now); err != nil {
|
60
internal/auth/user/user.go
Normal file
60
internal/auth/user/user.go
Normal 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{}
|
@ -10,6 +10,7 @@ import (
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/persistence"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/proxy"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/spec"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/status"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/buildinfo"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/agent/metadata/collector/shell"
|
||||
@ -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)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
@ -104,6 +114,10 @@ func RunCommand() *cli.Command {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
logger.SetLevel(logger.LevelInfo)
|
||||
logger.Info(ctx.Context, "agent thumbprint", logger.F("thumbprint", thumbprint))
|
||||
logger.SetLevel(logger.Level(conf.Logger.Level))
|
||||
|
||||
collectors := createShellCollectors(&conf.Agent)
|
||||
collectors = append(collectors, buildinfo.NewCollector())
|
||||
|
||||
|
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
51
internal/command/client/agent/claim.go
Normal file
51
internal/command/client/agent/claim.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
@ -3,12 +3,12 @@ package agent
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/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 CountCommand() *cli.Command {
|
@ -3,14 +3,14 @@ package agent
|
||||
import (
|
||||
"os"
|
||||
|
||||
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
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/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
"forge.cadoles.com/Cadoles/emissary/pkg/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func DeleteCommand() *cli.Command {
|
@ -3,7 +3,7 @@ package flag
|
||||
import (
|
||||
"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"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
@ -3,13 +3,13 @@ package agent
|
||||
import (
|
||||
"os"
|
||||
|
||||
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
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 {
|
@ -1,6 +1,9 @@
|
||||
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 {
|
||||
return format.Hints{
|
||||
@ -10,8 +13,8 @@ func agentHints(outputMode format.OutputMode) format.Hints {
|
||||
format.NewProp("Label", "Label"),
|
||||
format.NewProp("Thumbprint", "Thumbprint"),
|
||||
format.NewProp("Status", "Status"),
|
||||
format.NewProp("ContactedAt", "ContactedAt"),
|
||||
format.NewProp("UpdatedAt", "UpdatedAt"),
|
||||
format.NewProp("ContactedAt", "ContactedAt", table.WithCompactModeMaxColumnWidth(20)),
|
||||
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
|
||||
},
|
||||
}
|
||||
}
|
@ -3,13 +3,13 @@ package agent
|
||||
import (
|
||||
"os"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/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/internal/format"
|
||||
"forge.cadoles.com/Cadoles/emissary/pkg/client"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func QueryCommand() *cli.Command {
|
@ -1,7 +1,7 @@
|
||||
package agent
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@ -15,6 +15,7 @@ func Root() *cli.Command {
|
||||
UpdateCommand(),
|
||||
GetCommand(),
|
||||
DeleteCommand(),
|
||||
ClaimCommand(),
|
||||
spec.Root(),
|
||||
},
|
||||
}
|
@ -3,14 +3,13 @@ package spec
|
||||
import (
|
||||
"os"
|
||||
|
||||
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
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 DeleteCommand() *cli.Command {
|
||||
@ -23,6 +22,11 @@ func DeleteCommand() *cli.Command {
|
||||
Name: "spec-name",
|
||||
Usage: "use `NAME` as specification's name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spec-version",
|
||||
Usage: "use `VERSION` as specification's version",
|
||||
Value: "0.0.0",
|
||||
},
|
||||
),
|
||||
Action: func(ctx *cli.Context) error {
|
||||
baseFlags := clientFlag.GetBaseFlags(ctx)
|
||||
@ -37,14 +41,19 @@ func DeleteCommand() *cli.Command {
|
||||
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 {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
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 {
|
||||
return errors.WithStack(apierr.Wrap(err))
|
||||
}
|
||||
@ -54,9 +63,11 @@ func DeleteCommand() *cli.Command {
|
||||
}
|
||||
|
||||
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 {
|
||||
return errors.WithStack(err)
|
||||
}
|
68
internal/command/client/agent/spec/get.go
Normal file
68
internal/command/client/agent/spec/get.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
35
internal/command/client/agent/spec/hints.go
Normal file
35
internal/command/client/agent/spec/hints.go
Normal 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)),
|
||||
},
|
||||
}
|
||||
}
|
@ -3,19 +3,19 @@ package spec
|
||||
import (
|
||||
"os"
|
||||
|
||||
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
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 {
|
||||
func QueryCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "get",
|
||||
Usage: "Get agent specifications",
|
||||
Name: "query",
|
||||
Usage: "Query agent specifications",
|
||||
Flags: agentFlag.WithAgentFlags(),
|
||||
Action: func(ctx *cli.Context) error {
|
||||
baseFlags := clientFlag.GetBaseFlags(ctx)
|
||||
@ -31,14 +31,12 @@ func GetCommand() *cli.Command {
|
||||
|
||||
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 {
|
||||
return errors.WithStack(apierr.Wrap(err))
|
||||
}
|
||||
|
||||
hints := format.Hints{
|
||||
OutputMode: baseFlags.OutputMode,
|
||||
}
|
||||
hints := specHeaderHints(baseFlags.OutputMode)
|
||||
|
||||
if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(specs)...); err != nil {
|
||||
return errors.WithStack(err)
|
@ -10,6 +10,7 @@ func Root() *cli.Command {
|
||||
Usage: "Specifications related commands",
|
||||
Subcommands: []*cli.Command{
|
||||
GetCommand(),
|
||||
QueryCommand(),
|
||||
UpdateCommand(),
|
||||
DeleteCommand(),
|
||||
},
|
@ -4,15 +4,16 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
|
||||
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
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/internal/spec"
|
||||
"forge.cadoles.com/Cadoles/emissary/pkg/client"
|
||||
jsonpatch "github.com/evanphx/json-patch/v5"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/api"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
)
|
||||
|
||||
func UpdateCommand() *cli.Command {
|
||||
@ -24,6 +25,11 @@ func UpdateCommand() *cli.Command {
|
||||
Name: "spec-name",
|
||||
Usage: "use `NAME` as specification's name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spec-version",
|
||||
Usage: "use `VERSION` as specification's version",
|
||||
Value: "0.0.0",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "spec-data",
|
||||
Usage: "use `DATA` as specification's data, '-' to read from STDIN",
|
||||
@ -44,7 +50,12 @@ func UpdateCommand() *cli.Command {
|
||||
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 {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
@ -63,21 +74,12 @@ func UpdateCommand() *cli.Command {
|
||||
|
||||
client := client.New(baseFlags.ServerURL, client.WithToken(token))
|
||||
|
||||
specs, err := client.GetAgentSpecs(ctx.Context, agentID)
|
||||
existingSpec, err := client.GetAgentSpec(ctx.Context, agentID, specDefName, specDefVersion)
|
||||
if err != nil {
|
||||
return errors.WithStack(apierr.Wrap(err))
|
||||
}
|
||||
|
||||
var existingSpec spec.Spec
|
||||
|
||||
for _, s := range specs {
|
||||
if s.SpecName() != specName {
|
||||
continue
|
||||
var apiErr api.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Code != api.ErrCodeNotFound {
|
||||
return errors.WithStack(apierr.Wrap(err))
|
||||
}
|
||||
|
||||
existingSpec = s
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
revision := 0
|
||||
@ -100,13 +102,10 @@ func UpdateCommand() *cli.Command {
|
||||
}
|
||||
|
||||
rawSpec := &spec.RawSpec{
|
||||
Name: specName,
|
||||
Revision: revision,
|
||||
Data: specData,
|
||||
}
|
||||
|
||||
if err := spec.Validate(ctx.Context, rawSpec); err != nil {
|
||||
return errors.WithStack(apierr.Wrap(err))
|
||||
DefinitionName: specDefName,
|
||||
DefinitionVersion: specDefVersion,
|
||||
Revision: revision,
|
||||
Data: specData,
|
||||
}
|
||||
|
||||
spec, err := client.UpdateAgentSpec(ctx.Context, agentID, rawSpec)
|
||||
@ -114,9 +113,7 @@ func UpdateCommand() *cli.Command {
|
||||
return errors.WithStack(apierr.Wrap(err))
|
||||
}
|
||||
|
||||
hints := format.Hints{
|
||||
OutputMode: baseFlags.OutputMode,
|
||||
}
|
||||
hints := specHints(baseFlags.OutputMode)
|
||||
|
||||
if err := format.Write(baseFlags.Format, os.Stdout, hints, spec); err != nil {
|
||||
return errors.WithStack(err)
|
||||
@ -127,14 +124,24 @@ func UpdateCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func assertSpecName(ctx *cli.Context) (spec.Name, error) {
|
||||
specName := ctx.String("spec-name")
|
||||
func assertSpecDefName(ctx *cli.Context) (string, error) {
|
||||
specDefName := ctx.String("spec-name")
|
||||
|
||||
if specName == "" {
|
||||
if specDefName == "" {
|
||||
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) {
|
@ -3,13 +3,13 @@ package agent
|
||||
import (
|
||||
"os"
|
||||
|
||||
agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr"
|
||||
clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
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 UpdateCommand() *cli.Command {
|
@ -5,10 +5,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/format/table"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
"gitlab.com/wpetit/goweb/cli/format"
|
||||
"gitlab.com/wpetit/goweb/cli/format/table"
|
||||
)
|
||||
|
||||
const (
|
18
internal/command/client/root.go
Normal file
18
internal/command/client/root.go
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
51
internal/command/client/tenant/create.go
Normal file
51
internal/command/client/tenant/create.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
56
internal/command/client/tenant/delete.go
Normal file
56
internal/command/client/tenant/delete.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
37
internal/command/client/tenant/flag/flag.go
Normal file
37
internal/command/client/tenant/flag/flag.go
Normal 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
|
||||
}
|
49
internal/command/client/tenant/get.go
Normal file
49
internal/command/client/tenant/get.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
18
internal/command/client/tenant/hints.go
Normal file
18
internal/command/client/tenant/hints.go
Normal 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)),
|
||||
},
|
||||
}
|
||||
}
|
63
internal/command/client/tenant/query.go
Normal file
63
internal/command/client/tenant/query.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
19
internal/command/client/tenant/root.go
Normal file
19
internal/command/client/tenant/root.go
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
62
internal/command/client/tenant/update.go
Normal file
62
internal/command/client/tenant/update.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
@ -5,11 +5,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/api/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/auth/user"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/client/flag"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/command/common"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
@ -21,13 +22,18 @@ func CreateTokenCommand() *cli.Command {
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "role",
|
||||
Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []thirdparty.Role{thirdparty.RoleReader, thirdparty.RoleWriter}),
|
||||
Value: string(thirdparty.RoleReader),
|
||||
Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []user.Role{user.RoleReader, user.RoleWriter, user.RoleAdmin}),
|
||||
Value: string(user.RoleReader),
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "subject",
|
||||
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{
|
||||
Name: "output",
|
||||
@ -44,6 +50,7 @@ func CreateTokenCommand() *cli.Command {
|
||||
}
|
||||
|
||||
subject := ctx.String("subject")
|
||||
tenant := ctx.String("tenant")
|
||||
role := ctx.String("role")
|
||||
output := ctx.String("output")
|
||||
|
||||
@ -57,7 +64,7 @@ func CreateTokenCommand() *cli.Command {
|
||||
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 {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ type ControllersConfig struct {
|
||||
App AppControllerConfig `yaml:"app"`
|
||||
SysUpgrade SysUpgradeControllerConfig `yaml:"sysupgrade"`
|
||||
MDNS MDNSControllerConfig `yaml:"mdns"`
|
||||
Status StatusControllerConfig `yaml:"status"`
|
||||
}
|
||||
|
||||
type PersistenceControllerConfig struct {
|
||||
@ -60,6 +61,13 @@ type MDNSControllerConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type StatusControllerConfig struct {
|
||||
Enabled InterpolatedBool `yaml:"enabled"`
|
||||
Address InterpolatedString `yaml:"address"`
|
||||
ClaimURL InterpolatedString `yaml:"claimURL"`
|
||||
AgentURL InterpolatedString `yaml:"agentURL"`
|
||||
}
|
||||
|
||||
func NewDefaultAgentConfig() AgentConfig {
|
||||
return AgentConfig{
|
||||
ServerURL: "http://127.0.0.1:3000",
|
||||
@ -94,6 +102,12 @@ func NewDefaultAgentConfig() AgentConfig {
|
||||
MDNS: MDNSControllerConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Status: StatusControllerConfig{
|
||||
Enabled: true,
|
||||
Address: ":42521",
|
||||
ClaimURL: "http://localhost:3001/claim/%s",
|
||||
AgentURL: "http://localhost:3001/agents/%v",
|
||||
},
|
||||
},
|
||||
Collectors: []ShellCollectorConfig{
|
||||
{
|
||||
|
@ -3,7 +3,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty"
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/auth/user"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
@ -23,9 +23,10 @@ func NewDefaultServerConfig() ServerConfig {
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
Local *LocalAuthConfig `yaml:"local"`
|
||||
Remote *RemoteAuthConfig `yaml:"remote"`
|
||||
RoleExtractionRules []string `yaml:"roleExtractionRules"`
|
||||
Local *LocalAuthConfig `yaml:"local"`
|
||||
Remote *RemoteAuthConfig `yaml:"remote"`
|
||||
RoleExtractionRules []string `yaml:"roleExtractionRules"`
|
||||
TenantExtractionRules []string `yaml:"tenantExtractionRules"`
|
||||
}
|
||||
|
||||
func NewDefaultAuthConfig() AuthConfig {
|
||||
@ -35,7 +36,10 @@ func NewDefaultAuthConfig() AuthConfig {
|
||||
},
|
||||
Remote: nil,
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ type Agent struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ContactedAt *time.Time `json:"contactedAt,omitempty"`
|
||||
TenantID *TenantID `json:"tenantId"`
|
||||
}
|
||||
|
||||
type SerializableKeySet struct {
|
||||
|
@ -9,14 +9,19 @@ import (
|
||||
|
||||
type AgentRepository interface {
|
||||
Create(ctx context.Context, thumbprint string, keySet jwk.Set, metadata map[string]any) (*Agent, error)
|
||||
|
||||
Attach(ctx context.Context, tenantID TenantID, agentID AgentID) (*Agent, error)
|
||||
Detach(ctx context.Context, agentID AgentID) (*Agent, error)
|
||||
|
||||
Get(ctx context.Context, id AgentID) (*Agent, error)
|
||||
Update(ctx context.Context, id AgentID, updates ...AgentUpdateOptionFunc) (*Agent, error)
|
||||
Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error)
|
||||
Delete(ctx context.Context, id AgentID) error
|
||||
|
||||
UpdateSpec(ctx context.Context, id AgentID, name string, revision int, data map[string]any) (*Spec, error)
|
||||
GetSpecs(ctx context.Context, id AgentID) ([]*Spec, error)
|
||||
DeleteSpec(ctx context.Context, id AgentID, name string) error
|
||||
UpdateSpec(ctx context.Context, id AgentID, name string, version string, revision int, data map[string]any) (*Spec, error)
|
||||
QuerySpecs(ctx context.Context, id AgentID) ([]*SpecHeader, error)
|
||||
GetSpec(ctx context.Context, id AgentID, name string, version string) (*Spec, error)
|
||||
DeleteSpec(ctx context.Context, id AgentID, name string, version string) error
|
||||
}
|
||||
|
||||
type AgentQueryOptionFunc func(*AgentQueryOptions)
|
||||
@ -25,6 +30,7 @@ type AgentQueryOptions struct {
|
||||
Limit *int
|
||||
Offset *int
|
||||
IDs []AgentID
|
||||
TenantIDs []TenantID
|
||||
Thumbprints []string
|
||||
Metadata *map[string]any
|
||||
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 {
|
||||
return func(opts *AgentQueryOptions) {
|
||||
opts.Statuses = statuses
|
||||
@ -75,6 +87,13 @@ type AgentUpdateOptions struct {
|
||||
Metadata *map[string]any
|
||||
KeySet *jwk.Set
|
||||
Thumbprint *string
|
||||
TenantID *TenantID
|
||||
}
|
||||
|
||||
func WithAgentUpdateTenant(id TenantID) AgentUpdateOptionFunc {
|
||||
return func(opts *AgentUpdateOptions) {
|
||||
opts.TenantID = &id
|
||||
}
|
||||
}
|
||||
|
||||
func WithAgentUpdateStatus(status AgentStatus) AgentUpdateOptionFunc {
|
||||
|
@ -6,4 +6,5 @@ var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExist = errors.New("already exist")
|
||||
ErrUnexpectedRevision = errors.New("unexpected revision")
|
||||
ErrAlreadyAttached = errors.New("already attached")
|
||||
)
|
||||
|
166
internal/datastore/memory/spec_definition_repository.go
Normal file
166
internal/datastore/memory/spec_definition_repository.go
Normal 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{}
|
14
internal/datastore/memory/spec_definition_repository_test.go
Normal file
14
internal/datastore/memory/spec_definition_repository_test.go
Normal 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)
|
||||
}
|
@ -2,23 +2,33 @@ package datastore
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/spec"
|
||||
)
|
||||
|
||||
type SpecID int64
|
||||
|
||||
type Spec struct {
|
||||
ID SpecID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Data map[string]any `json:"data"`
|
||||
Revision int `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
type SpecHeader struct {
|
||||
ID SpecID `json:"id"`
|
||||
DefinitionName string `json:"name"`
|
||||
DefinitionVersion string `json:"version"`
|
||||
Revision int `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
TenantID TenantID `json:"tenantId"`
|
||||
AgentID AgentID `json:"agentId"`
|
||||
}
|
||||
|
||||
func (s *Spec) SpecName() spec.Name {
|
||||
return spec.Name(s.Name)
|
||||
type Spec struct {
|
||||
SpecHeader
|
||||
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func (s *Spec) SpecDefinitionName() string {
|
||||
return s.DefinitionName
|
||||
}
|
||||
|
||||
func (s *Spec) SpecDefinitionVersion() string {
|
||||
return s.DefinitionVersion
|
||||
}
|
||||
|
||||
func (s *Spec) SpecRevision() int {
|
||||
|
18
internal/datastore/spec_definition.go
Normal file
18
internal/datastore/spec_definition.go
Normal 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"`
|
||||
}
|
46
internal/datastore/spec_definition_repository.go
Normal file
46
internal/datastore/spec_definition_repository.go
Normal 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
|
||||
}
|
||||
}
|
@ -5,7 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
|
||||
@ -16,12 +15,120 @@ import (
|
||||
)
|
||||
|
||||
type AgentRepository struct {
|
||||
db *sql.DB
|
||||
sqliteBusyRetryMaxAttempts int
|
||||
repository
|
||||
}
|
||||
|
||||
// Attach implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantID, agentID datastore.AgentID) (*datastore.Agent, error) {
|
||||
var agent datastore.Agent
|
||||
|
||||
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT count(id), tenant_id FROM agents WHERE id = $1`
|
||||
row := tx.QueryRowContext(ctx, query, agentID)
|
||||
|
||||
var (
|
||||
count int
|
||||
attachedTenantID *datastore.TenantID
|
||||
)
|
||||
|
||||
if err := row.Scan(&count, &attachedTenantID); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return errors.WithStack(datastore.ErrNotFound)
|
||||
}
|
||||
|
||||
if attachedTenantID != nil {
|
||||
return errors.WithStack(datastore.ErrAlreadyAttached)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
query = `
|
||||
UPDATE agents SET tenant_id = $1, updated_at = $2 WHERE id = $3
|
||||
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
|
||||
`
|
||||
|
||||
row = tx.QueryRowContext(
|
||||
ctx, query,
|
||||
tenantID,
|
||||
now,
|
||||
agentID,
|
||||
)
|
||||
|
||||
metadata := JSONMap{}
|
||||
var rawKeySet []byte
|
||||
|
||||
err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
agent.Metadata = metadata
|
||||
|
||||
keySet, err := jwk.Parse(rawKeySet)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
agent.KeySet = &datastore.SerializableKeySet{keySet}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// Detach implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) {
|
||||
var agent datastore.Agent
|
||||
|
||||
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
query := `
|
||||
UPDATE agents SET tenant_id = null, updated_at = $1 WHERE id = $2
|
||||
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
|
||||
`
|
||||
|
||||
row := tx.QueryRowContext(
|
||||
ctx, query,
|
||||
now,
|
||||
agentID,
|
||||
)
|
||||
|
||||
metadata := JSONMap{}
|
||||
var rawKeySet []byte
|
||||
|
||||
err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
agent.Metadata = metadata
|
||||
|
||||
keySet, err := jwk.Parse(rawKeySet)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
agent.KeySet = &datastore.SerializableKeySet{keySet}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &agent, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
exists, err := r.agentExists(ctx, tx, agentID)
|
||||
if err != nil {
|
||||
@ -32,9 +139,9 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
|
||||
return errors.WithStack(datastore.ErrNotFound)
|
||||
}
|
||||
|
||||
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2`
|
||||
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)
|
||||
}
|
||||
|
||||
@ -47,9 +154,44 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSpec implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) GetSpec(ctx context.Context, agentID datastore.AgentID, name string, version string) (*datastore.Spec, error) {
|
||||
var spec datastore.Spec
|
||||
|
||||
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
|
||||
exists, err := r.agentExists(ctx, tx, agentID)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return errors.WithStack(datastore.ErrNotFound)
|
||||
}
|
||||
|
||||
query := `SELECT id, name, version, revision, data, created_at, updated_at, agent_id, tenant_id FROM specs WHERE agent_id = $1 AND name = $2 AND version = $3`
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, agentID, name, version)
|
||||
|
||||
var data JSONMap
|
||||
|
||||
if err := row.Scan(&spec.ID, &spec.DefinitionName, &spec.DefinitionVersion, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt, &spec.AgentID, &spec.TenantID); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
spec.Data = data
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// GetSpecs implements datastore.AgentRepository.
|
||||
func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) {
|
||||
specs := make([]*datastore.Spec, 0)
|
||||
func (r *AgentRepository) QuerySpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.SpecHeader, error) {
|
||||
specs := make([]*datastore.SpecHeader, 0)
|
||||
|
||||
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
|
||||
exists, err := r.agentExists(ctx, tx, agentID)
|
||||
@ -62,7 +204,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
|
||||
}
|
||||
|
||||
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
|
||||
WHERE agent_id = $1
|
||||
`
|
||||
@ -80,15 +222,16 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
|
||||
}()
|
||||
|
||||
for rows.Next() {
|
||||
spec := &datastore.Spec{}
|
||||
spec := &datastore.SpecHeader{}
|
||||
|
||||
data := JSONMap{}
|
||||
|
||||
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil {
|
||||
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 {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
spec.Data = data
|
||||
if tenantID.Valid {
|
||||
spec.TenantID = datastore.TenantID(tenantID.String)
|
||||
}
|
||||
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
@ -107,7 +250,7 @@ func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentI
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
||||
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()
|
||||
|
||||
query := `
|
||||
INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at)
|
||||
VALUES($1, $2, $3, $4, $5, $5)
|
||||
ON CONFLICT (agent_id, name) DO UPDATE SET
|
||||
data = $4, updated_at = $5, revision = specs.revision + 1
|
||||
WHERE revision = $3
|
||||
RETURNING "id", "name", "revision", "data", "created_at", "updated_at"
|
||||
INSERT INTO specs (agent_id, name, version, revision, data, created_at, updated_at, tenant_id)
|
||||
VALUES($1, $2, $3, $4, $5, $6, $6, ( SELECT tenant_id FROM agents WHERE id = $1 ))
|
||||
ON CONFLICT (agent_id, name, version) DO UPDATE SET
|
||||
data = $5, updated_at = $6, revision = specs.revision + 1, tenant_id = ( SELECT tenant_id FROM agents WHERE id = $1 )
|
||||
WHERE revision = $4
|
||||
RETURNING "id", "name", "version", "revision", "data", "created_at", "updated_at", "tenant_id", "agent_id"
|
||||
`
|
||||
|
||||
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))
|
||||
|
||||
row := tx.QueryRowContext(ctx, query, args...)
|
||||
|
||||
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 errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.WithStack(datastore.ErrUnexpectedRevision)
|
||||
@ -148,6 +292,10 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if tenantID.Valid {
|
||||
spec.TenantID = datastore.TenantID(tenantID.String)
|
||||
}
|
||||
|
||||
spec.Data = data
|
||||
|
||||
return nil
|
||||
@ -170,7 +318,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
|
||||
count := 0
|
||||
|
||||
err := r.withTxRetry(ctx, func(tx *sql.Tx) error {
|
||||
query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at FROM agents`
|
||||
query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at, tenant_id FROM agents`
|
||||
|
||||
limit := 10
|
||||
if options.Limit != nil {
|
||||
@ -193,6 +341,17 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
|
||||
args = append(args, newArgs...)
|
||||
}
|
||||
|
||||
if options.TenantIDs != nil && len(options.TenantIDs) > 0 {
|
||||
if filters != "" {
|
||||
filters += " AND "
|
||||
}
|
||||
|
||||
filter, newArgs, newParamIndex := inFilter("tenant_id", paramIndex, options.TenantIDs)
|
||||
filters += filter
|
||||
paramIndex = newParamIndex
|
||||
args = append(args, newArgs...)
|
||||
}
|
||||
|
||||
if options.Thumbprints != nil && len(options.Thumbprints) > 0 {
|
||||
if filters != "" {
|
||||
filters += " AND "
|
||||
@ -240,7 +399,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer
|
||||
metadata := JSONMap{}
|
||||
contactedAt := sql.NullTime{}
|
||||
|
||||
if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); 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)
|
||||
}
|
||||
|
||||
@ -293,7 +452,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
|
||||
query = `
|
||||
INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at)
|
||||
VALUES($1, $2, $3, $4, $5, $5)
|
||||
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at"
|
||||
RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id"
|
||||
`
|
||||
|
||||
rawKeySet, err := json.Marshal(keySet)
|
||||
@ -308,7 +467,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet
|
||||
|
||||
metadata := JSONMap{}
|
||||
|
||||
err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt)
|
||||
err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
WHERE id = $1
|
||||
`
|
||||
@ -374,7 +533,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas
|
||||
contactedAt := sql.NullTime{}
|
||||
var rawKeySet []byte
|
||||
|
||||
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); 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) {
|
||||
return datastore.ErrNotFound
|
||||
}
|
||||
@ -476,7 +635,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
|
||||
|
||||
query += `
|
||||
WHERE id = $1
|
||||
RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at"
|
||||
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))
|
||||
@ -487,7 +646,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
|
||||
contactedAt := sql.NullTime{}
|
||||
var rawKeySet []byte
|
||||
|
||||
if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); 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) {
|
||||
return datastore.ErrNotFound
|
||||
}
|
||||
@ -536,109 +695,8 @@ func (r *AgentRepository) agentExists(ctx context.Context, tx *sql.Tx, agentID d
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *AgentRepository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
attempts := 0
|
||||
max := r.sqliteBusyRetryMaxAttempts
|
||||
|
||||
ctx = logger.With(ctx, logger.F("max", max))
|
||||
|
||||
var err error
|
||||
for {
|
||||
ctx = logger.With(ctx)
|
||||
|
||||
if attempts >= max {
|
||||
logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts))
|
||||
|
||||
return errors.Wrapf(err, "transaction failed after %d attempts", max)
|
||||
}
|
||||
|
||||
err = r.withTx(ctx, fn)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
err = errors.WithStack(err)
|
||||
|
||||
logger.Warn(ctx, "database is busy", logger.E(err))
|
||||
|
||||
wait := time.Duration(8<<(attempts+1)) * time.Millisecond
|
||||
|
||||
logger.Debug(
|
||||
ctx, "database is busy, waiting before retrying transaction",
|
||||
logger.F("wait", wait.String()),
|
||||
logger.F("attempts", attempts),
|
||||
)
|
||||
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-timer.C:
|
||||
attempts++
|
||||
continue
|
||||
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *AgentRepository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
if errors.Is(err, sql.ErrTxDone) {
|
||||
return
|
||||
}
|
||||
|
||||
err = errors.WithStack(err)
|
||||
logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err))
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentRepository {
|
||||
return &AgentRepository{db, sqliteBusyRetryMaxAttempts}
|
||||
return &AgentRepository{repository{db, sqliteBusyRetryMaxAttempts}}
|
||||
}
|
||||
|
||||
var _ datastore.AgentRepository = &AgentRepository{}
|
||||
|
||||
func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) {
|
||||
args := make([]any, 0, len(items))
|
||||
filter := fmt.Sprintf("%s in (", column)
|
||||
|
||||
for idx, item := range items {
|
||||
if idx != 0 {
|
||||
filter += ","
|
||||
}
|
||||
|
||||
filter += fmt.Sprintf("$%d", paramIndex)
|
||||
paramIndex++
|
||||
|
||||
args = append(args, item)
|
||||
}
|
||||
|
||||
filter += ")"
|
||||
|
||||
return filter, args, paramIndex
|
||||
}
|
||||
|
@ -7,6 +7,42 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
value any
|
||||
}
|
||||
|
||||
func (j JSON) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data []byte
|
||||
|
||||
switch typ := value.(type) {
|
||||
case []byte:
|
||||
data = typ
|
||||
case string:
|
||||
data = []byte(typ)
|
||||
default:
|
||||
return errors.Errorf("unexpected type '%T'", value)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &j.value); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j JSON) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(j.value)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type JSONMap map[string]any
|
||||
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
|
97
internal/datastore/sqlite/repository.go
Normal file
97
internal/datastore/sqlite/repository.go
Normal 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
|
||||
}
|
219
internal/datastore/sqlite/spec_definition_repository.go
Normal file
219
internal/datastore/sqlite/spec_definition_repository.go
Normal 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{}
|
46
internal/datastore/sqlite/spec_definition_repository_test.go
Normal file
46
internal/datastore/sqlite/spec_definition_repository_test.go
Normal 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)
|
||||
}
|
23
internal/datastore/sqlite/sql.go
Normal file
23
internal/datastore/sqlite/sql.go
Normal 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
|
||||
}
|
284
internal/datastore/sqlite/tenant_repository.go
Normal file
284
internal/datastore/sqlite/tenant_repository.go
Normal 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{}
|
46
internal/datastore/sqlite/tenant_repository_test.go
Normal file
46
internal/datastore/sqlite/tenant_repository_test.go
Normal 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)
|
||||
}
|
32
internal/datastore/tenant.go
Normal file
32
internal/datastore/tenant.go
Normal 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"`
|
||||
}
|
50
internal/datastore/tenant_repository.go
Normal file
50
internal/datastore/tenant_repository.go
Normal 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
Loading…
x
Reference in New Issue
Block a user