eyz / docker-ipam-proxy-plugin

Docker IPAM Plugin (v2) proxy to remote HTTP IPAM service
MIT License
3 stars 0 forks source link

Is there HTTP IPAM service which is compatible with this plugin #1

Closed olljanat closed 5 years ago

olljanat commented 5 years ago

Thanks for sharing this.

How ever I wondering that did you ever implement actual IPAM service which is compatible with this pluging?

eyz commented 5 years ago

@olljanat I started on one when my company was likely going with Docker Swarm, but we ended up switching to Kubernetes, so I stopped development on it.

It was mostly implementing some backend APIs, KoaJS as the HTTP(S) server framework (in NodeJS), with a Vue.js frontend (just to view IPAM usage, not to implement any changes to the backend IPs / pools), PowerDNS integration, and a RocksDB backend KV store.

It used the docker-ipam-proxy-plugin from this repo.

Please note that I am not offering any support or improvements to the following code. It can be also considered released under the MIT License (which the plugin in this repo is)

Also, I scrubbed quite a lot of organizational strings out which were hardcoded in various places, so I expect the test suite likely wouldnt pass without adjustments.

Here's most of it -

server.js -

// from https://github.com/mgtitimoli/await-mutex
// await-mutex is Unlicense licensed
class Mutex {
  constructor() {
    this._locking = Promise.resolve();
    this._locked = false;
  }

  isLocked() {
    return this._locked;
  }

  lock() {
    this._locked = true;
    let unlockNext,
        willLock = new Promise(resolve => unlockNext = resolve);
    willLock.then(() => this._locked = false);
    let willUnlock = this._locking.then(() => unlockNext);
    this._locking = this._locking.then(() => willLock);
    return willUnlock;
  }
}

const koa = require('koa'),
      requests = require('koa-log-requests'),
      appHandlers = new koa(),
      appStatic = new koa(),
      koaJsonBody = require('koa-json-body'),
      koaMount = require('koa-mount'),
      koaStatic = require('koa-static'),
      koaSend = require('koa-send'),
      koaRouter = require('koa-router'),
      appRouter = new koaRouter(),
      level = require('level-rocksdb'),
      ipAddressLib = require('ip-address'),
      ipamDb = level('/opt/ipam/db/ipamDb'),
      ipamDbPromise = require('level-promisify')(ipamDb),
      awaitReadStream = require('await-stream-ready').read,
      ipamDbMutex = new Mutex(),
      reclaimCidrIpCooldownMs = 60*2*1000;

const trimTrailingCharacter = (stringToTrim, trailingCharacterToRemove) => {
  if (stringToTrim.charAt(stringToTrim.length - 1) === trailingCharacterToRemove) {
    return stringToTrim.substring(0, stringToTrim.length - 1);
  }
  return stringToTrim;
}

const trimTrailingPeriod = stringToTrim => trimTrailingCharacter(stringToTrim, '.');

//////////////////////////////////////////////////////////////////////////////////////////////////////////////
const ipOctetCompare = (a, b) => {
  if (a == b) { return 0; }
  if (a < b) { return -1; }
  return 1;
}

const cidrIpStringToIpOctetIntegerArray = (cidrIpString) => {
  return cidrIpString.split('/')[0].split('.').map(octetString => parseInt(octetString));
}

const sortCidrIps = (cidrIpA, cidrIpB) => {
  const cidrIpOctetIntegerArrayA = cidrIpStringToIpOctetIntegerArray(cidrIpA),
        cidrIpOctetIntegerArrayB = cidrIpStringToIpOctetIntegerArray(cidrIpB);
  for (octetId = 0; octetId<4; octetId++) {
    const octetCompare = ipOctetCompare(cidrIpOctetIntegerArrayA[octetId], cidrIpOctetIntegerArrayB[octetId]);
    if (octetCompare != 0) { return octetCompare; }
  }
  // we've exited early if any octets are inequal, so we've determine both octet arrays are equal (= 0)
  return 0;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////

appHandlers.use(koaJsonBody({
  fallback: true
}));

const shouldLogRequest = (ctx) => {
  if (ctx.request.url === '/instances') {
    return false;
  }

  return true;
}

appHandlers.use(async (ctx, next) => {
  await next();
  if (!shouldLogRequest(ctx)) {
    return;
  }

  let ctxResponseBodyOutput;
  if (ctx.response.type === 'application/json') {
    // reformat with indentation
    ctxResponseBodyOutput = JSON.stringify(JSON.parse(ctx.response.body), null, 2);
  } else {
    ctxResponseBodyOutput = ctx.response.body;
  }
  console.log(`ctx.ip = ${JSON.stringify(ctx.ip, null, 2)}\nctx.request = ${JSON.stringify(ctx.request, null, 2)}\nctx.request.body = ${JSON.stringify(ctx.request.body, null, 2)}\nctx.response = ${JSON.stringify(ctx.response, null, 2)}\nctx.response.body = ${ctxResponseBodyOutput}\
n\n================================================================\n`);
});

const respondJsonBody = (ctx, responseObject) => {
  ctx.type = 'application/json';
  ctx.body = JSON.stringify(responseObject);
}

const respondWithAddressJson = (ctx, cidrIp) => respondJsonBody(ctx, {
  'Address': cidrIp
});

const incrementCidrIp = cidrIp => {
  const currentCidr = new ipAddressLib.Address4(cidrIp);
  let currentIntegerIp = currentCidr.bigInteger();

  const resultIp = ipAddressLib.Address4.fromBigInteger(++currentIntegerIp).address;
  if (currentCidr.subnetMask === 32) {
    return resultIp;
  } else {
    return resultIp + currentCidr.subnet;
  }
}

const getPoolDetailsByName = poolName => {
  if (poolName == 'test1' || poolName == 'test2' || poolName == 'test3') {
    return {
      name: poolName,
      cidrNet: '10.100.0.0/16',
      nextCidrIp: '10.100.1.0/16', // defaults to 10.100.1.0/16 (to leave room for host VMs on 10.100.0.0/24)
      gateway: '10.100.0.1/16'
    }
  }
  // return null if pool details not found
  return null;
}

const asyncGetPoolNextCidrIp = async poolName => {
  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    // return null if pool details not found
    console.log(`asyncGetPoolNextCidrIp: could not get pool details for pool ${poolName}`);
    return null
  }

  const key = `poolNextCidrIp:${poolDetails.cidrNet}`;

  let nextCidrIp;
  try {
    nextCidrIp = await ipamDbPromise.get(key);
  } catch (e) {
    console.log(`asyncGetPoolNextCidrIp: ipamDb did have have key ${key}; continuing with initializing this key from poolDetails`);
    nextCidrIp = poolDetails.nextCidrIp;
  }

  const nextCidrIpIncremented = incrementCidrIp(nextCidrIp);
  try {
    await ipamDbPromise.put(key, nextCidrIpIncremented);
  } catch (e) {
    console.log(`asyncGetPoolNextCidrIp: ipamDb could not update key ${key} to ${nextCidrIpIncremented}`);
    return null;
  }

  console.log(`asyncGetPoolNextCidrIp: ipamDb ${key} incremented to ${nextCidrIpIncremented}`);
  return nextCidrIp;
}

