feat(enroll): opt-in IPvlan per-agent for Wi-Fi-bridged VMs
Wi-Fi APs bind one MAC per associated station, so VirtualBox/VMware guests bridged over Wi-Fi rotate the VM's DHCP lease when Docker's macvlan starts emitting container-MAC frames through the vNIC. Adds a `use_ipvlan` toggle on the Agent Enrollment tab (mirrors the updater daemon checkbox): flips the flag on SwarmHost, bakes `ipvlan=true` into the agent's decnet.ini, and `_worker_config` forces ipvlan=True on the per-host shard at dispatch. Safe no-op on wired/bare-metal agents.
This commit is contained in:
@@ -126,6 +126,11 @@ class SwarmHost(SQLModel, table=True):
|
|||||||
cert_bundle_path: str
|
cert_bundle_path: str
|
||||||
enrolled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
enrolled_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
notes: Optional[str] = Field(default=None, sa_column=Column("notes", Text, nullable=True))
|
notes: Optional[str] = Field(default=None, sa_column=Column("notes", Text, nullable=True))
|
||||||
|
# Per-host driver preference. True => deckies on this host run over IPvlan
|
||||||
|
# (L2) instead of macvlan — required when the host is a VirtualBox guest
|
||||||
|
# bridged over Wi-Fi, because Wi-Fi APs only allow one MAC per station
|
||||||
|
# and macvlan's per-container MACs rotate the VM's DHCP lease.
|
||||||
|
use_ipvlan: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
class DeckyShard(SQLModel, table=True):
|
class DeckyShard(SQLModel, table=True):
|
||||||
@@ -322,6 +327,7 @@ class SwarmHostView(BaseModel):
|
|||||||
updater_cert_fingerprint: Optional[str] = None
|
updater_cert_fingerprint: Optional[str] = None
|
||||||
enrolled_at: datetime
|
enrolled_at: datetime
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
use_ipvlan: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DeckyShardView(BaseModel):
|
class DeckyShardView(BaseModel):
|
||||||
|
|||||||
@@ -43,8 +43,18 @@ def _shard_by_host(config: DecnetConfig) -> dict[str, list[DeckyConfig]]:
|
|||||||
return buckets
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def _worker_config(base: DecnetConfig, shard: list[DeckyConfig]) -> DecnetConfig:
|
def _worker_config(
|
||||||
return base.model_copy(update={"deckies": shard})
|
base: DecnetConfig,
|
||||||
|
shard: list[DeckyConfig],
|
||||||
|
host: dict[str, Any],
|
||||||
|
) -> DecnetConfig:
|
||||||
|
updates: dict[str, Any] = {"deckies": shard}
|
||||||
|
# Per-host driver opt-in (Wi-Fi-bridged VMs can't use macvlan — see
|
||||||
|
# SwarmHost.use_ipvlan). Never downgrade: if the operator picked ipvlan
|
||||||
|
# at the deploy level, keep it regardless of the per-host flag.
|
||||||
|
if host.get("use_ipvlan"):
|
||||||
|
updates["ipvlan"] = True
|
||||||
|
return base.model_copy(update=updates)
|
||||||
|
|
||||||
|
|
||||||
async def dispatch_decnet_config(
|
async def dispatch_decnet_config(
|
||||||
@@ -69,7 +79,7 @@ async def dispatch_decnet_config(
|
|||||||
|
|
||||||
async def _dispatch(host_uuid: str, shard: list[DeckyConfig]) -> SwarmHostResult:
|
async def _dispatch(host_uuid: str, shard: list[DeckyConfig]) -> SwarmHostResult:
|
||||||
host = hosts[host_uuid]
|
host = hosts[host_uuid]
|
||||||
cfg = _worker_config(config, shard)
|
cfg = _worker_config(config, shard, host)
|
||||||
try:
|
try:
|
||||||
async with AgentClient(host=host) as agent:
|
async with AgentClient(host=host) as agent:
|
||||||
body = await agent.deploy(cfg, dry_run=dry_run, no_cache=no_cache)
|
body = await agent.deploy(cfg, dry_run=dry_run, no_cache=no_cache)
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ class EnrollBundleRequest(BaseModel):
|
|||||||
default=True,
|
default=True,
|
||||||
description="Include updater cert bundle and auto-start decnet updater on the agent",
|
description="Include updater cert bundle and auto-start decnet updater on the agent",
|
||||||
)
|
)
|
||||||
|
use_ipvlan: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Run deckies on this agent over IPvlan L2 instead of MACVLAN. "
|
||||||
|
"Required when the agent is a VirtualBox/VMware guest bridged over Wi-Fi — "
|
||||||
|
"Wi-Fi APs bind one MAC per station, so MACVLAN's extra container MACs "
|
||||||
|
"rotate the VM's DHCP lease. Safe no-op on wired/bare-metal hosts."
|
||||||
|
),
|
||||||
|
)
|
||||||
services_ini: Optional[str] = Field(
|
services_ini: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional INI text shipped to the agent as /etc/decnet/services.ini",
|
description="Optional INI text shipped to the agent as /etc/decnet/services.ini",
|
||||||
@@ -166,13 +175,15 @@ def _is_excluded(rel: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _render_decnet_ini(master_host: str) -> bytes:
|
def _render_decnet_ini(master_host: str, use_ipvlan: bool = False) -> bytes:
|
||||||
|
ipvlan_line = f"ipvlan = {'true' if use_ipvlan else 'false'}\n"
|
||||||
return (
|
return (
|
||||||
"; Generated by DECNET agent-enrollment bundle.\n"
|
"; Generated by DECNET agent-enrollment bundle.\n"
|
||||||
"[decnet]\n"
|
"[decnet]\n"
|
||||||
"mode = agent\n"
|
"mode = agent\n"
|
||||||
"disallow-master = true\n"
|
"disallow-master = true\n"
|
||||||
"log-directory = /var/log/decnet\n"
|
"log-directory = /var/log/decnet\n"
|
||||||
|
f"{ipvlan_line}"
|
||||||
"\n"
|
"\n"
|
||||||
"[agent]\n"
|
"[agent]\n"
|
||||||
f"master-host = {master_host}\n"
|
f"master-host = {master_host}\n"
|
||||||
@@ -197,6 +208,7 @@ def _build_tarball(
|
|||||||
issued: pki.IssuedCert,
|
issued: pki.IssuedCert,
|
||||||
services_ini: Optional[str],
|
services_ini: Optional[str],
|
||||||
updater_issued: Optional[pki.IssuedCert] = None,
|
updater_issued: Optional[pki.IssuedCert] = None,
|
||||||
|
use_ipvlan: bool = False,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Gzipped tarball with:
|
"""Gzipped tarball with:
|
||||||
- full repo source (minus excludes)
|
- full repo source (minus excludes)
|
||||||
@@ -216,7 +228,7 @@ def _build_tarball(
|
|||||||
continue
|
continue
|
||||||
tar.add(path, arcname=rel, recursive=False)
|
tar.add(path, arcname=rel, recursive=False)
|
||||||
|
|
||||||
_add_bytes(tar, "etc/decnet/decnet.ini", _render_decnet_ini(master_host))
|
_add_bytes(tar, "etc/decnet/decnet.ini", _render_decnet_ini(master_host, use_ipvlan))
|
||||||
for unit in _SYSTEMD_UNITS:
|
for unit in _SYSTEMD_UNITS:
|
||||||
_add_bytes(
|
_add_bytes(
|
||||||
tar,
|
tar,
|
||||||
@@ -329,11 +341,15 @@ async def create_enroll_bundle(
|
|||||||
"cert_bundle_path": str(bundle_dir),
|
"cert_bundle_path": str(bundle_dir),
|
||||||
"enrolled_at": datetime.now(timezone.utc),
|
"enrolled_at": datetime.now(timezone.utc),
|
||||||
"notes": "enrolled via UI bundle",
|
"notes": "enrolled via UI bundle",
|
||||||
|
"use_ipvlan": req.use_ipvlan,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Render payload + bootstrap.
|
# 3. Render payload + bootstrap.
|
||||||
tarball = _build_tarball(req.master_host, req.agent_name, issued, req.services_ini, updater_issued)
|
tarball = _build_tarball(
|
||||||
|
req.master_host, req.agent_name, issued, req.services_ini, updater_issued,
|
||||||
|
use_ipvlan=req.use_ipvlan,
|
||||||
|
)
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL
|
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
const [masterHost, setMasterHost] = useState(window.location.hostname);
|
const [masterHost, setMasterHost] = useState(window.location.hostname);
|
||||||
const [agentName, setAgentName] = useState('');
|
const [agentName, setAgentName] = useState('');
|
||||||
const [withUpdater, setWithUpdater] = useState(true);
|
const [withUpdater, setWithUpdater] = useState(true);
|
||||||
|
const [useIpvlan, setUseIpvlan] = useState(false);
|
||||||
const [servicesIni, setServicesIni] = useState<string | null>(null);
|
const [servicesIni, setServicesIni] = useState<string | null>(null);
|
||||||
const [servicesIniName, setServicesIniName] = useState<string | null>(null);
|
const [servicesIniName, setServicesIniName] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -49,6 +50,7 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setAgentName('');
|
setAgentName('');
|
||||||
setWithUpdater(true);
|
setWithUpdater(true);
|
||||||
|
setUseIpvlan(false);
|
||||||
setServicesIni(null);
|
setServicesIni(null);
|
||||||
setServicesIniName(null);
|
setServicesIniName(null);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
@@ -64,6 +66,7 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
master_host: masterHost,
|
master_host: masterHost,
|
||||||
agent_name: agentName,
|
agent_name: agentName,
|
||||||
with_updater: withUpdater,
|
with_updater: withUpdater,
|
||||||
|
use_ipvlan: useIpvlan,
|
||||||
services_ini: servicesIni,
|
services_ini: servicesIni,
|
||||||
});
|
});
|
||||||
setResult(res.data);
|
setResult(res.data);
|
||||||
@@ -130,6 +133,16 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<span>Install updater daemon (lets the master push code updates to this agent)</span>
|
<span>Install updater daemon (lets the master push code updates to this agent)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="form-inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useIpvlan}
|
||||||
|
onChange={(e) => setUseIpvlan(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Use IPvlan instead of MACVLAN (required for VirtualBox/VMware guests bridged over Wi-Fi — Wi-Fi APs bind one MAC per station, so MACVLAN rotates the VM's lease)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Services INI (optional)
|
Services INI (optional)
|
||||||
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
|
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
|
||||||
|
|||||||
@@ -154,6 +154,28 @@ async def test_deploy_automode_resets_stale_host_uuid(client, auth_token, monkey
|
|||||||
await repo.set_state("deployment", None)
|
await repo.set_state("deployment", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_deploy_automode_flips_ipvlan_for_opted_in_host(client, auth_token, monkeypatch):
|
||||||
|
"""A host enrolled with use_ipvlan=True must receive a DecnetConfig with
|
||||||
|
ipvlan=True in its shard — sharding is per-host, so _worker_config flips it."""
|
||||||
|
from decnet.web.router.swarm.api_deploy_swarm import _worker_config
|
||||||
|
from decnet.config import DecnetConfig, DeckyConfig
|
||||||
|
|
||||||
|
base = DecnetConfig(
|
||||||
|
mode="swarm", interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1",
|
||||||
|
ipvlan=False,
|
||||||
|
deckies=[DeckyConfig(
|
||||||
|
name="decky-1", ip="192.168.1.10", services=["ssh"],
|
||||||
|
distro="debian", base_image="debian:bookworm-slim", hostname="decky-1",
|
||||||
|
host_uuid="h1",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
opted_in = {"uuid": "h1", "name": "w1", "use_ipvlan": True}
|
||||||
|
opted_out = {"uuid": "h1", "name": "w1", "use_ipvlan": False}
|
||||||
|
assert _worker_config(base, base.deckies, opted_in).ipvlan is True
|
||||||
|
assert _worker_config(base, base.deckies, opted_out).ipvlan is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deployment_mode_endpoint(client, auth_token, monkeypatch):
|
async def test_deployment_mode_endpoint(client, auth_token, monkeypatch):
|
||||||
monkeypatch.setenv("DECNET_MODE", "master")
|
monkeypatch.setenv("DECNET_MODE", "master")
|
||||||
|
|||||||
@@ -78,6 +78,42 @@ async def test_bundle_urls_use_master_host_not_request_base(client, auth_token):
|
|||||||
assert "testserver" not in sh
|
assert "testserver" not in sh
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_use_ipvlan_opt_in_persists_and_bakes_into_ini(client, auth_token):
|
||||||
|
"""use_ipvlan=True must persist on the SwarmHost row AND bake `ipvlan = true`
|
||||||
|
into the agent's decnet.ini so locally-initiated deploys also use IPvlan."""
|
||||||
|
from decnet.web.dependencies import repo
|
||||||
|
|
||||||
|
resp = await _post(client, auth_token, agent_name="ipv-node", use_ipvlan=True)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
host_uuid = resp.json()["host_uuid"]
|
||||||
|
token = resp.json()["token"]
|
||||||
|
|
||||||
|
row = await repo.get_swarm_host_by_uuid(host_uuid)
|
||||||
|
assert row["use_ipvlan"] is True
|
||||||
|
|
||||||
|
tgz = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||||
|
assert tgz.status_code == 200
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(tgz.content), mode="r:gz") as tar:
|
||||||
|
ini = tar.extractfile("etc/decnet/decnet.ini").read().decode()
|
||||||
|
assert "ipvlan = true" in ini
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_use_ipvlan_default_false(client, auth_token):
|
||||||
|
from decnet.web.dependencies import repo
|
||||||
|
|
||||||
|
resp = await _post(client, auth_token, agent_name="macv-node")
|
||||||
|
assert resp.status_code == 201
|
||||||
|
row = await repo.get_swarm_host_by_uuid(resp.json()["host_uuid"])
|
||||||
|
assert row["use_ipvlan"] is False
|
||||||
|
|
||||||
|
tgz = await client.get(f"/api/v1/swarm/enroll-bundle/{resp.json()['token']}.tgz")
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(tgz.content), mode="r:gz") as tar:
|
||||||
|
ini = tar.extractfile("etc/decnet/decnet.ini").read().decode()
|
||||||
|
assert "ipvlan = false" in ini
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_duplicate_agent_name_409(client, auth_token):
|
async def test_duplicate_agent_name_409(client, auth_token):
|
||||||
r1 = await _post(client, auth_token, agent_name="dup-node")
|
r1 = await _post(client, auth_token, agent_name="dup-node")
|
||||||
|
|||||||
Reference in New Issue
Block a user