diff --git a/gso/oss-params-example.json b/gso/oss-params-example.json
index a13415dd26ddb0a58ad7eb65abcfd191c2e40de2..afc41967d1dbe3eb333eca86567f4546db71dcdd 100644
--- a/gso/oss-params-example.json
+++ b/gso/oss-params-example.json
@@ -11,13 +11,20 @@
       "username": "robot-user",
       "password": "robot-user-password"
     },
+    "LO": {
+      "V4": {"containers": ["1.1.0.0/24"], "mask": 32},
+      "V6": {"containers": ["dead:beef::/64"], "mask": 128},
+      "domain_name": ".lo"
+    },
     "TRUNK": {
-      "V4": {"container": "1.1.0.0/24", "mask": 31},
-      "V6": {"container": "dead:beef::/64", "mask": 96}
+      "V4": {"containers": ["1.1.1.0/24"], "mask": 31},
+      "V6": {"containers": ["dead:beef::/64"], "mask": 126},
+      "domain_name": ".trunk"
     },
     "GEANT_IP": {
-      "V4": {"container": "1.1.8.0/24", "mask": 31},
-      "V6": {"container": "dead:beef::/64", "mask": 96}
+      "V4": {"containers": ["1.1.2.0/24"], "mask": 31},
+      "V6": {"containers": ["dead:beef::/64"], "mask": 126},
+      "domain_name": ".geantip"
     }
   },
   "PROVISIONING_PROXY": {
diff --git a/gso/services/_ipam.py b/gso/services/_ipam.py
new file mode 100644
index 0000000000000000000000000000000000000000..2707a53ddc0ef4c2111baca5b8ac640f80a9b614
--- /dev/null
+++ b/gso/services/_ipam.py
@@ -0,0 +1,681 @@
+import ipaddress
+import requests
+from enum import Enum
+from pydantic import BaseSettings
+from requests.auth import HTTPBasicAuth
+from typing import Union
+
+from gso import settings
+
+
+class V4ServiceNetwork(BaseSettings):
+    v4: ipaddress.IPv4Network
+
+
+class V6ServiceNetwork(BaseSettings):
+    v6: ipaddress.IPv6Network
+
+
+class ServiceNetworks(BaseSettings):
+    v4: ipaddress.IPv4Network
+    v6: ipaddress.IPv6Network
+
+
+class V4HostAddress(BaseSettings):
+    v4: ipaddress.IPv4Address
+
+
+class V6HostAddress(BaseSettings):
+    v6: ipaddress.IPv6Address
+
+
+class HostAddresses(BaseSettings):
+    v4: ipaddress.IPv4Address
+    v6: ipaddress.IPv6Address
+
+
+class IPAMErrors(Enum):
+    # HTTP error code, match in error message
+    CONTAINER_FULL = 400, "Can not find requested number of networks"
+    EXTATTR_UNKNOWN = 400, "Unknown extensible attribute"
+    EXTATTR_BADVALUE = 400, "Bad value for extensible attribute"
+
+
+# TODO: remove this!
+# lab infoblox cert is not valid for the ipv4 address
+#   ... disable warnings for now
+requests.packages.urllib3.disable_warnings()
+
+
+def _match_error_code(response, error_code):
+    return response.status_code == error_code.value[0] \
+        and error_code.value[1] in response.text
+
+
+def _wapi(infoblox_params: settings.InfoBloxParams):
+    return (f'https://{infoblox_params.host}'
+            f'/wapi/{infoblox_params.wapi_version}')
+
+
+def _ip_addr_version(addr):
+    ip_version = None
+    ip_addr = ipaddress.ip_address(addr)
+    if isinstance(ip_addr, ipaddress.IPv4Address):
+        ip_version = 4
+    elif isinstance(ip_addr, ipaddress.IPv6Address):
+        ip_version = 6
+    assert ip_version in [4, 6]
+    return ip_version
+
+
+def _ip_network_version(network):
+    ip_version = None
+    ip_network = ipaddress.ip_network(network)
+    if isinstance(ip_network, ipaddress.IPv4Network):
+        ip_version = 4
+    elif isinstance(ip_network, ipaddress.IPv6Network):
+        ip_version = 6
+    assert ip_version in [4, 6]
+    return ip_version
+
+
+def _find_networks(network_container=None, network=None, ip_version=4):
+    """
+    If network_container is not None, find all networks within the specified
+    container.
+    Otherwise, if network is not None, find the specified network.
+    Otherwise find all networks.
+    """
+    assert ip_version in [4, 6]
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+    endpoint = 'network' if ip_version == 4 else 'ipv6network'
+    params = None
+    if network_container:
+        params = {'network_container': network_container}
+    elif network:
+        params = {'network': network}
+    r = requests.get(
+        f'{_wapi(infoblox_params)}/{endpoint}',
+        params=params,
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    # TODO: propagate "network not found" error to caller
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    return r.json()
+
+
+def _get_network_capacity(network=None):
+    """
+    Get utilization of a IPv4 network in a fraction of 1000.
+    """
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+
+    ip_version = _ip_network_version(network)
+    assert ip_version == 4, "Utilization is only available for IPv4 networks."
+    params = {
+        'network': network,
+        '_return_fields': 'network,total_hosts,utilization'
+    }
+
+    r = requests.get(
+        f'{_wapi(infoblox_params)}/network',
+        params=params,
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    # Utilization info takes several minutes to converge.
+    # The IPAM utilization bar in the GUI as well. Why?
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    capacity_info = r.json()
+    assert len(capacity_info) == 1, "Requested IPv4 network doesn't exist."
+    assert 'utilization' in capacity_info[0]
+    utilization = capacity_info[0]['utilization']
+    return utilization
+
+
+def _allocate_network(
+    infoblox_params: settings.InfoBloxParams,
+    network_params: Union[settings.V4NetworkParams, settings.V6NetworkParams],
+    ip_version=4,
+    comment="",
+    extattrs={}
+) -> Union[V4ServiceNetwork, V6ServiceNetwork]:
+    assert ip_version in [4, 6]
+    endpoint = 'network' if ip_version == 4 else 'ipv6network'
+    ip_container = 'networkcontainer' if ip_version == 4 else \
+        'ipv6networkcontainer'
+
+    # only return in the response the allocated network, not all available
+    # TODO: any validation needed for extrattrs wherever it's used?
+    req_payload = {
+        "network": {
+            "_object_function": "next_available_network",
+            "_parameters": {
+                "cidr": network_params.mask
+            },
+            "_object": ip_container,
+            "_object_parameters": {
+                "network": str(network_params.containers[0])
+            },
+            "_result_field": "networks",
+        },
+        "comment": comment,
+        "extattrs": extattrs
+    }
+
+    container_index = 0
+    while True:
+        r = requests.post(
+            f'{_wapi(infoblox_params)}/{endpoint}',
+            params={'_return_fields': 'network'},
+            json=req_payload,
+            auth=HTTPBasicAuth(infoblox_params.username,
+                               infoblox_params.password),
+            headers={'content-type': "application/json"},
+            verify=False
+        )
+        if not _match_error_code(response=r,
+                                 error_code=IPAMErrors.CONTAINER_FULL):
+            break
+        # Container full: try with next valid container for service (if any)
+        container_index += 1
+        if len(network_params.containers) < (container_index + 1):
+            break
+        req_payload["network"]["_object_parameters"]["network"] = \
+            str(network_params.containers[container_index])
+
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+
+    assert 'network' in r.json()
+    allocated_network = r.json()['network']
+    if ip_version == 4:
+        return V4ServiceNetwork(v4=allocated_network)
+    else:
+        return V6ServiceNetwork(v6=allocated_network)
+
+
+def allocate_service_ipv4_network(service_type, comment="", extattrs={}
+                                  ) -> V4ServiceNetwork:
+    """
+    Allocate IPv4 network within the container of the specified service type.
+    """
+    oss = settings.load_oss_params()
+    assert oss.IPAM
+    ipam_params = oss.IPAM
+    assert hasattr(ipam_params, service_type) \
+        and service_type != 'INFOBLOX', "Invalid service type."
+    return _allocate_network(ipam_params.INFOBLOX,
+                             getattr(ipam_params, service_type).V4,
+                             4,
+                             comment,
+                             extattrs)
+
+
+def allocate_service_ipv6_network(service_type, comment="", extattrs={}
+                                  ) -> V6ServiceNetwork:
+    """
+    Allocate IPv6 network within the container of the specified service type.
+    """
+    oss = settings.load_oss_params()
+    assert oss.IPAM
+    ipam_params = oss.IPAM
+    assert hasattr(ipam_params, service_type) \
+        and service_type != 'INFOBLOX', "Invalid service type."
+    return _allocate_network(ipam_params.INFOBLOX,
+                             getattr(ipam_params, service_type).V6,
+                             6,
+                             comment,
+                             extattrs)
+
+
+def _find_next_available_ip(infoblox_params, network_ref):
+    r = requests.post(
+        f'{_wapi(infoblox_params)}/{network_ref}?_function=next_available_ip&num=1',   # noqa: E501
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    # TODO: propagate no more available IPs in the network
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    assert 'ips' in r.json()
+    received_ip = r.json()['ips']
+    assert len(received_ip) == 1
+    return received_ip[0]
+
+
+def _allocate_host(hostname=None, addr=None, network=None, extattrs={}
+                   ) -> Union[V4HostAddress, V6HostAddress]:
+    """
+    If network is not None, allocate host in that network.
+    Otherwise if addr is not None, allocate host with that address.
+    hostname parameter must be full name including domain name.
+    """
+    # TODO: should hostnames be unique
+    # (i.e. fail if hostname already exists in this domain/service)?
+    assert addr or network, \
+        "You must specify either the host address or the network CIDR."
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+
+    if network:
+        ip_version = _ip_network_version(network)
+        # Find the next available IP address in the network
+        network_info = _find_networks(network=network, ip_version=ip_version)
+        assert len(network_info) == 1, \
+            "Network does not exist. Create it first."
+        assert '_ref' in network_info[0]
+        addr = _find_next_available_ip(infoblox_params,
+                                       network_info[0]["_ref"])
+
+    else:
+        ip_version = _ip_addr_version(addr)
+
+    ip_req_payload = {
+        f"ipv{ip_version}addrs": [
+            {
+                f"ipv{ip_version}addr": addr
+            }
+        ],
+        "name": hostname,
+        "configure_for_dns": False,
+        "view": "default",
+        "extattrs": extattrs
+    }
+
+    r = requests.post(
+        f'{_wapi(infoblox_params)}/record:host',
+        json=ip_req_payload,
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    assert isinstance(r.json(), str)
+    assert r.json().startswith("record:host/")
+
+    dns_req_payload = {
+        f"ipv{ip_version}addr": addr,
+        "name": hostname,
+        "view": "default",
+        "extattrs": extattrs
+    }
+
+    endpoint = 'record:a' if ip_version == 4 else 'record:aaaa'
+
+    r = requests.post(
+        f'{_wapi(infoblox_params)}/{endpoint}',
+        json=dns_req_payload,
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    assert isinstance(r.json(), str)
+    assert r.json().startswith(f"{endpoint}/")
+
+    if ip_version == 4:
+        return V4HostAddress(v4=addr)
+    else:
+        return V6HostAddress(v6=addr)
+
+
+def allocate_service_host(hostname=None,
+                          service_type=None,
+                          service_networks: ServiceNetworks = None,
+                          host_addresses: HostAddresses = None,
+                          extattrs={}
+                          ) -> HostAddresses:
+    """
+    Allocate host with both IPv4 and IPv6 address (and respective DNS
+    records).
+    The domain name is also taken from the service type and appended to
+    specified hostname.
+    If service_networks is provided, that one is used.
+    If service_networks is not provided, and host_addresses is provided,
+    those specific addresses are used.
+    If neither is not provided, the first network with available space for
+    this service type is used.
+        Note that if WFO will always specify the network/addresses after
+        creating it, this mode won't be needed. Currently this mode doesn't
+        look further than the first container, so if needed, this will need
+        to be updated.
+    """
+    oss = settings.load_oss_params()
+    assert oss.IPAM
+    ipam_params = oss.IPAM
+
+    assert hasattr(ipam_params, service_type) \
+        and service_type != 'INFOBLOX', "Invalid service type."
+    ipv4_containers = getattr(ipam_params, service_type).V4.containers
+    ipv6_containers = getattr(ipam_params, service_type).V6.containers
+    domain_name = getattr(ipam_params, service_type).domain_name
+
+    # IPv4
+    if not service_networks and not host_addresses:
+        ipv4_networks_info = _find_networks(
+            network_container=str(ipv4_containers[0]), ip_version=4)
+        assert len(ipv4_networks_info) >= 1, \
+            "No IPv4 network exists in the container for this service type."
+        first_nonfull_ipv4_network = None
+        for ipv4_network_info in ipv4_networks_info:
+            assert 'network' in ipv4_network_info
+            capacity = _get_network_capacity(ipv4_network_info["network"])
+            if capacity < 1000:
+                first_nonfull_ipv4_network = ipv4_network_info["network"]
+                break
+        # Create a new network if the existing networks in the container for
+        # the service type are all full.
+        if not first_nonfull_ipv4_network:
+            first_nonfull_ipv4_network = str(allocate_service_ipv4_network(
+                service_type=service_type).v4)
+        assert first_nonfull_ipv4_network, \
+            "No available IPv4 addresses for this service type."
+        v4_host = _allocate_host(hostname=hostname+domain_name,
+                                 network=first_nonfull_ipv4_network,
+                                 extattrs=extattrs)
+    elif service_networks:
+        network = service_networks.v4
+        assert any(network.subnet_of(ipv4_container)
+                   for ipv4_container in ipv4_containers)
+        v4_host = _allocate_host(hostname=hostname+domain_name,
+                                 network=str(network),
+                                 extattrs=extattrs)
+    elif host_addresses:
+        addr = host_addresses.v4
+        assert any(addr in ipv4_container
+                   for ipv4_container in ipv4_containers)
+        v4_host = _allocate_host(hostname=hostname+domain_name,
+                                 addr=str(addr),
+                                 extattrs=extattrs)
+
+    # IPv6
+    if not service_networks and not host_addresses:
+        # ipv6 does not support capacity fetching (not even the GUI displays
+        # it). Maybe it's assumed that there is always available space?
+        ipv6_networks_info = _find_networks(
+            network_container=str(ipv6_containers[0]),
+            ip_version=6)
+        assert len(ipv6_networks_info) >= 1, \
+            "No IPv6 network exists in the container for this service type."
+        assert 'network' in ipv6_networks_info[0]
+        # TODO: if "no available IP" error, create a new network?
+        v6_host = _allocate_host(hostname=hostname+domain_name,
+                                 network=ipv6_networks_info[0]['network'],
+                                 extattrs=extattrs)
+    elif service_networks:
+        network = service_networks.v6
+        assert any(network.subnet_of(ipv6_container)
+                   for ipv6_container in ipv6_containers)
+        v6_host = _allocate_host(hostname=hostname+domain_name,
+                                 network=str(network),
+                                 extattrs=extattrs)
+    elif host_addresses:
+        addr = host_addresses.v6
+        assert any(addr in ipv6_container
+                   for ipv6_container in ipv6_containers)
+        v6_host = _allocate_host(hostname=hostname+domain_name,
+                                 addr=str(addr),
+                                 extattrs=extattrs)
+
+    return HostAddresses(v4=v4_host.v4, v6=v6_host.v6)
+
+
+"""
+Below methods are not used for supported outside calls
+"""
+
+'''
+def _find_containers(network=None, ip_version=4):
+    """
+    If network is not None, find that container.
+    Otherwise find all containers.
+    """
+    assert ip_version in [4, 6]
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+    endpoint = 'networkcontainer' if ip_version == 4 \
+        else 'ipv6networkcontainer'
+    r = requests.get(
+        f'{_wapi(infoblox_params)}/{endpoint}',
+        params={'network': network} if network else None,
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    return r.json()
+
+
+def _delete_network(network) -> Union[V4ServiceNetwork, V6ServiceNetwork]:
+    """
+    Delete IPv4 or IPv6 network by CIDR.
+    """
+    # TODO: should we check that there are no hosts in this network before
+    # deleting? Deleting a network deletes the hosts in it, but not the
+    # associated DNS records.
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+
+    ip_version = _ip_network_version(network)
+
+    network_info = _find_networks(network=network, ip_version=ip_version)
+    assert len(network_info) == 1, "Network does not exist."
+    assert '_ref' in network_info[0]
+
+    r = requests.delete(
+        f'{_wapi(infoblox_params)}/{network_info[0]["_ref"]}',
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+
+    # Extract ipv4/ipv6 address from the network reference obtained in the
+    # response
+    r_json = r.json()
+    network_address = ipaddress.ip_network(
+        r_json.rsplit("/", 1)[0].split(":")[1].replace("%3A", ":"))
+    if ip_version == 4:
+        return V4ServiceNetwork(v4=network_address)
+    else:
+        return V6ServiceNetwork(v6=network_address)
+
+
+def _delete_host_by_ip(addr) -> Union[V4HostAddress, V6HostAddress]:
+    """
+    Delete IPv4 or IPv6 host by its address.
+    """
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+
+    ip_version = _ip_addr_version(addr)
+    ip_param = 'ipv4addr' if ip_version == 4 else 'ipv6addr'
+
+    # Find host record reference
+    r = requests.get(
+        f'{_wapi(infoblox_params)}/record:host',
+        params={ip_param: addr},
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    host_data = r.json()
+    assert len(host_data) == 1, "Host does not exist."
+    assert '_ref' in host_data[0]
+    host_ref = host_data[0]['_ref']
+
+    # Delete it
+    r = requests.delete(
+        f'{_wapi(infoblox_params)}/{host_ref}',
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+
+    # Also find and delete the associated dns a/aaaa record
+    endpoint = 'record:a' if ip_version == 4 else 'record:aaaa'
+
+    r = requests.get(
+        f'{_wapi(infoblox_params)}/{endpoint}',
+        params={ip_param: addr},
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    dns_data = r.json()
+    assert len(dns_data) == 1, "DNS record does not exist."
+    assert '_ref' in dns_data[0]
+    dns_ref = dns_data[0]['_ref']
+
+    r = requests.delete(
+        f'{_wapi(infoblox_params)}/{dns_ref}',
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+
+    if ip_version == 4:
+        return V4HostAddress(v4=addr)
+    else:
+        return V6HostAddress(v6=addr)
+
+
+def _get_network_usage_status(network):
+    """
+    Get status and usage fields of all hosts in the specified ipv4 or ipv6
+    network.
+    """
+    oss = settings.load_oss_params()
+    assert oss.IPAM.INFOBLOX
+    infoblox_params = oss.IPAM.INFOBLOX
+
+    ip_version = _ip_network_version(network)
+    endpoint = 'ipv4address' if ip_version == 4 else 'ipv6address'
+
+    r = requests.get(
+        f'{_wapi(infoblox_params)}/{endpoint}',
+        params={
+            '_return_fields': 'ip_address,status,usage',
+            'network': network},
+        auth=HTTPBasicAuth(infoblox_params.username,
+                           infoblox_params.password),
+        verify=False
+    )
+    assert r.status_code >= 200 and r.status_code < 300, \
+        f"HTTP error {r.status_code}: {r.reason}\n\n{r.text}"
+    return r.json()
+'''
+'''
+if __name__ == '__main__':
+    while True:
+        print("1. Find all containers")
+        print("2. Find all networks")
+        print("3. Get network capacity")
+        print("4. Create new network")
+        print("5. Delete network")
+        print("6. Allocate host by IP")
+        print("7. Allocate host by network CIDR")
+        print("8. Allocate host by service type")
+        print("9. Delete host by IP")
+        print("10. Get network usage status")
+        print("11. Exit")
+
+        choice = input("Enter your choice: ")
+
+        if choice == '1':
+            ip_version = int(input("Enter IP version (4 or 6): "))
+            containers = _find_containers(ip_version=ip_version)
+            print(json.dumps(containers, indent=2))
+
+        elif choice == '2':
+            ip_version = int(input("Enter IP version (4 or 6): "))
+            networks = _find_networks(ip_version=ip_version)
+            print(json.dumps(networks, indent=2))
+
+        elif choice == '3':
+            network = input("Enter network (in CIDR notation): ")
+            network_capacity = _get_network_capacity(network=network)
+            print(json.dumps(network_capacity, indent=2))
+
+        elif choice == '4':
+            service_type = input("Enter service type: ")
+            comment = input("Enter a comment for the network: ")
+            ip_version = int(input("Enter IP version (4 or 6): "))
+            if ip_version == 4:
+                new_network = allocate_service_ipv4_network(
+                    comment=comment, service_type=service_type)
+            elif ip_version == 6:
+                new_network = allocate_service_ipv6_network(
+                    comment=comment, service_type=service_type)
+            else:
+                print("Invalid IP version. Please enter either 4 or 6.")
+                continue
+            print(json.dumps(str(new_network), indent=2))
+
+        elif choice == '5':
+            network = input("Enter network to delete (in CIDR notation): ")
+            deleted_network = _delete_network(network=network)
+            print(json.dumps(str(deleted_network), indent=2))
+
+        elif choice == '6':
+            hostname = input("Enter host name (full name w/ domain name): ")
+            addr = input("Enter IP address to allocate: ")
+            alloc_ip = _allocate_host(hostname=hostname, addr=addr)
+            print(json.dumps(str(alloc_ip), indent=2))
+
+        elif choice == '7':
+            hostname = input("Enter host name (full name w/ domain name): ")
+            network = input(
+                "Enter existing network to allocate from (CIDR notation): ")
+            alloc_ip = _allocate_host(hostname=hostname, network=network)
+            print(json.dumps(str(alloc_ip), indent=2))
+
+        elif choice == '8':
+            hostname = input("Enter host name (w/o domain name): ")
+            service_type = input("Enter service type: ")
+            alloc_ip = allocate_service_host(
+                hostname=hostname,
+                service_type=service_type)
+            print(json.dumps(str(alloc_ip), indent=2))
+
+        elif choice == '9':
+            addr = input("Enter IP address of host to delete: ")
+            deleted_host = _delete_host_by_ip(addr=addr)
+            print(json.dumps(str(deleted_host), indent=2))
+
+        elif choice == '10':
+            network = input(
+                "Enter network to get host usage status (CIDR notation): ")
+            usage_status_info = _get_network_usage_status(network=network)
+            print(json.dumps(usage_status_info, indent=2))
+
+        elif choice == '11':
+            print("Exiting...")
+            break
+
+        else:
+            print("Invalid choice. Please try again.")
+'''
diff --git a/gso/services/ipam.py b/gso/services/ipam.py
index 6e63130c6ed1cb00934b216bc6aee4ec8e2d80c5..c41a1239a14506e3104aaad5d5e4dcdfadd4dcae 100644
--- a/gso/services/ipam.py
+++ b/gso/services/ipam.py
@@ -1,6 +1,15 @@
 import ipaddress
 from pydantic import BaseSettings
-from gso import settings
+
+from gso.services import _ipam
+
+
+class V4ServiceNetwork(BaseSettings):
+    v4: ipaddress.IPv4Network
+
+
+class V6ServiceNetwork(BaseSettings):
+    v6: ipaddress.IPv6Network
 
 
 class ServiceNetworks(BaseSettings):
@@ -8,26 +17,97 @@ class ServiceNetworks(BaseSettings):
     v6: ipaddress.IPv6Network
 
 
+class V4HostAddress(BaseSettings):
+    v4: ipaddress.IPv4Address
+
+
+class V6HostAddress(BaseSettings):
+    v6: ipaddress.IPv6Address
+
+
 class HostAddresses(BaseSettings):
     v4: ipaddress.IPv4Address
     v6: ipaddress.IPv6Address
 
 
-def new_service_networks(
-        service_params: settings.ServiceNetworkParams) -> ServiceNetworks:
-    oss = settings.load_oss_params()
-    assert oss.IPAM.INFOBLOX
-    # TODO: load from ipam
-    # cf. https://gitlab.geant.org/goat/gap-jenkins/-/blob/development/service-editor/gap_service_editor/ipam.py#L35-66  # noqa: E501
+def new_service_networks(service_type,
+                         comment="",
+                         extattrs={}) -> ServiceNetworks:
+    v4_service_network = _ipam.allocate_service_ipv4_network(
+        service_type=service_type, comment=comment, extattrs=extattrs)
+    v6_service_network = _ipam.allocate_service_ipv6_network(
+        service_type=service_type, comment=comment, extattrs=extattrs)
     return ServiceNetworks(
-        v4=ipaddress.IPv4Network('10.0.0.0/24'),
-        v6=ipaddress.IPv6Network('dead:beef::/120'))
+        v4=v4_service_network.v4,
+        v6=v6_service_network.v6)
+
+
+def new_service_host(hostname,
+                     service_type,
+                     service_networks: ServiceNetworks = None,
+                     host_addresses: HostAddresses = None,
+                     extattrs={}) -> HostAddresses:
+    return _ipam.allocate_service_host(
+        hostname=hostname,
+        service_type=service_type,
+        service_networks=service_networks,
+        host_addresses=host_addresses,
+        extattrs=extattrs)
+
+
+'''
+if __name__ == '__main__':
+    # sample call flow to allocate two loopback interfaces and a trunk service
+    # new_service_host can be called passing networks or addresses
+    # - host h1 for service LO uses a specific ipv4/ipv6 address pair
+    # - the rest use the ipv4/ipv6 network pair
+    # networks and hosts can be allocated with extensible attributes
+    # - host h2 for service LO uses extattrs for both network and address/DNS
+    # - the rest don't use extattrs
+
+    hostname_A = 'h1'
+    hostname_B = 'h2'
+
+    # h1 LO (loopback)
+    lo1_service_networks = new_service_networks(
+        service_type='LO',
+        comment="Network for h1 LO"
+    )
+    lo1_v4_host_address = lo1_service_networks.v4.network_address
+    lo1_v6_host_address = lo1_service_networks.v6.network_address
+    print(lo1_v4_host_address)
+    print(lo1_v6_host_address)
+    lo1_host_addresses = HostAddresses(v4=lo1_v4_host_address,
+                                       v6=lo1_v6_host_address)
+    new_service_host(hostname=hostname_A,
+                     service_type='LO',
+                     host_addresses=lo1_host_addresses)
 
+    # h2 LO (loopback)
+    lo2_network_extattrs = {
+        "vrf_name": {"value": "dummy_vrf"},
+    }
+    lo2_host_extattrs = {
+        "Site": {"value": "dummy_site"},
+    }
+    lo2_service_networks = \
+        new_service_networks(service_type='LO',
+                             comment="Network for h2 LO",
+                             extattrs=lo2_network_extattrs)
+    new_service_host(hostname=hostname_B,
+                     service_type='LO',
+                     service_networks=lo2_service_networks,
+                     extattrs=lo2_host_extattrs)
 
-def new_device_lo_address() -> HostAddresses:
-    oss = settings.load_oss_params()
-    assert oss.IPAM.INFOBLOX
-    # TODO: load from ipam
-    return HostAddresses(
-        v4=ipaddress.IPv4Address('10.10.10.10'),
-        v6=ipaddress.IPv6Address('fc00:798:aa:1::10'))
+    # h1-h2 TRUNK
+    trunk12_service_networks = new_service_networks(
+        service_type='TRUNK',
+        comment="Network for h1-h2 TRUNK"
+    )
+    new_service_host(hostname=hostname_A,
+                     service_type='TRUNK',
+                     service_networks=trunk12_service_networks)
+    new_service_host(hostname=hostname_B,
+                     service_type='TRUNK',
+                     service_networks=trunk12_service_networks)
+'''
diff --git a/gso/settings.py b/gso/settings.py
index 11cebf9d63dcf14633cae189df201ab295d31532..76e1dccfd4cce663aa61aeb50c8ebebc51eabb56 100644
--- a/gso/settings.py
+++ b/gso/settings.py
@@ -17,22 +17,24 @@ class InfoBloxParams(BaseSettings):
 
 
 class V4NetworkParams(BaseSettings):
-    container: ipaddress.IPv4Network
+    containers: list[ipaddress.IPv4Network]
     mask: int  # TODO: validation on mask?
 
 
 class V6NetworkParams(BaseSettings):
-    container: ipaddress.IPv6Network
+    containers: list[ipaddress.IPv6Network]
     mask: int  # TODO: validation on mask?
 
 
 class ServiceNetworkParams(BaseSettings):
     V4: V4NetworkParams
     V6: V6NetworkParams
+    domain_name: str
 
 
 class IPAMParams(BaseSettings):
     INFOBLOX: InfoBloxParams
+    LO: ServiceNetworkParams
     TRUNK: ServiceNetworkParams
     GEANT_IP: ServiceNetworkParams
 
diff --git a/requirements.txt b/requirements.txt
index 243292eec5c1bf77b11fc4166714d056f98fd313..3ac3e8394ad7a4649d8b87cd724f0d44ed8c1e03 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,6 @@
 orchestrator-core==1.0.0
+pydantic
 requests
 
 pytest
+responses
diff --git a/setup.py b/setup.py
index 50d3dc63c04f6f68ec902ab6478942f252c4dc40..7062c773b0d214c582e7c5b9db49da806d1eae59 100644
--- a/setup.py
+++ b/setup.py
@@ -9,7 +9,8 @@ setup(
     url=('https://gitlab.geant.org/goat/geant-service-orchestrator'),
     packages=find_packages(),
     install_requires=[
-        'requests',
         'orchestrator-core==1.0.0',
+        'pydantic',
+        'requests',
     ]
 )
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..2ef1475af538d31f0a235e78bd42fb79a834d859
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,76 @@
+import contextlib
+import json
+import os
+import socket
+import pytest
+import tempfile
+
+
+@pytest.fixture(scope='session')
+def configuration_data():
+    with contextlib.closing(
+            socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
+        s.bind(('', 0))
+        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        yield {
+            "GENERAL": {
+                "public_hostname": "https://gap.geant.org"
+            },
+            "RESOURCE_MANAGER_API_PREFIX": "http://localhost:44444",
+            "IPAM": {
+                "INFOBLOX": {
+                    "scheme": "https",
+                    "wapi_version": "v2.12",
+                    "host": "10.0.0.1",
+                    "username": "robot-user",
+                    "password": "robot-user-password"
+                },
+                "LO": {
+                    "V4": {"containers": ["10.255.255.0/24"], "mask": 32},
+                    "V6": {"containers": ["dead:beef::/64"], "mask": 128},
+                    "domain_name": ".lo"
+                },
+                "TRUNK": {
+                    "V4": {
+                        "containers": ["10.255.255.0/24", "10.255.254.0/24"],
+                        "mask": 31
+                    },
+                    "V6": {
+                        "containers": ["dead:beef::/64", "dead:beee::/64"],
+                        "mask": 126
+                    },
+                    "domain_name": ".trunk"
+                },
+                "GEANT_IP": {
+                    "V4": {
+                        "containers": ["10.255.255.0/24", "10.255.254.0/24"],
+                        "mask": 31
+                    },
+                    "V6": {
+                        "containers": ["dead:beef::/64", "dead:beee::/64"],
+                        "mask": 126
+                    },
+                    "domain_name": ".geantip"
+                }
+            },
+            "PROVISIONING_PROXY": {
+                "scheme": "https",
+                "api_base": "localhost:44444",
+                "auth": "Bearer <token>",
+                "api_version": 1123
+            }
+        }
+
+
+@pytest.fixture(scope='session')
+def data_config_filename(configuration_data):
+    file_name = os.path.join(
+        tempfile.gettempdir(), os.urandom(24).hex())
+    open(file_name, 'x').close()
+    with open(file_name, 'wb') as f:
+        f.write(json.dumps(configuration_data).encode('utf-8'))
+        f.flush()
+
+        os.environ['OSS_PARAMS_FILENAME'] = f.name
+
+        yield f.name
diff --git a/test/test_ipam.py b/test/test_ipam.py
new file mode 100644
index 0000000000000000000000000000000000000000..2778148347a7fe84164305d86029efe3c5750ddc
--- /dev/null
+++ b/test/test_ipam.py
@@ -0,0 +1,118 @@
+import ipaddress
+import re
+import responses
+
+from gso.services import ipam
+
+
+@responses.activate
+def test_new_service_networks(data_config_filename):
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/network.*'),
+        json={
+            '_ref': 'network/ZG5zLm5ldHdvcmskMTAuMjU1LjI1NS4yMC8zMi8w:10.255.255.20/32/default',   # noqa: E501
+            'network': '10.255.255.20/32'
+        }
+    )
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/ipv6network.*'),
+        json={
+            '_ref': 'ipv6network/ZG5zLm5ldHdvcmskZGVhZDpiZWVmOjoxOC8xMjgvMA:dead%3Abeef%3A%3A18/128/default',   # noqa: E501
+            'network': 'dead:beef::18/128'
+        }
+    )
+
+    service_networks = ipam.new_service_networks(service_type='LO')
+    assert service_networks == ipam.ServiceNetworks(
+        v4=ipaddress.ip_network('10.255.255.20/32'),
+        v6=ipaddress.ip_network('dead:beef::18/128')
+    )
+
+
+@responses.activate
+def test_new_service_host(data_config_filename):
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/record:host$'),
+        json='record:host/ZG5zLmhvc3QkLm5vbl9ETlNfaG9zdF9yb290LjAuMTY4MzcwNTU4MzY3MC5nc28udGVzdA:test.lo/%20'   # noqa: E501
+    )
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/record:a$'),
+        json='record:a/ZG5zLmJpbmRfYSQuX2RlZmF1bHQuZ3NvLHRlc3QsMTAuMjU1LjI1NS44:test.lo/default'   # noqa: E501
+    )
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/record:aaaa$'),
+        json='record:aaaa/ZG5zLmJpbmRfYSQuX2RlZmF1bHQuZ3NvLHRlc3QsMTAuMjU1LjI1NS44:test.lo/default'   # noqa: E501
+    )
+
+    responses.add(
+        method=responses.GET,
+        url=re.compile(r'.*/wapi.*/network.*'),
+        json=[
+            {
+                "_ref": "network/ZG5zLm5ldHdvcmskMTAuMjU1LjI1NS4yMC8zMi8w:10.255.255.20/32/default",   # noqa: E501
+                "network": "10.255.255.20/32",
+                "network_view": "default"
+            }
+        ]
+
+    )
+
+    responses.add(
+        method=responses.GET,
+        url=re.compile(r'.*/wapi.*/ipv6network.*'),
+        json=[
+            {
+                "_ref": "ipv6network/ZG5zLm5ldHdvcmskZGVhZDpiZWVmOjoxOC8xMjgvMA:dead%3Abeef%3A%3A18/128/default",   # noqa: E501
+                "network": "dead:beef::18/128",
+                "network_view": "default"
+            }
+        ]
+    )
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/network.*/.*?_function=next_available_ip&num=1.*'),   # noqa: E501
+        json={'ips': ['10.255.255.20']}
+    )
+
+    responses.add(
+        method=responses.POST,
+        url=re.compile(r'.*/wapi.*/ipv6network.*/.*?_function=next_available_ip&num=1.*'),   # noqa: E501
+        json={'ips': ['dead:beef::18']}
+    )
+
+    service_hosts = ipam.new_service_host(
+        hostname='test',
+        service_type='LO',
+        host_addresses=ipam.HostAddresses(
+            v4=ipaddress.ip_address('10.255.255.20'),
+            v6=ipaddress.ip_address('dead:beef::18')
+        )
+    )
+    assert service_hosts == ipam.HostAddresses(
+        v4=ipaddress.ip_address('10.255.255.20'),
+        v6=ipaddress.ip_address('dead:beef::18')
+    )
+
+    service_hosts = ipam.new_service_host(
+        hostname='test',
+        service_type='LO',
+        service_networks=ipam.ServiceNetworks(
+            v4=ipaddress.ip_network('10.255.255.20/32'),
+            v6=ipaddress.ip_network('dead:beef::18/128')
+        )
+    )
+    assert service_hosts == ipam.HostAddresses(
+        v4=ipaddress.ip_address('10.255.255.20'),
+        v6=ipaddress.ip_address('dead:beef::18')
+    )
diff --git a/test/test_placeholder.py b/test/test_placeholder.py
deleted file mode 100644
index e07460e2644214a04d3a149b0cf18c7de7367467..0000000000000000000000000000000000000000
--- a/test/test_placeholder.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# just a placeholder to be able to run tests during ci
-
-def test_placeholder():
-    pass