const asyncGetPoolReclaimableCidrIps = async (poolName) => {
  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    // return null if pool details not found
    console.log(`asyncAddReclaimableIpGetPoolNextCidrIp: could not get pool details for pool ${poolName}`);
    return null
  }

  const key = `poolReclaimableCidrIps:${poolDetails.cidrNet}`;

  let reclaimableCidrIps = [];
  try {
    reclaimableCidrIps = JSON.parse(await ipamDbPromise.get(key));
  } catch (e) {
    console.log(`asyncAddReclaimableIpGetPoolNextCidrIp: ipamDb did have have key ${key}; initializing with first`);
  }

  console.log(`asyncGetPoolReclaimableCidrIps: current value of ${key} = ${JSON.stringify(reclaimableCidrIps, null, 2)}`)
  return reclaimableCidrIps;
}

const asyncUpdatePoolReclaimableCidrIps = async (poolName, newReclaimableCidrIps) => {
  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    // return null if pool details not found
    console.log(`asyncUpdatePoolReclaimableCidrIps: could not get pool details for pool ${poolName}`);
    return null
  }

  const key = `poolReclaimableCidrIps:${poolDetails.cidrNet}`;

  // sort the newReclaimableCidrIps before writing the new sorted object to the database
  const newReclaimableCidrIpsSorted = newReclaimableCidrIps.sort((ipMapA, ipMapB) => sortCidrIps(ipMapA.cidrIp, ipMapB.cidrIp));

  try {
    await ipamDbPromise.put(key, JSON.stringify(newReclaimableCidrIpsSorted));
  } catch (e) {
    console.log(`asyncUpdatePoolReclaimableCidrIps: ipamDb could not update key ${key} to ${reclaimableCidrIps}`);
    return null;
  }

  console.log(`asyncUpdatePoolReclaimableCidrIps: ipamDb ${key} changed to ${newReclaimableCidrIps}`);
  return newReclaimableCidrIps;
}

const asyncAddPoolReclaimableCidrIp = async (poolName, cidrIpToAddForReclaimation) => {
  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    // return null if pool details not found
    console.log(`asyncAddReclaimableIpGetPoolNextCidrIp: could not get pool details for pool ${poolName}`);
    return null
  }

  const currentReclaimableCidrIps = await asyncGetPoolReclaimableCidrIps(poolName),
        newReclaimableCidrIps = appendCidrIpToReclaimableArray(currentReclaimableCidrIps, cidrIpToAddForReclaimation);
  return await asyncUpdatePoolReclaimableCidrIps(poolName, newReclaimableCidrIps)
}

const asyncReclaimFirstPoolCidrIp = async (poolName) => {
  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    // return null if pool details not found
    console.log(`asyncRemovePoolReclaimableCidrIp: could not get pool details for pool ${poolName}`);
    return null
  }

  const reclaimableCidrIpsUnfiltered = await asyncGetPoolReclaimableCidrIps(poolName);
  if (reclaimableCidrIpsUnfiltered === null) {
    console.log(`asyncRemovePoolReclaimableCidrIp: could not get candidate reclaimable IPs for pool: ${poolName}`);
    return null
  }

  const currentTimeInMs = Date.now(),
        reclaimableCidrIpsFiltered = reclaimableCidrIpsUnfiltered.filter(candidate => (candidate.releasedEpochMs + reclaimCidrIpCooldownMs) < currentTimeInMs)
  if (reclaimableCidrIpsFiltered.length == 0) {
    console.log(`asyncRemovePoolReclaimableCidrIp: no reclaimable IPs past their cooldown delay found for pool: ${poolName}`);
    return null
  }
  const firstReclaimedCidrIp = reclaimableCidrIpsFiltered[0].cidrIp;

  // reclaimableCidrIpsNew will be reclaimableCidrIpsUnfiltered (raw key before operation), but without the CIDR IP we picked (which was the first in the filtered list)
  const reclaimableCidrIpsNew = reclaimableCidrIpsUnfiltered.filter(lease => lease.cidrIp != firstReclaimedCidrIp)

  const updateSuccess = await asyncUpdatePoolReclaimableCidrIps(poolName, reclaimableCidrIpsNew)
  if (updateSuccess === null) {
    return null
  }

  return firstReclaimedCidrIp;
}

const splitCidrIpAndNetBits = (cidr) => {
  const splitCidr = cidr.split('/');
  return {
    ip: splitCidr[0],
    netBits: splitCidr[1]
  }
}

const asyncGetCidrIpByInstanceName = async instanceName => {
  let cidrIp;

  const key = `instanceNameToCidrIp:${instanceName}`;

  try {
    cidrIp = await ipamDbPromise.get(key);
  } catch (e) {
    console.log(`asyncGetCidrIpByInstanceName could not get key ${key}`);
    return null;
  }

  return {
    instanceName: instanceName,
    cidrIp: cidrIp,
    ip: splitCidrIpAndNetBits(cidrIp).ip
  };
}

