pyveth - veth driver for Docker Engine written in Python
10 files modified
416 ■■■■ changed files
.github/workflows/release.yml 43 ●●●● patch | view | raw | blame | history
.github/workflows/test.yml 9 ●●●●● patch | view | raw | blame | history
Dockerfile 4 ●●●● patch | view | raw | blame | history
README.md 54 ●●●●● patch | view | raw | blame | history
config.json 2 ●●● patch | view | raw | blame | history
lib/NetworkDriver.py 51 ●●●●● patch | view | raw | blame | history
package.sh 28 ●●●● patch | view | raw | blame | history
requirements.txt 2 ●●● patch | view | raw | blame | history
run.py 3 ●●●●● patch | view | raw | blame | history
test_integration.sh 220 ●●●●● 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