Added missing APIs to update external networks, subnets and endpoints, updated NPM packages and updated tests to cover new APIs

This commit is contained in:
Matthew Garrett 2024-06-30 21:12:45 -07:00
Родитель 35bb35aa21
Коммит 87a55ef74b
16 изменённых файлов: 2270 добавлений и 449 удалений

Просмотреть файл

@ -152,7 +152,10 @@ async def ipam_init():
"env": globals.AZURE_ENV
}
requests.post(url = "https://azureipammetrics.azurewebsites.net/api/heartbeat", json = hb_message)
try:
requests.post(url = "https://azureipammetrics.azurewebsites.net/api/heartbeat", json = hb_message)
except Exception:
pass
async def upgrade_db():
managed_identity_credential = ManagedIdentityCredential(

Просмотреть файл

@ -445,7 +445,7 @@ class ExtSubnetReq(BaseModel):
return data
class ExtEndpointUpdate(BaseModel):
class ExtEndpointReq(BaseModel):
"""DOCSTRING"""
name: str
@ -463,6 +463,12 @@ SpaceUpdate = Annotated[List[JSONPatch], None]
BlockUpdate = Annotated[List[JSONPatch], None]
ExtNetUpdate = Annotated[List[JSONPatch], None]
ExtSubnetUpdate = Annotated[List[JSONPatch], None]
ExtEndpointUpdate = Annotated[List[JSONPatch], None]
VNetsUpdate = Annotated[List[str], None]
ExtNetsUpdate = Annotated[List[ExtNet], None]

Просмотреть файл

@ -171,6 +171,39 @@ async def update_admins(
return PlainTextResponse(status_code=status.HTTP_200_OK)
@router.get(
"/admins/{objectId}",
summary = "Get IPAM Admin",
response_model = Admin,
status_code = 200
)
async def get_admins(
objectId: UUID = Path(..., description="Azure AD ObjectID for the target user"),
authorization: str = Header(None, description="Azure Bearer token"),
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Get a specific IPAM admin.
"""
if not is_admin:
raise HTTPException(status_code=403, detail="API restricted to admins.")
admin_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'admin'", tenant_id)
try:
admins = copy.deepcopy(admin_query[0])
except:
raise HTTPException(status_code=400, detail="No admins found in database.")
target_admin = next((x for x in admins['admins'] if x['id'] == str(objectId)), None)
if target_admin:
return target_admin
else:
raise HTTPException(status_code=404, detail="Admin not found.")
@router.delete(
"/admins/{objectId}",
summary = "Delete IPAM Admin",

Просмотреть файл

@ -52,6 +52,8 @@ EXTERNAL_NAME_REGEX = "^(?![\._-])([a-zA-Z0-9\._-]){1,64}(?<![\._-])$"
EXTERNAL_DESC_REGEX = "^(?![ /\._-])([a-zA-Z0-9 /\._-]){1,128}(?<![ /\._-])$"
EXTSUBNET_NAME_REGEX = "^(?![\._-])([a-zA-Z0-9\._-]){1,64}(?<![\._-])$"
EXTSUBNET_DESC_REGEX = "^(?![ /\._-])([a-zA-Z0-9 /\._-]){1,128}(?<![ /\._-])$"
EXTENDPOINT_NAME_REGEX = "^(?![\._-])([a-zA-Z0-9\._-]){1,64}(?<![\._-])$"
EXTENDPOINT_DESC_REGEX = "^(?![ /\._-])([a-zA-Z0-9 /\._-]){1,128}(?<![ /\._-])$"
router = APIRouter(
prefix="/spaces",
@ -108,8 +110,7 @@ async def scrub_space_patch(patch, space_name, tenant_id):
return scrubbed_patch
async def valid_block_name_update(name, space_name, block_name, tenant_id):
blocks = await cosmos_query("SELECT VALUE LOWER(t.name) FROM c join t IN c.blocks WHERE c.type = 'space' AND LOWER(c.name) != LOWER('{}')".format(space_name), tenant_id)
other_blocks = [x for x in blocks if x != block_name.lower()]
other_blocks = await cosmos_query("SELECT VALUE LOWER(t.name) FROM c join t IN c.blocks WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) != LOWER('{}')".format(space_name, block_name), tenant_id)
if name.lower() in other_blocks:
raise HTTPException(status_code=400, detail="Updated Block name cannot match existing Blocks within the Space.")
@ -130,7 +131,10 @@ async def valid_block_cidr_update(cidr, space_name, block_name, tenant_id):
if(cidr == target_block['cidr']):
return True
block_network = IPNetwork(cidr)
try:
block_network = IPNetwork(cidr)
except Exception:
raise HTTPException(status_code=400, detail="Updated Block CIDR must be in valid CIDR notation (x.x.x.x/x).")
if(str(block_network.cidr) != cidr):
raise HTTPException(status_code=400, detail="Invalid CIDR value, try '{}' instead.".format(block_network.cidr))
@ -147,6 +151,9 @@ async def valid_block_cidr_update(cidr, space_name, block_name, tenant_id):
if target_net:
block_cidrs += target_net['prefixes']
for external in block['externals']:
block_cidrs.append(external['cidr'])
for resv in block['resv']:
not resv['settledOn'] and block_cidrs.append(resv['cidr'])
@ -169,14 +176,14 @@ async def scrub_block_patch(patch, space_name, block_name, tenant_id):
{
"op": "replace",
"path": "/name",
"valid": valid_block_name_update,
"valid": valid_ext_network_name_update,
"error": "Block name can be a maximum of 64 characters and may contain alphanumerics, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/cidr",
"valid": valid_block_cidr_update,
"error": "Block CIDR must be in valid CIDR notation (x.x.x.x/x) and must contain all existing block networks and reservations."
"error": "Block CIDR must be in valid CIDR notation (x.x.x.x/x), cannot overlap existing Blocks within the Space and must contain all existing Virtual Networks, External Networks and unfulfilled Reservations within the Block."
}
]
@ -199,6 +206,300 @@ async def scrub_block_patch(patch, space_name, block_name, tenant_id):
return scrubbed_patch
async def valid_ext_network_name_update(name, space_name, block_name, external_name, tenant_id):
other_networks = await cosmos_query("SELECT VALUE LOWER(u.name) FROM c join t IN c.blocks join u in t.externals WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}') AND LOWER(u.name) != LOWER('{}')".format(space_name, block_name, external_name), tenant_id)
if name.lower() in other_networks:
raise HTTPException(status_code=400, detail="Updated External Network name cannot match existing External Networks within the Block.")
if re.match(EXTERNAL_NAME_REGEX, name):
return True
return False
async def valid_ext_network_cidr_update(cidr, space_name, block_name, external_name, tenant_id):
block_cidrs = []
external_cidrs = []
blocks = await cosmos_query("SELECT VALUE t FROM c join t IN c.blocks WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space_name), tenant_id)
target_block = next((x for x in blocks if x['name'].lower() == block_name.lower()), None)
externals = await cosmos_query("SELECT VALUE u FROM c join t IN c.blocks join u IN t.externals WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}')".format(space_name, block_name), tenant_id)
target_external = next((x for x in externals if x['name'].lower() == external_name.lower()), None)
if target_block and target_external:
if(cidr == target_external['cidr']):
return True
try:
external_network = IPNetwork(cidr)
except Exception:
raise HTTPException(status_code=400, detail="Updated External Network CIDR must be in valid CIDR notation (x.x.x.x/x).")
if(str(external_network.cidr) != cidr):
raise HTTPException(status_code=400, detail="Invalid CIDR value, try '{}' instead.".format(external_network.cidr))
if not external_network in IPNetwork(target_block['cidr']):
raise HTTPException(status_code=400, detail="Updated External Network CIDR must be contained within the Block CIDR.")
net_list = await get_network(None, True)
for vnet in target_block['vnets']:
target_net = next((i for i in net_list if i['id'] == vnet['id']), None)
if target_net:
block_cidrs += target_net['prefixes']
for resv in target_block['resv']:
not resv['settledOn'] and block_cidrs.append(resv['cidr'])
for external in externals:
if external['name'] != external_name:
block_cidrs.append(external['cidr'])
else:
for subnet in external['subnets']:
external_cidrs.append(subnet['cidr'])
update_set = IPSet([cidr])
block_set = IPSet(block_cidrs)
external_set = IPSet(external_cidrs)
if block_set & update_set:
raise HTTPException(status_code=400, detail="Updated CIDR cannot overlap other Virtual Networks, External Networks, or unfulfilled Reservations within the Block.")
if not external_set.issubset(update_set):
return False
return True
async def scrub_ext_network_patch(patch, space_name, block_name, external_name, tenant_id):
scrubbed_patch = []
allowed_ops = [
{
"op": "replace",
"path": "/name",
"valid": valid_ext_network_name_update,
"error": "External Network name can be a maximum of 64 characters and may contain alphanumerics, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/desc",
"valid": EXTERNAL_DESC_REGEX,
"error": "External Network description can be a maximum of 128 characters and may contain alphanumerics, spaces, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/cidr",
"valid": valid_ext_network_cidr_update,
"error": "External Network CIDR must be in valid CIDR notation (x.x.x.x/x), must contain all existing External Subnets and cannot overlap existing External Networks, Virtual Networks or unfulfilled Reservations within the Block."
}
]
for item in list(patch):
target = next((x for x in allowed_ops if (x['op'] == item['op'] and x['path'] == item['path'])), None)
if target:
if isinstance(target['valid'], str):
if re.match(target['valid'], str(item['value']), re.IGNORECASE):
scrubbed_patch.append(item)
else:
raise HTTPException(status_code=400, detail=target['error'])
elif callable(target['valid']):
if await target['valid'](item['value'], space_name, block_name, external_name, tenant_id):
scrubbed_patch.append(item)
else:
raise HTTPException(status_code=400, detail=target['error'])
else:
raise HTTPException(status_code=400, detail=target['error'])
return scrubbed_patch
async def valid_ext_subnet_name_update(name, space_name, block_name, external_name, subnet_name, tenant_id):
other_subnets = await cosmos_query("SELECT VALUE v FROM c join t IN c.blocks join u IN t.externals join v IN u.subnets WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}') AND LOWER(u.name) = LOWER('{}') AND LOWER(v.name) != LOWER('{}')".format(space_name, block_name, external_name, subnet_name), tenant_id)
if name.lower() in other_subnets:
raise HTTPException(status_code=400, detail="Updated External Subnet name cannot match existing External Subnets within the External Network.")
if re.match(EXTSUBNET_NAME_REGEX, name):
return True
return False
async def valid_ext_subnet_cidr_update(cidr, space_name, block_name, external_name, subnet_name, tenant_id):
external_cidrs = []
subnet_ips = []
externals = await cosmos_query("SELECT VALUE u FROM c join t IN c.blocks join u IN t.externals WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}')".format(space_name, block_name), tenant_id)
target_external = next((x for x in externals if x['name'].lower() == external_name.lower()), None)
subnets = await cosmos_query("SELECT VALUE v FROM c join t IN c.blocks join u IN t.externals join v IN u.subnets WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}') AND LOWER(u.name) = LOWER('{}')".format(space_name, block_name, external_name), tenant_id)
target_subnet = next((x for x in subnets if x['name'].lower() == subnet_name.lower()), None)
if target_external and target_subnet:
if(cidr == target_subnet['cidr']):
return True
try:
subnet_network = IPNetwork(cidr)
except Exception:
raise HTTPException(status_code=400, detail="Updated External Subnet CIDR must be in valid CIDR notation (x.x.x.x/x).")
if(str(subnet_network.cidr) != cidr):
raise HTTPException(status_code=400, detail="Invalid CIDR value, try '{}' instead.".format(subnet_network.cidr))
if not subnet_network in IPNetwork(target_external['cidr']):
raise HTTPException(status_code=400, detail="Updated External Subnet CIDR must be contained within the External Network CIDR.")
for subnet in subnets:
if subnet['name'] != subnet_name:
external_cidrs.append(subnet['cidr'])
else:
for endpoint in subnet['endpoints']:
subnet_ips.append(endpoint['ip'])
update_set = IPSet([cidr])
external_set = IPSet(external_cidrs)
subnet_set = IPSet(subnet_ips)
if external_set & update_set:
raise HTTPException(status_code=400, detail="Updated CIDR cannot overlap other External Subnets within the External Network.")
if not subnet_set.issubset(update_set):
return False
return True
async def scrub_ext_subnet_patch(patch, space_name, block_name, external_name, subnet_name, tenant_id):
scrubbed_patch = []
allowed_ops = [
{
"op": "replace",
"path": "/name",
"valid": valid_ext_subnet_name_update,
"error": "External Subnet name can be a maximum of 64 characters and may contain alphanumerics, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/desc",
"valid": EXTSUBNET_DESC_REGEX,
"error": "External Subnet description can be a maximum of 128 characters and may contain alphanumerics, spaces, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/cidr",
"valid": valid_ext_subnet_cidr_update,
"error": "External Subnet CIDR must be in valid CIDR notation (x.x.x.x/x), must contain all existing Endpoints and cannot overlap existing External Subnets within the External Network."
}
]
for item in list(patch):
target = next((x for x in allowed_ops if (x['op'] == item['op'] and x['path'] == item['path'])), None)
if target:
if isinstance(target['valid'], str):
if re.match(target['valid'], str(item['value']), re.IGNORECASE):
scrubbed_patch.append(item)
else:
raise HTTPException(status_code=400, detail=target['error'])
elif callable(target['valid']):
if await target['valid'](item['value'], space_name, block_name, external_name, subnet_name, tenant_id):
scrubbed_patch.append(item)
else:
raise HTTPException(status_code=400, detail=target['error'])
else:
raise HTTPException(status_code=400, detail=target['error'])
return scrubbed_patch
async def valid_ext_endpoint_name_update(name, space_name, block_name, external_name, subnet_name, endpoint_name, tenant_id):
other_endpoints = await cosmos_query("SELECT VALUE x FROM c join t IN c.blocks join u IN t.externals join v IN u.subnets join x in v.endpoints WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}') AND LOWER(u.name) = LOWER('{}') AND LOWER(v.name) = LOWER('{}') AND LOWER(x.name) != LOWER('{}')".format(space_name, block_name, external_name, subnet_name, endpoint_name), tenant_id)
if name.lower() in other_endpoints:
raise HTTPException(status_code=400, detail="Updated External Endpoint name cannot match existing External Endpoints within the External Subnet.")
if re.match(EXTENDPOINT_NAME_REGEX, name):
return True
return False
async def valid_ext_endpoint_ip_update(ip, space_name, block_name, external_name, subnet_name, endpoint_name, tenant_id):
subnet_ips = []
subnets = await cosmos_query("SELECT VALUE v FROM c join t IN c.blocks join u IN t.externals join v IN u.subnets WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}') AND LOWER(u.name) = LOWER('{}')".format(space_name, block_name, external_name), tenant_id)
target_subnet = next((x for x in subnets if x['name'].lower() == subnet_name.lower()), None)
endpoints = await cosmos_query("SELECT VALUE x FROM c join t IN c.blocks join u IN t.externals join v IN u.subnets join x in v.endpoints WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}') AND LOWER(t.name) = LOWER('{}') AND LOWER(u.name) = LOWER('{}') and LOWER(v.name) = LOWER('{}')".format(space_name, block_name, external_name, subnet_name), tenant_id)
target_endpoint = next((x for x in endpoints if x['name'].lower() == endpoint_name.lower()), None)
if target_subnet and target_endpoint:
if(ip == target_endpoint['ip']):
return True
try:
endpoint_ip = IPAddress(ip)
except Exception:
raise HTTPException(status_code=400, detail="Updated External Endpoint IP must be in valid IPv4 notation (x.x.x.x).")
if not endpoint_ip in IPNetwork(target_subnet['cidr']):
raise HTTPException(status_code=400, detail="Updated External Endpoint IP must be contained within the External Subnet CIDR.")
for endpoint in endpoints:
if endpoint['name'] != endpoint_name:
subnet_ips.append(endpoint['ip'])
update_set = IPSet([ip])
subnet_set = IPSet(subnet_ips)
if subnet_set & update_set:
raise HTTPException(status_code=400, detail="Updated IP cannot overlap other External Endpoints within the External Subnet.")
return True
async def scrub_ext_endpoint_patch(patch, space_name, block_name, external_name, subnet_name, endpoint_name, tenant_id):
scrubbed_patch = []
allowed_ops = [
{
"op": "replace",
"path": "/name",
"valid": valid_ext_endpoint_name_update,
"error": "External Endpoint name can be a maximum of 64 characters and may contain alphanumerics, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/desc",
"valid": EXTENDPOINT_DESC_REGEX,
"error": "External Endpoint description can be a maximum of 128 characters and may contain alphanumerics, spaces, underscores, hypens, slashes, and periods."
},
{
"op": "replace",
"path": "/ip",
"valid": valid_ext_endpoint_ip_update,
"error": "External Endpoint IP must be in valid IPv4 notation (x.x.x.x) and cannot overlap existing External Endpoints within the External Subnet."
}
]
for item in list(patch):
target = next((x for x in allowed_ops if (x['op'] == item['op'] and x['path'] == item['path'])), None)
if target:
if isinstance(target['valid'], str):
if re.match(target['valid'], str(item['value']), re.IGNORECASE):
scrubbed_patch.append(item)
else:
raise HTTPException(status_code=400, detail=target['error'])
elif callable(target['valid']):
if await target['valid'](item['value'], space_name, block_name, external_name, subnet_name, endpoint_name, tenant_id):
scrubbed_patch.append(item)
else:
raise HTTPException(status_code=400, detail=target['error'])
else:
raise HTTPException(status_code=400, detail=target['error'])
return scrubbed_patch
@router.get(
"",
summary = "Get All Spaces",
@ -1602,6 +1903,72 @@ async def get_external_network(
return target_ext_network
@router.patch(
"/{space}/blocks/{block}/externals/{external}",
summary = "Update External Network Details",
response_model = ExtNet,
status_code = 200
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error updating external network, please try again."
)
async def update_ext_network(
updates: ExtNetUpdate,
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
external: str = Path(..., description="Name of the target External Network"),
authorization: str = Header(None, description="Azure Bearer token"),
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Update an External Network with a JSON patch:
- **[&lt;JSON Patch&gt;]**: Array of JSON Patches
Allowed operations:
- **replace**
Allowed paths:
- **/name**
- **/desc**
- **/cidr**
"""
if not is_admin:
raise HTTPException(status_code=403, detail="This API is admin restricted.")
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id)
try:
target_space = copy.deepcopy(space_query[0])
update_space = copy.deepcopy(space_query[0])
except:
raise HTTPException(status_code=400, detail="Invalid space name.")
target_block = next((x for x in update_space['blocks'] if x['name'].lower() == block.lower()), None)
if not target_block:
raise HTTPException(status_code=400, detail="Invalid block name.")
update_ext_network = next((x for x in target_block['externals'] if x['name'].lower() == external.lower()), None)
if not update_ext_network:
raise HTTPException(status_code=400, detail="Invalid external network name.")
try:
patch = jsonpatch.JsonPatch([x.model_dump() for x in updates])
except jsonpatch.InvalidJsonPatch:
raise HTTPException(status_code=500, detail="Invalid JSON patch, please review and try again.")
scrubbed_patch = jsonpatch.JsonPatch(await scrub_ext_network_patch(patch, space, block, external, tenant_id))
scrubbed_patch.apply(update_ext_network, in_place=True)
await cosmos_replace(target_space, update_space)
return update_ext_network
@router.delete(
"/{space}/blocks/{block}/externals/{external}",
summary = "Remove External Network",
@ -1842,6 +2209,78 @@ async def get_external_subnet(
return target_ext_subnet
@router.patch(
"/{space}/blocks/{block}/externals/{external}/subnets/{subnet}",
summary = "Update External Subnet Details",
response_model = ExtSubnet,
status_code = 200
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error updating external subnet, please try again."
)
async def update_ext_subnet(
updates: ExtSubnetUpdate,
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
external: str = Path(..., description="Name of the target External Network"),
subnet: str = Path(..., description="Name of the target external subnet"),
authorization: str = Header(None, description="Azure Bearer token"),
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Update an External Subnet with a JSON patch:
- **[&lt;JSON Patch&gt;]**: Array of JSON Patches
Allowed operations:
- **replace**
Allowed paths:
- **/name**
- **/desc**
- **/cidr**
"""
if not is_admin:
raise HTTPException(status_code=403, detail="This API is admin restricted.")
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id)
try:
target_space = copy.deepcopy(space_query[0])
update_space = copy.deepcopy(space_query[0])
except:
raise HTTPException(status_code=400, detail="Invalid space name.")
target_block = next((x for x in update_space['blocks'] if x['name'].lower() == block.lower()), None)
if not target_block:
raise HTTPException(status_code=400, detail="Invalid block name.")
external_network = next((x for x in target_block['externals'] if x['name'].lower() == external.lower()), None)
if not external_network:
raise HTTPException(status_code=400, detail="Invalid external network name.")
update_ext_subnet = next((x for x in external_network['subnets'] if x['name'].lower() == subnet.lower()), None)
if not update_ext_subnet:
raise HTTPException(status_code=400, detail="Invalid external subnet name.")
try:
patch = jsonpatch.JsonPatch([x.model_dump() for x in updates])
except jsonpatch.InvalidJsonPatch:
raise HTTPException(status_code=500, detail="Invalid JSON patch, please review and try again.")
scrubbed_patch = jsonpatch.JsonPatch(await scrub_ext_subnet_patch(patch, space, block, external, subnet, tenant_id))
scrubbed_patch.apply(update_ext_subnet, in_place=True)
await cosmos_replace(target_space, update_space)
return update_ext_subnet
@router.delete(
"/{space}/blocks/{block}/externals/{external}/subnets/{subnet}",
summary = "Remove External Network Subnet",
@ -1957,7 +2396,7 @@ async def get_external_subnet_endpoints(
error_msg = "Error creating external network subnet endpoint, please try again."
)
async def create_external_subnet_endpoint(
endpoint: ExtEndpointUpdate,
endpoint: ExtEndpointReq,
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
external: str = Path(..., description="Name of the target External Network"),
@ -2005,10 +2444,10 @@ async def create_external_subnet_endpoint(
if endpoint_name_overlap:
raise HTTPException(status_code=400, detail="Target endpoint name overlaps existing endpoint name.")
if not re.match(EXTERNAL_NAME_REGEX, endpoint.name, re.IGNORECASE):
if not re.match(EXTENDPOINT_NAME_REGEX, endpoint.name, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Endpoint names can be a maximum of 32 characters and may contain alphanumerics, underscores, hypens, and periods.")
if not re.match(EXTERNAL_DESC_REGEX, endpoint.desc, re.IGNORECASE):
if not re.match(EXTENDPOINT_DESC_REGEX, endpoint.desc, re.IGNORECASE):
raise HTTPException(status_code=400, detail="Endpoint descriptions can be a maximum of 64 characters and may contain alphanumerics, spaces, underscores, hypens, slashes, and periods.")
subnet_network = IPNetwork(target_ext_subnet['cidr'])
@ -2052,7 +2491,7 @@ async def create_external_subnet_endpoint(
error_msg = "Error updating external network subnet endpoints, please try again."
)
async def update_external_subnet_enpoints(
endpoints: List[ExtEndpointUpdate],
endpoints: List[ExtEndpointReq],
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
external: str = Path(..., description="Name of the target External Network"),
@ -2086,10 +2525,10 @@ async def update_external_subnet_enpoints(
invalid_descs = []
for endpoint in endpoints:
if not re.match(EXTERNAL_NAME_REGEX, endpoint.name, re.IGNORECASE):
if not re.match(EXTENDPOINT_NAME_REGEX, endpoint.name, re.IGNORECASE):
invalid_names.append(endpoint['name'])
if not re.match(EXTERNAL_DESC_REGEX, endpoint.desc, re.IGNORECASE):
if not re.match(EXTENDPOINT_DESC_REGEX, endpoint.desc, re.IGNORECASE):
invalid_descs.append(endpoint['desc'])
if invalid_names:
@ -2282,6 +2721,84 @@ async def get_external_subnet_endpoint(
return target_ext_endpoint
@router.patch(
"/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}",
summary = "Update External Endpoint Details",
response_model = ExtEndpoint,
status_code = 200
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error updating external endpoint, please try again."
)
async def update_ext_endpoint(
updates: ExtEndpointUpdate,
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
external: str = Path(..., description="Name of the target External Network"),
subnet: str = Path(..., description="Name of the target external subnet"),
endpoint: str = Path(..., description="Name of the target external subnet endpoint"),
authorization: str = Header(None, description="Azure Bearer token"),
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Update an External Endpoint with a JSON patch:
- **[&lt;JSON Patch&gt;]**: Array of JSON Patches
Allowed operations:
- **replace**
Allowed paths:
- **/name**
- **/desc**
- **/ip**
"""
if not is_admin:
raise HTTPException(status_code=403, detail="This API is admin restricted.")
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id)
try:
target_space = copy.deepcopy(space_query[0])
update_space = copy.deepcopy(space_query[0])
except:
raise HTTPException(status_code=400, detail="Invalid space name.")
target_block = next((x for x in update_space['blocks'] if x['name'].lower() == block.lower()), None)
if not target_block:
raise HTTPException(status_code=400, detail="Invalid block name.")
external_network = next((x for x in target_block['externals'] if x['name'].lower() == external.lower()), None)
if not external_network:
raise HTTPException(status_code=400, detail="Invalid external network name.")
external_subnet = next((x for x in external_network['subnets'] if x['name'].lower() == subnet.lower()), None)
if not external_subnet:
raise HTTPException(status_code=400, detail="Invalid external subnet name.")
update_ext_endpoint = next((x for x in external_subnet['endpoints'] if x['name'].lower() == endpoint.lower()), None)
if not update_ext_endpoint:
raise HTTPException(status_code=400, detail="Invalid external endpoint name.")
try:
patch = jsonpatch.JsonPatch([x.model_dump() for x in updates])
except jsonpatch.InvalidJsonPatch:
raise HTTPException(status_code=500, detail="Invalid JSON patch, please review and try again.")
scrubbed_patch = jsonpatch.JsonPatch(await scrub_ext_endpoint_patch(patch, space, block, external, subnet, endpoint, tenant_id))
scrubbed_patch.apply(update_ext_endpoint, in_place=True)
await cosmos_replace(target_space, update_space)
return update_ext_endpoint
@router.delete(
"/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}",
summary = "Remove External Network Subnet Endpoint",
@ -2618,3 +3135,107 @@ async def delete_block_reservations(
await cosmos_replace(space_query[0], target_space)
return PlainTextResponse(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/{space}/blocks/{block}/reservations/{reservation}",
summary = "Get Block Reservation",
response_model = ReservationExpand,
status_code = 200
)
async def get_block_reservations(
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
reservation: str = Path(..., description="ID of the target Reservation"),
authorization: str = Header(None, description="Azure Bearer token"),
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Get the details of a specific CIDR Reservation.
"""
user_assertion = authorization.split(' ')[1]
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id)
try:
target_space = copy.deepcopy(space_query[0])
except:
raise HTTPException(status_code=400, detail="Invalid space name.")
target_block = next((x for x in target_space['blocks'] if x['name'].lower() == block.lower()), None)
if not target_block:
raise HTTPException(status_code=400, detail="Invalid block name.")
target_reservation = next((x for x in target_block['resv'] if x['id'] == reservation), None)
if not target_reservation:
raise HTTPException(status_code=400, detail="Invalid reservation ID.")
target_reservation['space'] = target_space['name']
target_reservation['block'] = target_block['name']
if not is_admin:
user_name = get_username_from_jwt(user_assertion)
if target_reservation['createdBy'] == user_name:
return target_reservation
else:
raise HTTPException(status_code=403, detail="Users can only view their own reservations.")
else:
return target_reservation
@router.delete(
"/{space}/blocks/{block}/reservations/{reservation}",
summary = "Delete CIDR Reservation",
status_code = 204
)
@cosmos_retry(
max_retry = 5,
error_msg = "Error removing reservation, please try again."
)
async def delete_block_reservations(
space: str = Path(..., description="Name of the target Space"),
block: str = Path(..., description="Name of the target Block"),
reservation: str = Path(..., description="ID of the target Reservation"),
authorization: str = Header(None, description="Azure Bearer token"),
tenant_id: str = Depends(get_tenant_id),
is_admin: str = Depends(get_admin)
):
"""
Remove a specific CIDR Reservation.
"""
user_assertion = authorization.split(' ')[1]
user_name = get_username_from_jwt(user_assertion)
space_query = await cosmos_query("SELECT * FROM c WHERE c.type = 'space' AND LOWER(c.name) = LOWER('{}')".format(space), tenant_id)
try:
target_space = copy.deepcopy(space_query[0])
except:
raise HTTPException(status_code=400, detail="Invalid space name.")
target_block = next((x for x in target_space['blocks'] if x['name'].lower() == block.lower()), None)
if not target_block:
raise HTTPException(status_code=400, detail="Invalid block name.")
target_reservation = next((x for x in target_block['resv'] if x['id'] == reservation), None)
if not target_reservation:
raise HTTPException(status_code=400, detail="Invalid reservation ID.")
if not is_admin:
if target_reservation['createdBy'] != user_name:
raise HTTPException(status_code=403, detail="Users can only delete their own reservations.")
if not target_reservation['settledOn']:
target_reservation['settledOn'] = time.time()
target_reservation['settledBy'] = user_name
target_reservation['status'] = "cancelledByUser"
await cosmos_replace(space_query[0], target_space)
return PlainTextResponse(status_code=status.HTTP_204_NO_CONTENT)

Просмотреть файл

@ -317,7 +317,7 @@ Context 'Blocks' {
}
# GET /api/spaces/{space}/blocks/{block}
It 'Get A Specific Block' {
It 'Get a Specific Block' {
$block, $blockStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA'
@ -336,7 +336,7 @@ Context 'Networks' {
}
# POST /api/spaces/{space}/blocks/{block}/networks
It 'Add a Virtual Network to a Block' {
It 'Add a Virtual Network to Block' {
$script:newNetA = New-AzVirtualNetwork `
-Name 'TestVNet01' `
-ResourceGroupName $env:IPAM_RESOURCE_GROUP `
@ -446,7 +446,7 @@ Context 'External Networks' {
}
# GET /api/spaces/{space}/blocks/{block}/externals/{external}
It 'Get Specific External Network' {
It 'Get a Specific External Network' {
$external, $externalStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB'
@ -455,9 +455,44 @@ Context 'External Networks' {
$external.Cidr -eq "10.1.2.0/24" | Should -Be $true
}
# PATCH /api/spaces/{space}/blocks/{block}/externals/{external}
It 'Update an External Network' {
$update = @(
@{
op = 'replace'
path = '/name'
value = 'ExternalNetC'
}
@{
op = 'replace'
path = '/desc'
value = 'External Network C'
}
@{
op = 'replace'
path = '/cidr'
value = '10.1.3.0/23'
}
)
Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB' $update
$externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals'
$externals.Count | Should -Be 2
$externals[0].Name -eq "ExternalNetA" | Should -Be $true
$externals[0].Desc -eq "External Network A" | Should -Be $true
$externals[0].Cidr -eq "10.1.1.0/24" | Should -Be $true
$externals[1].Name -eq "ExternalNetC" | Should -Be $true
$externals[1].Desc -eq "External Network B" | Should -Be $true
$externals[1].Cidr -eq "10.1.3.0/23" | Should -Be $true
}
# DELETE /api/spaces/{space}/blocks/{block}/externals/{external}
It 'Delete an External Network' {
Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetB'
Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetC'
$externals, $externalsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals'
@ -469,7 +504,7 @@ Context 'External Networks' {
}
# GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets
It 'Verify No Subnets Exist in External Network' {
It 'Verify No External Subnets Exist in External Network' {
$subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets'
@ -477,7 +512,7 @@ Context 'External Networks' {
}
# POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets
It 'Add a Subnet to an External Network' {
It 'Add an External Subnet to an External Network' {
$script:subnetA = @{
name = "SubnetA"
desc = "Subnet A"
@ -496,7 +531,7 @@ Context 'External Networks' {
}
# POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets
It 'Add a Second Subnet to an External Network' {
It 'Add a Second External Subnet to an External Network' {
$script:subnetB = @{
name = "SubnetB"
desc = "Subnet B"
@ -519,7 +554,7 @@ Context 'External Networks' {
}
# GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}
It 'Get Specific Subnet' {
It 'Get Specific External Subnet' {
$subnet, $subnetStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB'
@ -528,9 +563,44 @@ Context 'External Networks' {
$subnet.Cidr -eq "10.1.1.64/26" | Should -Be $true
}
# PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}
It 'Update an External Subnet' {
$update = @(
@{
op = 'replace'
path = '/name'
value = 'SubnetC'
}
@{
op = 'replace'
path = '/desc'
value = 'Subnet C'
}
@{
op = 'replace'
path = '/cidr'
value = '10.1.1.128/27'
}
)
Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB' $update
$subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets'
$subnets.Count | Should -Be 2
$subnets[0].Name -eq "SubnetA" | Should -Be $true
$subnets[0].Desc -eq "Subnet A" | Should -Be $true
$subnets[0].Cidr -eq "10.1.1.0/26" | Should -Be $true
$subnets[1].Name -eq "SubnetC" | Should -Be $true
$subnets[1].Desc -eq "Subnet C" | Should -Be $true
$subnets[1].Cidr -eq "10.1.1.128/27" | Should -Be $true
}
# DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}
It 'Delete an Subnet' {
Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetB'
It 'Delete an External Subnet' {
Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetC'
$subnets, $subnetsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets'
@ -542,7 +612,7 @@ Context 'External Networks' {
}
# GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints
It 'Verify No Endpoints Exist in Subnet' {
It 'Verify No External Endpoints Exist in External Subnet' {
$endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints'
@ -550,7 +620,7 @@ Context 'External Networks' {
}
# POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints
It 'Add an Endpoint to a Subnet' {
It 'Add an External Endpoint to an External Subnet' {
$script:endpointA = @{
name = "EndpointA"
desc = "Endpoint A"
@ -569,7 +639,7 @@ Context 'External Networks' {
}
# POST /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints
It 'Add a Second Endpoint to a Subnet' {
It 'Add a Second External Endpoint to an External Subnet' {
$script:endpointB = @{
name = "EndpointB"
desc = "Endpoint B"
@ -592,7 +662,7 @@ Context 'External Networks' {
}
# PUT /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints
It 'Replace Endpoints in Subnet' {
It 'Replace External Endpoints in an External Subnet' {
$script:endpointC = @{
name = "EndpointC"
desc = "Endpoint C"
@ -636,7 +706,7 @@ Context 'External Networks' {
}
# DELETE /api/spaces/{space}/blocks/{block}/externals/ExternalNetA/subnets/SubnetA/endpoints
It 'Delete Endpoint' {
It 'Delete External Endpoints' {
$body = @(
$script:endpointC.name
$script:endpointD.name
@ -658,7 +728,7 @@ Context 'External Networks' {
}
# GET /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}
It 'Get Specific Endpoint' {
It 'Get a Specific External Endpoint' {
$endpoint, $endpointStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointA'
@ -667,17 +737,52 @@ Context 'External Networks' {
$endpoint.IP | Should -Be "10.1.1.4"
}
# PATCH /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}
It 'Update an External Endpoint' {
$update = @(
@{
op = 'replace'
path = '/name'
value = 'EndpointC'
}
@{
op = 'replace'
path = '/desc'
value = 'Endpoint C'
}
@{
op = 'replace'
path = '/ip'
value = '10.1.1.10'
}
)
Update-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointB' $update
$endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints'
$endpoints.Count | Should -Be 2
$endpoints[0].Name -eq "EndpointA" | Should -Be $true
$endpoints[0].Desc -eq "Endpoint A" | Should -Be $true
$endpoints[0].IP -eq "10.1.1.4" | Should -Be $true
$endpoints[1].Name -eq "EndpointC" | Should -Be $true
$endpoints[1].Desc -eq "Endpoint C" | Should -Be $true
$endpoints[1].IP -eq "10.1.1.10" | Should -Be $true
}
# DELETE /api/spaces/{space}/blocks/{block}/externals/{external}/subnets/{subnet}/endpoints/{endpoint}
It 'Delete an Endpoint' {
Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointA'
It 'Delete an External Endpoint' {
Remove-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints/EndpointC'
$endpoints, $endpointsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/externals/ExternalNetA/subnets/SubnetA/endpoints'
$endpoints.Count | Should -Be 1
$endpoints[0].Name -eq "EndpointB" | Should -Be $true
$endpoints[0].Desc -eq "Endpoint B" | Should -Be $true
$endpoints[0].IP -eq "10.1.1.1" | Should -Be $true
$endpoints[0].Name -eq "EndpointA" | Should -Be $true
$endpoints[0].Desc -eq "Endpoint A" | Should -Be $true
$endpoints[0].IP -eq "10.1.1.4" | Should -Be $true
}
}
@ -702,11 +807,18 @@ Context 'Reservations' {
desc = "Test Reservation B"
}
$bodyC = @{
size = 24
desc = "Test Reservation C"
}
$script:reservationA, $reservationAStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyA
$script:reservationB, $reservationBStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyB
$script:reservationC, $reservationCStatus = New-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $bodyC
$reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations'
$reservations.Count | Should -Be 2
$reservations.Count | Should -Be 3
$reservations[0].Space -eq "TestSpaceA" | Should -Be $true
$reservations[0].Block -eq "TestBlockA" | Should -Be $true
@ -719,6 +831,12 @@ Context 'Reservations' {
$reservations[1].Desc -eq "Test Reservation B" | Should -Be $true
$reservations[1].Cidr -eq "10.1.3.0/24" | Should -Be $true
$reservations[1].SettledOn -eq $null | Should -Be $true
$reservations[2].Space -eq "TestSpaceA" | Should -Be $true
$reservations[2].Block -eq "TestBlockA" | Should -Be $true
$reservations[2].Desc -eq "Test Reservation C" | Should -Be $true
$reservations[2].Cidr -eq "10.1.4.0/24" | Should -Be $true
$reservations[2].SettledOn -eq $null | Should -Be $true
}
# Create an Azure Virtual Network w/ Reservation ID Tag and Verify it's Automatically Imported into IPAM
@ -742,16 +860,18 @@ Context 'Reservations' {
$($networks | Select-Object -ExpandProperty id) -contains $script:newNetA.Id | Should -Be $true
$($networks | Select-Object -ExpandProperty id) -contains $script:newNetC.Id | Should -Be $true
$reservations.Count | Should -Be 2
$reservations.Count | Should -Be 3
$reservations[0].SettledOn -eq $null | Should -Be $false
$reservations[0].Status -eq "fulfilled" | Should -Be $true
$reservations[1].SettledOn -eq $null | Should -Be $true
$reservations[1].Status -eq "wait" | Should -Be $true
$reservations[2].SettledOn -eq $null | Should -Be $true
$reservations[2].Status -eq "wait" | Should -Be $true
}
# DELETE /api/spaces/{space}/blocks/{block}/reservations
It 'Delete A Reservation' {
It 'Delete Reservations' {
$body = @(
$script:reservationB.Id
)
@ -764,12 +884,46 @@ Context 'Reservations' {
$reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query
$reservations.Count | Should -Be 2
$reservations.Count | Should -Be 3
$reservations[0].SettledOn -eq $null | Should -Be $false
$reservations[0].Status -eq "fulfilled" | Should -Be $true
$reservations[1].SettledOn -eq $null | Should -Be $false
$reservations[1].Status -eq "cancelledByUser" | Should -Be $true
$reservations[2].SettledOn -eq $null | Should -Be $true
$reservations[2].Status -eq "wait" | Should -Be $true
}
# GET /api/spaces/{space}/blocks/{block}/reservations/{reservationId}
It 'Get a Specific Reservation' {
$reservation, $reservationStatus = Get-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/reservations/$($script:reservationA.Id)"
$reservation.Space -eq "TestSpaceA" | Should -Be $true
$reservation.Block -eq "TestBlockA" | Should -Be $true
$reservation.Desc -eq "Test Reservation C" | Should -Be $true
$reservation.Cidr -eq "10.1.4.0/24" | Should -Be $true
$reservation.SettledOn -eq $null | Should -Be $true
}
# DELETE /api/spaces/{space}/blocks/{block}/reservations/{reservationId}
It 'Delete a Specific Reservation' {
$query = @{
settled = $true
}
Remove-ApiResource "/spaces/TestSpaceA/blocks/TestBlockA/reservations/$($script:reservationA.Id)"
$reservations, $reservationsStatus = Get-ApiResource '/spaces/TestSpaceA/blocks/TestBlockA/reservations' $query
$reservations.Count | Should -Be 3
$reservations[0].SettledOn -eq $null | Should -Be $false
$reservations[0].Status -eq "fulfilled" | Should -Be $true
$reservations[1].SettledOn -eq $null | Should -Be $false
$reservations[1].Status -eq "cancelledByUser" | Should -Be $true
$reservations[2].SettledOn -eq $null | Should -Be $false
$reservations[2].Status -eq "cancelledByUser" | Should -Be $true
}
}

745
ui/package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -4,22 +4,23 @@
"type": "module",
"private": true,
"dependencies": {
"@azure/msal-browser": "^3.16.0",
"@azure/msal-react": "^2.0.18",
"@azure/msal-browser": "^3.17.0",
"@azure/msal-react": "^2.0.19",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@inovua/reactdatagrid-community": "^5.10.2",
"@mui/icons-material": "^5.15.19",
"@mui/icons-material": "^5.15.21",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.19",
"@mui/material": "^5.15.21",
"@reduxjs/toolkit": "^2.2.5",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"axios": "^1.7.2",
"echarts": "^5.5.0",
"echarts": "^5.5.1",
"echarts-for-react": "^3.0.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"moment": "^2.30.1",
"notistack": "^3.0.1",
"pluralize": "^8.0.0",
@ -27,9 +28,9 @@
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
"react-redux": "^9.1.2",
"react-router-dom": "^6.23.1",
"react-router-dom": "^6.24.0",
"spinners-react": "^1.0.7",
"web-vitals": "^4.0.1"
"web-vitals": "^4.2.0"
},
"scripts": {
"start": "vite",
@ -48,12 +49,12 @@
]
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react": "^7.34.3",
"eslint-plugin-react-hooks": "^4.6.2",
"serve": "^14.2.3",
"vite": "^5.2.12",
"vite": "^5.3.2",
"vite-plugin-eslint2": "^4.4.0"
}
}

Просмотреть файл

@ -29,7 +29,7 @@ import {
TaskAltOutlined,
CancelOutlined,
AddOutlined,
// EditOutlined,
EditOutlined,
DeleteOutline
} from "@mui/icons-material";
@ -39,6 +39,7 @@ import {
} from "../../../ipam/ipamSlice";
import AddExtNetwork from "./utils/addNetwork";
import EditExtNetwork from "./utils/editNetwork";
import DeleteExtNetwork from "./utils/deleteNetwork";
import { ExternalContext } from "../externalContext";
@ -58,6 +59,7 @@ function HeaderMenu(props) {
selectedBlock,
selectedExternal,
setAddExtOpen,
setEditExtOpen,
setDelExtOpen,
saving,
sendResults,
@ -81,6 +83,11 @@ function HeaderMenu(props) {
setMenuOpen(false);
}
const onEditExt = () => {
setEditExtOpen(true);
setMenuOpen(false);
}
const onDelExt = () => {
setDelExtOpen(true);
setMenuOpen(false);
@ -183,15 +190,15 @@ function HeaderMenu(props) {
</ListItemIcon>
Add Network
</MenuItem>
{/* <MenuItem
onClick={() => console.log("EDIT!")}
disabled={ true }
<MenuItem
onClick={onEditExt}
disabled={ !selectedExternal }
>
<ListItemIcon>
<EditOutlined fontSize="small" />
</ListItemIcon>
Edit Network
</MenuItem> */}
</MenuItem>
<MenuItem
onClick={onDelExt}
disabled={ !selectedExternal }
@ -253,6 +260,7 @@ const Networks = (props) => {
const [columnSortState, setColumnSortState] = React.useState({});
const [addExtOpen, setAddExtOpen] = React.useState(false);
const [editExtOpen, setEditExtOpen] = React.useState(false);
const [delExtOpen, setDelExtOpen] = React.useState(false);
const viewSetting = useSelector(state => selectViewSetting(state, 'extnetworks'));
@ -486,6 +494,14 @@ const Networks = (props) => {
block={selectedBlock ? selectedBlock : null}
externals={externals}
/>
<EditExtNetwork
open={editExtOpen}
handleClose={() => setEditExtOpen(false)}
space={selectedSpace ? selectedSpace.name : null}
block={selectedBlock ? selectedBlock : null}
externals={externals}
selectedExternal={selectedExternal}
/>
<DeleteExtNetwork
open={delExtOpen}
handleClose={() => setDelExtOpen(false)}
@ -493,7 +509,7 @@ const Networks = (props) => {
block={selectedBlock ? selectedBlock.name : null}
external={selectedExternal ? selectedExternal.name : null}
/>
<ExtNetworkContext.Provider value={{ selectedSpace, selectedBlock, selectedExternal, setAddExtOpen, setDelExtOpen, selectionModel, saving, sendResults, saveConfig, loadConfig, resetConfig }}>
<ExtNetworkContext.Provider value={{ selectedSpace, selectedBlock, selectedExternal, setAddExtOpen, setEditExtOpen, setDelExtOpen, selectionModel, saving, sendResults, saveConfig, loadConfig, resetConfig }}>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%'}}>
<Box sx={{ display: 'flex', height: '35px', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(224, 224, 224, 1)', borderBottom: 'none' }}>
<Typography variant='button'>

Просмотреть файл

@ -0,0 +1,363 @@
import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import { useSnackbar } from "notistack";
import Draggable from "react-draggable";
import {
Box,
Button,
Tooltip,
TextField,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
Paper
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import {
selectNetworks,
updateBlockExternalAsync
} from "../../../../ipam/ipamSlice";
import {
isSubnetOf,
isSubnetOverlap
} from "../../../../tools/planner/utils/iputils";
import {
EXTERNAL_NAME_REGEX,
EXTERNAL_DESC_REGEX,
CIDR_REGEX
} from "../../../../../global/globals";
function DraggablePaper(props) {
const nodeRef = React.useRef(null);
return (
<Draggable
nodeRef={nodeRef}
handle="#draggable-dialog-title"
cancel={'[class*="MuiDialogContent-root"]'}
bounds="parent"
>
<Paper {...props} ref={nodeRef}/>
</Draggable>
);
}
export default function EditExtNetwork(props) {
const { open, handleClose, space, block, externals, selectedExternal } = props;
const { enqueueSnackbar } = useSnackbar();
const [extName, setExtName] = React.useState({ value: "", error: false });
const [extDesc, setExtDesc] = React.useState({ value: "", error: false });
const [extCidr, setExtCidr] = React.useState({ value: "", error: false });
const [sending, setSending] = React.useState(false);
const dispatch = useDispatch();
const networks = useSelector(selectNetworks);
function onCancel() {
handleClose();
if(selectedExternal) {
setExtName({ value: selectedExternal.name, error: false });
setExtDesc({ value: selectedExternal.desc, error: false });
setExtCidr({ value: selectedExternal.cidr, error: false });
} else {
setExtName({ value: "", error: false });
setExtDesc({ value: "", error: false });
setExtCidr({ value: "", error: false });
}
}
function onSubmit() {
var body = [
{
op: "replace",
path: "/name",
value: extName.value
},
{
op: "replace",
path: "/desc",
value: extDesc.value,
},
{
op: "replace",
path: "/cidr",
value: extCidr.value
}
];
(async () => {
try {
setSending(true);
await dispatch(updateBlockExternalAsync({ space: space, block: block.name, external: selectedExternal.name, body: body }));
enqueueSnackbar("Successfully updated External Network", { variant: "success" });
onCancel();
} catch (e) {
console.log("ERROR");
console.log("------------------");
console.log(e);
console.log("------------------");
enqueueSnackbar(e.message, { variant: "error" });
} finally {
setSending(false);
}
})();
}
function onNameChange(event) {
const newName = event.target.value;
if(externals) {
const regex = new RegExp(
EXTERNAL_NAME_REGEX
);
const nameError = newName ? !regex.test(newName) : false;
const nameExists = externals?.reduce((acc, curr) => {
curr['name'] !== selectedExternal['name'] && acc.push(curr['name'].toLowerCase());
return acc;
}, []).includes(newName.toLowerCase());
setExtName({
value: newName,
error: (nameError || nameExists)
});
}
}
function onDescChange(event) {
const newDesc = event.target.value;
const regex = new RegExp(
EXTERNAL_DESC_REGEX
);
setExtDesc({
value: newDesc,
error: (newDesc ? !regex.test(newDesc) : false)
});
}
function onCidrChange(event) {
const newCidr = event.target.value;
const regex = new RegExp(
CIDR_REGEX
);
const cidrError = newCidr ? !regex.test(newCidr) : false;
var blockNetworks= [];
var extNetworks = [];
var cidrInBlock = false;
var resvOverlap = true;
var vnetOverlap = true;
var extOverlap = true;
if(!cidrError && newCidr.length > 0) {
cidrInBlock = isSubnetOf(newCidr, block.cidr);
const openResv = block?.resv.reduce((acc, curr) => {
if(!curr['settledOn']) {
acc.push(curr['cidr']);
}
return acc;
}, []);
if(space && block && networks) {
blockNetworks = networks?.reduce((acc, curr) => {
if(curr['parent_space'] && curr['parent_block']) {
if(curr['parent_space'] === space && curr['parent_block'].includes(block.name)) {
acc = acc.concat(curr['prefixes']);
}
}
return acc;
}, []);
}
if(externals) {
extNetworks = externals?.reduce((acc, curr) => {
curr['name'] !== selectedExternal['name'] && acc.push(curr['cidr']);
return acc;
}, []);
}
resvOverlap = isSubnetOverlap(newCidr, openResv);
vnetOverlap = isSubnetOverlap(newCidr, blockNetworks);
extOverlap = isSubnetOverlap(newCidr, extNetworks);
}
setExtCidr({
value: newCidr,
error: (cidrError || !cidrInBlock || resvOverlap || vnetOverlap || extOverlap)
});
}
const unchanged = React.useMemo(() => {
if(selectedExternal) {
return (
extName.value === selectedExternal.name &&
extDesc.value === selectedExternal.desc &&
extCidr.value === selectedExternal.cidr
);
} else {
return true;
}
}, [selectedExternal, extName, extDesc, extCidr]);
const hasError = React.useMemo(() => {
var emptyCheck = false;
var errorCheck = false;
errorCheck = (extName.error || extDesc.error || extCidr.error);
emptyCheck = (extName.value.length === 0 || extDesc.value.length === 0 || extCidr.value.length === 0);
return (errorCheck || emptyCheck);
}, [extName, extDesc, extCidr]);
React.useEffect(() => {
if (selectedExternal) {
setExtName({ value: selectedExternal.name, error: false });
setExtDesc({ value: selectedExternal.desc, error: false });
setExtCidr({ value: selectedExternal.cidr, error: false });
} else {
handleClose();
setExtName({ value: "", error: false });
setExtDesc({ value: "", error: false });
setExtCidr({ value: "", error: false });
}
}, [selectedExternal, handleClose]);
return (
<div>
<Dialog
open={open}
onClose={onCancel}
PaperComponent={DraggablePaper}
maxWidth="xs"
fullWidth
>
<DialogTitle style={{ cursor: 'move' }} id="draggable-dialog-title">
Edit External Network
</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" alignItems="center">
<Tooltip
arrow
disableFocusListener
placement="right"
title={
<>
- Network name must be unique
<br />- Max of 64 characters
<br />- Can contain alphnumerics
<br />- Can contain underscore, hypen and period
<br />- Cannot start/end with underscore, hypen or period
</>
}
>
<TextField
autoFocus
error={extName.error}
margin="dense"
id="name"
label="Name"
type="name"
variant="standard"
value={extName.value}
onChange={(event) => onNameChange(event)}
inputProps={{ spellCheck: false }}
sx={{ width: "80%" }}
/>
</Tooltip>
<Tooltip
arrow
disableFocusListener
placement="right"
title={
<>
- Max of 128 characters
<br />- Can contain alphnumerics
<br />- Can contain spaces
<br />- Can contain underscore, hypen, slash and period
<br />- Cannot start/end with underscore, hypen, slash or period
</>
}
>
<TextField
error={extDesc.error}
margin="dense"
id="name"
label="Description"
type="description"
variant="standard"
value={extDesc.value}
onChange={(event) => onDescChange(event)}
inputProps={{ spellCheck: false }}
sx={{ width: "80%" }}
/>
</Tooltip>
<Tooltip
arrow
disableFocusListener
placement="right"
title={
<>
- Must be in valid CIDR notation format
<br />&nbsp;&nbsp;&nbsp;&nbsp;- Example: 1.2.3.4/5
<br />- Cannot overlap existing subnets
</>
}
>
<TextField
error={(extCidr.value.length > 0 && extCidr.error)}
margin="dense"
id="name"
label="CIDR"
type="cidr"
variant="standard"
value={extCidr.value}
onChange={(event) => onCidrChange(event)}
inputProps={{ spellCheck: false }}
sx={{ width: "80%" }}
/>
</Tooltip>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={onCancel}
disabled={sending}
>
Cancel
</Button>
<LoadingButton
onClick={onSubmit}
loading={sending}
disabled={hasError || unchanged}
>
Update
</LoadingButton>
</DialogActions>
</Dialog>
</div>
);
}

Просмотреть файл

@ -29,7 +29,7 @@ import {
TaskAltOutlined,
CancelOutlined,
AddOutlined,
// EditOutlined,
EditOutlined,
DeleteOutline,
EditNoteOutlined
} from "@mui/icons-material";
@ -40,6 +40,7 @@ import {
} from "../../../ipam/ipamSlice";
import AddExtSubnet from "./utils/addSubnet";
import EditExtSubnet from "./utils/editSubnet";
import DeleteExtSubnet from "./utils/deleteSubnet";
import ManageExtEndpoints from "./utils/manageEndpoints";
@ -59,6 +60,7 @@ function HeaderMenu(props) {
selectedExternal,
selectedSubnet,
setAddExtSubOpen,
setEditExtSubOpen,
setDelExtSubOpen,
setManExtEndOpen,
saving,
@ -83,6 +85,11 @@ function HeaderMenu(props) {
setMenuOpen(false);
}
const onEditExtSub = () => {
setEditExtSubOpen(true);
setMenuOpen(false);
}
const onDelExtSub = () => {
setDelExtSubOpen(true);
setMenuOpen(false);
@ -190,15 +197,15 @@ function HeaderMenu(props) {
</ListItemIcon>
Add Subnet
</MenuItem>
{/* <MenuItem
onClick={() => console.log("EDIT!")}
disabled={ true }
<MenuItem
onClick={onEditExtSub}
disabled={ !selectedSubnet }
>
<ListItemIcon>
<EditOutlined fontSize="small" />
</ListItemIcon>
Edit Subnet
</MenuItem> */}
</MenuItem>
<MenuItem
onClick={onDelExtSub}
disabled={ !selectedSubnet }
@ -270,6 +277,7 @@ const Subnets = (props) => {
const [columnSortState, setColumnSortState] = React.useState({});
const [addExtSubOpen, setAddExtSubOpen] = React.useState(false);
const [editExtSubOpen, setEditExtSubOpen] = React.useState(false);
const [delExtSubOpen, setDelExtSubOpen] = React.useState(false);
const [manExtEndOpen, setManExtEndOpen] = React.useState(false);
@ -505,6 +513,15 @@ const Subnets = (props) => {
external={selectedExternal ? selectedExternal : null}
subnets={subnets}
/>
<EditExtSubnet
open={editExtSubOpen}
handleClose={() => setEditExtSubOpen(false)}
space={selectedSpace ? selectedSpace.name : null}
block={selectedBlock ? selectedBlock.name : null}
external={selectedExternal ? selectedExternal : null}
subnets={subnets}
selectedSubnet={selectedSubnet}
/>
<DeleteExtSubnet
open={delExtSubOpen}
handleClose={() => setDelExtSubOpen(false)}
@ -521,7 +538,7 @@ const Subnets = (props) => {
external={selectedExternal ? selectedExternal.name : null}
subnet={selectedSubnet ? selectedSubnet : null}
/>
<ExtSubnetContext.Provider value={{ selectedExternal, selectedSubnet, setAddExtSubOpen, setDelExtSubOpen, setManExtEndOpen, saving, sendResults, saveConfig, loadConfig, resetConfig }}>
<ExtSubnetContext.Provider value={{ selectedExternal, selectedSubnet, setAddExtSubOpen, setEditExtSubOpen, setDelExtSubOpen, setManExtEndOpen, saving, sendResults, saveConfig, loadConfig, resetConfig }}>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%'}}>
<Box sx={{ display: 'flex', height: '35px', alignItems: 'center', justifyContent: 'center', border: '1px solid rgba(224, 224, 224, 1)', borderBottom: 'none' }}>
<Typography variant='button'>

Просмотреть файл

@ -36,7 +36,6 @@ import {
CIDR_REGEX,
cidrMasks
} from "../../../../../global/globals";
import { Spellcheck } from "@mui/icons-material";
function DraggablePaper(props) {
const nodeRef = React.useRef(null);

335
ui/src/features/configure/externals/subnets/utils/editSubnet.jsx поставляемый Normal file
Просмотреть файл

@ -0,0 +1,335 @@
import * as React from "react";
import { useDispatch } from "react-redux";
import { useSnackbar } from "notistack";
import Draggable from "react-draggable";
import {
Box,
Button,
Tooltip,
TextField,
Dialog,
DialogTitle,
DialogActions,
DialogContent,
Paper
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import {
updateBlockExtSubnetAsync
} from "../../../../ipam/ipamSlice";
import {
isSubnetOf,
isSubnetOverlap
} from "../../../../tools/planner/utils/iputils";
import {
EXTSUBNET_NAME_REGEX,
EXTSUBNET_DESC_REGEX,
CIDR_REGEX
} from "../../../../../global/globals";
function DraggablePaper(props) {
const nodeRef = React.useRef(null);
return (
<Draggable
nodeRef={nodeRef}
handle="#draggable-dialog-title"
cancel={'[class*="MuiDialogContent-root"]'}
bounds="parent"
>
<Paper {...props} ref={nodeRef}/>
</Draggable>
);
}
export default function EditExtSubnet(props) {
const { open, handleClose, space, block, external, subnets, selectedSubnet } = props;
const { enqueueSnackbar } = useSnackbar();
const [subName, setSubName] = React.useState({ value: "", error: false });
const [subDesc, setSubDesc] = React.useState({ value: "", error: false });
const [subCidr, setSubCidr] = React.useState({ value: "", error: false });
const [sending, setSending] = React.useState(false);
const dispatch = useDispatch();
function onCancel() {
handleClose();
if(selectedSubnet) {
setSubName({ value: selectedSubnet.name, error: false });
setSubDesc({ value: selectedSubnet.desc, error: false });
setSubCidr({ value: selectedSubnet.cidr, error: false });
} else {
setSubName({ value: "", error: false });
setSubDesc({ value: "", error: false });
setSubCidr({ value: "", error: false });
}
}
function onSubmit() {
var body = [
{
op: "replace",
path: "/name",
value: subName.value
},
{
op: "replace",
path: "/desc",
value: subDesc.value
},
{
op: "replace",
path: "/cidr",
value: subCidr.value
}
];
(async () => {
try {
setSending(true);
await dispatch(updateBlockExtSubnetAsync({ space: space, block: block, external: external.name, subnet: selectedSubnet.name, body: body }));
enqueueSnackbar("Successfully updated External Subnet", { variant: "success" });
onCancel();
} catch (e) {
console.log("ERROR");
console.log("------------------");
console.log(e);
console.log("------------------");
enqueueSnackbar(e.message, { variant: "error" });
} finally {
setSending(false);
}
})();
}
function onNameChange(event) {
const newName = event.target.value;
if(subnets) {
const regex = new RegExp(
EXTSUBNET_NAME_REGEX
);
const nameError = newName ? !regex.test(newName) : false;
const nameExists = subnets?.reduce((acc, curr) => {
curr['name'] !== selectedSubnet['name'] && acc.push(curr['name'].toLowerCase());
return acc;
}, []).includes(newName.toLowerCase());
setSubName({
value: newName,
error: (nameError || nameExists)
});
}
}
function onDescChange(event) {
const newDesc = event.target.value;
const regex = new RegExp(
EXTSUBNET_DESC_REGEX
);
setSubDesc({
value: newDesc,
error: (newDesc ? !regex.test(newDesc) : false)
});
}
function onCidrChange(event) {
const newCidr = event.target.value;
const regex = new RegExp(
CIDR_REGEX
);
const cidrError = newCidr ? !regex.test(newCidr) : false;
var extSubnets = [];
var cidrInBlock = false;
var subOverlap = true;
if(!cidrError && newCidr.length > 0) {
cidrInBlock = isSubnetOf(newCidr, external.cidr);
if(subnets) {
extSubnets = subnets?.reduce((acc, curr) => {
curr['name'] !== selectedSubnet['name'] && acc.push(curr['cidr']);
return acc;
}, []);
}
subOverlap = isSubnetOverlap(newCidr, extSubnets);
}
setSubCidr({
value: newCidr,
error: (cidrError || !cidrInBlock || subOverlap)
});
}
const unchanged = React.useMemo(() => {
if(selectedSubnet) {
return (
subName.value === selectedSubnet.name &&
subDesc.value === selectedSubnet.desc &&
subCidr.value === selectedSubnet.cidr
);
} else {
return true;
}
}, [selectedSubnet, subName, subDesc, subCidr]);
const hasError = React.useMemo(() => {
var emptyCheck = false;
var errorCheck = false;
errorCheck = (subName.error || subDesc.error || subCidr.error);
emptyCheck = (subName.value.length === 0 || subDesc.value.length === 0 || subCidr.value.length === 0);
return (errorCheck || emptyCheck);
}, [subName, subDesc, subCidr]);
React.useEffect(() => {
if (selectedSubnet) {
setSubName({ value: selectedSubnet.name, error: false });
setSubDesc({ value: selectedSubnet.desc, error: false });
setSubCidr({ value: selectedSubnet.cidr, error: false });
} else {
handleClose();
setSubName({ value: "", error: false });
setSubDesc({ value: "", error: false });
setSubCidr({ value: "", error: false });
}
}, [selectedSubnet, handleClose]);
return (
<div>
<Dialog
open={open}
onClose={onCancel}
PaperComponent={DraggablePaper}
maxWidth="xs"
fullWidth
>
<DialogTitle style={{ cursor: 'move' }} id="draggable-dialog-title">
Edit External Subnet
</DialogTitle>
<DialogContent>
<Box display="flex" flexDirection="column" alignItems="center">
<Tooltip
arrow
disableFocusListener
placement="right"
title={
<>
- Subnet name must be unique
<br />- Max of 64 characters
<br />- Can contain alphnumerics
<br />- Can contain underscore, hypen and period
<br />- Cannot start/end with underscore, hypen or period
</>
}
>
<TextField
autoFocus
error={subName.error}
margin="dense"
id="name"
label="Name"
type="name"
variant="standard"
value={subName.value}
onChange={(event) => onNameChange(event)}
inputProps={{ spellCheck: false }}
sx={{width: "80%" }}
/>
</Tooltip>
<Tooltip
arrow
disableFocusListener
placement="right"
title={
<>
- Max of 128 characters
<br />- Can contain alphnumerics
<br />- Can contain spaces
<br />- Can contain underscore, hypen, slash and period
<br />- Cannot start/end with underscore, hypen, slash or period
</>
}
>
<TextField
error={subDesc.error}
margin="dense"
id="name"
label="Description"
type="description"
variant="standard"
value={subDesc.value}
onChange={(event) => onDescChange(event)}
inputProps={{ spellCheck: false }}
sx={{ width: "80%" }}
/>
</Tooltip>
<Tooltip
arrow
disableFocusListener
placement="right"
title={
<>
- Must be in valid CIDR notation format
<br />&nbsp;&nbsp;&nbsp;&nbsp;- Example: 1.2.3.4/5
<br />- Cannot overlap existing subnets
</>
}
>
<TextField
error={(subCidr.value.length > 0 && subCidr.error)}
margin="dense"
id="name"
label="CIDR"
type="cidr"
variant="standard"
value={subCidr.value}
onChange={(event) => onCidrChange(event)}
inputProps={{ spellCheck: false }}
sx={{ width: "80%" }}
/>
</Tooltip>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={onCancel}
disabled={sending}
>
Cancel
</Button>
<LoadingButton
onClick={onSubmit}
loading={sending}
disabled={hasError || unchanged}
>
Update
</LoadingButton>
</DialogActions>
</Dialog>
</div>
);
}

Просмотреть файл

@ -2,7 +2,7 @@ import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import { styled } from "@mui/material/styles";
import { isEmpty, isEqual, pickBy, orderBy, cloneDeep } from "lodash";
import { omit, isEmpty, isEqual, pickBy, orderBy, cloneDeep } from "lodash";
import { useSnackbar } from "notistack";
@ -14,6 +14,8 @@ import Draggable from "react-draggable";
import { useTheme } from "@mui/material/styles";
import md5 from "md5";
import {
Box,
Button,
@ -43,7 +45,9 @@ import {
TaskAltOutlined,
CancelOutlined,
PlaylistAddOutlined,
HighlightOff,
PlaylistAddCheckOutlined,
PlaylistRemoveOutlined,
// HighlightOff,
InfoOutlined
} from "@mui/icons-material";
@ -56,7 +60,8 @@ import {
} from "../../../../ipam/ipamSlice";
import {
expandCIDR
expandCIDR,
getSubnetSize
} from "../../../../tools/planner/utils/iputils";
import {
@ -87,7 +92,7 @@ const gridStyle = {
function RenderDelete(props) {
const { value } = props;
const { endpoints, setAdded, deleted, setDeleted, selectionModel } = React.useContext(EndpointContext);
const { setChanges, selectionModel } = React.useContext(EndpointContext);
const flexCenter = {
display: "flex",
@ -108,14 +113,18 @@ function RenderDelete(props) {
disableTouchRipple
disableRipple
onClick={() => {
if(endpoints.find(e => e.name === value.name) && !deleted.includes(value.name)) {
setDeleted(prev => [...prev, value.name]);
} else {
setAdded(prev => prev.filter(e => e.name !== value.name));
}
var endpointDetails = cloneDeep(value);
endpointDetails['op'] = "delete";
setChanges(prev => [
...prev,
endpointDetails
]);
}}
>
<HighlightOff />
{/* <HighlightOff /> */}
<PlaylistRemoveOutlined />
</IconButton>
</span>
</Tooltip>
@ -284,8 +293,7 @@ export default function ManageExtEndpoints(props) {
const [sendResults, setSendResults] = React.useState(null);
const [endpoints, setEndpoints] = React.useState(null);
const [addressOptions, setAddressOptions] = React.useState([]);
const [added, setAdded] = React.useState([]);
const [deleted, setDeleted] = React.useState([]);
const [changes, setChanges] = React.useState([]);
const [gridData, setGridData] = React.useState(null);
const [sending, setSending] = React.useState(false);
const [selectionModel, setSelectionModel] = React.useState({});
@ -331,6 +339,31 @@ export default function ManageExtEndpoints(props) {
setSelectionModel(prevState => {
if(!prevState.hasOwnProperty(id)) {
newSelectionModel[id] = data;
setEndName({ value: data.name, error: false });
setEndDesc({ value: data.desc, error: false });
setEndAddrInput(data.ip);
setEndAddr(data.ip);
const endpointAddresses = endpoints.map(e => {
if (data.ip !== e.ip) {
return e.ip;
}
});
const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr));
setAddressOptions(newAddressOptions);
} else {
setEndName({ value: "", error: true });
setEndDesc({ value: "", error: true });
setEndAddrInput("");
setEndAddr(null);
const endpointAddresses = endpoints.map(e => e.ip);
const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr));
setAddressOptions(["<auto>", ...newAddressOptions]);
}
return newSelectionModel;
@ -482,14 +515,43 @@ export default function ManageExtEndpoints(props) {
function onAddExternal() {
if(!hasError) {
setAdded(prev => [
...prev,
{
name: endName.value,
desc: endDesc.value,
ip: endAddr
var endpointDetails = {
name: endName.value,
desc: endDesc.value,
ip: endAddr
};
endpointDetails['id'] = md5(JSON.stringify(endpointDetails));
if (Object.keys(selectionModel).length !== 0) {
const updates = {
op: "update",
old: Object.values(selectionModel)[0],
new: endpointDetails
}
]);
setChanges(prev => [
...prev,
updates
]);
} else {
const numEndpoints = endpoints.length;
const numAdditions = changes.filter(change => change.op === "add").length;
const numDeletions = changes.filter(change => change.op === "delete").length;
const subnetSize = getSubnetSize(subnet.cidr) - 2;
if (((numEndpoints + numAdditions) - numDeletions) >= subnetSize) {
enqueueSnackbar(`Number of endpoints cannot exceed subnet size of ${subnetSize}`, { variant: "error" });
return;
}
endpointDetails['op'] = "add";
setChanges(prev => [
...prev,
endpointDetails
]);
}
setEndName({ value: "", error: true });
setEndDesc({ value: "", error: true });
@ -533,15 +595,15 @@ export default function ManageExtEndpoints(props) {
const onCancel = React.useCallback(() => {
if (open) {
setAdded([]);
setDeleted([]);
handleClose();
setSelectionModel({});
setChanges([]);
setEndName({ value: "", error: true });
setEndDesc({ value: "", error: true });
setEndAddrInput("");
setEndAddr(null);
handleClose();
}
}, [open, handleClose]);
@ -561,7 +623,17 @@ export default function ManageExtEndpoints(props) {
);
const nameError = newName ? !regex.test(newName) : false;
const nameExists = endpoints.map(e => e.name.toLowerCase()).includes(newName.toLowerCase());
const nameExists = endpoints?.reduce((acc, curr) => {
if(Object.keys(selectionModel).length !== 0) {
if (curr['name'].toLowerCase() !== Object.values(selectionModel)[0].name.toLowerCase()) {
acc.push(curr['name'].toLowerCase());
}
} else {
acc.push(curr['name'].toLowerCase());
}
return acc;
}, []).includes(newName.toLowerCase());
setEndName({
value: newName,
@ -594,17 +666,41 @@ export default function ManageExtEndpoints(props) {
if(subnet) {
var newEndpoints = cloneDeep(subnet['endpoints']);
newEndpoints = newEndpoints.filter(e => !deleted.includes(e.name));
newEndpoints = newEndpoints.concat(added);
const newData = newEndpoints.reduce((acc, curr) => {
curr['id'] = `${subnet}@${curr.name}}`
var newData = newEndpoints.reduce((acc, curr) => {
// curr['id'] = `${subnet}@${curr.name}}`;
curr['id'] = md5(JSON.stringify(curr));
acc.push(curr);
return acc;
}, []);
changes.forEach(change => {
switch(change.op) {
case "add": {
newData.push(omit(change, 'op'));
break;
}
case "update": {
const index = newData.findIndex(e => e.name === change.old.name);
if (index !== -1) {
newData[index] = change.new;
}
break;
}
case "delete": {
newData = newData.filter(e => e.name !== change.name);
break;
}
default:
break;
}
});
const endpointAddresses = newData.map(e => e.ip);
const newAddressOptions = expandCIDR(subnet.cidr).slice(1,-1).filter(addr => !endpointAddresses.includes(addr));
@ -613,10 +709,10 @@ export default function ManageExtEndpoints(props) {
} else {
onCancel();
}
}, [subnet, added, deleted, onCancel]);
}, [subnet, changes, onCancel]);
return (
<EndpointContext.Provider value={{ endpoints, setAdded, deleted, setDeleted, selectionModel, saving, sendResults, saveConfig, loadConfig, resetConfig }}>
<EndpointContext.Provider value={{ endpoints, setChanges, selectionModel, saving, sendResults, saveConfig, loadConfig, resetConfig }}>
<Dialog
open={open}
onClose={onCancel}
@ -674,7 +770,11 @@ export default function ManageExtEndpoints(props) {
color: theme.palette.mode === 'dark' ? '#9ba7b4' : '#555e68'
}}
>
Add New Endpoint
{
Object.keys(selectionModel).length !== 0 ?
"Edit Existing Endpoint" :
"Add New Endpoint"
}
</span>
</Box>
<Box
@ -884,17 +984,31 @@ export default function ManageExtEndpoints(props) {
disabled={(hasError || sending || refreshing)}
onClick={onAddExternal}
>
<PlaylistAddOutlined
style={
theme.palette.mode === 'dark'
? (hasError || sending || refreshing)
? { color: "lightgrey", opacity: 0.25 }
: { color: "forestgreen", opacity: 1 }
: (hasError || sending || refreshing)
? { color: "black", opacity: 0.25 }
: { color: "limegreen", opacity: 1 }
}
/>
{
Object.keys(selectionModel).length !== 0 ?
<PlaylistAddCheckOutlined
style={
theme.palette.mode === 'dark'
? (hasError || sending || refreshing)
? { color: "lightgrey", opacity: 0.25 }
: { color: "forestgreen", opacity: 1 }
: (hasError || sending || refreshing)
? { color: "black", opacity: 0.25 }
: { color: "limegreen", opacity: 1 }
}
/> :
<PlaylistAddOutlined
style={
theme.palette.mode === 'dark'
? (hasError || sending || refreshing)
? { color: "lightgrey", opacity: 0.25 }
: { color: "forestgreen", opacity: 1 }
: (hasError || sending || refreshing)
? { color: "black", opacity: 0.25 }
: { color: "limegreen", opacity: 1 }
}
/>
}
</IconButton>
</span>
</Tooltip>

Просмотреть файл

@ -149,6 +149,12 @@ export function createBlockExternal(space, block, body) {
return api.post(url, body);
}
export function updateBlockExternal(space, block, external, body) {
const url = new URL(`${ENGINE_URL}/api/spaces/${space}/blocks/${block}/externals/${external}`);
return api.patch(url, body);
}
export function deleteBlockExternal(space, block, external, force) {
const url = new URL(`${ENGINE_URL}/api/spaces/${space}/blocks/${block}/externals/${external}`);
var urlParams = url.searchParams;
@ -164,6 +170,12 @@ export function createBlockExtSubnet(space, block, external, body) {
return api.post(url, body);
}
export function updateBlockExtSubnet(space, block, external, subnet, body) {
const url = new URL(`${ENGINE_URL}/api/spaces/${space}/blocks/${block}/externals/${external}/subnets/${subnet}`);
return api.patch(url, body);
}
export function deleteBlockExtSubnet(space, block, external, subnet, force) {
const url = new URL(`${ENGINE_URL}/api/spaces/${space}/blocks/${block}/externals/${external}/subnets/${subnet}`);
var urlParams = url.searchParams;

Просмотреть файл

@ -13,8 +13,10 @@ import {
updateBlock,
deleteBlock,
createBlockExternal,
updateBlockExternal,
deleteBlockExternal,
createBlockExtSubnet,
updateBlockExtSubnet,
deleteBlockExtSubnet,
replaceBlockExtSubnetEndpoints,
createBlockResv,
@ -178,6 +180,19 @@ export const createBlockExternalAsync = createAsyncThunk(
}
);
export const updateBlockExternalAsync = createAsyncThunk(
'ipam/updateBlockExternal',
async (args, { rejectWithValue }) => {
try {
const response = await updateBlockExternal(args.space, args.block, args.external, args.body);
return response;
} catch (err) {
return rejectWithValue(err);
}
}
);
export const deleteBlockExternalAsync = createAsyncThunk(
'ipam/deleteBlockExternal',
async (args, { rejectWithValue }) => {
@ -204,6 +219,19 @@ export const createBlockExtSubnetAsync = createAsyncThunk(
}
);
export const updateBlockExtSubnetAsync = createAsyncThunk(
'ipam/updateBlockExtSubnet',
async (args, { rejectWithValue }) => {
try {
const response = await updateBlockExtSubnet(args.space, args.block, args.external, args.subnet, args.body);
return response;
} catch (err) {
return rejectWithValue(err);
}
}
);
export const deleteBlockExtSubnetAsync = createAsyncThunk(
'ipam/deleteBlockExtSubnet',
async (args, { rejectWithValue }) => {
@ -565,6 +593,31 @@ export const ipamSlice = createSlice({
// SnackbarUtils.error(`Error fetching user settings (${action.error.message})`);
throw action.payload;
})
.addCase(updateBlockExternalAsync.fulfilled, (state, action) => {
const spaceName = action.meta.arg.space;
const blockName = action.meta.arg.block;
const externalName = action.meta.arg.external;
const updatedExternal = action.payload;
const spaceIndex = state.spaces.findIndex((x) => x.name === spaceName);
if (spaceIndex > -1) {
const blockIndex = state.spaces[spaceIndex].blocks.findIndex((x) => x.name === blockName);
if(blockIndex > -1) {
const externalIndex = state.spaces[spaceIndex].blocks[blockIndex].externals.findIndex((x) => x.name === externalName);
if(externalIndex > -1) {
state.spaces[spaceIndex].blocks[blockIndex].externals[externalIndex] = merge(state.spaces[spaceIndex].blocks[blockIndex].externals[externalIndex], updatedExternal);
}
}
}
})
.addCase(updateBlockExternalAsync.rejected, (state, action) => {
console.log("updateBlockExternalAsync Rejected");
console.log(action);
// SnackbarUtils.error(`Error fetching user settings (${action.error.message})`);
throw action.payload;
})
.addCase(deleteBlockExternalAsync.fulfilled, (state, action) => {
const spaceName = action.meta.arg.space;
const blockName = action.meta.arg.block;
@ -604,6 +657,36 @@ export const ipamSlice = createSlice({
// SnackbarUtils.error(`Error fetching user settings (${action.error.message})`);
throw action.payload;
})
.addCase(updateBlockExtSubnetAsync.fulfilled, (state, action) => {
const spaceName = action.meta.arg.space;
const blockName = action.meta.arg.block;
const externalName = action.meta.arg.external;
const subnetName = action.meta.arg.subnet;
const updatedSubnet = action.payload;
const spaceIndex = state.spaces.findIndex((x) => x.name === spaceName);
if (spaceIndex > -1) {
const blockIndex = state.spaces[spaceIndex].blocks.findIndex((x) => x.name === blockName);
if(blockIndex > -1) {
const externalIndex = state.spaces[spaceIndex].blocks[blockIndex].externals.findIndex((x) => x.name === externalName);
if(externalIndex > -1) {
const subnetIndex = state.spaces[spaceIndex].blocks[blockIndex].externals[externalIndex].subnets.findIndex((x) => x.name === subnetName);
if(subnetIndex > -1) {
state.spaces[spaceIndex].blocks[blockIndex].externals[externalIndex].subnets[subnetIndex] = merge(state.spaces[spaceIndex].blocks[blockIndex].externals[externalIndex].subnets[subnetIndex], updatedSubnet);
}
}
}
}
})
.addCase(updateBlockExtSubnetAsync.rejected, (state, action) => {
console.log("updateBlockExtSubnetAsync Rejected");
console.log(action);
// SnackbarUtils.error(`Error fetching user settings (${action.error.message})`);
throw action.payload;
})
.addCase(deleteBlockExtSubnetAsync.fulfilled, (state, action) => {
const spaceName = action.meta.arg.space;
const blockName = action.meta.arg.block;

Просмотреть файл

@ -23,6 +23,13 @@ function getIpRangeForSubnet(cidr) {
return results;
}
export function getSubnetSize(cidr) {
var mask = parseInt(cidr.split('/')[1], 10);
var size = Math.pow(2, (32 - mask));
return size;
}
export function isSubnetOverlap(subnetCIDR, existingSubnetCIDR) {
var ipRangeforCurrent = getIpRangeForSubnet(subnetCIDR);