const asyncGetInstanceNameByIp = async ip => {
  let instanceName;

  const key = `ipToInstance:${ip}`;

  try {
    instanceName = await ipamDbPromise.get(key);
  } catch (e) {
    console.log(`asyncGetInstanceNameByIp: could not get key ${key}`);
    return null;
  }

  return {
    instanceName: instanceName,
    ip: ip
  };
}

const asyncGetLeasedEpochByIp = async ip => {
  let leasedEpoch;

  const key = `ipToLeasedEpoch:${ip}`;

  try {
    leasedEpoch = await ipamDbPromise.get(key);
  } catch (e) {
    console.log(`asyncGetLeasedEpochByIp: could not get key ${key}`);
    return null;
  }

  return {
    leasedEpoch: leasedEpoch,
    ip: ip
  };
}

/*
const ipToPtrFqdn = ip => {
  return ip.split('.').reverse().join('.') + '.in-addr.arpa.';
}
*/

const ptrFqdnToIp = ptrFqdn => {
  return trimTrailingPeriod(ptrFqdn).split('.').slice(0,-2).reverse().join('.');
}

const asyncKoaSendFromStatic = async (ctx, staticRelativePath) => {
  if (typeof staticRelativePath === 'string') {
    staticRelativePath = '/' + staticRelativePath;
  } else {
    staticRelativePath = ctx.path;
  }
  return await koaSend(ctx, 'static' + staticRelativePath);
}

const respondDnsResult = (ctx, result) => respondJsonBody(ctx, {
  'result': [ result ]
});

const respondDnsResultFalse = ctx => respondDnsResult(ctx, false);

const logAndRespondWithStatus = (ctx, responseStatus, logResponse) => {
  console.log(`* ${logResponse}`)
  ctx.status = responseStatus;
  ctx.body = logResponse;
};

const getInstanceNameFromRequestBodyOptions = requestBodyOptions => {
  // if we were not passed an object, then return null (failure)
  if (typeof requestBodyOptions !== 'object') {
    return null;
  }

  // if ctx.request.body.Options.instanceName exists, then it is our instanceName to return
  if (typeof requestBodyOptions.instanceName === 'string') {
    return requestBodyOptions.instanceName;
  }

  // if we don't at least have a hostname, then return null (failure)
  if (typeof requestBodyOptions.Hostname !== 'string') {
    return null;
  }

  // the returned instanceName will at least be the hostname
  let instanceName = requestBodyOptions.Hostname;

  // if a Domainname exists, then we'll append the Domainname to the Hostname from above to make a FQDN
  if (typeof requestBodyOptions.Domainname === 'string') {
    instanceName = `${instanceName}.${requestBodyOptions.Domainname}`;
  }

  // return the resultant instanceName
  return instanceName;
}

const respondJsonEmptySuccess = ctx => {
  // return empty JSON (success)
  return respondJsonBody(ctx, {});
}

const appendCidrIpToReclaimableArray = (sourceReclaimableArray, newCidrIp) => {
  const currentTimeInMs = Date.now(),
        newElement = {
          cidrIp: newCidrIp,
          releasedEpochMs: Date.now()
        };
  return sourceReclaimableArray.concat(newElement);
}

appRouter.get('/', async ctx => asyncKoaSendFromStatic(ctx, 'index.html'));
appRouter.get('/favicon.ico', async ctx => asyncKoaSendFromStatic(ctx));
appRouter.get('/favicon.png', async ctx => asyncKoaSendFromStatic(ctx));

// TODO (20171125): DNS handlers need to get moved to new lookup functions and error reporting patterns
/*
appRouter.get('/dns/getDomainMetadata/:reqFqdn/:reqType', ctx => respondDnsResultFalse(ctx));

appRouter.get('/dns/lookup/:reqFqdn/:reqType', async ctx => {
  switch(ctx.params.reqType) {
    case 'ANY':
      if (ctx.params.reqFqdn.endsWith('.in-addr.arpa.')) {
        const lookupIp = ptrFqdnToIp(ctx.params.reqFqdn);
        const instanceNameLookup = await asyncGetInstanceNameByIp(lookupIp);
        if (instanceNameLookup.found !== true) {
          return;
        }
        return respondDnsResult(ctx, {
          'qtype': 'PTR',
          'qname': trimTrailingPeriod(ctx.params.reqFqdn),
          'content': instanceNameLookup.instanceName,
          'ttl': 0
        });
      } else {
        cidrIpLookup = await asyncGetCidrIpByInstanceName(trimTrailingPeriod(ctx.params.reqFqdn));
        if (cidrIpLookup === null) {
          return logAndRespondWithStatus(ctx, 400, `/dns/lookup could not lookup cidr IP from ${ctx.params.reqFqdn}`);
        }
        return respondDnsResult(ctx, {
          'qtype': 'A',
          'qname': cidrIpLookup.instanceName,
          'content': cidrIpLookup.ip,
          'ttl': 0
        });
      }
      break;

    case 'SOA':
      cidrIpLookup = { instanceName: trimTrailingPeriod(ctx.params.reqFqdn) };
      return respondDnsResult(ctx, {
        'qtype': 'SOA',
        'qname': cidrIpLookup.instanceName,
        'content': `ns.${cidrIpLookup.instanceName}. ns-admin.org.com. 2017071000 7200 3600 1209600 3600`,
        'ttl': 3600,
        'domain_id': -1
      });
      break;
  }
});
*/

const asyncDumpAllDbToString = async db => {
  let allData = '';
  const dbReadStream = db.createReadStream();
  dbReadStream.on('data', data => {
    allData += `${data.key} = ${data.value}\n`;
  });
  await awaitReadStream(dbReadStream);
  return allData;
}

appRouter.get('/dumpDb', async ctx => {
  ctx.type = 'text/plain';
  // ipamDB operations for this API call start here
  const unlockIpamDbMutex = await ipamDbMutex.lock();
  ctx.body = await asyncDumpAllDbToString(ipamDb);
  unlockIpamDbMutex();
});

