Prepare an identifiable experiment
The runner creates three FRRouting 10.4.5 containers and two dedicated Docker bridges without NAT, with container default routes removed. R1 uses 172.30.241.11, R2 uses 172.30.241.12 and 172.30.242.12, and R3 uses 172.30.242.13. Router IDs are 1.1.1.1, 2.2.2.2 and 3.3.3.3; do not confuse these identifiers with every packet’s source address. Synthetic loopbacks are 10.255.1.1 on R1 and 10.50.1.1/10.50.2.1 on R3. The image is pinned by digest. The runner requires the local image, checks the observed version and removes only resources it created, including when a phase fails.
Case: evidence for RUN handover
Before a network change supporting financial batch processing, agree expected results with networking and APS: required neighbors, service prefixes, return paths, representative probes and a rollback condition. The lab illustrates this process with synthetic ICMP and no real banking systems. When a ping originates from 10.255.1.1, the response must also reach that source. A transit-sourced ping may exercise a different return path. Record source, destination, change phase and result. This lets the next shift reproduce the observation instead of receiving only the statement network operational.
Four observation layers
Neighbors show OSPF relationships; the LSDB shows protocol information; the routing table and kernel routes show decisions and local installation; a probe observes a particular flow. None of these layers replaces all the others. An LSA may remain present without producing the expected route. A route may exist without allowing an application session because of policy, MTU, return path or an unavailable service. The runner records these layers during relevant phases but does not create an application server. Transaction or TLS acceptance therefore still requires a dedicated probe.
Run and interpret failures
Save the complete script shown below as run.py. Obtain the image with docker pull quay.io/frrouting/frr@sha256:b0faf7c8f3d8b09aea1e38f77603c745a66dbf5e26ef9718527c2fbb53cc3aed and run python3 run.py path-to-evidence.json in a writable local directory. Requires Python 3, running Docker and conflict-free subnets 172.30.241.0/24 and 172.30.242.0/24. Each change has an observable condition and a timeout. If an assertion fails, the command exits with an error; it does not convert that result into success because some pings passed. A success file is written only after checks and resource cleanup complete. Initial execution identified daemon capability requirements; the runner uses NET_ADMIN, NET_RAW and SYS_ADMIN only inside disposable containers, without mounts, published ports or host networking. Review these requirements before running in another environment.
Evidence boundaries
Three logical routers on one Mac do not demonstrate physical redundancy. A quick run does not measure production performance, convergence or resilience across data centers. The lab does not configure OSPF authentication, OSPFv3, eBGP or application protection. It also does not execute Cisco IOS XE. Use results to explain reproduced failures and plan additional checks. For an actual handover, retain approved configuration, per-flow evidence, rehearsed rollback, owners and unresolved exceptions. Completing this unit does not establish readiness for all ENCOR domains.
# Save this complete example as run.py. Requires Python 3 and running Docker.
# First pull the exact image named by IMAGE below.
# Then: python3 run.py /tmp/encor-routing-evidence.json
"""Disposable FRR OSPF lab. No host networking, mounts, or published ports."""
import datetime, hashlib, json, pathlib, subprocess, sys, time, uuid
IMAGE='quay.io/frrouting/frr@sha256:b0faf7c8f3d8b09aea1e38f77603c745a66dbf5e26ef9718527c2fbb53cc3aed'
ROOT=pathlib.Path(__file__).resolve.parent
prefix='dr-encor-'+uuid.uuid4.hex[:8]
containers=[]; networks=[]; phases=[]; checks=[]
def command(args, check=True):
r=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=45)
if check and r.returncode: raise RuntimeError(str(args)+'\n'+r.stdout+r.stderr)
return r
def ex(n,*args,check=True):return command(['exec',prefix+'-'+n,*args],check)
def cli(n,*lines):
args=['vtysh']
for line in lines:args+=['-c',line]
return ex(n,*args).stdout
def snapshot(label):
data={n:{'neighbors':json.loads(cli(n,'show ip ospf neighbor json')),
'routes':json.loads(cli(n,'show ip route json')),
'interfaces':json.loads(cli(n,'show ip ospf interface json')),
'lsdb':cli(n,'show ip ospf database'),
'kernelRoutes':ex(n,'ip','route').stdout} for n in ['r1','r2','r3']}
phases.append({'name':label,'observedAt':datetime.datetime.now(datetime.timezone.utc).isoformat,'routers':data})
return data
def verify(name, condition):
if not condition:raise AssertionError(name)
checks.append(name)
def route(n,prefix_):return json.loads(cli(n,'show ip route json')).get(prefix_,[])
def ospf(n,prefix_):return any(r.get('protocol')=='ospf' for r in route(n,prefix_))
def ping(n,address,source):return ex(n,'ping','-c','1','-W','1','-I',source,address,check=False).returncode==0
def wait_for(name, predicate):
end=time.monotonic+40
while time.monotonic<end:
if predicate:return
time.sleep(1)
raise AssertionError('Timed out: '+name)
try:
meta=json.loads(command(['image','inspect',IMAGE]).stdout)[0]
for suffix,subnet in [('backbone','172.30.241.0/24'),('branch','172.30.242.0/24')]:
name=prefix+'-'+suffix
command(['network','create','--opt','com.docker.network.bridge.enable_ip_masquerade=false','--label','dr.lab=encor-routing','--subnet',subnet,name]);networks.append(name)
for n,net,ip in [('r1','backbone','172.30.241.11'),('r2','backbone','172.30.241.12'),('r3','branch','172.30.242.13')]:
name=prefix+'-'+n
command(['run','-d','--name',name,'--label','dr.lab=encor-routing','--network',prefix+'-'+net,'--ip',ip,
'--memory','192m','--cpus','0.5','--pids-limit','100','--cap-add','NET_ADMIN','--cap-add','NET_RAW','--cap-add','SYS_ADMIN',
'--entrypoint','/bin/sh',IMAGE,'-c','sleep 3600']);containers.append(name)
command(['network','connect','--ip','172.30.242.12',prefix+'-branch',prefix+'-r2'])
for n in ['r1','r2','r3']:
ex(n,'ip','route','del','default',check=False)
verify(n+' has no default route',not ex(n,'ip','route','show','default').stdout.strip)
ex(n,'sed','-i','s/^ospfd=no/ospfd=yes/','/etc/frr/daemons')
ex(n,'touch','/etc/frr/frr.conf','/etc/frr/vtysh.conf')
ex(n,'chown','frr:frr','/etc/frr/frr.conf')
ex(n,'/usr/lib/frr/zebra','-d','-A','127.0.0.1')
ex(n,'/usr/lib/frr/ospfd','-d','-A','127.0.0.1')
wait_for(n+' daemon',lambda n=n:ex(n,'vtysh','-c','show version',check=False).returncode==0)
version=cli(n,'show version');verify(n+' runs FRR 10.4.5','10.4.5' in version)
cli(n,'configure terminal','router ospf','ospf router-id '+{'r1':'1.1.1.1','r2':'2.2.2.2','r3':'3.3.3.3'}[n])
interfaces=[('eth0','0' if n!='r3' else '10')]+([('eth1','10')] if n=='r2' else [])
for iface,area in interfaces:
cli(n,'configure terminal','interface '+iface,'ip ospf area '+area,'ip ospf network point-to-point','ip ospf hello-interval 1','ip ospf dead-interval 4')
if n in ['r1','r3']:
ips=['10.255.1.1/32'] if n=='r1' else ['10.50.1.1/32','10.50.2.1/32']
for address in ips:ex(n,'ip','address','add',address,'dev','lo')
cli(n,'configure terminal','interface lo','ip ospf area '+('0' if n=='r1' else '10'),'ip ospf passive')
wait_for('inter-area route',lambda:ospf('r1','10.50.1.1/32') and ospf('r3','10.255.1.1/32'))
baseline=snapshot('baseline-specific-prefixes')
verify('baseline loopback-source ping crosses ABR',ping('r1','10.50.1.1','10.255.1.1'))
cli('r2','configure terminal','interface eth1','ip ospf passive')
wait_for('withdraw after passive interface',lambda:not ospf('r1','10.50.1.1/32'))
snapshot('passive-breaks-transit')
verify('passive transit loses remote ping',not ping('r1','10.50.1.1','10.255.1.1'))
verify('passive retains connected transport reachability',ping('r2','172.30.242.13','172.30.242.12'))
cli('r2','configure terminal','interface eth1','no ip ospf passive')
wait_for('passive rollback',lambda:ospf('r1','10.50.1.1/32'))
verify('passive rollback restores loopback ping',ping('r1','10.50.1.1','10.255.1.1'))
snapshot('passive-recovered')
cli('r3','configure terminal','interface eth0','ip ospf hello-interval 2')
wait_for('hello mismatch withdrawal',lambda:not ospf('r1','10.50.1.1/32'))
snapshot('hello-mismatch')
verify('hello mismatch still permits connected ping',ping('r2','172.30.242.13','172.30.242.12'))
verify('hello mismatch removes remote service reachability',not ping('r1','10.50.1.1','10.255.1.1'))
cli('r3','configure terminal','interface eth0','ip ospf hello-interval 1')
wait_for('timer rollback',lambda:ospf('r1','10.50.1.1/32'))
verify('timer rollback restores remote ping',ping('r1','10.50.1.1','10.255.1.1'))
cli('r2','configure terminal','router ospf','area 10 range 10.50.0.0/16')
wait_for('summary route',lambda:ospf('r1','10.50.0.0/16') and not ospf('r1','10.50.1.1/32'))
snapshot('summary-active')
verify('summary retains real destination reachability',ping('r1','10.50.1.1','10.255.1.1'))
ex('r3','ip','address','del','10.50.1.1/32','dev','lo')
wait_for('component withdrawn',lambda:not ospf('r2','10.50.1.1/32'))
snapshot('summary-with-one-component-missing')
verify('aggregate persists with remaining component',ospf('r1','10.50.0.0/16'))
verify('missing component fails despite aggregate',not ping('r1','10.50.1.1','10.255.1.1'))
verify('remaining component still reachable',ping('r1','10.50.2.1','10.255.1.1'))
ex('r3','ip','address','add','10.50.1.1/32','dev','lo')
wait_for('component restored',lambda:ospf('r2','10.50.1.1/32'))
verify('restored component reachable',ping('r1','10.50.1.1','10.255.1.1'))
cli('r2','configure terminal','router ospf','no area 10 range 10.50.0.0/16')
wait_for('summary rollback',lambda:ospf('r1','10.50.1.1/32') and not ospf('r1','10.50.0.0/16'))
final=snapshot('all-changes-reverted')
verify('final remote probe successful',ping('r1','10.50.1.1','10.255.1.1'))
evidence={'checkedAt':datetime.datetime.now(datetime.timezone.utc).isoformat,'image':IMAGE,'imageId':meta['Id'],'architecture':meta['Architecture'],'scriptSha256':hashlib.sha256(pathlib.Path(__file__).read_bytes).hexdigest,'checks':checks,'phases':phases,'limitations':['FRRouting on Docker Desktop, not Cisco IOS XE execution','Three logical routers share one physical host','FRR requires NET_ADMIN, NET_RAW and SYS_ADMIN inside disposable containers; no host mounts, devices, networking or privileged mode','Accelerated 1/4-second timers for teaching, not a production recommendation','ICMP evidence is not application or TLS acceptance','No OSPFv3, authentication, performance, or full ENCOR readiness verified'],'cleanup':None}
except Exception:
try:
snapshot('failure-diagnostic')
pathlib.Path(sys.argv[1]+'.failure.json').write_text(json.dumps({'phases':phases,'configs':{n:cli(n,'show running-config') for n in ['r1','r2','r3']}},indent=2))
except Exception as error: print('Diagnostic failure:',error)
raise
finally:
cleanup=[]
for name in reversed(containers):cleanup.append({'container':name,'exit':command(['rm','-f',name],False).returncode})
for name in reversed(networks):cleanup.append({'network':name,'exit':command(['network','rm',name],False).returncode})
if any(x['exit'] for x in cleanup):raise RuntimeError(cleanup)
evidence['cleanup']=cleanup
pathlib.Path(sys.argv[1]).write_text(json.dumps(evidence,indent=2)+'\n')
print(json.dumps({'checks':len(checks),'phases':len(phases),'imageId':meta['Id'],'cleanup':True}))
A Full neighbor and installed route support proceeding to the probe; the transaction still needs its own validation.
Common pitfalls
Confusing ICMP with a transaction; omitting source; treating timeout as success; counting containers as physical diversity.
Related topics: OSPF: adjacency and controlled failures · OSPF: areas, summarization and covered destinations
Deliver reproducible evidence with context and limits as well as the aggregate result.
Reference: FRRouting OSPFv2 · 350-401 ENCOR v1.2, effective 2026-03-19; core component of CCNP Enterprise