acex-driver-juniper-junoscli 1.0.0__tar.gz
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.
- acex_driver_juniper_junoscli-1.0.0/PKG-INFO +16 -0
- acex_driver_juniper_junoscli-1.0.0/pyproject.toml +29 -0
- acex_driver_juniper_junoscli-1.0.0/src/acex_driver_juniper_junos_cli/__init__.py +0 -0
- acex_driver_juniper_junoscli-1.0.0/src/acex_driver_juniper_junos_cli/augment_renderers.py +67 -0
- acex_driver_juniper_junoscli-1.0.0/src/acex_driver_juniper_junos_cli/junos_cli.py +139 -0
- acex_driver_juniper_junoscli-1.0.0/src/acex_driver_juniper_junos_cli/parser.py +17 -0
- acex_driver_juniper_junoscli-1.0.0/src/acex_driver_juniper_junos_cli/renderer.py +191 -0
- acex_driver_juniper_junoscli-1.0.0/src/acex_driver_juniper_junos_cli/template.j2 +176 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acex-driver-juniper-junoscli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: ACE-X Driver: Juniper Junos CLI
|
|
5
|
+
License: AGPL-3.0
|
|
6
|
+
Author: Johan Lahti
|
|
7
|
+
Author-email: johan.lahti@acebit.se
|
|
8
|
+
Requires-Python: >=3.13,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: acex-devkit (>=1.14.0,<2.0.0)
|
|
14
|
+
Requires-Dist: netmiko (>=4.6.0,<5.0.0)
|
|
15
|
+
Project-URL: Homepage, https://github.com/acex-labs/acex
|
|
16
|
+
Project-URL: Repository, https://github.com/acex-labs/acex
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "acex-driver-juniper-junoscli"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "ACE-X Driver: Juniper Junos CLI"
|
|
5
|
+
authors = ["Johan Lahti <johan.lahti@acebit.se>"]
|
|
6
|
+
license = "AGPL-3.0"
|
|
7
|
+
homepage = "https://github.com/acex-labs/acex"
|
|
8
|
+
repository = "https://github.com/acex-labs/acex"
|
|
9
|
+
|
|
10
|
+
packages = [
|
|
11
|
+
{ include = "acex_driver_juniper_junos_cli", from = "src" }
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.poetry.dependencies]
|
|
15
|
+
python = "^3.13"
|
|
16
|
+
netmiko = "^4.6.0"
|
|
17
|
+
acex-devkit = "^1.14.0"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
22
|
+
|
|
23
|
+
[tool.poetry.plugins."acex.neds"]
|
|
24
|
+
juniper_junos_cli = "acex_driver_juniper_junos_cli.junos_cli:JunosCLI"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = [
|
|
28
|
+
"ipython (>=9.10.0,<10.0.0)"
|
|
29
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-type rendering of `Augment`-class components into Junos CLI lines.
|
|
3
|
+
|
|
4
|
+
Mirrors the Cisco driver's dispatch pattern. Augments unknown to this
|
|
5
|
+
driver (no renderer registered for that type) are silently skipped.
|
|
6
|
+
|
|
7
|
+
Junos-set commands are flat (no indented blocks like Cisco), so renderer
|
|
8
|
+
functions emit fully-formed `set ...` lines. They receive `target_path`
|
|
9
|
+
so they can extract context (e.g. the SNMP community name) from it.
|
|
10
|
+
|
|
11
|
+
To add a new Juniper augment type:
|
|
12
|
+
1. Define the augment component + payload model in the backend
|
|
13
|
+
2. Register a renderer here under its `type` key
|
|
14
|
+
"""
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from typing import Callable, Dict, List
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _render_snmp_community_clients(aug: dict, target_path: str) -> List[str]:
|
|
20
|
+
"""`set snmp community <name> clients <prefix>` — one line per prefix."""
|
|
21
|
+
community_name = target_path.rsplit(".", 1)[-1]
|
|
22
|
+
clients = (aug.get("clients") or {}).get("value") or []
|
|
23
|
+
return [
|
|
24
|
+
f"set snmp community {community_name} clients {prefix}"
|
|
25
|
+
for prefix in clients
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
AUGMENT_RENDERERS: Dict[str, Callable[[dict, str], List[str]]] = {
|
|
30
|
+
"juniper.snmp_community_clients": _render_snmp_community_clients,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_augment_lines(configuration: dict) -> Dict[str, List[str]]:
|
|
35
|
+
"""
|
|
36
|
+
Walk every targetable tree node, collect its `augments` dict, dispatch each
|
|
37
|
+
augment to its renderer, and return a {target_path: [cli_line, ...]} dict
|
|
38
|
+
for use in Jinja.
|
|
39
|
+
|
|
40
|
+
Augments unknown to this driver (no renderer registered for that type)
|
|
41
|
+
are silently skipped — that's the point of augment-on-target dispatch.
|
|
42
|
+
"""
|
|
43
|
+
by_target: Dict[str, List[str]] = defaultdict(list)
|
|
44
|
+
|
|
45
|
+
def _collect(target_path: str, augments: dict):
|
|
46
|
+
for aug_type, aug in (augments or {}).items():
|
|
47
|
+
renderer = AUGMENT_RENDERERS.get(aug_type)
|
|
48
|
+
if renderer is None:
|
|
49
|
+
continue
|
|
50
|
+
by_target[target_path].extend(renderer(aug, target_path))
|
|
51
|
+
|
|
52
|
+
for name, intf in (configuration.get("interfaces") or {}).items():
|
|
53
|
+
_collect(f"interfaces.{name}", intf.get("augments"))
|
|
54
|
+
|
|
55
|
+
for name, tmpl in (configuration.get("interface_templates") or {}).items():
|
|
56
|
+
_collect(f"interface_templates.{name}", tmpl.get("augments"))
|
|
57
|
+
|
|
58
|
+
# SNMP communities — Junos has per-community augments (e.g. clients list)
|
|
59
|
+
for name, com in (((configuration.get("system") or {}).get("snmp") or {}).get("communities") or {}).items():
|
|
60
|
+
_collect(f"system.snmp.communities.{name}", com.get("augments"))
|
|
61
|
+
|
|
62
|
+
# Singletons under system.* — extend here as more Augmentable targets land.
|
|
63
|
+
vtp_cfg = ((configuration.get("system") or {}).get("vtp") or {}).get("config")
|
|
64
|
+
if vtp_cfg:
|
|
65
|
+
_collect("system.vtp.config", vtp_cfg.get("augments"))
|
|
66
|
+
|
|
67
|
+
return dict(by_target)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from acex_devkit.models.composed_configuration import ComposedConfiguration
|
|
5
|
+
from acex_devkit.models.node_response import NodeListItem
|
|
6
|
+
from acex_devkit.models.management_connection import ManagementConnection
|
|
7
|
+
from netmiko import ConnectHandler
|
|
8
|
+
|
|
9
|
+
from acex_devkit.drivers import NetworkElementDriver, TransportBase
|
|
10
|
+
from acex_devkit.configdiffer import Diff
|
|
11
|
+
|
|
12
|
+
from .renderer import JunosCLIRenderer
|
|
13
|
+
from .parser import JunosCLIParser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JunosCLITransport(TransportBase):
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._session_conn: Optional[ConnectHandler] = None
|
|
20
|
+
|
|
21
|
+
def _open_connection(self, connection: ManagementConnection, **kwargs) -> ConnectHandler:
|
|
22
|
+
username = kwargs.get("username")
|
|
23
|
+
password = kwargs.get("password")
|
|
24
|
+
if not username or not password:
|
|
25
|
+
raise ValueError("Credentials required: username and password must be provided")
|
|
26
|
+
device = {
|
|
27
|
+
"device_type": "juniper_junos",
|
|
28
|
+
"host": connection.target_ip,
|
|
29
|
+
"username": username,
|
|
30
|
+
"password": password,
|
|
31
|
+
"port": 22,
|
|
32
|
+
"conn_timeout": 30,
|
|
33
|
+
}
|
|
34
|
+
return ConnectHandler(**device)
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def session(self, connection: ManagementConnection, **kwargs):
|
|
38
|
+
"""Hold one SSH session open for the duration of the block."""
|
|
39
|
+
conn = self._open_connection(connection, **kwargs)
|
|
40
|
+
self._session_conn = conn
|
|
41
|
+
try:
|
|
42
|
+
yield self
|
|
43
|
+
finally:
|
|
44
|
+
self._session_conn = None
|
|
45
|
+
try:
|
|
46
|
+
conn.disconnect()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def _conn(self, connection: ManagementConnection, **kwargs):
|
|
52
|
+
"""Yield active session conn or open a one-shot, closing what we own."""
|
|
53
|
+
if self._session_conn is not None:
|
|
54
|
+
yield self._session_conn
|
|
55
|
+
return
|
|
56
|
+
conn = self._open_connection(connection, **kwargs)
|
|
57
|
+
try:
|
|
58
|
+
yield conn
|
|
59
|
+
finally:
|
|
60
|
+
try:
|
|
61
|
+
conn.disconnect()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def get_config(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> str:
|
|
66
|
+
with self._conn(connection, **kwargs) as conn:
|
|
67
|
+
# `display set` flattens hierarchical config into reproducible
|
|
68
|
+
# `set` lines; `no-more` disables paging.
|
|
69
|
+
return conn.send_command(
|
|
70
|
+
"show configuration | display set | no-more",
|
|
71
|
+
read_timeout=120,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def send_config(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> str:
|
|
75
|
+
# netmiko's juniper_junos handler enters configure mode, sends the
|
|
76
|
+
# commands, commits, and exits — driven by send_config_set.
|
|
77
|
+
with self._conn(connection, **kwargs) as conn:
|
|
78
|
+
return conn.send_config_set(commands)
|
|
79
|
+
|
|
80
|
+
def execute(self, node: NodeListItem, connection: ManagementConnection, commands: list[str], **kwargs) -> list[str]:
|
|
81
|
+
with self._conn(connection, **kwargs) as conn:
|
|
82
|
+
return [conn.send_command(cmd, read_timeout=120) for cmd in commands]
|
|
83
|
+
|
|
84
|
+
def get_lldp_neighbors(self, node: NodeListItem, connection: ManagementConnection, **kwargs) -> list[dict]:
|
|
85
|
+
with self._conn(connection, **kwargs) as conn:
|
|
86
|
+
try:
|
|
87
|
+
raw = conn.send_command("show lldp neighbors", read_timeout=60)
|
|
88
|
+
except Exception:
|
|
89
|
+
return []
|
|
90
|
+
return self._parse_lldp_table(raw)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _parse_lldp_table(raw: str) -> list[dict]:
|
|
94
|
+
# `show lldp neighbors` returns a fixed-width table:
|
|
95
|
+
# Local Interface Parent Interface Chassis Id Port info System Name
|
|
96
|
+
# ge-0/0/0 - 00:11:22:33:44:55 ge-0/0/1 peer-switch
|
|
97
|
+
neighbors = []
|
|
98
|
+
lines = [ln.rstrip() for ln in raw.splitlines() if ln.strip()]
|
|
99
|
+
# Skip header line if present
|
|
100
|
+
for line in lines:
|
|
101
|
+
if line.lower().startswith("local interface"):
|
|
102
|
+
continue
|
|
103
|
+
parts = line.split()
|
|
104
|
+
# Expected at minimum: local, parent, chassis_id, port_id, sysname
|
|
105
|
+
if len(parts) < 5:
|
|
106
|
+
continue
|
|
107
|
+
local_iface = parts[0]
|
|
108
|
+
remote_port = parts[-2]
|
|
109
|
+
remote_dev = parts[-1]
|
|
110
|
+
neighbors.append({
|
|
111
|
+
"local_interface": local_iface,
|
|
112
|
+
"remote_device": remote_dev,
|
|
113
|
+
"remote_interface": remote_port,
|
|
114
|
+
"discovery_protocol": "lldp",
|
|
115
|
+
})
|
|
116
|
+
return neighbors
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class JunosCLI(NetworkElementDriver):
|
|
120
|
+
"""Juniper Junos CLI driver."""
|
|
121
|
+
|
|
122
|
+
version = "1.0.0"
|
|
123
|
+
renderer_class = JunosCLIRenderer
|
|
124
|
+
transport_class = JunosCLITransport
|
|
125
|
+
parser_class = JunosCLIParser
|
|
126
|
+
|
|
127
|
+
def render(self, configuration: ComposedConfiguration, asset):
|
|
128
|
+
return self.renderer.render(configuration, asset)
|
|
129
|
+
|
|
130
|
+
def parse(self, configuration: str) -> ComposedConfiguration:
|
|
131
|
+
return self.parser.parse(configuration)
|
|
132
|
+
|
|
133
|
+
def render_patch(self, diff: Diff, node_instance: "NodeInstance"):
|
|
134
|
+
return self.renderer.render_patch(diff, node_instance)
|
|
135
|
+
|
|
136
|
+
def apply_patch(self, diff: Diff, node_instance, node: NodeListItem, connection: ManagementConnection, **kwargs):
|
|
137
|
+
rendered = self.render_patch(diff, node_instance=node_instance)
|
|
138
|
+
commands = [c.strip() for c in rendered.splitlines() if c.strip()]
|
|
139
|
+
return self.transport.send_config(node, connection, commands, **kwargs)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Junos CLI configuration parser.
|
|
2
|
+
|
|
3
|
+
Parsing a real Junos `show configuration` (hierarchical or set-format) into
|
|
4
|
+
the device-agnostic model is a significant undertaking. This stub keeps the
|
|
5
|
+
driver instantiable so deployment-side flows (render/render_patch/apply_patch)
|
|
6
|
+
work; calling parse() raises until a real implementation lands.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from acex.plugins.neds.core import ParserBase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JunosCLIParser(ParserBase):
|
|
13
|
+
def parse(self, configuration: str):
|
|
14
|
+
raise NotImplementedError(
|
|
15
|
+
"JunosCLIParser.parse is not yet implemented. "
|
|
16
|
+
"Implement parsing of `show configuration | display set` output here."
|
|
17
|
+
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
6
|
+
|
|
7
|
+
from acex.plugins.neds.core import RendererBase
|
|
8
|
+
from acex_devkit.configdiffer import Diff
|
|
9
|
+
from acex_devkit.configdiffer.command import Command, Context
|
|
10
|
+
from acex_devkit.models.composed_configuration import ComposedConfiguration
|
|
11
|
+
|
|
12
|
+
from .augment_renderers import resolve_augment_lines
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GeneratorRegistry:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._patterns: list[tuple[tuple, Callable]] = []
|
|
18
|
+
|
|
19
|
+
def register(self, pattern: tuple, generator: Callable):
|
|
20
|
+
self._patterns.append((pattern, generator))
|
|
21
|
+
|
|
22
|
+
def resolve(self, path: tuple):
|
|
23
|
+
for pattern, generator in self._patterns:
|
|
24
|
+
if self._match(path, pattern):
|
|
25
|
+
return generator
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
def _match(self, path, pattern):
|
|
29
|
+
if len(path) < len(pattern):
|
|
30
|
+
return False
|
|
31
|
+
for p, pat in zip(path, pattern):
|
|
32
|
+
if pat != "*" and p != pat:
|
|
33
|
+
return False
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Junos uses different interface name prefixes per port speed.
|
|
38
|
+
# Speeds are in kbps to match the AttributeValue[int] convention used elsewhere.
|
|
39
|
+
PORT_PREFIX_BY_SPEED = {
|
|
40
|
+
1_000_000: "ge", # 1 Gbps
|
|
41
|
+
10_000_000: "xe", # 10 Gbps
|
|
42
|
+
25_000_000: "et", # 25 Gbps (some platforms)
|
|
43
|
+
40_000_000: "et", # 40 Gbps
|
|
44
|
+
100_000_000: "et", # 100 Gbps
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class JunosCLIRenderer(RendererBase):
|
|
49
|
+
|
|
50
|
+
def _load_template_file(self) -> str:
|
|
51
|
+
path = Path(__file__).parent
|
|
52
|
+
env = Environment(loader=FileSystemLoader(path), undefined=StrictUndefined)
|
|
53
|
+
return env.get_template("template.j2")
|
|
54
|
+
|
|
55
|
+
def render(self, configuration: ComposedConfiguration, asset) -> Any:
|
|
56
|
+
"""Render the configuration model for Junos CLI devices."""
|
|
57
|
+
if isinstance(configuration, ComposedConfiguration):
|
|
58
|
+
configuration = configuration.model_dump(mode="json")
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Configuration must be a ComposedConfiguration instance. Not {type(configuration)}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
processed_config = self.pre_process(configuration, asset)
|
|
65
|
+
template = self._load_template_file()
|
|
66
|
+
return template.render(configuration=processed_config)
|
|
67
|
+
|
|
68
|
+
# ── Patch rendering ─────────────────────────────────────────
|
|
69
|
+
# Junos `set` / `delete` lines are fully qualified per command, so
|
|
70
|
+
# there's no enter/exit context to track — generators emit complete
|
|
71
|
+
# lines and rendering is just a join.
|
|
72
|
+
|
|
73
|
+
def _register(self):
|
|
74
|
+
self.registry.register(('system', 'config'), self._generate_system_config_commands)
|
|
75
|
+
self.registry.register(('interfaces', '*'), self._generate_interface_config_commands)
|
|
76
|
+
|
|
77
|
+
def render_patch(self, diff: Diff, node_instance: "NodeInstance"):
|
|
78
|
+
"""Render device commands for a Diff using registered generators."""
|
|
79
|
+
self.registry = GeneratorRegistry()
|
|
80
|
+
self._register()
|
|
81
|
+
|
|
82
|
+
commands: List[Command] = []
|
|
83
|
+
for change in diff.get_all_changes():
|
|
84
|
+
generator = self.registry.resolve(tuple(change.path))
|
|
85
|
+
if generator is None:
|
|
86
|
+
continue
|
|
87
|
+
commands.extend(generator(change, node_instance))
|
|
88
|
+
|
|
89
|
+
return "\n".join(c.command for c in commands)
|
|
90
|
+
|
|
91
|
+
def _generate_system_config_commands(self, component_change, node_instance) -> List[Command]:
|
|
92
|
+
ctx = Context(path=[])
|
|
93
|
+
commands: List[Command] = []
|
|
94
|
+
for attr in component_change.changed_attributes:
|
|
95
|
+
if attr.attribute_name == "hostname":
|
|
96
|
+
if component_change.op in ("add", "change"):
|
|
97
|
+
commands.append(Command(context=ctx, command=f"set system host-name {attr.after.value}"))
|
|
98
|
+
elif component_change.op == "remove":
|
|
99
|
+
commands.append(Command(context=ctx, command="delete system host-name"))
|
|
100
|
+
return commands
|
|
101
|
+
|
|
102
|
+
def _generate_interface_config_commands(self, component_change, node_instance) -> List[Command]:
|
|
103
|
+
# Stub mirroring the Cisco renderer's placeholder — real attribute
|
|
104
|
+
# mapping per change.op / attr.attribute_name lands here.
|
|
105
|
+
ctx = Context(path=component_change.path)
|
|
106
|
+
return [Command(context=ctx, command="set interfaces TODO description \"TODO\"")]
|
|
107
|
+
|
|
108
|
+
def pre_process(self, configuration: dict, asset) -> Dict[str, Any]:
|
|
109
|
+
"""Pre-process the configuration model before rendering j2."""
|
|
110
|
+
configuration = self._physical_interface_names(configuration, asset)
|
|
111
|
+
self._resolve_lag_lacp(configuration)
|
|
112
|
+
configuration["augment_lines"] = resolve_augment_lines(configuration)
|
|
113
|
+
return configuration
|
|
114
|
+
|
|
115
|
+
def _resolve_lag_lacp(self, config: dict) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Bubble per-member LACP settings up to the LAG itself, plus count LAGs
|
|
118
|
+
for chassis-level `aggregated-devices` config. The model keeps LACP
|
|
119
|
+
timing/mode on member ports (Cisco-flavored convention); Junos needs
|
|
120
|
+
them on the LAG.
|
|
121
|
+
|
|
122
|
+
Sets on each LAG: `_lacp_enabled`, `_lacp_mode`, `_lacp_interval`.
|
|
123
|
+
Sets on config root: `_lag_count`.
|
|
124
|
+
"""
|
|
125
|
+
interfaces = config.get("interfaces") or {}
|
|
126
|
+
|
|
127
|
+
members_by_lag: Dict[int, list] = defaultdict(list)
|
|
128
|
+
for intf in interfaces.values():
|
|
129
|
+
if intf.get("type") != "ethernetCsmacd":
|
|
130
|
+
continue
|
|
131
|
+
agg = (intf.get("aggregate_id") or {}).get("value")
|
|
132
|
+
if agg is None:
|
|
133
|
+
continue
|
|
134
|
+
members_by_lag[agg].append(intf)
|
|
135
|
+
|
|
136
|
+
for intf in interfaces.values():
|
|
137
|
+
if intf.get("type") != "ieee8023adLag":
|
|
138
|
+
continue
|
|
139
|
+
agg_id = (intf.get("aggregate_id") or {}).get("value")
|
|
140
|
+
if agg_id is None:
|
|
141
|
+
continue
|
|
142
|
+
for member in members_by_lag.get(agg_id, []):
|
|
143
|
+
if (member.get("lacp_enabled") or {}).get("value"):
|
|
144
|
+
intf["_lacp_enabled"] = True
|
|
145
|
+
mode = (member.get("lacp_mode") or {}).get("value")
|
|
146
|
+
intf["_lacp_mode"] = mode if mode in ("active", "passive") else "active"
|
|
147
|
+
interval = (member.get("lacp_interval") or {}).get("value")
|
|
148
|
+
if interval:
|
|
149
|
+
intf["_lacp_interval"] = interval
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
lag_count = sum(1 for i in interfaces.values() if i.get("type") == "ieee8023adLag")
|
|
153
|
+
if lag_count > 0:
|
|
154
|
+
config["_lag_count"] = lag_count
|
|
155
|
+
|
|
156
|
+
def _physical_interface_names(self, config: dict, asset) -> dict:
|
|
157
|
+
"""
|
|
158
|
+
Resolve physical/management interface names per Junos conventions.
|
|
159
|
+
|
|
160
|
+
- ethernetCsmacd: ge-/xe-/et- prefix per speed, suffix `<stack>/<module>/<port>`
|
|
161
|
+
using the asset's stack_index/module_index/index from the model.
|
|
162
|
+
- managementInterface: hardcoded to `me0` (switch-style); routers use
|
|
163
|
+
`fxp0` — extend if/when we model that.
|
|
164
|
+
- softwareLoopback: `lo0` with the index becoming the unit number, set
|
|
165
|
+
on the model so the template can use it.
|
|
166
|
+
"""
|
|
167
|
+
for _, intf in (config.get("interfaces") or {}).items():
|
|
168
|
+
t = intf.get("type")
|
|
169
|
+
|
|
170
|
+
if t == "ethernetCsmacd":
|
|
171
|
+
speed = (intf.get("speed") or {}).get("value") or 1_000_000
|
|
172
|
+
prefix = PORT_PREFIX_BY_SPEED.get(speed, "ge")
|
|
173
|
+
stack_index = (intf.get("stack_index") or {}).get("value") or 0
|
|
174
|
+
module_index = (intf.get("module_index") or {}).get("value") or 0
|
|
175
|
+
port_index = intf["index"]["value"]
|
|
176
|
+
intf["name"] = f"{prefix}-{stack_index}/{module_index}/{port_index}"
|
|
177
|
+
|
|
178
|
+
elif t == "managementInterface":
|
|
179
|
+
# Junos out-of-band mgmt; default to me0 for switches.
|
|
180
|
+
intf["name"] = "me0"
|
|
181
|
+
|
|
182
|
+
elif t == "softwareLoopback":
|
|
183
|
+
# Junos models loopback as lo0 unit N — name doesn't appear
|
|
184
|
+
# standalone, but having it deterministic helps templates.
|
|
185
|
+
intf["name"] = "lo0"
|
|
186
|
+
|
|
187
|
+
elif t == "ieee8023adLag":
|
|
188
|
+
# Junos uses ae<N> for aggregated Ethernet.
|
|
189
|
+
intf["name"] = f"ae{intf['index']['value']}"
|
|
190
|
+
|
|
191
|
+
return config
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
{#- Junos CLI day-0 template — renders against the current ComposedConfiguration shape.
|
|
2
|
+
Scope: hostname, domain, DNS, SSH, NTP, interfaces (mgmt/loopback/ethernet),
|
|
3
|
+
SNMP communities (with per-community augments), VLANs, static routes.
|
|
4
|
+
Out of scope: AAA, logging beyond defaults, ACLs, complex routing, sFlow. -#}
|
|
5
|
+
{%- if configuration._lag_count %}
|
|
6
|
+
set chassis aggregated-devices ethernet device-count {{ configuration._lag_count }}
|
|
7
|
+
{%- endif %}
|
|
8
|
+
{%- if configuration.system.config.hostname %}
|
|
9
|
+
set system host-name {{ configuration.system.config.hostname.value }}
|
|
10
|
+
{%- endif %}
|
|
11
|
+
{%- if configuration.system.config.domain_name %}
|
|
12
|
+
set system domain-name {{ configuration.system.config.domain_name.value }}
|
|
13
|
+
{%- endif %}
|
|
14
|
+
{%- if configuration.system.config.location %}
|
|
15
|
+
set system location "{{ configuration.system.config.location.value }}"
|
|
16
|
+
{%- endif %}
|
|
17
|
+
{%- if configuration.system.config.contact %}
|
|
18
|
+
set system contact "{{ configuration.system.config.contact.value }}"
|
|
19
|
+
{%- endif %}
|
|
20
|
+
{#- DNS -#}
|
|
21
|
+
{%- if configuration.system.dns and configuration.system.dns.dns_servers %}
|
|
22
|
+
{%- for _, dns in configuration.system.dns.dns_servers.items() %}
|
|
23
|
+
{%- if dns.address %}
|
|
24
|
+
set system name-server {{ dns.address.value }}
|
|
25
|
+
{%- endif %}
|
|
26
|
+
{%- endfor %}
|
|
27
|
+
{%- endif %}
|
|
28
|
+
{#- NTP -#}
|
|
29
|
+
{%- if configuration.system.ntp and configuration.system.ntp.servers %}
|
|
30
|
+
{%- for _, ntp in configuration.system.ntp.servers.items() %}
|
|
31
|
+
set system ntp server {{ ntp.address.value }}{% if ntp.prefer and ntp.prefer.value %} prefer{% endif %}
|
|
32
|
+
{%- endfor %}
|
|
33
|
+
{%- endif %}
|
|
34
|
+
{#- SSH / NETCONF -#}
|
|
35
|
+
{%- set ssh = configuration.system.ssh.config if configuration.system.ssh else None %}
|
|
36
|
+
{%- if ssh and ssh.enable and ssh.enable.value %}
|
|
37
|
+
set system services ssh
|
|
38
|
+
{%- if ssh.timeout %}
|
|
39
|
+
set system services ssh idle-timeout {{ ssh.timeout.value }}
|
|
40
|
+
{%- endif %}
|
|
41
|
+
{%- endif %}
|
|
42
|
+
set system services netconf ssh
|
|
43
|
+
{#- Management interface -#}
|
|
44
|
+
{%- for _, intf in configuration.interfaces.items() %}
|
|
45
|
+
{%- if intf.type == "managementInterface" %}
|
|
46
|
+
{%- if intf.description %}
|
|
47
|
+
set interfaces {{ intf.name }} description "{{ intf.description.value }}"
|
|
48
|
+
{%- endif %}
|
|
49
|
+
{%- if intf.ipv4 %}
|
|
50
|
+
set interfaces {{ intf.name }} unit 0 family inet address {{ intf.ipv4.value }}
|
|
51
|
+
{%- endif %}
|
|
52
|
+
{%- if intf.enabled and not intf.enabled.value %}
|
|
53
|
+
set interfaces {{ intf.name }} disable
|
|
54
|
+
{%- endif %}
|
|
55
|
+
{%- endif %}
|
|
56
|
+
{%- endfor %}
|
|
57
|
+
{#- Loopback interfaces (lo0 with multiple units) -#}
|
|
58
|
+
{%- for _, intf in configuration.interfaces.items() %}
|
|
59
|
+
{%- if intf.type == "softwareLoopback" %}
|
|
60
|
+
{%- if intf.ipv4 %}
|
|
61
|
+
set interfaces {{ intf.name }} unit {{ intf.index.value }} family inet address {{ intf.ipv4.value }}
|
|
62
|
+
{%- endif %}
|
|
63
|
+
{%- if intf.description %}
|
|
64
|
+
set interfaces {{ intf.name }} unit {{ intf.index.value }} description "{{ intf.description.value }}"
|
|
65
|
+
{%- endif %}
|
|
66
|
+
{%- endif %}
|
|
67
|
+
{%- endfor %}
|
|
68
|
+
{#- Physical Ethernet interfaces -#}
|
|
69
|
+
{%- for _, intf in configuration.interfaces.items() %}
|
|
70
|
+
{%- if intf.type == "ethernetCsmacd" %}
|
|
71
|
+
{%- if intf.description %}
|
|
72
|
+
set interfaces {{ intf.name }} description "{{ intf.description.value }}"
|
|
73
|
+
{%- endif %}
|
|
74
|
+
{%- if intf.mtu %}
|
|
75
|
+
set interfaces {{ intf.name }} mtu {{ intf.mtu.value }}
|
|
76
|
+
{%- endif %}
|
|
77
|
+
{%- if intf.ipv4 %}
|
|
78
|
+
set interfaces {{ intf.name }} unit 0 family inet address {{ intf.ipv4.value }}
|
|
79
|
+
{%- endif %}
|
|
80
|
+
{%- if intf.switchport and intf.switchport.value %}
|
|
81
|
+
{%- if intf.switchport_mode and intf.switchport_mode.value == "access" %}
|
|
82
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching interface-mode access
|
|
83
|
+
{%- if intf.access_vlan %}
|
|
84
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching vlan members {{ intf.access_vlan.value }}
|
|
85
|
+
{%- endif %}
|
|
86
|
+
{%- endif %}
|
|
87
|
+
{%- if intf.switchport_mode and intf.switchport_mode.value == "trunk" %}
|
|
88
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching interface-mode trunk
|
|
89
|
+
{%- if intf.trunk_allowed_vlans %}
|
|
90
|
+
{%- for vlan in intf.trunk_allowed_vlans.value %}
|
|
91
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching vlan members {{ vlan }}
|
|
92
|
+
{%- endfor %}
|
|
93
|
+
{%- endif %}
|
|
94
|
+
{%- if intf.native_vlan %}
|
|
95
|
+
set interfaces {{ intf.name }} native-vlan-id {{ intf.native_vlan.value }}
|
|
96
|
+
{%- endif %}
|
|
97
|
+
{%- endif %}
|
|
98
|
+
{%- endif %}
|
|
99
|
+
{%- if intf.aggregate_id %}
|
|
100
|
+
set interfaces {{ intf.name }} ether-options 802.3ad ae{{ intf.aggregate_id.value }}
|
|
101
|
+
{%- endif %}
|
|
102
|
+
{%- if intf.enabled and not intf.enabled.value %}
|
|
103
|
+
set interfaces {{ intf.name }} disable
|
|
104
|
+
{%- endif %}
|
|
105
|
+
{%- endif %}
|
|
106
|
+
{%- endfor %}
|
|
107
|
+
{#- LAG interfaces (ae<N>) -#}
|
|
108
|
+
{%- for _, intf in configuration.interfaces.items() %}
|
|
109
|
+
{%- if intf.type == "ieee8023adLag" %}
|
|
110
|
+
{%- if intf.description %}
|
|
111
|
+
set interfaces {{ intf.name }} description "{{ intf.description.value }}"
|
|
112
|
+
{%- endif %}
|
|
113
|
+
{%- if intf.mtu %}
|
|
114
|
+
set interfaces {{ intf.name }} mtu {{ intf.mtu.value }}
|
|
115
|
+
{%- endif %}
|
|
116
|
+
{%- if intf._lacp_enabled %}
|
|
117
|
+
set interfaces {{ intf.name }} aggregated-ether-options lacp {{ intf._lacp_mode }}
|
|
118
|
+
{%- if intf._lacp_interval %}
|
|
119
|
+
set interfaces {{ intf.name }} aggregated-ether-options lacp periodic {{ intf._lacp_interval }}
|
|
120
|
+
{%- endif %}
|
|
121
|
+
{%- endif %}
|
|
122
|
+
{%- if intf.switchport and intf.switchport.value %}
|
|
123
|
+
{%- if intf.switchport_mode and intf.switchport_mode.value == "access" %}
|
|
124
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching interface-mode access
|
|
125
|
+
{%- endif %}
|
|
126
|
+
{%- if intf.switchport_mode and intf.switchport_mode.value == "trunk" %}
|
|
127
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching interface-mode trunk
|
|
128
|
+
{%- if intf.trunk_allowed_vlans %}
|
|
129
|
+
{%- for vlan in intf.trunk_allowed_vlans.value %}
|
|
130
|
+
set interfaces {{ intf.name }} unit 0 family ethernet-switching vlan members {{ vlan }}
|
|
131
|
+
{%- endfor %}
|
|
132
|
+
{%- endif %}
|
|
133
|
+
{%- if intf.native_vlan %}
|
|
134
|
+
set interfaces {{ intf.name }} native-vlan-id {{ intf.native_vlan.value }}
|
|
135
|
+
{%- endif %}
|
|
136
|
+
{%- endif %}
|
|
137
|
+
{%- endif %}
|
|
138
|
+
{%- if intf.enabled and not intf.enabled.value %}
|
|
139
|
+
set interfaces {{ intf.name }} disable
|
|
140
|
+
{%- endif %}
|
|
141
|
+
{%- endif %}
|
|
142
|
+
{%- endfor %}
|
|
143
|
+
{#- VLANs (defined under network_instances.<ni>.vlans) -#}
|
|
144
|
+
{%- for _, ni in configuration.network_instances.items() %}
|
|
145
|
+
{%- if ni.vlans %}
|
|
146
|
+
{%- for _, vlan in ni.vlans.items() %}
|
|
147
|
+
{%- set vname = vlan.vlan_name.value if vlan.vlan_name else vlan.name.value %}
|
|
148
|
+
set vlans {{ vname }} vlan-id {{ vlan.vlan_id.value }}
|
|
149
|
+
{%- endfor %}
|
|
150
|
+
{%- endif %}
|
|
151
|
+
{%- endfor %}
|
|
152
|
+
{#- SNMP communities (with per-community augments such as juniper.snmp_community_clients) -#}
|
|
153
|
+
{%- if configuration.system.snmp and configuration.system.snmp.communities %}
|
|
154
|
+
{%- for name, com in configuration.system.snmp.communities.items() %}
|
|
155
|
+
set snmp community {{ name }} authorization {% if com.access and com.access.value == "READ_WRITE" %}read-write{% else %}read-only{% endif %}
|
|
156
|
+
{%- for line in configuration.augment_lines.get("system.snmp.communities." ~ name, []) %}
|
|
157
|
+
{{ line }}
|
|
158
|
+
{%- endfor %}
|
|
159
|
+
{%- endfor %}
|
|
160
|
+
{%- endif %}
|
|
161
|
+
{#- Static routes (per network_instance) -#}
|
|
162
|
+
{%- for ni_name, ni in configuration.network_instances.items() %}
|
|
163
|
+
{%- if ni.protocols and ni.protocols.static_routes %}
|
|
164
|
+
{%- for _, route in ni.protocols.static_routes.items() %}
|
|
165
|
+
{%- if route.next_hops %}
|
|
166
|
+
{%- for _, hop in route.next_hops.items() %}
|
|
167
|
+
{%- if ni_name == "global" %}
|
|
168
|
+
set routing-options static route {{ route.prefix.value }} next-hop {{ hop.next_hop.value }}
|
|
169
|
+
{%- else %}
|
|
170
|
+
set routing-instances {{ ni_name }} routing-options static route {{ route.prefix.value }} next-hop {{ hop.next_hop.value }}
|
|
171
|
+
{%- endif %}
|
|
172
|
+
{%- endfor %}
|
|
173
|
+
{%- endif %}
|
|
174
|
+
{%- endfor %}
|
|
175
|
+
{%- endif %}
|
|
176
|
+
{%- endfor %}
|