const asyncDumpDbIpToInstanceArrayUnsorted = async (db) => {
  const dbReadStream = db.createReadStream(),
        keyPrefix = `ipToInstance:`;
  let responseObject = [];
  dbReadStream.on('data', data => {
    if (data.key.startsWith(keyPrefix)) {
      const ip = data.key.split(':')[1];
      responseObject.push({ ip: ip, name: data.value});
    }
  });
  await awaitReadStream(dbReadStream);

  // add in KVs for leasedEpoch
  for (instanceIndex in responseObject) {
    const instanceIp  = responseObject[instanceIndex].ip,
          leasedEpochObj = await asyncGetLeasedEpochByIp(instanceIp);
    responseObject[instanceIndex].leasedEpoch = leasedEpochObj.leasedEpoch;
  }

  return responseObject;
}

appRouter.get('/instances', async ctx => {
  ctx.type = 'application/json';
  // ipamDB operations for this API call start here
  const unlockIpamDbMutex = await ipamDbMutex.lock();
  const unsortedIpToInstanceArray = await asyncDumpDbIpToInstanceArrayUnsorted(ipamDb);
  unlockIpamDbMutex();
  // sort result after mutex, as DB read operation is complete
  ctx.body = JSON.stringify(unsortedIpToInstanceArray.sort((ipMapA, ipMapB) => sortCidrIps(ipMapA.ip, ipMapB.ip)));
});

appRouter.post('/IpamDriver.GetCapabilities', ctx => respondJsonBody(ctx, {
  'RequiresMACAddress': false,
  'RequiresRequestReplay': false
}));

appRouter.post('/IpamDriver.GetDefaultAddressSpaces', ctx => respondJsonBody(ctx, {
  'LocalDefaultAddressSpace': 'LOCAL',
  'GlobalDefaultAddressSpace': 'GLOBAL'
}));

appRouter.post('/IpamDriver.RequestPool', ctx => {
  let poolName;

  if (typeof ctx.request.body.Options === 'object' && typeof ctx.request.body.Options.poolName == 'string') {
    poolName = ctx.request.body.Options.poolName;
  } else {
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestPool: request.body.Options.poolName not provided`);
  }

  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestPool: pool details could not be obtained for pool: ${poolName}`);
  }

  return respondJsonBody(ctx, {
    'PoolID': poolDetails.name,
    'Pool': poolDetails.cidrNet,
    'Data': { 'conflictCheck': 'skip' }
  });
});

