Compare commits

..

12 Commits

Author SHA1 Message Date
dee62184b9 feat: update arcad/edge dependency
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-13 11:35:51 +02:00
76656e8dbf feat: update arcad/edge dependency
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-13 11:28:22 +02:00
41b1619fc1 feat: update arcad/edge dependency
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-13 11:05:12 +02:00
35d5ee868f chore: update sample specs
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-12 11:10:11 +02:00
765257b4b1 feat(datastore): add basic testsuite for agent repository
Some checks reported errors
arcad/emissary/pipeline/head Something is wrong with the build of this commit
2023-04-12 11:09:53 +02:00
2315ee7b61 feat: update arcad/edge dependency
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-11 15:11:15 +02:00
86a6d81e1d chore: execute tests before commit on edge lib update
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-11 12:06:16 +02:00
c4427dfd2b feat(controller,app): sort apps by id 2023-04-11 12:05:51 +02:00
280b0fbd50 feat(controller,app): validate app manifests on app load 2023-04-11 12:05:19 +02:00
8fb86c600f feat: update arcad/edge dependency
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-11 11:13:41 +02:00
12f8b3aa25 chore: add task to update arcad/edge dependency
All checks were successful
arcad/emissary/pipeline/head This commit looks good
2023-04-06 20:56:43 +02:00
2d2dc29c84 feat: update arcad/edge dependency 2023-04-06 20:56:00 +02:00
15 changed files with 412 additions and 79 deletions

View File

@ -154,4 +154,13 @@ load-sample-specs:
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
full-version:
@echo $(FULL_VERSION)
@echo $(FULL_VERSION)
update-edge-lib:
git pull --rebase
GOPRIVATE=forge.cadoles.com/arcad/edge go get -u forge.cadoles.com/arcad/edge
go mod tidy
$(MAKE) test
git add go.mod go.sum
git commit -m "feat: update arcad/edge dependency"
git push

2
go.mod
View File

