| 2026-03-13 | Jacek Kowalski | ![]() |
| 2026-03-13 | Jacek Kowalski | ![]() |
| 2026-03-12 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| 2026-03-11 | Jacek Kowalski | ![]() |
| .github/workflows/release.yml | ●●●●● patch | view | raw | blame | history | |
| .github/workflows/test.yml | ●●●●● patch | view | raw | blame | history | |
| Dockerfile | ●●●●● patch | view | raw | blame | history | |
| README.md | ●●●●● patch | view | raw | blame | history | |
| config.json | ●●●●● patch | view | raw | blame | history | |
| lib/NetworkDriver.py | ●●●●● patch | view | raw | blame | history | |
| package.sh | ●●●●● patch | view | raw | blame | history | |
| requirements.txt | ●●●●● patch | view | raw | blame | history | |
| run.py | ●●●●● patch | view | raw | blame | history | |
| test_integration.sh | ●●●●● patch | view | raw | blame | history |
.github/workflows/release.yml
@@ -5,23 +5,48 @@ tags: - 'v[0-9]+.*' permissions: contents: read jobs: deploy: deploy-to-ghcr-io: name: Publish to GitHub Container Registry runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout code uses: actions/checkout@v6 - name: Log in to the Container registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Prepare and push Docker plugin run: | export NAME="ghcr.io/${{ github.repository }}" export VERSIONS="latest ${GITHUB_REF/refs\/tags\//}" ./package.sh deploy-to-docker-hub: name: Publish to Docker Hub runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 uses: actions/checkout@v6 - name: Login to Docker Hub run: | docker login -u 'jacekkow' -p '${{ secrets.DOCKER_PASSWORD }}' uses: docker/login-action@v4 with: username: ${{ github.actor }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Prepare and push Docker plugin run: | VERSIONS="latest ${GITHUB_REF/refs\/tags\//}" export VERSIONS export NAME="${{ github.actor }}/pyveth" export VERSIONS="latest ${GITHUB_REF/refs\/tags\//}" ./package.sh for VERSION in ${VERSIONS}; do docker plugin push "jacekkow/pyveth:${VERSION}" done .github/workflows/test.yml
@@ -6,16 +6,19 @@ branches: - '**' permissions: contents: read jobs: unit_test: name: Unit tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v1 uses: actions/setup-python@v6 with: python-version: 3.x @@ -35,7 +38,7 @@ VERSIONS: dev steps: - name: Checkout code uses: actions/checkout@v2 uses: actions/checkout@v6 - name: Prepare Docker plugin run: | Dockerfile
@@ -8,8 +8,8 @@ WORKDIR /usr/src/app COPY --chown=nobody:nobody requirements.txt . RUN pip3 install --user --no-cache-dir -r requirements.txt RUN python -m venv venv && ./venv/bin/pip install --no-cache-dir -r requirements.txt COPY --chown=nobody:nobody . . CMD [ "./run.py" ] CMD [ "./venv/bin/python", "run.py" ] README.md
@@ -31,14 +31,62 @@ One will be pushed inside the container and another will remain on host (without any IP assigned). Plugin accepts optional `parent` parameter, which is a name of bridge interface that the second interface should be added to: ## Options To use options, add `--opt option=value` as an argument of `docker network create`: ```bash docker network create --driver jacekkow/pyveth:latest --opt parent=br0 new-network ``` This way host interface will be automatically attached to the specified bridge. Available options: `parent=brname` Automatically attach host interface to the bridge interface `brname`. `nogw=1` Disable assignment of gateway IP. `nogw4=1` Disable assignment of IPv4 gateway IP. `nogw6=1` Disable assignment of IPv6 gateway IP. `gw4=IP` `gw6=IP` Forces assignment of a specified gateway (only if one is not provided by the IPAM module) when creating the interface. Useful for [pyipam](https://github.com/jacekkow/docker-plugin-pyipam) with `ptp=1` option and `nogw=1`/`nogw4=1`/`nogw6=1` here. Using these would add routes like: ``` default via IP dev eth0 IP dev eth0 scope link ``` ## Container creation options To use these options add `--network name=network_name,driver-opt=option=value,driver-opt=option=value` to the `docker run` invocation: ```bash docker run -i -t --rm --network name=test,ip=192.168.1.1,driver-opt=gw4=192.168.0.1,driver-opt=gw6=fe80:: alpine ``` Available options: `gw4=IP` `gw6=IP` Overrides network-level gw4/gw6 options. ## Manual packaging config.json
@@ -2,7 +2,7 @@ "description": "pyveth - veth network driver in Python", "documentation": "https://github.com/jacekkow/docker-plugin-pyveth", "workdir": "/usr/src/app", "entrypoint": ["./run.py"], "entrypoint": ["./venv/bin/python", "run.py"], "env": [ { "name": "ENVIRONMENT", lib/NetworkDriver.py
@@ -37,10 +37,19 @@ ip.link('set', index=idx, state='up') if 'parent' in network.Options: id_parent = ip.link_lookup(ifname=network.Options['parent'])[0] print(ip.link("set", index=idx, master=id_parent)) ip.link("set", index=idx, master=id_parent) endpoint.Interface.Peer = ifname1 return ifname0 return ifname0, ifname1 def delete_interface(interface): try: with pyroute2.IPRoute() as ip: idx = ip.link_lookup(ifname=interface)[0] ip.link("delete", index=idx) except: pass @app.route('/NetworkDriver.GetCapabilities', methods=['POST']) @@ -48,6 +57,21 @@ return { 'Scope': 'local', 'ConnectivityScope': 'global', 'GwAllocChecker': True, } @app.route('/NetworkDriver.GwAllocCheck', methods=['POST']) def GwAllocCheck(): request = GwAllocCheckEntity(**flask.request.get_json(force=True)) skip_ipv4 = skip_ipv6 = request.Options.get('com.docker.network.generic', {}).get('nogw') == '1' if request.Options.get('com.docker.network.generic', {}).get('nogw4') == '1': skip_ipv4 = True if request.Options.get('com.docker.network.generic', {}).get('nogw6') == '1': skip_ipv6 = True return { 'SkipIPv4': skip_ipv4, 'SkipIPv6': skip_ipv6, } @@ -108,7 +132,10 @@ network = networks[join.NetworkID] endpoint = endpoints['{}-{}'.format(join.NetworkID, join.EndpointID)] interface = create_interface(endpoint, network) interface, interface_external = create_interface(endpoint, network) endpoint.internal_interface_name = interface endpoint.external_interface_name = interface_external gw4 = None for net4 in network.IPv4: @@ -130,17 +157,35 @@ 'SrcName': interface, 'DstPrefix': 'eth', }, 'StaticRoutes': [], } if gw4 is not None: result['Gateway'] = gw4.ip.compressed if gw6 is not None: result['GatewayIPv6'] = gw6.ip.compressed gw4 = endpoint.Options.get("gw4", network.Options.get("gw4", None)) if gw4 is not None: result['StaticRoutes'].append({ 'Destination': gw4 + '/32', 'RouteType': 1, }) result['Gateway'] = gw4 gw6 = endpoint.Options.get("gw6", network.Options.get("gw6", None)) if gw6 is not None: result['StaticRoutes'].append({ 'Destination': gw6 + '/128', 'RouteType': 1, }) result['GatewayIPv6'] = gw6 return result @app.route('/NetworkDriver.Leave', methods=['POST']) def Leave(): leave = LeaveEntity(**flask.request.get_json(force=True)) endpoint = endpoints.get('{}-{}'.format(leave.NetworkID, leave.EndpointID), None) if endpoint is not None and endpoint.external_interface_name: delete_interface(endpoint.external_interface_name) return {} package.sh
@@ -1,6 +1,6 @@ #!/bin/sh #!/bin/bash set -x set -e -x NAME=${NAME:-jacekkow/pyveth} VERSIONS=${VERSIONS:-latest} @@ -14,14 +14,16 @@ sudo mkdir -p rootfs docker export "${id}" | sudo tar -x -C rootfs docker rm -vf "${id}" docker plugin disable "${NAME}" docker plugin rm "${NAME}" sudo chmod 755 rootfs rootfs/usr/src/app/.local && sudo chmod -R o=g rootfs/usr/src for VERSION in ${VERSIONS}; do sudo docker plugin create "${NAME}:${VERSION}" . done sudo du -hs rootfs for VERSION in ${VERSIONS}; do docker plugin enable "${NAME}:${VERSION}" || exit 1 break done docker plugin disable "${NAME}" || true docker plugin rm "${NAME}" || true sudo chmod 755 rootfs && sudo chmod -R o=g rootfs/usr/src if [ `echo ${VERSIONS} | wc -w` -gt 1 ]; then for VERSION in ${VERSIONS}; do sudo docker plugin create "${NAME}:${VERSION}" . docker plugin push "${NAME}:${VERSION}" docker plugin rm "${NAME}:${VERSION}" done else sudo docker plugin create "${NAME}:${VERSIONS}" . docker plugin enable "${NAME}:${VERSIONS}" fi requirements.txt
@@ -1,4 +1,4 @@ docker-plugin-api>=0.3 docker-plugin-api>=0.4 Flask pyroute2 waitress run.py
@@ -2,6 +2,8 @@ import logging import os import signal import sys import docker_plugin_api.Plugin import flask @@ -20,4 +22,5 @@ if os.environ.get('ENVIRONMENT', 'dev') == 'dev': app.run(debug=True) else: signal.signal(signal.SIGTERM, lambda: sys.exit(0)) waitress.serve(app, unix_socket='/run/docker/plugins/pyveth.sock', threads=1) test_integration.sh
@@ -1,14 +1,21 @@ #!/bin/sh #!/bin/bash set -e set -e -x NAME=${NAME:-jacekkow/pyveth} VERSION=${VERSION:-latest} PLUGIN="${NAME}:${VERSION}" docker network rm test1 || true docker network rm test2 || true docker plugin install jacekkow/pyipam:latest || true ########################## # Test address assignment docker network create \ --internal \ --driver "${PLUGIN}" \ @@ -23,26 +30,40 @@ test1 ADDRESSES=$(docker run --rm --network test1 \ debian \ /bin/ip addr show alpine \ /sbin/ip addr show ) echo "${ADDRESSES}" | grep 192.168.255.129/24 echo "${ADDRESSES}" | grep 2001:db8:aaaa:bbbb::1/32 if ! echo "${ADDRESSES}" | grep 192.168.255.129/24; then echo "ERROR: invalid IPv4 address assigned" exit 1 fi if ! echo "${ADDRESSES}" | grep 2001:db8:aaaa:bbbb::1/32; then echo "ERROR: invalid IPv6 address assigned" exit 1 fi ADDRESSES=$(docker run --rm --network test1 \ --ip 192.168.255.25 --ip6 2001:db8:dddd:eeee:ffff:1:2:3 \ debian \ /bin/ip addr show alpine \ /sbin/ip addr show ) echo "${ADDRESSES}" | grep 192.168.255.25/24 echo "${ADDRESSES}" | grep 2001:db8:dddd:eeee:ffff:1:2:3/32 if ! echo "${ADDRESSES}" | grep 192.168.255.25/24; then echo "ERROR: invalid IPv4 address assigned" exit 1 fi if ! echo "${ADDRESSES}" | grep 2001:db8:dddd:eeee:ffff:1:2:3/32; then echo "ERROR: invalid IPv6 address assigned" exit 1 fi docker network rm test1 ###################### # Test routing config docker network create \ --internal \ --driver "${PLUGIN}" \ --ipam-driver jacekkow/pyipam:latest \ --ipv6 \ @@ -53,15 +74,180 @@ test2 ROUTES=$(docker run --rm --network test2 \ debian \ /bin/ip route show alpine \ /sbin/ip route show ) echo "${ROUTES}" | grep 192.168.255.254 if ! echo "${ROUTES}" | grep 192.168.255.254; then echo "ERROR: invalid IPv4 route" exit 1 fi ROUTES=$(docker run --rm --network test2 \ debian \ /bin/ip -6 route show alpine \ /sbin/ip -6 route show ) echo "${ROUTES}" | grep 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff if ! echo "${ROUTES}" | grep 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff; then echo "ERROR: invalid IPv6 route" exit 1 fi docker network rm test2 ############## # Test nogw=1 docker network create \ --driver "${PLUGIN}" \ --opt nogw=1 \ --ipam-driver jacekkow/pyipam:latest \ --ipv6 \ --subnet 192.168.255.0/24 \ --subnet 2001:db8::/32 \ test2 ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip route show ) if echo "${ROUTES}" | grep 192.168.255.254; then echo "ERROR: IPv4 route assigned with nogw=1" exit 1 fi ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip -6 route show ) if echo "${ROUTES}" | grep 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff; then echo "ERROR: IPv6 route assigned with nogw=1" exit 1 fi docker network rm test2 ############### # Test nogw4=1 docker network create \ --driver "${PLUGIN}" \ --opt nogw4=1 \ --ipam-driver jacekkow/pyipam:latest \ --ipv6 \ --subnet 192.168.255.0/24 \ --subnet 2001:db8::/32 \ --gateway 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff \ test2 ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip route show ) if echo "${ROUTES}" | grep 192.168.255.254; then echo "ERROR: IPv4 route assigned with nogw4=1" exit 1 fi ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip -6 route show ) if ! echo "${ROUTES}" | grep 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff; then echo "ERROR: IPv6 route not assigned with nogw4=1" exit 1 fi docker network rm test2 ################ # Test nogw6=1 docker network create \ --driver "${PLUGIN}" \ --opt nogw6=1 \ --ipam-driver jacekkow/pyipam:latest \ --ipv6 \ --subnet 192.168.255.0/24 \ --gateway 192.168.255.254 \ --subnet 2001:db8::/32 \ test2 ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip route show ) if ! echo "${ROUTES}" | grep 192.168.255.254; then echo "ERROR: IPv4 route not assigned with nogw6=1" exit 1 fi ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip -6 route show ) if echo "${ROUTES}" | grep 2001:db8:ffff:ffff:ffff:ffff:ffff:ffff; then echo "ERROR: IPv6 route assigned with nogw6=1" exit 1 fi docker network rm test2 ############### # Test gw4/gw6 docker network create \ --internal \ --driver "${PLUGIN}" \ --opt nogw=1 \ --opt gw4=192.168.254.1 \ --ipam-driver jacekkow/pyipam:latest \ --ipam-opt ptp=1 \ --ipv6 \ --subnet 192.168.255.0/24 \ --subnet 2001:db8::/32 \ test2 ADDRESSES=$(docker run --rm --network test2 \ alpine \ /sbin/ip addr show ) if ! echo "${ADDRESSES}" | grep 192.168.255.0/32; then echo "ERROR: invalid PtP address assigned" exit 1 fi if ! echo "${ADDRESSES}" | grep 2001:db8::/128; then echo "ERROR: invalid PtP address assigned" exit 1 fi ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip route show ) if ! echo "${ROUTES}" | grep "via 192.168.254.1"; then echo "ERROR: IPv4 route not assigned with gw4=..." exit 1 fi ROUTES=$(docker run --rm --network test2 \ alpine \ /sbin/ip -6 route show ) if echo "${ROUTES}" | grep default; then echo "ERROR: IPv6 route assigned with nogw=1" exit 1 fi ROUTES=$(docker run --rm --network name=test2,driver-opt=gw6=fe80::1 \ alpine \ /sbin/ip -6 route show ) if ! echo "${ROUTES}" | grep "via fe80::1"; then echo "ERROR: IPv6 route not assigned with per-container gw6=..." exit 1 fi docker network rm test2