pyIPAM - Docker Plugin for IPAM written in Python
10 files modified
224 ■■■■ 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 25 ●●●●● patch | view | raw | blame | history
config.json 2 ●●● patch | view | raw | blame | history
lib/Ipam.py 26 ●●●●● patch | view | raw | blame | history
package.sh 28 ●●●●● patch | view | raw | blame | history
run.py 3 ●●●●● patch | view | raw | blame | history
test/IpamPoolTest.py 32 ●●●●● patch | view | raw | blame | history
test_integration.sh 52 ●●●●● 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 }}/pyipam"
          export VERSIONS="latest ${GITHUB_REF/refs\/tags\//}"
          ./package.sh
          for VERSION in ${VERSIONS}; do
            docker plugin push "jacekkow/pyipam:${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
@@ -26,6 +26,31 @@
Check out [`test_integration.sh`](test_integration.sh) for more examples.
## Options
To use options, add `--ipam-opt option=value` as an argument of `docker network create`:
```bash
docker network create --ipam-driver jacekkow/pyipam:latest --ipam-opt ptp=1 new-network
```
Available options:
`ptp=1`
When set addresses with netmask /32 (IPv4) or /128 (IPv6) are handed out.
In this mode all IP addresses are handed out from the subnet,
including ones that would be "network address" and "broadcast address"!
`validate=0`
Do not validate duplicate IP address assignment. This IPAM plugin would
then happily hand out already-used addresses if such were manually specified.
This option does not affect automatic assignments.
Note that this module does not track how many times the IP was handed out,
hence if two containers have the same IP and one of them stops,
IP will be marked as free!
## Manual packaging
In order to test this module in development environment, you can build it
config.json
@@ -2,7 +2,7 @@
    "description": "pyIPAM - IPAM driver in Python",
    "documentation": "https://github.com/jacekkow/docker-plugin-pyipam",
    "workdir": "/usr/src/app",
    "entrypoint": ["./run.py"],
    "entrypoint": ["./venv/bin/python", "./run.py"],
    "env": [
        {
            "name": "ENVIRONMENT",
lib/Ipam.py
@@ -12,8 +12,7 @@
    def __init__(self, pool: str = None, options: dict = None, subPool: str = None, v6: bool = None):
        if pool == '':
            pool = None
        if options is None:
            options = {}
        self.options = options or {}
        if subPool == '':
            subPool = None
@@ -42,8 +41,11 @@
        if not self.subpool.subnet_of(self.pool):
            raise InputValidationException('Subpool must be a subnet of pool')
        self.validate = self.options.get('validate', '1') == '1'
        self.ptp = self.options.get('ptp', '0') == '1'
        self.allocations = set()
        self.current = self.subpool.hosts()
        self.current = self.subpool.hosts() if not self.ptp else self.subpool.__iter__()
        self.v6 = isinstance(self.pool, ipaddress.IPv6Network)
@@ -62,7 +64,7 @@
        for address in self.current:
            if not self._is_allocated(address):
                return address
        self.current = self.subpool.hosts()
        self.current = self.subpool.hosts() if not self.ptp else self.subpool.__iter__()
        for address in self.current:
            if not self._is_allocated(address):
                return address
@@ -74,19 +76,23 @@
        else:
            address = ipaddress.ip_address(address)
        if self.pool.network_address == address:
            raise InputValidationException('Cannot allocate network address to a host')
        if not self.v6 and self.pool.broadcast_address == address:
            raise InputValidationException('Cannot allocate broadcast address to a host')
        if not self.ptp:
            if self.pool.network_address == address:
                raise InputValidationException('Cannot allocate network address to a host')
            if not self.v6 and self.pool.broadcast_address == address:
                raise InputValidationException('Cannot allocate broadcast address to a host')
        if address not in self.pool:
            raise InputValidationException('Requested address does not belong to a pool')
        address = str(address)
        if self._is_allocated(address):
        if self.validate and self._is_allocated(address):
            raise InputValidationException('Requested address {} is already used'.format(address))
        self.allocations.add(address)
        return '{}/{}'.format(address, self.pool.prefixlen)
        prefixlen = self.pool.prefixlen
        if self.ptp:
            prefixlen = 128 if self.v6 else 32
        return '{}/{}'.format(address, prefixlen)
    def deallocate(self, address: str):
        address = ipaddress.ip_address(address)
package.sh
@@ -1,4 +1,6 @@
#!/bin/bash -x
#!/bin/bash
set -e -x
NAME=${NAME:-jacekkow/pyipam}
VERSIONS=${VERSIONS:-latest}
@@ -12,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
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/pyipam.sock', threads=1)
test/IpamPoolTest.py
@@ -294,3 +294,35 @@
        pool.deallocate('fe80::2')
        self.assertEqual(pool.allocate('fe80::2'), 'fe80::2/125')
        self.assertEqual(pool.allocate(), 'fe80::1/125')
class TestPoolPointToPoint(unittest.TestCase):
    def test_pool_allocate_ptp_ipv4(self):
        pool = Pool(pool='127.0.0.0/30', options={'ptp': '1'})
        self.assertEqual(pool.allocate(), '127.0.0.0/32')
        self.assertEqual(pool.allocate(), '127.0.0.1/32')
        self.assertEqual(pool.allocate(), '127.0.0.2/32')
        self.assertEqual(pool.allocate(), '127.0.0.3/32')
    def test_pool_allocate_ptp_ipv6(self):
        pool = Pool(pool='fe80::/126', options={'ptp': '1'})
        self.assertEqual(pool.allocate(), 'fe80::/128')
        self.assertEqual(pool.allocate(), 'fe80::1/128')
        self.assertEqual(pool.allocate(), 'fe80::2/128')
        self.assertEqual(pool.allocate(), 'fe80::3/128')
class TestPoolWithoutValidation(unittest.TestCase):
    def test_pool_allocate_duplicates_ipv4(self):
        pool = Pool(pool='127.0.0.0/30', options={'validate': '0'})
        self.assertEqual(pool.allocate(), '127.0.0.1/30')
        self.assertEqual(pool.allocate(), '127.0.0.2/30')
        self.assertEqual(pool.allocate('127.0.0.1'), '127.0.0.1/30')
        self.assertEqual(pool.allocate('127.0.0.1'), '127.0.0.1/30')
    def test_pool_allocate_duplicates_ipv6(self):
        pool = Pool(pool='fe80::/126', options={'validate': '0'})
        self.assertEqual(pool.allocate(), 'fe80::1/126')
        self.assertEqual(pool.allocate(), 'fe80::2/126')
        self.assertEqual(pool.allocate(), 'fe80::3/126')
        self.assertEqual(pool.allocate('fe80::1'), 'fe80::1/126')
        self.assertEqual(pool.allocate('fe80::1'), 'fe80::1/126')
test_integration.sh
@@ -1,12 +1,15 @@
#!/bin/bash
set -e
set -e -x
NAME=${NAME:-jacekkow/pyipam}
VERSION=${VERSION:-latest}
PLUGIN="${NAME}:${VERSION}"
docker network rm test1 || true
docker network rm test2 || true
docker network create \
  --internal \
  --driver bridge \
@@ -21,26 +24,37 @@
  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
docker network create \
  --internal \
  --driver bridge \
  --ipam-driver "${PLUGIN}" \
  --ipv6 \
@@ -51,15 +65,21 @@
  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