Compare commits

..

28 Commits

Author SHA1 Message Date
dusan 05ce424453 Update migrations
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-06-26 20:52:01 +02:00
dusan afd852d7b4 Fix migrations
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-06-26 20:30:31 +02:00
Arvindh 3c7e81fe1f remove rule metadata from atom
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh db81b3033b fix alarms
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 710f18b95a fix the makefile
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 59f8cc9b1a auto provision before start compose
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh a6264fb5ae fix rules engine atom token
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 8408fac353 allow unverified user
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 369cc49874 fix atom api ci failures
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh d252655ab4 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 2dd5225288 add back fluxmq-auth
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 22ffcda201 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh 3a13e81956 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:31 +02:00
Arvindh eed4d4aa00 auto provision of ATOM token
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 601bbacd17 remove fluxmq
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Rodney Osodo d7c49e068d chore(docker): update service credentials in .env
Signed-off-by: Rodney Osodo <socials@rodneyosodo.com>
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Dušan Borovčanin 9cc0991227 Add Magistrala v0.30.0 migration tool (#3536)
Signed-off-by: dusan <borovcanindusan1@gmail.com>
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 523305c120 fix(authz): enforce Atom list checks for rules reports and alarms
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 19bdd334b2 fix ci
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh 746086f1cd remove old services
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:36:15 +02:00
Arvindh b929d51b3f remove core
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 0bb8b0947a Remove stale bootstrap backfill artifacts after Atom rebase
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh dbbd8453e9 add atom bootstrap
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh aee8474e49 update nginx and env
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 36cfd989fe update compose
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh baf507e699 certficate integration with ATOM
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 4921cb4cf5 align with ATOM changes
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:49 +02:00
Arvindh 9a186fd26f inital integration with ATOM
Signed-off-by: Arvindh <arvindh91@gmail.com>
2026-06-26 19:35:48 +02:00
317 changed files with 38584 additions and 3777 deletions
+71 -17
View File
@@ -15,10 +15,14 @@ on:
- "clients/api/http/**"
- "domains/api/http/**"
- "groups/api/http/**"
- "journal/api/**"
- "users/api/**"
- "bootstrap/api/**"
- "certs/api/http/**"
- "readers/api/http/**"
- "re/**"
- "alarms/**"
- "reports/**"
- "apidocs/openapi/**"
pull_request:
branches:
@@ -31,10 +35,14 @@ on:
- "clients/api/http/**"
- "domains/api/http/**"
- "groups/api/http/**"
- "journal/api/**"
- "users/api/**"
- "bootstrap/api/**"
- "certs/api/http/**"
- "readers/api/http/**"
- "re/**"
- "alarms/**"
- "reports/**"
- "apidocs/openapi/**"
concurrency:
@@ -51,9 +59,13 @@ env:
CLIENTS_URL: http://localhost
CHANNELS_URL: http://localhost
GROUPS_URL: http://localhost
JOURNAL_URL: http://localhost:9021
BOOTSTRAP_URL: http://localhost:9013
CERTS_URL: http://localhost:9019
READERS_URL: http://localhost:9011
RE_URL: http://localhost:9008
ALARMS_URL: http://localhost:8050
REPORTS_URL: http://localhost:9017
jobs:
api-test:
@@ -65,7 +77,7 @@ jobs:
fetch-depth: 0
- name: Install Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
@@ -78,6 +90,10 @@ jobs:
workflow:
- ".github/workflows/api-tests.yaml"
journal:
- "apidocs/openapi/journal.yaml"
- "journal/api/**"
domains:
- "apidocs/openapi/domains.yaml"
- "internal/atom/**"
@@ -115,6 +131,24 @@ jobs:
- "apidocs/openapi/readers.yaml"
- "readers/api/http/**"
re:
- "apidocs/openapi/rules.yaml"
- "re/**"
- "cmd/re/**"
- "internal/atom/**"
alarms:
- "apidocs/openapi/alarms.yaml"
- "alarms/**"
- "cmd/alarms/**"
- "internal/atom/**"
reports:
- "apidocs/openapi/reports.yaml"
- "reports/**"
- "cmd/reports/**"
- "internal/atom/**"
- name: Build images
run: make all -j $(nproc) && make dockers_dev -j $(nproc)
@@ -159,8 +193,6 @@ jobs:
base-url: ${{ env.USERS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --exclude-operation-id=requestPasswordReset --phases=examples'
coverage-artifact-name: schema-coverage-users
coverage-pr-comment: false
- name: Run Groups API tests
if: (steps.changes.outputs.groups == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('groups/api/http/**') != ''
@@ -170,8 +202,6 @@ jobs:
base-url: ${{ env.GROUPS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-groups
coverage-pr-comment: false
- name: Run Clients API tests
if: (steps.changes.outputs.clients == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('clients/api/http/**') != ''
@@ -181,8 +211,6 @@ jobs:
base-url: ${{ env.CLIENTS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-clients
coverage-pr-comment: false
- name: Run Channels API tests
if: (steps.changes.outputs.channels == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('channels/api/http/**') != ''
@@ -192,8 +220,6 @@ jobs:
base-url: ${{ env.CHANNELS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-channels
coverage-pr-comment: false
- name: Run Domains API tests
if: (steps.changes.outputs.domains == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('domains/api/http/**') != ''
@@ -203,8 +229,15 @@ jobs:
base-url: ${{ env.DOMAIN_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-domains
coverage-pr-comment: false
- name: Run Journal API tests
if: steps.changes.outputs.journal == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/journal.yaml
base-url: ${{ env.JOURNAL_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Bootstrap API tests
if: (steps.changes.outputs.bootstrap == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('bootstrap/api/**') != ''
@@ -214,8 +247,6 @@ jobs:
base-url: ${{ env.BOOTSTRAP_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-bootstrap
coverage-pr-comment: false
- name: Run Certs API tests
if: (steps.changes.outputs.certs == 'true' || steps.changes.outputs.workflow == 'true') && hashFiles('docker/addons/certs/docker-compose.yaml') != ''
@@ -225,8 +256,6 @@ jobs:
base-url: ${{ env.CERTS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-certs
coverage-pr-comment: false
- name: Run Readers API tests
if: steps.changes.outputs.readers == 'true' || steps.changes.outputs.workflow == 'true'
@@ -236,8 +265,33 @@ jobs:
base-url: ${{ env.READERS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
coverage-artifact-name: schema-coverage-readers
coverage-pr-comment: false
- name: Run Rules Engine API tests
if: steps.changes.outputs.re == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/rules.yaml
base-url: ${{ env.RE_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Alarms API tests
if: steps.changes.outputs.alarms == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/alarms.yaml
base-url: ${{ env.ALARMS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Run Reports API tests
if: steps.changes.outputs.reports == 'true' || steps.changes.outputs.workflow == 'true'
uses: schemathesis/action@v3.0.0
with:
schema: apidocs/openapi/reports.yaml
base-url: ${{ env.REPORTS_URL }}
checks: all
args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --suppress-health-check=filter_too_much --exclude-checks=positive_data_acceptance --phases=examples'
- name: Stop containers
if: always()
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
fetch-tags: true
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
+4 -3
View File
@@ -12,15 +12,15 @@ jobs:
check-generated-files:
runs-on: ubuntu-latest
env:
PROTOC_VERSION: "35.1"
PROTOC_VERSION: "33.0"
PROTOC_GEN_GO_VERSION: "v1.36.11"
PROTOC_GEN_GO_GRPC_VERSION: "v1.6.2"
PROTOC_GEN_GO_GRPC_VERSION: "v1.6.0"
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
@@ -68,6 +68,7 @@ jobs:
- "pkg/groups/groups.go"
- "users/emailer.go"
- "users/hasher.go"
- "journal/journal.go"
- "consumers/notifier.go"
- name: Install Protoc
+4 -4
View File
@@ -15,13 +15,13 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
- name: Run linters
uses: golangci/golangci-lint-action@v9.3.0
uses: golangci/golangci-lint-action@v9.2.1
with:
version: v2.10.1
args: --config ./tools/config/.golangci.yaml
@@ -35,7 +35,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
@@ -53,7 +53,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
+38 -25
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
@@ -105,22 +105,14 @@ jobs:
internal:
- "internal/**"
journal:
- "journal/**"
- "cmd/journal/**"
- "pkg/events/**"
logger:
- "logger/**"
pkg-atom:
- "pkg/atom/**"
pkg-email:
- "pkg/email/**"
- "pkg/emailer/**"
pkg-readersclient:
- "pkg/readersclient/**"
pkg-testsutil:
- "pkg/testsutil/**"
pkg-errors:
- "pkg/errors/**"
@@ -144,7 +136,11 @@ jobs:
- "channels/**"
- "domains/**"
- "groups/**"
- "journal/**"
- "api/http/**"
- "re/**"
- "alarms/**"
- "reports/**"
pkg-transformers:
- "pkg/transformers/**"
@@ -161,6 +157,12 @@ jobs:
- "pkg/uuid/**"
- "pkg/events/**"
notifications:
- "notifications/**"
- "cmd/notifications/**"
- "consumers/notifier.go"
- "pkg/events/**"
api:
- "api/**"
@@ -176,14 +178,27 @@ jobs:
- "cmd/postgres-reader/**"
- "cmd/timescale-reader/**"
re:
- "re/**"
- "cmd/re/**"
- "re/api/**"
alarms:
- "alarms/**"
- "cmd/alarms/**"
reports:
- "reports/**"
- "cmd/reports/**"
- name: Set matrix for changed modules
id: set-matrix
run: |
modules=()
if [[ "${{ steps.changes.outputs.workflow }}" == "true" || "${{ steps.changes.outputs.pkg-errors }}" == "true" || "${{ steps.changes.outputs.pkg-testsutil }}" == "true" ]]; then
# If workflow, shared errors, or test utilities changed, test everything
modules=("auth" "channels" "cli" "clients" "domains" "groups" "internal" "logger" "pkg-atom" "pkg-email" "pkg-errors" "pkg-events" "pkg-grpcclient" "pkg-messaging" "pkg-readersclient" "pkg-sdk" "pkg-testsutil" "pkg-transformers" "pkg-ulid" "pkg-uuid" "users" "api" "consumers" "readers")
if [[ "${{ steps.changes.outputs.workflow }}" == "true" || "${{ steps.changes.outputs.pkg-errors }}" == "true" ]]; then
# If workflow or pkg/errors changed, test everything
modules=("auth" "channels" "cli" "clients" "domains" "groups" "internal" "journal" "logger" "pkg-errors" "pkg-events" "pkg-grpcclient" "pkg-messaging" "pkg-sdk" "pkg-transformers" "pkg-ulid" "pkg-uuid" "users" "notifications" "api" "consumers" "readers" "re" "alarms" "reports")
else
# Add only changed modules
[[ "${{ steps.changes.outputs.auth }}" == "true" ]] && modules+=("auth")
@@ -193,22 +208,24 @@ jobs:
[[ "${{ steps.changes.outputs.domains }}" == "true" ]] && modules+=("domains")
[[ "${{ steps.changes.outputs.groups }}" == "true" ]] && modules+=("groups")
[[ "${{ steps.changes.outputs.internal }}" == "true" ]] && modules+=("internal")
[[ "${{ steps.changes.outputs.journal }}" == "true" ]] && modules+=("journal")
[[ "${{ steps.changes.outputs.logger }}" == "true" ]] && modules+=("logger")
[[ "${{ steps.changes.outputs.pkg-atom }}" == "true" ]] && modules+=("pkg-atom")
[[ "${{ steps.changes.outputs.pkg-email }}" == "true" ]] && modules+=("pkg-email")
[[ "${{ steps.changes.outputs.pkg-errors }}" == "true" ]] && modules+=("pkg-errors")
[[ "${{ steps.changes.outputs.pkg-events }}" == "true" ]] && modules+=("pkg-events")
[[ "${{ steps.changes.outputs.pkg-grpcclient }}" == "true" ]] && modules+=("pkg-grpcclient")
[[ "${{ steps.changes.outputs.pkg-messaging }}" == "true" ]] && modules+=("pkg-messaging")
[[ "${{ steps.changes.outputs.pkg-readersclient }}" == "true" ]] && modules+=("pkg-readersclient")
[[ "${{ steps.changes.outputs.pkg-sdk }}" == "true" ]] && modules+=("pkg-sdk")
[[ "${{ steps.changes.outputs.pkg-transformers }}" == "true" ]] && modules+=("pkg-transformers")
[[ "${{ steps.changes.outputs.pkg-ulid }}" == "true" ]] && modules+=("pkg-ulid")
[[ "${{ steps.changes.outputs.pkg-uuid }}" == "true" ]] && modules+=("pkg-uuid")
[[ "${{ steps.changes.outputs.users }}" == "true" ]] && modules+=("users")
[[ "${{ steps.changes.outputs.notifications }}" == "true" ]] && modules+=("notifications")
[[ "${{ steps.changes.outputs.api }}" == "true" ]] && modules+=("api")
[[ "${{ steps.changes.outputs.consumers }}" == "true" ]] && modules+=("consumers")
[[ "${{ steps.changes.outputs.readers }}" == "true" ]] && modules+=("readers")
[[ "${{ steps.changes.outputs.re }}" == "true" ]] && modules+=("re")
[[ "${{ steps.changes.outputs.alarms }}" == "true" ]] && modules+=("alarms")
[[ "${{ steps.changes.outputs.reports }}" == "true" ]] && modules+=("reports")
fi
# Convert to JSON array
@@ -232,7 +249,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: "go.sum"
@@ -246,15 +263,11 @@ jobs:
run: |
# Map module names to directories
case "${{ matrix.module }}" in
pkg-atom) dir="pkg/atom" ;;
pkg-email) dir="pkg/email" ;;
pkg-errors) dir="pkg/errors" ;;
pkg-events) dir="pkg/events" ;;
pkg-grpcclient) dir="pkg/grpcclient" ;;
pkg-messaging) dir="pkg/messaging" ;;
pkg-readersclient) dir="pkg/readersclient" ;;
pkg-sdk) dir="pkg/sdk" ;;
pkg-testsutil) dir="pkg/testsutil" ;;
pkg-transformers) dir="pkg/transformers" ;;
pkg-ulid) dir="pkg/ulid" ;;
pkg-uuid) dir="pkg/uuid" ;;
+5 -14
View File
@@ -4,8 +4,8 @@
override MG_DOCKER_IMAGE_NAME_PREFIX := ghcr.io/absmach/magistrala
MG_DOCKER_VOLUME_NAME_PREFIX ?= magistrala
BUILD_DIR ?= build
SERVICES = atom-bootstrap certs postgres-writer postgres-reader timescale-writer timescale-reader fluxmq
TEST_API_SERVICES = certs clients users channels groups domains
SERVICES = atom-bootstrap notifications certs re postgres-writer postgres-reader timescale-writer timescale-reader alarms reports journal fluxmq
TEST_API_SERVICES = journal certs clients users channels groups domains
TEST_API = $(addprefix test_api_,$(TEST_API_SERVICES))
DOCKERS = $(addprefix docker_,$(SERVICES))
DOCKERS_DEV = $(addprefix docker_dev_,$(SERVICES))
@@ -24,7 +24,7 @@ DOCKER_PROJECT ?= $(shell echo $(subst $(space),,$(USER_REPO)) | sed -E 's/[^a-z
DOCKER_COMPOSE_COMMANDS_SUPPORTED := up down config restart
DEFAULT_DOCKER_COMPOSE_COMMAND := up
ATOM_TOKENS_ENV ?= docker/.env.tokens
REQUIRED_ATOM_TOKEN_ENVS := MG_ATOM_TOKEN_FLUXMQ_AUTH MG_ATOM_TOKEN_FLUXMQ_NODE1 MG_ATOM_TOKEN_FLUXMQ_NODE2 MG_ATOM_TOKEN_FLUXMQ_NODE3 MG_ATOM_TOKEN_TIMESCALE_READER MG_ATOM_TOKEN_RE MG_ATOM_TOKEN_ALARMS MG_ATOM_TOKEN_REPORTS MG_ATOM_TOKEN_POSTGRES_READER
REQUIRED_ATOM_TOKEN_ENVS := MG_ATOM_TOKEN_FLUXMQ_AUTH MG_ATOM_TOKEN_FLUXMQ_NODE1 MG_ATOM_TOKEN_FLUXMQ_NODE2 MG_ATOM_TOKEN_FLUXMQ_NODE3 MG_ATOM_TOKEN_JOURNAL MG_ATOM_TOKEN_NOTIFICATIONS MG_ATOM_TOKEN_TIMESCALE_READER MG_ATOM_TOKEN_RE MG_ATOM_TOKEN_ALARMS MG_ATOM_TOKEN_REPORTS MG_ATOM_TOKEN_POSTGRES_READER
PROVISION_ATOM_TOKENS ?= false
PROVISION_ATOM_TOKEN_GOALS := provision-atom-tokens
DOCKER_BASE_ENV_FILES := --env-file docker/.env
@@ -197,7 +197,7 @@ FILTERED_SERVICES = $(filter-out $(RUN_ADDON_ARGS), $(SERVICES))
all: $(SERVICES)
.PHONY: all $(SERVICES) dockers dockers_dev latest release provision_atom_tokens provision-atom-tokens migrate_atom run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
.PHONY: all $(SERVICES) dockers dockers_dev latest release provision_atom_tokens provision-atom-tokens run_latest run_latest_ci run_tls run_stable run_addons grpc_mtls_certs check_mtls check_certs test_api mocks
clean:
rm -rf ${BUILD_DIR}
@@ -258,6 +258,7 @@ test_api_domains: TEST_API_URL := http://localhost:9000
test_api_channels: TEST_API_URL := http://localhost:9000
test_api_groups: TEST_API_URL := http://localhost:9000
test_api_certs: TEST_API_URL := http://localhost:9019
test_api_journal: TEST_API_URL := http://localhost:9021
$(TEST_API):
$(call test_api_service,$(@),$(TEST_API_URL))
@@ -323,16 +324,6 @@ provision_atom_tokens:
provision-atom-tokens:
@:
# Migrate an old Magistrala (v0.30.0 / pre-Atom) deployment into Atom. Runs an
# isolated, collision-free stack, seeds the Atom schema into the run_latest Atom
# volume and loads the data. Default is a dry-run; pass args="--apply" to load,
# args="--verify" to reconcile afterwards.
# make migrate_atom # dry-run
# make migrate_atom args="--apply" # perform the migration
# make migrate_atom args="--apply --fresh-atom" # rebuild Atom schema first
migrate_atom:
DOCKER_PROJECT="$(DOCKER_PROJECT)" tools/atom-migration/migrate.sh $(args)
check_tls:
ifeq ($(GRPC_TLS),true)
@echo "gRPC TLS is enabled"
-2
View File
@@ -156,8 +156,6 @@ Atom is the source of truth for:
Magistrala services such as rules, alarms, and reports remain Magistrala services, but they use Atom for identity and authorization.
Current Docker deployments use the Atom image configured by `ATOM_IMAGE` in `docker/.env`. For compatibility with the current Magistrala integration, the generated `MG_ATOM_TOKEN_*` service credentials are unscoped Atom access tokens. Scoped Atom access tokens should not be used for these service env vars until Magistrala stops using owner-wide Atom listing APIs such as `authorizedObjectIds` in service policy paths.
### Core Entity Mapping
| Magistrala concept | Atom concept | Meaning |
+192
View File
@@ -0,0 +1,192 @@
# Alarms
The Alarms service stores, manages and exposes alarms raised by rules and device activity. It consumes alarm events from the message broker, persists them to PostgreSQL, and provides an HTTP API for listing, viewing, updating, and deleting alarms with full authn/authz, metrics, and tracing support.
## Configuration
The service is configured using the following environment variables (values shown are from [docker/.env](https://github.com/absmach/magistrala/blob/main/docker/.env) as consumed by [docker/docker-compose.yaml](https://github.com/absmach/magistrala/blob/main/docker/docker-compose.yaml)):
| Variable | Description | Default |
| --- | --- | --- |
| `MG_ALARMS_LOG_LEVEL` | Log level for the service | `debug` |
| `MG_ALARMS_HTTP_HOST` | HTTP host to bind | `alarms` |
| `MG_ALARMS_HTTP_PORT` | HTTP port to bind | `8050` |
| `MG_ALARMS_HTTP_SERVER_CERT` | Path to PEM-encoded HTTPS server certificate | "" |
| `MG_ALARMS_HTTP_SERVER_KEY` | Path to PEM-encoded HTTPS server key | "" |
| `MG_ALARMS_DB_HOST` | PostgreSQL host | `alarms-db` |
| `MG_ALARMS_DB_PORT` | PostgreSQL port | `5432` |
| `MG_ALARMS_DB_USER` | PostgreSQL user | `magistrala` |
| `MG_ALARMS_DB_PASS` | PostgreSQL password | `magistrala` |
| `MG_ALARMS_DB_NAME` | PostgreSQL database name | `alarms` |
| `MG_ALARMS_DB_SSL_MODE` | PostgreSQL SSL mode | `disable` |
| `MG_ALARMS_DB_SSL_CERT` | PostgreSQL SSL client cert | "" |
| `MG_ALARMS_DB_SSL_KEY` | PostgreSQL SSL client key | "" |
| `MG_ALARMS_DB_SSL_ROOT_CERT` | PostgreSQL SSL root cert | "" |
| `MG_ALARMS_INSTANCE_ID` | Instance ID for tracing/health | "" |
| `MG_MESSAGE_BROKER_URL` | Message broker URL for alarm ingestion | `nats://nats:4222` |
| `MG_JAEGER_URL` | Jaeger collector endpoint | `http://jaeger:4318/v1/traces` |
| `MG_JAEGER_TRACE_RATIO` | Trace sampling ratio | `1.0` |
| `ATOM_URL` | Atom HTTP endpoint | `http://atom:8080` |
| `ATOM_JWKS_URL` | Atom JWKS endpoint for JWT verification | `http://atom:8080/.well-known/jwks.json` |
| `ATOM_ADMIN_USERNAME` | Atom admin login for service projections | `atom-admin` |
| `ATOM_ADMIN_SECRET` | Atom admin secret for service projections | `change-me` |
| `ATOM_TIMEOUT` | Atom request timeout | `5s` |
| `MG_ALLOW_UNVERIFIED_USER` | Allow unverified users to access | `true` |
## Features
- **Alarm ingestion**: Consumes alarms from the message broker and persists them to PostgreSQL.
- **Stateful updates**: Updates assignee, acknowledgment, resolution, and metadata fields.
- **Filtering and paging**: Lists alarms by domain, rule, channel, client, subtopic, status, severity, and time range.
- **Observability**: `/metrics` Prometheus endpoint and Jaeger tracing support.
- **Auth and authorization**: Authn/authz enforced through Atom JWT verification and PDP checks.
## Architecture
### Runtime flow
1. The message broker publishes alarm events under the `alarms.>` subject.
2. The Alarms consumer decodes the event payload, enriches it with message metadata, validates it, and calls `CreateAlarm`.
3. The repository writes to PostgreSQL while deduplicating repeated active alarms with the same severity.
4. The HTTP API exposes list/view/update/delete operations with authn/authz, metrics, and tracing middleware.
### Components
- **HTTP API**: `alarms/api` exposes REST endpoints and health/metrics handlers.
- **Service layer**: `alarms/service.go` validates requests and coordinates repository operations.
- **Repository**: `alarms/postgres/alarms.go` implements persistence and filtering.
- **Consumer**: `alarms/consumer` processes broker messages and creates alarms.
- **Message broker**: `alarms/brokers` uses NATS JetStream with stream `alarms` and subject `alarms.>`.
- **Migrations**: `alarms/postgres/init.go` defines the alarms schema and indexes.
### Alarms table
Defined in `alarms/postgres/init.go`:
| Column | Type | Description |
| --- | --- | --- |
| `id` | `VARCHAR(36)` | Alarm UUID (primary key) |
| `rule_id` | `VARCHAR(36)` | Rule ID that triggered the alarm |
| `domain_id` | `VARCHAR(36)` | Domain ID |
| `channel_id` | `VARCHAR(36)` | Channel ID |
| `subtopic` | `TEXT` | Subtopic associated with the alarm |
| `client_id` | `VARCHAR(36)` | Client ID |
| `measurement` | `TEXT` | Measurement name |
| `value` | `TEXT` | Measured value |
| `unit` | `TEXT` | Measurement unit |
| `threshold` | `TEXT` | Threshold value |
| `cause` | `TEXT` | Cause/description |
| `status` | `SMALLINT` | 0 = active, 1 = cleared |
| `severity` | `SMALLINT` | Severity (0-100) |
| `assignee_id` | `VARCHAR(36)` | Assignee ID |
| `created_at` | `TIMESTAMPTZ` | Creation timestamp |
| `updated_at` | `TIMESTAMPTZ` | Last update timestamp |
| `updated_by` | `VARCHAR(36)` | User who updated |
| `assigned_at` | `TIMESTAMPTZ` | When assigned |
| `assigned_by` | `VARCHAR(36)` | Who assigned |
| `acknowledged_at` | `TIMESTAMPTZ` | When acknowledged |
| `acknowledged_by` | `VARCHAR(36)` | Who acknowledged |
| `resolved_at` | `TIMESTAMPTZ` | When resolved |
| `resolved_by` | `VARCHAR(36)` | Who resolved |
| `metadata` | `JSONB` | Custom metadata |
Index: `idx_alarms_state (domain_id, rule_id, channel_id, subtopic, client_id, measurement, created_at DESC)`
## Deployment
### Build and run locally
```bash
make alarms
MG_ALARMS_LOG_LEVEL=debug \
MG_ALARMS_HTTP_PORT=8050 \
MG_ALARMS_DB_HOST=localhost \
MG_ALARMS_DB_PORT=5432 \
MG_ALARMS_DB_USER=magistrala \
MG_ALARMS_DB_PASS=magistrala \
MG_ALARMS_DB_NAME=alarms \
MG_MESSAGE_BROKER_URL=nats://localhost:4222 \
MG_AUTH_GRPC_URL=localhost:7001 \
MG_AUTH_GRPC_TIMEOUT=300s \
MG_DOMAINS_GRPC_URL=localhost:7003 \
MG_DOMAINS_GRPC_TIMEOUT=300s \
./build/alarms
```
### Docker Compose
The service is available as a Docker container. Refer to [docker/docker-compose.yaml](https://github.com/absmach/magistrala/blob/main/docker/docker-compose.yaml) for the `alarms` and `alarms-db` services and their environment variables. For a full local stack, make sure the auth, domains, and message broker services are also running.
```bash
docker compose -f docker/docker-compose.yaml up alarms alarms-db
```
### Health check
```bash
curl -X GET http://localhost:8050/health \
-H "accept: application/health+json"
```
## Testing
```bash
go test ./alarms/...
```
## Usage
The Alarms service supports the following operations:
| Operation | Method & Path | Description |
| --- | --- | --- |
| `listAlarms` | `GET /{domainID}/alarms` | List alarms with filters |
| `viewAlarm` | `GET /{domainID}/alarms/{alarmID}` | Retrieve a single alarm |
| `updateAlarm` | `PUT /{domainID}/alarms/{alarmID}` | Update alarm status/assignee/metadata |
| `deleteAlarm` | `DELETE /{domainID}/alarms/{alarmID}` | Delete an alarm |
| `health` | `GET /health` | Service health check |
Alarm creation is driven by message broker events and is not exposed as an HTTP endpoint.
### Example: List alarms
```bash
curl -X GET "http://localhost:8050/<domainID>/alarms?limit=10&offset=0&status=active&severity=50" \
-H "Authorization: Bearer <your_access_token>"
```
### Example: View an alarm
```bash
curl -X GET http://localhost:8050/<domainID>/alarms/<alarmID> \
-H "Authorization: Bearer <your_access_token>"
```
### Example: Update an alarm
```bash
curl -X PUT http://localhost:8050/<domainID>/alarms/<alarmID> \
-H "Authorization: Bearer <your_access_token>" \
-H "Content-Type: application/json" \
-d '{
"status": "cleared",
"assignee_id": "<userID>",
"severity": 40,
"metadata": { "note": "cleared after inspection" }
}'
```
### Example: Delete an alarm
```bash
curl -X DELETE http://localhost:8050/<domainID>/alarms/<alarmID> \
-H "Authorization: Bearer <your_access_token>"
```
### Example: Health check
```bash
curl -X GET http://localhost:8050/health \
-H "accept: application/health+json"
```
+122
View File
@@ -0,0 +1,122 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"errors"
"time"
"github.com/absmach/magistrala/pkg/authn"
)
const SeverityMax uint8 = 100
var ErrInvalidSeverity = errors.New("invalid severity. Must be between 0 and 100")
type Metadata map[string]any
// Alarm represents an alarm instance.
type Alarm struct {
ID string `json:"id"`
RuleID string `json:"rule_id"`
DomainID string `json:"domain_id"`
ChannelID string `json:"channel_id"`
ClientID string `json:"client_id"`
Subtopic string `json:"subtopic"`
Status Status `json:"status"`
Measurement string `json:"measurement"`
Value string `json:"value"`
Unit string `json:"unit"`
Threshold string `json:"threshold"`
Cause string `json:"cause"`
Severity uint8 `json:"severity"`
AssigneeID string `json:"assignee_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
AssignedAt time.Time `json:"assigned_at,omitempty"`
AssignedBy string `json:"assigned_by,omitempty"`
AcknowledgedAt time.Time `json:"acknowledged_at,omitempty"`
AcknowledgedBy string `json:"acknowledged_by,omitempty"`
ResolvedAt time.Time `json:"resolved_at,omitempty"`
ResolvedBy string `json:"resolved_by,omitempty"`
Metadata Metadata `json:"metadata,omitempty"`
}
type AlarmsPage struct {
Offset uint64 `json:"offset"`
Limit uint64 `json:"limit"`
Total uint64 `json:"total"`
Alarms []Alarm `json:"alarms"`
}
type PageMetadata struct {
Offset uint64 `json:"offset" db:"offset"`
Limit uint64 `json:"limit" db:"limit"`
DomainID string `json:"domain_id" db:"domain_id"`
RuleID string `json:"rule_id" db:"rule_id"`
ChannelID string `json:"channel_id" db:"channel_id"`
ClientID string `json:"client_id" db:"client_id"`
Subtopic string `json:"subtopic" db:"subtopic"`
Measurement string `json:"measurement" db:"measurement"`
Dir string `json:"dir" db:"dir"`
Order string `json:"order" db:"order"`
Status Status `json:"status" db:"status"`
CreatedFrom time.Time `json:"created_from" db:"created_from"`
CreatedTo time.Time `json:"created_to" db:"created_to"`
AssigneeID string `json:"assignee_id" db:"assignee_id"`
Severity uint8 `json:"severity" db:"severity"`
UpdatedBy string `json:"updated_by" db:"updated_by"`
AssignedBy string `json:"assigned_by" db:"assigned_by"`
AcknowledgedBy string `json:"acknowledged_by" db:"acknowledged_by"`
ResolvedBy string `json:"resolved_by" db:"resolved_by"`
UserID string `json:"user_id" db:"user_id"`
}
func (a Alarm) Validate() error {
if a.RuleID == "" {
return errors.New("rule_id is required")
}
if a.DomainID == "" {
return errors.New("domain_id is required")
}
if a.ChannelID == "" {
return errors.New("channel_id is required")
}
if a.ClientID == "" {
return errors.New("client_id is required")
}
if a.Measurement == "" {
return errors.New("measurement is required")
}
if a.Value == "" {
return errors.New("value is required")
}
if a.Cause == "" {
return errors.New("cause is required")
}
if a.Severity > SeverityMax {
return ErrInvalidSeverity
}
return nil
}
// Service specifies an API that must be fulfilled by the domain service.
type Service interface {
CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error)
UpdateAlarm(ctx context.Context, session authn.Session, alarm Alarm) (Alarm, error)
ViewAlarm(ctx context.Context, session authn.Session, id string) (Alarm, error)
ListAlarms(ctx context.Context, session authn.Session, pm PageMetadata) (AlarmsPage, error)
DeleteAlarm(ctx context.Context, session authn.Session, id string) error
}
type Repository interface {
CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error)
UpdateAlarm(ctx context.Context, alarm Alarm) (Alarm, error)
ViewAlarm(ctx context.Context, alarmID, domainID string) (Alarm, error)
ListAllAlarms(ctx context.Context, pm PageMetadata) (AlarmsPage, error)
DeleteAlarm(ctx context.Context, id string) error
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms_test
import (
"fmt"
"testing"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateAlarms(t *testing.T) {
cases := []struct {
desc string
alarm alarms.Alarm
err error
}{
{
desc: "valid alarm",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: nil,
},
{
desc: "missing rule_id",
alarm: alarms.Alarm{
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: errors.New("rule_id is required"),
},
{
desc: "missing domain_id",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: errors.New("domain_id is required"),
},
{
desc: "missing channel_id",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: errors.New("channel_id is required"),
},
{
desc: "missing client_id",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: errors.New("client_id is required"),
},
{
desc: "missing measurement",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: errors.New("measurement is required"),
},
{
desc: "missing value",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: errors.New("value is required"),
},
{
desc: "missing cause",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Severity: 100,
},
err: errors.New("cause is required"),
},
{
desc: "higher severity",
alarm: alarms.Alarm{
RuleID: testsutil.GenerateUUID(t),
DomainID: testsutil.GenerateUUID(t),
ChannelID: testsutil.GenerateUUID(t),
ClientID: testsutil.GenerateUUID(t),
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: alarms.SeverityMax + 1,
},
err: alarms.ErrInvalidSeverity,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
err := tc.alarm.Validate()
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
})
}
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package api contains API-related concerns: endpoint definitions, middlewares
// and all resource representations.
package api
+104
View File
@@ -0,0 +1,104 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"context"
"github.com/absmach/magistrala/alarms"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/go-kit/kit/endpoint"
)
func updateAlarmEndpoint(svc alarms.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(updateAlarmReq)
if err := req.validate(); err != nil {
return alarmRes{}, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return alarmRes{}, svcerr.ErrAuthorization
}
alarm, err := svc.UpdateAlarm(ctx, session, req.Alarm)
if err != nil {
return alarmRes{}, err
}
return alarmRes{
Alarm: alarm,
}, nil
}
}
func viewAlarmEndpoint(svc alarms.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(alarmReq)
if err := req.validate(); err != nil {
return alarmRes{}, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return alarmRes{}, svcerr.ErrAuthorization
}
alarm, err := svc.ViewAlarm(ctx, session, req.ID)
if err != nil {
return alarmRes{}, err
}
return alarmRes{
Alarm: alarm,
}, nil
}
}
func listAlarmsEndpoint(svc alarms.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(listAlarmsReq)
if err := req.validate(); err != nil {
return alarmsPageRes{}, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return alarmsPageRes{}, svcerr.ErrAuthorization
}
alarms, err := svc.ListAlarms(ctx, session, req.PageMetadata)
if err != nil {
return alarmsPageRes{}, err
}
return alarmsPageRes{
AlarmsPage: alarms,
}, nil
}
}
func deleteAlarmEndpoint(svc alarms.Service) endpoint.Endpoint {
return func(ctx context.Context, request any) (any, error) {
req := request.(alarmReq)
if err := req.validate(); err != nil {
return alarmRes{}, errors.Wrap(apiutil.ErrValidation, err)
}
session, ok := ctx.Value(authn.SessionKey).(authn.Session)
if !ok {
return alarmRes{}, svcerr.ErrAuthorization
}
if err := svc.DeleteAlarm(ctx, session, req.ID); err != nil {
return alarmRes{}, err
}
return alarmRes{deleted: true}, nil
}
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"errors"
"github.com/absmach/magistrala/alarms"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
)
type alarmReq struct {
alarms.Alarm `json:",inline"`
}
func (req alarmReq) validate() error {
if req.Alarm.ID == "" {
return errors.New("missing alarm id")
}
return nil
}
type updateAlarmReq struct {
alarms.Alarm `json:",inline"`
}
func (req updateAlarmReq) validate() error {
if req.Alarm.ID == "" {
return errors.New("missing alarm id")
}
if req.Alarm.AssigneeID == "" && req.Alarm.AcknowledgedBy == "" && req.Alarm.ResolvedBy == "" && len(req.Alarm.Metadata) == 0 {
return errors.New("at least one of assignee_id, acknowledged_by, resolved_by, or metadata must be set")
}
return nil
}
type listAlarmsReq struct {
alarms.PageMetadata
}
func (req listAlarmsReq) validate() error {
if req.Limit > api.MaxLimitSize || req.Limit < 1 {
return apiutil.ErrLimitSize
}
if req.Order != "" && req.Order != api.UpdatedAtOrder && req.Order != api.CreatedAtOrder {
return apiutil.ErrInvalidOrder
}
if req.Dir != api.AscDir && req.Dir != api.DescDir {
return apiutil.ErrInvalidDirection
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"fmt"
"net/http"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/alarms"
)
var (
_ magistrala.Response = (*alarmRes)(nil)
_ magistrala.Response = (*alarmsPageRes)(nil)
)
type alarmRes struct {
alarms.Alarm `json:",inline"`
created bool
deleted bool
}
func (res alarmRes) Headers() map[string]string {
switch {
case res.created:
return map[string]string{
"Location": fmt.Sprintf("/%s/alarms/%s", res.DomainID, res.ID),
}
default:
return map[string]string{}
}
}
func (res alarmRes) Code() int {
switch {
case res.created:
return http.StatusCreated
case res.deleted:
return http.StatusNoContent
default:
return http.StatusOK
}
}
func (res alarmRes) Empty() bool {
switch {
case res.deleted:
return true
default:
return false
}
}
type alarmsPageRes struct {
alarms.AlarmsPage `json:",inline"`
}
func (res alarmsPageRes) Headers() map[string]string {
return map[string]string{}
}
func (res alarmsPageRes) Code() int {
return http.StatusOK
}
func (res alarmsPageRes) Empty() bool {
return false
}
+209
View File
@@ -0,0 +1,209 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package api
import (
"context"
"encoding/json"
"log/slog"
"math"
"net/http"
"strings"
"time"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/alarms"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
smqauthn "github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
"github.com/go-chi/chi/v5"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func MakeHandler(svc alarms.Service, logger *slog.Logger, idp magistrala.IDProvider, instanceID string, authn smqauthn.AuthNMiddleware) http.Handler {
opts := []kithttp.ServerOption{
kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, api.EncodeError)),
}
mux := chi.NewRouter()
mux.Route("/{domainID}/alarms", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authn.WithOptions(smqauthn.WithDomainCheck(true)).Middleware())
r.Use(api.RequestIDMiddleware(idp))
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
listAlarmsEndpoint(svc),
decodeListAlarmsReq,
api.EncodeResponse,
opts...,
), "list_alarms").ServeHTTP)
r.Route("/{alarmID}", func(r chi.Router) {
r.Get("/", otelhttp.NewHandler(kithttp.NewServer(
viewAlarmEndpoint(svc),
decodeAlarmReq,
api.EncodeResponse,
opts...,
), "get_alarm").ServeHTTP)
r.Put("/", otelhttp.NewHandler(kithttp.NewServer(
updateAlarmEndpoint(svc),
decodeUpdateAlarmReq,
api.EncodeResponse,
opts...,
), "update_alarm").ServeHTTP)
r.Delete("/", otelhttp.NewHandler(kithttp.NewServer(
deleteAlarmEndpoint(svc),
decodeAlarmReq,
api.EncodeResponse,
opts...,
), "delete_alarm").ServeHTTP)
})
})
})
mux.Get("/health", magistrala.Health("alarms", instanceID))
mux.Handle("/metrics", promhttp.Handler())
return mux
}
func decodeListAlarmsReq(_ context.Context, r *http.Request) (any, error) {
offset, err := apiutil.ReadNumQuery[uint64](r, api.OffsetKey, api.DefOffset)
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
limit, err := apiutil.ReadNumQuery[uint64](r, api.LimitKey, api.DefLimit)
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
domainID, err := apiutil.ReadStringQuery(r, "domain_id", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
channelID, err := apiutil.ReadStringQuery(r, "channel_id", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
clientID, err := apiutil.ReadStringQuery(r, "client_id", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
subtopic, err := apiutil.ReadStringQuery(r, "subtopic", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
ruleID, err := apiutil.ReadStringQuery(r, "rule_id", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
s, err := apiutil.ReadStringQuery(r, api.StatusKey, alarms.All)
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
status, err := alarms.ToStatus(s)
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
assigneeID, err := apiutil.ReadStringQuery(r, "assignee_id", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
serverity, err := apiutil.ReadNumQuery(r, "severity", uint64(math.MaxUint8))
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
updatedBy, err := apiutil.ReadStringQuery(r, "updated_by", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
assignedBy, err := apiutil.ReadStringQuery(r, "assigned_by", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
acknowledgedBy, err := apiutil.ReadStringQuery(r, "acknowledged_by", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
resolvedBy, err := apiutil.ReadStringQuery(r, "resolved_by", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
cfrom, err := apiutil.ReadStringQuery(r, "created_from", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
cto, err := apiutil.ReadStringQuery(r, "created_to", "")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
order, err := apiutil.ReadStringQuery(r, api.OrderKey, api.DefOrder)
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
dir, err := apiutil.ReadStringQuery(r, api.DirKey, "desc")
if err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
var createdFrom, createdTo time.Time
if cfrom != "" {
if createdFrom, err = time.Parse(time.RFC3339, cfrom); err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
}
if cto != "" {
if createdTo, err = time.Parse(time.RFC3339, cto); err != nil {
return listAlarmsReq{}, errors.Wrap(apiutil.ErrValidation, err)
}
}
return listAlarmsReq{
PageMetadata: alarms.PageMetadata{
Offset: offset,
Limit: limit,
DomainID: domainID,
ChannelID: channelID,
ClientID: clientID,
Subtopic: subtopic,
RuleID: ruleID,
Status: status,
AssigneeID: assigneeID,
ResolvedBy: resolvedBy,
Severity: uint8(serverity),
UpdatedBy: updatedBy,
AcknowledgedBy: acknowledgedBy,
AssignedBy: assignedBy,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
Dir: dir,
Order: order,
},
}, nil
}
func decodeAlarmReq(_ context.Context, r *http.Request) (any, error) {
return alarmReq{
Alarm: alarms.Alarm{
ID: chi.URLParam(r, "alarmID"),
},
}, nil
}
func decodeUpdateAlarmReq(_ context.Context, r *http.Request) (any, error) {
if !strings.Contains(r.Header.Get("Content-Type"), api.ContentType) {
return updateAlarmReq{}, apiutil.ErrUnsupportedContentType
}
req := updateAlarmReq{}
if err := json.NewDecoder(r.Body).Decode(&req.Alarm); err != nil {
return updateAlarmReq{}, errors.Wrap(apiutil.ErrMalformedRequestBody, err)
}
req.Alarm.ID = chi.URLParam(r, "alarmID")
return req, nil
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"time"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
)
type atomService struct {
Service
projector atom.Projector
}
func WithAtom(svc Service, projector atom.Projector) Service {
if projector == nil {
return svc
}
return atomService{Service: svc, projector: projector}
}
func (svc atomService) CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error) {
created, err := svc.Service.CreateAlarm(ctx, alarm)
if err != nil {
return created, err
}
if created.ID == "" {
return created, nil
}
if err := svc.projector.UpsertResource(ctx, alarmProjection(created)); err != nil {
return created, nil
}
return created, nil
}
func (svc atomService) UpdateAlarm(ctx context.Context, session authn.Session, alarm Alarm) (Alarm, error) {
updated, err := svc.Service.UpdateAlarm(ctx, session, alarm)
if err != nil {
return updated, err
}
if err := svc.projector.UpsertResource(ctx, alarmProjection(updated)); err != nil {
return updated, nil
}
return updated, nil
}
func (svc atomService) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
if err := svc.Service.DeleteAlarm(ctx, session, id); err != nil {
return err
}
_ = svc.projector.DeleteResource(ctx, id)
return nil
}
func alarmProjection(a Alarm) atom.Resource {
res := atom.ResourceFromFields(atom.ObjectFields{
ID: a.ID,
Kind: atom.KindAlarm,
Name: a.Cause,
TenantID: a.DomainID,
OwnerID: a.AssigneeID,
Status: a.Status.String(),
Metadata: map[string]any(a.Metadata),
UpdatedBy: a.UpdatedBy,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
res.Attributes["rule_id"] = a.RuleID
res.Attributes["channel_id"] = a.ChannelID
res.Attributes["client_id"] = a.ClientID
res.Attributes["subtopic"] = a.Subtopic
res.Attributes["severity"] = a.Severity
res.Attributes["measurement"] = a.Measurement
res.Attributes["value"] = a.Value
res.Attributes["unit"] = a.Unit
res.Attributes["threshold"] = a.Threshold
res.Attributes["cause"] = a.Cause
res.Attributes["assignee_id"] = a.AssigneeID
res.Attributes["assigned_at"] = alarmTimeString(a.AssignedAt)
res.Attributes["assigned_by"] = a.AssignedBy
res.Attributes["acknowledged_at"] = alarmTimeString(a.AcknowledgedAt)
res.Attributes["acknowledged_by"] = a.AcknowledgedBy
res.Attributes["resolved_at"] = alarmTimeString(a.ResolvedAt)
res.Attributes["resolved_by"] = a.ResolvedBy
return res
}
func alarmTimeString(ts time.Time) string {
if ts.IsZero() {
return ""
}
return ts.Format(time.RFC3339Nano)
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"testing"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
)
func TestAtomServiceCreateAlarmProjectsCreatedAlarm(t *testing.T) {
projector := &alarmProjector{}
svc := WithAtom(alarmService{
create: Alarm{
ID: "alarm-1",
RuleID: "rule-1",
DomainID: "domain-1",
ChannelID: "channel-1",
ClientID: "client-1",
Cause: "high temperature",
Measurement: "temperature",
Value: "92.4",
Unit: "C",
Threshold: "80",
Severity: 90,
Status: ActiveStatus,
},
}, projector)
created, err := svc.CreateAlarm(context.Background(), Alarm{RuleID: "rule-1"})
if err != nil {
t.Fatalf("create alarm: %v", err)
}
if created.ID != "alarm-1" {
t.Fatalf("unexpected created alarm: %#v", created)
}
if projector.resource.ID != "alarm-1" || projector.resource.Kind != atom.KindAlarm {
t.Fatalf("unexpected projection: %#v", projector.resource)
}
if projector.resource.Attributes["rule_id"] != "rule-1" {
t.Fatalf("missing rule projection: %#v", projector.resource.Attributes)
}
if projector.resource.Attributes["value"] != "92.4" || projector.resource.Attributes["threshold"] != "80" {
t.Fatalf("missing alarm value projection: %#v", projector.resource.Attributes)
}
}
type alarmService struct {
create Alarm
}
func (svc alarmService) CreateAlarm(context.Context, Alarm) (Alarm, error) {
return svc.create, nil
}
func (svc alarmService) UpdateAlarm(context.Context, authn.Session, Alarm) (Alarm, error) {
return Alarm{}, nil
}
func (svc alarmService) ViewAlarm(context.Context, authn.Session, string) (Alarm, error) {
return Alarm{}, nil
}
func (svc alarmService) ListAlarms(context.Context, authn.Session, PageMetadata) (AlarmsPage, error) {
return AlarmsPage{}, nil
}
func (svc alarmService) DeleteAlarm(context.Context, authn.Session, string) error {
return nil
}
type alarmProjector struct {
atom.Projector
resource atom.Resource
}
func (p *alarmProjector) UpsertResource(_ context.Context, resource atom.Resource) error {
p.resource = resource
return nil
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
//go:build msg_fluxmq
// +build msg_fluxmq
package brokers
import (
"context"
"log/slog"
"time"
"github.com/absmach/magistrala/pkg/messaging"
broker "github.com/absmach/magistrala/pkg/messaging/fluxmq"
"github.com/nats-io/nats.go/jetstream"
)
const (
AllTopic = "alarms/#"
prefix = "alarms"
)
var cfg = jetstream.StreamConfig{
Name: "alarms",
Description: "Magistrala stream alarms",
Subjects: []string{"alarms/#"},
Retention: jetstream.LimitsPolicy,
MaxMsgsPerSubject: 1e6,
MaxAge: time.Hour * 24,
MaxMsgSize: 1024 * 1024,
Discard: jetstream.DiscardOld,
Storage: jetstream.FileStorage,
}
func NewPubSub(ctx context.Context, url string, logger *slog.Logger) (messaging.PubSub, error) {
pb, err := broker.NewPubSub(ctx, url, logger, broker.Prefix(prefix), broker.JSStreamConfig(cfg), broker.ConnectionName("alarms-msg-pubsub"))
if err != nil {
return nil, err
}
return pb, nil
}
func NewPublisher(ctx context.Context, url string) (messaging.Publisher, error) {
pb, err := broker.NewPublisher(ctx, url, broker.Prefix(prefix), broker.JSStreamConfig(cfg), broker.ConnectionName("alarms-msg-pub"))
if err != nil {
return nil, err
}
return pb, nil
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
//go:build !msg_fluxmq && !msg_rabbitmq && !rabbitmq
// +build !msg_fluxmq,!msg_rabbitmq,!rabbitmq
package brokers
import (
"context"
"log/slog"
"time"
"github.com/absmach/magistrala/pkg/messaging"
broker "github.com/absmach/magistrala/pkg/messaging/nats"
"github.com/nats-io/nats.go/jetstream"
)
const (
AllTopic = "alarms/#"
prefix = "alarms"
)
var cfg = jetstream.StreamConfig{
Name: "alarms",
Description: "Magistrala stream alarms",
Subjects: []string{"alarms.>"},
Retention: jetstream.LimitsPolicy,
MaxMsgsPerSubject: 1e6,
MaxAge: time.Hour * 24,
MaxMsgSize: 1024 * 1024,
Discard: jetstream.DiscardOld,
Storage: jetstream.FileStorage,
}
func NewPubSub(ctx context.Context, url string, logger *slog.Logger) (messaging.PubSub, error) {
pb, err := broker.NewPubSub(ctx, url, logger, broker.Prefix(prefix), broker.JSStreamConfig(cfg))
if err != nil {
return nil, err
}
return pb, nil
}
func NewPublisher(ctx context.Context, url string) (messaging.Publisher, error) {
pb, err := broker.NewPublisher(ctx, url, broker.Prefix(prefix), broker.JSStreamConfig(cfg))
if err != nil {
return nil, err
}
return pb, nil
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package consumer
import (
"bytes"
"context"
"encoding/gob"
"log/slog"
"time"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/messaging"
)
var errFailedToDecode = errors.New("failed to decode alarm")
type handler struct {
svc alarms.Service
logger *slog.Logger
}
func NewHandler(svc alarms.Service, logger *slog.Logger) messaging.MessageHandler {
return &handler{svc: svc, logger: logger}
}
func (h handler) Handle(msg *messaging.Message) (err error) {
if msg == nil {
return errors.New("message is empty")
}
if msg.GetPayload() == nil {
return errors.New("message payload is empty")
}
var alarm alarms.Alarm
if err := gob.NewDecoder(bytes.NewReader(msg.GetPayload())).Decode(&alarm); err != nil {
return messaging.NewError(errors.Wrap(errFailedToDecode, err), messaging.Term)
}
alarm.DomainID = msg.GetDomain()
alarm.ChannelID = msg.GetChannel()
alarm.ClientID = msg.ClientIdentity()
alarm.Subtopic = msg.GetSubtopic()
alarm.CreatedAt = time.Unix(0, int64(msg.GetCreated()))
if err := alarm.Validate(); err != nil {
return err
}
_, err = h.svc.CreateAlarm(context.Background(), alarm)
return err
}
func (h handler) Cancel() error {
return nil
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package alarms contains domain concept definitions needed to support
// Alarms service feature, i.e. create, read, update, and delete alarms.
package alarms
+197
View File
@@ -0,0 +1,197 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/operations"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
smqauthz "github.com/absmach/magistrala/pkg/authz"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/absmach/magistrala/pkg/policies"
)
var (
errDomainUpdateAlarms = errors.New("not authorized to update alarms in domain")
errDomainDeleteAlarms = errors.New("not authorized to delete alarms in domain")
errDomainViewAlarms = errors.New("not authorized to view alarms in domain")
)
type authorizationMiddleware struct {
svc alarms.Service
authz smqauthz.Authorization
atomAuthz atom.Authorizer
entitiesOps permissions.EntitiesOperations[permissions.Operation]
}
var _ alarms.Service = (*authorizationMiddleware)(nil)
func NewAuthorizationMiddleware(svc alarms.Service, authz smqauthz.Authorization, entitiesOps permissions.EntitiesOperations[permissions.Operation]) (alarms.Service, error) {
if err := entitiesOps.Validate(); err != nil {
return nil, err
}
return &authorizationMiddleware{
svc: svc,
authz: authz,
entitiesOps: entitiesOps,
}, nil
}
func NewAtomAuthorizationMiddleware(svc alarms.Service, authz atom.Authorizer, entitiesOps permissions.EntitiesOperations[permissions.Operation]) (alarms.Service, error) {
if err := entitiesOps.Validate(); err != nil {
return nil, err
}
return &authorizationMiddleware{
svc: svc,
atomAuthz: authz,
entitiesOps: entitiesOps,
}, nil
}
func (am *authorizationMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
return am.svc.CreateAlarm(ctx, alarm)
}
func (am *authorizationMiddleware) UpdateAlarm(ctx context.Context, session authn.Session, alarm alarms.Alarm) (alarms.Alarm, error) {
if len(alarm.Metadata) > 0 {
if err := am.authorize(ctx, operations.OpUpdateAlarm, session, policies.DomainType, session.DomainID); err != nil {
return alarms.Alarm{}, errors.Wrap(errDomainUpdateAlarms, err)
}
}
if alarm.AssigneeID != "" {
if err := am.authorize(ctx, operations.OpAssignAlarm, session, policies.DomainType, session.DomainID); err != nil {
return alarms.Alarm{}, errors.Wrap(errDomainUpdateAlarms, err)
}
if am.atomAuthz == nil {
domainUserID := auth.EncodeDomainUserID(session.DomainID, alarm.AssigneeID)
if err := am.authz.Authorize(ctx, smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: domainUserID,
Permission: policies.MembershipPermission,
ObjectType: policies.DomainType,
Object: session.DomainID,
}, nil); err != nil {
return alarms.Alarm{}, err
}
}
}
if alarm.AcknowledgedBy != "" {
if err := am.authorize(ctx, operations.OpAcknowledgeAlarm, session, policies.DomainType, session.DomainID); err != nil {
return alarms.Alarm{}, errors.Wrap(errDomainUpdateAlarms, err)
}
}
if alarm.ResolvedBy != "" {
if err := am.authorize(ctx, operations.OpResolveAlarm, session, policies.DomainType, session.DomainID); err != nil {
return alarms.Alarm{}, errors.Wrap(errDomainUpdateAlarms, err)
}
}
return am.svc.UpdateAlarm(ctx, session, alarm)
}
func (am *authorizationMiddleware) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
if err := am.authorize(ctx, operations.OpDeleteAlarm, session, policies.DomainType, session.DomainID); err != nil {
return errors.Wrap(errDomainDeleteAlarms, err)
}
return am.svc.DeleteAlarm(ctx, session, id)
}
func (am *authorizationMiddleware) ListAlarms(ctx context.Context, session authn.Session, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
if pm.DomainID == "" {
pm.DomainID = session.DomainID
}
switch err := am.checkSuperAdmin(ctx, session); {
case err == nil:
session.SuperAdmin = true
case errors.Contains(err, svcerr.ErrSuperAdminAction):
if err := am.authorize(ctx, operations.OpListAlarms, session, operations.EntityType, auth.AnyIDs); err != nil {
return alarms.AlarmsPage{}, errors.Wrap(errDomainViewAlarms, err)
}
default:
return alarms.AlarmsPage{}, err
}
return am.svc.ListAlarms(ctx, session, pm)
}
func (am *authorizationMiddleware) ViewAlarm(ctx context.Context, session authn.Session, id string) (alarms.Alarm, error) {
if err := am.authorize(ctx, operations.OpViewAlarm, session, policies.DomainType, session.DomainID); err != nil {
return alarms.Alarm{}, errors.Wrap(errDomainViewAlarms, err)
}
return am.svc.ViewAlarm(ctx, session, id)
}
func (am *authorizationMiddleware) authorize(ctx context.Context, op permissions.Operation, session authn.Session, objType, obj string) error {
perm, err := am.entitiesOps.GetPermission(operations.EntityType, op)
if err != nil {
return err
}
if am.atomAuthz != nil {
return atom.Authorize(ctx, am.atomAuthz, session, perm.String(), objType, obj, atom.KindAlarm)
}
pr := smqauthz.PolicyReq{
Domain: session.DomainID,
SubjectType: policies.UserType,
SubjectKind: policies.UsersKind,
Subject: session.DomainUserID,
Object: obj,
ObjectType: objType,
Permission: perm.String(),
}
var pat *smqauthz.PATReq
if session.PatID != "" {
opName := am.entitiesOps.OperationName(operations.EntityType, op)
pat = &smqauthz.PATReq{
UserID: session.UserID,
PatID: session.PatID,
EntityID: auth.AnyIDs,
EntityType: auth.RulesType.String(),
Operation: opName,
Domain: session.DomainID,
}
}
if err := am.authz.Authorize(ctx, pr, pat); err != nil {
return err
}
return nil
}
func (am *authorizationMiddleware) checkSuperAdmin(ctx context.Context, session authn.Session) error {
if session.Role != authn.SuperAdminRole {
return svcerr.ErrSuperAdminAction
}
if am.atomAuthz != nil {
return atom.Authorize(ctx, am.atomAuthz, session, policies.AdminPermission, policies.PlatformType, policies.MagistralaObject, policies.PlatformType)
}
if err := am.authz.Authorize(ctx, smqauthz.PolicyReq{
SubjectType: policies.UserType,
Subject: session.UserID,
Permission: policies.AdminPermission,
ObjectType: policies.PlatformType,
Object: policies.MagistralaObject,
}, nil); err != nil {
return err
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"testing"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/mocks"
"github.com/absmach/magistrala/alarms/operations"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
pkgerrors "github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type recordingAtomAuthorizer struct {
allowed bool
reqs []atom.AuthzRequest
}
func (a *recordingAtomAuthorizer) CheckAuthz(_ context.Context, req atom.AuthzRequest) (atom.AuthzResponse, error) {
a.reqs = append(a.reqs, req)
return atom.AuthzResponse{Allowed: a.allowed}, nil
}
func TestListAlarmsAuthorizesRegularUser(t *testing.T) {
svc := mocks.NewService(t)
pm := alarms.PageMetadata{Limit: 10}
expectedPM := pm
expectedPM.DomainID = "domain-1"
session := authn.Session{UserID: "user-1", DomainID: "domain-1", DomainUserID: "domain-1_user-1"}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListAlarms", mock.Anything, session, expectedPM).Return(alarms.AlarmsPage{Limit: 10}, nil).Once()
page, err := wrapped.ListAlarms(context.Background(), session, pm)
require.NoError(t, err)
assert.Equal(t, uint64(10), page.Limit)
require.Len(t, authz.reqs, 1)
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: "list",
ResourceID: auth.AnyIDs,
ObjectKind: "resource",
ObjectID: auth.AnyIDs,
Context: map[string]any{
"domain_id": "domain-1",
"legacy_object_type": operations.EntityType,
},
}, authz.reqs[0])
}
func TestListAlarmsDeniedRegularUserDoesNotDelegate(t *testing.T) {
svc := mocks.NewService(t)
authz := &recordingAtomAuthorizer{allowed: false}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
_, err = wrapped.ListAlarms(context.Background(), authn.Session{UserID: "user-1", DomainID: "domain-1"}, alarms.PageMetadata{})
assert.True(t, pkgerrors.Contains(err, pkgerrors.ErrAuthorization))
require.Len(t, authz.reqs, 1)
}
func TestListAlarmsSuperAdminSkipsListAuthorization(t *testing.T) {
svc := mocks.NewService(t)
pm := alarms.PageMetadata{Limit: 10}
expectedPM := pm
expectedPM.DomainID = "domain-1"
session := authn.Session{UserID: "admin-1", DomainID: "domain-1", Role: authn.SuperAdminRole}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListAlarms", mock.Anything, mock.MatchedBy(func(s authn.Session) bool {
return s.SuperAdmin
}), expectedPM).Return(alarms.AlarmsPage{Limit: 10}, nil).Once()
_, err = wrapped.ListAlarms(context.Background(), session, pm)
require.NoError(t, err)
require.Len(t, authz.reqs, 1)
assert.Equal(t, "manage", authz.reqs[0].Action)
}
func testEntitiesOps(t *testing.T) permissions.EntitiesOperations[permissions.Operation] {
t.Helper()
details := operations.OperationDetails()
perms := make(map[string]permissions.Permission, len(details))
for _, detail := range details {
if detail.PermissionRequired {
perms[detail.Name] = permissions.Permission(detail.Name)
}
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{operations.EntityType: perms},
permissions.EntitiesOperationDetails[permissions.Operation]{operations.EntityType: details},
)
require.NoError(t, err)
return entitiesOps
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package middleware provides middleware for the alarms service.
// This is logging, metrics, and tracing middleware.
package middleware
+155
View File
@@ -0,0 +1,155 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"log/slog"
"time"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/pkg/authn"
"github.com/go-chi/chi/v5/middleware"
)
type loggingMiddleware struct {
logger *slog.Logger
service alarms.Service
}
var _ alarms.Service = (*loggingMiddleware)(nil)
func NewLoggingMiddleware(logger *slog.Logger, service alarms.Service) alarms.Service {
return &loggingMiddleware{
logger: logger,
service: service,
}
}
func (lm *loggingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (created alarms.Alarm, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("request_id", middleware.GetReqID(ctx)),
slog.Group("alarm",
slog.String("rule_id", alarm.RuleID),
slog.String("domain_id", alarm.DomainID),
slog.String("channel_id", alarm.ChannelID),
slog.String("client_id", alarm.ClientID),
slog.String("subtopic", alarm.Subtopic),
slog.String("measurement", alarm.Measurement),
slog.String("value", alarm.Value),
slog.String("unit", alarm.Unit),
slog.Uint64("status", uint64(alarm.Status)),
slog.Uint64("severity", uint64(alarm.Severity)),
slog.String("threshold", alarm.Threshold),
slog.String("cause", alarm.Cause),
),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Create alarm failed", args...)
return
}
if created.ID != "" {
lm.logger.Info("Create alarm completed successfully", args...)
}
}(time.Now())
return lm.service.CreateAlarm(ctx, alarm)
}
func (lm *loggingMiddleware) UpdateAlarm(ctx context.Context, session authn.Session, alarm alarms.Alarm) (dba alarms.Alarm, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("request_id", middleware.GetReqID(ctx)),
slog.Group("alarm",
slog.String("id", dba.ID),
slog.String("rule_id", dba.RuleID),
slog.String("domain_id", dba.DomainID),
slog.String("channel_id", dba.ChannelID),
slog.String("client_id", dba.ClientID),
slog.String("subtopic", dba.Subtopic),
slog.String("measurement", dba.Measurement),
slog.String("value", dba.Value),
slog.String("unit", dba.Unit),
slog.String("status", dba.Status.String()),
slog.Uint64("severity", uint64(dba.Severity)),
slog.String("threshold", dba.Threshold),
slog.String("cause", dba.Cause),
),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Update alarm failed", args...)
return
}
lm.logger.Info("Update alarm completed successfully", args...)
}(time.Now())
return lm.service.UpdateAlarm(ctx, session, alarm)
}
func (lm *loggingMiddleware) ViewAlarm(ctx context.Context, session authn.Session, id string) (dba alarms.Alarm, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("request_id", middleware.GetReqID(ctx)),
slog.String("id", id),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("View alarm failed", args...)
return
}
lm.logger.Info("View alarm completed successfully", args...)
}(time.Now())
return lm.service.ViewAlarm(ctx, session, id)
}
func (lm *loggingMiddleware) ListAlarms(ctx context.Context, session authn.Session, pm alarms.PageMetadata) (dbp alarms.AlarmsPage, err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("request_id", middleware.GetReqID(ctx)),
slog.Int("offset", int(pm.Offset)),
slog.Int("limit", int(pm.Limit)),
slog.String("rule_id", pm.RuleID),
slog.String("domain_id", pm.DomainID),
slog.String("channel_id", pm.ChannelID),
slog.String("client_id", pm.ClientID),
slog.String("subtopic", pm.Subtopic),
slog.String("status", pm.Status.String()),
slog.Uint64("severity", uint64(pm.Severity)),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("List alarms failed", args...)
return
}
lm.logger.Info("List alarms completed successfully", args...)
}(time.Now())
return lm.service.ListAlarms(ctx, session, pm)
}
func (lm *loggingMiddleware) DeleteAlarm(ctx context.Context, session authn.Session, id string) (err error) {
defer func(begin time.Time) {
args := []any{
slog.String("duration", time.Since(begin).String()),
slog.String("request_id", middleware.GetReqID(ctx)),
slog.String("id", id),
}
if err != nil {
args = append(args, slog.Any("error", err))
lm.logger.Warn("Delete alarm failed", args...)
return
}
lm.logger.Info("Delete alarm completed successfully", args...)
}(time.Now())
return lm.service.DeleteAlarm(ctx, session, id)
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"time"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/pkg/authn"
"github.com/go-kit/kit/metrics"
)
type metricsMiddleware struct {
counter metrics.Counter
latency metrics.Histogram
service alarms.Service
}
var _ alarms.Service = (*metricsMiddleware)(nil)
func NewMetricsMiddleware(counter metrics.Counter, latency metrics.Histogram, service alarms.Service) alarms.Service {
return &metricsMiddleware{
counter: counter,
latency: latency,
service: service,
}
}
func (mm *metricsMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
defer func(begin time.Time) {
mm.counter.With("method", "create_alarm").Add(1)
mm.latency.With("method", "create_alarm").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.service.CreateAlarm(ctx, alarm)
}
func (mm *metricsMiddleware) UpdateAlarm(ctx context.Context, session authn.Session, alarm alarms.Alarm) (alarms.Alarm, error) {
defer func(begin time.Time) {
mm.counter.With("method", "update_alarm").Add(1)
mm.latency.With("method", "update_alarm").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.service.UpdateAlarm(ctx, session, alarm)
}
func (mm *metricsMiddleware) ViewAlarm(ctx context.Context, session authn.Session, id string) (alarms.Alarm, error) {
defer func(begin time.Time) {
mm.counter.With("method", "get_alarm").Add(1)
mm.latency.With("method", "get_alarm").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.service.ViewAlarm(ctx, session, id)
}
func (mm *metricsMiddleware) ListAlarms(ctx context.Context, session authn.Session, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
defer func(begin time.Time) {
mm.counter.With("method", "list_alarms").Add(1)
mm.latency.With("method", "list_alarms").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.service.ListAlarms(ctx, session, pm)
}
func (mm *metricsMiddleware) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
defer func(begin time.Time) {
mm.counter.With("method", "delete_alarm").Add(1)
mm.latency.With("method", "delete_alarm").Observe(time.Since(begin).Seconds())
}(time.Now())
return mm.service.DeleteAlarm(ctx, session, id)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/pkg/authn"
smqTracing "github.com/absmach/magistrala/pkg/tracing"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
type tracingMiddleware struct {
tracer trace.Tracer
svc alarms.Service
}
var _ alarms.Service = (*tracingMiddleware)(nil)
func NewTracingMiddleware(tracer trace.Tracer, svc alarms.Service) alarms.Service {
return &tracingMiddleware{
tracer: tracer,
svc: svc,
}
}
func (tm *tracingMiddleware) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
ctx, span := smqTracing.StartSpan(ctx, tm.tracer, "create_alarm", trace.WithAttributes(
attribute.String("rule_id", alarm.RuleID),
attribute.String("measurement", alarm.Measurement),
attribute.String("value", alarm.Value),
attribute.String("unit", alarm.Unit),
attribute.String("cause", alarm.Cause),
attribute.String("status", alarm.Status.String()),
))
defer span.End()
return tm.svc.CreateAlarm(ctx, alarm)
}
func (tm *tracingMiddleware) UpdateAlarm(ctx context.Context, session authn.Session, alarm alarms.Alarm) (alarms.Alarm, error) {
ctx, span := smqTracing.StartSpan(ctx, tm.tracer, "update_alarm", trace.WithAttributes(
attribute.String("rule_id", alarm.RuleID),
attribute.String("measurement", alarm.Measurement),
attribute.String("value", alarm.Value),
attribute.String("unit", alarm.Unit),
attribute.String("cause", alarm.Cause),
attribute.String("status", alarm.Status.String()),
))
defer span.End()
return tm.svc.UpdateAlarm(ctx, session, alarm)
}
func (tm *tracingMiddleware) ViewAlarm(ctx context.Context, session authn.Session, id string) (alarms.Alarm, error) {
ctx, span := smqTracing.StartSpan(ctx, tm.tracer, "get_alarm", trace.WithAttributes(
attribute.String("id", id),
))
defer span.End()
return tm.svc.ViewAlarm(ctx, session, id)
}
func (tm *tracingMiddleware) ListAlarms(ctx context.Context, session authn.Session, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
ctx, span := smqTracing.StartSpan(ctx, tm.tracer, "list_alarms", trace.WithAttributes(
attribute.Int("offset", int(pm.Offset)),
attribute.Int("limit", int(pm.Limit)),
))
defer span.End()
return tm.svc.ListAlarms(ctx, session, pm)
}
func (tm *tracingMiddleware) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
ctx, span := smqTracing.StartSpan(ctx, tm.tracer, "delete_alarm", trace.WithAttributes(
attribute.String("id", id),
))
defer span.End()
return tm.svc.DeleteAlarm(ctx, session, id)
}
+370
View File
@@ -0,0 +1,370 @@
// 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/alarms"
mock "github.com/stretchr/testify/mock"
)
// NewRepository creates a new instance of Repository. 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 NewRepository(t interface {
mock.TestingT
Cleanup(func())
}) *Repository {
mock := &Repository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Repository is an autogenerated mock type for the Repository type
type Repository struct {
mock.Mock
}
type Repository_Expecter struct {
mock *mock.Mock
}
func (_m *Repository) EXPECT() *Repository_Expecter {
return &Repository_Expecter{mock: &_m.Mock}
}
// CreateAlarm provides a mock function for the type Repository
func (_mock *Repository) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
ret := _mock.Called(ctx, alarm)
if len(ret) == 0 {
panic("no return value specified for CreateAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) (alarms.Alarm, error)); ok {
return returnFunc(ctx, alarm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) alarms.Alarm); ok {
r0 = returnFunc(ctx, alarm)
} else {
r0 = ret.Get(0).(alarms.Alarm)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, alarms.Alarm) error); ok {
r1 = returnFunc(ctx, alarm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Repository_CreateAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAlarm'
type Repository_CreateAlarm_Call struct {
*mock.Call
}
// CreateAlarm is a helper method to define mock.On call
// - ctx context.Context
// - alarm alarms.Alarm
func (_e *Repository_Expecter) CreateAlarm(ctx interface{}, alarm interface{}) *Repository_CreateAlarm_Call {
return &Repository_CreateAlarm_Call{Call: _e.mock.On("CreateAlarm", ctx, alarm)}
}
func (_c *Repository_CreateAlarm_Call) Run(run func(ctx context.Context, alarm alarms.Alarm)) *Repository_CreateAlarm_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 alarms.Alarm
if args[1] != nil {
arg1 = args[1].(alarms.Alarm)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Repository_CreateAlarm_Call) Return(alarm1 alarms.Alarm, err error) *Repository_CreateAlarm_Call {
_c.Call.Return(alarm1, err)
return _c
}
func (_c *Repository_CreateAlarm_Call) RunAndReturn(run func(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error)) *Repository_CreateAlarm_Call {
_c.Call.Return(run)
return _c
}
// DeleteAlarm provides a mock function for the type Repository
func (_mock *Repository) DeleteAlarm(ctx context.Context, id string) error {
ret := _mock.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for DeleteAlarm")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = returnFunc(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Repository_DeleteAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAlarm'
type Repository_DeleteAlarm_Call struct {
*mock.Call
}
// DeleteAlarm is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *Repository_Expecter) DeleteAlarm(ctx interface{}, id interface{}) *Repository_DeleteAlarm_Call {
return &Repository_DeleteAlarm_Call{Call: _e.mock.On("DeleteAlarm", ctx, id)}
}
func (_c *Repository_DeleteAlarm_Call) Run(run func(ctx context.Context, id string)) *Repository_DeleteAlarm_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 *Repository_DeleteAlarm_Call) Return(err error) *Repository_DeleteAlarm_Call {
_c.Call.Return(err)
return _c
}
func (_c *Repository_DeleteAlarm_Call) RunAndReturn(run func(ctx context.Context, id string) error) *Repository_DeleteAlarm_Call {
_c.Call.Return(run)
return _c
}
// ListAllAlarms provides a mock function for the type Repository
func (_mock *Repository) ListAllAlarms(ctx context.Context, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
ret := _mock.Called(ctx, pm)
if len(ret) == 0 {
panic("no return value specified for ListAllAlarms")
}
var r0 alarms.AlarmsPage
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.PageMetadata) (alarms.AlarmsPage, error)); ok {
return returnFunc(ctx, pm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.PageMetadata) alarms.AlarmsPage); ok {
r0 = returnFunc(ctx, pm)
} else {
r0 = ret.Get(0).(alarms.AlarmsPage)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, alarms.PageMetadata) error); ok {
r1 = returnFunc(ctx, pm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Repository_ListAllAlarms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAllAlarms'
type Repository_ListAllAlarms_Call struct {
*mock.Call
}
// ListAllAlarms is a helper method to define mock.On call
// - ctx context.Context
// - pm alarms.PageMetadata
func (_e *Repository_Expecter) ListAllAlarms(ctx interface{}, pm interface{}) *Repository_ListAllAlarms_Call {
return &Repository_ListAllAlarms_Call{Call: _e.mock.On("ListAllAlarms", ctx, pm)}
}
func (_c *Repository_ListAllAlarms_Call) Run(run func(ctx context.Context, pm alarms.PageMetadata)) *Repository_ListAllAlarms_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 alarms.PageMetadata
if args[1] != nil {
arg1 = args[1].(alarms.PageMetadata)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Repository_ListAllAlarms_Call) Return(alarmsPage alarms.AlarmsPage, err error) *Repository_ListAllAlarms_Call {
_c.Call.Return(alarmsPage, err)
return _c
}
func (_c *Repository_ListAllAlarms_Call) RunAndReturn(run func(ctx context.Context, pm alarms.PageMetadata) (alarms.AlarmsPage, error)) *Repository_ListAllAlarms_Call {
_c.Call.Return(run)
return _c
}
// UpdateAlarm provides a mock function for the type Repository
func (_mock *Repository) UpdateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
ret := _mock.Called(ctx, alarm)
if len(ret) == 0 {
panic("no return value specified for UpdateAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) (alarms.Alarm, error)); ok {
return returnFunc(ctx, alarm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) alarms.Alarm); ok {
r0 = returnFunc(ctx, alarm)
} else {
r0 = ret.Get(0).(alarms.Alarm)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, alarms.Alarm) error); ok {
r1 = returnFunc(ctx, alarm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Repository_UpdateAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAlarm'
type Repository_UpdateAlarm_Call struct {
*mock.Call
}
// UpdateAlarm is a helper method to define mock.On call
// - ctx context.Context
// - alarm alarms.Alarm
func (_e *Repository_Expecter) UpdateAlarm(ctx interface{}, alarm interface{}) *Repository_UpdateAlarm_Call {
return &Repository_UpdateAlarm_Call{Call: _e.mock.On("UpdateAlarm", ctx, alarm)}
}
func (_c *Repository_UpdateAlarm_Call) Run(run func(ctx context.Context, alarm alarms.Alarm)) *Repository_UpdateAlarm_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 alarms.Alarm
if args[1] != nil {
arg1 = args[1].(alarms.Alarm)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Repository_UpdateAlarm_Call) Return(alarm1 alarms.Alarm, err error) *Repository_UpdateAlarm_Call {
_c.Call.Return(alarm1, err)
return _c
}
func (_c *Repository_UpdateAlarm_Call) RunAndReturn(run func(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error)) *Repository_UpdateAlarm_Call {
_c.Call.Return(run)
return _c
}
// ViewAlarm provides a mock function for the type Repository
func (_mock *Repository) ViewAlarm(ctx context.Context, alarmID string, domainID string) (alarms.Alarm, error) {
ret := _mock.Called(ctx, alarmID, domainID)
if len(ret) == 0 {
panic("no return value specified for ViewAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (alarms.Alarm, error)); ok {
return returnFunc(ctx, alarmID, domainID)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) alarms.Alarm); ok {
r0 = returnFunc(ctx, alarmID, domainID)
} else {
r0 = ret.Get(0).(alarms.Alarm)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = returnFunc(ctx, alarmID, domainID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Repository_ViewAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViewAlarm'
type Repository_ViewAlarm_Call struct {
*mock.Call
}
// ViewAlarm is a helper method to define mock.On call
// - ctx context.Context
// - alarmID string
// - domainID string
func (_e *Repository_Expecter) ViewAlarm(ctx interface{}, alarmID interface{}, domainID interface{}) *Repository_ViewAlarm_Call {
return &Repository_ViewAlarm_Call{Call: _e.mock.On("ViewAlarm", ctx, alarmID, domainID)}
}
func (_c *Repository_ViewAlarm_Call) Run(run func(ctx context.Context, alarmID string, domainID string)) *Repository_ViewAlarm_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 *Repository_ViewAlarm_Call) Return(alarm alarms.Alarm, err error) *Repository_ViewAlarm_Call {
_c.Call.Return(alarm, err)
return _c
}
func (_c *Repository_ViewAlarm_Call) RunAndReturn(run func(ctx context.Context, alarmID string, domainID string) (alarms.Alarm, error)) *Repository_ViewAlarm_Call {
_c.Call.Return(run)
return _c
}
+389
View File
@@ -0,0 +1,389 @@
// 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/alarms"
"github.com/absmach/magistrala/pkg/authn"
mock "github.com/stretchr/testify/mock"
)
// NewService creates a new instance of Service. 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 NewService(t interface {
mock.TestingT
Cleanup(func())
}) *Service {
mock := &Service{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Service is an autogenerated mock type for the Service type
type Service struct {
mock.Mock
}
type Service_Expecter struct {
mock *mock.Mock
}
func (_m *Service) EXPECT() *Service_Expecter {
return &Service_Expecter{mock: &_m.Mock}
}
// CreateAlarm provides a mock function for the type Service
func (_mock *Service) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
ret := _mock.Called(ctx, alarm)
if len(ret) == 0 {
panic("no return value specified for CreateAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) (alarms.Alarm, error)); ok {
return returnFunc(ctx, alarm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alarms.Alarm) alarms.Alarm); ok {
r0 = returnFunc(ctx, alarm)
} else {
r0 = ret.Get(0).(alarms.Alarm)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, alarms.Alarm) error); ok {
r1 = returnFunc(ctx, alarm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Service_CreateAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateAlarm'
type Service_CreateAlarm_Call struct {
*mock.Call
}
// CreateAlarm is a helper method to define mock.On call
// - ctx context.Context
// - alarm alarms.Alarm
func (_e *Service_Expecter) CreateAlarm(ctx interface{}, alarm interface{}) *Service_CreateAlarm_Call {
return &Service_CreateAlarm_Call{Call: _e.mock.On("CreateAlarm", ctx, alarm)}
}
func (_c *Service_CreateAlarm_Call) Run(run func(ctx context.Context, alarm alarms.Alarm)) *Service_CreateAlarm_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 alarms.Alarm
if args[1] != nil {
arg1 = args[1].(alarms.Alarm)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *Service_CreateAlarm_Call) Return(alarm1 alarms.Alarm, err error) *Service_CreateAlarm_Call {
_c.Call.Return(alarm1, err)
return _c
}
func (_c *Service_CreateAlarm_Call) RunAndReturn(run func(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error)) *Service_CreateAlarm_Call {
_c.Call.Return(run)
return _c
}
// DeleteAlarm provides a mock function for the type Service
func (_mock *Service) DeleteAlarm(ctx context.Context, session authn.Session, id string) error {
ret := _mock.Called(ctx, session, id)
if len(ret) == 0 {
panic("no return value specified for DeleteAlarm")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) error); ok {
r0 = returnFunc(ctx, session, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Service_DeleteAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAlarm'
type Service_DeleteAlarm_Call struct {
*mock.Call
}
// DeleteAlarm is a helper method to define mock.On call
// - ctx context.Context
// - session authn.Session
// - id string
func (_e *Service_Expecter) DeleteAlarm(ctx interface{}, session interface{}, id interface{}) *Service_DeleteAlarm_Call {
return &Service_DeleteAlarm_Call{Call: _e.mock.On("DeleteAlarm", ctx, session, id)}
}
func (_c *Service_DeleteAlarm_Call) Run(run func(ctx context.Context, session authn.Session, id string)) *Service_DeleteAlarm_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 authn.Session
if args[1] != nil {
arg1 = args[1].(authn.Session)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Service_DeleteAlarm_Call) Return(err error) *Service_DeleteAlarm_Call {
_c.Call.Return(err)
return _c
}
func (_c *Service_DeleteAlarm_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, id string) error) *Service_DeleteAlarm_Call {
_c.Call.Return(run)
return _c
}
// ListAlarms provides a mock function for the type Service
func (_mock *Service) ListAlarms(ctx context.Context, session authn.Session, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
ret := _mock.Called(ctx, session, pm)
if len(ret) == 0 {
panic("no return value specified for ListAlarms")
}
var r0 alarms.AlarmsPage
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, alarms.PageMetadata) (alarms.AlarmsPage, error)); ok {
return returnFunc(ctx, session, pm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, alarms.PageMetadata) alarms.AlarmsPage); ok {
r0 = returnFunc(ctx, session, pm)
} else {
r0 = ret.Get(0).(alarms.AlarmsPage)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, authn.Session, alarms.PageMetadata) error); ok {
r1 = returnFunc(ctx, session, pm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Service_ListAlarms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAlarms'
type Service_ListAlarms_Call struct {
*mock.Call
}
// ListAlarms is a helper method to define mock.On call
// - ctx context.Context
// - session authn.Session
// - pm alarms.PageMetadata
func (_e *Service_Expecter) ListAlarms(ctx interface{}, session interface{}, pm interface{}) *Service_ListAlarms_Call {
return &Service_ListAlarms_Call{Call: _e.mock.On("ListAlarms", ctx, session, pm)}
}
func (_c *Service_ListAlarms_Call) Run(run func(ctx context.Context, session authn.Session, pm alarms.PageMetadata)) *Service_ListAlarms_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 authn.Session
if args[1] != nil {
arg1 = args[1].(authn.Session)
}
var arg2 alarms.PageMetadata
if args[2] != nil {
arg2 = args[2].(alarms.PageMetadata)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Service_ListAlarms_Call) Return(alarmsPage alarms.AlarmsPage, err error) *Service_ListAlarms_Call {
_c.Call.Return(alarmsPage, err)
return _c
}
func (_c *Service_ListAlarms_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, pm alarms.PageMetadata) (alarms.AlarmsPage, error)) *Service_ListAlarms_Call {
_c.Call.Return(run)
return _c
}
// UpdateAlarm provides a mock function for the type Service
func (_mock *Service) UpdateAlarm(ctx context.Context, session authn.Session, alarm alarms.Alarm) (alarms.Alarm, error) {
ret := _mock.Called(ctx, session, alarm)
if len(ret) == 0 {
panic("no return value specified for UpdateAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, alarms.Alarm) (alarms.Alarm, error)); ok {
return returnFunc(ctx, session, alarm)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, alarms.Alarm) alarms.Alarm); ok {
r0 = returnFunc(ctx, session, alarm)
} else {
r0 = ret.Get(0).(alarms.Alarm)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, authn.Session, alarms.Alarm) error); ok {
r1 = returnFunc(ctx, session, alarm)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Service_UpdateAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAlarm'
type Service_UpdateAlarm_Call struct {
*mock.Call
}
// UpdateAlarm is a helper method to define mock.On call
// - ctx context.Context
// - session authn.Session
// - alarm alarms.Alarm
func (_e *Service_Expecter) UpdateAlarm(ctx interface{}, session interface{}, alarm interface{}) *Service_UpdateAlarm_Call {
return &Service_UpdateAlarm_Call{Call: _e.mock.On("UpdateAlarm", ctx, session, alarm)}
}
func (_c *Service_UpdateAlarm_Call) Run(run func(ctx context.Context, session authn.Session, alarm alarms.Alarm)) *Service_UpdateAlarm_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 authn.Session
if args[1] != nil {
arg1 = args[1].(authn.Session)
}
var arg2 alarms.Alarm
if args[2] != nil {
arg2 = args[2].(alarms.Alarm)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Service_UpdateAlarm_Call) Return(alarm1 alarms.Alarm, err error) *Service_UpdateAlarm_Call {
_c.Call.Return(alarm1, err)
return _c
}
func (_c *Service_UpdateAlarm_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, alarm alarms.Alarm) (alarms.Alarm, error)) *Service_UpdateAlarm_Call {
_c.Call.Return(run)
return _c
}
// ViewAlarm provides a mock function for the type Service
func (_mock *Service) ViewAlarm(ctx context.Context, session authn.Session, id string) (alarms.Alarm, error) {
ret := _mock.Called(ctx, session, id)
if len(ret) == 0 {
panic("no return value specified for ViewAlarm")
}
var r0 alarms.Alarm
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) (alarms.Alarm, error)); ok {
return returnFunc(ctx, session, id)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, authn.Session, string) alarms.Alarm); ok {
r0 = returnFunc(ctx, session, id)
} else {
r0 = ret.Get(0).(alarms.Alarm)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, authn.Session, string) error); ok {
r1 = returnFunc(ctx, session, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Service_ViewAlarm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ViewAlarm'
type Service_ViewAlarm_Call struct {
*mock.Call
}
// ViewAlarm is a helper method to define mock.On call
// - ctx context.Context
// - session authn.Session
// - id string
func (_e *Service_Expecter) ViewAlarm(ctx interface{}, session interface{}, id interface{}) *Service_ViewAlarm_Call {
return &Service_ViewAlarm_Call{Call: _e.mock.On("ViewAlarm", ctx, session, id)}
}
func (_c *Service_ViewAlarm_Call) Run(run func(ctx context.Context, session authn.Session, id string)) *Service_ViewAlarm_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 authn.Session
if args[1] != nil {
arg1 = args[1].(authn.Session)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Service_ViewAlarm_Call) Return(alarm alarms.Alarm, err error) *Service_ViewAlarm_Call {
_c.Call.Return(alarm, err)
return _c
}
func (_c *Service_ViewAlarm_Call) RunAndReturn(run func(ctx context.Context, session authn.Session, id string) (alarms.Alarm, error)) *Service_ViewAlarm_Call {
_c.Call.Return(run)
return _c
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package operations
import "github.com/absmach/magistrala/pkg/permissions"
const EntityType = "alarm"
// Alarm Operations.
const (
OpViewAlarm permissions.Operation = iota
OpDeleteAlarm
OpListAlarms
OpAssignAlarm
OpAcknowledgeAlarm
OpResolveAlarm
OpUpdateAlarm
)
func OperationDetails() map[permissions.Operation]permissions.OperationDetails {
return map[permissions.Operation]permissions.OperationDetails{
OpViewAlarm: {
Name: "view",
PermissionRequired: true,
},
OpDeleteAlarm: {
Name: "delete",
PermissionRequired: true,
},
OpListAlarms: {
Name: "list",
PermissionRequired: true,
},
OpAssignAlarm: {
Name: "alarm_assign",
PermissionRequired: true,
},
OpAcknowledgeAlarm: {
Name: "alarm_acknowledge",
PermissionRequired: true,
},
OpResolveAlarm: {
Name: "alarm_resolve",
PermissionRequired: true,
},
OpUpdateAlarm: {
Name: "update",
PermissionRequired: true,
},
}
}
+507
View File
@@ -0,0 +1,507 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/absmach/magistrala/alarms"
api "github.com/absmach/magistrala/api/http"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/absmach/magistrala/pkg/postgres"
"github.com/jmoiron/sqlx"
)
const alarmColumns = `alarms.id, alarms.rule_id, alarms.domain_id, alarms.channel_id, alarms.client_id, alarms.subtopic, alarms.measurement, alarms.value, alarms.unit,
alarms.threshold, alarms.cause, alarms.status, alarms.severity, alarms.assignee_id, alarms.created_at, alarms.updated_at, alarms.updated_by, alarms.assigned_at,
alarms.assigned_by, alarms.acknowledged_at, alarms.acknowledged_by, alarms.resolved_at, alarms.resolved_by, alarms.metadata`
type repository struct {
db *sqlx.DB
}
var _ alarms.Repository = (*repository)(nil)
func NewAlarmsRepo(db *sqlx.DB) alarms.Repository {
return &repository{db: db}
}
func (r *repository) CreateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
query := `
WITH existing AS (
SELECT status, severity
FROM alarms
WHERE domain_id = :domain_id
AND rule_id = :rule_id
AND channel_id = :channel_id
AND client_id = :client_id
AND subtopic = :subtopic
AND measurement = :measurement
AND created_at <= :created_at
ORDER BY created_at DESC
LIMIT 1
)
INSERT INTO alarms (
id, rule_id, domain_id, channel_id, client_id, subtopic, measurement,
value, unit, threshold, cause, status, severity, assignee_id,
created_at, updated_at, updated_by, assigned_at, assigned_by,
acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
)
SELECT
:id, :rule_id, :domain_id, :channel_id, :client_id, :subtopic, :measurement,
:value, :unit, :threshold, :cause, :status, :severity, :assignee_id,
:created_at, :updated_at, :updated_by, :assigned_at, :assigned_by,
:acknowledged_at, :acknowledged_by, :resolved_at, :resolved_by, :metadata
WHERE (
EXISTS (
SELECT 1 FROM existing
WHERE existing.status IS DISTINCT FROM :status
OR (:status = 0 AND existing.status = 0 AND existing.severity IS DISTINCT FROM :severity)
)
OR (
NOT EXISTS (SELECT 1 FROM existing) AND :status = 0
)
)
RETURNING
id, rule_id, domain_id, channel_id, client_id, subtopic, measurement,
value, unit, threshold, cause, status, severity, created_at,
assignee_id, updated_at, updated_by, assigned_at, assigned_by,
acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
;
`
dba, err := toDBAlarm(alarm)
if err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrCreateEntity, err)
}
row, err := r.db.NamedQueryContext(ctx, query, dba)
if err != nil {
return alarms.Alarm{}, postgres.HandleError(repoerr.ErrCreateEntity, err)
}
defer row.Close()
if !row.Next() {
return alarms.Alarm{}, repoerr.ErrNotFound
}
dba = dbAlarm{}
if err := row.StructScan(&dba); err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrCreateEntity, err)
}
return toAlarm(dba)
}
func (r *repository) UpdateAlarm(ctx context.Context, alarm alarms.Alarm) (alarms.Alarm, error) {
var query []string
var upq string
if alarm.Status != 0 {
query = append(query, "status = :status,")
}
if alarm.AssigneeID != "" {
query = append(query, "assignee_id = :assignee_id,")
}
if !alarm.AssignedAt.IsZero() {
query = append(query, "assigned_at = :assigned_at,")
}
if alarm.AssignedBy != "" {
query = append(query, "assigned_by = :assigned_by,")
}
if alarm.AcknowledgedBy != "" {
query = append(query, "acknowledged_by = :acknowledged_by,")
}
if !alarm.AcknowledgedAt.IsZero() {
query = append(query, "acknowledged_at = :acknowledged_at,")
}
if alarm.ResolvedBy != "" {
query = append(query, "resolved_by = :resolved_by,")
}
if !alarm.ResolvedAt.IsZero() {
query = append(query, "resolved_at = :resolved_at,")
}
if alarm.Metadata != nil {
query = append(query, "metadata = :metadata,")
}
if len(query) > 0 {
upq = strings.Join(query, " ")
}
q := fmt.Sprintf(`UPDATE alarms SET %s updated_by = :updated_by, updated_at = :updated_at WHERE id = :id
RETURNING id, rule_id, domain_id, channel_id, client_id, subtopic, measurement, value, unit, threshold,
cause, status, severity, assignee_id, assigned_at, assigned_by, acknowledged_at, acknowledged_by,
resolved_by, resolved_at, metadata, created_at, updated_by, updated_at;`, upq)
dba, err := toDBAlarm(alarm)
if err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrUpdateEntity, err)
}
row, err := r.db.NamedQueryContext(ctx, q, dba)
if err != nil {
return alarms.Alarm{}, postgres.HandleError(repoerr.ErrUpdateEntity, err)
}
defer row.Close()
if !row.Next() {
return alarms.Alarm{}, repoerr.ErrNotFound
}
dba = dbAlarm{}
if err := row.StructScan(&dba); err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrUpdateEntity, err)
}
return toAlarm(dba)
}
func (r *repository) ViewAlarm(ctx context.Context, alarmID, domainID string) (alarms.Alarm, error) {
query := `SELECT * FROM alarms WHERE id = :id AND domain_id = :domain_id;`
row, err := r.db.NamedQueryContext(ctx, query, map[string]any{
"id": alarmID, "domain_id": domainID,
})
if err != nil {
return alarms.Alarm{}, postgres.HandleError(repoerr.ErrViewEntity, err)
}
defer row.Close()
if !row.Next() {
return alarms.Alarm{}, repoerr.ErrNotFound
}
dba := dbAlarm{}
if err := row.StructScan(&dba); err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
alarm, err := toAlarm(dba)
if err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
return alarm, nil
}
func (r *repository) ListAllAlarms(ctx context.Context, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
query, err := pageQuery(pm)
if err != nil {
return alarms.AlarmsPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
comQuery := fmt.Sprintf(`SELECT %s FROM alarms %s`, alarmColumns, query)
return r.alarmsPage(ctx, comQuery, pm)
}
func (r *repository) alarmsPage(ctx context.Context, comQuery string, pm alarms.PageMetadata) (alarms.AlarmsPage, error) {
dir := api.DescDir
if pm.Dir == api.AscDir {
dir = api.AscDir
}
var orderClause string
switch pm.Order {
case api.CreatedAtOrder:
orderClause = fmt.Sprintf("ORDER BY created_at %s, id %s", dir, dir)
default:
orderClause = fmt.Sprintf("ORDER BY COALESCE(updated_at, created_at) %s, id %s", dir, dir)
}
q := fmt.Sprintf(`SELECT * FROM (%s) AS sub_query %s LIMIT :limit OFFSET :offset;`, comQuery, orderClause)
cq := fmt.Sprintf(`SELECT COUNT(*) AS total_count FROM (%s) AS sub_query;`, comQuery)
rows, err := r.db.NamedQueryContext(ctx, q, pm)
if err != nil {
return alarms.AlarmsPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
defer rows.Close()
var items []alarms.Alarm
for rows.Next() {
dba := dbAlarm{}
if err := rows.StructScan(&dba); err != nil {
return alarms.AlarmsPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
a, err := toAlarm(dba)
if err != nil {
return alarms.AlarmsPage{}, err
}
items = append(items, a)
}
total, err := postgres.Total(ctx, r.db, cq, pm)
if err != nil {
return alarms.AlarmsPage{}, errors.Wrap(repoerr.ErrViewEntity, err)
}
return alarms.AlarmsPage{
Total: total,
Offset: pm.Offset,
Limit: pm.Limit,
Alarms: items,
}, nil
}
func (r *repository) DeleteAlarm(ctx context.Context, id string) error {
query := `DELETE FROM alarms WHERE id = :id;`
result, err := r.db.NamedExecContext(ctx, query, map[string]any{"id": id})
if err != nil {
return errors.Wrap(repoerr.ErrRemoveEntity, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return errors.Wrap(repoerr.ErrRemoveEntity, err)
}
if rowsAffected == 0 {
return repoerr.ErrNotFound
}
return nil
}
type dbAlarm struct {
ID string `db:"id"`
RuleID string `db:"rule_id"`
DomainID string `db:"domain_id"`
ChannelID string `db:"channel_id"`
ClientID string `db:"client_id"`
Subtopic string `db:"subtopic"`
Measurement string `db:"measurement"`
Value string `db:"value"`
Unit string `db:"unit"`
Cause string `db:"cause"`
Threshold string `db:"threshold"`
Status alarms.Status `db:"status"`
Severity uint8 `db:"severity"`
AssigneeID string `db:"assignee_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt sql.NullTime `db:"updated_at,omitempty"`
UpdatedBy *string `db:"updated_by,omitempty"`
AssignedAt sql.NullTime `db:"assigned_at,omitempty"`
AssignedBy *string `db:"assigned_by,omitempty"`
AcknowledgedAt sql.NullTime `db:"acknowledged_at,omitempty"`
AcknowledgedBy *string `db:"acknowledged_by,omitempty"`
ResolvedAt sql.NullTime `db:"resolved_at,omitempty"`
ResolvedBy *string `db:"resolved_by,omitempty"`
Metadata []byte `db:"metadata,omitempty"`
}
func toDBAlarm(a alarms.Alarm) (dbAlarm, error) {
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now()
}
var updatedBy *string
if a.UpdatedBy != "" {
updatedBy = &a.UpdatedBy
}
var updatedAt sql.NullTime
if a.UpdatedAt != (time.Time{}) {
updatedAt = sql.NullTime{Time: a.UpdatedAt, Valid: true}
}
var acknowledgedBy *string
if a.AcknowledgedBy != "" {
acknowledgedBy = &a.AcknowledgedBy
}
var acknowledgedAt sql.NullTime
if a.AcknowledgedAt != (time.Time{}) {
acknowledgedAt = sql.NullTime{Time: a.AcknowledgedAt, Valid: true}
}
var resolvedBy *string
if a.ResolvedBy != "" {
resolvedBy = &a.ResolvedBy
}
var resolvedAt sql.NullTime
if a.ResolvedAt != (time.Time{}) {
resolvedAt = sql.NullTime{Time: a.ResolvedAt, Valid: true}
}
var assignedBy *string
if a.AssignedBy != "" {
assignedBy = &a.AssignedBy
}
var assignedAt sql.NullTime
if a.AssignedAt != (time.Time{}) {
assignedAt = sql.NullTime{Time: a.AssignedAt, Valid: true}
}
metadata := []byte("{}")
if len(a.Metadata) > 0 {
b, err := json.Marshal(a.Metadata)
if err != nil {
return dbAlarm{}, errors.Wrap(repoerr.ErrMalformedEntity, err)
}
metadata = b
}
return dbAlarm{
ID: a.ID,
RuleID: a.RuleID,
DomainID: a.DomainID,
ChannelID: a.ChannelID,
ClientID: a.ClientID,
Subtopic: a.Subtopic,
Measurement: a.Measurement,
Value: a.Value,
Unit: a.Unit,
Cause: a.Cause,
Threshold: a.Threshold,
Status: a.Status,
Severity: a.Severity,
AssigneeID: a.AssigneeID,
CreatedAt: a.CreatedAt,
UpdatedAt: updatedAt,
UpdatedBy: updatedBy,
AssignedAt: assignedAt,
AssignedBy: assignedBy,
AcknowledgedAt: acknowledgedAt,
AcknowledgedBy: acknowledgedBy,
ResolvedAt: resolvedAt,
ResolvedBy: resolvedBy,
Metadata: metadata,
}, nil
}
func toAlarm(dbr dbAlarm) (alarms.Alarm, error) {
var updatedBy string
if dbr.UpdatedBy != nil {
updatedBy = *dbr.UpdatedBy
}
var updatedAt time.Time
if dbr.UpdatedAt.Valid {
updatedAt = dbr.UpdatedAt.Time
}
var assignedBy string
if dbr.AssignedBy != nil {
assignedBy = *dbr.AssignedBy
}
var assignedAt time.Time
if dbr.AssignedAt.Valid {
assignedAt = dbr.AssignedAt.Time
}
var acknowledgedBy string
if dbr.AcknowledgedBy != nil {
acknowledgedBy = *dbr.AcknowledgedBy
}
var acknowledgedAt time.Time
if dbr.AcknowledgedAt.Valid {
acknowledgedAt = dbr.AcknowledgedAt.Time
}
var resolvedBy string
if dbr.ResolvedBy != nil {
resolvedBy = *dbr.ResolvedBy
}
var resolvedAt time.Time
if dbr.ResolvedAt.Valid {
resolvedAt = dbr.ResolvedAt.Time
}
var metadata map[string]any
if len(dbr.Metadata) > 0 {
err := json.Unmarshal(dbr.Metadata, &metadata)
if err != nil {
return alarms.Alarm{}, errors.Wrap(repoerr.ErrMalformedEntity, err)
}
}
return alarms.Alarm{
ID: dbr.ID,
RuleID: dbr.RuleID,
DomainID: dbr.DomainID,
ChannelID: dbr.ChannelID,
ClientID: dbr.ClientID,
Subtopic: dbr.Subtopic,
Measurement: dbr.Measurement,
Value: dbr.Value,
Unit: dbr.Unit,
Threshold: dbr.Threshold,
Cause: dbr.Cause,
Status: dbr.Status,
Severity: dbr.Severity,
AssigneeID: dbr.AssigneeID,
CreatedAt: dbr.CreatedAt,
UpdatedAt: updatedAt,
UpdatedBy: updatedBy,
AssignedAt: assignedAt,
AssignedBy: assignedBy,
AcknowledgedAt: acknowledgedAt,
AcknowledgedBy: acknowledgedBy,
ResolvedAt: resolvedAt,
ResolvedBy: resolvedBy,
Metadata: metadata,
}, nil
}
func pageQuery(pm alarms.PageMetadata) (string, error) {
query := pageQueryConditions(pm)
var emq string
if len(query) > 0 {
emq = fmt.Sprintf("WHERE %s", strings.Join(query, " AND "))
}
return emq, nil
}
func pageQueryConditions(pm alarms.PageMetadata) []string {
var query []string
if pm.DomainID != "" {
query = append(query, "alarms.domain_id = :domain_id")
}
if pm.RuleID != "" {
query = append(query, "alarms.rule_id = :rule_id")
}
if pm.ChannelID != "" {
query = append(query, "alarms.channel_id = :channel_id")
}
if pm.Subtopic != "" {
query = append(query, "alarms.subtopic = :subtopic")
}
if pm.ClientID != "" {
query = append(query, "alarms.client_id = :client_id")
}
if pm.Measurement != "" {
query = append(query, "alarms.measurement = :measurement")
}
if pm.Status != alarms.AllStatus {
query = append(query, "alarms.status = :status")
}
if pm.Severity != math.MaxUint8 {
query = append(query, "alarms.severity = :severity")
}
if pm.AssigneeID != "" {
query = append(query, "alarms.assignee_id = :assignee_id")
}
if pm.UpdatedBy != "" {
query = append(query, "alarms.updated_by = :updated_by")
}
if pm.ResolvedBy != "" {
query = append(query, "alarms.resolved_by = :resolved_by")
}
if pm.AcknowledgedBy != "" {
query = append(query, "alarms.acknowledged_by = :acknowledged_by")
}
if pm.AssignedBy != "" {
query = append(query, "alarms.assigned_by = :assigned_by")
}
if !pm.CreatedFrom.IsZero() {
query = append(query, "alarms.created_at >= :created_from")
}
if !pm.CreatedTo.IsZero() {
query = append(query, "alarms.created_at <= :created_to")
}
return query
}
+481
View File
@@ -0,0 +1,481 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/0x6flab/namegenerator"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/postgres"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
namegen = namegenerator.NewGenerator()
idProvider = uuid.New()
)
func TestCreateAlarm(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM alarms")
require.Nil(t, err, fmt.Sprintf("clean alarms unexpected error: %s", err))
})
repo := postgres.NewAlarmsRepo(db)
alarm := alarms.Alarm{
ID: generateUUID(t),
RuleID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Subtopic: namegen.Generate(),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
}
cases := []struct {
desc string
alarm alarms.Alarm
err error
}{
{
desc: "valid alarm",
alarm: alarm,
err: nil,
},
{
desc: "duplicate alarm",
alarm: alarm,
err: repoerr.ErrNotFound,
},
{
desc: "missing rule id",
alarm: alarms.Alarm{
ID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Subtopic: namegen.Generate(),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
},
err: repoerr.ErrCreateEntity,
},
{
desc: "invalid alarm",
alarm: alarms.Alarm{
ID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Subtopic: namegen.Generate(),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": make(chan int),
},
},
err: repoerr.ErrCreateEntity,
},
{
desc: "empty alarm",
alarm: alarms.Alarm{},
err: repoerr.ErrCreateEntity,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
alarm, err := repo.CreateAlarm(context.Background(), tc.alarm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
assert.NotEmpty(t, alarm.ID)
assert.Equal(t, tc.alarm.RuleID, alarm.RuleID)
assert.Equal(t, tc.alarm.Measurement, alarm.Measurement)
assert.Equal(t, tc.alarm.Value, alarm.Value)
assert.Equal(t, tc.alarm.Unit, alarm.Unit)
assert.Equal(t, tc.alarm.Cause, alarm.Cause)
assert.Equal(t, tc.alarm.Status, alarm.Status)
assert.Equal(t, tc.alarm.DomainID, alarm.DomainID)
assert.Equal(t, tc.alarm.AssigneeID, alarm.AssigneeID)
assert.Equal(t, tc.alarm.Metadata, alarm.Metadata)
})
}
}
func TestUpdateAlarm(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM alarms")
require.Nil(t, err, fmt.Sprintf("clean alarms unexpected error: %s", err))
})
repo := postgres.NewAlarmsRepo(db)
alarm := alarms.Alarm{
ID: generateUUID(t),
RuleID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
}
alarm, err := repo.CreateAlarm(context.Background(), alarm)
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
cases := []struct {
desc string
alarm alarms.Alarm
err error
}{
{
desc: "valid alarm",
alarm: alarms.Alarm{
ID: alarm.ID,
Status: alarms.ClearedStatus,
DomainID: alarm.DomainID,
AssigneeID: generateUUID(t),
AssignedBy: generateUUID(t),
AssignedAt: time.Now().UTC(),
AcknowledgedBy: generateUUID(t),
AcknowledgedAt: time.Now().UTC(),
CreatedAt: alarm.CreatedAt,
UpdatedAt: time.Now().UTC(),
UpdatedBy: generateUUID(t),
ResolvedAt: time.Now().UTC(),
ResolvedBy: generateUUID(t),
Metadata: map[string]any{
"key": "value",
},
},
err: nil,
},
{
desc: "non existing alarm",
alarm: alarms.Alarm{
ID: generateUUID(t),
},
err: repoerr.ErrNotFound,
},
{
desc: "invalid alarm",
alarm: alarms.Alarm{
ID: alarm.ID,
RuleID: generateUUID(t),
Status: 0,
DomainID: generateUUID(t),
AssigneeID: strings.Repeat("a", 40),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
},
err: repoerr.ErrMalformedEntity,
},
{
desc: "empty alarm",
alarm: alarms.Alarm{},
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
alarm, err := repo.UpdateAlarm(context.Background(), tc.alarm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
assert.NotEmpty(t, alarm.ID)
assert.Equal(t, tc.alarm.Status, alarm.Status)
assert.Equal(t, tc.alarm.DomainID, alarm.DomainID)
assert.Equal(t, tc.alarm.AssigneeID, alarm.AssigneeID)
assert.Equal(t, tc.alarm.UpdatedBy, alarm.UpdatedBy)
assert.Equal(t, tc.alarm.ResolvedBy, alarm.ResolvedBy)
assert.Equal(t, tc.alarm.AcknowledgedBy, alarm.AcknowledgedBy)
assert.Equal(t, tc.alarm.Metadata, alarm.Metadata)
})
}
}
func TestViewAlarm(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM alarms")
require.Nil(t, err, fmt.Sprintf("clean alarms unexpected error: %s", err))
})
repo := postgres.NewAlarmsRepo(db)
alarm := alarms.Alarm{
ID: generateUUID(t),
RuleID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
}
alarm, err := repo.CreateAlarm(context.Background(), alarm)
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
cases := []struct {
desc string
id string
domainID string
err error
}{
{
desc: "valid alarm",
id: alarm.ID,
domainID: alarm.DomainID,
err: nil,
},
{
desc: "non existing alarm id",
id: generateUUID(t),
domainID: alarm.DomainID,
err: repoerr.ErrNotFound,
},
{
desc: "non existing domain id",
id: alarm.ID,
domainID: generateUUID(t),
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
alarm, err := repo.ViewAlarm(context.Background(), tc.id, tc.domainID)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
assert.NotEmpty(t, alarm.ID)
assert.Equal(t, tc.id, alarm.ID)
})
}
}
func TestListAlarms(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM alarms")
require.Nil(t, err, fmt.Sprintf("clean alarms unexpected error: %s", err))
})
repo := postgres.NewAlarmsRepo(db)
items := make([]alarms.Alarm, 1000)
for i := range 1000 {
items[i] = alarms.Alarm{
ID: generateUUID(t),
RuleID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
}
alarm, err := repo.CreateAlarm(context.Background(), items[i])
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
items[i].ID = alarm.ID
}
cases := []struct {
desc string
pm alarms.PageMetadata
response []alarms.Alarm
err error
}{
{
desc: "valid page",
pm: alarms.PageMetadata{
Offset: 0,
Limit: 10,
},
response: items[:10],
err: nil,
},
{
desc: "offset and limit",
pm: alarms.PageMetadata{
Offset: 10,
Limit: 50,
},
response: items[10:60],
err: nil,
},
{
desc: "empty page",
pm: alarms.PageMetadata{},
response: []alarms.Alarm{},
err: nil,
},
{
desc: "invalid page",
pm: alarms.PageMetadata{
Offset: 1000,
Limit: 10,
},
response: []alarms.Alarm{},
err: nil,
},
{
desc: "invalid assignee id",
pm: alarms.PageMetadata{
Offset: 0,
Limit: 10,
AssigneeID: generateUUID(t),
},
response: []alarms.Alarm{},
err: nil,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
alarms, err := repo.ListAllAlarms(context.Background(), tc.pm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
assert.Equal(t, len(tc.response), len(alarms.Alarms))
})
}
}
func TestDeleteAlarm(t *testing.T) {
t.Cleanup(func() {
_, err := db.Exec("DELETE FROM alarms")
require.Nil(t, err, fmt.Sprintf("clean alarms unexpected error: %s", err))
})
repo := postgres.NewAlarmsRepo(db)
alarm := alarms.Alarm{
ID: generateUUID(t),
RuleID: generateUUID(t),
DomainID: generateUUID(t),
ChannelID: generateUUID(t),
ClientID: generateUUID(t),
Measurement: namegen.Generate(),
Value: namegen.Generate(),
Unit: namegen.Generate(),
Threshold: namegen.Generate(),
Cause: namegen.Generate(),
Status: 0,
AssigneeID: generateUUID(t),
CreatedAt: time.Now().UTC(),
Metadata: map[string]any{
"key": "value",
},
}
alarm, err := repo.CreateAlarm(context.Background(), alarm)
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
cases := []struct {
desc string
id string
err error
}{
{
desc: "valid alarm",
id: alarm.ID,
err: nil,
},
{
desc: "non existing alarm",
id: generateUUID(t),
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
err := repo.DeleteAlarm(context.Background(), tc.id)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
assert.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
})
}
}
func generateUUID(t *testing.T) string {
ulid, err := idProvider.ID()
require.Nil(t, err, fmt.Sprintf("unexpected error: %s", err))
return ulid
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres
import (
_ "github.com/jackc/pgx/v5/stdlib" // required for SQL access
migrate "github.com/rubenv/sql-migrate"
)
// Migration of Alarms service.
func Migration() (*migrate.MemoryMigrationSource, error) {
alarmsMigration := &migrate.MemoryMigrationSource{
Migrations: []*migrate.Migration{
{
Id: "alarms_01",
// VARCHAR(36) for columns with IDs as UUIDS have a maximum of 36 characters
Up: []string{
`CREATE TABLE IF NOT EXISTS alarms (
id VARCHAR(36) PRIMARY KEY,
rule_id VARCHAR(36) NOT NULL CHECK (length(rule_id) > 0),
domain_id VARCHAR(36) NOT NULL,
channel_id VARCHAR(36) NOT NULL,
subtopic TEXT NOT NULL,
client_id VARCHAR(36) NOT NULL,
measurement TEXT NOT NULL,
value TEXT NOT NULL,
unit TEXT NOT NULL,
threshold TEXT NOT NULL,
cause TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 0 CHECK (status >= 0),
severity SMALLINT NOT NULL DEFAULT 0 CHECK (severity >= 0),
assignee_id VARCHAR(36),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NULL,
updated_by VARCHAR(36) NULL,
assigned_at TIMESTAMPTZ NULL,
assigned_by VARCHAR(36) NULL,
acknowledged_at TIMESTAMPTZ NULL,
acknowledged_by VARCHAR(36) NULL,
resolved_at TIMESTAMPTZ NULL,
resolved_by VARCHAR(36) NULL,
metadata JSONB
);`,
"CREATE INDEX IF NOT EXISTS idx_alarms_state ON alarms (domain_id, rule_id, channel_id, subtopic, client_id, measurement, created_at DESC);",
},
Down: []string{
`DROP TABLE IF EXISTS alarms`,
},
},
},
}
return alarmsMigration, nil
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package postgres_test
import (
"database/sql"
"fmt"
"log"
"os"
"testing"
"time"
apostgres "github.com/absmach/magistrala/alarms/postgres"
"github.com/absmach/magistrala/pkg/postgres"
"github.com/jmoiron/sqlx"
dockertest "github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"go.opentelemetry.io/otel"
)
var (
db *sqlx.DB
database postgres.Database
tracer = otel.Tracer("repo_tests")
)
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
container, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Tag: "16.2-alpine",
Env: []string{
"POSTGRES_USER=test",
"POSTGRES_PASSWORD=test",
"POSTGRES_DB=test",
"listen_addresses = '*'",
},
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
log.Fatalf("Could not start container: %s", err)
}
port := container.GetPort("5432/tcp")
// exponential backoff-retry, because the application in the container might not be ready to accept connections yet
pool.MaxWait = 120 * time.Second
if err := pool.Retry(func() error {
url := fmt.Sprintf("host=localhost port=%s user=test dbname=test password=test sslmode=disable", port)
db, err := sql.Open("pgx", url)
if err != nil {
return err
}
return db.Ping()
}); err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}
dbConfig := postgres.Config{
Host: "localhost",
Port: port,
User: "test",
Pass: "test",
Name: "test",
SSLMode: "disable",
SSLCert: "",
SSLKey: "",
SSLRootCert: "",
}
migration, err := apostgres.Migration()
if err != nil {
log.Fatalf("Could not get migration: %s", err)
}
if db, err = postgres.Setup(dbConfig, *migration); err != nil {
log.Fatalf("Could not setup test DB connection: %s", err)
}
database = postgres.NewDatabase(db, dbConfig, tracer)
code := m.Run()
// Defers will not be run when using os.Exit
db.Close()
if err := pool.Purge(container); err != nil {
log.Fatalf("Could not purge container: %s", err)
}
os.Exit(code)
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"context"
"time"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/pkg/authn"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
)
type service struct {
idp magistrala.IDProvider
repo Repository
}
var _ Service = (*service)(nil)
func NewService(idp magistrala.IDProvider, repo Repository) Service {
return &service{
idp: idp,
repo: repo,
}
}
func (s *service) CreateAlarm(ctx context.Context, alarm Alarm) (Alarm, error) {
id, err := s.idp.ID()
if err != nil {
return Alarm{}, err
}
alarm.ID = id
if alarm.CreatedAt.IsZero() {
alarm.CreatedAt = time.Now()
}
if err := alarm.Validate(); err != nil {
return Alarm{}, err
}
created, err := s.repo.CreateAlarm(ctx, alarm)
if err != nil && err != repoerr.ErrNotFound {
return Alarm{}, err
}
if err == repoerr.ErrNotFound {
return Alarm{}, nil
}
return created, nil
}
func (s *service) ViewAlarm(ctx context.Context, session authn.Session, alarmID string) (Alarm, error) {
return s.repo.ViewAlarm(ctx, alarmID, session.DomainID)
}
func (s *service) ListAlarms(ctx context.Context, session authn.Session, pm PageMetadata) (AlarmsPage, error) {
pm.DomainID = session.DomainID
return s.repo.ListAllAlarms(ctx, pm)
}
func (s *service) DeleteAlarm(ctx context.Context, session authn.Session, alarmID string) error {
return s.repo.DeleteAlarm(ctx, alarmID)
}
func (s *service) UpdateAlarm(ctx context.Context, session authn.Session, alarm Alarm) (Alarm, error) {
alarm.UpdatedAt = time.Now()
alarm.UpdatedBy = session.UserID
return s.repo.UpdateAlarm(ctx, alarm)
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/mocks"
"github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var idp = uuid.New()
func newService(t *testing.T, repo *mocks.Repository) alarms.Service {
return alarms.NewService(idp, repo)
}
func TestCreateAlarm(t *testing.T) {
repo := new(mocks.Repository)
svc := newService(t, repo)
ts := time.Now()
cases := []struct {
desc string
alarm alarms.Alarm
err error
}{
{
desc: "valid alarm",
alarm: alarms.Alarm{
RuleID: "rule-id",
DomainID: "domain-id",
ChannelID: "channel-id",
ClientID: "client-id",
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
CreatedAt: ts,
},
err: nil,
},
{
desc: "missing rule_id",
alarm: alarms.Alarm{
DomainID: "domain-id",
ChannelID: "channel-id",
ClientID: "client-id",
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
CreatedAt: ts,
},
err: errors.New("rule_id is required"),
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
repoCall := repo.On("CreateAlarm", context.Background(), mock.Anything).Return(tc.alarm, tc.err)
_, err := svc.CreateAlarm(context.Background(), tc.alarm)
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
repoCall.Unset()
})
}
}
func TestViewAlarm(t *testing.T) {
repo := new(mocks.Repository)
svc := newService(t, repo)
cases := []struct {
desc string
id string
domainID string
err error
}{
{
desc: "valid alarm",
id: "alarm-id",
domainID: "domain-id",
err: nil,
},
{
desc: "non existing alarm id",
id: "alarm-id",
domainID: "domain-id",
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
s := authn.Session{DomainID: tc.domainID}
repoCall := repo.On("ViewAlarm", context.Background(), tc.id, tc.domainID).Return(alarms.Alarm{}, tc.err)
_, err := svc.ViewAlarm(context.Background(), s, tc.id)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
repoCall.Unset()
})
}
}
func TestUpdateAlarm(t *testing.T) {
repo := new(mocks.Repository)
svc := newService(t, repo)
cases := []struct {
desc string
alarm alarms.Alarm
err error
}{
{
desc: "valid alarm",
alarm: alarms.Alarm{
RuleID: "rule-id",
DomainID: "domain-id",
ChannelID: "channel-id",
ClientID: "client-id",
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: nil,
},
{
desc: "non existing alarm",
alarm: alarms.Alarm{
RuleID: "rule-id",
DomainID: "domain-id",
ChannelID: "channel-id",
ClientID: "client-id",
Subtopic: "subtopic",
Measurement: "measurement",
Value: "value",
Unit: "unit",
Cause: "cause",
Severity: 100,
},
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
s := authn.Session{DomainID: tc.alarm.DomainID}
repoCall := repo.On("UpdateAlarm", context.Background(), mock.Anything).Return(tc.alarm, tc.err)
_, err := svc.UpdateAlarm(context.Background(), s, tc.alarm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
repoCall.Unset()
})
}
}
func TestListAlarms(t *testing.T) {
repo := new(mocks.Repository)
svc := newService(t, repo)
cases := []struct {
desc string
pm alarms.PageMetadata
page alarms.AlarmsPage
err error
}{
{
desc: "valid page",
pm: alarms.PageMetadata{
Offset: 0,
Limit: 10,
},
page: alarms.AlarmsPage{
Offset: 0,
Limit: 10,
Total: 10,
Alarms: []alarms.Alarm{},
},
err: nil,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
s := authn.Session{DomainID: tc.pm.DomainID}
repoCall := repo.On("ListAllAlarms", context.Background(), tc.pm).Return(tc.page, tc.err)
_, err := svc.ListAlarms(context.Background(), s, tc.pm)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
repoCall.Unset()
})
}
}
func TestDeleteAlarm(t *testing.T) {
repo := new(mocks.Repository)
svc := newService(t, repo)
cases := []struct {
desc string
id string
err error
}{
{
desc: "valid alarm",
id: "alarm-id",
err: nil,
},
{
desc: "non existing alarm",
id: "alarm-id",
err: repoerr.ErrNotFound,
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
s := authn.Session{DomainID: tc.id}
repoCall := repo.On("DeleteAlarm", context.Background(), tc.id).Return(tc.err)
err := svc.DeleteAlarm(context.Background(), s, tc.id)
if tc.err != nil {
assert.True(t, errors.Contains(err, tc.err), fmt.Sprintf("%s: expected %s got %s\n", tc.desc, tc.err, err))
return
}
repoCall.Unset()
})
}
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package alarms
import (
"encoding/json"
"strings"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
)
type Status uint8
const (
ActiveStatus Status = iota
ClearedStatus
// AllStatus is used for querying purposes to list alarms irrespective
// of their status. It is never stored in the database as the actual
// Alarm status and should always be the largest value in this enumeration.
AllStatus
)
const (
Active = "active"
Cleared = "cleared"
Unknown = "unknown"
All = "all"
)
// String converts alarm status to string literal.
func (s Status) String() string {
switch s {
case ActiveStatus:
return Active
case ClearedStatus:
return Cleared
default:
return Unknown
}
}
// ToStatus converts string value to a valid Alarm status.
func ToStatus(status string) (Status, error) {
switch strings.ToLower(status) {
case Active:
return ActiveStatus, nil
case Cleared:
return ClearedStatus, nil
case All:
return AllStatus, nil
default:
return Status(0), svcerr.ErrInvalidStatus
}
}
// Custom Marshaller for Alarm.
func (s Status) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
// Custom Unmarshaler for Alarm.
func (s *Status) UnmarshalJSON(data []byte) error {
str := strings.Trim(string(data), "\"")
val, err := ToStatus(str)
*s = val
return err
}
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: auth/v1/auth.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: auth/v1/auth.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: certs/v1/certs.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: certs/v1/certs.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: channels/v1/channels.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: channels/v1/channels.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: clients/v1/clients.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: clients/v1/clients.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: common/v1/common.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: domains/v1/domains.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: domains/v1/domains.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: groups/v1/groups.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: groups/v1/groups.proto
package v1
+3 -14
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: readers/v1/readers.proto
package v1
@@ -104,7 +104,6 @@ type PageMetadata struct {
Format string `protobuf:"bytes,17,opt,name=format,proto3" json:"format,omitempty"`
Order string `protobuf:"bytes,18,opt,name=order,proto3" json:"order,omitempty"`
Dir string `protobuf:"bytes,19,opt,name=dir,proto3" json:"dir,omitempty"`
Publishers []string `protobuf:"bytes,20,rep,name=publishers,proto3" json:"publishers,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -272,13 +271,6 @@ func (x *PageMetadata) GetDir() string {
return ""
}
func (x *PageMetadata) GetPublishers() []string {
if x != nil {
return x.Publishers
}
return nil
}
type ReadMessagesRes struct {
state protoimpl.MessageState `protogen:"open.v1"`
Total uint64 `protobuf:"varint,1,opt,name=total,proto3" json:"total,omitempty"`
@@ -730,7 +722,7 @@ var File_readers_v1_readers_proto protoreflect.FileDescriptor
const file_readers_v1_readers_proto_rawDesc = "" +
"\n" +
"\x18readers/v1/readers.proto\x12\n" +
"readers.v1\"\xac\x04\n" +
"readers.v1\"\x8c\x04\n" +
"\fPageMetadata\x12\x14\n" +
"\x05limit\x18\x01 \x01(\x04R\x05limit\x12\x16\n" +
"\x06offset\x18\x02 \x01(\x04R\x06offset\x12\x1a\n" +
@@ -755,10 +747,7 @@ const file_readers_v1_readers_proto_rawDesc = "" +
"comparator\x12\x16\n" +
"\x06format\x18\x11 \x01(\tR\x06format\x12\x14\n" +
"\x05order\x18\x12 \x01(\tR\x05order\x12\x10\n" +
"\x03dir\x18\x13 \x01(\tR\x03dir\x12\x1e\n" +
"\n" +
"publishers\x18\x14 \x03(\tR\n" +
"publishers\"\x97\x01\n" +
"\x03dir\x18\x13 \x01(\tR\x03dir\"\x97\x01\n" +
"\x0fReadMessagesRes\x12\x14\n" +
"\x05total\x18\x01 \x01(\x04R\x05total\x12=\n" +
"\rpage_metadata\x18\x02 \x01(\v2\x18.readers.v1.PageMetadataR\fpageMetadata\x12/\n" +
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: readers/v1/readers.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: token/v1/token.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: token/v1/token.proto
package v1
+1 -1
View File
@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.1
// protoc v6.33.0
// source: users/v1/users.proto
package v1
+2 -2
View File
@@ -3,8 +3,8 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.1
// - protoc-gen-go-grpc v1.6.0
// - protoc v6.33.0
// source: users/v1/users.proto
package v1
+1 -1
View File
@@ -13,9 +13,9 @@ import (
"github.com/absmach/magistrala"
api "github.com/absmach/magistrala/api/http"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/testsutil"
"github.com/stretchr/testify/assert"
)
+426
View File
@@ -0,0 +1,426 @@
# Copyright (c) Abstract Machines
# SPDX-License-Identifier: Apache-2.0
openapi: 3.0.3
info:
title: Magistrala Journal Log Service
description: |
This is the Journal Log Server based on the OpenAPI 3.0 specification. It is the HTTP API for viewing journal log history. You can now help us improve the API whether it's by making changes to the definition itself or to the code.
Some useful links:
- [The Magistrala repository](https://github.com/absmach/magistrala)
contact:
email: info@mainflux.com
license:
name: Apache 2.0
url: https://github.com/absmach/magistrala/blob/main/LICENSE
version: 0.18.0
servers:
- url: http://localhost:9021
- url: https://localhost:9021
tags:
- name: journal-log
description: Everything about your Journal Log
externalDocs:
description: Find out more about Journal Log
url: http://docs.mainflux.io/
paths:
/journal/user/{userID}:
get:
tags:
- journal-log
summary: List user journal log
description: |
Retrieves a list of journal. Due to performance concerns, data
is retrieved in subsets. The API must ensure that the entire
dataset is consumed either by making subsequent requests, or by
increasing the subset size of the initial request.
parameters:
- $ref: "#/components/parameters/user_id"
- $ref: "#/components/parameters/offset"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/operation"
- $ref: "#/components/parameters/with_attributes"
- $ref: "#/components/parameters/with_metadata"
- $ref: "#/components/parameters/from"
- $ref: "#/components/parameters/to"
- $ref: "#/components/parameters/dir"
security:
- bearerAuth: []
responses:
"200":
$ref: "#/components/responses/JournalsPageRes"
"400":
description: Failed due to malformed query parameters.
"401":
description: Missing or invalid access token provided.
"403":
description: Failed to perform authorization over the entity.
"404":
description: A non-existent entity request.
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/{domainID}/journal/client/{clientID}/telemetry:
get:
tags:
- journal-log
summary: View client telemetry
description: |
Retrieves telemetry data for a specific client within a domain.
This includes connection status, messages sent/received, and other metrics.
parameters:
- $ref: "#/components/parameters/domain_id"
- $ref: "#/components/parameters/client_id"
security:
- bearerAuth: []
responses:
"200":
description: Client telemetry data retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Telemetry"
"400":
description: Failed due to malformed request parameters.
"401":
description: Missing or invalid access token provided.
"403":
description: Failed to perform authorization over the entity.
"404":
description: Client not found.
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/{domainID}/journal/{entityType}/{id}:
get:
tags:
- journal-log
summary: List entity journal log
description: |
Retrieves a list of journal. Due to performance concerns, data
is retrieved in subsets. The API must ensure that the entire
dataset is consumed either by making subsequent requests, or by
increasing the subset size of the initial request.
parameters:
- $ref: "#/components/parameters/domain_id"
- $ref: "#/components/parameters/entity_type"
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/offset"
- $ref: "#/components/parameters/limit"
- $ref: "#/components/parameters/operation"
- $ref: "#/components/parameters/with_attributes"
- $ref: "#/components/parameters/with_metadata"
- $ref: "#/components/parameters/from"
- $ref: "#/components/parameters/to"
- $ref: "#/components/parameters/dir"
security:
- bearerAuth: []
responses:
"200":
$ref: "#/components/responses/JournalsPageRes"
"400":
description: Failed due to malformed query parameters.
"401":
description: Missing or invalid access token provided.
"403":
description: Failed to perform authorization over the entity.
"404":
description: A non-existent entity request.
"422":
description: Database can't process request.
"500":
$ref: "#/components/responses/ServiceError"
/health:
get:
summary: Retrieves service health check info.
tags:
- health
security: []
responses:
"200":
$ref: "#/components/responses/HealthRes"
"500":
$ref: "#/components/responses/ServiceError"
components:
schemas:
Telemetry:
type: object
properties:
client_id:
type: string
format: uuid
description: Unique identifier of the client
example: "bb7edb32-2eac-4aad-aebe-ed96fe073879"
domain_id:
type: string
format: uuid
description: Unique identifier of the domain
example: "29d425c8-542b-4614-8a4d-a5951945d720"
subscriptions:
type: integer
format: int64
description: Number of active subscriptions for the client
example: 5
inbound_messages:
type: integer
format: int64
description: Number of messages received by the client
example: 1234567
outbound_messages:
type: integer
format: int64
description: Number of messages sent by the client
example: 987654
first_seen:
type: string
format: date-time
description: Timestamp when the client was first seen
example: "2024-01-11T10:00:00.000Z"
last_seen:
type: string
format: date-time
description: Timestamp when the client was last seen
example: "2024-01-11T12:05:07.449053Z"
Journal:
type: object
properties:
operation:
type: string
example: user.create
description: Journal operation.
occurred_at:
type: string
format: date-time
example: "2024-01-11T12:05:07.449053Z"
description: Time when the journal occurred.
attributes:
type: object
description: Journal attributes.
example:
{
"created_at": "2024-06-12T11:34:32.991591Z",
"id": "29d425c8-542b-4614-8a4d-a5951945d720",
"identity": "Gawne-Havlicek@email.com",
"name": "Newgard-Frisina",
"status": "enabled",
"updated_at": "2024-06-12T11:34:33.116795Z",
"updated_by": "ad228f20-4741-47c5-bef7-d871b541c019",
}
metadata:
type: object
description: Journal payload.
example: { "Update": "Calvo-Felkins" }
xml:
name: journal
JournalPage:
type: object
properties:
journals:
type: array
minItems: 0
uniqueItems: true
items:
$ref: "#/components/schemas/Journal"
total:
type: integer
example: 1
description: Total number of items.
offset:
type: integer
description: Number of items to skip during retrieval.
limit:
type: integer
example: 10
description: Maximum number of items to return in one page.
required:
- journals
- total
- offset
Error:
type: object
properties:
error:
type: string
description: Error message
example: { "error": "malformed entity specification" }
parameters:
domain_id:
name: domainID
description: Unique identifier for a domain.
in: path
schema:
type: string
format: uuid
required: true
example: bb7edb32-2eac-4aad-aebe-ed96fe073879
client_id:
name: clientID
description: Unique identifier for a client
in: path
schema:
type: string
format: uuid
required: true
example: bb7edb32-2eac-4aad-aebe-ed96fe073879
entity_type:
name: entityType
description: Type of entity, e.g. group, client, channel.
in: path
schema:
type: string
enum:
- group
- client
- channel
required: true
example: group
user_id:
name: userID
description: Unique identifier for a user.
in: path
schema:
type: string
format: uuid
required: true
example: bb7edb32-2eac-4aad-aebe-ed96fe073879
id:
name: id
description: Unique identifier for an entity, e.g. group, channel or client. Used together with entity_type.
in: path
schema:
type: string
format: uuid
required: true
example: bb7edb32-2eac-4aad-aebe-ed96fe073879
offset:
name: offset
description: Number of items to skip during retrieval.
in: query
schema:
type: integer
default: 0
minimum: 0
required: false
example: 0
limit:
name: limit
description: Size of the subset to retrieve.
in: query
schema:
type: integer
default: 10
maximum: 100
minimum: 1
required: false
example: 10
operation:
name: operation
description: Journal operation.
in: query
schema:
type: string
required: false
example: user.create
with_attributes:
name: with_attributes
description: Include journal attributes.
in: query
schema:
type: boolean
required: false
example: true
with_metadata:
name: with_metadata
description: Include journal metadata.
in: query
schema:
type: boolean
required: false
example: true
from:
name: from
description: Start date in unix time.
in: query
schema:
type: integer
format: int64
required: false
example: 1966777289
to:
name: to
description: End date in unix time.
in: query
schema:
type: integer
format: int64
required: false
example: 1966777289
dir:
name: dir
description: Sort direction.
in: query
schema:
type: string
enum:
- asc
- desc
required: false
example: desc
responses:
JournalsPageRes:
description: Data retrieved.
content:
application/json:
schema:
$ref: "#/components/schemas/JournalPage"
HealthRes:
description: Service Health Check.
content:
application/health+json:
schema:
$ref: "./schemas/health_info.yaml"
ServiceError:
description: Unexpected server-side error occurred.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
* User access: "Authorization: Bearer <user_access_token>"
security:
- bearerAuth: []
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/auth/cache"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/testsutil"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
)
+1 -1
View File
@@ -13,12 +13,12 @@ import (
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/auth/mocks"
"github.com/absmach/magistrala/internal/testsutil"
"github.com/absmach/magistrala/pkg/errors"
repoerr "github.com/absmach/magistrala/pkg/errors/repository"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/policies"
policymocks "github.com/absmach/magistrala/pkg/policies/mocks"
"github.com/absmach/magistrala/pkg/testsutil"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
+7
View File
@@ -25,6 +25,7 @@ const (
defChannelsURL string = defURL + ":9005"
defGroupsURL string = defURL + ":9004"
defHTTPURL string = defURL + ":8008"
defJournalURL string = defURL + ":9021"
defTLSVerification bool = false
defOffset string = "0"
defLimit string = "10"
@@ -40,6 +41,7 @@ type remotes struct {
GroupsURL string `toml:"groups_url"`
HTTPAdapterURL string `toml:"http_adapter_url"`
CertsURL string `toml:"certs_url"`
JournalURL string `toml:"journal_url"`
HostURL string `toml:"host_url"`
TLSVerification bool `toml:"tls_verification"`
}
@@ -108,6 +110,7 @@ func ParseConfig(sdkConf smqsdk.Config) (smqsdk.Config, error) {
ChannelsURL: defChannelsURL,
GroupsURL: defGroupsURL,
HTTPAdapterURL: defHTTPURL,
JournalURL: defJournalURL,
HostURL: defURL,
TLSVerification: defTLSVerification,
},
@@ -191,6 +194,10 @@ func ParseConfig(sdkConf smqsdk.Config) (smqsdk.Config, error) {
sdkConf.CertsURL = config.Remotes.CertsURL
}
if sdkConf.JournalURL == "" && config.Remotes.JournalURL != "" {
sdkConf.JournalURL = config.Remotes.JournalURL
}
if sdkConf.HostURL == "" && config.Remotes.HostURL != "" {
sdkConf.HostURL = config.Remotes.HostURL
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package cli
import (
smqsdk "github.com/absmach/magistrala/pkg/sdk"
"github.com/spf13/cobra"
)
var cmdJournal = cobra.Command{
Use: "get <entity_type> <entity_id> <domain_id> <user_auth_token>",
Short: "Get journal",
Long: "Get journal\n" +
"Usage:\n" +
"\tmagistrala-cli journal get user <user_id> <user_auth_token> - lists user journal logs\n" +
"\tmagistrala-cli journal get <entity_type> <entity_id> <domain_id> <user_auth_token> - lists entity journal logs\n" +
"\tmagistrala-cli journal get <entity_type> <entity_id> <domain_id> <user_auth_token> --offset <offset> --limit <limit> - lists user journal logs with provided offset and limit\n",
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 3 || len(args) > 4 {
logUsageCmd(*cmd, cmd.Use)
return
}
pageMetadata := smqsdk.PageMetadata{
Offset: Offset,
Limit: Limit,
}
entityType, entityID, token := args[0], args[1], args[2]
domainID := ""
if len(args) == 4 {
entityType, entityID, domainID, token = args[0], args[1], args[2], args[3]
}
journal, err := sdk.Journal(cmd.Context(), entityType, entityID, domainID, pageMetadata, token)
if err != nil {
logErrorCmd(*cmd, err)
return
}
logJSONCmd(*cmd, journal)
},
}
// NewJournalCmd returns journal log command.
func NewJournalCmd() *cobra.Command {
cmd := cobra.Command{
Use: "journal get",
Short: "journal log",
Long: `journal to read journal log`,
}
cmd.AddCommand(&cmdJournal)
return &cmd
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"fmt"
"log"
"net/url"
"os"
"github.com/absmach/magistrala/alarms"
httpAPI "github.com/absmach/magistrala/alarms/api"
"github.com/absmach/magistrala/alarms/brokers"
"github.com/absmach/magistrala/alarms/consumer"
"github.com/absmach/magistrala/alarms/middleware"
"github.com/absmach/magistrala/alarms/operations"
alarmsRepo "github.com/absmach/magistrala/alarms/postgres"
"github.com/absmach/magistrala/internal/atom"
mglog "github.com/absmach/magistrala/logger"
smqauthn "github.com/absmach/magistrala/pkg/authn"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
"github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/messaging"
brokerstracing "github.com/absmach/magistrala/pkg/messaging/brokers/tracing"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
httpserver "github.com/absmach/magistrala/pkg/server/http"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/caarlos0/env/v11"
"golang.org/x/sync/errgroup"
)
const (
svcName = "alarms"
envPrefixDB = "MG_ALARMS_DB_"
envPrefixHTTP = "MG_ALARMS_HTTP_"
defDB = "alarms"
defSvcHTTPPort = "8050"
alarmEntity = "alarm"
)
type config struct {
LogLevel string `env:"MG_ALARMS_LOG_LEVEL" envDefault:"info"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
InstanceID string `env:"MG_ALARMS_INSTANCE_ID" envDefault:""`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
PermissionsFile string `env:"MG_PERMISSIONS_FILE" envDefault:"permission.yaml"`
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
cfg := config{}
if err := env.Parse(&cfg); err != nil {
log.Fatalf("failed to load %s configuration : %s", svcName, err.Error())
}
logger, err := mglog.New(os.Stdout, cfg.LogLevel)
if err != nil {
log.Fatalf("failed to init logger: %s", err.Error())
}
var exitCode int
defer mglog.ExitWithError(&exitCode)
tp, err := jaeger.NewProvider(ctx, svcName, cfg.JaegerURL, cfg.InstanceID, cfg.TraceRatio)
if err != nil {
logger.Error(fmt.Sprintf("failed to init Jaeger: %s", err))
exitCode = 1
return
}
defer func() {
if err := tp.Shutdown(ctx); err != nil {
logger.Error(fmt.Sprintf("error shutting down tracer provider: %v", err))
}
}()
tracer := tp.Tracer(svcName)
dbConfig := postgres.Config{Name: defDB}
if err := env.ParseWithOptions(&dbConfig, env.Options{Prefix: envPrefixDB}); err != nil {
logger.Error(err.Error())
}
migrations, err := alarmsRepo.Migration()
if err != nil {
logger.Error(fmt.Sprintf("failed to load migrations: %s", err))
exitCode = 1
return
}
db, err := postgres.Setup(dbConfig, *migrations)
if err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
defer db.Close()
repo := alarmsRepo.NewAlarmsRepo(db)
atomCfg := atom.LoadConfig()
if atomCfg.URL == "" {
logger.Error("ATOM_URL is required")
exitCode = 1
return
}
logger.Info("AuthN configured to use Atom bearer tokens")
logger.Info("AuthZ configured to use Atom PDP")
am := smqauthn.NewAuthNMiddleware(atomauthn.NewAuthentication())
idp := uuid.New()
svc := alarms.NewService(idp, repo)
svc = alarms.WithAtom(svc, atom.NewClient(atomCfg))
permConfig, err := permissions.ParsePermissionsFile(cfg.PermissionsFile)
if err != nil {
logger.Error(fmt.Sprintf("failed to parse permissions file: %s", err))
exitCode = 1
return
}
alarmOps, _, err := permConfig.GetEntityPermissions(alarmEntity)
if err != nil {
logger.Error(fmt.Sprintf("failed to get alarm permissions: %s", err))
exitCode = 1
return
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{
operations.EntityType: alarmOps,
},
permissions.EntitiesOperationDetails[permissions.Operation]{
operations.EntityType: operations.OperationDetails(),
},
)
if err != nil {
logger.Error(fmt.Sprintf("failed to create entity operations: %s", err))
exitCode = 1
return
}
svc, err = middleware.NewAtomAuthorizationMiddleware(svc, atom.NewClient(atomCfg), entitiesOps)
if err != nil {
logger.Error(fmt.Sprintf("failed to create authorization middleware: %s", err))
exitCode = 1
return
}
svc = middleware.NewLoggingMiddleware(logger, svc)
counter, latency := prometheus.MakeMetrics("alarms", "api")
svc = middleware.NewMetricsMiddleware(counter, latency, svc)
svc = middleware.NewTracingMiddleware(tracer, svc)
httpServerConfig := server.Config{Port: defSvcHTTPPort}
if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil {
logger.Error(fmt.Sprintf("failed to load %s HTTP server configuration : %s", svcName, err))
exitCode = 1
return
}
hs := httpserver.NewServer(ctx, cancel, svcName, httpServerConfig, httpAPI.MakeHandler(svc, logger, idp, cfg.InstanceID, am), logger)
pubSub, err := brokers.NewPubSub(ctx, cfg.BrokerURL, logger)
if err != nil {
logger.Error(fmt.Sprintf("failed to connect to message broker: %s", err))
exitCode = 1
return
}
defer pubSub.Close()
pubSub = brokerstracing.NewPubSub(httpServerConfig, tracer, pubSub)
consumer := consumer.NewHandler(svc, logger)
subCfg := messaging.SubscriberConfig{
ID: svcName,
Topic: brokers.AllTopic,
DeliveryPolicy: messaging.DeliverAllPolicy,
Handler: consumer,
}
if err := pubSub.Subscribe(ctx, subCfg); err != nil {
logger.Error(fmt.Sprintf("failed to subscribe to message broker: %s", err))
exitCode = 1
return
}
g.Go(func() error {
return hs.Start()
})
g.Go(func() error {
return server.StopSignalHandler(ctx, cancel, logger, svcName, hs)
})
if err := g.Wait(); err != nil {
logger.Error(fmt.Sprintf("billing service terminated: %s", err))
}
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
"strings"
"time"
"github.com/absmach/magistrala/pkg/atom"
"github.com/absmach/magistrala/internal/atom"
)
const (
@@ -75,7 +75,7 @@ func runProvisionTokens(client *atom.Client, args []string) {
fs := flag.NewFlagSet("provision-tokens", flag.ExitOnError)
output := fs.String("output", envString("MG_ATOM_TOKENS_OUTPUT", "docker/.env.tokens"), "path to write generated token env file")
rotate := fs.String("rotate", "", "rotate one token by name/env var, or all")
entityID := fs.String("entity-id", envString("ATOM_SERVICE_ENTITY_ID", atom.DefaultServiceEntityID), "Atom service entity ID to receive access tokens")
entityID := fs.String("entity-id", envString("ATOM_SERVICE_ENTITY_ID", atom.DefaultServiceEntityID), "Atom service entity ID to receive API keys")
if err := fs.Parse(args); err != nil {
log.Fatal(err)
}
+1 -1
View File
@@ -26,9 +26,9 @@ import (
apostgres "github.com/absmach/magistrala/auth/postgres"
"github.com/absmach/magistrala/auth/tokenizer/asymmetric"
"github.com/absmach/magistrala/auth/tokenizer/symmetric"
"github.com/absmach/magistrala/internal/atom"
redisclient "github.com/absmach/magistrala/internal/clients/redis"
mglog "github.com/absmach/magistrala/logger"
"github.com/absmach/magistrala/pkg/atom"
"github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/policies"
pgclient "github.com/absmach/magistrala/pkg/postgres"
+1 -1
View File
@@ -20,8 +20,8 @@ import (
"github.com/absmach/magistrala/certs/middleware"
"github.com/absmach/magistrala/certs/pki"
"github.com/absmach/magistrala/certs/postgres"
"github.com/absmach/magistrala/internal/atom"
mglog "github.com/absmach/magistrala/logger"
"github.com/absmach/magistrala/pkg/atom"
smqauthn "github.com/absmach/magistrala/pkg/authn"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
smqauthz "github.com/absmach/magistrala/pkg/authz"
+76 -3
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
@@ -22,8 +23,8 @@ import (
"github.com/absmach/fluxmq/pkg/proto/auth/v1/authv1connect"
fluxmqgrpc "github.com/absmach/magistrala/fluxmq/api/grpc"
fluxmqhttp "github.com/absmach/magistrala/fluxmq/api/http"
"github.com/absmach/magistrala/internal/atom"
mglog "github.com/absmach/magistrala/logger"
"github.com/absmach/magistrala/pkg/atom"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/messaging"
@@ -53,6 +54,40 @@ type config struct {
InstanceID string `env:"MG_FLUXMQ_INSTANCE_ID" envDefault:""`
}
type fanoutPublisher struct {
publishers []messaging.Publisher
}
func (fp fanoutPublisher) Publish(ctx context.Context, topic string, msg *messaging.Message) error {
for _, publisher := range fp.publishers {
if err := publisher.Publish(ctx, topic, msg); err != nil {
return err
}
}
return nil
}
func (fp fanoutPublisher) Close() error {
errs := make([]error, 0, len(fp.publishers))
for _, publisher := range fp.publishers {
errs = append(errs, publisher.Close())
}
return errors.Join(errs...)
}
type writerBridgeHandler struct {
ctx context.Context
publisher messaging.Publisher
}
func (h writerBridgeHandler) Handle(msg *messaging.Message) error {
return h.publisher.Publish(h.ctx, messaging.EncodeMessageTopic(msg), msg)
}
func (h writerBridgeHandler) Cancel() error {
return nil
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
@@ -137,7 +172,6 @@ func main() {
connect.WithInterceptors(otelInterceptor),
)
mux.Handle(path, handler)
mux.Handle("/hooks", fluxmqhttp.MakeHooksHandler(parser))
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`)) //nolint:errcheck // HTTP response write; client disconnect is non-fatal.
@@ -166,6 +200,45 @@ func main() {
}
defer messagePublisher.Close()
writerPublisher, err := fluxmqbroker.NewUndeclaredPublisher(
ctx,
cfg.BrokerURL,
fluxmqbroker.Prefix("writers"),
fluxmqbroker.ConnectionName("fluxmq-ui-publish-proxy"),
)
if err != nil {
logger.Error(fmt.Sprintf("failed to create publish proxy writer publisher: %s", err))
exitCode = 1
return
}
defer writerPublisher.Close()
publisher := fanoutPublisher{publishers: []messaging.Publisher{messagePublisher, writerPublisher}}
writerBridge, err := fluxmqbroker.NewPubSub(
ctx,
cfg.BrokerURL,
logger,
fluxmqbroker.DirectTopicOnly(),
fluxmqbroker.ConnectionName("fluxmq-mqtt-writer-bridge"),
)
if err != nil {
logger.Error(fmt.Sprintf("failed to create MQTT writer bridge subscriber: %s", err))
exitCode = 1
return
}
defer writerBridge.Close()
if err := writerBridge.Subscribe(ctx, messaging.SubscriberConfig{
ID: cfg.InstanceID + "-mqtt-writer-bridge",
Topic: "m/#",
Handler: writerBridgeHandler{ctx: ctx, publisher: writerPublisher},
DeliveryPolicy: messaging.DeliverNewPolicy,
}); err != nil {
logger.Error(fmt.Sprintf("failed to subscribe MQTT writer bridge: %s", err))
exitCode = 1
return
}
logger.Info("FluxMQ MQTT writer bridge subscribed", "topic", "m/#")
httpServerConfig := server.Config{Port: "9026"}
if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil {
logger.Error(fmt.Sprintf("failed to load publish proxy HTTP server configuration: %s", err))
@@ -177,7 +250,7 @@ func main() {
cancel,
"fluxmq-publish",
httpServerConfig,
fluxmqhttp.MakePublishHandler(authn, atomAuthz, messagePublisher),
fluxmqhttp.MakePublishHandler(authn, atomAuthz, publisher),
logger,
)
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package main contains journal main function to start the journal service.
package main
import (
"context"
"fmt"
"log"
"log/slog"
"net/url"
"os"
chclient "github.com/absmach/callhome/pkg/client"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/journal"
httpapi "github.com/absmach/magistrala/journal/api"
"github.com/absmach/magistrala/journal/events"
"github.com/absmach/magistrala/journal/middleware"
journalpg "github.com/absmach/magistrala/journal/postgres"
mglog "github.com/absmach/magistrala/logger"
smqauthn "github.com/absmach/magistrala/pkg/authn"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
smqauthz "github.com/absmach/magistrala/pkg/authz"
"github.com/absmach/magistrala/pkg/events/store"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/postgres"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
"github.com/absmach/magistrala/pkg/server/http"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/caarlos0/env/v11"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
)
const (
svcName = "journal"
envPrefixDB = "MG_JOURNAL_DB_"
envPrefixHTTP = "MG_JOURNAL_HTTP_"
defDB = "journal"
defSvcHTTPPort = "9021"
)
type config struct {
LogLevel string `env:"MG_JOURNAL_LOG_LEVEL" envDefault:"info"`
ESURL string `env:"MG_ES_URL" envDefault:"amqp://guest:guest@localhost:5682/"`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"MG_JOURNAL_INSTANCE_ID" envDefault:""`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
cfg := config{}
if err := env.Parse(&cfg); err != nil {
log.Fatalf("failed to load %s configuration : %s", svcName, err)
}
logger, err := mglog.New(os.Stdout, cfg.LogLevel)
if err != nil {
log.Fatalf("failed to init logger: %s", err)
}
var exitCode int
defer mglog.ExitWithError(&exitCode)
if cfg.InstanceID == "" {
if cfg.InstanceID, err = uuid.New().ID(); err != nil {
logger.Error(fmt.Sprintf("failed to generate instanceID: %s", err))
exitCode = 1
return
}
}
dbConfig := pgclient.Config{Name: defDB}
if err := env.ParseWithOptions(&dbConfig, env.Options{Prefix: envPrefixDB}); err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
db, err := pgclient.Setup(dbConfig, *journalpg.Migration())
if err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
defer db.Close()
atomCfg := atom.LoadConfig()
if atomCfg.URL == "" {
logger.Error("ATOM_URL is required")
exitCode = 1
return
}
atomClient := atom.NewClient(atomCfg)
authn := atomauthn.NewAuthentication()
authnMiddleware := smqauthn.NewAuthNMiddleware(authn)
authz := atom.NewAuthorizationCompat(atomClient)
logger.Info("AuthN/AuthZ configured to use Atom")
tp, err := jaegerclient.NewProvider(ctx, svcName, cfg.JaegerURL, cfg.InstanceID, cfg.TraceRatio)
if err != nil {
logger.Error(fmt.Sprintf("failed to init Jaeger: %s", err))
exitCode = 1
return
}
defer func() {
if err := tp.Shutdown(ctx); err != nil {
logger.Error(fmt.Sprintf("error shutting down tracer provider: %s", err))
}
}()
tracer := tp.Tracer(svcName)
svc := newService(db, dbConfig, authz, logger, tracer)
subscriber, err := store.NewSubscriber(ctx, cfg.ESURL, "journal-es-sub", logger)
if err != nil {
logger.Error(fmt.Sprintf("failed to create subscriber: %s", err))
exitCode = 1
return
}
logger.Info("Subscribed to Event Store")
if err := events.Start(ctx, svcName, subscriber, svc); err != nil {
logger.Error("failed to start %s service: %s", svcName, err)
exitCode = 1
return
}
httpServerConfig := server.Config{Port: defSvcHTTPPort}
if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil {
logger.Error(fmt.Sprintf("failed to load %s HTTP server configuration : %s", svcName, err.Error()))
exitCode = 1
return
}
hs := http.NewServer(ctx, cancel, svcName, httpServerConfig, httpapi.MakeHandler(svc, authnMiddleware, logger, svcName, cfg.InstanceID), logger)
if cfg.SendTelemetry {
chc := chclient.New(svcName, magistrala.Version, logger, cancel)
go chc.CallHome(ctx)
}
g.Go(func() error {
return hs.Start()
})
g.Go(func() error {
return server.StopSignalHandler(ctx, cancel, logger, svcName, hs)
})
if err := g.Wait(); err != nil {
logger.Error(fmt.Sprintf("%s service terminated: %s", svcName, err))
}
}
func newService(db *sqlx.DB, dbConfig pgclient.Config, authz smqauthz.Authorization, logger *slog.Logger, tracer trace.Tracer) journal.Service {
database := postgres.NewDatabase(db, dbConfig, tracer)
repo := journalpg.NewRepository(database)
idp := uuid.New()
svc := journal.NewService(idp, repo)
svc = middleware.NewAuthorization(svc, authz)
svc = middleware.NewLogging(svc, logger)
counter, latency := prometheus.MakeMetrics("journal", "journal_writer")
svc = middleware.NewMetrics(svc, counter, latency)
svc = middleware.NewTracing(svc, tracer)
return svc
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package main contains notifications main function to start the notifications service.
package main
import (
"context"
"fmt"
"log"
"net/url"
"os"
chclient "github.com/absmach/callhome/pkg/client"
"github.com/absmach/magistrala"
"github.com/absmach/magistrala/internal/atom"
mglog "github.com/absmach/magistrala/logger"
"github.com/absmach/magistrala/notifications/emailer"
"github.com/absmach/magistrala/notifications/events"
"github.com/absmach/magistrala/notifications/middleware"
"github.com/absmach/magistrala/pkg/events/store"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/caarlos0/env/v11"
"golang.org/x/sync/errgroup"
)
const (
svcName = "notifications"
defEmailPort = "25"
)
type config struct {
LogLevel string `env:"MG_NOTIFICATIONS_LOG_LEVEL" envDefault:"info"`
ESURL string `env:"MG_ES_URL" envDefault:"amqp://guest:guest@localhost:5682/"`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
InstanceID string `env:"MG_NOTIFICATIONS_INSTANCE_ID" envDefault:""`
DomainAltName string `env:"MG_NOTIFICATIONS_DOMAIN_ALT_NAME" envDefault:"domain"`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
EmailHost string `env:"MG_EMAIL_HOST" envDefault:"localhost"`
EmailPort string `env:"MG_EMAIL_PORT" envDefault:"25"`
EmailUsername string `env:"MG_EMAIL_USERNAME" envDefault:""`
EmailPassword string `env:"MG_EMAIL_PASSWORD" envDefault:""`
EmailFromAddress string `env:"MG_EMAIL_FROM_ADDRESS" envDefault:"noreply@magistrala.com"`
EmailFromName string `env:"MG_EMAIL_FROM_NAME" envDefault:"Magistrala Notifications"`
InvitationTemplate string `env:"MG_EMAIL_INVITATION_TEMPLATE" envDefault:"docker/templates/invitation-sent-email.tmpl"`
AcceptanceTemplate string `env:"MG_EMAIL_ACCEPTANCE_TEMPLATE" envDefault:"docker/templates/invitation-accepted-email.tmpl"`
RejectionTemplate string `env:"MG_EMAIL_REJECTION_TEMPLATE" envDefault:"docker/templates/invitation-rejected-email.tmpl"`
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
cfg := config{}
if err := env.Parse(&cfg); err != nil {
log.Fatalf("failed to load %s configuration : %s", svcName, err)
}
logger, err := mglog.New(os.Stdout, cfg.LogLevel)
if err != nil {
log.Fatalf("failed to init logger: %s", err)
}
var exitCode int
defer mglog.ExitWithError(&exitCode)
if cfg.InstanceID == "" {
if cfg.InstanceID, err = uuid.New().ID(); err != nil {
logger.Error(fmt.Sprintf("failed to generate instanceID: %s", err))
exitCode = 1
return
}
}
tp, err := jaegerclient.NewProvider(ctx, svcName, cfg.JaegerURL, cfg.InstanceID, cfg.TraceRatio)
if err != nil {
logger.Error(fmt.Sprintf("failed to init Jaeger: %s", err))
exitCode = 1
return
}
defer func() {
if err := tp.Shutdown(ctx); err != nil {
logger.Error(fmt.Sprintf("error shutting down tracer provider: %s", err))
}
}()
atomCfg := atom.LoadConfig()
if atomCfg.URL == "" {
logger.Error("ATOM_URL is required")
exitCode = 1
return
}
usersResolver := emailer.NewAtomUserResolver(atom.NewClient(atomCfg))
logger.Info("Notifications user lookup configured to use Atom")
emailerCfg := emailer.Config{
FromAddress: cfg.EmailFromAddress,
FromName: cfg.EmailFromName,
DomainAltName: cfg.DomainAltName,
InvitationTemplate: cfg.InvitationTemplate,
AcceptanceTemplate: cfg.AcceptanceTemplate,
RejectionTemplate: cfg.RejectionTemplate,
EmailHost: cfg.EmailHost,
EmailPort: cfg.EmailPort,
EmailUsername: cfg.EmailUsername,
EmailPassword: cfg.EmailPassword,
}
notifier, err := emailer.New(usersResolver, emailerCfg)
if err != nil {
logger.Error(fmt.Sprintf("failed to create emailer: %s", err))
exitCode = 1
return
}
// Wrap notifier with middleware
notifier = middleware.NewLogging(notifier, logger)
counter, latency := prometheus.MakeMetrics(svcName, "notifier")
notifier = middleware.NewMetrics(notifier, counter, latency)
notifier = middleware.NewTracing(notifier, tp.Tracer(svcName))
subscriber, err := store.NewSubscriber(ctx, cfg.ESURL, "notifications-es-sub", logger)
if err != nil {
logger.Error(fmt.Sprintf("failed to create subscriber: %s", err))
exitCode = 1
return
}
logger.Info("Subscribed to Event Store")
if err := events.Start(ctx, svcName, subscriber, notifier); err != nil {
logger.Error(fmt.Sprintf("failed to start %s service: %s", svcName, err))
exitCode = 1
return
}
if cfg.SendTelemetry {
chc := chclient.New(svcName, magistrala.Version, logger, cancel)
go chc.CallHome(ctx)
}
g.Go(func() error {
return server.StopSignalHandler(ctx, cancel, logger, svcName)
})
if err := g.Wait(); err != nil {
logger.Error(fmt.Sprintf("%s service terminated: %s", svcName, err))
}
}
+1 -1
View File
@@ -14,8 +14,8 @@ import (
chclient "github.com/absmach/callhome/pkg/client"
"github.com/absmach/magistrala"
grpcReadersV1 "github.com/absmach/magistrala/api/grpc/readers/v1"
"github.com/absmach/magistrala/internal/atom"
mglog "github.com/absmach/magistrala/logger"
"github.com/absmach/magistrala/pkg/atom"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
+1 -1
View File
@@ -17,11 +17,11 @@ import (
"github.com/absmach/magistrala/consumers"
consumertracing "github.com/absmach/magistrala/consumers/tracing"
httpapi "github.com/absmach/magistrala/consumers/writers/api"
"github.com/absmach/magistrala/consumers/writers/brokers"
writerpg "github.com/absmach/magistrala/consumers/writers/postgres"
mglog "github.com/absmach/magistrala/logger"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
brokerstracing "github.com/absmach/magistrala/pkg/messaging/brokers/tracing"
brokers "github.com/absmach/magistrala/pkg/messaging/writers"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
+348
View File
@@ -0,0 +1,348 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package main contains rule engine main function to start the service.
package main
import (
"context"
"fmt"
"log"
"log/slog"
"net/url"
"os"
"time"
chclient "github.com/absmach/callhome/pkg/client"
"github.com/absmach/magistrala"
abrokers "github.com/absmach/magistrala/alarms/brokers"
grpcReadersV1 "github.com/absmach/magistrala/api/grpc/readers/v1"
"github.com/absmach/magistrala/consumers/writers/brokers"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/internal/email"
mglog "github.com/absmach/magistrala/logger"
smqauthn "github.com/absmach/magistrala/pkg/authn"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
"github.com/absmach/magistrala/pkg/callout"
"github.com/absmach/magistrala/pkg/emailer"
"github.com/absmach/magistrala/pkg/grpcclient"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
pkglog "github.com/absmach/magistrala/pkg/logger"
"github.com/absmach/magistrala/pkg/messaging"
smqbrokers "github.com/absmach/magistrala/pkg/messaging/brokers"
brokerstracing "github.com/absmach/magistrala/pkg/messaging/brokers/tracing"
"github.com/absmach/magistrala/pkg/permissions"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
httpserver "github.com/absmach/magistrala/pkg/server/http"
"github.com/absmach/magistrala/pkg/ticker"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/absmach/magistrala/re"
httpapi "github.com/absmach/magistrala/re/api"
"github.com/absmach/magistrala/re/events"
"github.com/absmach/magistrala/re/middleware"
"github.com/absmach/magistrala/re/operations"
repg "github.com/absmach/magistrala/re/postgres"
grpcClient "github.com/absmach/magistrala/readers/api/grpc"
"github.com/caarlos0/env/v11"
"github.com/go-chi/chi/v5"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
)
const (
svcName = "rules_engine"
envPrefixDB = "MG_RE_DB_"
envPrefixHTTP = "MG_RE_HTTP_"
envPrefixCallout = "MG_RE_CALLOUT_"
defDB = "r"
defSvcHTTPPort = "9008"
envPrefixGrpc = "MG_TIMESCALE_READER_GRPC_"
)
// We use a buffered channel to prevent blocking, as logging is an expensive operation.
// A larger buffer size would also work, but wed likely need another instance of RE in that case.
// A smaller size would probably work too, but there's no need to be that frugal with resources.
const channBuffer = 256
type config struct {
LogLevel string `env:"MG_RE_LOG_LEVEL" envDefault:"info"`
InstanceID string `env:"MG_RE_INSTANCE_ID" envDefault:""`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
ESURL string `env:"MG_ES_URL" envDefault:"nats://localhost:4222"`
ESConsumerName string `env:"MG_RE_EVENT_CONSUMER" envDefault:"rules_engine"`
CacheURL string `env:"MG_RE_CACHE_URL" envDefault:"redis://localhost:6379/0"`
CacheKeyDuration time.Duration `env:"MG_RE_CACHE_KEY_DURATION" envDefault:"10m"`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
PermissionsFile string `env:"MG_PERMISSIONS_FILE" envDefault:"permission.yaml"`
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
cfg := config{}
if err := env.Parse(&cfg); err != nil {
log.Fatalf("failed to load %s configuration : %s", svcName, err)
}
var logger *slog.Logger
logger, err := mglog.New(os.Stdout, cfg.LogLevel)
if err != nil {
log.Fatalf("failed to init logger: %s", err.Error())
}
var exitCode int
defer mglog.ExitWithError(&exitCode)
if cfg.InstanceID == "" {
if cfg.InstanceID, err = uuid.New().ID(); err != nil {
logger.Error(fmt.Sprintf("failed to generate instanceID: %s", err))
exitCode = 1
return
}
}
ec := email.Config{}
if err := env.Parse(&ec); err != nil {
logger.Error(fmt.Sprintf("failed to load email configuration : %s", err))
exitCode = 1
return
}
callCfg := callout.Config{}
if err := env.ParseWithOptions(&callCfg, env.Options{Prefix: envPrefixCallout}); err != nil {
logger.Error(fmt.Sprintf("failed to parse callout config : %s", err))
exitCode = 1
return
}
dbConfig := pgclient.Config{Name: defDB}
if err := env.ParseWithOptions(&dbConfig, env.Options{Prefix: envPrefixDB}); err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
migration, err := repg.Migration()
if err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
db, err := pgclient.Setup(dbConfig, *migration)
if err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
defer db.Close()
tp, err := jaegerclient.NewProvider(ctx, svcName, cfg.JaegerURL, cfg.InstanceID, cfg.TraceRatio)
if err != nil {
logger.Error(fmt.Sprintf("Failed to init Jaeger: %s", err))
exitCode = 1
return
}
defer func() {
if err := tp.Shutdown(ctx); err != nil {
logger.Error(fmt.Sprintf("Error shutting down tracer provider: %v", err))
}
}()
tracer := tp.Tracer(svcName)
httpServerConfig := server.Config{Port: defSvcHTTPPort}
if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil {
logger.Error(fmt.Sprintf("failed to load %s HTTP server configuration : %s", svcName, err))
exitCode = 1
return
}
callout, err := callout.New(callCfg)
if err != nil {
logger.Error(fmt.Sprintf("failed to create new callout: %s", err))
exitCode = 1
return
}
msgSub, err := smqbrokers.NewPubSub(ctx, cfg.BrokerURL, logger, smqbrokers.ConnectionName("re-msg-pubsub"))
if err != nil {
logger.Error(fmt.Sprintf("failed to connect to message broker for mg pubSub: %s", err))
exitCode = 1
return
}
defer msgSub.Close()
msgSub = brokerstracing.NewPubSub(httpServerConfig, tracer, msgSub)
writersPub, err := brokers.NewPublisher(ctx, cfg.BrokerURL)
if err != nil {
logger.Error(fmt.Sprintf("failed to connect to message broker for writers publisher: %s", err))
exitCode = 1
return
}
defer writersPub.Close()
writersPub = brokerstracing.NewPublisher(httpServerConfig, tracer, writersPub)
alarmsPub, err := abrokers.NewPublisher(ctx, cfg.BrokerURL)
if err != nil {
logger.Error(fmt.Sprintf("failed to connect to message broker for alarms publisher: %s", err))
exitCode = 1
return
}
defer alarmsPub.Close()
alarmsPub = brokerstracing.NewPublisher(httpServerConfig, tracer, alarmsPub)
atomCfg := atom.LoadConfig()
if atomCfg.URL == "" {
logger.Error("ATOM_URL is required")
exitCode = 1
return
}
authnSvc := atomauthn.NewAuthentication()
logger.Info("AuthN configured to use Atom bearer tokens")
am := smqauthn.NewAuthNMiddleware(authnSvc)
runInfo := make(chan pkglog.RunInfo, channBuffer)
logger.Info("AuthZ configured to use Atom PDP")
database := pgclient.NewDatabase(db, dbConfig, tracer)
regrpcCfg := grpcclient.Config{}
if err := env.ParseWithOptions(&regrpcCfg, env.Options{Prefix: envPrefixGrpc}); err != nil {
logger.Error(fmt.Sprintf("failed to load clients gRPC client configuration : %s", err))
exitCode = 1
return
}
client, err := grpcclient.NewHandler(regrpcCfg)
if err != nil {
exitCode = 1
return
}
defer client.Close()
readersClient := grpcClient.NewReadersClient(client.Connection(), regrpcCfg.Timeout)
logger.Info("Readers gRPC client successfully connected to readers gRPC server " + client.Secure())
svc, err := newService(ctx, cfg, database, runInfo, msgSub, writersPub, alarmsPub, ec, logger, readersClient, callout, tracer)
if err != nil {
logger.Error(fmt.Sprintf("failed to create services: %s", err))
exitCode = 1
return
}
subCfg := messaging.SubscriberConfig{
ID: svcName,
Topic: smqbrokers.SubjectAllMessages,
DeliveryPolicy: messaging.DeliverAllPolicy,
Handler: svc,
}
if err := msgSub.Subscribe(ctx, subCfg); err != nil {
logger.Error(fmt.Sprintf("failed to subscribe to internal message broker: %s", err))
exitCode = 1
return
}
go func() {
for info := range runInfo {
logger.LogAttrs(context.Background(), info.Level, info.Message, info.Details...)
}
}()
mux := chi.NewRouter()
httpSvc := httpserver.NewServer(ctx, cancel, svcName, httpServerConfig, httpapi.MakeHandler(svc, am, mux, logger, cfg.InstanceID), logger)
if cfg.SendTelemetry {
chc := chclient.New(svcName, magistrala.Version, logger, cancel)
go chc.CallHome(ctx)
}
g.Go(func() error {
return svc.StartScheduler(ctx)
})
g.Go(func() error {
return httpSvc.Start()
})
g.Go(func() error {
return server.StopSignalHandler(ctx, cancel, logger, svcName, httpSvc)
})
if err := g.Wait(); err != nil {
logger.Error(fmt.Sprintf("%s service terminated: %s", svcName, err))
}
}
func newService(ctx context.Context, cfg config, db pgclient.Database, runInfo chan pkglog.RunInfo, rePubSub messaging.PubSub, writersPub, alarmsPub messaging.Publisher, ec email.Config, logger *slog.Logger, readersClient grpcReadersV1.ReadersServiceClient, callout callout.Callout, tracer trace.Tracer) (re.Service, error) {
repo := repg.NewRepository(db)
idp := uuid.New()
emailerClient, err := emailer.New(&ec)
if err != nil {
logger.Error(fmt.Sprintf("failed to configure e-mailing util: %s", err.Error()))
}
atomCfg := atom.LoadConfig()
var csvc re.Service
csvc, err = re.NewService(repo, runInfo, idp, rePubSub, writersPub, alarmsPub, ticker.NewTicker(time.Second*30), emailerClient, readersClient)
if err != nil {
return nil, fmt.Errorf("failed to create RE service: %w", err)
}
csvc = re.WithAtom(csvc, atom.NewClient(atomCfg))
csvc, err = events.NewEventStoreMiddleware(ctx, csvc, cfg.ESURL)
if err != nil {
return nil, fmt.Errorf("failed to init re event store middleware: %w", err)
}
permConfig, err := permissions.ParsePermissionsFile(cfg.PermissionsFile)
if err != nil {
return nil, fmt.Errorf("failed to parse permissions file: %w", err)
}
ruleOps, _, err := permConfig.GetEntityPermissions(operations.EntityType)
if err != nil {
return nil, fmt.Errorf("failed to get rule permissions: %w", err)
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{
operations.EntityType: ruleOps,
},
permissions.EntitiesOperationDetails[permissions.Operation]{
operations.EntityType: operations.OperationDetails(),
},
)
if err != nil {
return nil, fmt.Errorf("failed to create entities operations: %w", err)
}
csvc, err = middleware.AtomAuthorizationMiddleware(csvc, atom.NewClient(atomCfg), entitiesOps)
if err != nil {
return nil, err
}
csvc, err = middleware.NewCallout(csvc, callout, entitiesOps)
if err != nil {
return nil, err
}
csvc = middleware.LoggingMiddleware(csvc, logger)
counter, latency := prometheus.MakeMetrics("re", "api")
csvc = middleware.NewMetricsMiddleware(counter, latency, csvc)
csvc = middleware.NewTracingMiddleware(tracer, csvc)
return csvc, nil
}
+331
View File
@@ -0,0 +1,331 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
// Package main contains reports main function to start the service.
package main
import (
"context"
"embed"
"fmt"
"log"
"log/slog"
"net/url"
"os"
"time"
chclient "github.com/absmach/callhome/pkg/client"
"github.com/absmach/magistrala"
grpcReadersV1 "github.com/absmach/magistrala/api/grpc/readers/v1"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/internal/email"
mglog "github.com/absmach/magistrala/logger"
smqauthn "github.com/absmach/magistrala/pkg/authn"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
"github.com/absmach/magistrala/pkg/callout"
"github.com/absmach/magistrala/pkg/emailer"
"github.com/absmach/magistrala/pkg/grpcclient"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
pkglog "github.com/absmach/magistrala/pkg/logger"
"github.com/absmach/magistrala/pkg/permissions"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
httpserver "github.com/absmach/magistrala/pkg/server/http"
"github.com/absmach/magistrala/pkg/ticker"
"github.com/absmach/magistrala/pkg/uuid"
grpcClient "github.com/absmach/magistrala/readers/api/grpc"
"github.com/absmach/magistrala/reports"
httpapi "github.com/absmach/magistrala/reports/api"
reportsevents "github.com/absmach/magistrala/reports/events"
"github.com/absmach/magistrala/reports/middleware"
"github.com/absmach/magistrala/reports/operations"
repg "github.com/absmach/magistrala/reports/postgres"
"github.com/caarlos0/env/v11"
"github.com/go-chi/chi/v5"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
)
const (
svcName = "reports"
envPrefixDB = "MG_REPORTS_DB_"
envPrefixHTTP = "MG_REPORTS_HTTP_"
envPrefixCallout = "MG_REPORTS_CALLOUT_"
defDB = "repo"
defSvcHTTPPort = "9017"
envPrefixGrpc = "MG_TIMESCALE_READER_GRPC_"
templatePath = "template/reports_default_template.html"
reportEntity = "report"
)
// We use a buffered channel to prevent blocking, as logging is an expensive operation.
const channBuffer = 256
//go:embed template/reports_default_template.html
var templateFS embed.FS
type config struct {
LogLevel string `env:"MG_REPORTS_LOG_LEVEL" envDefault:"info"`
InstanceID string `env:"MG_REPORTS_INSTANCE_ID" envDefault:""`
JaegerURL url.URL `env:"MG_JAEGER_URL" envDefault:"http://localhost:4318/v1/traces"`
SendTelemetry bool `env:"MG_SEND_TELEMETRY" envDefault:"true"`
ESURL string `env:"MG_ES_URL" envDefault:"nats://localhost:4222"`
ESConsumerName string `env:"MG_REPORTS_EVENT_CONSUMER" envDefault:"reports"`
TraceRatio float64 `env:"MG_JAEGER_TRACE_RATIO" envDefault:"1.0"`
BrokerURL string `env:"MG_MESSAGE_BROKER_URL" envDefault:"nats://localhost:4222"`
DefaultTemplatePath string `env:"MG_REPORTS_DEFAULT_TEMPLATE" envDefault:""`
ConverterURL string `env:"MG_PDF_CONVERTER_URL" envDefault:"http://localhost:4000/pdf"`
PermissionsFile string `env:"MG_PERMISSIONS_FILE" envDefault:"permission.yaml"`
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
g, ctx := errgroup.WithContext(ctx)
cfg := config{}
if err := env.Parse(&cfg); err != nil {
log.Fatalf("failed to load %s configuration : %s", svcName, err)
}
var logger *slog.Logger
logger, err := mglog.New(os.Stdout, cfg.LogLevel)
if err != nil {
log.Fatalf("failed to init logger: %s", err.Error())
}
var exitCode int
defer mglog.ExitWithError(&exitCode)
if cfg.InstanceID == "" {
if cfg.InstanceID, err = uuid.New().ID(); err != nil {
logger.Error(fmt.Sprintf("failed to generate instanceID: %s", err))
exitCode = 1
return
}
}
var templateData []byte
switch cfg.DefaultTemplatePath {
case "":
templateData, err = templateFS.ReadFile(templatePath)
default:
templateData, err = os.ReadFile(templatePath)
}
if err != nil {
logger.Error(fmt.Sprintf("failed to read report template: %s", err))
exitCode = 1
return
}
template := reports.ReportTemplate(string(templateData))
if err := template.Validate(); err != nil {
logger.Error(fmt.Sprintf("failed to validate report template: %s", err))
exitCode = 1
return
}
logger.Info("Report template validated successfully")
ec := email.Config{}
if err := env.Parse(&ec); err != nil {
logger.Error(fmt.Sprintf("failed to load email configuration : %s", err))
exitCode = 1
return
}
callCfg := callout.Config{}
if err := env.ParseWithOptions(&callCfg, env.Options{Prefix: envPrefixCallout}); err != nil {
logger.Error(fmt.Sprintf("failed to parse callout config : %s", err))
exitCode = 1
return
}
dbConfig := pgclient.Config{Name: defDB}
if err := env.ParseWithOptions(&dbConfig, env.Options{Prefix: envPrefixDB}); err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
migration, err := repg.Migration()
if err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
db, err := pgclient.Setup(dbConfig, *migration)
if err != nil {
logger.Error(err.Error())
exitCode = 1
return
}
defer db.Close()
tp, err := jaegerclient.NewProvider(ctx, svcName, cfg.JaegerURL, cfg.InstanceID, cfg.TraceRatio)
if err != nil {
logger.Error(fmt.Sprintf("Failed to init Jaeger: %s", err))
exitCode = 1
return
}
defer func() {
if err := tp.Shutdown(ctx); err != nil {
logger.Error(fmt.Sprintf("Error shutting down tracer provider: %v", err))
}
}()
tracer := tp.Tracer(svcName)
httpServerConfig := server.Config{Port: defSvcHTTPPort}
if err := env.ParseWithOptions(&httpServerConfig, env.Options{Prefix: envPrefixHTTP}); err != nil {
logger.Error(fmt.Sprintf("failed to load %s HTTP server configuration : %s", svcName, err))
exitCode = 1
return
}
callout, err := callout.New(callCfg)
if err != nil {
logger.Error(fmt.Sprintf("failed to create new callout: %s", err))
exitCode = 1
return
}
atomCfg := atom.LoadConfig()
if atomCfg.URL == "" {
logger.Error("ATOM_URL is required")
exitCode = 1
return
}
authnSvc := atomauthn.NewAuthentication()
logger.Info("AuthN configured to use Atom bearer tokens")
am := smqauthn.NewAuthNMiddleware(authnSvc)
logger.Info("AuthZ configured to use Atom PDP")
database := pgclient.NewDatabase(db, dbConfig, tracer)
regrpcCfg := grpcclient.Config{}
if err := env.ParseWithOptions(&regrpcCfg, env.Options{Prefix: envPrefixGrpc}); err != nil {
logger.Error(fmt.Sprintf("failed to load clients gRPC client configuration : %s", err))
exitCode = 1
return
}
client, err := grpcclient.NewHandler(regrpcCfg)
if err != nil {
exitCode = 1
return
}
defer client.Close()
readersClient := grpcClient.NewReadersClient(client.Connection(), regrpcCfg.Timeout)
logger.Info("Readers gRPC client successfully connected to readers gRPC server " + client.Secure())
runInfo := make(chan pkglog.RunInfo, channBuffer)
svc, err := newService(ctx, cfg, database, runInfo, ec, logger, readersClient, template, callout, tracer)
if err != nil {
logger.Error(fmt.Sprintf("failed to create services: %s", err))
exitCode = 1
return
}
go func() {
for info := range runInfo {
logger.LogAttrs(context.Background(), info.Level, info.Message, info.Details...)
}
}()
mux := chi.NewRouter()
httpSvc := httpserver.NewServer(ctx, cancel, svcName, httpServerConfig, httpapi.MakeHandler(svc, am, mux, logger, cfg.InstanceID), logger)
if cfg.SendTelemetry {
chc := chclient.New(svcName, magistrala.Version, logger, cancel)
go chc.CallHome(ctx)
}
g.Go(func() error {
return svc.StartScheduler(ctx)
})
g.Go(func() error {
return httpSvc.Start()
})
g.Go(func() error {
return server.StopSignalHandler(ctx, cancel, logger, svcName, httpSvc)
})
if err := g.Wait(); err != nil {
logger.Error(fmt.Sprintf("%s service terminated: %s", svcName, err))
}
}
func newService(ctx context.Context, cfg config, db pgclient.Database, runInfo chan pkglog.RunInfo, ec email.Config, logger *slog.Logger, readersClient grpcReadersV1.ReadersServiceClient, template reports.ReportTemplate, callout callout.Callout, tracer trace.Tracer) (reports.Service, error) {
repo := repg.NewRepository(db)
idp := uuid.New()
emailClient, err := emailer.New(&ec)
if err != nil {
logger.Error(fmt.Sprintf("failed to configure e-mailing util: %s", err.Error()))
}
atomCfg := atom.LoadConfig()
var csvc reports.Service
csvc, err = reports.NewService(repo, runInfo, idp, ticker.NewTicker(time.Second*30), emailClient, readersClient, template, cfg.ConverterURL)
if err != nil {
return nil, fmt.Errorf("failed to create reports service: %w", err)
}
csvc = reports.WithAtom(csvc, atom.NewClient(atomCfg))
csvc, err = reportsevents.NewEventStoreMiddleware(ctx, csvc, cfg.ESURL)
if err != nil {
return nil, fmt.Errorf("failed to init reports event store middleware: %w", err)
}
permConfig, err := permissions.ParsePermissionsFile(cfg.PermissionsFile)
if err != nil {
return nil, fmt.Errorf("failed to parse permissions file: %w", err)
}
reportOps, _, err := permConfig.GetEntityPermissions(reportEntity)
if err != nil {
return nil, fmt.Errorf("failed to get report permissions: %w", err)
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{
operations.EntityType: reportOps,
},
permissions.EntitiesOperationDetails[permissions.Operation]{
operations.EntityType: operations.OperationDetails(),
},
)
if err != nil {
return nil, fmt.Errorf("failed to create entities operations: %w", err)
}
csvc, err = middleware.AtomAuthorizationMiddleware(csvc, atom.NewClient(atomCfg), entitiesOps)
if err != nil {
return nil, err
}
csvc, err = middleware.NewCallout(csvc, callout, entitiesOps)
if err != nil {
return nil, err
}
csvc = middleware.LoggingMiddleware(csvc, logger)
counter, latency := prometheus.MakeMetrics("reports", "api")
csvc = middleware.NewMetricsMiddleware(counter, latency, csvc)
csvc = middleware.NewTracingMiddleware(tracer, csvc)
return csvc, nil
}
@@ -0,0 +1,479 @@
<!-- Copyright (c) Abstract Machines -->
<!-- SPDX-License-Identifier: Apache-2.0 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
:root {
--primary-color: rgb(41, 128, 185);
--secondary-color: rgb(26, 82, 118);
--subtle-color: rgb(189, 195, 199);
--table-header-bg: rgb(236, 240, 241);
--alternate-row: rgb(245, 247, 249);
--text-primary: rgb(44, 62, 80);
--text-secondary: rgb(127, 140, 141);
--white: #ffffff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: var(--white);
color: var(--text-primary);
line-height: 1.4;
}
.page {
width: 210mm;
height: 297mm;
padding: 15mm 10mm;
margin: 0 auto;
background: var(--white);
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
page-break-after: always;
display: flex;
flex-direction: column;
}
.page:last-child {
page-break-after: auto;
}
.header {
flex-shrink: 0;
margin-bottom: 8mm;
}
.header-top-bar {
height: 8px;
background-color: var(--primary-color);
margin: 0 0 8px 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.header-title {
font-size: 20px;
font-weight: bold;
color: var(--primary-color);
text-align: center;
flex-grow: 1;
}
.header-date {
font-size: 10px;
font-style: italic;
color: var(--text-secondary);
text-align: right;
width: 100px;
}
.header-separator {
height: 2px;
background-color: var(--subtle-color);
margin: 5px 0;
position: relative;
}
.header-separator::after {
content: '';
position: absolute;
top: 3px;
left: 0;
right: 0;
height: 1px;
background-color: var(--subtle-color);
}
.content-area {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.metrics-section {
flex-shrink: 0;
margin-bottom: 10px;
}
.metrics-section.continuation {
display: none;
}
.metrics-title {
font-size: 16px;
font-weight: bold;
color: var(--secondary-color);
margin-bottom: 8px;
}
.metrics-info {
background-color: var(--alternate-row);
padding: 10px;
border-radius: 4px;
margin-bottom: 8px;
}
.metric-row {
display: flex;
margin-bottom: 6px;
}
.metric-row:last-child {
margin-bottom: 0;
}
.metric-label {
font-weight: bold;
color: var(--text-primary);
width: 120px;
font-size: 11px;
}
.metric-value {
font-style: italic;
color: var(--text-primary);
font-size: 11px;
flex-grow: 1;
}
.record-count {
text-align: right;
font-size: 10px;
font-style: italic;
color: var(--text-secondary);
margin-bottom: 8px;
flex-shrink: 0;
}
.table-container {
flex-grow: 1;
overflow: hidden;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.table-header-bar {
height: 4px;
background-color: var(--primary-color);
}
.data-table th {
background-color: var(--table-header-bg);
color: var(--secondary-color);
font-weight: bold;
font-size: 11px;
padding: 8px;
text-align: center;
border-bottom: 2px solid var(--subtle-color);
}
.data-table td {
padding: 6px 8px;
font-size: 10px;
text-align: center;
border-bottom: 1px solid #eee;
}
.data-table tr:nth-child(even) {
background-color: var(--alternate-row);
}
.data-table tr:hover {
background-color: rgba(41, 128, 185, 0.05);
}
.col-time {
width: 25%;
color: var(--text-primary);
}
.col-value {
width: 17%;
color: var(--text-primary);
font-weight: normal;
}
.col-unit {
width: 17%;
color: var(--text-secondary);
font-style: italic;
}
.col-protocol {
width: 17%;
color: var(--text-primary);
}
.col-subtopic {
width: 24%;
color: var(--secondary-color);
}
.footer {
flex-shrink: 0;
border-top: 2px solid var(--subtle-color);
padding-top: 6px;
margin-top: 8mm;
}
.footer-separator {
height: 1px;
background-color: var(--subtle-color);
margin-bottom: 4px;
position: relative;
}
.footer-separator::after {
content: '';
position: absolute;
top: 1px;
left: 0;
right: 0;
height: 1px;
background-color: var(--subtle-color);
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-generated {
font-size: 8px;
font-style: italic;
color: var(--text-secondary);
}
.footer-page {
font-size: 9px;
font-weight: bold;
color: var(--text-primary);
}
@media print {
@page {
size: A4;
margin: 0;
}
html, body {
width: 210mm;
height: 297mm;
margin: 0;
padding: 0;
}
.page {
margin: 0;
box-shadow: none;
}
}
</style>
</head>
<body>
{{if gt (len .Reports) 0}}
{{$firstPageRows := 24}}
{{$continuationPageRows := 32}}
{{$totalPages := 0}}
{{/* Calculate total pages across all reports */}}
{{range $report := .Reports}}
{{$totalMessages := len .Messages}}
{{$reportPages := 1}}
{{if gt $totalMessages $firstPageRows}}
{{$remaining := sub $totalMessages $firstPageRows}}
{{$additionalPages := div $remaining $continuationPageRows}}
{{if gt (mod $remaining $continuationPageRows) 0}}
{{$additionalPages = add $additionalPages 1}}
{{end}}
{{$reportPages = add 1 $additionalPages}}
{{end}}
{{$totalPages = add $totalPages $reportPages}}
{{end}}
{{$globalPage := 0}}
{{range $reportIndex, $report := .Reports}}
{{$totalMessages := len .Messages}}
{{$pageCount := 1}}
{{if gt $totalMessages $firstPageRows}}
{{$remaining := sub $totalMessages $firstPageRows}}
{{$additionalPages := div $remaining $continuationPageRows}}
{{if gt (mod $remaining $continuationPageRows) 0}}
{{$additionalPages = add $additionalPages 1}}
{{end}}
{{$pageCount = add 1 $additionalPages}}
{{end}}
{{range $pageNum := iterate $pageCount}}
{{$globalPage = add $globalPage 1}}
{{$isFirstPage := eq $pageNum 0}}
{{$startRow := getStartRow $pageNum $firstPageRows $continuationPageRows}}
{{$endRow := getEndRow $pageNum $firstPageRows $continuationPageRows $totalMessages}}
<div class="page">
<div class="header">
<div class="header-top-bar"></div>
<div class="header-content">
<div style="width: 100px;"></div>
<div class="header-title">{{$.Title}}</div>
<div class="header-date">{{$.GeneratedDate}}{{if $.Timezone}} ({{$.Timezone}}){{end}}</div>
</div>
<div class="header-separator"></div>
</div>
<div class="content-area">
{{if $isFirstPage}}
<div class="metrics-section">
<div class="metrics-title">Metrics</div>
<div class="metrics-info">
<div class="metric-row">
<div class="metric-label">Name:</div>
<div class="metric-value">{{$report.Metric.Name}}</div>
</div>
{{if $report.Metric.ClientID}}
<div class="metric-row">
<div class="metric-label">Device ID:</div>
<div class="metric-value">{{$report.Metric.ClientID}}</div>
</div>
{{end}}
<div class="metric-row">
<div class="metric-label">Channel ID:</div>
<div class="metric-value">{{$report.Metric.ChannelID}}</div>
</div>
</div>
</div>
<div class="record-count">
Total Records: {{$totalMessages}}
</div>
{{else}}
<div class="metrics-section continuation">
<div class="metrics-title">Metrics (continued)</div>
</div>
{{end}}
<div class="table-container">
<div class="table-header-bar"></div>
<table class="data-table">
<thead>
<tr>
<th class="col-time">Time</th>
<th class="col-value">Value</th>
<th class="col-unit">Unit</th>
<th class="col-protocol">Protocol</th>
<th class="col-subtopic">Subtopic</th>
</tr>
</thead>
<tbody>
{{range $msgIndex, $msg := $report.Messages}}
{{if and (ge $msgIndex $startRow) (lt $msgIndex $endRow)}}
<tr>
<td class="col-time">{{formatTime $msg.Time}}</td>
<td class="col-value">{{formatValue $msg}}</td>
<td class="col-unit">{{$msg.Unit}}</td>
<td class="col-protocol">{{$msg.Protocol}}</td>
<td class="col-subtopic">{{$msg.Subtopic}}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
</div>
<div class="footer">
<div class="footer-separator"></div>
<div class="footer-content">
<div class="footer-generated">Generated: {{$.GeneratedTime}}{{if $.Timezone}} ({{$.Timezone}}){{end}}</div>
<div class="footer-page">Page {{$globalPage}} of {{$totalPages}}</div>
</div>
</div>
</div>
{{end}}
{{end}}
{{else}}
<div class="page">
<div class="header">
<div class="header-top-bar"></div>
<div class="header-content">
<div style="width: 100px;"></div>
<div class="header-title">{{.Title}}</div>
<div class="header-date">{{.GeneratedDate}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
</div>
<div class="header-separator"></div>
</div>
<div class="content-area">
<div class="metrics-section">
<div class="metrics-title">Metrics</div>
<div class="metrics-info">
<div class="metric-row">
<div class="metric-label">Name:</div>
<div class="metric-value">No Report</div>
</div>
<div class="metric-row">
<div class="metric-label">Channel ID:</div>
<div class="metric-value">N/A</div>
</div>
</div>
</div>
<div class="record-count">
Total Records: 0
</div>
<div class="table-container">
<div class="table-header-bar"></div>
<table class="data-table">
<thead>
<tr>
<th class="col-time">Time</th>
<th class="col-value">Value</th>
<th class="col-unit">Unit</th>
<th class="col-protocol">Protocol</th>
<th class="col-subtopic">Subtopic</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" style="text-align: center; font-style: italic; color: #888;">No data available</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="footer">
<div class="footer-separator"></div>
<div class="footer-content">
<div class="footer-generated">Generated: {{.GeneratedTime}}{{if .Timezone}} ({{.Timezone}}){{end}}</div>
<div class="footer-page">Page 1 of 1</div>
</div>
</div>
</div>
{{end}}
</body>
</html>
+1 -1
View File
@@ -14,8 +14,8 @@ import (
chclient "github.com/absmach/callhome/pkg/client"
"github.com/absmach/magistrala"
grpcReadersV1 "github.com/absmach/magistrala/api/grpc/readers/v1"
"github.com/absmach/magistrala/internal/atom"
mglog "github.com/absmach/magistrala/logger"
"github.com/absmach/magistrala/pkg/atom"
atomauthn "github.com/absmach/magistrala/pkg/authn/atom"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
+1 -1
View File
@@ -17,11 +17,11 @@ import (
"github.com/absmach/magistrala/consumers"
consumertracing "github.com/absmach/magistrala/consumers/tracing"
httpapi "github.com/absmach/magistrala/consumers/writers/api"
"github.com/absmach/magistrala/consumers/writers/brokers"
"github.com/absmach/magistrala/consumers/writers/timescale"
mglog "github.com/absmach/magistrala/logger"
jaegerclient "github.com/absmach/magistrala/pkg/jaeger"
brokerstracing "github.com/absmach/magistrala/pkg/messaging/brokers/tracing"
brokers "github.com/absmach/magistrala/pkg/messaging/writers"
pgclient "github.com/absmach/magistrala/pkg/postgres"
"github.com/absmach/magistrala/pkg/prometheus"
"github.com/absmach/magistrala/pkg/server"
+1
View File
@@ -17,5 +17,6 @@ user_token = ""
groups_url = "http://localhost:9004"
host_url = "http://localhost"
http_adapter_url = "http://localhost:8008"
journal_url = "http://localhost:9021"
tls_verification = false
users_url = "http://localhost:9002"
+2 -2
View File
@@ -8,7 +8,7 @@ The service is configured using environment variables. Values shown are from [do
### SMTP notifier (email)
Used by `consumers/notifiers/smtp` via `pkg/email`.
Used by `consumers/notifiers/smtp` via `internal/email`.
| Variable | Description | Default |
| ----------------------- | ---------------------------------------------- | ------------------ |
@@ -93,7 +93,7 @@ Defined in `consumers/notifiers/smpp/README.md`.
- **Service layer**: `consumers/notifiers/service.go` handles authn, ID creation, and notification dispatch.
- **Repository**: `consumers/notifiers/postgres` persists subscriptions and supports filtering.
- **Notifier implementations**: `consumers/notifiers/smtp` (email) and `consumers/notifiers/smpp` (SMS).
- **Email agent**: `pkg/email` manages SMTP connectivity and template rendering.
- **Email agent**: `internal/email` manages SMTP connectivity and template rendering.
### Subscriptions table
+1 -1
View File
@@ -17,9 +17,9 @@ import (
"github.com/absmach/magistrala/consumers/notifiers"
"github.com/absmach/magistrala/consumers/notifiers/api"
"github.com/absmach/magistrala/consumers/notifiers/mocks"
"github.com/absmach/magistrala/internal/testsutil"
mglog "github.com/absmach/magistrala/logger"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/testsutil"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
+1 -1
View File
@@ -12,12 +12,12 @@ import (
smqmocks "github.com/absmach/magistrala/consumers/mocks"
"github.com/absmach/magistrala/consumers/notifiers"
"github.com/absmach/magistrala/consumers/notifiers/mocks"
"github.com/absmach/magistrala/internal/testsutil"
smqauthn "github.com/absmach/magistrala/pkg/authn"
authnmocks "github.com/absmach/magistrala/pkg/authn/mocks"
"github.com/absmach/magistrala/pkg/errors"
svcerr "github.com/absmach/magistrala/pkg/errors/service"
"github.com/absmach/magistrala/pkg/messaging"
"github.com/absmach/magistrala/pkg/testsutil"
"github.com/absmach/magistrala/pkg/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"fmt"
"github.com/absmach/magistrala/consumers"
"github.com/absmach/magistrala/pkg/email"
"github.com/absmach/magistrala/internal/email"
"github.com/absmach/magistrala/pkg/messaging"
)
+2 -2
View File
@@ -104,7 +104,7 @@ time_fields = [
]
```
The topic filter uses slash-delimited MQTT-style syntax (`+`, `#`) in the config file for both backends. Writers do not expose broker mode, delivery policy, or consumer-group settings in this file. They always consume through the stream-backed broker adapter in `pkg/messaging/writers`:
The topic filter uses slash-delimited MQTT-style syntax (`+`, `#`) in the config file for both backends. Writers do not expose broker mode, delivery policy, or consumer-group settings in this file. They always consume through the stream-backed broker adapter in `consumers/writers/brokers`:
- NATS builds use JetStream streams with durable consumers.
- FluxMQ builds publish to and consume from the `writers` stream queue while preserving the same `writers/#` config syntax.
@@ -129,7 +129,7 @@ The topic filter uses slash-delimited MQTT-style syntax (`+`, `#`) in the config
### Components
- **Message broker adapter**: `pkg/messaging/writers` (NATS JetStream or FluxMQ stream queues).
- **Message broker adapter**: `consumers/writers/brokers` (NATS JetStream or FluxMQ stream queues).
- **Writer services**: `consumers/writers/postgres` and `consumers/writers/timescale`.
- **HTTP API**: `consumers/writers/api` exposes `/health` and `/metrics`.
- **Migrations**: `consumers/writers/*/init.go` defines the schema and indexes.
@@ -4,7 +4,7 @@
//go:build msg_fluxmq
// +build msg_fluxmq
package writers
package brokers
import (
"context"
@@ -4,7 +4,7 @@
//go:build !msg_fluxmq && !msg_rabbitmq && !rabbitmq
// +build !msg_fluxmq,!msg_rabbitmq,!rabbitmq
package writers
package brokers
import (
"context"
+27 -5
View File
@@ -141,7 +141,6 @@ MG_AUTH_GRPC_CLIENT_KEY=${GRPC_MTLS:+./ssl/certs/auth-grpc-client.key}
MG_AUTH_GRPC_CLIENT_CA_CERTS=${GRPC_MTLS:+./ssl/certs/ca.crt}
### Atom Integration
ATOM_IMAGE=ghcr.io/absmach/atom:latest
ATOM_URL=http://atom:8080
ATOM_PUBLIC_URL=http://nginx:80
ATOM_JWKS_URL=http://atom:8080/.well-known/jwks.json
@@ -280,6 +279,13 @@ MG_EMAIL_PASSWORD=password
MG_EMAIL_FROM_ADDRESS=from@example.com
MG_EMAIL_FROM_NAME=Example
MG_EMAIL_TEMPLATE=
MG_EMAIL_INVITATION_TEMPLATE=invitation-sent-email.tmpl
MG_EMAIL_ACCEPTANCE_TEMPLATE=invitation-accepted-email.tmpl
MG_EMAIL_REJECTION_TEMPLATE=invitation-rejected-email.tmpl
### Notifications
MG_NOTIFICATIONS_LOG_LEVEL=debug
MG_NOTIFICATIONS_INSTANCE_ID=
### Google OAuth2
MG_GOOGLE_CLIENT_ID=
@@ -431,6 +437,23 @@ MG_TIMESCALE_SSL_CERT=
MG_TIMESCALE_SSL_KEY=
MG_TIMESCALE_SSL_ROOT_CERT=
### Journal
MG_JOURNAL_LOG_LEVEL=info
MG_JOURNAL_HTTP_HOST=journal
MG_JOURNAL_HTTP_PORT=9021
MG_JOURNAL_HTTP_SERVER_CERT=
MG_JOURNAL_HTTP_SERVER_KEY=
MG_JOURNAL_DB_HOST=journal-db
MG_JOURNAL_DB_PORT=5432
MG_JOURNAL_DB_USER=magistrala
MG_JOURNAL_DB_PASS=magistrala
MG_JOURNAL_DB_NAME=journal
MG_JOURNAL_DB_SSL_MODE=disable
MG_JOURNAL_DB_SSL_CERT=
MG_JOURNAL_DB_SSL_KEY=
MG_JOURNAL_DB_SSL_ROOT_CERT=
MG_JOURNAL_INSTANCE_ID=
### GRAFANA and PROMETHEUS
MG_PROMETHEUS_PORT=9090
MG_GRAFANA_PORT=3001
@@ -647,6 +670,8 @@ MG_UI_BACKEND_INSTANCE_ID=
MG_UI_BACKEND_URL=http://ui-backend:9097
MG_UI_VERIFICATION_TLS=false
MG_UI_CONTENT_TYPE=application/senml+json
# Set to yes to accept the EULA for the UI services. To view the EULA visit: https://github.com/absmach/eula
MG_UI_DOCKER_ACCEPT_EULA=yes
OTEL_SERVICE_NAME=ui-mg
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318
@@ -685,6 +710,7 @@ MG_BOOTSTRAP_URL=http://bootstrap:9013
MG_HTTP_ADAPTER_URL=http://nginx:80/http
MG_PUBLISH_PROXY_URL=http://nginx:80
MG_READER_URL=http://timescale-reader:9011
MG_JOURNAL_URL=http://journal:9021
### UI Configuration
MG_UI_TYPE=mg
@@ -713,7 +739,3 @@ MG_UI_CLI_WS_URL=ws://localhost:80/mqtt
MG_UI_CLI_COAP_HOST=0.0.0.0
MG_UI_CLI_COAP_PORT=5684
MG_UI_CLI_HTTP_URL=http://localhost:80/http
# Atom
ATOM_LOG_LEVEL=info
ATOM_LOG_FORMAT=text
+2 -6
View File
@@ -17,11 +17,7 @@ make provision_atom_tokens
make run_latest
```
`make provision_atom_tokens` starts Atom, creates per-service unscoped Atom access tokens, and writes them to the generated `docker/.env.tokens` file. That file is local-only and must not be committed.
The Atom runtime image is selected with `ATOM_IMAGE` in `docker/.env`. To test Magistrala against a local Atom checkout, build that checkout with a local tag and set `ATOM_IMAGE` to that tag before running Compose.
The generated `MG_ATOM_TOKEN_*` values are intentionally unscoped service credentials for the current integration. Do not replace them with scoped Atom access tokens yet: Atom rejects scoped tokens on owner-wide listing APIs such as `authorizedObjectIds`, and some Magistrala policy-listing paths still use that API.
`make provision_atom_tokens` starts Atom, creates per-service Atom API keys, and writes them to the generated `docker/.env.tokens` file. That file is local-only and must not be committed.
If you use `docker compose` directly instead of the Makefile, pass both env files:
@@ -180,7 +176,7 @@ For a trusted production certificate, set `staging=false`:
make run_tls host=example.com email=admin@example.com staging=false
```
The target updates `docker/.env`, starts the Compose stack with the fallback certificate, runs certbot, switches Nginx to the issued certificate, and recreates Nginx. It also configures public UI URLs to `https://${MG_PUBLIC_HOST}`.
The target updates `docker/.env`, starts the Compose stack with the fallback certificate, runs certbot, switches Nginx to the issued certificate, and recreates Nginx. It also sets `MG_UI_DOCKER_ACCEPT_EULA=yes` for the UI container and configures public UI URLs to `https://${MG_PUBLIC_HOST}`.
To configure the same instance without Let's Encrypt, use:
+1 -1
View File
@@ -15,7 +15,7 @@ volumes:
services:
bootstrap-db:
image: postgres:16.14-alpine3.24
image: postgres:16.2-alpine
container_name: magistrala-bootstrap-db
restart: on-failure
environment:
@@ -17,7 +17,7 @@ volumes:
services:
postgres:
image: postgres:16.14-alpine3.24
image: postgres:16.2-alpine
container_name: magistrala-postgres
restart: on-failure
environment:
+2 -2
View File
@@ -18,7 +18,7 @@ volumes:
services:
promethues:
image: prom/prometheus:v3.13.1
image: prom/prometheus:v2.49.1
container_name: magistrala-prometheus
restart: on-failure
ports:
@@ -32,7 +32,7 @@ services:
- magistrala-prometheus-volume:/prometheus
grafana:
image: grafana/grafana:13.1.1
image: grafana/grafana:10.2.3
container_name: magistrala-grafana
depends_on:
- promethues
+110 -19
View File
@@ -14,6 +14,7 @@ networks:
volumes:
magistrala-pat-db-volume:
magistrala-ui-backend-db-volume:
magistrala-journal-volume:
magistrala-re-db-volume:
magistrala-alarms-db-volume:
magistrala-reports-db-volume:
@@ -45,7 +46,7 @@ services:
retries: 5
atom:
image: ${ATOM_IMAGE:-ghcr.io/absmach/atom:latest}
image: ghcr.io/absmach/atom:latest
container_name: magistrala-atom
restart: on-failure
depends_on:
@@ -127,8 +128,65 @@ services:
networks:
- magistrala-base-net
journal-db:
image: postgres:16.2-alpine
container_name: magistrala-journal-db
restart: on-failure
command: postgres -c "max_connections=${MG_POSTGRES_MAX_CONNECTIONS}"
environment:
POSTGRES_USER: ${MG_JOURNAL_DB_USER}
POSTGRES_PASSWORD: ${MG_JOURNAL_DB_PASS}
POSTGRES_DB: ${MG_JOURNAL_DB_NAME}
MG_POSTGRES_MAX_CONNECTIONS: ${MG_POSTGRES_MAX_CONNECTIONS}
networks:
- magistrala-base-net
volumes:
- magistrala-journal-volume:/var/lib/postgresql/data
journal:
image: ghcr.io/absmach/magistrala/journal:${MG_RELEASE_TAG}
container_name: magistrala-journal
depends_on:
journal-db:
condition: service_started
atom-bootstrap:
condition: service_completed_successfully
nginx:
condition: service_started
restart: on-failure
environment:
MG_JOURNAL_LOG_LEVEL: ${MG_JOURNAL_LOG_LEVEL}
MG_JOURNAL_HTTP_HOST: ${MG_JOURNAL_HTTP_HOST}
MG_JOURNAL_HTTP_PORT: ${MG_JOURNAL_HTTP_PORT}
MG_JOURNAL_HTTP_SERVER_CERT: ${MG_JOURNAL_HTTP_SERVER_CERT}
MG_JOURNAL_HTTP_SERVER_KEY: ${MG_JOURNAL_HTTP_SERVER_KEY}
MG_JOURNAL_DB_HOST: ${MG_JOURNAL_DB_HOST}
MG_JOURNAL_DB_PORT: ${MG_JOURNAL_DB_PORT}
MG_JOURNAL_DB_USER: ${MG_JOURNAL_DB_USER}
MG_JOURNAL_DB_PASS: ${MG_JOURNAL_DB_PASS}
MG_JOURNAL_DB_NAME: ${MG_JOURNAL_DB_NAME}
MG_JOURNAL_DB_SSL_MODE: ${MG_JOURNAL_DB_SSL_MODE}
MG_JOURNAL_DB_SSL_CERT: ${MG_JOURNAL_DB_SSL_CERT}
MG_JOURNAL_DB_SSL_KEY: ${MG_JOURNAL_DB_SSL_KEY}
MG_JOURNAL_DB_SSL_ROOT_CERT: ${MG_JOURNAL_DB_SSL_ROOT_CERT}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_JOURNAL}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
ATOM_TIMEOUT: ${ATOM_TIMEOUT}
MG_ES_URL: ${MG_ES_URL}
MG_JAEGER_URL: ${MG_JAEGER_URL}
MG_JAEGER_TRACE_RATIO: ${MG_JAEGER_TRACE_RATIO}
MG_SEND_TELEMETRY: ${MG_SEND_TELEMETRY}
MG_JOURNAL_INSTANCE_ID: ${MG_JOURNAL_INSTANCE_ID}
MG_ALLOW_UNVERIFIED_USER: ${MG_ALLOW_UNVERIFIED_USER}
ports:
- ${MG_JOURNAL_HTTP_PORT}:${MG_JOURNAL_HTTP_PORT}
networks:
- magistrala-base-net
nginx:
image: docker.io/nginx:1.31.3-alpine3.24
image: docker.io/nginx:1.29.2-alpine3.22
container_name: magistrala-nginx
restart: on-failure
volumes:
@@ -170,7 +228,7 @@ services:
hard: 65536
certbot:
image: docker.io/certbot/certbot:v5.7.0
image: docker.io/certbot/certbot:v2.11.0
container_name: magistrala-certbot
profiles:
- letsencrypt
@@ -226,8 +284,46 @@ services:
sleep 12h & wait $$!
done
notifications:
image: ghcr.io/absmach/magistrala/notifications:${MG_RELEASE_TAG}
container_name: magistrala-notifications
depends_on:
atom-bootstrap:
condition: service_completed_successfully
nginx:
condition: service_started
restart: on-failure
environment:
MG_NOTIFICATIONS_LOG_LEVEL: ${MG_NOTIFICATIONS_LOG_LEVEL}
MG_NOTIFICATIONS_INSTANCE_ID: ${MG_NOTIFICATIONS_INSTANCE_ID}
MG_ES_URL: ${MG_ES_URL}
MG_JAEGER_URL: ${MG_JAEGER_URL}
MG_JAEGER_TRACE_RATIO: ${MG_JAEGER_TRACE_RATIO}
MG_SEND_TELEMETRY: ${MG_SEND_TELEMETRY}
MG_EMAIL_HOST: ${MG_EMAIL_HOST}
MG_EMAIL_PORT: ${MG_EMAIL_PORT}
MG_EMAIL_USERNAME: ${MG_EMAIL_USERNAME}
MG_EMAIL_PASSWORD: ${MG_EMAIL_PASSWORD}
MG_EMAIL_FROM_ADDRESS: ${MG_EMAIL_FROM_ADDRESS}
MG_EMAIL_FROM_NAME: ${MG_EMAIL_FROM_NAME}
MG_EMAIL_INVITATION_TEMPLATE: ${MG_EMAIL_INVITATION_TEMPLATE}
MG_EMAIL_ACCEPTANCE_TEMPLATE: ${MG_EMAIL_ACCEPTANCE_TEMPLATE}
MG_EMAIL_REJECTION_TEMPLATE: ${MG_EMAIL_REJECTION_TEMPLATE}
ATOM_URL: ${ATOM_URL}
ATOM_SERVICE_TOKEN: ${MG_ATOM_TOKEN_NOTIFICATIONS}
ATOM_JWKS_URL: ${ATOM_JWKS_URL}
ATOM_JWT_ISSUER: ${ATOM_JWT_ISSUER}
ATOM_JWT_AUDIENCE: ${ATOM_JWT_AUDIENCE}
ATOM_TIMEOUT: ${ATOM_TIMEOUT}
networks:
- magistrala-base-net
volumes:
- ./templates/${MG_EMAIL_INVITATION_TEMPLATE}:/${MG_EMAIL_INVITATION_TEMPLATE}
- ./templates/${MG_EMAIL_ACCEPTANCE_TEMPLATE}:/${MG_EMAIL_ACCEPTANCE_TEMPLATE}
- ./templates/${MG_EMAIL_REJECTION_TEMPLATE}:/${MG_EMAIL_REJECTION_TEMPLATE}
jaeger:
image: docker.io/jaegertracing/all-in-one:1.76.0
image: docker.io/jaegertracing/all-in-one:1.74.0
container_name: magistrala-jaeger
environment:
COLLECTOR_OTLP_ENABLED: ${MG_JAEGER_COLLECTOR_OTLP_ENABLED}
@@ -353,6 +449,7 @@ services:
MG_PUBLISH_PROXY_URL: ${MG_PUBLISH_PROXY_URL}
MG_READER_URL: ${MG_READER_URL}
MG_BACKEND_URL: ${MG_UI_BACKEND_URL}
MG_JOURNAL_URL: ${MG_JOURNAL_URL}
MG_ALARMS_URL: ${MG_ALARMS_URL}
MG_RE_URL: ${MG_RE_URL}
MG_REPORTS_URL: ${MG_REPORTS_URL}
@@ -370,6 +467,7 @@ services:
NEXT_LOG_LEVEL: "debug"
MG_HOST_URL: ${MG_HOST_URL}
MG_UI_IMAGE_URL: ${MG_UI_IMAGE_URL}
MG_UI_DOCKER_ACCEPT_EULA: ${MG_UI_DOCKER_ACCEPT_EULA}
MG_SUPPORT_EMAIL: ${MG_SUPPORT_EMAIL}
MG_SUPPORT_EMAIL_PASS: ${MG_SUPPORT_EMAIL_PASS}
MG_UI_CLI_MQTT_HOST: ${MG_UI_CLI_MQTT_HOST}
@@ -423,6 +521,7 @@ services:
MG_UI_VERIFICATION_TLS: ${MG_UI_VERIFICATION_TLS}
MG_UI_CONTENT_TYPE: ${MG_UI_CONTENT_TYPE}
MG_READER_URL: ${MG_READER_URL}
MG_UI_DOCKER_ACCEPT_EULA: ${MG_UI_DOCKER_ACCEPT_EULA}
MG_CHANNELS_GRPC_URL: ${MG_CHANNELS_GRPC_URL}
MG_CHANNELS_GRPC_TIMEOUT: ${MG_CHANNELS_GRPC_TIMEOUT}
MG_CHANNELS_GRPC_CLIENT_CERT: ${MG_CHANNELS_GRPC_CLIENT_CERT:+/channels-grpc-client.crt}
@@ -502,12 +601,11 @@ services:
create_host_path: true
ui-backend-db:
image: docker.io/postgres:18.4-alpine3.24
image: docker.io/postgres:18.0-alpine3.22
container_name: magistrala-ui-backend-db
restart: on-failure
command: postgres -c "max_connections=${MG_POSTGRES_MAX_CONNECTIONS}"
environment:
PGDATA: /var/lib/postgresql/data
POSTGRES_USER: ${MG_UI_BACKEND_DB_USER}
POSTGRES_PASSWORD: ${MG_UI_BACKEND_DB_PASS}
POSTGRES_DB: ${MG_UI_BACKEND_DB_NAME}
@@ -525,7 +623,7 @@ services:
retries: 60
seaweedfs-s3:
image: chrislusf/seaweedfs:4.40
image: chrislusf/seaweedfs:4.16
container_name: magistrala-seaweedfs-s3
command: server -s3 -s3.config=/etc/seaweedfs/s3.json -dir=/data
ports:
@@ -570,7 +668,7 @@ services:
AWS_EC2_METADATA_DISABLED: "true"
timescale:
image: timescale/timescaledb:2.29.0-pg16-oss
image: timescale/timescaledb:2.19.3-pg16-oss
container_name: magistrala-timescale
restart: on-failure
environment:
@@ -698,12 +796,11 @@ services:
volumes:
- ./addons/timescale-writer/config.toml:${MG_TIMESCALE_WRITER_CONFIG_PATH}
re-db:
image: docker.io/postgres:18.4-alpine3.24
image: docker.io/postgres:18.0-alpine3.22
container_name: magistrala-re-db
restart: on-failure
command: postgres -c "max_connections=${MG_POSTGRES_MAX_CONNECTIONS}"
environment:
PGDATA: /var/lib/postgresql/data
POSTGRES_USER: ${MG_RE_DB_USER}
POSTGRES_PASSWORD: ${MG_RE_DB_PASS}
POSTGRES_DB: ${MG_RE_DB_NAME}
@@ -783,12 +880,11 @@ services:
- ./templates/${MG_RE_EMAIL_TEMPLATE}:/email.tmpl
alarms-db:
image: docker.io/postgres:18.4-alpine3.24
image: docker.io/postgres:18.0-alpine3.22
container_name: magistrala-alarms-db
restart: on-failure
command: postgres -c "max_connections=${MG_POSTGRES_MAX_CONNECTIONS}"
environment:
PGDATA: /var/lib/postgresql/data
POSTGRES_USER: ${MG_ALARMS_DB_USER}
POSTGRES_PASSWORD: ${MG_ALARMS_DB_PASS}
POSTGRES_DB: ${MG_ALARMS_DB_NAME}
@@ -847,12 +943,11 @@ services:
- ./permission.yaml:${MG_PERMISSIONS_FILE}
reports-db:
image: docker.io/postgres:18.4-alpine3.24
image: docker.io/postgres:18.0-alpine3.22
container_name: magistrala-reports-db
restart: on-failure
command: postgres -c "max_connections=${MG_POSTGRES_MAX_CONNECTIONS}"
environment:
PGDATA: /var/lib/postgresql/data
POSTGRES_USER: ${MG_REPORTS_DB_USER}
POSTGRES_PASSWORD: ${MG_REPORTS_DB_PASS}
POSTGRES_DB: ${MG_REPORTS_DB_NAME}
@@ -934,12 +1029,8 @@ services:
- ./templates/${MG_REPORTS_EMAIL_TEMPLATE}:/email.tmpl
pdf-generator:
image: gotenberg/gotenberg:8.34.0
image: gotenberg/gotenberg:8.25.1
container_name: magistrala-pdf
restart: on-failure
# Gotenberg leaks memory over time (upstream issues #987, #1169); the cap
# keeps the OOM kill inside this container instead of stalling the host.
mem_limit: 512m
ports:
- "4000:3000"
networks:
+3 -20
View File
@@ -127,29 +127,12 @@ queues:
max_length_bytes: 1073741824
auth:
external:
url: "http://fluxmq-auth:7016"
transport: "grpc"
timeout: 15s
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: false
hooks:
url: "http://fluxmq-auth:7016"
transport: "http"
timeout: 500ms
fail_mode: "deny"
transport: "grpc"
timeout: 15s
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: true
events:
auth_on_publish: true
auth_on_subscribe: true
auth_on_unsubscribe: true
amqp091: false
+3 -20
View File
@@ -124,29 +124,12 @@ queues:
max_length_bytes: 1073741824
auth:
external:
url: "http://fluxmq-auth:7016"
transport: "grpc"
timeout: 15s
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: false
hooks:
url: "http://fluxmq-auth:7016"
transport: "http"
timeout: 500ms
fail_mode: "deny"
transport: "grpc"
timeout: 15s
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: true
events:
auth_on_publish: true
auth_on_subscribe: true
auth_on_unsubscribe: true
amqp091: false
+3 -20
View File
@@ -124,29 +124,12 @@ queues:
max_length_bytes: 1073741824
auth:
external:
url: "http://fluxmq-auth:7016"
transport: "grpc"
timeout: 15s
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: false
hooks:
url: "http://fluxmq-auth:7016"
transport: "http"
timeout: 500ms
fail_mode: "deny"
transport: "grpc"
timeout: 15s
protocols:
mqtt: true
http: true
coap: true
amqp: true
amqp091: true
events:
auth_on_publish: true
auth_on_subscribe: true
auth_on_unsubscribe: true
amqp091: false
+7 -11
View File
@@ -6,7 +6,6 @@ set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT_DIR/docker/.env"
TOKENS_ENV_FILE="$ROOT_DIR/docker/.env.tokens"
COMPOSE_FILE="$ROOT_DIR/docker/docker-compose.yaml"
HOST=${MG_PUBLIC_HOST:-}
@@ -125,11 +124,7 @@ comment_env_any() {
}
compose() {
if [ -f "$TOKENS_ENV_FILE" ]; then
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" --env-file "$TOKENS_ENV_FILE" -p "$PROJECT" "$@"
else
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" "$@"
fi
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" -p "$PROJECT" "$@"
}
write_ui_proxy() {
@@ -213,6 +208,7 @@ set_env MG_LETSENCRYPT_FORCE_RENEWAL "$FORCE_RENEWAL"
set_env MG_NGINX_SERVER_NAME "$HOST"
comment_env_any MG_NGINX_SERVER_CERT
comment_env_any MG_NGINX_SERVER_KEY
set_env MG_UI_DOCKER_ACCEPT_EULA yes
set_env MG_OAUTH_UI_REDIRECT_URL "https://$HOST/api/auth/token"
set_env MG_OAUTH_UI_ERROR_URL "https://$HOST/login"
@@ -233,19 +229,19 @@ write_ui_proxy
if [ "$LETSENCRYPT_ENABLED" = "false" ]; then
echo "Starting Magistrala with the fallback Nginx certificate"
compose up -d
COMPOSE_PROFILES=letsencrypt compose stop certbot >/dev/null 2>&1 || true
MG_UI_DOCKER_ACCEPT_EULA=yes compose up -d
MG_UI_DOCKER_ACCEPT_EULA=yes COMPOSE_PROFILES=letsencrypt compose stop certbot >/dev/null 2>&1 || true
echo "Let's Encrypt disabled. Nginx cert/key paths are commented in docker/.env."
echo "Fallback TLS setup complete: https://$HOST/"
exit 0
fi
echo "Starting Magistrala with the fallback Nginx certificate"
compose up -d
MG_UI_DOCKER_ACCEPT_EULA=yes compose up -d
wait_for_nginx_http
echo "Requesting Let's Encrypt certificate for $HOST"
COMPOSE_PROFILES=letsencrypt compose up -d --force-recreate certbot
MG_UI_DOCKER_ACCEPT_EULA=yes COMPOSE_PROFILES=letsencrypt compose up -d --force-recreate certbot
cert_ready() {
compose logs certbot 2>&1 | \
@@ -270,6 +266,6 @@ set_env MG_NGINX_SERVER_CERT "$cert_path"
set_env MG_NGINX_SERVER_KEY "$key_path"
set_env MG_LETSENCRYPT_FORCE_RENEWAL false
compose up -d --force-recreate nginx
MG_UI_DOCKER_ACCEPT_EULA=yes compose up -d --force-recreate nginx
echo "TLS setup complete: https://$HOST/"
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{.Subject}}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 30px auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
padding: 30px 40px;
text-align: center;
}
.header h1 {
color: #ffffff;
margin: 0;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 40px;
}
.content p {
margin: 0 0 15px 0;
font-size: 15px;
}
.success-details {
background-color: #eafaf1;
border-left: 4px solid #27ae60;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.success-details p {
margin: 0;
font-size: 16px;
line-height: 1.8;
}
.icon-success {
text-align: center;
margin: 20px 0;
font-size: 48px;
}
.footer {
background-color: #f8f9fb;
padding: 25px 40px;
text-align: center;
font-size: 13px;
color: #666;
}
.footer-divider {
border: none;
border-top: 1px solid #e0e3e8;
margin: 30px 0 20px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Invitation Accepted</h1>
</div>
<div class="content">
<div class="icon-success">✓</div>
<p>Dear <strong>{{.User}}</strong>,</p>
<div class="success-details">
<p>{{.Content}}</p>
</div>
<p>You can now collaborate with them in the domain.</p>
<hr class="footer-divider">
<p>Best regards,<br><strong>{{.Footer}}</strong></p>
</div>
<div class="footer">
<p>This is an automated email. Please do not reply directly to this message.</p>
<p style="margin-top: 10px; color: #999;">Powered by Magistrala</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{.Subject}}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 30px auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
padding: 30px 40px;
text-align: center;
}
.header h1 {
color: #ffffff;
margin: 0;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 40px;
}
.content p {
margin: 0 0 15px 0;
font-size: 15px;
}
.rejection-details {
background-color: #fadbd8;
border-left: 4px solid #e74c3c;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.rejection-details p {
margin: 0;
font-size: 16px;
line-height: 1.8;
}
.footer {
background-color: #f8f9fb;
padding: 25px 40px;
text-align: center;
font-size: 13px;
color: #666;
}
.footer-divider {
border: none;
border-top: 1px solid #e0e3e8;
margin: 30px 0 20px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Invitation Declined</h1>
</div>
<div class="content">
<p>Dear <strong>{{.User}}</strong>,</p>
<div class="rejection-details">
<p>{{.Content}}</p>
</div>
<p>If you would like to extend another invitation or have any questions, please don't hesitate to contact us.</p>
<hr class="footer-divider">
<p>Best regards,<br><strong>{{.Footer}}</strong></p>
</div>
<div class="footer">
<p>This is an automated email. Please do not reply directly to this message.</p>
<p style="margin-top: 10px; color: #999;">Powered by Magistrala</p>
</div>
</div>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{.Subject}}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f7fa;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 30px auto;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #083662 0%, #0a4d8f 100%);
padding: 30px 40px;
text-align: center;
}
.header h1 {
color: #ffffff;
margin: 0;
font-size: 24px;
font-weight: 600;
}
.content {
padding: 40px;
}
.content p {
margin: 0 0 15px 0;
font-size: 15px;
}
.invitation-details {
background-color: #f8f9fb;
border-left: 4px solid #083662;
padding: 20px;
margin: 25px 0;
border-radius: 4px;
}
.invitation-details p {
margin: 0;
font-size: 16px;
line-height: 1.8;
}
.cta-button {
display: inline-block;
background-color: #083662;
color: #ffffff;
padding: 14px 32px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
font-weight: 600;
font-size: 15px;
}
.footer {
background-color: #f8f9fb;
padding: 25px 40px;
text-align: center;
font-size: 13px;
color: #666;
}
.footer-divider {
border: none;
border-top: 1px solid #e0e3e8;
margin: 30px 0 20px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Domain Invitation</h1>
</div>
<div class="content">
<p>Dear <strong>{{.User}}</strong>,</p>
<div class="invitation-details">
<p>{{.Content}}</p>
</div>
<p>Please log in to your account to view and respond to this invitation.</p>
<p>If you have any questions, please don't hesitate to contact us.</p>
<hr class="footer-divider">
<p>Best regards,<br><strong>{{.Footer}}</strong></p>
</div>
<div class="footer">
<p>This is an automated email. Please do not reply directly to this message.</p>
<p style="margin-top: 10px; color: #999;">Powered by Magistrala</p>
</div>
</div>
</body>
</html>
+4 -9
View File
@@ -13,7 +13,7 @@ import (
grpcChannelsV1 "github.com/absmach/magistrala/api/grpc/channels/v1"
grpcClientsV1 "github.com/absmach/magistrala/api/grpc/clients/v1"
apiutil "github.com/absmach/magistrala/api/http/util"
"github.com/absmach/magistrala/pkg/atom"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
"github.com/absmach/magistrala/pkg/connections"
"github.com/absmach/magistrala/pkg/errors"
@@ -56,13 +56,6 @@ func (s *connectServer) Authenticate(ctx context.Context, req *connect.Request[a
username := req.Msg.GetUsername()
password := req.Msg.GetPassword()
// Reject empty-username clients early to avoid hitting Atom's rate limiter
// for an empty identifier, which would trigger the FluxMQ circuit breaker
// and block all subsequent MQTT connections.
if username == "" || password == "" {
return connect.NewResponse(&authv1.AuthnRes{Authenticated: false}), nil
}
token := authn.AuthPack(authn.BasicAuth, username, password)
res, err := s.clients.Authenticate(ctx, &grpcClientsV1.AuthnReq{Token: token})
if err != nil {
@@ -152,7 +145,9 @@ func (s *connectServer) Authorize(ctx context.Context, req *connect.Request[auth
return nil, encodeError(err)
}
return connect.NewResponse(&authv1.AuthzRes{Authorized: res.GetAuthorized()}), nil
return connect.NewResponse(&authv1.AuthzRes{
Authorized: res.GetAuthorized(),
}), nil
}
func shouldTryDomainAuth(msg *authv1.AuthnReq, username, password string) bool {
-76
View File
@@ -1,76 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package grpc
import (
"context"
"testing"
"connectrpc.com/connect"
authv1 "github.com/absmach/fluxmq/pkg/proto/auth/v1"
"github.com/absmach/magistrala/pkg/atom"
"github.com/absmach/magistrala/pkg/messaging"
"github.com/stretchr/testify/require"
)
type fakeTopicParser struct {
domainID string
channelID string
subtopic string
topicType messaging.TopicType
err error
}
func (p fakeTopicParser) ParsePublishTopic(context.Context, string, bool) (string, string, string, messaging.TopicType, error) {
return p.domainID, p.channelID, p.subtopic, p.topicType, p.err
}
func (p fakeTopicParser) ParseSubscribeTopic(context.Context, string, bool) (string, string, string, messaging.TopicType, error) {
return p.domainID, p.channelID, p.subtopic, p.topicType, p.err
}
type fakeAtomAuthorizer struct {
resp atom.AuthzResponse
err error
}
func (a fakeAtomAuthorizer) CheckAuthz(context.Context, atom.AuthzRequest) (atom.AuthzResponse, error) {
return a.resp, a.err
}
func TestAuthorizeReturnsAuthorizedOnlyWhenAllowed(t *testing.T) {
srv := NewServer(nil, nil, fakeTopicParser{
domainID: "26ad5c3f-cd91-4ff0-9685-0c3115643174",
channelID: "cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15",
subtopic: "messages",
topicType: messaging.MessageType,
}, fakeAtomAuthorizer{resp: atom.AuthzResponse{Allowed: true}}).(*connectServer)
res, err := srv.Authorize(context.Background(), connect.NewRequest(&authv1.AuthzReq{
ExternalId: "64d6bc95-b313-4412-9369-299543d9c63b",
Topic: "m/d1/c/ch1/messages",
Action: authv1.Action_Publish,
}))
require.NoError(t, err)
require.True(t, res.Msg.GetAuthorized())
require.Empty(t, res.Msg.ProtoReflect().GetUnknown())
}
func TestAuthorizeReturnsDeniedOnlyWhenDenied(t *testing.T) {
srv := NewServer(nil, nil, fakeTopicParser{
domainID: "26ad5c3f-cd91-4ff0-9685-0c3115643174",
channelID: "cdc8f55f-0c54-4a9f-b4aa-8c69d4a8ce15",
subtopic: "messages",
topicType: messaging.MessageType,
}, fakeAtomAuthorizer{resp: atom.AuthzResponse{Allowed: false}}).(*connectServer)
res, err := srv.Authorize(context.Background(), connect.NewRequest(&authv1.AuthzReq{
ExternalId: "64d6bc95-b313-4412-9369-299543d9c63b",
Topic: "m/d1/c/ch1/messages",
Action: authv1.Action_Subscribe,
}))
require.NoError(t, err)
require.False(t, res.Msg.GetAuthorized())
require.Empty(t, res.Msg.ProtoReflect().GetUnknown())
}
-136
View File
@@ -1,136 +0,0 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package http
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/absmach/magistrala/pkg/messaging"
)
const (
hookResultOK = "ok"
hookResultDeny = "deny"
hookAuthOnPublish = "auth_on_publish"
hookAuthOnSubscribe = "auth_on_subscribe"
hookAuthOnUnsubscribe = "auth_on_unsubscribe"
hookProtocolAMQP091 = "amqp091"
)
type hookRequest struct {
Hook string `json:"hook"`
ClientID string `json:"client_id"`
ExternalID string `json:"external_id"`
Protocol string `json:"protocol"`
Topic string `json:"topic"`
Payload []byte `json:"payload,omitempty"`
QoS uint32 `json:"qos"`
Retain bool `json:"retain"`
Properties map[string]string `json:"properties,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
type hookResponse struct {
Result string `json:"result"`
Topic string `json:"topic,omitempty"`
Payload []byte `json:"payload,omitempty"`
PayloadSet bool `json:"payload_set,omitempty"`
QoS uint32 `json:"qos,omitempty"`
QoSSet bool `json:"qos_set,omitempty"`
Retain bool `json:"retain,omitempty"`
RetainSet bool `json:"retain_set,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
ExternalID string `json:"external_id,omitempty"`
ReasonCode uint32 `json:"reason_code,omitempty"`
Reason string `json:"reason,omitempty"`
}
// MakeHooksHandler returns an HTTP handler for FluxMQ blocking hooks.
func MakeHooksHandler(parser messaging.TopicParser) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req hookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid hook request")
return
}
res := handleHook(r.Context(), parser, req)
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(res); err != nil {
return
}
})
}
func handleHook(ctx context.Context, parser messaging.TopicParser, req hookRequest) hookResponse {
topic, err := resolveHookTopic(ctx, parser, req)
if err != nil {
return hookResponse{Result: hookResultDeny, Reason: err.Error()}
}
return hookResponse{Result: hookResultOK, Topic: topic}
}
func resolveHookTopic(ctx context.Context, parser messaging.TopicParser, req hookRequest) (string, error) {
if isInternalMessageConsumer(req) {
return "", nil
}
if !isMessageTopic(req.Topic) {
return "", nil
}
if parser == nil {
return "", fmt.Errorf("topic parser is not configured")
}
var domainID, channelID, subtopic string
var topicType messaging.TopicType
var err error
switch strings.ToLower(strings.TrimSpace(req.Hook)) {
case hookAuthOnPublish:
domainID, channelID, subtopic, topicType, err = parser.ParsePublishTopic(ctx, req.Topic, true)
case hookAuthOnSubscribe, hookAuthOnUnsubscribe:
domainID, channelID, subtopic, topicType, err = parser.ParseSubscribeTopic(ctx, req.Topic, true)
default:
return "", nil
}
if err != nil {
return "", err
}
if topicType != messaging.MessageType {
return "", nil
}
return messaging.EncodeTopic(domainID, channelID, subtopic), nil
}
func isMessageTopic(topic string) bool {
topic = strings.TrimSpace(topic)
topic = strings.TrimPrefix(topic, "/")
return strings.HasPrefix(topic, string(messaging.MsgTopicPrefix)+"/")
}
func isInternalMessageConsumer(req hookRequest) bool {
hook := strings.ToLower(strings.TrimSpace(req.Hook))
if hook != hookAuthOnSubscribe && hook != hookAuthOnUnsubscribe {
return false
}
if strings.ToLower(strings.TrimSpace(req.Protocol)) != hookProtocolAMQP091 {
return false
}
topic := strings.TrimPrefix(strings.TrimSpace(req.Topic), "/")
return topic == string(messaging.MsgTopicPrefix)+"/#"
}

Some files were not shown because too many files have changed in this diff Show More