mirror of
https://github.com/absmach/magistrala.git
synced 2026-08-07 07:14:46 +00:00
Remove stale bootstrap backfill artifacts after Atom rebase
Signed-off-by: Arvindh <arvindh91@gmail.com>
This commit is contained in:
@@ -283,54 +283,6 @@ make run_latest
|
||||
|
||||
---
|
||||
|
||||
## Upgrade from v0.19.0 to v0.20.0
|
||||
|
||||
Before upgrading, back up the Domains, Rules Engine, Reports, Alarms, Auth, and SpiceDB databases.
|
||||
|
||||
v0.20.0 adds new domain admin actions for alarms and reports, and it requires existing rules and reports to have their built-in admin roles backfilled. The service database migrations run when the v0.20.0 services start, then the role backfill scripts must be run once.
|
||||
|
||||
For the default Docker Compose setup:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
|
||||
docker compose up -d \
|
||||
spicedb-db spicedb-migrate spicedb \
|
||||
auth-db auth \
|
||||
domains-db domains \
|
||||
re-db re \
|
||||
reports-db reports \
|
||||
alarms-db alarms
|
||||
```
|
||||
|
||||
Wait until the services are running. The `auth` service must start successfully because it loads the SpiceDB schema.
|
||||
|
||||
From the repository root, run the backfills:
|
||||
|
||||
```bash
|
||||
go run ./scripts/re-backfill-roles/
|
||||
go run ./scripts/reports-backfill-roles/
|
||||
```
|
||||
|
||||
The scripts are idempotent. If they are interrupted, fix the issue and run them again.
|
||||
|
||||
Expected successful summaries:
|
||||
|
||||
```text
|
||||
backfill finished processed=<number> skipped=<number> failed=0
|
||||
```
|
||||
|
||||
After the backfills finish, verify that the services are still running:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose ps re reports alarms domains auth spicedb
|
||||
```
|
||||
|
||||
For non-default deployments, make sure the database and SpiceDB connection settings used by the backfill scripts match your environment before running them.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
)
|
||||
|
||||
var errBindingSlot = errors.New("invalid binding slot")
|
||||
|
||||
func validateProfileBindingSlots(profile Profile) error {
|
||||
seen := make(map[string]struct{}, len(profile.BindingSlots))
|
||||
for _, slot := range profile.BindingSlots {
|
||||
if slot.Name == "" {
|
||||
return fmt.Errorf("%w: slot name is required", errBindingSlot)
|
||||
}
|
||||
if slot.Type == "" {
|
||||
return fmt.Errorf("%w: slot %q type is required", errBindingSlot, slot.Name)
|
||||
}
|
||||
if _, ok := seen[slot.Name]; ok {
|
||||
return fmt.Errorf("%w: duplicate slot %q", errBindingSlot, slot.Name)
|
||||
}
|
||||
seen[slot.Name] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequestedBindings(profile Profile, requested []BindingRequest) error {
|
||||
if len(profile.BindingSlots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
slots := make(map[string]BindingSlot, len(profile.BindingSlots))
|
||||
for _, slot := range profile.BindingSlots {
|
||||
slots[slot.Name] = slot
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(requested))
|
||||
for _, binding := range requested {
|
||||
slot, ok := slots[binding.Slot]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: unknown slot %q", errBindingSlot, binding.Slot)
|
||||
}
|
||||
if slot.Type != binding.Type {
|
||||
return fmt.Errorf("%w: slot %q expects %q, got %q", errBindingSlot, binding.Slot, slot.Type, binding.Type)
|
||||
}
|
||||
if _, ok := seen[binding.Slot]; ok {
|
||||
return fmt.Errorf("%w: duplicate binding for slot %q", errBindingSlot, binding.Slot)
|
||||
}
|
||||
seen[binding.Slot] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRequiredBindings(profile Profile, bindings []BindingSnapshot) error {
|
||||
if len(profile.BindingSlots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bound := make(map[string]BindingSnapshot, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
bound[binding.Slot] = binding
|
||||
}
|
||||
|
||||
for _, slot := range profile.BindingSlots {
|
||||
binding, ok := bound[slot.Name]
|
||||
if !slot.Required && !ok {
|
||||
continue
|
||||
}
|
||||
if slot.Required && !ok {
|
||||
return fmt.Errorf("%w: required slot %q is not bound", errBindingSlot, slot.Name)
|
||||
}
|
||||
if binding.Type != slot.Type {
|
||||
return fmt.Errorf("%w: slot %q expects %q, got %q", errBindingSlot, slot.Name, slot.Type, binding.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeBindingSnapshots(existing, updated []BindingSnapshot) []BindingSnapshot {
|
||||
merged := make(map[string]BindingSnapshot, len(existing)+len(updated))
|
||||
for _, binding := range existing {
|
||||
merged[binding.Slot] = binding
|
||||
}
|
||||
for _, binding := range updated {
|
||||
merged[binding.Slot] = binding
|
||||
}
|
||||
|
||||
bindings := make([]BindingSnapshot, 0, len(merged))
|
||||
for _, binding := range merged {
|
||||
bindings = append(bindings, binding)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func validateProfileTemplate(p Profile) error {
|
||||
if p.ContentTemplate == "" || p.ContentFormat == ContentFormatRaw {
|
||||
return nil
|
||||
}
|
||||
_, err := template.New("bootstrap").Funcs(allowlistedFuncs()).Parse(p.ContentTemplate)
|
||||
if err != nil {
|
||||
return errors.Wrap(ErrRenderFailed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BindingRequest carries a user's intent to bind a named profile slot to
|
||||
// a concrete resource.
|
||||
type BindingRequest struct {
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type"` // "client" | "channel" | "cert"
|
||||
ResourceID string `json:"resource_id"` // ID of the resource in its owning service
|
||||
}
|
||||
|
||||
// BindingSnapshot is a Bootstrap-owned point-in-time copy of the resource
|
||||
// fields needed for template rendering. It is populated at binding time so
|
||||
// that the render path never calls external services.
|
||||
type BindingSnapshot struct {
|
||||
ConfigID string `json:"config_id"`
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Snapshot map[string]any `json:"snapshot,omitempty"`
|
||||
SecretSnapshot map[string]any `json:"secret_snapshot,omitempty"` // encrypted at rest
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// BindingStore is the persistence interface for BindingSnapshots.
|
||||
type BindingStore interface {
|
||||
// Save upserts all given snapshots for the config.
|
||||
Save(ctx context.Context, configID string, bindings []BindingSnapshot) error
|
||||
|
||||
// Retrieve returns all snapshots for the given config.
|
||||
Retrieve(ctx context.Context, configID string) ([]BindingSnapshot, error)
|
||||
|
||||
// Delete removes the snapshot for a specific slot of a config.
|
||||
Delete(ctx context.Context, configID, slot string) error
|
||||
}
|
||||
|
||||
// ResolveRequest carries everything the BindingResolver needs to snapshot a
|
||||
// set of resource bindings.
|
||||
type ResolveRequest struct {
|
||||
Enrollment Config
|
||||
Token string
|
||||
Requested []BindingRequest
|
||||
}
|
||||
|
||||
// BindingResolver validates that requested resources exist in their owning
|
||||
// services, verifies type and slot compatibility, and returns snapshots ready
|
||||
// for storage. It is called at binding time only; the render path must not
|
||||
// call it.
|
||||
type BindingResolver interface {
|
||||
Resolve(ctx context.Context, req ResolveRequest) ([]BindingSnapshot, error)
|
||||
}
|
||||
|
||||
// RenderContext is the typed value injected into Go templates during rendering.
|
||||
type RenderContext struct {
|
||||
Device DeviceContext
|
||||
Vars map[string]any
|
||||
Bindings map[string]BindingContext
|
||||
}
|
||||
|
||||
// DeviceContext holds enrollment identity fields available inside templates.
|
||||
type DeviceContext struct {
|
||||
ID string
|
||||
ExternalID string
|
||||
DomainID string
|
||||
}
|
||||
|
||||
// BindingContext holds the resolved resource data available inside templates
|
||||
// for a specific slot.
|
||||
type BindingContext struct {
|
||||
Type string
|
||||
ID string
|
||||
Snapshot map[string]any
|
||||
Secret map[string]any
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
// Hasher specifies an API for generating hashes of arbitrary textual content.
|
||||
type Hasher interface {
|
||||
// Hash generates the hashed string from plain-text.
|
||||
Hash(string) (string, error)
|
||||
|
||||
// Compare compares plain-text version to the hashed one. An error should
|
||||
// indicate failed comparison.
|
||||
Compare(string, string) error
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package hasher
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
cost = 10
|
||||
legacyScryptPrefix = "scrypt$"
|
||||
legacyScryptKeyN = 16384
|
||||
legacyScryptKeyR = 8
|
||||
legacyScryptKeyP = 1
|
||||
legacyScryptKeySize = 32
|
||||
)
|
||||
|
||||
var (
|
||||
errHashExternalKey = errors.NewServiceError("generate hash from external key failed")
|
||||
errCompareExternalKey = errors.NewServiceError("compare external key and hash failed")
|
||||
errInvalidHashStore = errors.New("invalid stored external key hash format")
|
||||
errDecode = errors.New("failed to decode external key hash")
|
||||
)
|
||||
|
||||
var _ bootstrap.Hasher = (*bcryptHasher)(nil)
|
||||
|
||||
type bcryptHasher struct{}
|
||||
|
||||
// New instantiates a bcrypt-based hasher implementation.
|
||||
func New() bootstrap.Hasher {
|
||||
return &bcryptHasher{}
|
||||
}
|
||||
|
||||
func (*bcryptHasher) Hash(key string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(key), cost)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errHashExternalKey, err)
|
||||
}
|
||||
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (*bcryptHasher) Compare(plain, hashed string) error {
|
||||
if strings.HasPrefix(hashed, legacyScryptPrefix) {
|
||||
return compareLegacyScryptHash(plain, hashed)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plain)); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Legacy rows may still contain plaintext external keys.
|
||||
if subtle.ConstantTimeCompare([]byte(plain), []byte(hashed)) == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return bootstrap.ErrExternalKey
|
||||
}
|
||||
|
||||
func compareLegacyScryptHash(plain, hashed string) error {
|
||||
parts := strings.Split(strings.TrimPrefix(hashed, legacyScryptPrefix), ".")
|
||||
if len(parts) != 2 {
|
||||
return errInvalidHashStore
|
||||
}
|
||||
|
||||
actualHash, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return errors.Wrap(errDecode, err)
|
||||
}
|
||||
|
||||
salt, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return errors.Wrap(errDecode, err)
|
||||
}
|
||||
|
||||
derivedHash, err := scrypt.Key([]byte(plain), salt, legacyScryptKeyN, legacyScryptKeyR, legacyScryptKeyP, legacyScryptKeySize)
|
||||
if err != nil {
|
||||
return errors.Wrap(errCompareExternalKey, err)
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(derivedHash, actualHash) == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return bootstrap.ErrExternalKey
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewBindingResolver creates a new instance of BindingResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewBindingResolver(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *BindingResolver {
|
||||
mock := &BindingResolver{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// BindingResolver is an autogenerated mock type for the BindingResolver type
|
||||
type BindingResolver struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type BindingResolver_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *BindingResolver) EXPECT() *BindingResolver_Expecter {
|
||||
return &BindingResolver_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Resolve provides a mock function for the type BindingResolver
|
||||
func (_mock *BindingResolver) Resolve(ctx context.Context, req bootstrap.ResolveRequest) ([]bootstrap.BindingSnapshot, error) {
|
||||
ret := _mock.Called(ctx, req)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Resolve")
|
||||
}
|
||||
|
||||
var r0 []bootstrap.BindingSnapshot
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, bootstrap.ResolveRequest) ([]bootstrap.BindingSnapshot, error)); ok {
|
||||
return returnFunc(ctx, req)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, bootstrap.ResolveRequest) []bootstrap.BindingSnapshot); ok {
|
||||
r0 = returnFunc(ctx, req)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]bootstrap.BindingSnapshot)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, bootstrap.ResolveRequest) error); ok {
|
||||
r1 = returnFunc(ctx, req)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// BindingResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
|
||||
type BindingResolver_Resolve_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Resolve is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - req bootstrap.ResolveRequest
|
||||
func (_e *BindingResolver_Expecter) Resolve(ctx interface{}, req interface{}) *BindingResolver_Resolve_Call {
|
||||
return &BindingResolver_Resolve_Call{Call: _e.mock.On("Resolve", ctx, req)}
|
||||
}
|
||||
|
||||
func (_c *BindingResolver_Resolve_Call) Run(run func(ctx context.Context, req bootstrap.ResolveRequest)) *BindingResolver_Resolve_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 bootstrap.ResolveRequest
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bootstrap.ResolveRequest)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingResolver_Resolve_Call) Return(bindingSnapshots []bootstrap.BindingSnapshot, err error) *BindingResolver_Resolve_Call {
|
||||
_c.Call.Return(bindingSnapshots, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingResolver_Resolve_Call) RunAndReturn(run func(ctx context.Context, req bootstrap.ResolveRequest) ([]bootstrap.BindingSnapshot, error)) *BindingResolver_Resolve_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewBindingStore creates a new instance of BindingStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewBindingStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *BindingStore {
|
||||
mock := &BindingStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// BindingStore is an autogenerated mock type for the BindingStore type
|
||||
type BindingStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type BindingStore_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *BindingStore) EXPECT() *BindingStore_Expecter {
|
||||
return &BindingStore_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Delete provides a mock function for the type BindingStore
|
||||
func (_mock *BindingStore) Delete(ctx context.Context, configID string, slot string) error {
|
||||
ret := _mock.Called(ctx, configID, slot)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
|
||||
r0 = returnFunc(ctx, configID, slot)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BindingStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
|
||||
type BindingStore_Delete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Delete is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - configID string
|
||||
// - slot string
|
||||
func (_e *BindingStore_Expecter) Delete(ctx interface{}, configID interface{}, slot interface{}) *BindingStore_Delete_Call {
|
||||
return &BindingStore_Delete_Call{Call: _e.mock.On("Delete", ctx, configID, slot)}
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Delete_Call) Run(run func(ctx context.Context, configID string, slot string)) *BindingStore_Delete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Delete_Call) Return(err error) *BindingStore_Delete_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Delete_Call) RunAndReturn(run func(ctx context.Context, configID string, slot string) error) *BindingStore_Delete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Retrieve provides a mock function for the type BindingStore
|
||||
func (_mock *BindingStore) Retrieve(ctx context.Context, configID string) ([]bootstrap.BindingSnapshot, error) {
|
||||
ret := _mock.Called(ctx, configID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Retrieve")
|
||||
}
|
||||
|
||||
var r0 []bootstrap.BindingSnapshot
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]bootstrap.BindingSnapshot, error)); ok {
|
||||
return returnFunc(ctx, configID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string) []bootstrap.BindingSnapshot); ok {
|
||||
r0 = returnFunc(ctx, configID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]bootstrap.BindingSnapshot)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = returnFunc(ctx, configID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// BindingStore_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
|
||||
type BindingStore_Retrieve_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Retrieve is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - configID string
|
||||
func (_e *BindingStore_Expecter) Retrieve(ctx interface{}, configID interface{}) *BindingStore_Retrieve_Call {
|
||||
return &BindingStore_Retrieve_Call{Call: _e.mock.On("Retrieve", ctx, configID)}
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Retrieve_Call) Run(run func(ctx context.Context, configID string)) *BindingStore_Retrieve_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Retrieve_Call) Return(bindingSnapshots []bootstrap.BindingSnapshot, err error) *BindingStore_Retrieve_Call {
|
||||
_c.Call.Return(bindingSnapshots, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Retrieve_Call) RunAndReturn(run func(ctx context.Context, configID string) ([]bootstrap.BindingSnapshot, error)) *BindingStore_Retrieve_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Save provides a mock function for the type BindingStore
|
||||
func (_mock *BindingStore) Save(ctx context.Context, configID string, bindings []bootstrap.BindingSnapshot) error {
|
||||
ret := _mock.Called(ctx, configID, bindings)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Save")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []bootstrap.BindingSnapshot) error); ok {
|
||||
r0 = returnFunc(ctx, configID, bindings)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// BindingStore_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
|
||||
type BindingStore_Save_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Save is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - configID string
|
||||
// - bindings []bootstrap.BindingSnapshot
|
||||
func (_e *BindingStore_Expecter) Save(ctx interface{}, configID interface{}, bindings interface{}) *BindingStore_Save_Call {
|
||||
return &BindingStore_Save_Call{Call: _e.mock.On("Save", ctx, configID, bindings)}
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Save_Call) Run(run func(ctx context.Context, configID string, bindings []bootstrap.BindingSnapshot)) *BindingStore_Save_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 []bootstrap.BindingSnapshot
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].([]bootstrap.BindingSnapshot)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Save_Call) Return(err error) *BindingStore_Save_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *BindingStore_Save_Call) RunAndReturn(run func(ctx context.Context, configID string, bindings []bootstrap.BindingSnapshot) error) *BindingStore_Save_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewProfileRepository creates a new instance of ProfileRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewProfileRepository(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *ProfileRepository {
|
||||
mock := &ProfileRepository{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// ProfileRepository is an autogenerated mock type for the ProfileRepository type
|
||||
type ProfileRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type ProfileRepository_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *ProfileRepository) EXPECT() *ProfileRepository_Expecter {
|
||||
return &ProfileRepository_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Delete provides a mock function for the type ProfileRepository
|
||||
func (_mock *ProfileRepository) Delete(ctx context.Context, domainID string, id string) error {
|
||||
ret := _mock.Called(ctx, domainID, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
|
||||
r0 = returnFunc(ctx, domainID, id)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// ProfileRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
|
||||
type ProfileRepository_Delete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Delete is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - domainID string
|
||||
// - id string
|
||||
func (_e *ProfileRepository_Expecter) Delete(ctx interface{}, domainID interface{}, id interface{}) *ProfileRepository_Delete_Call {
|
||||
return &ProfileRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, domainID, id)}
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Delete_Call) Run(run func(ctx context.Context, domainID string, id string)) *ProfileRepository_Delete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Delete_Call) Return(err error) *ProfileRepository_Delete_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, domainID string, id string) error) *ProfileRepository_Delete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// RetrieveAll provides a mock function for the type ProfileRepository
|
||||
func (_mock *ProfileRepository) RetrieveAll(ctx context.Context, domainID string, offset uint64, limit uint64, name string) (bootstrap.ProfilesPage, error) {
|
||||
ret := _mock.Called(ctx, domainID, offset, limit, name)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RetrieveAll")
|
||||
}
|
||||
|
||||
var r0 bootstrap.ProfilesPage
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint64, uint64, string) (bootstrap.ProfilesPage, error)); ok {
|
||||
return returnFunc(ctx, domainID, offset, limit, name)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, uint64, uint64, string) bootstrap.ProfilesPage); ok {
|
||||
r0 = returnFunc(ctx, domainID, offset, limit, name)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bootstrap.ProfilesPage)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, uint64, uint64, string) error); ok {
|
||||
r1 = returnFunc(ctx, domainID, offset, limit, name)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ProfileRepository_RetrieveAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveAll'
|
||||
type ProfileRepository_RetrieveAll_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// RetrieveAll is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - domainID string
|
||||
// - offset uint64
|
||||
// - limit uint64
|
||||
// - name string
|
||||
func (_e *ProfileRepository_Expecter) RetrieveAll(ctx interface{}, domainID interface{}, offset interface{}, limit interface{}, name interface{}) *ProfileRepository_RetrieveAll_Call {
|
||||
return &ProfileRepository_RetrieveAll_Call{Call: _e.mock.On("RetrieveAll", ctx, domainID, offset, limit, name)}
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_RetrieveAll_Call) Run(run func(ctx context.Context, domainID string, offset uint64, limit uint64, name string)) *ProfileRepository_RetrieveAll_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 uint64
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(uint64)
|
||||
}
|
||||
var arg3 uint64
|
||||
if args[3] != nil {
|
||||
arg3 = args[3].(uint64)
|
||||
}
|
||||
var arg4 string
|
||||
if args[4] != nil {
|
||||
arg4 = args[4].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3,
|
||||
arg4,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_RetrieveAll_Call) Return(profilesPage bootstrap.ProfilesPage, err error) *ProfileRepository_RetrieveAll_Call {
|
||||
_c.Call.Return(profilesPage, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_RetrieveAll_Call) RunAndReturn(run func(ctx context.Context, domainID string, offset uint64, limit uint64, name string) (bootstrap.ProfilesPage, error)) *ProfileRepository_RetrieveAll_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// RetrieveByID provides a mock function for the type ProfileRepository
|
||||
func (_mock *ProfileRepository) RetrieveByID(ctx context.Context, domainID string, id string) (bootstrap.Profile, error) {
|
||||
ret := _mock.Called(ctx, domainID, id)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RetrieveByID")
|
||||
}
|
||||
|
||||
var r0 bootstrap.Profile
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (bootstrap.Profile, error)); ok {
|
||||
return returnFunc(ctx, domainID, id)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bootstrap.Profile); ok {
|
||||
r0 = returnFunc(ctx, domainID, id)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bootstrap.Profile)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||
r1 = returnFunc(ctx, domainID, id)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ProfileRepository_RetrieveByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveByID'
|
||||
type ProfileRepository_RetrieveByID_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// RetrieveByID is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - domainID string
|
||||
// - id string
|
||||
func (_e *ProfileRepository_Expecter) RetrieveByID(ctx interface{}, domainID interface{}, id interface{}) *ProfileRepository_RetrieveByID_Call {
|
||||
return &ProfileRepository_RetrieveByID_Call{Call: _e.mock.On("RetrieveByID", ctx, domainID, id)}
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_RetrieveByID_Call) Run(run func(ctx context.Context, domainID string, id string)) *ProfileRepository_RetrieveByID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 string
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(string)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_RetrieveByID_Call) Return(profile bootstrap.Profile, err error) *ProfileRepository_RetrieveByID_Call {
|
||||
_c.Call.Return(profile, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_RetrieveByID_Call) RunAndReturn(run func(ctx context.Context, domainID string, id string) (bootstrap.Profile, error)) *ProfileRepository_RetrieveByID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Save provides a mock function for the type ProfileRepository
|
||||
func (_mock *ProfileRepository) Save(ctx context.Context, p bootstrap.Profile) (bootstrap.Profile, error) {
|
||||
ret := _mock.Called(ctx, p)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Save")
|
||||
}
|
||||
|
||||
var r0 bootstrap.Profile
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, bootstrap.Profile) (bootstrap.Profile, error)); ok {
|
||||
return returnFunc(ctx, p)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, bootstrap.Profile) bootstrap.Profile); ok {
|
||||
r0 = returnFunc(ctx, p)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bootstrap.Profile)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, bootstrap.Profile) error); ok {
|
||||
r1 = returnFunc(ctx, p)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ProfileRepository_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
|
||||
type ProfileRepository_Save_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Save is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - p bootstrap.Profile
|
||||
func (_e *ProfileRepository_Expecter) Save(ctx interface{}, p interface{}) *ProfileRepository_Save_Call {
|
||||
return &ProfileRepository_Save_Call{Call: _e.mock.On("Save", ctx, p)}
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Save_Call) Run(run func(ctx context.Context, p bootstrap.Profile)) *ProfileRepository_Save_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 bootstrap.Profile
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bootstrap.Profile)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Save_Call) Return(profile bootstrap.Profile, err error) *ProfileRepository_Save_Call {
|
||||
_c.Call.Return(profile, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Save_Call) RunAndReturn(run func(ctx context.Context, p bootstrap.Profile) (bootstrap.Profile, error)) *ProfileRepository_Save_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Update provides a mock function for the type ProfileRepository
|
||||
func (_mock *ProfileRepository) Update(ctx context.Context, p bootstrap.Profile) (bootstrap.Profile, error) {
|
||||
ret := _mock.Called(ctx, p)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Update")
|
||||
}
|
||||
|
||||
var r0 bootstrap.Profile
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, bootstrap.Profile) (bootstrap.Profile, error)); ok {
|
||||
return returnFunc(ctx, p)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, bootstrap.Profile) bootstrap.Profile); ok {
|
||||
r0 = returnFunc(ctx, p)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bootstrap.Profile)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, bootstrap.Profile) error); ok {
|
||||
r1 = returnFunc(ctx, p)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ProfileRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update'
|
||||
type ProfileRepository_Update_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Update is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - p bootstrap.Profile
|
||||
func (_e *ProfileRepository_Expecter) Update(ctx interface{}, p interface{}) *ProfileRepository_Update_Call {
|
||||
return &ProfileRepository_Update_Call{Call: _e.mock.On("Update", ctx, p)}
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Update_Call) Run(run func(ctx context.Context, p bootstrap.Profile)) *ProfileRepository_Update_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 bootstrap.Profile
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bootstrap.Profile)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Update_Call) Return(profile bootstrap.Profile, err error) *ProfileRepository_Update_Call {
|
||||
_c.Call.Return(profile, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *ProfileRepository_Update_Call) RunAndReturn(run func(ctx context.Context, p bootstrap.Profile) (bootstrap.Profile, error)) *ProfileRepository_Update_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by mockery; DO NOT EDIT.
|
||||
// github.com/vektra/mockery
|
||||
// template: testify
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// NewRenderer creates a new instance of Renderer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewRenderer(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *Renderer {
|
||||
mock := &Renderer{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
// Renderer is an autogenerated mock type for the Renderer type
|
||||
type Renderer struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type Renderer_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *Renderer) EXPECT() *Renderer_Expecter {
|
||||
return &Renderer_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Render provides a mock function for the type Renderer
|
||||
func (_mock *Renderer) Render(profile bootstrap.Profile, enrollment bootstrap.Config, bindings []bootstrap.BindingSnapshot) ([]byte, error) {
|
||||
ret := _mock.Called(profile, enrollment, bindings)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Render")
|
||||
}
|
||||
|
||||
var r0 []byte
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(bootstrap.Profile, bootstrap.Config, []bootstrap.BindingSnapshot) ([]byte, error)); ok {
|
||||
return returnFunc(profile, enrollment, bindings)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(bootstrap.Profile, bootstrap.Config, []bootstrap.BindingSnapshot) []byte); ok {
|
||||
r0 = returnFunc(profile, enrollment, bindings)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]byte)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(bootstrap.Profile, bootstrap.Config, []bootstrap.BindingSnapshot) error); ok {
|
||||
r1 = returnFunc(profile, enrollment, bindings)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Renderer_Render_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Render'
|
||||
type Renderer_Render_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Render is a helper method to define mock.On call
|
||||
// - profile bootstrap.Profile
|
||||
// - enrollment bootstrap.Config
|
||||
// - bindings []bootstrap.BindingSnapshot
|
||||
func (_e *Renderer_Expecter) Render(profile interface{}, enrollment interface{}, bindings interface{}) *Renderer_Render_Call {
|
||||
return &Renderer_Render_Call{Call: _e.mock.On("Render", profile, enrollment, bindings)}
|
||||
}
|
||||
|
||||
func (_c *Renderer_Render_Call) Run(run func(profile bootstrap.Profile, enrollment bootstrap.Config, bindings []bootstrap.BindingSnapshot)) *Renderer_Render_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 bootstrap.Profile
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(bootstrap.Profile)
|
||||
}
|
||||
var arg1 bootstrap.Config
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(bootstrap.Config)
|
||||
}
|
||||
var arg2 []bootstrap.BindingSnapshot
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].([]bootstrap.BindingSnapshot)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Renderer_Render_Call) Return(bytes []byte, err error) *Renderer_Render_Call {
|
||||
_c.Call.Return(bytes, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *Renderer_Render_Call) RunAndReturn(run func(profile bootstrap.Profile, enrollment bootstrap.Config, bindings []bootstrap.BindingSnapshot) ([]byte, error)) *Renderer_Render_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
|
||||
"github.com/absmach/magistrala/pkg/postgres"
|
||||
)
|
||||
|
||||
var _ bootstrap.BindingStore = (*bindingRepository)(nil)
|
||||
|
||||
type bindingRepository struct {
|
||||
db postgres.Database
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewBindingRepository instantiates a PostgreSQL implementation of BindingStore.
|
||||
func NewBindingRepository(db postgres.Database, log *slog.Logger) bootstrap.BindingStore {
|
||||
return &bindingRepository{db: db, log: log}
|
||||
}
|
||||
|
||||
func (br bindingRepository) Save(ctx context.Context, configID string, bindings []bootstrap.BindingSnapshot) error {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := `INSERT INTO bindings (config_id, slot, type, resource_id, snapshot, secret_snapshot, updated_at)
|
||||
VALUES (:config_id, :slot, :type, :resource_id, :snapshot, :secret_snapshot, :updated_at)
|
||||
ON CONFLICT (config_id, slot) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
resource_id = EXCLUDED.resource_id,
|
||||
snapshot = EXCLUDED.snapshot,
|
||||
secret_snapshot = EXCLUDED.secret_snapshot,
|
||||
updated_at = EXCLUDED.updated_at`
|
||||
|
||||
now := time.Now().UTC()
|
||||
dbBindings := make([]dbBindingSnapshot, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
b.ConfigID = configID
|
||||
b.UpdatedAt = now
|
||||
dbb, err := toDBBindingSnapshot(b)
|
||||
if err != nil {
|
||||
return errors.Wrap(repoerr.ErrCreateEntity, err)
|
||||
}
|
||||
dbBindings = append(dbBindings, dbb)
|
||||
}
|
||||
|
||||
if _, err := br.db.NamedExecContext(ctx, q, dbBindings); err != nil {
|
||||
return errors.Wrap(repoerr.ErrCreateEntity, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (br bindingRepository) Retrieve(ctx context.Context, configID string) ([]bootstrap.BindingSnapshot, error) {
|
||||
q := `SELECT config_id, slot, type, resource_id, snapshot, secret_snapshot, updated_at
|
||||
FROM bindings WHERE config_id = $1 ORDER BY slot`
|
||||
|
||||
rows, err := br.db.QueryxContext(ctx, q, configID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var snapshots []bootstrap.BindingSnapshot
|
||||
for rows.Next() {
|
||||
var dbb dbBindingSnapshot
|
||||
if err := rows.StructScan(&dbb); err != nil {
|
||||
br.log.Error(fmt.Sprintf("failed to scan binding snapshot: %s", err))
|
||||
return nil, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
b, err := toBindingSnapshot(dbb)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
snapshots = append(snapshots, b)
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func (br bindingRepository) Delete(ctx context.Context, configID, slot string) error {
|
||||
q := `DELETE FROM bindings WHERE config_id = $1 AND slot = $2`
|
||||
if _, err := br.db.ExecContext(ctx, q, configID, slot); err != nil {
|
||||
return errors.Wrap(repoerr.ErrRemoveEntity, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dbBindingSnapshot is the database representation of a BindingSnapshot.
|
||||
type dbBindingSnapshot struct {
|
||||
ConfigID string `db:"config_id"`
|
||||
Slot string `db:"slot"`
|
||||
Type string `db:"type"`
|
||||
ResourceID string `db:"resource_id"`
|
||||
Snapshot []byte `db:"snapshot"`
|
||||
SecretSnapshot []byte `db:"secret_snapshot"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func toDBBindingSnapshot(b bootstrap.BindingSnapshot) (dbBindingSnapshot, error) {
|
||||
snap, err := json.Marshal(b.Snapshot)
|
||||
if err != nil {
|
||||
return dbBindingSnapshot{}, err
|
||||
}
|
||||
secret, err := json.Marshal(b.SecretSnapshot)
|
||||
if err != nil {
|
||||
return dbBindingSnapshot{}, err
|
||||
}
|
||||
return dbBindingSnapshot{
|
||||
ConfigID: b.ConfigID,
|
||||
Slot: b.Slot,
|
||||
Type: b.Type,
|
||||
ResourceID: b.ResourceID,
|
||||
Snapshot: snap,
|
||||
SecretSnapshot: secret,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toBindingSnapshot(dbb dbBindingSnapshot) (bootstrap.BindingSnapshot, error) {
|
||||
b := bootstrap.BindingSnapshot{
|
||||
ConfigID: dbb.ConfigID,
|
||||
Slot: dbb.Slot,
|
||||
Type: dbb.Type,
|
||||
ResourceID: dbb.ResourceID,
|
||||
UpdatedAt: dbb.UpdatedAt,
|
||||
}
|
||||
if len(dbb.Snapshot) > 0 && string(dbb.Snapshot) != jsonNull {
|
||||
if err := json.Unmarshal(dbb.Snapshot, &b.Snapshot); err != nil {
|
||||
return bootstrap.BindingSnapshot{}, err
|
||||
}
|
||||
}
|
||||
if len(dbb.SecretSnapshot) > 0 && string(dbb.SecretSnapshot) != jsonNull {
|
||||
if err := json.Unmarshal(dbb.SecretSnapshot, &b.SecretSnapshot); err != nil {
|
||||
return bootstrap.BindingSnapshot{}, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
|
||||
"github.com/absmach/magistrala/pkg/postgres"
|
||||
)
|
||||
|
||||
var _ bootstrap.ProfileRepository = (*profileRepository)(nil)
|
||||
|
||||
type profileRepository struct {
|
||||
db postgres.Database
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewProfileRepository instantiates a PostgreSQL implementation of ProfileRepository.
|
||||
func NewProfileRepository(db postgres.Database, log *slog.Logger) bootstrap.ProfileRepository {
|
||||
return &profileRepository{db: db, log: log}
|
||||
}
|
||||
|
||||
func (pr profileRepository) Save(ctx context.Context, p bootstrap.Profile) (bootstrap.Profile, error) {
|
||||
q := `INSERT INTO profiles (id, domain_id, name, description, content_format, content_template, defaults, binding_slots, version, created_at, updated_at)
|
||||
VALUES (:id, :domain_id, :name, :description, :content_format, :content_template, :defaults, :binding_slots, :version, :created_at, :updated_at)`
|
||||
|
||||
now := time.Now().UTC()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = now
|
||||
|
||||
dbp, err := toDBProfile(p)
|
||||
if err != nil {
|
||||
return bootstrap.Profile{}, errors.Wrap(repoerr.ErrCreateEntity, err)
|
||||
}
|
||||
|
||||
if _, err = pr.db.NamedExecContext(ctx, q, dbp); err != nil {
|
||||
return bootstrap.Profile{}, postgres.HandleError(repoerr.ErrCreateEntity, err)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (pr profileRepository) RetrieveByID(ctx context.Context, domainID, id string) (bootstrap.Profile, error) {
|
||||
q := `SELECT id, domain_id, name, description, content_format, content_template, defaults, binding_slots, version, created_at, updated_at
|
||||
FROM profiles WHERE id = :id AND domain_id = :domain_id`
|
||||
|
||||
rows, err := pr.db.NamedQueryContext(ctx, q, dbProfile{ID: id, DomainID: domainID})
|
||||
if err != nil {
|
||||
return bootstrap.Profile{}, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return bootstrap.Profile{}, repoerr.ErrNotFound
|
||||
}
|
||||
var dbp dbProfile
|
||||
if err := rows.StructScan(&dbp); err != nil {
|
||||
return bootstrap.Profile{}, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
|
||||
return toProfile(dbp)
|
||||
}
|
||||
|
||||
func (pr profileRepository) RetrieveAll(ctx context.Context, domainID string, offset, limit uint64, name string) (bootstrap.ProfilesPage, error) {
|
||||
dbPage := dbProfilesPage{DomainID: domainID, Offset: offset, Limit: limit, Name: name}
|
||||
pageQuery := profilesPageQuery(dbPage)
|
||||
q := fmt.Sprintf(`SELECT id, domain_id, name, description, content_format, content_template, defaults, binding_slots, version, created_at, updated_at
|
||||
FROM profiles %s`, pageQuery)
|
||||
q = applyProfilesOrdering(q)
|
||||
q = fmt.Sprintf(`%s LIMIT :limit OFFSET :offset`, q)
|
||||
|
||||
rows, err := pr.db.NamedQueryContext(ctx, q, dbPage)
|
||||
if err != nil {
|
||||
return bootstrap.ProfilesPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var profiles []bootstrap.Profile
|
||||
for rows.Next() {
|
||||
var dbp dbProfile
|
||||
if err := rows.StructScan(&dbp); err != nil {
|
||||
pr.log.Error(fmt.Sprintf("failed to scan profile row: %s", err))
|
||||
return bootstrap.ProfilesPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
p, err := toProfile(dbp)
|
||||
if err != nil {
|
||||
return bootstrap.ProfilesPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
|
||||
cq := fmt.Sprintf(`SELECT COUNT(*) FROM profiles %s`, pageQuery)
|
||||
total, err := postgres.Total(ctx, pr.db, cq, dbPage)
|
||||
if err != nil {
|
||||
return bootstrap.ProfilesPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
|
||||
}
|
||||
|
||||
return bootstrap.ProfilesPage{
|
||||
Total: total,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Profiles: profiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type dbProfilesPage struct {
|
||||
DomainID string `db:"domain_id"`
|
||||
Offset uint64 `db:"offset"`
|
||||
Limit uint64 `db:"limit"`
|
||||
Name string `db:"name"`
|
||||
}
|
||||
|
||||
func profilesPageQuery(pm dbProfilesPage) string {
|
||||
var query []string
|
||||
query = append(query, "domain_id = :domain_id")
|
||||
if pm.Name != "" {
|
||||
query = append(query, "name ILIKE '%' || :name || '%'")
|
||||
}
|
||||
return fmt.Sprintf("WHERE %s", strings.Join(query, " AND "))
|
||||
}
|
||||
|
||||
func applyProfilesOrdering(q string) string {
|
||||
return fmt.Sprintf("%s ORDER BY created_at DESC", q)
|
||||
}
|
||||
|
||||
func (pr profileRepository) Update(ctx context.Context, p bootstrap.Profile) (bootstrap.Profile, error) {
|
||||
var query []string
|
||||
var upq string
|
||||
if p.Name != "" {
|
||||
query = append(query, "name = :name,")
|
||||
}
|
||||
if p.Description != "" {
|
||||
query = append(query, "description = :description,")
|
||||
}
|
||||
if p.ContentFormat != "" {
|
||||
query = append(query, "content_format = :content_format,")
|
||||
}
|
||||
if p.ContentTemplate != "" {
|
||||
query = append(query, "content_template = :content_template,")
|
||||
}
|
||||
if p.Defaults != nil {
|
||||
query = append(query, "defaults = :defaults,")
|
||||
}
|
||||
if p.BindingSlots != nil {
|
||||
query = append(query, "binding_slots = :binding_slots,")
|
||||
}
|
||||
if len(query) > 0 {
|
||||
upq = strings.Join(query, " ")
|
||||
}
|
||||
|
||||
q := fmt.Sprintf(`UPDATE profiles SET %s version = version + 1, updated_at = :updated_at
|
||||
WHERE id = :id AND domain_id = :domain_id
|
||||
RETURNING id, domain_id, name, description, content_format, content_template, defaults, binding_slots, version, created_at, updated_at`,
|
||||
upq)
|
||||
|
||||
p.UpdatedAt = time.Now().UTC()
|
||||
dbp, err := toDBProfile(p)
|
||||
if err != nil {
|
||||
return bootstrap.Profile{}, errors.Wrap(repoerr.ErrUpdateEntity, err)
|
||||
}
|
||||
|
||||
rows, err := pr.db.NamedQueryContext(ctx, q, dbp)
|
||||
if err != nil {
|
||||
return bootstrap.Profile{}, postgres.HandleError(repoerr.ErrUpdateEntity, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
return bootstrap.Profile{}, repoerr.ErrNotFound
|
||||
}
|
||||
var updated dbProfile
|
||||
if err := rows.StructScan(&updated); err != nil {
|
||||
return bootstrap.Profile{}, errors.Wrap(repoerr.ErrUpdateEntity, err)
|
||||
}
|
||||
|
||||
return toProfile(updated)
|
||||
}
|
||||
|
||||
func (pr profileRepository) Delete(ctx context.Context, domainID, id string) error {
|
||||
q := `DELETE FROM profiles WHERE id = :id AND domain_id = :domain_id`
|
||||
if _, err := pr.db.NamedExecContext(ctx, q, dbProfile{ID: id, DomainID: domainID}); err != nil {
|
||||
return errors.Wrap(repoerr.ErrRemoveEntity, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dbProfile is the database representation of a Profile.
|
||||
type dbProfile struct {
|
||||
ID string `db:"id"`
|
||||
DomainID string `db:"domain_id"`
|
||||
Name string `db:"name"`
|
||||
Description sql.NullString `db:"description"`
|
||||
ContentFormat string `db:"content_format"`
|
||||
ContentTemplate sql.NullString `db:"content_template"`
|
||||
Defaults []byte `db:"defaults"`
|
||||
BindingSlots []byte `db:"binding_slots"`
|
||||
Version int `db:"version"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func toDBProfile(p bootstrap.Profile) (dbProfile, error) {
|
||||
defaults, err := json.Marshal(p.Defaults)
|
||||
if err != nil {
|
||||
return dbProfile{}, err
|
||||
}
|
||||
bindingSlots, err := json.Marshal(p.BindingSlots)
|
||||
if err != nil {
|
||||
return dbProfile{}, err
|
||||
}
|
||||
return dbProfile{
|
||||
ID: p.ID,
|
||||
DomainID: p.DomainID,
|
||||
Name: p.Name,
|
||||
Description: nullString(p.Description),
|
||||
ContentFormat: string(p.ContentFormat),
|
||||
ContentTemplate: nullString(p.ContentTemplate),
|
||||
Defaults: defaults,
|
||||
BindingSlots: bindingSlots,
|
||||
Version: p.Version,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toProfile(dbp dbProfile) (bootstrap.Profile, error) {
|
||||
p := bootstrap.Profile{
|
||||
ID: dbp.ID,
|
||||
DomainID: dbp.DomainID,
|
||||
Name: dbp.Name,
|
||||
ContentFormat: bootstrap.ContentFormat(dbp.ContentFormat),
|
||||
Version: dbp.Version,
|
||||
CreatedAt: dbp.CreatedAt,
|
||||
UpdatedAt: dbp.UpdatedAt,
|
||||
}
|
||||
if dbp.Description.Valid {
|
||||
p.Description = dbp.Description.String
|
||||
}
|
||||
if dbp.ContentTemplate.Valid {
|
||||
p.ContentTemplate = dbp.ContentTemplate.String
|
||||
}
|
||||
if len(dbp.Defaults) > 0 && string(dbp.Defaults) != jsonNull {
|
||||
if err := json.Unmarshal(dbp.Defaults, &p.Defaults); err != nil {
|
||||
return bootstrap.Profile{}, err
|
||||
}
|
||||
}
|
||||
if len(dbp.BindingSlots) > 0 && string(dbp.BindingSlots) != jsonNull {
|
||||
if err := json.Unmarshal(dbp.BindingSlots, &p.BindingSlots); err != nil {
|
||||
return bootstrap.Profile{}, err
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContentFormat enumerates the supported output formats for rendered profile templates.
|
||||
type ContentFormat string
|
||||
|
||||
const (
|
||||
ContentFormatGoTemplate ContentFormat = "go-template"
|
||||
ContentFormatRaw ContentFormat = "raw"
|
||||
ContentFormatJSON ContentFormat = "json"
|
||||
ContentFormatYAML ContentFormat = "yaml"
|
||||
ContentFormatTOML ContentFormat = "toml"
|
||||
)
|
||||
|
||||
// Profile is a user-managed device configuration template.
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
DomainID string `json:"domain_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ContentFormat ContentFormat `json:"content_format"`
|
||||
ContentTemplate string `json:"content_template,omitempty"`
|
||||
Defaults map[string]any `json:"defaults,omitempty"`
|
||||
BindingSlots []BindingSlot `json:"binding_slots,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// BindingSlot declares a named resource placeholder that a profile template can use.
|
||||
type BindingSlot struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// ProfilesPage contains pagination metadata and a slice of Profiles.
|
||||
type ProfilesPage struct {
|
||||
Total uint64 `json:"total"`
|
||||
Offset uint64 `json:"offset"`
|
||||
Limit uint64 `json:"limit"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
// ProfileRepository specifies the persistence API for Profiles.
|
||||
type ProfileRepository interface {
|
||||
// Save persists a new Profile and returns it with server-assigned fields set.
|
||||
Save(ctx context.Context, p Profile) (Profile, error)
|
||||
|
||||
// RetrieveByID returns the Profile with the given ID inside the given domain.
|
||||
RetrieveByID(ctx context.Context, domainID, id string) (Profile, error)
|
||||
|
||||
// RetrieveAll returns a page of Profiles belonging to the given domain, optionally filtered by name.
|
||||
RetrieveAll(ctx context.Context, domainID string, offset, limit uint64, name string) (ProfilesPage, error)
|
||||
|
||||
// Update updates editable fields of the given Profile and returns the updated Profile.
|
||||
Update(ctx context.Context, p Profile) (Profile, error)
|
||||
|
||||
// Delete removes the Profile with the given ID from the given domain.
|
||||
Delete(ctx context.Context, domainID, id string) error
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Renderer renders a Profile's content template into a concrete device
|
||||
// configuration. All input data must already be stored in Bootstrap — no
|
||||
// external service calls are allowed inside Render.
|
||||
type Renderer interface {
|
||||
Render(profile Profile, enrollment Config, bindings []BindingSnapshot) ([]byte, error)
|
||||
}
|
||||
|
||||
// ErrRenderFailed is returned when template execution or output validation fails.
|
||||
var ErrRenderFailed = errors.New("failed to render profile template")
|
||||
|
||||
type renderer struct{}
|
||||
|
||||
// NewRenderer returns the default Renderer implementation using Go text/template.
|
||||
func NewRenderer() Renderer {
|
||||
return renderer{}
|
||||
}
|
||||
|
||||
func (r renderer) Render(profile Profile, enrollment Config, bindings []BindingSnapshot) ([]byte, error) {
|
||||
rctx := buildRenderContext(profile, enrollment, bindings)
|
||||
|
||||
switch profile.ContentFormat {
|
||||
case ContentFormatRaw:
|
||||
return []byte(profile.ContentTemplate), nil
|
||||
case ContentFormatGoTemplate, ContentFormatJSON, ContentFormatYAML, ContentFormatTOML, "":
|
||||
return r.renderTemplate(profile, rctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported template format %q", ErrRenderFailed, profile.ContentFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func (r renderer) renderTemplate(profile Profile, rctx RenderContext) ([]byte, error) {
|
||||
t, err := template.New("bootstrap").
|
||||
Option("missingkey=error").
|
||||
Funcs(allowlistedFuncs()).
|
||||
Parse(profile.ContentTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRenderFailed, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, rctx); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRenderFailed, err)
|
||||
}
|
||||
|
||||
return convertOutput(buf.Bytes(), profile.ContentFormat)
|
||||
}
|
||||
|
||||
// convertOutput parses the rendered bytes as any structured format (JSON, YAML,
|
||||
// or TOML) and re-marshals them into the declared target format. For go-template
|
||||
// or empty format the raw bytes are returned unchanged.
|
||||
func convertOutput(out []byte, format ContentFormat) ([]byte, error) {
|
||||
switch format {
|
||||
case ContentFormatGoTemplate, "":
|
||||
return out, nil
|
||||
case ContentFormatJSON, ContentFormatYAML, ContentFormatTOML:
|
||||
var v any
|
||||
if err := parseStructured(out, &v); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRenderFailed, err)
|
||||
}
|
||||
result, err := marshalAs(v, format)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrRenderFailed, err)
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported format %q", ErrRenderFailed, format)
|
||||
}
|
||||
}
|
||||
|
||||
// parseStructured tries JSON, then YAML, then TOML and unmarshals into v.
|
||||
func parseStructured(out []byte, v any) error {
|
||||
if err := json.Unmarshal(out, v); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := yaml.Unmarshal(out, v); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := toml.Unmarshal(out, v); err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("template output is not valid JSON, YAML, or TOML")
|
||||
}
|
||||
|
||||
// marshalAs re-marshals v into the requested format.
|
||||
func marshalAs(v any, format ContentFormat) ([]byte, error) {
|
||||
switch format {
|
||||
case ContentFormatJSON:
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
case ContentFormatYAML:
|
||||
return yaml.Marshal(v)
|
||||
case ContentFormatTOML:
|
||||
var buf bytes.Buffer
|
||||
if err := toml.NewEncoder(&buf).Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRenderContext constructs the typed RenderContext from stored data.
|
||||
// No external calls are made here.
|
||||
func buildRenderContext(profile Profile, enrollment Config, bindings []BindingSnapshot) RenderContext {
|
||||
vars := make(map[string]any)
|
||||
for k, v := range profile.Defaults {
|
||||
vars[k] = v
|
||||
}
|
||||
for k, v := range enrollment.RenderContext {
|
||||
vars[k] = v
|
||||
}
|
||||
|
||||
bctx := make(map[string]BindingContext, len(bindings))
|
||||
for _, b := range bindings {
|
||||
bctx[b.Slot] = BindingContext{
|
||||
Type: b.Type,
|
||||
ID: b.ResourceID,
|
||||
Snapshot: b.Snapshot,
|
||||
Secret: b.SecretSnapshot,
|
||||
}
|
||||
}
|
||||
|
||||
return RenderContext{
|
||||
Device: DeviceContext{
|
||||
ID: enrollment.ID,
|
||||
ExternalID: enrollment.ExternalID,
|
||||
DomainID: enrollment.DomainID,
|
||||
},
|
||||
Vars: vars,
|
||||
Bindings: bctx,
|
||||
}
|
||||
}
|
||||
|
||||
// allowlistedFuncs returns the safe set of template helper functions.
|
||||
// No function in this map may call an external service or perform I/O.
|
||||
func allowlistedFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"toJSON": func(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
},
|
||||
"default": func(def, val any) any {
|
||||
if val == nil || val == "" {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
},
|
||||
"required": func(key string, val any) (any, error) {
|
||||
if val == nil || val == "" {
|
||||
return nil, fmt.Errorf("required value %q is missing", key)
|
||||
}
|
||||
return val, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRendererStructuredOutputValidation(t *testing.T) {
|
||||
renderer := bootstrap.NewRenderer()
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
format bootstrap.ContentFormat
|
||||
template string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
desc: "valid JSON output",
|
||||
format: bootstrap.ContentFormatJSON,
|
||||
template: `{"device_id":"{{ .Device.ID }}"}`,
|
||||
},
|
||||
{
|
||||
desc: "invalid output for JSON format",
|
||||
format: bootstrap.ContentFormatJSON,
|
||||
template: `[unclosed bracket`,
|
||||
err: bootstrap.ErrRenderFailed,
|
||||
},
|
||||
{
|
||||
desc: "valid YAML output",
|
||||
format: bootstrap.ContentFormatYAML,
|
||||
template: "device_id: {{ .Device.ID }}",
|
||||
},
|
||||
{
|
||||
desc: "invalid output for YAML format",
|
||||
format: bootstrap.ContentFormatYAML,
|
||||
template: "[unclosed bracket",
|
||||
err: bootstrap.ErrRenderFailed,
|
||||
},
|
||||
{
|
||||
desc: "valid TOML output",
|
||||
format: bootstrap.ContentFormatTOML,
|
||||
template: `[device]
|
||||
device_id = "{{ .Device.ID }}"`,
|
||||
},
|
||||
{
|
||||
desc: "invalid output for TOML format",
|
||||
format: bootstrap.ContentFormatTOML,
|
||||
template: `[unclosed bracket`,
|
||||
err: bootstrap.ErrRenderFailed,
|
||||
},
|
||||
{
|
||||
desc: "JSON template auto-converted to TOML",
|
||||
format: bootstrap.ContentFormatTOML,
|
||||
template: `{"device_id":"{{ .Device.ID }}"}`,
|
||||
},
|
||||
{
|
||||
desc: "TOML template auto-converted to JSON",
|
||||
format: bootstrap.ContentFormatJSON,
|
||||
template: `device_id = "{{ .Device.ID }}"`,
|
||||
},
|
||||
{
|
||||
desc: "YAML template auto-converted to TOML",
|
||||
format: bootstrap.ContentFormatTOML,
|
||||
template: "device_id: {{ .Device.ID }}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
_, err := renderer.Render(
|
||||
bootstrap.Profile{
|
||||
ContentFormat: tc.format,
|
||||
ContentTemplate: tc.template,
|
||||
},
|
||||
bootstrap.Config{ID: "config-id"},
|
||||
nil,
|
||||
)
|
||||
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %v got %v", tc.desc, tc.err, err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
svcerr "github.com/absmach/magistrala/pkg/errors/service"
|
||||
mgsdk "github.com/absmach/magistrala/pkg/sdk"
|
||||
)
|
||||
|
||||
var _ BindingResolver = (*sdkResolver)(nil)
|
||||
|
||||
type sdkResolver struct {
|
||||
sdk mgsdk.SDK
|
||||
}
|
||||
|
||||
// NewSDKResolver returns a BindingResolver that validates resources against
|
||||
// the Magistrala clients and channels services using the SDK. This resolver
|
||||
// is called only at binding time; the render path must never call it.
|
||||
func NewSDKResolver(sdk mgsdk.SDK) BindingResolver {
|
||||
return &sdkResolver{sdk: sdk}
|
||||
}
|
||||
|
||||
func (r *sdkResolver) Resolve(ctx context.Context, req ResolveRequest) ([]BindingSnapshot, error) {
|
||||
var snapshots []BindingSnapshot
|
||||
|
||||
for _, br := range req.Requested {
|
||||
snap, err := r.resolveOne(ctx, req.Enrollment.DomainID, req.Token, br)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshots = append(snapshots, snap)
|
||||
}
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
func (r *sdkResolver) resolveOne(ctx context.Context, domainID, token string, br BindingRequest) (BindingSnapshot, error) {
|
||||
switch br.Type {
|
||||
case "client":
|
||||
return r.resolveClient(ctx, domainID, token, br)
|
||||
case "channel":
|
||||
return r.resolveChannel(ctx, domainID, token, br)
|
||||
default:
|
||||
return BindingSnapshot{}, fmt.Errorf("unsupported binding type %q", br.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sdkResolver) resolveClient(ctx context.Context, domainID, token string, br BindingRequest) (BindingSnapshot, error) {
|
||||
client, sdkErr := r.sdk.Client(ctx, br.ResourceID, domainID, token)
|
||||
if sdkErr != nil {
|
||||
return BindingSnapshot{}, errors.Wrap(svcerr.ErrNotFound,
|
||||
fmt.Errorf("client %q not found: %s", br.ResourceID, sdkErr))
|
||||
}
|
||||
|
||||
snapshot := map[string]any{
|
||||
"id": client.ID,
|
||||
"name": client.Name,
|
||||
}
|
||||
if client.Credentials.Identity != "" {
|
||||
snapshot["identity"] = client.Credentials.Identity
|
||||
}
|
||||
if client.DomainID != "" {
|
||||
snapshot["domain_id"] = client.DomainID
|
||||
}
|
||||
|
||||
secret := map[string]any{}
|
||||
if client.Credentials.Secret != "" {
|
||||
secret["secret"] = client.Credentials.Secret
|
||||
}
|
||||
|
||||
return BindingSnapshot{
|
||||
Slot: br.Slot,
|
||||
Type: br.Type,
|
||||
ResourceID: br.ResourceID,
|
||||
Snapshot: snapshot,
|
||||
SecretSnapshot: secret,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *sdkResolver) resolveChannel(ctx context.Context, domainID, token string, br BindingRequest) (BindingSnapshot, error) {
|
||||
channel, sdkErr := r.sdk.Channel(ctx, br.ResourceID, domainID, token)
|
||||
if sdkErr != nil {
|
||||
return BindingSnapshot{}, errors.Wrap(svcerr.ErrNotFound,
|
||||
fmt.Errorf("channel %q not found: %s", br.ResourceID, sdkErr))
|
||||
}
|
||||
|
||||
snapshot := map[string]any{
|
||||
"id": channel.ID,
|
||||
"name": channel.Name,
|
||||
}
|
||||
if channel.Route != "" {
|
||||
snapshot["topic"] = channel.Route
|
||||
}
|
||||
if channel.DomainID != "" {
|
||||
snapshot["domain_id"] = channel.DomainID
|
||||
}
|
||||
if channel.Metadata != nil {
|
||||
snapshot["metadata"] = channel.Metadata
|
||||
}
|
||||
|
||||
return BindingSnapshot{
|
||||
Slot: br.Slot,
|
||||
Type: br.Type,
|
||||
ResourceID: br.ResourceID,
|
||||
Snapshot: snapshot,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const secretSnapshotCiphertextKey = "ciphertext"
|
||||
|
||||
func (bs bootstrapService) encryptSecretSnapshots(bindings []BindingSnapshot) ([]BindingSnapshot, error) {
|
||||
encrypted := make([]BindingSnapshot, len(bindings))
|
||||
for i, binding := range bindings {
|
||||
encrypted[i] = binding
|
||||
if len(binding.SecretSnapshot) == 0 {
|
||||
continue
|
||||
}
|
||||
secret, err := json.Marshal(binding.SecretSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ciphertext, err := bs.encrypt(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encrypted[i].SecretSnapshot = map[string]any{
|
||||
secretSnapshotCiphertextKey: ciphertext,
|
||||
}
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
func (bs bootstrapService) decryptSecretSnapshots(bindings []BindingSnapshot) ([]BindingSnapshot, error) {
|
||||
decrypted := make([]BindingSnapshot, len(bindings))
|
||||
for i, binding := range bindings {
|
||||
decrypted[i] = binding
|
||||
ciphertext, ok := binding.SecretSnapshot[secretSnapshotCiphertextKey].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
plain, err := bs.decrypt(ciphertext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var secret map[string]any
|
||||
if err := json.Unmarshal(plain, &secret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decrypted[i].SecretSnapshot = secret
|
||||
}
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func hideSecretSnapshots(bindings []BindingSnapshot) []BindingSnapshot {
|
||||
hidden := make([]BindingSnapshot, len(bindings))
|
||||
for i, binding := range bindings {
|
||||
hidden[i] = binding
|
||||
hidden[i].SecretSnapshot = nil
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func (bs bootstrapService) encrypt(plain []byte) (string, error) {
|
||||
block, err := aes.NewCipher(bs.encKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := make([]byte, aes.BlockSize+len(plain))
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
stream := cipher.NewCFBEncrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext[aes.BlockSize:], plain)
|
||||
return hex.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func (bs bootstrapService) decrypt(in string) ([]byte, error) {
|
||||
ciphertext, err := hex.DecodeString(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(bs.encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ciphertext) < aes.BlockSize {
|
||||
return nil, ErrExternalKeySecure
|
||||
}
|
||||
iv := ciphertext[:aes.BlockSize]
|
||||
ciphertext = ciphertext[aes.BlockSize:]
|
||||
stream := cipher.NewCFBDecrypter(block, iv)
|
||||
stream.XORKeyStream(ciphertext, ciphertext)
|
||||
return ciphertext, nil
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
svcerr "github.com/absmach/magistrala/pkg/errors/service"
|
||||
)
|
||||
|
||||
// Status represents bootstrap enrollment availability.
|
||||
type Status uint8
|
||||
|
||||
// Possible bootstrap enrollment statuses.
|
||||
const (
|
||||
EnabledStatus Status = iota
|
||||
DisabledStatus
|
||||
// AllStatus is used for querying purposes to list configs irrespective
|
||||
// of their status. It is never stored in the database.
|
||||
AllStatus
|
||||
)
|
||||
|
||||
// String representation of bootstrap status values.
|
||||
const (
|
||||
Disabled = "disabled"
|
||||
Enabled = "enabled"
|
||||
All = "all"
|
||||
Unknown = "unknown"
|
||||
)
|
||||
|
||||
// Backward-compatible aliases kept while callers move off the old names.
|
||||
const (
|
||||
Inactive = DisabledStatus
|
||||
Active = EnabledStatus
|
||||
)
|
||||
|
||||
// String returns string representation of Status.
|
||||
func (s Status) String() string {
|
||||
switch s {
|
||||
case DisabledStatus:
|
||||
return Disabled
|
||||
case EnabledStatus:
|
||||
return Enabled
|
||||
case AllStatus:
|
||||
return All
|
||||
default:
|
||||
return Unknown
|
||||
}
|
||||
}
|
||||
|
||||
// ToStatus converts a string or legacy numeric string value to Status.
|
||||
func ToStatus(status string) (Status, error) {
|
||||
switch strings.ToLower(status) {
|
||||
case "", Enabled, "0":
|
||||
return EnabledStatus, nil
|
||||
case Disabled, "1":
|
||||
return DisabledStatus, nil
|
||||
case All:
|
||||
return AllStatus, nil
|
||||
}
|
||||
return Status(0), svcerr.ErrInvalidStatus
|
||||
}
|
||||
|
||||
// MarshalJSON renders bootstrap status as a string literal.
|
||||
func (s Status) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts both string and legacy numeric bootstrap statuses.
|
||||
func (s *Status) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if data[0] != '"' {
|
||||
var n int
|
||||
if err := json.Unmarshal(data, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := ToStatus(strconv.Itoa(n))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := ToStatus(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*s = parsed
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package consumer contains events consumer for client and channel events
|
||||
// consumed by the Bootstrap service.
|
||||
package consumer
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package consumer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/absmach/magistrala/bootstrap"
|
||||
"github.com/absmach/magistrala/pkg/events"
|
||||
"github.com/absmach/magistrala/pkg/events/store"
|
||||
)
|
||||
|
||||
const stream = "events.magistrala.*.*"
|
||||
|
||||
type eventHandler struct {
|
||||
svc bootstrap.Service
|
||||
}
|
||||
|
||||
// BootstrapEventsSubscribe subscribes bootstrap config-state handlers to the event store.
|
||||
func BootstrapEventsSubscribe(ctx context.Context, svc bootstrap.Service, esURL, esConsumerName string, logger *slog.Logger) error {
|
||||
subscriber, err := store.NewSubscriber(ctx, esURL, "bootstrap-es-sub", logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subConfig := events.SubscriberConfig{
|
||||
Stream: stream,
|
||||
Consumer: esConsumerName,
|
||||
Handler: NewEventHandler(svc),
|
||||
Ordered: true,
|
||||
}
|
||||
return subscriber.Subscribe(ctx, subConfig)
|
||||
}
|
||||
|
||||
// NewEventHandler returns bootstrap events handler.
|
||||
func NewEventHandler(svc bootstrap.Service) events.EventHandler {
|
||||
return &eventHandler{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *eventHandler) Handle(_ context.Context, _ events.Event) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package events provides the events sourcing of bootstrap
|
||||
// provide replication in other service and definitions needed to support it
|
||||
package events
|
||||
@@ -1,536 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package main backfills missing built-in roles for rules.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
mglog "github.com/absmach/magistrala/logger"
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
"github.com/absmach/magistrala/pkg/policies"
|
||||
"github.com/absmach/magistrala/pkg/policies/spicedb"
|
||||
pgclient "github.com/absmach/magistrala/pkg/postgres"
|
||||
"github.com/absmach/magistrala/pkg/roles"
|
||||
spicedbdecoder "github.com/absmach/magistrala/pkg/spicedb"
|
||||
"github.com/absmach/magistrala/pkg/uuid"
|
||||
"github.com/absmach/magistrala/re"
|
||||
"github.com/absmach/magistrala/re/operations"
|
||||
repg "github.com/absmach/magistrala/re/postgres"
|
||||
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
|
||||
"github.com/authzed/authzed-go/v1"
|
||||
"github.com/authzed/grpcutil"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const cmdName = "re_backfill_roles"
|
||||
|
||||
var (
|
||||
logLevel = "info"
|
||||
dryRun = false
|
||||
limit = 0
|
||||
defaultMemberID = ""
|
||||
spicedbHost = "localhost"
|
||||
spicedbPort = "50051"
|
||||
spicedbPreSharedKey = "12345678"
|
||||
spicedbSchemaFile = "docker/spicedb/schema.zed"
|
||||
dbConfig = pgclient.Config{
|
||||
Host: "localhost",
|
||||
Port: "6009",
|
||||
User: "magistrala",
|
||||
Pass: "magistrala",
|
||||
Name: "rules_engine",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
)
|
||||
|
||||
type missingRule struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
DomainID string `db:"domain_id"`
|
||||
CreatedBy sql.NullString `db:"created_by"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
if limit < 0 {
|
||||
log.Fatalf("invalid limit %d: limit must be >= 0", limit)
|
||||
}
|
||||
|
||||
logger, err := mglog.New(os.Stdout, logLevel)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init logger: %s", err)
|
||||
}
|
||||
|
||||
var exitCode int
|
||||
defer mglog.ExitWithError(&exitCode)
|
||||
|
||||
sqlDB, err := pgclient.Connect(dbConfig)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to postgres", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
database := pgclient.NewDatabase(sqlDB, dbConfig, noop.NewTracerProvider().Tracer(cmdName))
|
||||
rulesRepo := repg.NewRepository(database)
|
||||
|
||||
rulesWithoutRoles, err := listRulesWithoutRoles(ctx, database, limit)
|
||||
if err != nil {
|
||||
logger.Error("failed to list rules without roles", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("loaded rules without roles", "count", len(rulesWithoutRoles), "dry_run", dryRun)
|
||||
if len(rulesWithoutRoles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
availableActions, builtInRoles, err := availableActionsAndBuiltInRoles(spicedbSchemaFile)
|
||||
if err != nil {
|
||||
logger.Error("failed to load built-in role actions", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
adminRoleActions, err := builtInRoleActionStrings(builtInRoles, re.BuiltInRoleAdmin)
|
||||
if err != nil {
|
||||
logger.Error("failed to resolve built-in admin role actions", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
authzedClient, err := newAuthzedClient(spicedbHost, spicedbPort, spicedbPreSharedKey)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to spicedb", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
var processed, skipped int
|
||||
|
||||
for _, rule := range rulesWithoutRoles {
|
||||
memberID := strings.TrimSpace(rule.CreatedBy.String)
|
||||
if memberID == "" {
|
||||
memberID = strings.TrimSpace(defaultMemberID)
|
||||
}
|
||||
if rule.DomainID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping rule without domain_id", "rule_id", rule.ID, "name", rule.Name)
|
||||
continue
|
||||
}
|
||||
if memberID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping rule without created_by and no default member override", "rule_id", rule.ID, "name", rule.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
isDomainMember, err := isDomainRoleMember(ctx, database, rule.DomainID, memberID)
|
||||
if err != nil {
|
||||
skipped++
|
||||
logger.Warn(
|
||||
"skipping rule after failed domain membership check",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
candidatePolicies := []policies.Policy{
|
||||
{
|
||||
SubjectType: policies.DomainType,
|
||||
Subject: rule.DomainID,
|
||||
Relation: policies.DomainRelation,
|
||||
ObjectType: operations.EntityType,
|
||||
Object: rule.ID,
|
||||
},
|
||||
}
|
||||
policiesToAdd, existingPolicies, err := filterMissingPolicies(ctx, authzedClient.PermissionsServiceClient, candidatePolicies)
|
||||
if err != nil {
|
||||
skipped++
|
||||
logger.Warn(
|
||||
"skipping rule after failed spicedb policy lookup",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, existing := range existingPolicies {
|
||||
logger.Info(
|
||||
"dry run: spicedb policy already exists, will not be re-added",
|
||||
"rule_id", rule.ID,
|
||||
"subject_type", existing.SubjectType,
|
||||
"subject", existing.Subject,
|
||||
"relation", existing.Relation,
|
||||
"object_type", existing.ObjectType,
|
||||
"object", existing.Object,
|
||||
)
|
||||
}
|
||||
|
||||
if !isDomainMember {
|
||||
logger.Warn(
|
||||
"created_by user is not a member of the domain; role will be provisioned without member",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", false,
|
||||
"role_actions", adminRoleActions,
|
||||
)
|
||||
processed++
|
||||
logger.Info(
|
||||
"dry run: would provision missing built-in role without member",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", false,
|
||||
"role_actions", adminRoleActions,
|
||||
"role_name", re.BuiltInRoleAdmin.String(),
|
||||
"new_optional_policies", len(policiesToAdd),
|
||||
"existing_optional_policies", len(existingPolicies),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
processed++
|
||||
logger.Info(
|
||||
"dry run: would provision missing built-in role",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", true,
|
||||
"role_actions", adminRoleActions,
|
||||
"role_name", re.BuiltInRoleAdmin.String(),
|
||||
"new_optional_policies", len(policiesToAdd),
|
||||
"existing_optional_policies", len(existingPolicies),
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"backfill finished",
|
||||
"processed", processed,
|
||||
"skipped", skipped,
|
||||
"failed", 0,
|
||||
"dry_run", true,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
policyService := spicedb.NewPolicyService(authzedClient, logger)
|
||||
|
||||
provisioner, err := roles.NewProvisionManageService(
|
||||
operations.EntityType,
|
||||
rulesRepo,
|
||||
policyService,
|
||||
uuid.New(),
|
||||
availableActions,
|
||||
builtInRoles,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("failed to create roles provisioner", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
var processed, skipped, failed int
|
||||
|
||||
for _, rule := range rulesWithoutRoles {
|
||||
memberID := strings.TrimSpace(rule.CreatedBy.String)
|
||||
if memberID == "" {
|
||||
memberID = strings.TrimSpace(defaultMemberID)
|
||||
}
|
||||
if rule.DomainID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping rule without domain_id", "rule_id", rule.ID, "name", rule.Name)
|
||||
continue
|
||||
}
|
||||
if memberID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping rule without created_by and no default member override", "rule_id", rule.ID, "name", rule.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
assignMembers := []roles.Member{}
|
||||
isDomainMember, err := isDomainRoleMember(ctx, database, rule.DomainID, memberID)
|
||||
if err != nil {
|
||||
failed++
|
||||
logger.Error(
|
||||
"failed to check domain membership before provisioning role",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if isDomainMember {
|
||||
assignMembers = []roles.Member{roles.Member(memberID)}
|
||||
} else {
|
||||
logger.Warn(
|
||||
"created_by user is not a member of the domain; provisioning role without member",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", false,
|
||||
"role_actions", adminRoleActions,
|
||||
)
|
||||
}
|
||||
|
||||
candidatePolicies := []policies.Policy{
|
||||
{
|
||||
SubjectType: policies.DomainType,
|
||||
Subject: rule.DomainID,
|
||||
Relation: policies.DomainRelation,
|
||||
ObjectType: operations.EntityType,
|
||||
Object: rule.ID,
|
||||
},
|
||||
}
|
||||
optionalPolicies, existingPolicies, err := filterMissingPolicies(ctx, authzedClient.PermissionsServiceClient, candidatePolicies)
|
||||
if err != nil {
|
||||
failed++
|
||||
logger.Error(
|
||||
"failed to check existing spicedb policies",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, existing := range existingPolicies {
|
||||
logger.Info(
|
||||
"spicedb policy already exists, skipping re-add",
|
||||
"rule_id", rule.ID,
|
||||
"subject_type", existing.SubjectType,
|
||||
"subject", existing.Subject,
|
||||
"relation", existing.Relation,
|
||||
"object_type", existing.ObjectType,
|
||||
"object", existing.Object,
|
||||
)
|
||||
}
|
||||
|
||||
newBuiltInRoleMembers := map[roles.BuiltInRoleName][]roles.Member{
|
||||
re.BuiltInRoleAdmin: assignMembers,
|
||||
}
|
||||
|
||||
if _, err := provisioner.AddNewEntitiesRoles(
|
||||
ctx,
|
||||
rule.DomainID,
|
||||
memberID,
|
||||
[]string{rule.ID},
|
||||
optionalPolicies,
|
||||
newBuiltInRoleMembers,
|
||||
); err != nil {
|
||||
failed++
|
||||
logger.Error(
|
||||
"failed to provision missing built-in role",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
processed++
|
||||
logger.Info(
|
||||
"provisioned missing built-in role",
|
||||
"rule_id", rule.ID,
|
||||
"name", rule.Name,
|
||||
"domain_id", rule.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", isDomainMember,
|
||||
"member_added", len(assignMembers) > 0,
|
||||
"role_actions", adminRoleActions,
|
||||
"role_name", re.BuiltInRoleAdmin.String(),
|
||||
"new_optional_policies", len(optionalPolicies),
|
||||
"existing_optional_policies", len(existingPolicies),
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"backfill finished",
|
||||
"processed", processed,
|
||||
"skipped", skipped,
|
||||
"failed", failed,
|
||||
"dry_run", dryRun,
|
||||
)
|
||||
|
||||
if failed > 0 {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
func listRulesWithoutRoles(ctx context.Context, db pgclient.Database, limit int) ([]missingRule, error) {
|
||||
params := map[string]any{}
|
||||
|
||||
query := `
|
||||
SELECT r.id, r.name, r.domain_id, r.created_by
|
||||
FROM rules r
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM rules_roles rr
|
||||
WHERE rr.entity_id = r.id
|
||||
)
|
||||
`
|
||||
|
||||
query += " ORDER BY r.created_at ASC NULLS LAST, r.id ASC"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT :limit"
|
||||
params["limit"] = limit
|
||||
}
|
||||
|
||||
rows, err := db.NamedQueryContext(ctx, query, params)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(fmt.Errorf("failed to query rules without roles"), err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rules []missingRule
|
||||
for rows.Next() {
|
||||
var rule missingRule
|
||||
if err := rows.StructScan(&rule); err != nil {
|
||||
return nil, errors.Wrap(fmt.Errorf("failed to scan rule without role"), err)
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.Wrap(fmt.Errorf("failed to iterate rules without roles"), err)
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func isDomainRoleMember(ctx context.Context, db pgclient.Database, domainID, memberID string) (bool, error) {
|
||||
const query = `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM domains_role_members drm
|
||||
WHERE drm.entity_id = $1 AND drm.member_id = $2
|
||||
)
|
||||
`
|
||||
|
||||
var exists bool
|
||||
if err := db.QueryRowxContext(ctx, query, domainID, memberID).Scan(&exists); err != nil {
|
||||
return false, errors.Wrap(fmt.Errorf("failed to check domain role membership"), err)
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func newAuthzedClient(spicedbHost, spicedbPort, spicedbPreSharedKey string) (*authzed.ClientWithExperimental, error) {
|
||||
return authzed.NewClientWithExperimentalAPIs(
|
||||
fmt.Sprintf("%s:%s", spicedbHost, spicedbPort),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpcutil.WithInsecureBearerToken(spicedbPreSharedKey),
|
||||
)
|
||||
}
|
||||
|
||||
// filterMissingPolicies splits the given policies into those that do not yet
|
||||
// exist in SpiceDB (returned first) and those that already exist (returned
|
||||
// second). Any error from SpiceDB short-circuits with an empty result.
|
||||
func filterMissingPolicies(ctx context.Context, permClient v1.PermissionsServiceClient, ps []policies.Policy) ([]policies.Policy, []policies.Policy, error) {
|
||||
missing := make([]policies.Policy, 0, len(ps))
|
||||
existing := make([]policies.Policy, 0)
|
||||
for _, p := range ps {
|
||||
ok, err := policyExists(ctx, permClient, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if ok {
|
||||
existing = append(existing, p)
|
||||
continue
|
||||
}
|
||||
missing = append(missing, p)
|
||||
}
|
||||
return missing, existing, nil
|
||||
}
|
||||
|
||||
// policyExists returns true when SpiceDB already contains a relationship
|
||||
// matching the supplied policy on (object_type, object, relation, subject_type,
|
||||
// subject). The lookup is fully consistent and capped at one row.
|
||||
func policyExists(ctx context.Context, permClient v1.PermissionsServiceClient, p policies.Policy) (bool, error) {
|
||||
req := &v1.ReadRelationshipsRequest{
|
||||
Consistency: &v1.Consistency{
|
||||
Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true},
|
||||
},
|
||||
RelationshipFilter: &v1.RelationshipFilter{
|
||||
ResourceType: p.ObjectType,
|
||||
OptionalResourceId: p.Object,
|
||||
OptionalRelation: p.Relation,
|
||||
OptionalSubjectFilter: &v1.SubjectFilter{
|
||||
SubjectType: p.SubjectType,
|
||||
OptionalSubjectId: p.Subject,
|
||||
},
|
||||
},
|
||||
OptionalLimit: 1,
|
||||
}
|
||||
|
||||
stream, err := permClient.ReadRelationships(ctx, req)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(fmt.Errorf("failed to read spicedb relationships"), err)
|
||||
}
|
||||
|
||||
for {
|
||||
_, err := stream.Recv()
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Contains(err, io.EOF):
|
||||
return false, nil
|
||||
default:
|
||||
return false, errors.Wrap(fmt.Errorf("failed to receive spicedb relationship"), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func availableActionsAndBuiltInRoles(spicedbSchemaFile string) ([]roles.Action, map[roles.BuiltInRoleName][]roles.Action, error) {
|
||||
availableActions, err := spicedbdecoder.GetActionsFromSchema(spicedbSchemaFile, operations.EntityType)
|
||||
if err != nil {
|
||||
return []roles.Action{}, map[roles.BuiltInRoleName][]roles.Action{}, err
|
||||
}
|
||||
|
||||
builtInRoles := map[roles.BuiltInRoleName][]roles.Action{
|
||||
re.BuiltInRoleAdmin: availableActions,
|
||||
}
|
||||
|
||||
return availableActions, builtInRoles, nil
|
||||
}
|
||||
|
||||
func builtInRoleActionStrings(builtInRoles map[roles.BuiltInRoleName][]roles.Action, roleName roles.BuiltInRoleName) ([]string, error) {
|
||||
actions, ok := builtInRoles[roleName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("built-in role %q not found", roleName)
|
||||
}
|
||||
|
||||
ret := make([]string, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
ret = append(ret, action.String())
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package main backfills missing built-in roles for reports.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
mglog "github.com/absmach/magistrala/logger"
|
||||
"github.com/absmach/magistrala/pkg/errors"
|
||||
"github.com/absmach/magistrala/pkg/policies"
|
||||
"github.com/absmach/magistrala/pkg/policies/spicedb"
|
||||
pgclient "github.com/absmach/magistrala/pkg/postgres"
|
||||
"github.com/absmach/magistrala/pkg/roles"
|
||||
spicedbdecoder "github.com/absmach/magistrala/pkg/spicedb"
|
||||
"github.com/absmach/magistrala/pkg/uuid"
|
||||
"github.com/absmach/magistrala/reports"
|
||||
"github.com/absmach/magistrala/reports/operations"
|
||||
repg "github.com/absmach/magistrala/reports/postgres"
|
||||
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
|
||||
"github.com/authzed/authzed-go/v1"
|
||||
"github.com/authzed/grpcutil"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
cmdName = "reports_backfill_roles"
|
||||
)
|
||||
|
||||
var (
|
||||
logLevel = "info"
|
||||
dryRun = false
|
||||
limit = 0
|
||||
defaultMemberID = ""
|
||||
spicedbHost = "localhost"
|
||||
spicedbPort = "50051"
|
||||
spicedbPreSharedKey = "12345678"
|
||||
spicedbSchemaFile = "docker/spicedb/schema.zed"
|
||||
dbConfig = pgclient.Config{
|
||||
Host: "localhost",
|
||||
Port: "6020",
|
||||
User: "magistrala",
|
||||
Pass: "magistrala",
|
||||
Name: "reports",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
)
|
||||
|
||||
type missingReport struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
DomainID string `db:"domain_id"`
|
||||
CreatedBy sql.NullString `db:"created_by"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
if limit < 0 {
|
||||
log.Fatalf("invalid limit %d: limit must be >= 0", limit)
|
||||
}
|
||||
|
||||
logger, err := mglog.New(os.Stdout, logLevel)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init logger: %s", err)
|
||||
}
|
||||
|
||||
var exitCode int
|
||||
defer mglog.ExitWithError(&exitCode)
|
||||
|
||||
sqlDB, err := pgclient.Connect(dbConfig)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to postgres", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
database := pgclient.NewDatabase(sqlDB, dbConfig, noop.NewTracerProvider().Tracer(cmdName))
|
||||
reportsRepo := repg.NewRepository(database)
|
||||
|
||||
reportsWithoutRoles, err := listReportsWithoutRoles(ctx, database, limit)
|
||||
if err != nil {
|
||||
logger.Error("failed to list reports without roles", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("loaded reports without roles", "count", len(reportsWithoutRoles), "dry_run", dryRun)
|
||||
if len(reportsWithoutRoles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
availableActions, builtInRoles, err := availableActionsAndBuiltInRoles(spicedbSchemaFile)
|
||||
if err != nil {
|
||||
logger.Error("failed to load built-in role actions", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
adminRoleActions, err := builtInRoleActionStrings(builtInRoles, reports.BuiltInRoleAdmin)
|
||||
if err != nil {
|
||||
logger.Error("failed to resolve built-in admin role actions", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
authzedClient, err := newAuthzedClient(spicedbHost, spicedbPort, spicedbPreSharedKey)
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to spicedb", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
var processed, skipped int
|
||||
|
||||
for _, report := range reportsWithoutRoles {
|
||||
memberID := strings.TrimSpace(report.CreatedBy.String)
|
||||
if memberID == "" {
|
||||
memberID = strings.TrimSpace(defaultMemberID)
|
||||
}
|
||||
if report.DomainID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping report without domain_id", "report_id", report.ID, "name", report.Name)
|
||||
continue
|
||||
}
|
||||
if memberID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping report without created_by and no default member override", "report_id", report.ID, "name", report.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
isDomainMember, err := isDomainRoleMember(ctx, database, report.DomainID, memberID)
|
||||
if err != nil {
|
||||
skipped++
|
||||
logger.Warn(
|
||||
"skipping report after failed domain membership check",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
candidatePolicies := []policies.Policy{
|
||||
{
|
||||
SubjectType: policies.DomainType,
|
||||
Subject: report.DomainID,
|
||||
Relation: policies.DomainRelation,
|
||||
ObjectType: operations.EntityType,
|
||||
Object: report.ID,
|
||||
},
|
||||
}
|
||||
policiesToAdd, existingPolicies, err := filterMissingPolicies(ctx, authzedClient.PermissionsServiceClient, candidatePolicies)
|
||||
if err != nil {
|
||||
skipped++
|
||||
logger.Warn(
|
||||
"skipping report after failed spicedb policy lookup",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, existing := range existingPolicies {
|
||||
logger.Info(
|
||||
"dry run: spicedb policy already exists, will not be re-added",
|
||||
"report_id", report.ID,
|
||||
"subject_type", existing.SubjectType,
|
||||
"subject", existing.Subject,
|
||||
"relation", existing.Relation,
|
||||
"object_type", existing.ObjectType,
|
||||
"object", existing.Object,
|
||||
)
|
||||
}
|
||||
|
||||
if !isDomainMember {
|
||||
logger.Warn(
|
||||
"created_by user is not a member of the domain; role will be provisioned without member",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", false,
|
||||
"role_actions", adminRoleActions,
|
||||
)
|
||||
processed++
|
||||
logger.Info(
|
||||
"dry run: would provision missing built-in role without member",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", false,
|
||||
"role_actions", adminRoleActions,
|
||||
"role_name", reports.BuiltInRoleAdmin.String(),
|
||||
"new_optional_policies", len(policiesToAdd),
|
||||
"existing_optional_policies", len(existingPolicies),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
processed++
|
||||
logger.Info(
|
||||
"dry run: would provision missing built-in role",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", true,
|
||||
"role_actions", adminRoleActions,
|
||||
"role_name", reports.BuiltInRoleAdmin.String(),
|
||||
"new_optional_policies", len(policiesToAdd),
|
||||
"existing_optional_policies", len(existingPolicies),
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"backfill finished",
|
||||
"processed", processed,
|
||||
"skipped", skipped,
|
||||
"failed", 0,
|
||||
"dry_run", true,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
policyService := spicedb.NewPolicyService(authzedClient, logger)
|
||||
|
||||
provisioner, err := roles.NewProvisionManageService(
|
||||
operations.EntityType,
|
||||
reportsRepo,
|
||||
policyService,
|
||||
uuid.New(),
|
||||
availableActions,
|
||||
builtInRoles,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("failed to create roles provisioner", "error", err)
|
||||
exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
var processed, skipped, failed int
|
||||
|
||||
for _, report := range reportsWithoutRoles {
|
||||
memberID := strings.TrimSpace(report.CreatedBy.String)
|
||||
if memberID == "" {
|
||||
memberID = strings.TrimSpace(defaultMemberID)
|
||||
}
|
||||
if report.DomainID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping report without domain_id", "report_id", report.ID, "name", report.Name)
|
||||
continue
|
||||
}
|
||||
if memberID == "" {
|
||||
skipped++
|
||||
logger.Warn("skipping report without created_by and no default member override", "report_id", report.ID, "name", report.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
assignMembers := []roles.Member{}
|
||||
isDomainMember, err := isDomainRoleMember(ctx, database, report.DomainID, memberID)
|
||||
if err != nil {
|
||||
failed++
|
||||
logger.Error(
|
||||
"failed to check domain membership before provisioning role",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if isDomainMember {
|
||||
assignMembers = []roles.Member{roles.Member(memberID)}
|
||||
} else {
|
||||
logger.Warn(
|
||||
"created_by user is not a member of the domain; provisioning role without member",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", false,
|
||||
"role_actions", adminRoleActions,
|
||||
)
|
||||
}
|
||||
|
||||
candidatePolicies := []policies.Policy{
|
||||
{
|
||||
SubjectType: policies.DomainType,
|
||||
Subject: report.DomainID,
|
||||
Relation: policies.DomainRelation,
|
||||
ObjectType: operations.EntityType,
|
||||
Object: report.ID,
|
||||
},
|
||||
}
|
||||
optionalPolicies, existingPolicies, err := filterMissingPolicies(ctx, authzedClient.PermissionsServiceClient, candidatePolicies)
|
||||
if err != nil {
|
||||
failed++
|
||||
logger.Error(
|
||||
"failed to check existing spicedb policies",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, existing := range existingPolicies {
|
||||
logger.Info(
|
||||
"spicedb policy already exists, skipping re-add",
|
||||
"report_id", report.ID,
|
||||
"subject_type", existing.SubjectType,
|
||||
"subject", existing.Subject,
|
||||
"relation", existing.Relation,
|
||||
"object_type", existing.ObjectType,
|
||||
"object", existing.Object,
|
||||
)
|
||||
}
|
||||
|
||||
newBuiltInRoleMembers := map[roles.BuiltInRoleName][]roles.Member{
|
||||
reports.BuiltInRoleAdmin: assignMembers,
|
||||
}
|
||||
|
||||
if _, err := provisioner.AddNewEntitiesRoles(
|
||||
ctx,
|
||||
report.DomainID,
|
||||
memberID,
|
||||
[]string{report.ID},
|
||||
optionalPolicies,
|
||||
newBuiltInRoleMembers,
|
||||
); err != nil {
|
||||
failed++
|
||||
logger.Error(
|
||||
"failed to provision missing built-in role",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
processed++
|
||||
logger.Info(
|
||||
"provisioned missing built-in role",
|
||||
"report_id", report.ID,
|
||||
"name", report.Name,
|
||||
"domain_id", report.DomainID,
|
||||
"member_id", memberID,
|
||||
"created_by_exists_in_domain", isDomainMember,
|
||||
"member_added", len(assignMembers) > 0,
|
||||
"role_actions", adminRoleActions,
|
||||
"role_name", reports.BuiltInRoleAdmin.String(),
|
||||
"new_optional_policies", len(optionalPolicies),
|
||||
"existing_optional_policies", len(existingPolicies),
|
||||
)
|
||||
}
|
||||
|
||||
logger.Info(
|
||||
"backfill finished",
|
||||
"processed", processed,
|
||||
"skipped", skipped,
|
||||
"failed", failed,
|
||||
"dry_run", dryRun,
|
||||
)
|
||||
|
||||
if failed > 0 {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
func listReportsWithoutRoles(ctx context.Context, db pgclient.Database, limit int) ([]missingReport, error) {
|
||||
params := map[string]any{}
|
||||
|
||||
query := `
|
||||
SELECT rc.id, rc.name, rc.domain_id, rc.created_by
|
||||
FROM report_config rc
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM reports_roles rr
|
||||
WHERE rr.entity_id = rc.id
|
||||
)
|
||||
`
|
||||
|
||||
query += " ORDER BY rc.created_at ASC NULLS LAST, rc.id ASC"
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT :limit"
|
||||
params["limit"] = limit
|
||||
}
|
||||
|
||||
rows, err := db.NamedQueryContext(ctx, query, params)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(fmt.Errorf("failed to query reports without roles"), err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var reps []missingReport
|
||||
for rows.Next() {
|
||||
var rep missingReport
|
||||
if err := rows.StructScan(&rep); err != nil {
|
||||
return nil, errors.Wrap(fmt.Errorf("failed to scan report without role"), err)
|
||||
}
|
||||
reps = append(reps, rep)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.Wrap(fmt.Errorf("failed to iterate reports without roles"), err)
|
||||
}
|
||||
|
||||
return reps, nil
|
||||
}
|
||||
|
||||
func isDomainRoleMember(ctx context.Context, db pgclient.Database, domainID, memberID string) (bool, error) {
|
||||
const query = `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM domains_role_members drm
|
||||
WHERE drm.entity_id = $1 AND drm.member_id = $2
|
||||
)
|
||||
`
|
||||
|
||||
var exists bool
|
||||
if err := db.QueryRowxContext(ctx, query, domainID, memberID).Scan(&exists); err != nil {
|
||||
return false, errors.Wrap(fmt.Errorf("failed to check domain role membership"), err)
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func newAuthzedClient(spicedbHost, spicedbPort, spicedbPreSharedKey string) (*authzed.ClientWithExperimental, error) {
|
||||
return authzed.NewClientWithExperimentalAPIs(
|
||||
fmt.Sprintf("%s:%s", spicedbHost, spicedbPort),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpcutil.WithInsecureBearerToken(spicedbPreSharedKey),
|
||||
)
|
||||
}
|
||||
|
||||
func filterMissingPolicies(ctx context.Context, permClient v1.PermissionsServiceClient, ps []policies.Policy) ([]policies.Policy, []policies.Policy, error) {
|
||||
missing := make([]policies.Policy, 0, len(ps))
|
||||
existing := make([]policies.Policy, 0)
|
||||
for _, p := range ps {
|
||||
ok, err := policyExists(ctx, permClient, p)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if ok {
|
||||
existing = append(existing, p)
|
||||
continue
|
||||
}
|
||||
missing = append(missing, p)
|
||||
}
|
||||
return missing, existing, nil
|
||||
}
|
||||
|
||||
func policyExists(ctx context.Context, permClient v1.PermissionsServiceClient, p policies.Policy) (bool, error) {
|
||||
req := &v1.ReadRelationshipsRequest{
|
||||
Consistency: &v1.Consistency{
|
||||
Requirement: &v1.Consistency_FullyConsistent{FullyConsistent: true},
|
||||
},
|
||||
RelationshipFilter: &v1.RelationshipFilter{
|
||||
ResourceType: p.ObjectType,
|
||||
OptionalResourceId: p.Object,
|
||||
OptionalRelation: p.Relation,
|
||||
OptionalSubjectFilter: &v1.SubjectFilter{
|
||||
SubjectType: p.SubjectType,
|
||||
OptionalSubjectId: p.Subject,
|
||||
},
|
||||
},
|
||||
OptionalLimit: 1,
|
||||
}
|
||||
|
||||
stream, err := permClient.ReadRelationships(ctx, req)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(fmt.Errorf("failed to read spicedb relationships"), err)
|
||||
}
|
||||
|
||||
for {
|
||||
_, err := stream.Recv()
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Contains(err, io.EOF):
|
||||
return false, nil
|
||||
default:
|
||||
return false, errors.Wrap(fmt.Errorf("failed to receive spicedb relationship"), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func availableActionsAndBuiltInRoles(spicedbSchemaFile string) ([]roles.Action, map[roles.BuiltInRoleName][]roles.Action, error) {
|
||||
availableActions, err := spicedbdecoder.GetActionsFromSchema(spicedbSchemaFile, operations.EntityType)
|
||||
if err != nil {
|
||||
return []roles.Action{}, map[roles.BuiltInRoleName][]roles.Action{}, err
|
||||
}
|
||||
|
||||
builtInRoles := map[roles.BuiltInRoleName][]roles.Action{
|
||||
reports.BuiltInRoleAdmin: availableActions,
|
||||
}
|
||||
|
||||
return availableActions, builtInRoles, nil
|
||||
}
|
||||
|
||||
func builtInRoleActionStrings(builtInRoles map[roles.BuiltInRoleName][]roles.Action, roleName roles.BuiltInRoleName) ([]string, error) {
|
||||
actions, ok := builtInRoles[roleName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("built-in role %q not found", roleName)
|
||||
}
|
||||
|
||||
ret := make([]string, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
ret = append(ret, action.String())
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
# Backfill Roles — Testing Guide
|
||||
|
||||
This document covers end-to-end testing of the migration scripts on the `migrations` branch:
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/re-backfill-roles/` | Backfills missing built-in admin roles for **rules** (RE service) |
|
||||
| `scripts/reports-backfill-roles/` | Backfills missing built-in admin roles for **reports** |
|
||||
| `scripts/seed-test-data/` | Seeds all required test data across databases and SpiceDB |
|
||||
| `domains/postgres/init.go` | Migration adding `alarm_*` and `report_*` actions to the domain admin role |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Infrastructure
|
||||
|
||||
Start the required containers:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up -d \
|
||||
spicedb-db spicedb-migrate spicedb \
|
||||
auth-db auth \
|
||||
domains-db domains \
|
||||
re-db re \
|
||||
reports-db reports \
|
||||
alarms-db alarms
|
||||
```
|
||||
|
||||
Wait until all services are healthy. Each service applies its own Postgres migrations on startup, creating the required schemas. The `auth` service is required because it writes the SpiceDB schema on startup — without it, the seed script and backfill scripts fail with `object definition not found`.
|
||||
|
||||
### Connection Details (docker-compose defaults)
|
||||
|
||||
| Service | Host | Port | User | Password | Database |
|
||||
|---------|------|------|------|----------|----------|
|
||||
| Domains DB | localhost | 6003 | magistrala | magistrala | domains |
|
||||
| RE DB | localhost | 6009 | magistrala | magistrala | rules_engine |
|
||||
| Reports DB | localhost | 6020 | magistrala | magistrala | reports |
|
||||
| Alarms DB | localhost | 6019 | magistrala | magistrala | alarms |
|
||||
| SpiceDB gRPC | localhost | 50051 | — | 12345678 (pre-shared key) | — |
|
||||
|
||||
### Fix Hard-Coded Configs (if needed)
|
||||
|
||||
The backfill scripts have hard-coded database configs. Before running, verify they match your environment:
|
||||
|
||||
**`scripts/re-backfill-roles/main.go` (lines 48–55):**
|
||||
|
||||
```go
|
||||
dbConfig = pgclient.Config{
|
||||
Host: "localhost",
|
||||
Port: "6009", // docker-compose: 6009 (NOT 15432)
|
||||
User: "magistrala", // docker-compose: magistrala (NOT postgres)
|
||||
Pass: "magistrala", // docker-compose: magistrala (NOT supermq)
|
||||
Name: "rules_engine",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
```
|
||||
|
||||
**`scripts/reports-backfill-roles/main.go` (lines 49–56):**
|
||||
|
||||
```go
|
||||
dbConfig = pgclient.Config{
|
||||
Host: "localhost",
|
||||
Port: "6020", // docker-compose: 6020 (NOT 15432)
|
||||
User: "magistrala", // docker-compose: magistrala (NOT postgres)
|
||||
Pass: "magistrala", // docker-compose: magistrala (NOT supermq)
|
||||
Name: "reports",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
```
|
||||
|
||||
**Both scripts — SpiceDB schema file (line 47):**
|
||||
|
||||
```go
|
||||
spicedbSchemaFile = "docker/spicedb/schema.zed" // NOT combined-schema.zed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Seed Test Data
|
||||
|
||||
```bash
|
||||
go run ./scripts/seed-test-data/
|
||||
```
|
||||
|
||||
This inserts deterministic test data across all four databases and SpiceDB. It is idempotent (uses `ON CONFLICT DO NOTHING`), so re-running is safe.
|
||||
|
||||
### What Gets Created
|
||||
|
||||
**Domain:**
|
||||
|
||||
| ID | Name |
|
||||
|----|------|
|
||||
| `d0000000-0000-0000-0000-000000000001` | seed-test-domain |
|
||||
|
||||
**Users:**
|
||||
|
||||
| ID | Domain Membership |
|
||||
|----|-------------------|
|
||||
| `u0000000-0000-0000-0000-000000000001` (user1) | Member of domain (in `domains_role_members`) |
|
||||
| `u0000000-0000-0000-0000-000000000002` (user2) | NOT a domain member |
|
||||
|
||||
**Rules (RE DB) — 6 rules, 4 orphans:**
|
||||
|
||||
| Rule ID | Name | Scenario |
|
||||
|---------|------|----------|
|
||||
| `r0000000-...-000000000001` | rule-1-member-creator | Orphan. `created_by=user1` (domain member). Backfill should create role **with** member. |
|
||||
| `r0000000-...-000000000002` | rule-2-nonmember-creator | Orphan. `created_by=user2` (NOT member). Backfill should create role **without** member. |
|
||||
| `r0000000-...-000000000003` | rule-3-spicedb-exists | Orphan. `created_by=user1`. SpiceDB parent relation **pre-seeded**. Tests `policyExists` check. |
|
||||
| `r0000000-...-000000000004` | rule-4-null-creator | Orphan. `created_by=NULL`. Should be **skipped**. |
|
||||
| `r0000000-...-000000000005` | rule-5-no-domain | Orphan. `domain_id=""`. Should be **skipped**. |
|
||||
| `r0000000-...-000000000006` | rule-6-has-role-already | Has `rules_roles` entry. Should **NOT appear** in orphan list. |
|
||||
|
||||
**Reports (Reports DB) — 5 reports, 3 orphans:**
|
||||
|
||||
| Report ID | Name | Scenario |
|
||||
|-----------|------|----------|
|
||||
| `rp000000-...-000000000001` | report-1-member-creator | Orphan. `created_by=user1`. Backfill should create role **with** member. |
|
||||
| `rp000000-...-000000000002` | report-2-nonmember-creator | Orphan. `created_by=user2`. Backfill should create role **without** member. |
|
||||
| `rp000000-...-000000000003` | report-3-spicedb-exists | Orphan. `created_by=user1`. SpiceDB parent **pre-seeded**. Tests `policyExists`. |
|
||||
| `rp000000-...-000000000004` | report-4-null-creator | Orphan. `created_by=NULL`. Should be **skipped**. |
|
||||
| `rp000000-...-000000000005` | report-5-has-role-already | Has `reports_roles` entry. Should **NOT appear**. |
|
||||
|
||||
**Alarms (Alarms DB) — 2 alarms:**
|
||||
|
||||
| Alarm ID | Linked Rule |
|
||||
|----------|-------------|
|
||||
| `a0000000-...-000000000001` | rule-1 |
|
||||
| `a0000000-...-000000000002` | rule-2 |
|
||||
|
||||
**SpiceDB (pre-seeded parent relations):**
|
||||
|
||||
```
|
||||
rule:r0000000-...-000000000003#domain@domain:d0000000-...-000000000001
|
||||
report:rp000000-...-000000000003#domain@domain:d0000000-...-000000000001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Verify Seed Data
|
||||
|
||||
### Check orphan rules in RE DB
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
SELECT r.id, r.name, r.domain_id, r.created_by
|
||||
FROM rules r
|
||||
WHERE NOT EXISTS (SELECT 1 FROM rules_roles rr WHERE rr.entity_id = r.id)
|
||||
ORDER BY r.name;"
|
||||
```
|
||||
|
||||
**Expected:** 5 rows (rule-1 through rule-5). **rule-6 should NOT appear** (it has a role).
|
||||
|
||||
### Check orphan reports in Reports DB
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 6020 -U magistrala -d reports -c "
|
||||
SELECT rc.id, rc.name, rc.domain_id, rc.created_by
|
||||
FROM report_config rc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM reports_roles rr WHERE rr.entity_id = rc.id)
|
||||
ORDER BY rc.name;"
|
||||
```
|
||||
|
||||
**Expected:** 4 rows (report-1 through report-4). **report-5 should NOT appear**.
|
||||
|
||||
### Check domain membership
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
SELECT * FROM domains_role_members
|
||||
WHERE entity_id = 'd0000000-0000-0000-0000-000000000001';"
|
||||
```
|
||||
|
||||
**Expected:** 1 row for `user1`. `user2` should NOT be present.
|
||||
|
||||
### Check SpiceDB pre-seeded relationships
|
||||
|
||||
```bash
|
||||
zed relationship read rule \
|
||||
--insecure --endpoint localhost:50051 --token 12345678
|
||||
```
|
||||
|
||||
**Expected:** At least one relationship for `rule:r0000000-...-000000000003#domain@domain:d0000000-...-000000000001`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Test RE Backfill (Dry Run)
|
||||
|
||||
Set `dryRun = true` in `scripts/re-backfill-roles/main.go`, then:
|
||||
|
||||
```bash
|
||||
go run ./scripts/re-backfill-roles/
|
||||
```
|
||||
|
||||
### Expected Log Output
|
||||
|
||||
| Rule | Expected Log |
|
||||
|------|-------------|
|
||||
| rule-1 | `"dry run: would provision missing built-in role"` with `created_by_exists_in_domain=true` |
|
||||
| rule-2 | `"created_by user is not a member of the domain"` + `"dry run: would provision missing built-in role without member"` |
|
||||
| rule-3 | `"dry run: spicedb policy already exists, will not be re-added"` + `"dry run: would provision"` with `new_optional_policies=0, existing_optional_policies=1` |
|
||||
| rule-4 | `"skipping rule without created_by and no default member override"` |
|
||||
| rule-5 | `"skipping rule without domain_id"` |
|
||||
| rule-6 | Does NOT appear at all |
|
||||
|
||||
### Verify No Side Effects
|
||||
|
||||
```bash
|
||||
# Postgres: no new roles created
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
SELECT COUNT(*) FROM rules_roles
|
||||
WHERE entity_id IN (
|
||||
'r0000000-0000-0000-0000-000000000001',
|
||||
'r0000000-0000-0000-0000-000000000002',
|
||||
'r0000000-0000-0000-0000-000000000003'
|
||||
);"
|
||||
```
|
||||
|
||||
**Expected:** `0` (dry run should not write anything).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Test RE Backfill (Real Run)
|
||||
|
||||
Set `dryRun = false` in `scripts/re-backfill-roles/main.go`, then:
|
||||
|
||||
```bash
|
||||
go run ./scripts/re-backfill-roles/
|
||||
```
|
||||
|
||||
### Expected Log Output
|
||||
|
||||
| Rule | Expected Log |
|
||||
|------|-------------|
|
||||
| rule-1 | `"provisioned missing built-in role"` with `member_added=true` |
|
||||
| rule-2 | `"provisioned missing built-in role"` with `member_added=false` |
|
||||
| rule-3 | `"spicedb policy already exists, skipping re-add"` + `"provisioned missing built-in role"` with `new_optional_policies=0` |
|
||||
| rule-4 | `"skipping rule without created_by"` |
|
||||
| rule-5 | `"skipping rule without domain_id"` |
|
||||
|
||||
Final summary should show: `processed=3, skipped=2, failed=0`.
|
||||
|
||||
### Verify in Postgres
|
||||
|
||||
```bash
|
||||
# New roles exist for rules 1, 2, 3
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
SELECT rr.id, rr.entity_id, rr.name, rr.created_by
|
||||
FROM rules_roles rr
|
||||
ORDER BY rr.entity_id;"
|
||||
|
||||
# Role members: rule-1 should have user1; rule-2 and rule-3 check based on domain membership
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
SELECT rrm.role_id, rrm.member_id, rrm.entity_id
|
||||
FROM rules_role_members rrm
|
||||
ORDER BY rrm.entity_id;"
|
||||
```
|
||||
|
||||
### Verify in SpiceDB
|
||||
|
||||
```bash
|
||||
zed relationship read rule \
|
||||
--insecure --endpoint localhost:50051 --token 12345678
|
||||
```
|
||||
|
||||
**Expected:** Parent relations for rule-1 and rule-2 are newly created. Rule-3 already had one (no duplicate).
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Test Idempotency (Re-Run)
|
||||
|
||||
Run the same backfill again without any changes:
|
||||
|
||||
```bash
|
||||
go run ./scripts/re-backfill-roles/
|
||||
```
|
||||
|
||||
**Expected:** `"loaded rules without roles" count=2` and `"backfill finished" processed=0, skipped=2, failed=0`. The two remaining rows are rule-4 (`created_by=NULL`) and rule-5 (no `domain_id`), which always re-appear in the orphan query and are skipped each run. The idempotency signal is `processed=0` — no roles or SpiceDB writes are duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Test Partial State (policyExists Path)
|
||||
|
||||
This specifically validates the SpiceDB pre-check. Simulate a scenario where Postgres lost the role but SpiceDB still has the parent relation:
|
||||
|
||||
```bash
|
||||
# Delete just the Postgres role for rule-1
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
DELETE FROM rules_roles
|
||||
WHERE entity_id = 'r0000000-0000-0000-0000-000000000001';"
|
||||
|
||||
# Re-run backfill
|
||||
go run ./scripts/re-backfill-roles/
|
||||
```
|
||||
|
||||
### Expected
|
||||
|
||||
- `"loaded rules without roles" count=1` (only rule-1 reappears)
|
||||
- `"spicedb policy already exists, skipping re-add"` — the parent relation is detected and filtered out
|
||||
- `"provisioned missing built-in role"` with `new_optional_policies=0, existing_optional_policies=1`
|
||||
- The role row is re-created in Postgres **without** a duplicate SpiceDB write
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
# Postgres: role restored
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
SELECT * FROM rules_roles
|
||||
WHERE entity_id = 'r0000000-0000-0000-0000-000000000001';"
|
||||
|
||||
# SpiceDB: still exactly one parent relation (no duplicate)
|
||||
zed relationship read rule:r0000000-0000-0000-0000-000000000001 \
|
||||
--insecure --endpoint localhost:50051 --token 12345678
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Test Reports Backfill
|
||||
|
||||
Repeat Steps 3–6 for the reports backfill script:
|
||||
|
||||
```bash
|
||||
# Dry run (set dryRun = true first)
|
||||
go run ./scripts/reports-backfill-roles/
|
||||
|
||||
# Real run (set dryRun = false)
|
||||
go run ./scripts/reports-backfill-roles/
|
||||
```
|
||||
|
||||
### Expected behavior
|
||||
|
||||
| Report | Expected |
|
||||
|--------|----------|
|
||||
| report-1 | Role provisioned **with** member (user1 is domain member) |
|
||||
| report-2 | Role provisioned **without** member (user2 not in domain) |
|
||||
| report-3 | `"spicedb policy already exists"` + role provisioned with `new_optional_policies=0` |
|
||||
| report-4 | Skipped (NULL `created_by`) |
|
||||
| report-5 | Does not appear (already has role) |
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 6020 -U magistrala -d reports -c "
|
||||
SELECT rr.id, rr.entity_id, rr.name
|
||||
FROM reports_roles rr
|
||||
ORDER BY rr.entity_id;"
|
||||
|
||||
zed relationship read report \
|
||||
--insecure --endpoint localhost:50051 --token 12345678
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Verify Domains Migration
|
||||
|
||||
The `domains/postgres/init.go` change adds `alarm_*` and `report_*` actions to the domain admin role. This is applied by the domains service on startup.
|
||||
|
||||
```bash
|
||||
psql -h localhost -p 6003 -U magistrala -d domains -c "
|
||||
SELECT action FROM domains_role_actions
|
||||
WHERE role_id IN (SELECT id FROM domains_roles WHERE name = 'admin')
|
||||
ORDER BY action;"
|
||||
```
|
||||
|
||||
**Expected:** The result should include all of these new actions:
|
||||
|
||||
```
|
||||
alarm_acknowledge
|
||||
alarm_assign
|
||||
alarm_delete
|
||||
alarm_read
|
||||
alarm_resolve
|
||||
alarm_update
|
||||
report_add_role_users
|
||||
report_create
|
||||
report_delete
|
||||
report_manage_role
|
||||
report_read
|
||||
report_remove_role_users
|
||||
report_update
|
||||
report_view_role_users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Cleanup (Optional)
|
||||
|
||||
To reset and re-test from scratch:
|
||||
|
||||
```bash
|
||||
# Remove all seeded data from RE DB
|
||||
psql -h localhost -p 6009 -U magistrala -d rules_engine -c "
|
||||
DELETE FROM rules WHERE id LIKE 'r0000000-%';
|
||||
DELETE FROM domains WHERE id = 'd0000000-0000-0000-0000-000000000001';"
|
||||
|
||||
# Remove all seeded data from Reports DB
|
||||
psql -h localhost -p 6020 -U magistrala -d reports -c "
|
||||
DELETE FROM report_config WHERE id LIKE 'rp000000-%';
|
||||
DELETE FROM domains WHERE id = 'd0000000-0000-0000-0000-000000000001';"
|
||||
|
||||
# Remove all seeded data from Alarms DB
|
||||
psql -h localhost -p 6019 -U magistrala -d alarms -c "
|
||||
DELETE FROM alarms WHERE id LIKE 'a0000000-%';
|
||||
DELETE FROM domains WHERE id = 'd0000000-0000-0000-0000-000000000001';"
|
||||
|
||||
# Remove SpiceDB relationships
|
||||
zed relationship delete rule --insecure --endpoint localhost:50051 --token 12345678
|
||||
zed relationship delete report --insecure --endpoint localhost:50051 --token 12345678
|
||||
```
|
||||
|
||||
Then re-run `go run ./scripts/seed-test-data/` to start fresh.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| `failed to connect to postgres` | Verify containers are running: `docker compose ps`. Check ports with `docker compose port re-db 5432`. |
|
||||
| `failed to read spicedb relationships` | Ensure SpiceDB is running and schema is loaded. Check: `zed schema read --insecure --endpoint localhost:50051 --token 12345678` |
|
||||
| `failed to load built-in role actions` | Verify `spicedbSchemaFile` points to `docker/spicedb/schema.zed` (not `combined-schema.zed`). |
|
||||
| `no such table` errors during seed | Services haven't run yet to apply migrations. Start the full service (`re`, `reports`, `alarms`) at least once. |
|
||||
| Script exits with `count=0` unexpectedly | All rules/reports already have roles. Check with the orphan queries from Step 2. |
|
||||
@@ -1,451 +0,0 @@
|
||||
// Copyright (c) Abstract Machines
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package main seeds test data across the domains, RE, reports, and alarms
|
||||
// databases so that the backfill-roles scripts can be tested end-to-end.
|
||||
//
|
||||
// It creates one domain, two users (one domain member, one not), several
|
||||
// rules/reports with and without pre-existing roles, a couple of alarms, and
|
||||
// optionally a SpiceDB parent relation for one rule to exercise the
|
||||
// "policy already exists" path.
|
||||
//
|
||||
// All IDs are deterministic so re-running is idempotent (INSERT … ON CONFLICT DO NOTHING).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
|
||||
"github.com/authzed/authzed-go/v1"
|
||||
"github.com/authzed/grpcutil"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration — edit these to match your environment.
|
||||
// Default values target the standard docker-compose setup.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
// Domains database.
|
||||
domainsDB = dbConfig{host: "localhost", port: "6003", user: "magistrala", pass: "magistrala", name: "domains"}
|
||||
|
||||
// RE (rules engine) database.
|
||||
reDB = dbConfig{host: "localhost", port: "6009", user: "magistrala", pass: "magistrala", name: "rules_engine"}
|
||||
|
||||
// Reports database.
|
||||
reportsDB = dbConfig{host: "localhost", port: "6020", user: "magistrala", pass: "magistrala", name: "reports"}
|
||||
|
||||
// Alarms database.
|
||||
alarmsDB = dbConfig{host: "localhost", port: "6019", user: "magistrala", pass: "magistrala", name: "alarms"}
|
||||
|
||||
// SpiceDB.
|
||||
spicedbHost = "localhost"
|
||||
spicedbPort = "50051"
|
||||
spicedbPreSharedKey = "12345678"
|
||||
|
||||
// Whether to write a SpiceDB parent relation for rule-3 to test the
|
||||
// "policy already exists" code path.
|
||||
seedSpiceDB = true
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deterministic test IDs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
domainID = "d0000000-0000-0000-0000-000000000001"
|
||||
domainName = "seed-test-domain"
|
||||
|
||||
// user1 will be a domain member; user2 will NOT.
|
||||
user1ID = "u0000000-0000-0000-0000-000000000001"
|
||||
user2ID = "u0000000-0000-0000-0000-000000000002"
|
||||
|
||||
// Domain role (admin).
|
||||
domainRoleID = "dr000000-0000-0000-0000-000000000001"
|
||||
domainRoleName = "admin"
|
||||
|
||||
// Rules — orphans (no rules_roles entry).
|
||||
rule1ID = "r0000000-0000-0000-0000-000000000001" // created_by=user1 (domain member) → backfill should assign member
|
||||
rule2ID = "r0000000-0000-0000-0000-000000000002" // created_by=user2 (NOT member) → backfill should provision role without member
|
||||
rule3ID = "r0000000-0000-0000-0000-000000000003" // created_by=user1, SpiceDB parent already exists → test policyExists
|
||||
rule4ID = "r0000000-0000-0000-0000-000000000004" // created_by=NULL → should be skipped
|
||||
rule5ID = "r0000000-0000-0000-0000-000000000005" // empty domain_id → should be skipped
|
||||
|
||||
// Rule with pre-existing role — should NOT appear in orphan list.
|
||||
rule6ID = "r0000000-0000-0000-0000-000000000006"
|
||||
rule6RoleID = "rr000000-0000-0000-0000-000000000006"
|
||||
|
||||
// Reports — orphans (no reports_roles entry).
|
||||
report1ID = "rp000000-0000-0000-0000-000000000001" // created_by=user1 (domain member)
|
||||
report2ID = "rp000000-0000-0000-0000-000000000002" // created_by=user2 (NOT member)
|
||||
report3ID = "rp000000-0000-0000-0000-000000000003" // created_by=user1, SpiceDB parent already exists
|
||||
report4ID = "rp000000-0000-0000-0000-000000000004" // created_by=NULL → skipped
|
||||
|
||||
// Report with pre-existing role.
|
||||
report5ID = "rp000000-0000-0000-0000-000000000005"
|
||||
report5RoleID = "rpr00000-0000-0000-0000-000000000005"
|
||||
|
||||
// Alarms (live in alarms DB).
|
||||
alarm1ID = "a0000000-0000-0000-0000-000000000001"
|
||||
alarm2ID = "a0000000-0000-0000-0000-000000000002"
|
||||
)
|
||||
|
||||
type dbConfig struct {
|
||||
host, port, user, pass, name string
|
||||
}
|
||||
|
||||
func (c dbConfig) dsn() string {
|
||||
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", c.host, c.port, c.user, c.pass, c.name)
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Seed Domains DB
|
||||
// -----------------------------------------------------------------------
|
||||
log.Println("connecting to domains DB ...")
|
||||
ddb := mustConnect(domainsDB)
|
||||
defer ddb.Close()
|
||||
|
||||
seedDomainTables(ctx, ddb, now)
|
||||
log.Println("domains DB seeded")
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Seed RE DB (includes domain tables + rules)
|
||||
// -----------------------------------------------------------------------
|
||||
log.Println("connecting to RE DB ...")
|
||||
rdb := mustConnect(reDB)
|
||||
defer rdb.Close()
|
||||
|
||||
seedDomainTables(ctx, rdb, now)
|
||||
seedRules(ctx, rdb, now)
|
||||
log.Println("RE DB seeded")
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. Seed Reports DB (includes domain tables + report_config)
|
||||
// -----------------------------------------------------------------------
|
||||
log.Println("connecting to reports DB ...")
|
||||
rpdb := mustConnect(reportsDB)
|
||||
defer rpdb.Close()
|
||||
|
||||
seedDomainTables(ctx, rpdb, now)
|
||||
seedReports(ctx, rpdb, now)
|
||||
log.Println("reports DB seeded")
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Seed Alarms DB (includes domain + RE tables + alarms)
|
||||
// -----------------------------------------------------------------------
|
||||
log.Println("connecting to alarms DB ...")
|
||||
adb := mustConnect(alarmsDB)
|
||||
defer adb.Close()
|
||||
|
||||
seedDomainTables(ctx, adb, now)
|
||||
seedRulesMinimal(ctx, adb, now) // alarms DB has rules tables via RE migration
|
||||
seedAlarms(ctx, adb, now)
|
||||
log.Println("alarms DB seeded")
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Optionally seed SpiceDB (parent relations for rule3 and report3)
|
||||
// -----------------------------------------------------------------------
|
||||
if seedSpiceDB {
|
||||
log.Println("connecting to SpiceDB ...")
|
||||
seedSpiceDBRelationships(ctx)
|
||||
log.Println("SpiceDB seeded")
|
||||
}
|
||||
|
||||
log.Println("all seed data inserted successfully")
|
||||
printSummary()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain tables (identical across all DBs that include domain migrations)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func seedDomainTables(ctx context.Context, db *sql.DB, now time.Time) {
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO domains (id, name, tags, metadata, route, created_at, updated_at, created_by, status)
|
||||
VALUES ($1, $2, '{}', '{}', $3, $4, $4, $5, 0)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
domainID, domainName, "seed-test-domain", now, user1ID)
|
||||
|
||||
// Domain admin role
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO domains_roles (id, name, entity_id, created_at, updated_at, created_by)
|
||||
VALUES ($1, $2, $3, $4, $4, $5)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
domainRoleID, domainRoleName, domainID, now, user1ID)
|
||||
|
||||
// Domain role actions (a representative subset)
|
||||
actions := []string{
|
||||
"domain_update", "domain_read", "domain_membership",
|
||||
"domain_manage_role", "domain_add_role_users", "domain_remove_role_users", "domain_view_role_users",
|
||||
"rule_create", "rule_read", "rule_update", "rule_delete",
|
||||
"rule_manage_role", "rule_add_role_users", "rule_remove_role_users", "rule_view_role_users",
|
||||
"report_create", "report_read", "report_update", "report_delete",
|
||||
"report_manage_role", "report_add_role_users", "report_remove_role_users", "report_view_role_users",
|
||||
"alarm_update", "alarm_read", "alarm_delete", "alarm_assign", "alarm_acknowledge", "alarm_resolve",
|
||||
}
|
||||
for _, action := range actions {
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO domains_role_actions (role_id, action)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
domainRoleID, action)
|
||||
}
|
||||
|
||||
// user1 is a domain member; user2 is NOT.
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO domains_role_members (role_id, member_id, entity_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
domainRoleID, user1ID, domainID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rules (RE DB)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func seedRules(ctx context.Context, db *sql.DB, now time.Time) {
|
||||
type rule struct {
|
||||
id, name, domainID string
|
||||
createdBy *string
|
||||
}
|
||||
|
||||
u1 := strPtr(user1ID)
|
||||
u2 := strPtr(user2ID)
|
||||
|
||||
rules := []rule{
|
||||
{rule1ID, "rule-1-member-creator", domainID, u1},
|
||||
{rule2ID, "rule-2-nonmember-creator", domainID, u2},
|
||||
{rule3ID, "rule-3-spicedb-exists", domainID, u1},
|
||||
{rule4ID, "rule-4-null-creator", domainID, nil},
|
||||
{rule5ID, "rule-5-no-domain", "", u1},
|
||||
{rule6ID, "rule-6-has-role-already", domainID, u1},
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO rules (id, name, domain_id, created_by, created_at, status, logic_type)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
r.id, r.name, r.domainID, r.createdBy, now)
|
||||
}
|
||||
|
||||
// rule6 already has a role → should NOT appear in orphan list.
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO rules_roles (id, name, entity_id, created_at, updated_at, created_by)
|
||||
VALUES ($1, 'admin', $2, $3, $3, $4)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
rule6RoleID, rule6ID, now, user1ID)
|
||||
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO rules_role_actions (role_id, action)
|
||||
VALUES ($1, 'rule_read')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
rule6RoleID)
|
||||
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO rules_role_members (role_id, member_id, entity_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
rule6RoleID, user1ID, rule6ID)
|
||||
}
|
||||
|
||||
// seedRulesMinimal inserts the same rules into the alarms DB (which has RE
|
||||
// tables) so that foreign key constraints on rule_id can be satisfied.
|
||||
func seedRulesMinimal(ctx context.Context, db *sql.DB, now time.Time) {
|
||||
for _, r := range []struct{ id, name string }{
|
||||
{rule1ID, "rule-1-member-creator"},
|
||||
{rule2ID, "rule-2-nonmember-creator"},
|
||||
} {
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO rules (id, name, domain_id, created_by, created_at, status, logic_type)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
r.id, r.name, domainID, user1ID, now)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func seedReports(ctx context.Context, db *sql.DB, now time.Time) {
|
||||
type report struct {
|
||||
id, name, domainID string
|
||||
createdBy *string
|
||||
}
|
||||
|
||||
u1 := strPtr(user1ID)
|
||||
u2 := strPtr(user2ID)
|
||||
|
||||
reports := []report{
|
||||
{report1ID, "report-1-member-creator", domainID, u1},
|
||||
{report2ID, "report-2-nonmember-creator", domainID, u2},
|
||||
{report3ID, "report-3-spicedb-exists", domainID, u1},
|
||||
{report4ID, "report-4-null-creator", domainID, nil},
|
||||
{report5ID, "report-5-has-role-already", domainID, u1},
|
||||
}
|
||||
|
||||
for _, r := range reports {
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO report_config (id, name, domain_id, created_by, created_at, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 0)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
r.id, r.name, r.domainID, r.createdBy, now)
|
||||
}
|
||||
|
||||
// report5 already has a role.
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO reports_roles (id, name, entity_id, created_at, updated_at, created_by)
|
||||
VALUES ($1, 'admin', $2, $3, $3, $4)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
report5RoleID, report5ID, now, user1ID)
|
||||
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO reports_role_actions (role_id, action)
|
||||
VALUES ($1, 'report_read')
|
||||
ON CONFLICT DO NOTHING`,
|
||||
report5RoleID)
|
||||
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO reports_role_members (role_id, member_id, entity_id)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
report5RoleID, user1ID, report5ID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alarms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func seedAlarms(ctx context.Context, db *sql.DB, now time.Time) {
|
||||
for _, a := range []struct{ id, ruleID string }{
|
||||
{alarm1ID, rule1ID},
|
||||
{alarm2ID, rule2ID},
|
||||
} {
|
||||
mustExec(ctx, db, `
|
||||
INSERT INTO alarms (id, rule_id, domain_id, channel_id, subtopic, client_id,
|
||||
measurement, value, unit, threshold, cause, status, severity, created_at)
|
||||
VALUES ($1, $2, $3, 'ch000000-0000-0000-0000-000000000001', 'test/topic',
|
||||
'cl000000-0000-0000-0000-000000000001', 'temperature', '42.5', 'C', '40.0',
|
||||
'exceeded threshold', 0, 1, $4)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
a.id, a.ruleID, domainID, now)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpiceDB — write a parent relation for rule3 and report3 so the
|
||||
// "policy already exists" code path is exercised.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func seedSpiceDBRelationships(ctx context.Context) {
|
||||
addr := fmt.Sprintf("%s:%s", spicedbHost, spicedbPort)
|
||||
client, err := authzed.NewClientWithExperimentalAPIs(
|
||||
addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpcutil.WithInsecureBearerToken(spicedbPreSharedKey),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: failed to connect to SpiceDB at %s: %v (skipping SpiceDB seed)", addr, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use TOUCH so re-running is idempotent.
|
||||
updates := []*v1.RelationshipUpdate{
|
||||
{
|
||||
Operation: v1.RelationshipUpdate_OPERATION_TOUCH,
|
||||
Relationship: &v1.Relationship{
|
||||
Resource: &v1.ObjectReference{ObjectType: "rule", ObjectId: rule3ID},
|
||||
Relation: "domain",
|
||||
Subject: &v1.SubjectReference{Object: &v1.ObjectReference{ObjectType: "domain", ObjectId: domainID}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Operation: v1.RelationshipUpdate_OPERATION_TOUCH,
|
||||
Relationship: &v1.Relationship{
|
||||
Resource: &v1.ObjectReference{ObjectType: "report", ObjectId: report3ID},
|
||||
Relation: "domain",
|
||||
Subject: &v1.SubjectReference{Object: &v1.ObjectReference{ObjectType: "domain", ObjectId: domainID}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = client.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{Updates: updates})
|
||||
if err != nil {
|
||||
log.Printf("WARNING: failed to write SpiceDB relationships: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("wrote %d SpiceDB relationships (TOUCH)", len(updates))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustConnect(cfg dbConfig) *sql.DB {
|
||||
db, err := sql.Open("pgx", cfg.dsn())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open %s: %v", cfg.name, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
cancel()
|
||||
log.Fatalf("failed to ping %s at %s:%s: %v", cfg.name, cfg.host, cfg.port, err)
|
||||
}
|
||||
cancel()
|
||||
return db
|
||||
}
|
||||
|
||||
func mustExec(ctx context.Context, db *sql.DB, query string, args ...any) {
|
||||
if _, err := db.ExecContext(ctx, query, args...); err != nil {
|
||||
log.Fatalf("exec failed: %v\nquery: %s\nargs: %v", err, query, args)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func printSummary() {
|
||||
fmt.Print(`
|
||||
=== SEED DATA SUMMARY ===
|
||||
|
||||
Domain: ` + domainID + ` ("` + domainName + `")
|
||||
|
||||
Users:
|
||||
user1 (domain member): ` + user1ID + `
|
||||
user2 (NOT a member): ` + user2ID + `
|
||||
|
||||
Rules (RE DB):
|
||||
` + rule1ID + ` rule-1-member-creator orphan, created_by=user1 → expect role WITH member
|
||||
` + rule2ID + ` rule-2-nonmember-creator orphan, created_by=user2 → expect role WITHOUT member
|
||||
` + rule3ID + ` rule-3-spicedb-exists orphan, created_by=user1, SpiceDB parent pre-seeded → test policyExists
|
||||
` + rule4ID + ` rule-4-null-creator orphan, created_by=NULL → expect SKIPPED
|
||||
` + rule5ID + ` rule-5-no-domain orphan, domain_id="" → expect SKIPPED
|
||||
` + rule6ID + ` rule-6-has-role-already HAS role entry → should NOT appear in orphan list
|
||||
|
||||
Reports (Reports DB):
|
||||
` + report1ID + ` report-1-member-creator orphan, created_by=user1 → expect role WITH member
|
||||
` + report2ID + ` report-2-nonmember-creator orphan, created_by=user2 → expect role WITHOUT member
|
||||
` + report3ID + ` report-3-spicedb-exists orphan, created_by=user1, SpiceDB parent pre-seeded → test policyExists
|
||||
` + report4ID + ` report-4-null-creator orphan, created_by=NULL → expect SKIPPED
|
||||
` + report5ID + ` report-5-has-role-already HAS role entry → should NOT appear in orphan list
|
||||
|
||||
Alarms (Alarms DB):
|
||||
` + alarm1ID + ` alarm-1 (rule1)
|
||||
` + alarm2ID + ` alarm-2 (rule2)
|
||||
|
||||
SpiceDB:
|
||||
rule:` + rule3ID + `#domain@domain:` + domainID + ` (pre-seeded)
|
||||
report:` + report3ID + `#domain@domain:` + domainID + ` (pre-seeded)
|
||||
`)
|
||||
}
|
||||
Reference in New Issue
Block a user