Files
magistrala/tools/atom-migration/preflight.go
T
dusan c405028fc5
Continuous Delivery / lint-and-build (push) Has been cancelled
Continuous Delivery / Build and Push Docker Images (push) Has been cancelled
CI Pipeline / Lint Proto (push) Has been cancelled
CI Pipeline / lint-and-build (push) Has been cancelled
CI Pipeline / Detect Changes (push) Has been cancelled
CI Pipeline / Test ${{ matrix.module }} (push) Has been cancelled
CI Pipeline / Upload Coverage (push) Has been cancelled
Property Based Tests / api-test (push) Has been cancelled
Deploy GitHub Pages / swagger-ui (push) Has been cancelled
NOISSUE - Revert alarms approach
Signed-off-by: dusan <borovcanindusan1@gmail.com>
2026-06-29 19:16:16 +02:00

308 lines
8.6 KiB
Go

// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"fmt"
"strings"
)
// preflight runs read-only data-quality checks before any write. Blocking issues
// (rep.block) abort an --apply run; warnings (rep.warn) are advisory. See PLAN §6.
func (m *migrator) preflight(ctx context.Context, rep *report) error {
checks := []func(context.Context, *report) error{
m.pfEmails,
m.pfHumanNames,
m.pfTenantNames,
m.pfTenantAlias,
m.pfEntityResourceAlias,
m.pfClientNames,
m.pfGroupNames,
m.pfOrphans,
}
for _, c := range checks {
if err := c(ctx, rep); err != nil {
return err
}
}
return nil
}
// dupGroups returns the keys that appear more than once (with their count).
func dupGroups(keyOf func() []string) map[string]int {
seen := map[string]int{}
for _, k := range keyOf() {
if k != "" {
seen[k]++
}
}
for k, n := range seen {
if n < 2 {
delete(seen, k)
}
}
return seen
}
// pfEmails: Atom entity_emails.email is globally UNIQUE. Magistrala enforces this
// in the users table, but a dump merged across instances can break it.
func (m *migrator) pfEmails(ctx context.Context, rep *report) error {
users, err := readUsers(ctx, m.usersDB)
if err != nil {
return err
}
dups := dupGroups(func() []string {
out := make([]string, 0, len(users))
for _, u := range users {
if u.Email.Valid {
out = append(out, strings.ToLower(strings.TrimSpace(u.Email.String)))
}
}
return out
})
for email, n := range dups {
rep.blockf("email %q used by %d users (entity_emails.email is UNIQUE)", email, n)
}
return nil
}
// pfHumanNames: entities(name, tenant_id) is UNIQUE; humans have tenant_id NULL so
// they share one global name namespace. name = first of username/email/id.
func (m *migrator) pfHumanNames(ctx context.Context, rep *report) error {
users, err := readUsers(ctx, m.usersDB)
if err != nil {
return err
}
dups := dupGroups(func() []string {
out := make([]string, 0, len(users))
for _, u := range users {
out = append(out, firstNonEmpty(u.Username.String, u.Email.String, u.ID))
}
return out
})
for name, n := range dups {
// entities(name, tenant_id) is NULLS DISTINCT and humans have tenant_id
// NULL, so duplicate human names do NOT violate the index — advisory only.
rep.warnf("human entity name %q used by %d users (allowed; tenant NULL is NULLS DISTINCT)", name, n)
}
return nil
}
// pfTenantNames: tenants.name is UNIQUE and NOT NULL. Magistrala domain.name is
// nullable and non-unique, so empty or duplicate names break the load.
func (m *migrator) pfTenantNames(ctx context.Context, rep *report) error {
doms, err := readDomains(ctx, m.domainsDB)
if err != nil {
return err
}
names := make([]string, 0, len(doms))
for _, d := range doms {
if !d.Name.Valid || strings.TrimSpace(d.Name.String) == "" {
rep.warnf("domain %s has empty name -> will use its id (tenants.name is NOT NULL UNIQUE)", d.ID)
continue
}
names = append(names, d.Name.String)
}
for name, n := range dupGroups(func() []string { return names }) {
rep.warnf("domain name %q used by %d domains -> duplicates auto-renamed (tenants.name is UNIQUE)", name, n)
}
return nil
}
// pfGroupNames: object_groups(name, tenant_id) is UNIQUE. Magistrala dropped the
// groups (domain_id, name) constraint, so same-domain dups are possible.
func (m *migrator) pfGroupNames(ctx context.Context, rep *report) error {
grps, err := readGroups(ctx, m.groupsDB)
if err != nil {
return err
}
keys := make([]string, 0, len(grps))
for _, g := range grps {
keys = append(keys, g.DomainID+"|"+g.Name)
}
for k, n := range dupGroups(func() []string { return keys }) {
rep.warnf("group name collision (%s) across %d groups in one tenant -> duplicates auto-renamed", k, n)
}
return nil
}
// pfTenantAlias: domain.route -> tenants.alias. Globally unique, case-folded,
// slug-shaped, not UUID-shaped. Invalid shape => alias dropped (warn). Case-fold
// collision among otherwise-valid aliases => block.
func (m *migrator) pfTenantAlias(ctx context.Context, rep *report) error {
doms, err := readDomains(ctx, m.domainsDB)
if err != nil {
return err
}
valid := []string{}
for _, d := range doms {
if !d.Route.Valid || d.Route.String == "" {
continue
}
a, ok := normalizeAlias(d.Route.String)
if !ok {
rep.warnf("tenant %s alias %q invalid slug -> dropped", d.ID, d.Route.String)
continue
}
valid = append(valid, a)
}
for a, n := range dupGroups(func() []string { return valid }) {
rep.warnf("tenant alias %q collides case-insensitively across %d domains -> duplicates auto-suffixed", a, n)
}
return nil
}
// pfEntityResourceAlias: client.identity / channel.route are unique per tenant
// (case-folded). Collision within a domain => block (would violate Atom's unique
// index mid-apply). Invalid shape => warn (dropped).
func (m *migrator) pfEntityResourceAlias(ctx context.Context, rep *report) error {
clients, err := readClients(ctx, m.clientsDB)
if err != nil {
return err
}
chans, err := readChannels(ctx, m.channelsDB)
if err != nil {
return err
}
// key = domain|alias
cKeys := []string{}
for _, c := range clients {
if !c.Identity.Valid || c.Identity.String == "" {
continue
}
a, ok := normalizeAlias(c.Identity.String)
if !ok {
rep.warnf("client %s alias %q invalid slug -> dropped", c.ID, c.Identity.String)
continue
}
cKeys = append(cKeys, c.DomainID+"|"+a)
}
for k, n := range dupGroups(func() []string { return cKeys }) {
rep.warnf("device alias collision (%s) across %d clients in one tenant -> duplicates auto-suffixed", k, n)
}
rKeys := []string{}
for _, ch := range chans {
if !ch.Route.Valid || ch.Route.String == "" {
continue
}
a, ok := normalizeAlias(ch.Route.String)
if !ok {
rep.warnf("channel %s alias %q invalid slug -> dropped", ch.ID, ch.Route.String)
continue
}
rKeys = append(rKeys, ch.DomainID+"|"+a)
}
for k, n := range dupGroups(func() []string { return rKeys }) {
rep.warnf("channel alias collision (%s) across %d channels in one tenant -> duplicates auto-suffixed", k, n)
}
return nil
}
// pfClientNames: device entities are unique on (name, tenant_id). Magistrala
// dropped the (domain_id, name) unique constraint, so same-domain name dups are
// possible and would break the Atom insert.
func (m *migrator) pfClientNames(ctx context.Context, rep *report) error {
clients, err := readClients(ctx, m.clientsDB)
if err != nil {
return err
}
keys := []string{}
for _, c := range clients {
keys = append(keys, c.DomainID+"|"+firstNonEmpty(c.Name.String, c.ID))
}
for k, n := range dupGroups(func() []string { return keys }) {
rep.warnf("device name collision (%s) across %d clients in one tenant -> duplicates auto-renamed", k, n)
}
return nil
}
// pfOrphans: clients/channels/groups whose domain_id has no surviving domain are
// skipped during load. Advisory only.
func (m *migrator) pfOrphans(ctx context.Context, rep *report) error {
doms, err := readDomains(ctx, m.domainsDB)
if err != nil {
return err
}
domSet := map[string]bool{}
for _, d := range doms {
domSet[d.ID] = true
}
count := func(get func() []string, label string) {
n := 0
for _, id := range get() {
if !domSet[id] {
n++
}
}
if n > 0 {
rep.warnf("%d %s reference a missing domain -> will be skipped", n, label)
}
}
clients, err := readClients(ctx, m.clientsDB)
if err != nil {
return err
}
count(func() []string {
out := make([]string, len(clients))
for i, c := range clients {
out[i] = c.DomainID
}
return out
}, "clients")
chans, err := readChannels(ctx, m.channelsDB)
if err != nil {
return err
}
count(func() []string {
out := make([]string, len(chans))
for i, c := range chans {
out[i] = c.DomainID
}
return out
}, "channels")
grps, err := readGroups(ctx, m.groupsDB)
if err != nil {
return err
}
count(func() []string {
out := make([]string, len(grps))
for i, g := range grps {
out[i] = g.DomainID
}
return out
}, "groups")
rules, err := readRules(ctx, m.reDB)
if err != nil {
return err
}
count(func() []string {
out := make([]string, len(rules))
for i, r := range rules {
out[i] = r.DomainID
}
return out
}, "rules")
reports, err := readReports(ctx, m.reportsDB)
if err != nil {
return err
}
count(func() []string {
out := make([]string, len(reports))
for i, r := range reports {
out[i] = r.DomainID
}
return out
}, "reports")
return nil
}
// preflightGate aborts an --apply run when blocking issues exist.
func (m *migrator) preflightGate(rep *report) error {
if m.apply && rep.HasBlocking() {
return fmt.Errorf("preflight found %d blocking issue(s); aborting apply (see report)", len(rep.Blocking))
}
return nil
}