appRouter.post('/IpamDriver.RequestAddress', async ctx => {
  let poolName;

  if (typeof ctx.request.body.PoolID === 'string') {
    poolName = ctx.request.body.PoolID;
  } else {
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: request.body.PoolID (pool name) not provided`);
  }

  // ipamDB operations for this API call start here
  const unlockIpamDbMutex = await ipamDbMutex.lock();

  if (typeof ctx.request.body.Options !== 'object') {
    const nextCidrIp = await asyncGetPoolNextCidrIp(poolName);
    unlockIpamDbMutex();
    return respondWithAddressJson(ctx, nextCidrIp);
  }

  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    unlockIpamDbMutex();
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: pool details could not be obtained for pool: ${poolName}`);
  }

  if (typeof ctx.request.body.Options.RequestAddressType === 'string' && ctx.request.body.Options.RequestAddressType == 'com.docker.network.gateway') {
    unlockIpamDbMutex();
    return respondJsonBody(ctx, {
      'Address': poolDetails.gateway
    });
  }

  const instanceName = getInstanceNameFromRequestBodyOptions(ctx.request.body.Options);
  if (instanceName === null) {
    unlockIpamDbMutex();
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: request.Body.Options did not have either of the following: instanceName, Hostname`);
  }

  const cidrIpLookup = await asyncGetCidrIpByInstanceName(instanceName);
  if (cidrIpLookup !== null) {
    unlockIpamDbMutex();
    return respondWithAddressJson(ctx, cidrIpLookup.cidrIp);
  }

  let cidrIpToReturn;

  const reclaimedCidrIp = await asyncReclaimFirstPoolCidrIp(poolName);
  if (reclaimedCidrIp !== null) {
    // if we found a CIDR IP to return, then use it
    cidrIpToReturn = reclaimedCidrIp;
  } else {
    // if we did not find a CIDR IP to return, then get the next un-used CIDR IP for this pool, and use it
    const nextCidrIp = await asyncGetPoolNextCidrIp(poolName);
    if (nextCidrIp === null) {
      unlockIpamDbMutex();
      return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: could not get next CIDR IP for pool ${poolName}`);
    }
    cidrIpToReturn = nextCidrIp;
  }

  ipToReturn = splitCidrIpAndNetBits(cidrIpToReturn).ip;

  const instanceNameToCidrIpKey = `instanceNameToCidrIp:${instanceName}`,
        ipToInstanceKey = `ipToInstance:${ipToReturn}`;

  // testing start
  const currentTimeInMs = Date.now(),
        requestingIp = ctx.ip,
        ipLeaseLogKey = `ipLeaseLog:${ipToReturn}:${currentTimeInMs}`,
        ipToLeasedEpochKey = `ipToLeasedEpoch:${ipToReturn}`;

  try {
    await ipamDbPromise.batch([
      { type: 'put', key: instanceNameToCidrIpKey, value: cidrIpToReturn },
      { type: 'put', key: ipToInstanceKey, value: instanceName },
      { type: 'put', key: ipLeaseLogKey, value: `${requestingIp} requested` },
      { type: 'put', key: ipToLeasedEpochKey, value: currentTimeInMs }
    ])
  } catch (e) {
    unlockIpamDbMutex();
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.RequestAddress: could not write ${instanceNameToCidrIpKey} and/or ${ipToInstanceKey}`);
  }
  // testing end

  unlockIpamDbMutex();
  // return IP in CIDR format
  return respondWithAddressJson(ctx, cidrIpToReturn);
});

appRouter.post('/IpamDriver.ReleaseAddress', async ctx => {
  if (typeof ctx.request.body.Address !== 'string') {
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: request.Body.Address was not provided`);
  }
  const ipToRelease = ctx.request.body.Address;

  if (typeof ctx.request.body.PoolID !== 'string') {
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: request.body.PoolID (pool name) not provided`);
  }
  const poolName = ctx.request.body.PoolID;

  const poolDetails = getPoolDetailsByName(poolName);
  if (poolDetails === null) {
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: pool details could not be obtained for pool: ${poolName}`);
  }

  // ipamDB operations for this API call start here
  const unlockIpamDbMutex = await ipamDbMutex.lock();

  const instanceNameLookup = await asyncGetInstanceNameByIp(ipToRelease);
  if (instanceNameLookup === null) {
    unlockIpamDbMutex();
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: could not find instance with IP ${ipToRelease}`);
  }

  const instanceName = instanceNameLookup.instanceName;
  console.log(`* IpamDriver.ReleaseAddress found instance name ${instanceName} from IP ${ipToRelease}`);

  const delKeyA = `instanceNameToCidrIp:${instanceName}`,
        delKeyB = `ipToInstance:${ipToRelease}`,
        delKeyC = `ipToLeasedEpoch:${ipToRelease}`;

  // testing start
  const currentTimeInMs = Date.now(),
        requestingIp = ctx.ip,
        ipLeaseLogKey = `ipLeaseLog:${ipToRelease}:${currentTimeInMs}`;

  try {
    await ipamDbPromise.batch([
      { type: 'del', key: delKeyA },
      { type: 'del', key: delKeyB },
      { type: 'del', key: delKeyC },
      { type: 'put', key: ipLeaseLogKey, value: `${requestingIp} released` }
    ])
  } catch (e) {
    unlockIpamDbMutex();
    return logAndRespondWithStatus(ctx, 400, `IpamDriver.ReleaseAddress: could not delete ${delKeyA} and/or ${delKeyB}${ipToRelease}`);
  }
  // testing end

  const ipToReleaseNetBits = splitCidrIpAndNetBits(poolDetails.cidrNet).netBits;
  const lookupCidrIp = `${ipToRelease}/${ipToReleaseNetBits}`;
  console.log(`* IpamDriver.ReleaseAddress: lookCidrIp = ${lookupCidrIp}`);
  await asyncAddPoolReclaimableCidrIp(poolName, lookupCidrIp)

  unlockIpamDbMutex();
  return respondJsonEmptySuccess(ctx);
});

appRouter.post('/IpamDriver.ReleasePool', ctx => respondJsonEmptySuccess(ctx));

appHandlers
  .use(appRouter.routes())
  .use(appRouter.allowedMethods());

// static content mapping of /static -> /opt/ipam/static
appStatic.use(koaStatic('static'));
appHandlers.use(koaMount('/static', appStatic));

appHandlers.listen(8080);

//// REPL

const net = require('net');
const repl = require('repl');

net.createServer((socket) => {
  socket.write('\nExported objects: levelup, ipamDb, ipamDbPromise\n');

  const replInstance = repl.start({
    prompt: 'ipam> ',
    input: socket,
    output: socket,
    useColors: true,
    terminal: true,
    replMode: repl.REPL_MODE_STRICT,
    ignoreUndefined: true
  });

  replInstance.context.levelup = levelup;
  replInstance.context.ipamDb = ipamDb;
  replInstance.context.ipamDbPromise = ipamDbPromise;

  //socket.on('error', (err) => {
    //console.log("REPL error:", err);
  //});

  replInstance.on('exit', () => {
    socket.end();
  });
}).listen(5002);

package.json -

{
  "name": "docker-ipam-http-server",
  "version": "1.0.0",
  "main": "server.js",
  "devDependencies": {},
  "author": "isaac@eyz.us",
  "dependencies": {
    "koa": "*",
    "koa-log-requests": "*",
    "koa-json-body": "*",
    "koa-static": "*",
    "koa-mount": "*",
    "koa-send": "*",
    "koa-router": "*",
    "level-rocksdb": "^1.0.1",
    "level-promisify": "*",
    "ip-address": "*",
    "repl": "*",
    "await-stream-ready": "*",
    "vue": "*"
  }
}

test.sh

#!/bin/bash

BASE_URL="$1"

if [[ -z "${BASE_URL}" ]]; then
  echo "Usage: $0 BASE_URL"
  echo "       $0 http://localhost:8080"
  exit 1
fi

TEST=0

SUCCESS=true

dumpDb() {
  # to show the current state of the database, without comparing a checksum for success/fail; status only, for debugging
  RESPONSE="$(curl -s -H "Content-Type: application/json" -X GET "${BASE_URL}/dumpDb" | sed '/^\s*$/d')"

  # if a response was returned (aftere removing blank lines) then print the response and pad a blank line
  if [[ -n "${RESPONSE}" ]]; then
    echo -e "${RESPONSE}"
    echo
  fi
}

runTest() {
  ((TEST++))

  dumpDb

  local VERB="$1"
  local URL="$2"
  local REQUEST="$3"
  local MD5SUM_EXPECTED="$4"
  local INCLUDE_REGEX="$5"

  if [[ -n "${INCLUDE_REGEX}" ]]; then
    local RESPONSE="$(curl -s -H "Content-Type: application/json" -X "${VERB}" -d "$REQUEST" "$URL" | grep -P "${INCLUDE_REGEX}")"
  else
    local RESPONSE="$(curl -s -H "Content-Type: application/json" -X "${VERB}" -d "$REQUEST" "$URL")"
  fi

  local MD5SUM_RESULT="$(md5sum <<<"$RESPONSE" | cut -d ' ' -f 1)"

  echo -e "${TEST}) URL: ${URL}"
  echo -e "${TEST}) REQUEST BODY: ${REQUEST}"

  if [[ "${VERB}" == 'GET' ]]; then
    echo -e "${TEST}) RESPONSE BODY:\n${RESPONSE}"
  else
    echo -e "${TEST}) RESPONSE BODY: ${RESPONSE}"
  fi
  echo -e "${TEST}) RESPONSE BODY MD5SUM: ${MD5SUM_RESULT}"

  if [[ "$MD5SUM_EXPECTED" == "${MD5SUM_RESULT}" ]]; then
    echo "${TEST}) RESULT: PASS"
  else
    echo "${TEST}) RESULT: FAIL"
    SUCCESS=false
  fi

  echo
}

echo -n "Waiting for ${BASE_URL} to respond ... "
while true; do
  if curl -Is "${BASE_URL}" >/dev/null 2>&1; then
    break
  fi
  sleep 2
done
echo 'Done'
echo

# test gateway
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "RequestAddressType": "com.docker.network.gateway" } }' '82e0c8fce66fb39dc2c54c53035e3b7a'

# pool create
runTest 'POST' "${BASE_URL}/IpamDriver.RequestPool" '{ "Options": { "poolName": "test1" } }' 'c820bf5f44c2687093e0cb5d922e9e1d'

# pool remove (noop)
runTest 'POST' "${BASE_URL}/IpamDriver.ReleasePool" '' '8a80554c91d9fca8acb82f023de02f11'

# test invalid request
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { } }' '6842a7ee7c964ccf50042f0e5d08e09b'

# add test-1 and test-2.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-1" } }' '63aa00d0a6d8f7ad6a11fbe0128d1513'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-2", "Domainname": "test.local" } }' 'eff5b05e22b68f6a02f01391c08c4b29'

# add test-3 and test-4.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-3" } }' 'c3cdbb5a9543e44617c67c63b3d78748'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-4", "Domainname": "test.local" } }' 'b118534ef0e4d49e90d3f71d61fae750'

# del test-1 and test-2.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.0" }' '8a80554c91d9fca8acb82f023de02f11'
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.1" }' '8a80554c91d9fca8acb82f023de02f11'

# add test-5 and test-6.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-5" } }' 'b52095cd2770cc4c224fb83179c50580'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-6", "Domainname": "test.local" } }' '781f355c6bdbc6778be7a402440adf8d'

# NOTE: not currently testing IP reuse cool down
sleep 124

# add test-7 and test-8.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-7" } }' '63aa00d0a6d8f7ad6a11fbe0128d1513'
runTest 'POST' "${BASE_URL}/IpamDriver.RequestAddress" '{ "PoolID": "test1", "Address": "", "Options": { "Hostname": "test-8", "Domainname": "test.local" } }' 'eff5b05e22b68f6a02f01391c08c4b29'

# del test-3 and test-4.test.local
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.2" }' '8a80554c91d9fca8acb82f023de02f11'
runTest 'POST' "${BASE_URL}/IpamDriver.ReleaseAddress" '{ "PoolID": "test1", "Address": "10.100.1.3" }' '8a80554c91d9fca8acb82f023de02f11'

# verify database contents are as expected
runTest 'GET' "${BASE_URL}/dumpDb" '' 'f8006ae1a78e6c19aeab3dc0445dfc4a' '^(ipToInstance|poolNextCidrIp|instanceNameToCidrIp):'

if [[ "$SUCCESS" == 'true' ]]; then
  echo 'OVERALL = PASS'
  exit 0
else
  echo 'OVERALL = FAIL'
  exit 1
fi

index.html -

<!DOCTYPE html>
<html>
  <head>
    <title>IPAM Server 0.2</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8">
    <link rel="shortcut icon" href="/favicon.png">
    <link href='https://fonts.googleapis.com/css?family=Hammersmith One' rel='stylesheet'>
    <script src='/static/vendor/vue-dist/vue.js'></script>
    <style>
      body {
        background-image: url("/static/subtle-grunge.png");
      }

      span.infoHeader {
        margin: 3px 3px 3px 3px;
        padding: 4px 4px 4px 4px;
        border: 2px solid rgba(71,132,163,.9);
        background-color: rgba(166,211,234,.6);
        display: inline-block;
        font-family: 'Hammersmith One';
        font-size: 18px;
        vertical-align: top;
      }

      div.service {
        border-style: solid none none none;
        border-width: 2px;
        border-color: rgb(160,160,160);
        padding: 4px 0px 2px 0px;
        margin: 3px 0px 0px 0px;
      }

      span.service {
        margin: 3px 0px 3px 3px;
        padding: 4px 4px 4px 4px;
        border: 2px solid rgba(122,102,85,.9);
        background-color: rgba(242,190,146,.6);
        display: inline-block;
        font-family: 'Hammersmith One';
        font-size: 18px;
        vertical-align: top;
      }

      span.ipToInstance {
        margin: 3px 3px 3px 3px;
        padding: 4px 4px 4px 4px;
        border: 2px solid rgba(134,179,0,.9);
        background-color: rgba(236,255,179,.45);
        display: inline-block;
        font-family: 'Hammersmith One';
        font-size: 18px;
        vertical-align: top;
      }

      a.noUnderline {
        text-decoration: none;
      }

      .fade-enter-active {
        animation: fade-in 2s;
      }
      .fade-leave-active {
        animation: fade-out 1.5s;
      }
      @keyframes fade-in {
        0% {
          transform: scale(.4);
          opacity: 0;
        }
        50% {
          transform: scale(1);
          opacity: 1;
        }
        100% {
          transform: scale(1);
          opacity: 1;
          background-color: rgba(236,255,179,1);
        }
      }
      @keyframes fade-out {
        100% {
          transform: scale(.4);
          opacity: 0;
        }
        50% {
          transform: scale(1);
          opacity: 1;
        }
        0% {
          transform: scale(1);
          opacity: 1;
        }
      }
      .fade-move {
        transition: transform .2s;
      }

      .appear-enter-active {
        animation: appear 2s;
        background-color: rgba(243, 252, 172, .5);
      }
      .appear-leave-active {
        animation: appear 1.5s reverse;
      }
      @keyframes appear {
        0%   { opacity: 0; }
        100% { opacity: 1; }
      }
    </style>
  </head>

  <body>
        <span class="infoHeader"><a class="noUnderline" href="/dumpDb">View the current contents of the IPAM database</a></span>
    <div id="app">
      <transition-group name="appear">
      <div class="service" v-for="service in instancesGroupedByServiceName" v-bind:key="service.serviceName">
        <span class="service">{{ service.serviceName }}</span>
          <transition-group name="fade">
            <span class="ipToInstance" v-for="instance in service.instances" v-bind:key="instance.name">{{ instance.name }}<br/><strong>{{ instance.ip }}</strong>
              <svg class="chart" v-bind:width="lifecycleWidth" v-bind:height="lifecycleHeight" xmlns="http://www.w3.org/2000/svg">
                <!-- odd intermittent rendering issue of the gradient in Chrome, not using gradient for now (Isaac, 20170103)
                <defs>
                  <linearGradient id="monthGradient">
                    <stop offset="0%" stop-color="rgba(223,175,66,.5)"/>
                    <stop offset="100%" stop-color="rgba(243,91,76,.65)"/>
                 </linearGradient>
                </defs>
                <rect x="0" y="0" v-bind:width="lifecycleWidth" v-bind:height="lifecycleHeight" fill="url(#monthGradient)"/>
                -->
                <rect x="0" y="0" v-bind:width="lifecycleSeperators.oneMonthX" v-bind:height="lifecycleHeight" fill="rgba(149,210,73,.5)"/>
                <rect v-bind:x="lifecycleSeperators.oneMonthX" y="0" v-bind:width="lifecycleWidth - lifecycleSeperators.oneMonthX" v-bind:height="lifecycleHeight" fill="rgba(243,91,76,.65)"/>
                <line y1="0" v-bind:y2="lifecycleHeight" v-bind:x1="getCurrentAdjustedPositionByLeasedEpoch(instance.leasedEpoch)" v-bind:x2="getCurrentAdjustedPositionByLeasedEpoch(instance.leasedEpoch)" stroke-width="8" stroke="rgba(73, 154, 193, 1)"/>

                <text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneMinX" text-anchor="middle" fill="rgba(0,0,0,.45)">1</text> <!-- 1 min -->
                <text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.fiveMinsX" text-anchor="middle" fill="rgba(0,0,0,.45)">5</text> <!-- 5 mins -->
                <text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneHourX" text-anchor="middle" fill="rgba(0,0,0,.45)">H</text> <!-- 1 hour -->
                <text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneDayX" text-anchor="middle" fill="rgba(0,0,0,.45)">D</text> <!-- 1 day -->
                <text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneWeekX" text-anchor="middle" fill="rgba(0,0,0,.45)">W</text> <!-- 1 week -->
                <text v-bind:y="lifecycleHeight" v-bind:x="lifecycleSeperators.oneMonthX" text-anchor="middle" fill="rgba(0,0,0,.45)">M</text> <!-- 1 month -->
              </svg>
            </span>
          </transition-group>
      </div>
      </transition-group>
    </div>
    <script>
      let hidden,
          visibilityChange,
          updateUiSetIntervalTimer;

      const fetchIpToInstance = async () => {
        const response = await fetch('/instances');
        return await response.json();
      }

      let mockFetchIpToInstance = async () => {
        return [
          {
            "ip": "10.100.1.0",
            "name": "org-gitlab-runner-latest-3-s4z7etb9w5u23b4wad9hffcbz"
          },
          {
            "ip": "10.100.1.1",
            "name": "org-gitlab-runner-latest-2-sq8i9adaex1urd8pf0zjiqwbj"
          },
          {
            "ip": "10.100.1.2",
            "name": "org-gitlab-runner-latest-4-n8ww0ux4zfpc83pup5lmfuvdl"
          },
          {
            "ip": "10.100.1.3",
            "name": "org-gitlab-runner-latest-5-nlwzzf821i01989l5j40at5u4"
          },
          {
            "ip": "10.100.1.4",
            "name": "org-gitlab-runner-latest-1-yc9w1hfpissdmb6qf3cep2sd4"
          },
          {
            "ip": "10.100.1.5",
            "name": "portainer-1"
          }
        ]
      }

      const updateIpToInstanceCardUi = async (fetchJson) => {
        let newInstancesGroupedByServiceName;

        try {
          const jsonResponse = await fetchJson();
          newInstancesGroupedByServiceName = getInstancesGroupedByService(jsonResponse);
        } catch (error) {
          newInstancesGroupedByServiceName = [
            {
              "serviceName": "Errored / offline",
              "instances": []
            }
          ];
        }

        app.instancesGroupedByServiceName = newInstancesGroupedByServiceName;
      }

      let app = new Vue({
        el: "#app",
        data: {
          instancesGroupedByServiceName: [
            {
              "serviceName": "Loading",
              "instances": []
            }
          ],
          lifecycleWidth: 210,
          lifecycleHeight: 11,
          lifecycleLogBase: Math.log(6.9196)
        },
        methods: {
          getLifecycleBySecs: function(secs) {
            if (secs < 0) {
              return 0;
            }
            const lifecycle = Math.log(secs) / this.lifecycleLogBase / 8;
            if (lifecycle < 0) {
              return 0;
            }
            if (lifecycle > 1) {
              return 1;
            }
            return lifecycle;
          },
          getAdjustedLifecycleBySecs: function(secs) {
            const minLifecycle = this.getLifecycleBySecs(20),
                  adjustmentMultipler = 1 / (1 - minLifecycle),
                  adjustedLifecycle = (this.getLifecycleBySecs(secs) - minLifecycle) * adjustmentMultipler;
            if (adjustedLifecycle < 0) {
              return 0;
            }
            if (adjustedLifecycle > 1) {
              return 1;
            }
            return adjustedLifecycle;
          },
          getCurrentPositionBySecs: function(secs) {
            return Math.round(this.getLifecycleBySecs(secs) * this.lifecycleWidth);
          },
          getCurrentAdjustedPositionBySecs: function(secs) {
            return Math.round(this.getAdjustedLifecycleBySecs(secs) * this.lifecycleWidth);
          },
          getCurrentAdjustedPositionByLeasedEpoch: function(leasedEpoch) {
            return this.getCurrentAdjustedPositionBySecs(this.getSecsSinceLeasedEpoch(leasedEpoch));
          },
          getLeasedEpochDate: function(leasedEpoch) {
            return new Date(parseInt(leasedEpoch));
          },
          getLeasedEpochLocaleString: function(leasedEpoch) {
            return this.getLeasedEpochDate(leasedEpoch).toLocaleString();
          },
          getSecsSinceLeasedEpoch: function(leasedEpoch) {
            return (new Date().getTime()-parseInt(leasedEpoch)) / 1000;
          }
        },
        computed: {
          lifecycleSeperators: function () {
            return {
              oneMinX: this.getCurrentAdjustedPositionBySecs(60),
              fiveMinsX: this.getCurrentAdjustedPositionBySecs(60*5),
              oneHourX: this.getCurrentAdjustedPositionBySecs(60*60),
              oneDayX: this.getCurrentAdjustedPositionBySecs(60*60*24),
              oneWeekX: this.getCurrentAdjustedPositionBySecs(60*60*24*7),
              oneMonthX: this.getCurrentAdjustedPositionBySecs(365/12*60*60*24),
              oneMonthPercentString: `${this.getAdjustedLifecycleBySecs(365/12*60*60*24) * 100}%`
            }
          }
        }
      });

      const getServiceLabel = (item) => {
        const [serviceName, slotId, taskId, domainSuffix] = item.name.split(/(-\d+)(-\w+)?(\.\w+)?$/)
        if (typeof domainSuffix === 'string' && domainSuffix != '' && domainSuffix != '.nostack') {
          const domain = domainSuffix.split('.')[1];
      return `${domain}/${serviceName}`;
    }
        if (typeof slotId === 'undefined') {
          return 'individual'
        }
        return serviceName
      }

      const sortServicesByName = (a, b) => {
        if (a === 'individual') { return 1; }
        if (b === 'individual') { return -1; }
        if (a > b) { return 1; }
        if (a < b) { return -1; }
        return 0;
      }

      const getInstancesGroupedByService = (json) => {
        let groupedInstances = {},
            groupedInstancesResult = [];

        for (item of json) {
          const serviceName = getServiceLabel(item)
          if (typeof groupedInstances[serviceName] === 'undefined') {
            groupedInstances[serviceName] = [item]
          } else {
            groupedInstances[serviceName].push(item)
          }
        }

        const sortedServiceNames = Object.keys(groupedInstances).sort(sortServicesByName);

        for (serviceName of sortedServiceNames) {
          const serviceInstances = groupedInstances[serviceName];
          groupedInstancesResult.push({
            serviceName: serviceName,
            instances: serviceInstances
          });
        }

        return groupedInstancesResult;
      }

      const updateUi = () => updateIpToInstanceCardUi(fetchIpToInstance);

      const scheduleUpdateUiAndRunNow = () => {
        updateUi();
        updateUiSetIntervalTimer = setInterval(updateUi, 2000);
      }

      scheduleUpdateUiAndRunNow();

      if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support
        hidden = "hidden";
        visibilityChange = "visibilitychange";
      } else if (typeof document.msHidden !== "undefined") {
        hidden = "msHidden";
        visibilityChange = "msvisibilitychange";
      } else if (typeof document.webkitHidden !== "undefined") {
        hidden = "webkitHidden";
        visibilityChange = "webkitvisibilitychange";
      }

      const handleVisibilityChange = () => {
        if (document[hidden]) {
          if (typeof updateUiSetIntervalTimer === "number") {
            clearInterval(updateUiSetIntervalTimer);
          }
        } else {
          scheduleUpdateUiAndRunNow();
        }
      }

      document.addEventListener(visibilityChange, handleVisibilityChange, false);
    </script>
  </body>
</html>

Dockerfile -

ARG IMAGE_TAG=latest
FROM registry.org.com:5000/docker-ipam-http-server-module-build:${IMAGE_TAG} as moduleBuild

####################

FROM node:9-alpine

RUN apk --no-cache upgrade && \
    apk --no-cache add bash nano curl grep && \
    adduser -D -u 10000 ipam ipam && \
    mkdir -p /opt/ipam/db && \
    mkdir -p /home/ipam/.pm2 && \
    chown -R ipam:ipam /home/ipam/.pm2

COPY --from=moduleBuild /opt/ipam /opt/ipam

WORKDIR /opt/ipam

COPY ./tls /opt/ipam/tls

RUN cat ./tls/star-org-com-Cert.crt ./tls/star-org-com-Int.crt > ./tls/star-org-com-Bundle.crt

RUN yarn install && \
    yarn global add pm2

RUN mkdir /opt/ipam/log && \
    chown -R ipam:ipam /opt/ipam

COPY ./static /opt/ipam/static

RUN chown -R ipam:ipam /opt/ipam/static

EXPOSE 8080

COPY ./bin /opt/ipam/bin

# To speed up changes to server.js and start.sh
RUN ln -s /opt/ipam/node_modules/vue/dist /opt/ipam/static/vendor/vue-dist && \
    chown -R ipam:ipam /opt/ipam && \
    chmod -R ug+rX /opt/ipam

COPY ./server.js /opt/ipam/
RUN chown ipam:ipam /opt/ipam/server.js && \
    chmod ug+r /opt/ipam/server.js

WORKDIR /opt/ipam

USER 10000

CMD ["bin/start.sh"]

docker-ipam-pdns-server/Dockerfile -

FROM debian:latest

RUN apt-get -y update && \
    apt-get -y install pdns-server pdns-backend-remote libterm-readline-perl-perl && \
    echo 'remote-connection-string=http:url=http://docker-ipam-http-server:8080/dns' >> /etc/powerdns/pdns.conf && \
    sed -ri 's/^\s*#?\s*launch\s*.*$/launch=remote/g' /etc/powerdns/pdns.conf && \
    sed -ri 's/^\s*#?\s*socket-dir\s*=.*$/socket-dir=\/var\/run\/pdns/g' /etc/powerdns/pdns.conf && \
    mkdir -p /var/run/pdns

CMD ["/usr/sbin/pdns_server"]