#!/usr/bin/env python3

# Copyright (c) 2026, i-danos.
#
# SPDX-License-Identifier: LGPL-2.1-only
#
# Private VLAN, sent to the dataplane as split horizon on a bridge port.
#
# The three port roles are one rule with different arguments:
#
#     promiscuous    group 0
#     isolated       group N, intra_allow false
#     community N    group N, intra_allow true
#
# Group 0 is unrestricted. A frame received on a port whose group is non-zero is
# not forwarded out another port in the same group unless the group allows its
# members to reach each other.
#
# No component runs for this. There is no process to manage -- the whole job is
# to translate a role into those two values and store them -- so it is a script
# on the configuration path like vyatta-mac-limit, not a VCI component like
# 802.1X, which exists to run hostapd.

from argparse import ArgumentParser

from vplaned import Controller
from vyatta import configd
from vyatta.proto import PrivateVlanConfig_pb2

# Every isolated port on a bridge shares one group, because "isolated" means
# isolated from every other isolated port, not from a subset. Communities take
# their own group from the configured id, so the two spaces must not overlap:
# an id of 4095 is outside the 1..4094 the model accepts.
#
# This partition also disposes of a consistency problem rather than validating
# it. intra_allow belongs to the group, not to the port, and two ports in one
# group disagreeing about it would make forwarding asymmetric -- the dataplane
# reads the ingress port's copy, so A could reach B while B could not reach A,
# which is close to undiagnosable. Here the role determines both values and the
# group spaces do not overlap, so group implies intra_allow by construction and
# no two ports can disagree. Nothing has to check it.
ISOLATED_GROUP = 4095


def horizon_for(role, community):
    """Return (group, intra_allow) for a port role."""
    if role == "promiscuous":
        return 0, False
    if role == "isolated":
        return ISOLATED_GROUP, False
    if role == "community":
        # The model makes community mandatory when the role is community, so a
        # missing one here is a bug rather than an operator error. Failing
        # closed -- isolating the port -- beats defaulting to group 0, which
        # would silently make it promiscuous and connect what should have been
        # separated.
        if community is None:
            raise ValueError("community role with no community id")
        return int(community), True
    raise ValueError("unknown port role: {}".format(role))


def configure(action, ifname):
    cfg = PrivateVlanConfig_pb2.PrivateVlanConfig()
    cfg.if_name = ifname

    if action == "SET":
        client = configd.Client()
        tree = client.tree_get_dict(
            "interfaces dataplane {} bridge-group private-vlan".format(ifname))
        pv = tree.get("private-vlan", {})
        group, intra = horizon_for(pv.get("port-role"), pv.get("community"))

        cfg.cmd = PrivateVlanConfig_pb2.PrivateVlanConfig.SET
        cfg.group = group
        cfg.intra_allow = intra
    else:
        cfg.cmd = PrivateVlanConfig_pb2.PrivateVlanConfig.DELETE
        cfg.group = 0
        cfg.intra_allow = False

    key = "private-vlan {}".format(ifname)
    with Controller() as controller:
        controller.store(key, cfg, "ALL", action,
                         cmd_name="vyatta:private-vlan")


def main():
    # $COMMIT_ACTION is SET, ACTIVE or DELETE, not just the two. ACTIVE means
    # the node did not change in this commit but is still there, which happens
    # on every commit that touches anything else under the same interface.
    #
    # Accepting only SET and DELETE made argparse exit non-zero on ACTIVE, and
    # configd recorded that as "Error with no output" in its journal and said
    # nothing at all on the commit -- the configuration went into the tree, the
    # dataplane never heard about it, and the commit looked clean. Treat ACTIVE
    # as SET: re-sending the same horizon is harmless and makes the dataplane
    # converge if it ever missed one.
    parser = ArgumentParser()
    parser.add_argument("--action", required=True,
                        choices=["SET", "ACTIVE", "DELETE"])
    parser.add_argument("--dev", required=True)
    args = parser.parse_args()

    configure("DELETE" if args.action == "DELETE" else "SET", args.dev)


if __name__ == "__main__":
    main()
