merakisync 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- merakisync/__about__.py +4 -0
- merakisync/__init__.py +60 -0
- merakisync/cli/__init__.py +0 -0
- merakisync/cli/cli.py +196 -0
- merakisync/cli/cmd_init.py +120 -0
- merakisync/cli/cmd_migrate.py +59 -0
- merakisync/cli/cmd_sync.py +167 -0
- merakisync/cli/cmd_update.py +60 -0
- merakisync/config.py +220 -0
- merakisync/dashboard.py +73 -0
- merakisync/database.py +84 -0
- merakisync/exceptions.py +21 -0
- merakisync/logging.py +48 -0
- merakisync/migrations/env.py +66 -0
- merakisync/migrations/script.py.mako +28 -0
- merakisync/migrations/versions/0001_initial_schema.py +321 -0
- merakisync/migrations/versions/0002_add_vlan.py +61 -0
- merakisync/migrations/versions/0003_reconcile_production_schema.py +257 -0
- merakisync/migrations/versions/0004_add_ssid.py +72 -0
- merakisync/migrations/versions/0005_drop_switchport_perdevice_fields.py +56 -0
- merakisync/migrations/versions/0006_drop_switchport_legacy_columns.py +59 -0
- merakisync/migrations/versions/0007_add_uplink_provider.py +29 -0
- merakisync/migrations/versions/0008_add_ssid_psk.py +31 -0
- merakisync/migrations/versions/0009_jsonb_tags_product_types.py +58 -0
- merakisync/models/__init__.py +25 -0
- merakisync/models/alert.py +194 -0
- merakisync/models/base.py +564 -0
- merakisync/models/device.py +226 -0
- merakisync/models/dhcp_server_policy.py +120 -0
- merakisync/models/l3_firewall_rule.py +160 -0
- merakisync/models/network.py +183 -0
- merakisync/models/organization.py +122 -0
- merakisync/models/ssid.py +164 -0
- merakisync/models/switchport.py +222 -0
- merakisync/models/uplink.py +227 -0
- merakisync/models/uplink_usage.py +268 -0
- merakisync/models/vlan.py +191 -0
- merakisync/utils/__init__.py +12 -0
- merakisync/utils/action_batch.py +136 -0
- merakisync/utils/casing.py +28 -0
- merakisync/utils/confirm.py +14 -0
- merakisync/utils/filter_array.py +13 -0
- merakisync/utils/prompt.py +38 -0
- merakisync-0.1.6.dist-info/METADATA +527 -0
- merakisync-0.1.6.dist-info/RECORD +48 -0
- merakisync-0.1.6.dist-info/WHEEL +4 -0
- merakisync-0.1.6.dist-info/entry_points.txt +2 -0
- merakisync-0.1.6.dist-info/licenses/LICENSE.txt +18 -0
merakisync/__about__.py
ADDED
merakisync/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026-present Nathan Anderson <nathanea05@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from merakisync.__about__ import __version__
|
|
6
|
+
|
|
7
|
+
# Public API surface — import only stable, non-circular symbols at package level.
|
|
8
|
+
# Infrastructure helpers
|
|
9
|
+
from merakisync.config import get_config
|
|
10
|
+
from merakisync.dashboard import get_dashboard
|
|
11
|
+
from merakisync.database import get_engine, get_session
|
|
12
|
+
|
|
13
|
+
# Models
|
|
14
|
+
from merakisync.models.organization import Organization
|
|
15
|
+
from merakisync.models.network import Network
|
|
16
|
+
from merakisync.models.device import Device
|
|
17
|
+
from merakisync.models.switchport import Switchport
|
|
18
|
+
from merakisync.models.uplink import Uplink
|
|
19
|
+
from merakisync.models.uplink_usage import UplinkUsage
|
|
20
|
+
from merakisync.models.dhcp_server_policy import DhcpServerPolicy
|
|
21
|
+
from merakisync.models.alert import Alert
|
|
22
|
+
from merakisync.models.l3_firewall_rule import L3FirewallRule
|
|
23
|
+
from merakisync.models.vlan import Vlan
|
|
24
|
+
from merakisync.models.ssid import Ssid
|
|
25
|
+
|
|
26
|
+
# Exceptions
|
|
27
|
+
from merakisync.exceptions import (
|
|
28
|
+
MissingConfigError,
|
|
29
|
+
ConfigWriteError,
|
|
30
|
+
MerakiConnectionError,
|
|
31
|
+
DatabaseConnectionError,
|
|
32
|
+
UpsertError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"__version__",
|
|
37
|
+
# Infrastructure
|
|
38
|
+
"get_config",
|
|
39
|
+
"get_dashboard",
|
|
40
|
+
"get_engine",
|
|
41
|
+
"get_session",
|
|
42
|
+
# Models
|
|
43
|
+
"Organization",
|
|
44
|
+
"Network",
|
|
45
|
+
"Device",
|
|
46
|
+
"Switchport",
|
|
47
|
+
"Uplink",
|
|
48
|
+
"UplinkUsage",
|
|
49
|
+
"DhcpServerPolicy",
|
|
50
|
+
"Alert",
|
|
51
|
+
"L3FirewallRule",
|
|
52
|
+
"Vlan",
|
|
53
|
+
"Ssid",
|
|
54
|
+
# Exceptions
|
|
55
|
+
"MissingConfigError",
|
|
56
|
+
"ConfigWriteError",
|
|
57
|
+
"MerakiConnectionError",
|
|
58
|
+
"DatabaseConnectionError",
|
|
59
|
+
"UpsertError",
|
|
60
|
+
]
|
|
File without changes
|
merakisync/cli/cli.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from merakisync.__about__ import __version__
|
|
7
|
+
from merakisync.cli.cmd_sync import SyncFlags
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _add_log_flags(p: argparse.ArgumentParser) -> None:
|
|
11
|
+
"""Add --verbose/--quiet to a subparser without overriding the parent value."""
|
|
12
|
+
g = p.add_mutually_exclusive_group()
|
|
13
|
+
g.add_argument(
|
|
14
|
+
"-v", "--verbose", action="store_true", default=argparse.SUPPRESS,
|
|
15
|
+
help="Enable debug logging.",
|
|
16
|
+
)
|
|
17
|
+
g.add_argument(
|
|
18
|
+
"-q", "--quiet", action="store_true", default=argparse.SUPPRESS,
|
|
19
|
+
help="Suppress all output below WARNING.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="merakisync",
|
|
26
|
+
description="Sync Meraki Dashboard data into PostgreSQL.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version", action="version", version=f"merakisync {__version__}",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Global log-level flags — also added to each subcommand below so they
|
|
34
|
+
# can appear either before or after the subcommand name.
|
|
35
|
+
log_group = parser.add_mutually_exclusive_group()
|
|
36
|
+
log_group.add_argument(
|
|
37
|
+
"-v", "--verbose", action="store_true", help="Enable debug logging."
|
|
38
|
+
)
|
|
39
|
+
log_group.add_argument(
|
|
40
|
+
"-q", "--quiet", action="store_true", help="Suppress all output below WARNING."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
44
|
+
|
|
45
|
+
# init
|
|
46
|
+
init_parser = subparsers.add_parser(
|
|
47
|
+
"init",
|
|
48
|
+
help="Configure the Meraki API key and/or database connection.",
|
|
49
|
+
)
|
|
50
|
+
init_parser.add_argument(
|
|
51
|
+
"--meraki", action="store_true", default=False,
|
|
52
|
+
help="Configure Meraki API key only (preserve existing database settings).",
|
|
53
|
+
)
|
|
54
|
+
init_parser.add_argument(
|
|
55
|
+
"--database", action="store_true", default=False,
|
|
56
|
+
help="Configure database connection only (preserve existing Meraki API key).",
|
|
57
|
+
)
|
|
58
|
+
_add_log_flags(init_parser)
|
|
59
|
+
|
|
60
|
+
# update
|
|
61
|
+
update_parser = subparsers.add_parser(
|
|
62
|
+
"update",
|
|
63
|
+
help="Download the latest binary and apply database migrations.",
|
|
64
|
+
)
|
|
65
|
+
_add_log_flags(update_parser)
|
|
66
|
+
|
|
67
|
+
# migrate
|
|
68
|
+
migrate_parser = subparsers.add_parser(
|
|
69
|
+
"migrate",
|
|
70
|
+
help="Apply database migrations (Alembic upgrade head).",
|
|
71
|
+
)
|
|
72
|
+
migrate_parser.add_argument(
|
|
73
|
+
"--revision",
|
|
74
|
+
default="head",
|
|
75
|
+
metavar="REV",
|
|
76
|
+
help="Alembic revision target (default: head).",
|
|
77
|
+
)
|
|
78
|
+
_add_log_flags(migrate_parser)
|
|
79
|
+
|
|
80
|
+
# sync
|
|
81
|
+
sync_parser = subparsers.add_parser(
|
|
82
|
+
"sync",
|
|
83
|
+
help="Sync Meraki data into the database.",
|
|
84
|
+
)
|
|
85
|
+
_add_log_flags(sync_parser)
|
|
86
|
+
sync_parser.add_argument(
|
|
87
|
+
"-o", "--organizations", action="store_true", help="Sync organizations."
|
|
88
|
+
)
|
|
89
|
+
sync_parser.add_argument(
|
|
90
|
+
"-n", "--networks", action="store_true", help="Sync networks."
|
|
91
|
+
)
|
|
92
|
+
sync_parser.add_argument(
|
|
93
|
+
"-d", "--devices", action="store_true", help="Sync devices."
|
|
94
|
+
)
|
|
95
|
+
sync_parser.add_argument(
|
|
96
|
+
"--switchports", action="store_true", help="Sync switch port configurations."
|
|
97
|
+
)
|
|
98
|
+
sync_parser.add_argument(
|
|
99
|
+
"--uplinks", action="store_true", help="Sync uplink statuses."
|
|
100
|
+
)
|
|
101
|
+
sync_parser.add_argument(
|
|
102
|
+
"--uplink-usage", action="store_true", dest="uplink_usage",
|
|
103
|
+
help="Sync uplink bandwidth usage (current month)."
|
|
104
|
+
)
|
|
105
|
+
sync_parser.add_argument(
|
|
106
|
+
"--dhcp-server-policy", action="store_true", dest="dhcp_server_policy",
|
|
107
|
+
help="Sync switch DHCP server policies."
|
|
108
|
+
)
|
|
109
|
+
sync_parser.add_argument(
|
|
110
|
+
"--alerts", action="store_true", help="Sync assurance alerts."
|
|
111
|
+
)
|
|
112
|
+
sync_parser.add_argument(
|
|
113
|
+
"--l3-firewall-rules", action="store_true", dest="l3_firewall_rules",
|
|
114
|
+
help="Sync MX L3 firewall rules."
|
|
115
|
+
)
|
|
116
|
+
sync_parser.add_argument(
|
|
117
|
+
"--vlans", action="store_true", help="Sync MX appliance VLANs."
|
|
118
|
+
)
|
|
119
|
+
sync_parser.add_argument(
|
|
120
|
+
"--ssids", action="store_true", help="Sync wireless SSIDs."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return parser
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
parser = _build_parser()
|
|
128
|
+
args = parser.parse_args()
|
|
129
|
+
|
|
130
|
+
# Configure logging before doing anything else
|
|
131
|
+
from merakisync.logging import configure_logging
|
|
132
|
+
configure_logging(verbose=getattr(args, "verbose", False),
|
|
133
|
+
quiet=getattr(args, "quiet", False))
|
|
134
|
+
|
|
135
|
+
if args.command is None:
|
|
136
|
+
parser.print_help()
|
|
137
|
+
sys.exit(0)
|
|
138
|
+
|
|
139
|
+
if args.command == "init":
|
|
140
|
+
from merakisync.cli.cmd_init import run
|
|
141
|
+
configure_meraki = args.meraki or not args.database
|
|
142
|
+
configure_database = args.database or not args.meraki
|
|
143
|
+
run(configure_meraki=configure_meraki, configure_database=configure_database)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if args.command == "update":
|
|
147
|
+
from merakisync.cli.cmd_update import run
|
|
148
|
+
run()
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if args.command == "migrate":
|
|
152
|
+
from merakisync.cli.cmd_migrate import run
|
|
153
|
+
run(revision=args.revision)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if args.command == "sync":
|
|
157
|
+
import logging
|
|
158
|
+
from merakisync.config import get_config
|
|
159
|
+
from merakisync.exceptions import MissingConfigError
|
|
160
|
+
log = logging.getLogger(__name__)
|
|
161
|
+
try:
|
|
162
|
+
conf = get_config()
|
|
163
|
+
except MissingConfigError as exc:
|
|
164
|
+
log.error("%s", exc)
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
if conf.meraki_api_key is None:
|
|
167
|
+
log.error(
|
|
168
|
+
"Meraki API key is not configured. Run `merakisync init --meraki`."
|
|
169
|
+
)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
if conf.db is None:
|
|
172
|
+
log.error(
|
|
173
|
+
"Database is not configured. Run `merakisync init --database`."
|
|
174
|
+
)
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
|
|
177
|
+
from merakisync.cli.cmd_sync import run
|
|
178
|
+
flags = SyncFlags(
|
|
179
|
+
organizations=args.organizations,
|
|
180
|
+
networks=args.networks,
|
|
181
|
+
devices=args.devices,
|
|
182
|
+
switchports=args.switchports,
|
|
183
|
+
uplinks=args.uplinks,
|
|
184
|
+
uplink_usage=args.uplink_usage,
|
|
185
|
+
dhcp_server_policy=args.dhcp_server_policy,
|
|
186
|
+
alerts=args.alerts,
|
|
187
|
+
l3_firewall_rules=args.l3_firewall_rules,
|
|
188
|
+
vlans=args.vlans,
|
|
189
|
+
ssids=args.ssids,
|
|
190
|
+
)
|
|
191
|
+
run(flags)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from merakisync.config import (
|
|
4
|
+
Configuration,
|
|
5
|
+
DbConfig,
|
|
6
|
+
get_config,
|
|
7
|
+
get_save_path,
|
|
8
|
+
prompt_api_key,
|
|
9
|
+
prompt_database,
|
|
10
|
+
write_config,
|
|
11
|
+
)
|
|
12
|
+
from merakisync.dashboard import validate_api_key
|
|
13
|
+
from merakisync.database import validate_connection
|
|
14
|
+
from merakisync.exceptions import (
|
|
15
|
+
ConfigWriteError,
|
|
16
|
+
DatabaseConnectionError,
|
|
17
|
+
MerakiConnectionError,
|
|
18
|
+
MissingConfigError,
|
|
19
|
+
)
|
|
20
|
+
from merakisync.utils.confirm import confirm
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(*, configure_meraki: bool = True, configure_database: bool = True) -> None:
|
|
24
|
+
"""Interactive `merakisync init` wizard.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
configure_meraki: Prompt for and save the Meraki API key.
|
|
28
|
+
configure_database: Prompt for and save the database connection.
|
|
29
|
+
"""
|
|
30
|
+
save_path = get_save_path()
|
|
31
|
+
|
|
32
|
+
# Load existing config to preserve whichever section won't be re-configured.
|
|
33
|
+
existing: Configuration | None = None
|
|
34
|
+
try:
|
|
35
|
+
existing = get_config()
|
|
36
|
+
config_exists = True
|
|
37
|
+
except MissingConfigError:
|
|
38
|
+
config_exists = False
|
|
39
|
+
|
|
40
|
+
if config_exists:
|
|
41
|
+
print(f"Found an existing configuration at {save_path}")
|
|
42
|
+
if not confirm("Continue anyway?", default=True):
|
|
43
|
+
return
|
|
44
|
+
print("")
|
|
45
|
+
|
|
46
|
+
api_key: str | None = existing.meraki_api_key if existing else None
|
|
47
|
+
db_config: DbConfig | None = existing.db if existing else None
|
|
48
|
+
database_validated = False
|
|
49
|
+
|
|
50
|
+
# --- Meraki API key --------------------------------------------------
|
|
51
|
+
if configure_meraki:
|
|
52
|
+
print("=== Meraki Configuration ===")
|
|
53
|
+
while True:
|
|
54
|
+
new_api_key = prompt_api_key()
|
|
55
|
+
print("Validating API key...")
|
|
56
|
+
try:
|
|
57
|
+
validate_api_key(new_api_key)
|
|
58
|
+
print("OK Meraki API key validated.")
|
|
59
|
+
api_key = new_api_key
|
|
60
|
+
break
|
|
61
|
+
except MerakiConnectionError as exc:
|
|
62
|
+
print(f"FAIL {exc}")
|
|
63
|
+
print("")
|
|
64
|
+
|
|
65
|
+
# --- Database --------------------------------------------------------
|
|
66
|
+
if configure_database:
|
|
67
|
+
print("=== Database Configuration ===")
|
|
68
|
+
while True:
|
|
69
|
+
new_db_config = prompt_database()
|
|
70
|
+
print("Validating database connection...")
|
|
71
|
+
try:
|
|
72
|
+
validate_connection(new_db_config.get_dsn())
|
|
73
|
+
print("OK Database connection successful.")
|
|
74
|
+
db_config = new_db_config
|
|
75
|
+
database_validated = True
|
|
76
|
+
break
|
|
77
|
+
except DatabaseConnectionError:
|
|
78
|
+
print(f"FAIL Unable to connect to Postgres at {new_db_config.host}:{new_db_config.port}")
|
|
79
|
+
print(" Is the server running and accepting TCP connections?")
|
|
80
|
+
if confirm("Continue with these settings despite failed validation?", default=False):
|
|
81
|
+
print("WARN Configuration will be saved without successful validation.")
|
|
82
|
+
db_config = new_db_config
|
|
83
|
+
break
|
|
84
|
+
print("")
|
|
85
|
+
print("=== Re-enter Database Configuration ===")
|
|
86
|
+
print("")
|
|
87
|
+
|
|
88
|
+
# --- Save ------------------------------------------------------------
|
|
89
|
+
conf = Configuration.from_parts(api_key=api_key, db=db_config)
|
|
90
|
+
print("Configuration complete.")
|
|
91
|
+
print(f"Settings will be saved to: {save_path}")
|
|
92
|
+
config_saved = False
|
|
93
|
+
if confirm("Save configuration?", default=True):
|
|
94
|
+
try:
|
|
95
|
+
write_config(path=save_path, conf=conf)
|
|
96
|
+
print(f"OK Config saved to {save_path}")
|
|
97
|
+
config_saved = True
|
|
98
|
+
except ConfigWriteError as exc:
|
|
99
|
+
print(f"FAIL {exc}")
|
|
100
|
+
print("")
|
|
101
|
+
|
|
102
|
+
# --- Migrations ------------------------------------------------------
|
|
103
|
+
if config_saved and configure_database and database_validated:
|
|
104
|
+
print("=== Database Migrations ===")
|
|
105
|
+
if confirm("Apply database migrations now?", default=True):
|
|
106
|
+
_run_migrations()
|
|
107
|
+
else:
|
|
108
|
+
print("WARN Schema not applied. Run `merakisync migrate` before syncing.")
|
|
109
|
+
print("")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_migrations() -> None:
|
|
113
|
+
"""Invoke Alembic migrations programmatically."""
|
|
114
|
+
try:
|
|
115
|
+
from merakisync.cli.cmd_migrate import run as migrate_run
|
|
116
|
+
migrate_run()
|
|
117
|
+
print("OK Migrations applied.")
|
|
118
|
+
except SystemExit:
|
|
119
|
+
print("FAIL Migration failed.")
|
|
120
|
+
print(" Run `merakisync migrate` manually to retry.")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _check_db_configured() -> None:
|
|
10
|
+
from merakisync.config import get_config
|
|
11
|
+
from merakisync.exceptions import MissingConfigError
|
|
12
|
+
conf = get_config()
|
|
13
|
+
if conf.db is None:
|
|
14
|
+
raise MissingConfigError(
|
|
15
|
+
"Database is not configured. Run `merakisync init --database`."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(revision: str = "head") -> None:
|
|
20
|
+
"""Run Alembic migrations up to *revision* (default: head).
|
|
21
|
+
|
|
22
|
+
Reads the DSN from the merakisync configuration so no separate
|
|
23
|
+
database URL needs to be passed on the command line.
|
|
24
|
+
|
|
25
|
+
Works in three environments:
|
|
26
|
+
- Development (src layout): importlib.resources resolves to src/
|
|
27
|
+
- Pip install: importlib.resources resolves to site-packages/
|
|
28
|
+
- PyInstaller frozen binary: data files are extracted to sys._MEIPASS
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
from merakisync.exceptions import MissingConfigError
|
|
32
|
+
_check_db_configured()
|
|
33
|
+
except MissingConfigError as exc:
|
|
34
|
+
logger.error("%s", exc)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import pathlib
|
|
39
|
+
from alembic import command
|
|
40
|
+
from alembic.config import Config as AlembicConfig
|
|
41
|
+
|
|
42
|
+
if getattr(sys, "frozen", False):
|
|
43
|
+
# PyInstaller one-file binary: migrations/ is extracted alongside
|
|
44
|
+
# the merakisync package under sys._MEIPASS at startup.
|
|
45
|
+
migrations_dir = str(pathlib.Path(sys._MEIPASS) / "merakisync" / "migrations")
|
|
46
|
+
else:
|
|
47
|
+
import importlib.resources as pkg_resources
|
|
48
|
+
import merakisync
|
|
49
|
+
migrations_dir = str(pkg_resources.files(merakisync) / "migrations")
|
|
50
|
+
|
|
51
|
+
# Use a bare AlembicConfig (no ini file) and set every required option
|
|
52
|
+
# programmatically. This avoids any need to locate or bundle alembic.ini.
|
|
53
|
+
alembic_cfg = AlembicConfig()
|
|
54
|
+
alembic_cfg.set_main_option("script_location", migrations_dir)
|
|
55
|
+
command.upgrade(alembic_cfg, revision)
|
|
56
|
+
logger.info("Migrations applied to revision: %s", revision)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.error("Migration failed: %s", exc)
|
|
59
|
+
sys.exit(1)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SyncFlags:
|
|
11
|
+
"""Which resource types to sync. All False means sync everything."""
|
|
12
|
+
|
|
13
|
+
organizations: bool = False
|
|
14
|
+
networks: bool = False
|
|
15
|
+
devices: bool = False
|
|
16
|
+
switchports: bool = False
|
|
17
|
+
uplinks: bool = False
|
|
18
|
+
uplink_usage: bool = False
|
|
19
|
+
dhcp_server_policy: bool = False
|
|
20
|
+
alerts: bool = False
|
|
21
|
+
l3_firewall_rules: bool = False
|
|
22
|
+
vlans: bool = False
|
|
23
|
+
ssids: bool = False
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def sync_all(self) -> bool:
|
|
27
|
+
return not any(vars(self).values())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(flags: SyncFlags | None = None) -> None:
|
|
31
|
+
"""Orchestrate a full or selective sync.
|
|
32
|
+
|
|
33
|
+
Sync order respects dependencies:
|
|
34
|
+
Organizations → Networks → Devices → per-network/per-device resources
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
flags: Controls which resource types are synced. Pass None or an
|
|
38
|
+
all-False SyncFlags to sync everything.
|
|
39
|
+
"""
|
|
40
|
+
if flags is None:
|
|
41
|
+
flags = SyncFlags()
|
|
42
|
+
|
|
43
|
+
from merakisync.models.organization import Organization
|
|
44
|
+
from merakisync.models.network import Network
|
|
45
|
+
from merakisync.models.device import Device
|
|
46
|
+
from merakisync.models.switchport import Switchport
|
|
47
|
+
from merakisync.models.uplink import Uplink
|
|
48
|
+
from merakisync.models.uplink_usage import UplinkUsage
|
|
49
|
+
from merakisync.models.dhcp_server_policy import DhcpServerPolicy
|
|
50
|
+
from merakisync.models.alert import Alert
|
|
51
|
+
from merakisync.models.l3_firewall_rule import L3FirewallRule
|
|
52
|
+
from merakisync.models.vlan import Vlan
|
|
53
|
+
from merakisync.models.ssid import Ssid
|
|
54
|
+
|
|
55
|
+
do_all = flags.sync_all
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Organizations
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
if do_all or flags.organizations:
|
|
61
|
+
logger.info("Syncing organizations...")
|
|
62
|
+
orgs = Organization.sync()
|
|
63
|
+
else:
|
|
64
|
+
# We always need the org list to drive child syncs
|
|
65
|
+
logger.debug("Fetching organizations (not syncing to DB)...")
|
|
66
|
+
orgs = Organization.get(source="meraki")
|
|
67
|
+
|
|
68
|
+
if not orgs:
|
|
69
|
+
logger.error("No organizations found. Aborting sync.")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
logger.info("Found %d organization(s).", len(orgs))
|
|
73
|
+
|
|
74
|
+
for org in orgs:
|
|
75
|
+
org_id = org.id
|
|
76
|
+
logger.info("Processing org: %s (%s)", org.name, org_id)
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------
|
|
79
|
+
# Alerts (org-level, no network dependency)
|
|
80
|
+
# --------------------------------------------------------------
|
|
81
|
+
if do_all or flags.alerts:
|
|
82
|
+
logger.info(" Syncing alerts for org %s (%s)...", org_id, org.name)
|
|
83
|
+
Alert.sync(org_id)
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------
|
|
86
|
+
# Uplinks (org-level)
|
|
87
|
+
# --------------------------------------------------------------
|
|
88
|
+
if do_all or flags.uplinks:
|
|
89
|
+
logger.info(" Syncing uplinks for org %s (%s)...", org_id, org.name)
|
|
90
|
+
Uplink.sync(org_id)
|
|
91
|
+
|
|
92
|
+
# --------------------------------------------------------------
|
|
93
|
+
# Uplink usage (org-level)
|
|
94
|
+
# --------------------------------------------------------------
|
|
95
|
+
if do_all or flags.uplink_usage:
|
|
96
|
+
logger.info(" Syncing uplink usage for org %s (%s)...", org_id, org.name)
|
|
97
|
+
UplinkUsage.sync(org_id)
|
|
98
|
+
|
|
99
|
+
# --------------------------------------------------------------
|
|
100
|
+
# Devices (org-level)
|
|
101
|
+
# --------------------------------------------------------------
|
|
102
|
+
if do_all or flags.devices:
|
|
103
|
+
logger.info(" Syncing devices for org %s (%s)...", org_id, org.name)
|
|
104
|
+
Device.sync(org_id)
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------
|
|
107
|
+
# Switchports (org-level, no network dependency)
|
|
108
|
+
# --------------------------------------------------------------
|
|
109
|
+
if do_all or flags.switchports:
|
|
110
|
+
logger.info(" Syncing switchports for org %s (%s)...", org_id, org.name)
|
|
111
|
+
Switchport.sync(org_id)
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------
|
|
114
|
+
# Networks → per-network resources
|
|
115
|
+
# --------------------------------------------------------------
|
|
116
|
+
need_networks = do_all or flags.networks or flags.dhcp_server_policy \
|
|
117
|
+
or flags.l3_firewall_rules or flags.vlans or flags.ssids
|
|
118
|
+
|
|
119
|
+
if not need_networks:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if do_all or flags.networks:
|
|
123
|
+
logger.info(" Syncing networks for org %s (%s)...", org_id, org.name)
|
|
124
|
+
networks = Network.sync(org_id)
|
|
125
|
+
else:
|
|
126
|
+
networks = Network.get(org_id, source="meraki")
|
|
127
|
+
|
|
128
|
+
if not networks:
|
|
129
|
+
logger.warning(" No networks found for org %s (%s).", org_id, org.name)
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
logger.info(" Found %d network(s) in org %s (%s).", len(networks), org_id, org.name)
|
|
133
|
+
|
|
134
|
+
for network in networks:
|
|
135
|
+
net_id = network.id
|
|
136
|
+
net_name = network.name
|
|
137
|
+
product_types = set(network.product_types or [])
|
|
138
|
+
|
|
139
|
+
# ----------------------------------------------------------
|
|
140
|
+
# DHCP server policy (switch networks only)
|
|
141
|
+
# ----------------------------------------------------------
|
|
142
|
+
if (do_all or flags.dhcp_server_policy) and "switch" in product_types:
|
|
143
|
+
logger.debug(" Syncing DHCP server policy for network %s (%s)...", net_id, net_name)
|
|
144
|
+
DhcpServerPolicy.sync(net_id)
|
|
145
|
+
|
|
146
|
+
# ----------------------------------------------------------
|
|
147
|
+
# L3 firewall rules (appliance networks only)
|
|
148
|
+
# ----------------------------------------------------------
|
|
149
|
+
if (do_all or flags.l3_firewall_rules) and "appliance" in product_types:
|
|
150
|
+
logger.debug(" Syncing L3 firewall rules for network %s (%s)...", net_id, net_name)
|
|
151
|
+
L3FirewallRule.sync(net_id)
|
|
152
|
+
|
|
153
|
+
# ----------------------------------------------------------
|
|
154
|
+
# VLANs (appliance networks only)
|
|
155
|
+
# ----------------------------------------------------------
|
|
156
|
+
if (do_all or flags.vlans) and "appliance" in product_types:
|
|
157
|
+
logger.info(" Syncing VLANs for network %s (%s)...", net_id, net_name)
|
|
158
|
+
Vlan.sync(net_id)
|
|
159
|
+
|
|
160
|
+
# ----------------------------------------------------------
|
|
161
|
+
# SSIDs (wireless networks only)
|
|
162
|
+
# ----------------------------------------------------------
|
|
163
|
+
if (do_all or flags.ssids) and "wireless" in product_types:
|
|
164
|
+
logger.debug(" Syncing SSIDs for network %s (%s)...", net_id, net_name)
|
|
165
|
+
Ssid.sync(net_id)
|
|
166
|
+
|
|
167
|
+
logger.info("Sync complete.")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_INSTALL_SCRIPT_URL = (
|
|
13
|
+
"https://raw.githubusercontent.com/nathanea05/merakisync/main/install.sh"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run() -> None:
|
|
18
|
+
if not getattr(sys, "frozen", False):
|
|
19
|
+
logger.error(
|
|
20
|
+
"`merakisync update` is only supported for binary installs. "
|
|
21
|
+
"To update a pip-installed library run:\n"
|
|
22
|
+
" pip install --upgrade merakisync\n"
|
|
23
|
+
" merakisync migrate"
|
|
24
|
+
)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
binary_path = sys.executable
|
|
28
|
+
install_dir = os.path.dirname(os.path.abspath(binary_path))
|
|
29
|
+
|
|
30
|
+
logger.info("Downloading install script...")
|
|
31
|
+
try:
|
|
32
|
+
with urllib.request.urlopen(_INSTALL_SCRIPT_URL) as resp:
|
|
33
|
+
script_bytes = resp.read()
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
logger.error(
|
|
36
|
+
"Failed to download install script: %s\n"
|
|
37
|
+
"Run the update manually:\n"
|
|
38
|
+
" curl -LsSf %s | sh\n"
|
|
39
|
+
" merakisync migrate",
|
|
40
|
+
exc,
|
|
41
|
+
_INSTALL_SCRIPT_URL,
|
|
42
|
+
)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sh")
|
|
46
|
+
try:
|
|
47
|
+
os.write(tmp_fd, script_bytes)
|
|
48
|
+
os.close(tmp_fd)
|
|
49
|
+
|
|
50
|
+
result = subprocess.run(["sh", tmp_path, "--install-dir", install_dir])
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
logger.error("Binary update failed (exit code %d).", result.returncode)
|
|
53
|
+
sys.exit(result.returncode)
|
|
54
|
+
finally:
|
|
55
|
+
os.unlink(tmp_path)
|
|
56
|
+
|
|
57
|
+
logger.info("Applying database migrations...")
|
|
58
|
+
migrate = subprocess.run([binary_path, "migrate"])
|
|
59
|
+
if migrate.returncode != 0:
|
|
60
|
+
sys.exit(migrate.returncode)
|