Remove stale bootstrap backfill artifacts after Atom rebase

Signed-off-by: Arvindh <arvindh91@gmail.com>
This commit is contained in:
Arvindh
2026-06-19 15:54:10 +05:30
committed by dusan
parent dbbd8453e9
commit 0bb8b0947a
24 changed files with 0 additions and 4260 deletions
-48
View File
@@ -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
-109
View File
@@ -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
}
-81
View File
@@ -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
}
-14
View File
@@ -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
}
-94
View File
@@ -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
}
-111
View File
@@ -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
}
-237
View File
@@ -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
}
-394
View File
@@ -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
}
-115
View File
@@ -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
}
-146
View File
@@ -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
}
-263
View File
@@ -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
}
-69
View File
@@ -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
}
-174
View File
@@ -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
},
}
}
-88
View File
@@ -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))
})
}
}
-115
View File
@@ -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
}
-100
View File
@@ -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
}
-101
View File
@@ -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
}
-6
View File
@@ -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
-46
View File
@@ -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
}
-6
View File
@@ -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
-536
View File
@@ -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
}
-532
View File
@@ -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
}
-424
View File
@@ -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 4855):**
```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 4956):**
```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 36 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. |
-451
View File
@@ -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)
`)
}