Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/operator-guide/kubectl-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,65 @@ refusal in one domain does not block safe work in the other. When `--apply`
performs only the safe portion, the command verifies that work and exits `2`
to report that part of the router remains unresolved.

### net trunk audit

```bash
# Every baremetal trunk, or just one.
kubectl us net trunk audit
kubectl us net trunk audit <trunk-name-or-id>
```

Reports the trunk state ml2/ovn left behind on baremetal trunks. ml2/ovn used
to manage any trunk whose parent port had a `Logical_Switch_Port`, baremetal
parents included -- their LSP is `type=external` only so OVN can schedule an HA
chassis group, while the VLANs are programmed on the leaf switch by undersync.
It wrote `parent_name`/`tag` into each subport's binding profile and LSP, where
they mean nothing, and pointed `parent_name` at an LSP on a different logical
switch. [LP #2167105](https://bugs.launchpad.net/neutron/+bug/2167105) stops it
managing these trunks, but the rows it already wrote stay behind: the code that
removed them on subport delete now returns early.

Trunks whose parent port is not baremetal are skipped -- ml2/ovn still owns
those. For each baremetal trunk the audit verifies:

- the trunk's status matches its parent port: `ACTIVE` when the parent is
vif-attached with a `physical_network`, `DOWN` when it is unbound. A trunk
last touched before neutron-understack took over the status corrects itself
on its next subport change;
- no subport binding profile carries `parent_name` or `tag`;
- no subport LSP carries `parent_name` or `tag_request` (Neutron converts a
`tag` write into `tag_request`; northd derives the read-only `tag` from it);
and
- each subport is claimed with `device_owner=trunk:subport`.

Any failed check produces a nonzero exit status.

### net trunk repair

```bash
# Plan only; this writes nothing.
kubectl us net trunk repair
kubectl us net trunk repair <trunk-name-or-id>

# Apply the displayed plan and verify the result.
kubectl us net trunk repair <trunk-name-or-id> --apply
```

Repairs what the audit reports, except the trunk status, which is not settable
through the Neutron API.

The subport's binding profile is the source of truth: ml2/ovn derives the LSP's
`parent_name`/`tag` from it (`ovn_client._get_port_options`), so dropping those
keys through Neutron makes ml2/ovn rewrite the LSP itself. The same port update
also resets the subport's stale `vif_type=ovs` binding to `unbound`, and
re-claims a subport whose `device_owner` is missing. Only rows ml2/ovn did not
rewrite are then cleared directly with `ovn-nbctl clear`.

Nothing else in the binding profile is touched, and the dynamic VLAN segments
and level-0 binding levels that actually carry the subports are never modified.
With `--apply`, the affected trunks are reloaded afterwards and the command
exits nonzero if any repair did not stick.

### backup local

```bash
Expand Down
30 changes: 30 additions & 0 deletions python/kubectl-us/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,36 @@ See the
[operator guide](../../docs/operator-guide/kubectl-us.md#net-router-repair) for
the complete safety contract.

### `net trunk audit`

```
kubectl us net trunk audit # every baremetal trunk
kubectl us net trunk audit <trunk-name-or-id>
```

Reports the trunk state ml2/ovn left on baremetal trunks after
[LP #2167105](https://bugs.launchpad.net/neutron/+bug/2167105) stopped it
managing them: `parent_name`/`tag` in a subport's binding profile, the LSP
`parent_name`/`tag_request` derived from those, an unclaimed subport, and a
trunk status that disagrees with its parent port's binding. Trunks whose parent
port is not baremetal are skipped, since ml2/ovn still owns them. Any failed
check produces a nonzero exit status.

### `net trunk repair`

```
kubectl us net trunk repair <trunk-name-or-id> # dry-run plan
kubectl us net trunk repair <trunk-name-or-id> --apply # write and verify
```

Drops those keys from the subport's binding profile, which makes ml2/ovn
rewrite the LSP without them, and re-claims subports missing
`device_owner=trunk:subport`. Rows ml2/ovn does not rewrite are cleared
directly in the NB. Trunk status is audit-only: the Neutron API does not accept
it. Segments and binding levels are never touched. See the
[operator guide](../../docs/operator-guide/kubectl-us.md#net-trunk-repair) for
the complete contract.

### `backup local`

```
Expand Down
285 changes: 285 additions & 0 deletions python/kubectl-us/tests/test_trunk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import types

import pytest
import typer
from typer.testing import CliRunner

from us_cli.commands import trunk
from us_cli.connection import ConnectionContext

runner = CliRunner()


def make_app():
app = typer.Typer()

@app.callback()
def main(ctx: typer.Context) -> None:
ctx.obj = ConnectionContext(
kube_context=None,
namespace="openstack",
nb_pod="ovn-ovsdb-nb-0",
sb_pod="ovn-ovsdb-sb-0",
os_cloud="dev-cloud",
)

app.add_typer(trunk.app, name="trunk")
return app


def make_parent(vnic_type="baremetal", bound=True):
return types.SimpleNamespace(
id="parent-1",
device_owner="compute:ironic",
binding_vnic_type=vnic_type,
binding_vif_type="other" if bound else "unbound",
binding_profile={"physical_network": "f20-1-network"} if bound else {},
)


def make_subport(profile=None, device_owner=trunk.SUBPORT_DEVICE_OWNER):
"""A subport port, by default already in the state we want."""
return types.SimpleNamespace(
id="sub-1",
name="subport",
device_owner=device_owner,
binding_vnic_type="normal",
binding_vif_type="unbound",
binding_profile={} if profile is None else profile,
)


def make_trunk(status="ACTIVE"):
return types.SimpleNamespace(
id="trunk-1",
name="bm-trunk",
port_id="parent-1",
status=status,
sub_ports=[
{"port_id": "sub-1", "segmentation_type": "vlan", "segmentation_id": 1500}
],
)


class FakeNetworkAPI:
def __init__(self, trunks, ports):
self._trunks = trunks
self._ports = {port.id: port for port in ports}
self.updates: list[tuple[str, dict]] = []
self.reflect_updates = True

def trunks(self):
return list(self._trunks)

def find_trunk(self, name_or_id):
for item in self._trunks:
if name_or_id in (item.id, item.name):
return item
return None

def get_port(self, port_id):
return self._ports[port_id]

def update_port(self, port_id, **attrs):
self.updates.append((port_id, attrs))
port = self._ports[port_id]
if self.reflect_updates:
for key, value in attrs.items():
setattr(port, key, value)
return port


class FakeConfig:
config = {}


class FakeConnection:
def __init__(self, trunks, ports):
self.network = FakeNetworkAPI(trunks, ports)
self.config = FakeConfig()


class FakeNb:
"""The NB Logical_Switch_Port rows, keyed by port id."""

def __init__(self, rows):
self.rows = rows
self.calls: list[list[str]] = []

def list_records(self, ctx, table, records):
assert table == "Logical_Switch_Port"
return [
{"name": port_id, **columns}
for port_id, columns in self.rows.items()
if port_id in records
]

def raw(self, ctx, args):
self.calls.append(args)
for index, arg in enumerate(args):
# `-- clear Logical_Switch_Port <port> <column>`
if arg == "clear":
port_id, column = args[index + 2], args[index + 3]
self.rows.get(port_id, {}).pop(column, None)
return ""


@pytest.fixture
def setup(monkeypatch):
def _setup(trunks=None, ports=None, nb_rows=None):
trunks = [make_trunk()] if trunks is None else trunks
ports = [make_parent(), make_subport()] if ports is None else ports
conn = FakeConnection(trunks, ports)
nb = FakeNb({} if nb_rows is None else nb_rows)
monkeypatch.setattr(trunk.osclient, "get_connection", lambda os_cloud: conn)
monkeypatch.setattr(trunk.ovn, "nbctl_list_records", nb.list_records)
monkeypatch.setattr(trunk.ovn, "nbctl_raw", nb.raw)
return conn, nb

return _setup


STALE_PROFILE = {"parent_name": "parent-1", "tag": 1500}
STALE_LSP = {"sub-1": {"parent_name": "parent-1", "tag_request": 1500}}


class TestAudit:
def test_a_clean_trunk_passes(self, setup):
setup(nb_rows={"sub-1": {"parent_name": "", "tag_request": []}})

result = runner.invoke(make_app(), ["trunk", "audit"])

assert result.exit_code == 0, result.output
assert "FAIL" not in result.output
assert "Trunk bm-trunk (trunk-1)" in result.output

def test_stale_profile_and_lsp_rows_fail(self, setup):
setup(
ports=[make_parent(), make_subport(profile=dict(STALE_PROFILE))],
nb_rows={"sub-1": dict(STALE_LSP["sub-1"])},
)

result = runner.invoke(make_app(), ["trunk", "audit"])

assert result.exit_code == 1
assert "stale ml2/ovn keys: parent_name, tag" in result.output
assert "still set: parent_name, tag_request" in result.output

def test_a_status_that_disagrees_with_the_parent_fails(self, setup):
setup(trunks=[make_trunk(status="DOWN")])

result = runner.invoke(make_app(), ["trunk", "audit"])

assert result.exit_code == 1
assert "expected ACTIVE for a bound parent, found DOWN" in result.output

def test_an_unbound_parent_expects_a_down_trunk(self, setup):
setup(
trunks=[make_trunk(status="DOWN")],
ports=[make_parent(bound=False), make_subport()],
)

result = runner.invoke(make_app(), ["trunk", "audit"])

assert result.exit_code == 0, result.output
assert "trunk status: DOWN" in result.output

def test_a_non_baremetal_trunk_is_skipped(self, setup):
setup(ports=[make_parent(vnic_type="normal"), make_subport()])

result = runner.invoke(make_app(), ["trunk", "audit"])

assert result.exit_code == 0, result.output
assert "so ml2/ovn still owns this trunk" in result.output
assert "(no baremetal trunks found)" in result.output

def test_an_unclaimed_subport_fails(self, setup):
setup(ports=[make_parent(), make_subport(device_owner="")])

result = runner.invoke(make_app(), ["trunk", "audit"])

assert result.exit_code == 1
assert "expected trunk:subport, found (empty)" in result.output

def test_an_unknown_trunk_errors(self, setup):
setup()

result = runner.invoke(make_app(), ["trunk", "audit", "nope"])

assert result.exit_code == 1
assert "trunk 'nope' not found" in result.output


class TestRepair:
def test_a_dry_run_changes_nothing(self, setup):
conn, nb = setup(
ports=[make_parent(), make_subport(profile=dict(STALE_PROFILE))],
nb_rows={"sub-1": dict(STALE_LSP["sub-1"])},
)

result = runner.invoke(make_app(), ["trunk", "repair"])

assert result.exit_code == 0, result.output
assert "binding profile: drop parent_name, tag" in result.output
assert "Dry run only" in result.output
assert conn.network.updates == []
assert nb.calls == []

def test_apply_strips_the_profile_and_clears_the_lsp(self, setup):
conn, nb = setup(
ports=[
make_parent(),
make_subport(profile={**STALE_PROFILE, "keep": "me"}),
],
nb_rows={"sub-1": dict(STALE_LSP["sub-1"])},
)

result = runner.invoke(make_app(), ["trunk", "repair", "--apply"])

assert result.exit_code == 0, result.output
assert conn.network.updates == [("sub-1", {"binding_profile": {"keep": "me"}})]
assert nb.rows == {"sub-1": {}}
assert "Repaired 1 subport(s); verification passed." in result.output

def test_apply_reclaims_an_unclaimed_subport(self, setup):
conn, _ = setup(ports=[make_parent(), make_subport(device_owner="")])

result = runner.invoke(make_app(), ["trunk", "repair", "--apply"])

assert result.exit_code == 0, result.output
assert conn.network.updates == [
("sub-1", {"device_owner": trunk.SUBPORT_DEVICE_OWNER})
]

def test_apply_clears_an_lsp_whose_profile_is_already_clean(self, setup):
conn, nb = setup(nb_rows={"sub-1": dict(STALE_LSP["sub-1"])})

result = runner.invoke(make_app(), ["trunk", "repair", "--apply"])

assert result.exit_code == 0, result.output
assert conn.network.updates == []
assert nb.rows == {"sub-1": {}}
assert "cleared in the NB directly: sub-1: parent_name" in result.output

def test_a_clean_trunk_needs_no_repair(self, setup):
conn, nb = setup()

result = runner.invoke(make_app(), ["trunk", "repair", "--apply"])

assert result.exit_code == 0, result.output
assert "No leftover ml2/ovn trunk state found." in result.output
assert conn.network.updates == []
assert nb.calls == []

def test_verification_failure_exits_nonzero(self, setup):
conn, _ = setup(
ports=[make_parent(), make_subport(profile=dict(STALE_PROFILE))]
)
# A Neutron update that silently does not stick.
conn.network.reflect_updates = False

result = runner.invoke(make_app(), ["trunk", "repair", "--apply"])

assert result.exit_code == 1
assert "repair verification failed" in result.output
assert "sub-1: binding profile: drop parent_name, tag" in result.output
Loading
Loading