@ -3,7 +3,7 @@ module forge.cadoles.com/Cadoles/emissary
go 1.19
require (
forge.cadoles.com/arcad/edge v0.0.0-20230406171836-da73b842e112
forge.cadoles.com/arcad/edge v0.0.0-20230413093531-de330c004207
github.com/Masterminds/sprig/v3 v3.2.3
github.com/alecthomas/participle/v2 v2.0.0-beta.5
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883

4
go.sum
View File

@ -54,8 +54,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
forge.cadoles.com/arcad/edge v0.0.0-20230406171836-da73b842e112 h1:XlDIFCdUf3Pq26uecJ+KYPCCx5le8GJvFGfryT9HFSA=
forge.cadoles.com/arcad/edge v0.0.0-20230406171836-da73b842e112/go.mod h1:Vx4iq/oewXUOkGyi8QKc14clTLNO1sWpb0SjBYELlAs=
forge.cadoles.com/arcad/edge v0.0.0-20230413093531-de330c004207 h1:SClle/69UAfxm1a0ZYGCAUYagH0uB1iEcWBAPLJPJ/k=
forge.cadoles.com/arcad/edge v0.0.0-20230413093531-de330c004207/go.mod h1:Vx4iq/oewXUOkGyi8QKc14clTLNO1sWpb0SjBYELlAs=
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20210715213245-6c3934b029d8/go.mod h1:CzsSbkDixRphAF5hS6wbMKq0eI6ccJRb7/A0M6JBnwg=
github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=

View File

@ -17,14 +17,12 @@ import (
edgeHTTP "forge.cadoles.com/arcad/edge/pkg/http"
"forge.cadoles.com/arcad/edge/pkg/module"
appModule "forge.cadoles.com/arcad/edge/pkg/module/app"
"forge.cadoles.com/arcad/edge/pkg/module/auth"
"forge.cadoles.com/arcad/edge/pkg/module/blob"
"forge.cadoles.com/arcad/edge/pkg/module/cast"
fetchModule "forge.cadoles.com/arcad/edge/pkg/module/fetch"
netModule "forge.cadoles.com/arcad/edge/pkg/module/net"
"forge.cadoles.com/arcad/edge/pkg/storage/sqlite"
"github.com/Masterminds/sprig/v3"
"github.com/dop251/goja"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
@ -49,12 +47,6 @@ func (c *Controller) getHandlerOptions(ctx context.Context, appKey string, specs
return nil, errors.Wrap(err, "could not retrieve auth key set")
}
bundles := make([]string, 0, len(specs.Apps))
for appKey, app := range specs.Apps {
path := c.getAppBundlePath(appKey, app.Format)
bundles = append(bundles, path)
}
bus := memory.NewBus()
modules := c.getAppModules(bus, db, specs, keySet)
@ -252,30 +244,7 @@ func (c *Controller) getAppModules(bus bus.Bus, db *sql.DB, spec *appSpec.Spec,
module.RPCModuleFactory(bus),
module.StoreModuleFactory(ds),
blob.ModuleFactory(bus, bs),
module.Extends(
auth.ModuleFactory(
auth.WithJWT(func() (jwk.Set, error) {
return keySet, nil
}),
),
func(o *goja.Object) {
if err := o.Set("CLAIM_TENANT", "arcad_tenant"); err != nil {
panic(errors.New("could not set 'CLAIM_TENANT' property"))
}
if err := o.Set("CLAIM_ENTRYPOINT", "arcad_entrypoint"); err != nil {
panic(errors.New("could not set 'CLAIM_ENTRYPOINT' property"))
}
if err := o.Set("CLAIM_ROLE", "arcad_role"); err != nil {
panic(errors.New("could not set 'CLAIM_ROLE' property"))
}
if err := o.Set("CLAIM_PREFERRED_USERNAME", "preferred_username"); err != nil {
panic(errors.New("could not set 'CLAIM_PREFERRED_USERNAME' property"))
}
},
),
authModule(keySet),
appModule.ModuleFactory(c.appRepository),
fetchModule.ModuleFactory(bus),
}

View File

@ -2,6 +2,7 @@ package app
import (
"context"
"sort"
"sync"
"forge.cadoles.com/arcad/edge/pkg/app"
@ -77,6 +78,8 @@ func (r *AppRepository) List(ctx context.Context) ([]*app.Manifest, error) {
manifests = append(manifests, manifest)
}
sort.Sort(ByID(manifests))
return manifests, nil
}
@ -126,3 +129,9 @@ func NewAppRepository() *AppRepository {
}
var _ appModule.Repository = &AppRepository{}
type ByID []*app.Manifest
func (a ByID) Len() int { return len(a) }
func (a ByID) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByID) Less(i, j int) bool { return a[i].ID > a[j].ID }

View File

@ -0,0 +1,65 @@
package app
import (
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/module"
"forge.cadoles.com/arcad/edge/pkg/module/auth"
"github.com/dop251/goja"
"github.com/pkg/errors"
)
const (
RoleVisitor string = "visitor"
RoleUser string = "user"
RoleSuperuser string = "superuser"
RoleAdmin string = "admin"
RoleSuperadmin string = "superadmin"
)
func authModule(keySet jwk.Set) app.ServerModuleFactory {
return module.Extends(
auth.ModuleFactory(
auth.WithJWT(func() (jwk.Set, error) {
return keySet, nil
}),
),
func(o *goja.Object) {
if err := o.Set("CLAIM_TENANT", "arcad_tenant"); err != nil {
panic(errors.New("could not set 'CLAIM_TENANT' property"))
}
if err := o.Set("CLAIM_ENTRYPOINT", "arcad_entrypoint"); err != nil {
panic(errors.New("could not set 'CLAIM_ENTRYPOINT' property"))
}
if err := o.Set("CLAIM_ROLE", "arcad_role"); err != nil {
panic(errors.New("could not set 'CLAIM_ROLE' property"))
}
if err := o.Set("CLAIM_PREFERRED_USERNAME", "preferred_username"); err != nil {
panic(errors.New("could not set 'CLAIM_PREFERRED_USERNAME' property"))
}
if err := o.Set("ROLE_VISITOR", RoleVisitor); err != nil {
panic(errors.New("could not set 'ROLE_VISITOR' property"))
}
if err := o.Set("ROLE_USER", RoleUser); err != nil {
panic(errors.New("could not set 'ROLE_USER' property"))
}
if err := o.Set("ROLE_SUPERUSER", RoleSuperuser); err != nil {
panic(errors.New("could not set 'ROLE_SUPERUSER' property"))
}
if err := o.Set("ROLE_ADMIN", RoleAdmin); err != nil {
panic(errors.New("could not set 'ROLE_ADMIN' property"))
}
if err := o.Set("ROLE_SUPERADMIN", RoleSuperadmin); err != nil {
panic(errors.New("could not set 'ROLE_SUPERADMIN' property"))
}
},
)
}

View File

@ -9,6 +9,7 @@ import (
"forge.cadoles.com/Cadoles/emissary/internal/agent"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/app/spec"
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/bundle"
"github.com/mitchellh/hashstructure/v2"
"github.com/pkg/errors"
@ -276,7 +277,21 @@ func (c *Controller) ensureAppBundle(ctx context.Context, appID string, spec spe
return nil, "", errors.WithStack(err)
}
return bdle, "", nil
manifest, err := app.LoadManifest(bdle)
if err != nil {
return nil, "", errors.WithStack(err)
}
valid, err := validateManifest(manifest)
if err != nil {
return nil, "", errors.WithStack(err)
}
if !valid {
return nil, "", errors.New("bundle's manifest is invalid")
}
return bdle, spec.SHA256Sum, nil
}
func (c *Controller) downloadFile(url string, sha256sum string, dest string) error {

View File

@ -0,0 +1,19 @@
package app
import (
"forge.cadoles.com/arcad/edge/pkg/app"
"forge.cadoles.com/arcad/edge/pkg/app/metadata"
"github.com/pkg/errors"
)
func validateManifest(manifest *app.Manifest) (bool, error) {
valid, err := manifest.Validate(
metadata.WithMinimumRoleValidator(RoleVisitor, RoleUser, RoleSuperuser, RoleAdmin, RoleSuperadmin),
metadata.WithNamedPathsValidator(metadata.NamedPathAdmin, metadata.NamedPathIcon),
)
if err != nil {
return false, errors.WithStack(err)
}
return valid, nil
}

View File

@ -20,9 +20,24 @@ type AgentRepository struct {
// DeleteSpec implements datastore.AgentRepository.
func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string) error {
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2`
err := r.withTx(ctx, func(tx *sql.Tx) error {
exists, err := r.agentExists(ctx, tx, agentID)
if err != nil {
return errors.WithStack(err)
}
_, err := r.db.ExecContext(ctx, query, agentID, name)
if !exists {
return errors.WithStack(datastore.ErrNotFound)
}
query := `DELETE FROM specs WHERE agent_id = $1 AND name = $2`
if _, err = tx.ExecContext(ctx, query, agentID, name); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
@ -34,41 +49,57 @@ func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.Agen
func (r *AgentRepository) GetSpecs(ctx context.Context, agentID datastore.AgentID) ([]*datastore.Spec, error) {
specs := make([]*datastore.Spec, 0)
query := `
err := r.withTx(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, revision, data, created_at, updated_at
FROM specs
WHERE agent_id = $1
`
`
rows, err := r.db.QueryContext(ctx, query, agentID)
rows, err := tx.QueryContext(ctx, query, agentID)
if err != nil {
return errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
}
}()
for rows.Next() {
spec := &datastore.Spec{}
data := JSONMap{}
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil {
return errors.WithStack(err)
}
spec.Data = data
specs = append(specs, spec)
}
if err := rows.Err(); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Error(ctx, "could not close rows", logger.E(errors.WithStack(err)))
}
}()
for rows.Next() {
spec := &datastore.Spec{}
data := JSONMap{}
if err := rows.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt); err != nil {
return nil, errors.WithStack(err)
}
spec.Data = data
specs = append(specs, spec)
}
if err := rows.Err(); err != nil {
return nil, errors.WithStack(err)
}
return specs, nil
}
@ -77,6 +108,15 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
spec := &datastore.Spec{}
err := r.withTx(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)
}
now := time.Now().UTC()
query := `
@ -96,7 +136,7 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen
data := JSONMap{}
err := row.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt)
err = row.Scan(&spec.ID, &spec.Name, &spec.Revision, &data, &spec.CreatedAt, &spec.UpdatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errors.WithStack(datastore.ErrUnexpectedRevision)
@ -472,8 +512,28 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts
return agent, nil
}
func (r *AgentRepository) agentExists(ctx context.Context, tx *sql.Tx, agentID datastore.AgentID) (bool, error) {
row := tx.QueryRowContext(ctx, `SELECT count(id) FROM agents WHERE id = $1`, agentID)
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 (r *AgentRepository) withTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := r.db.Begin()
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return errors.WithStack(err)
}

View File

@ -0,0 +1,46 @@
package sqlite
import (
"database/sql"
"fmt"
"os"
"testing"
"time"
"forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite"
"forge.cadoles.com/Cadoles/emissary/internal/migrate"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
_ "modernc.org/sqlite"
)
func TestSQLiteAgentRepository(t *testing.T) {
logger.SetLevel(logger.LevelDebug)
file := "testdata/agent_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 := NewAgentRepository(db)
testsuite.TestAgentRepository(t, repo)
}

View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,14 @@
package testsuite
import (
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
)
func TestAgentRepository(t *testing.T, repo datastore.AgentRepository) {
t.Run("Cases", func(t *testing.T) {
t.Parallel()
runAgentRepositoryTests(t, repo)
})
}

View File

@ -0,0 +1,129 @@
package testsuite
import (
"context"
"testing"
"forge.cadoles.com/Cadoles/emissary/internal/agent/controller/mdns/spec"
"forge.cadoles.com/Cadoles/emissary/internal/datastore"
"forge.cadoles.com/Cadoles/emissary/internal/jwk"
"github.com/pkg/errors"
)
type agentRepositoryTestCase struct {
Name string
Skip bool
Run func(ctx context.Context, repo datastore.AgentRepository) error
}
var agentRepositoryTestCases = []agentRepositoryTestCase{
{
Name: "Create a new agent",
Run: func(ctx context.Context, repo datastore.AgentRepository) error {
thumbprint := "foo"
keySet := jwk.NewSet()
var metadata map[string]any
agent, err := repo.Create(ctx, thumbprint, keySet, metadata)
if err != nil {
return errors.WithStack(err)
}
if agent.CreatedAt.IsZero() {
return errors.Errorf("agent.CreatedAt should not be zero time")
}
if agent.UpdatedAt.IsZero() {
return errors.Errorf("agent.UpdatedAt should not be zero time")
}
if e, g := datastore.AgentStatusPending, agent.Status; e != g {
return errors.Errorf("agent.Status: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Try to update spec for an unexistant agent",
Run: func(ctx context.Context, repo datastore.AgentRepository) error {
var unexistantAgentID datastore.AgentID = 9999
var specData map[string]any
agent, err := repo.UpdateSpec(ctx, unexistantAgentID, string(spec.Name), 0, specData)
if err == nil {
return errors.New("error should not be nil")
}
if !errors.Is(err, datastore.ErrNotFound) {
return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err)
}
if agent != nil {
return errors.New("agent should be nil")
}
return nil
},
},
{
Name: "Try to delete spec of an unexistant agent",
Run: func(ctx context.Context, repo datastore.AgentRepository) error {
var unexistantAgentID datastore.AgentID = 9999
err := repo.DeleteSpec(ctx, unexistantAgentID, string(spec.Name))
if err == nil {
return errors.New("error should not be nil")
}
if !errors.Is(err, datastore.ErrNotFound) {
return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err)
}
return nil
},
},
{
Name: "Try to get specs of an unexistant agent",
Run: func(ctx context.Context, repo datastore.AgentRepository) error {
var unexistantAgentID datastore.AgentID = 9999
specs, err := repo.GetSpecs(ctx, unexistantAgentID)
if err == nil {
return errors.New("error should not be nil")
}
if !errors.Is(err, datastore.ErrNotFound) {
return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err)
}
if specs != nil {
return errors.Errorf("specs should be nil, got '%+v'", err)
}
return nil
},
},
}
func runAgentRepositoryTests(t *testing.T, repo datastore.AgentRepository) {
for _, tc := range agentRepositoryTestCases {
func(tc agentRepositoryTestCase) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
if tc.Skip {
t.SkipNow()
return
}
ctx := context.Background()
if err := tc.Run(ctx, repo); err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}

View File

@ -2,7 +2,6 @@ package migrate
import (
"fmt"
"log"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
@ -23,8 +22,6 @@ func New(migrationDir, driver, dsn string) (*migrate.Migrate, error) {
fmt.Sprintf("file://%s/%s", migrationDir, driver),
dsn,
)
log.Println(migrationDir, driver, dsn)
if err != nil {
return nil, errors.WithStack(err)
}

View File

@ -1,26 +1,26 @@
{
"apps": {
"edge.portal": {
"url": "https://emissary.cadol.es/files/apps/edge.portal_v2023.4.5-45546c4.zip",
"sha256sum": "c83e7e4b3785f5f4d3fcae7cad334819626015b11b446520aa79f42176a2744d",
"url": "https://emissary.cadol.es/files/apps/edge.portal_v2023.4.9-41c100d.zip",
"sha256sum": "b73a6741654f3e24281e354b3b506b109dac6ada8a9698452f52b03a53299a7d",
"address": ":8082",
"format": "zip"
},
"app.arcad.edge.hextris": {
"url": "https://emissary.cadol.es/files/apps/app.arcad.edge.hextris_v2023.3.22-33ece28.zip",
"sha256sum": "5f9f3c8d6f22796beb051d747d7ff12efa17af9d1552c0ab08baef13703a2aba",
"url": "https://emissary.cadol.es/files/apps/app.arcad.edge.hextris_v2023.4.11-81fb4c4.zip",
"sha256sum": "6d70f65971b3dd288da32d8d004ab8fbca030398b5c12e3c052ef98c53a6b81a",
"address": ":8083",
"format": "zip"
},
"edge.sdk.client.test": {
"url": "https://emissary.cadol.es/files/apps/edge.sdk.client.test_v2023.4.2-f08f645.zip",
"sha256sum": "8b48388c817802ebeb38907b3a42f1189dc0759f94c5f33de4546c1a7ebfc784",
"url": "https://emissary.cadol.es/files/apps/edge.sdk.client.test_v2023.4.11-f5283b8.zip",
"sha256sum": "785d9f8d427900e1bb27ab85a33e8b1cbd1b6a1f8b2eab6366dc215a69655ade",
"address": ":8084",
"format": "zip"
},
"arcad.diffusion": {
"url": "https://emissary.cadol.es/files/apps/arcad.diffusion_v2023.4.5-ffcd1c7.zip",
"sha256sum": "a51a961212470ce1de4527aaaec9e8e0286a978ec675ff9df29b2029daf05a55",
"url": "https://emissary.cadol.es/files/apps/arcad.diffusion_v2023.4.9-81046a2.zip",
"sha256sum": "b8770adfaaf60e6d3e7776e0a090e6e7a0b31f3f9425b91168b42144d0346513",
"address": ":8085",
"format": "zip"
}