Files
freeipa/ipaserver/install/ipa_idrange_fix.py
T
2025-12-01 11:19:26 +01:00

1089 lines
35 KiB
Python

"""Tool to analyze and fix IPA ID ranges"""
#
# Copyright (C) 2024 FreeIPA Contributors see COPYING for license
#
import logging
import ldap
from ipalib import api, errors
from ipapython.admintool import AdminTool
from ipapython.dn import DN
from ipapython import ipautil
from ipaserver.install.installutils import check_server_configuration
from typing import List, Tuple
logger = logging.getLogger(__name__)
class IDRange:
"""Class for ID range entity"""
def __init__(self):
self.last_id: int = None
self.last_base_rid: int = None
self.last_secondary_rid: int = None
self.name: str = None
self.size: int = None
self.first_id: int = None
self.base_rid: int = None
self.secondary_base_rid: int = None
self.type: str = None
self.suffix: str = None
self.dn: str = None
def _count(self) -> None:
"""Function to calculate last IDs for the range"""
self.last_id = self.first_id + self.size - 1
if self.type == "ipa-local":
self.last_base_rid = (
self.base_rid + self.size
if self.base_rid is not None
else None
)
self.last_secondary_rid = (
self.secondary_base_rid + self.size
if self.secondary_base_rid is not None
else None
)
def __repr__(self):
return (
f"IDRange(name='{self.name}', "
f"type={self.type}, "
f"size={self.size}, "
f"first_id={self.first_id}, "
f"base_rid={self.base_rid}, "
f"secondary_base_rid={self.secondary_base_rid})"
)
def __eq__(self, other):
return self.first_id == other.first_id
class IDentity:
"""A generic class for ID entity - users or groups"""
def __init__(self, **kwargs):
self.dn: str = kwargs.get('dn')
self.name: str = kwargs.get('name')
self.user: str = kwargs.get('user')
self.number: int = kwargs.get('number')
def __str__(self):
if self.user:
return (f"user '{self.name}', uid={self.number}")
return (f"group '{self.name}', gid={self.number}")
def debug(self):
if self.user:
return (
f"user(username='{self.name}', "
f"uid={self.number}, "
f"{self.dn})"
)
return (
f"group(groupname='{self.name}', "
f"gid={self.number}, "
f"{self.dn})"
)
def __eq__(self, other):
return self.number == other.number and self.user == other.user
class IPAIDRangeFix(AdminTool):
"""Tool to analyze and fix IPA ID ranges"""
command_name = "ipa-idrange-fix"
log_file_name = "/var/log/ipa-idrange-fix.log"
usage = "%prog"
description = "Analyze and fix IPA ID ranges"
@classmethod
def add_options(cls, parser, debug_option=False):
super(IPAIDRangeFix, cls).add_options(parser)
parser.add_option(
"--ridoffset",
dest="ridoffset",
type=int,
default=100000,
metavar=100000,
help="Offset for a next base RID from previous RID range. \
Needed for future range size expansions. Has to be > 0",
)
parser.add_option(
"--rangegap",
dest="rangegap",
type=int,
default=200000,
metavar=200000,
help="Threshold for a gap between out-of-range IDs to be \
considered a different range. Has to be > 0",
)
parser.add_option(
"--minrange",
dest="minrange",
type=int,
default=10,
metavar=10,
help="Minimal considered range size for out-of-range IDs.\
All ranges with amount of IDs lower than this number will be discarded and \
IDs will be listed to be moved manually. Has to be > 1",
)
parser.add_option(
"--allowunder1000",
dest="allowunder1000",
action="store_true",
default=False,
help="Allow idranges to start below 1000. Be careful to not \
overlap IPA users/groups with existing system-local ones!",
)
parser.add_option(
"--norounding",
dest="norounding",
action="store_true",
default=False,
help="Disable IDrange rounding attempt in order to get ranges \
exactly covering just IDs provided",
)
parser.add_option(
"--unattended",
dest="unattended",
action="store_true",
default=False,
help="Automatically fix all range issues found without asking \
for confirmation",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.realm: str = None
self.suffix: DN = None
self.proposals_rid: List[IDRange] = []
self.proposals_new: List[IDRange] = []
self.outliers: List[IDentity] = []
self.under1000: List[IDentity] = []
self.id_ranges: List[IDRange] = []
def validate_options(self, needs_root=True):
super().validate_options(needs_root)
def run(self):
check_server_configuration()
api.bootstrap(in_server=True)
api.finalize()
self.realm = api.env.realm
self.suffix = ipautil.realm_to_suffix(self.realm)
try:
api.Backend.ldap2.connect()
# Reading range data
self.id_ranges = read_ranges(self.suffix)
# Evaluating existing ranges, if something is off, exit
if self.evaluate_ranges() != 0:
return 1
# reading out of range IDs
ids_out_of_range = read_outofrange_identities(
self.suffix, self.id_ranges
)
# Evaluating out of range IDs
self.evaluate_identities(ids_out_of_range)
# Print the proposals
self.print_intentions()
# If there are no proposals, we have nothing to do, exiting
if (len(self.proposals_rid) == 0
and len(self.proposals_new) == 0):
logger.info("\nNo changes proposed, nothing to do.")
return 0
logger.info("\nID ranges table after proposed changes:")
draw_ascii_table(self.id_ranges)
if self.options.unattended:
logger.info(
"Unattended mode, proceeding with applying changes!"
)
else:
response = ipautil.user_input('Enter "yes" to proceed')
if response.lower() != "yes":
logger.info("Not proceeding.")
return 0
logger.info("Proceeding.")
# Applying changes
for id_range in self.proposals_rid:
apply_ridbases(id_range)
for id_range in self.proposals_new:
create_range(id_range)
logger.info("All changes applied successfully!")
finally:
if api.Backend.ldap2.isconnected():
api.Backend.ldap2.disconnect()
return 0
def evaluate_ranges(self) -> int:
"""Function to evaluate existing ID ranges"""
if len(self.id_ranges) == 0:
logger.error("No ID ranges found!")
return 1
draw_ascii_table(self.id_ranges)
if not ranges_overlap_check(self.id_ranges):
logger.error(
"Ranges overlap detected, cannot proceed! Please adjust \
existing ranges manually."
)
return 1
# Checking RID bases for existing ranges
id_ranges_nobase = get_ranges_no_base(self.id_ranges)
if len(id_ranges_nobase) > 0:
logger.info(
"Found %s ranges without base RIDs", len(id_ranges_nobase)
)
for id_range in id_ranges_nobase:
logger.debug(
"Range '%s' has RID base %s and secondary RID base %s",
id_range.name,
id_range.base_rid,
id_range.secondary_base_rid,
)
propose_rid_ranges(
self.id_ranges,
self.options.ridoffset,
self.proposals_rid
)
else:
logger.info(
"All ID ranges have base RIDs set, RID adjustments are \
not needed."
)
return 0
def evaluate_identities(self, ids_out_of_range: List[IDentity]) -> None:
"""Function to evaluate out of range IDs"""
if len(ids_out_of_range) == 0:
logger.info("No out of range IDs found!")
else:
logger.info(
"Found overall %s IDs out of existing ID ranges.\n",
len(ids_out_of_range),
)
# ruling out IDs under 1000 if flag is not set
if not self.options.allowunder1000:
self.under1000, ids_out_of_range = separate_under1000(
ids_out_of_range
)
if len(self.under1000) > 0:
logger.info(
"Found IDs under 1000, which is not recommended \
(if you definitely need ranges proposed for those, use --allowunder1000):"
)
for identity in self.under1000:
logger.info("%s", identity)
# Get initial divide of IDs into groups
groups = group_identities_by_threshold(
ids_out_of_range, self.options.rangegap
)
# Get outliers from too small groups and clean groups for
# further processing
self.outliers, cleangroups = separate_ranges_and_outliers(
groups, self.options.minrange
)
# Print the outliers, they have to be moved manually
if len(self.outliers) > 0:
logger.info(
"\nIdentities that don't fit the criteria to get a new "
"range found! Current attributes:\n"
"Minimal range size: %s\n"
"Maximum gap between IDs: %s\n"
"Try adjusting --minrange, --rangegap or move the "
"following identities into already existing ranges:",
self.options.minrange,
self.options.rangegap
)
for identity in self.outliers:
logger.info("%s", identity)
if len(cleangroups) > 0:
# Get IDrange name base
basename = get_rangename_base(self.id_ranges)
# Create proposals for new ranges from groups
for group in cleangroups:
newrange = propose_range(
group,
self.id_ranges,
self.options.ridoffset,
basename,
self.options.norounding,
self.options.allowunder1000
)
if newrange is not None:
self.proposals_new.append(newrange)
self.id_ranges.append(newrange)
self.id_ranges.sort(key=lambda x: x.first_id)
else:
logger.info(
"\nNo IDs fit the criteria for a new ID range to propose!"
)
def print_intentions(self) -> None:
"""Function to print out the summary of the proposed changes"""
logger.info("\nSummary:")
if len(self.outliers) > 0:
logger.info("Outlier IDs that are too far away to get a range:")
for identity in self.outliers:
logger.info("%s", identity)
if len(self.under1000) > 0:
if self.options.allowunder1000:
logger.info("IDs under 1000 were treated like normal IDs.")
else:
logger.info("IDs under 1000:")
for identity in self.under1000:
logger.info("%s", identity)
else:
logger.info("No IDs under 1000 found.")
if len(self.proposals_rid) > 0:
logger.info("Proposed changes to existing ranges:")
for id_range in self.proposals_rid:
logger.info(
"Range '%s' - base RID: %s, secondary base RID: %s",
id_range.name,
id_range.base_rid,
id_range.secondary_base_rid,
)
else:
logger.info("No changes proposed for existing ranges.")
if len(self.proposals_new) > 0:
logger.info("Proposed new ranges:")
for id_range in self.proposals_new:
logger.info("%s", id_range)
else:
logger.info("No new ranges proposed.")
# Working with output
# region
def draw_ascii_table(id_ranges: List[IDRange], stdout: bool = False) -> None:
"""Function to draw a table with ID ranges in ASCII"""
table: str = "\n"
# Calculate the maximum width required for each column using column names
max_widths = {
column: max(
len(str(column)),
max(
(
len(str(getattr(id_range, column)))
if getattr(id_range, column) is not None
else 0
)
for id_range in id_ranges
),
)
for column in [
"name",
"type",
"size",
"first_id",
"last_id",
"base_rid",
"last_base_rid",
"secondary_base_rid",
"last_secondary_rid",
]
}
# Draw the table header
header = "| "
for column, width in max_widths.items():
header += f"{column.ljust(width)} | "
horizontal_line = "-" * (len(header) - 1)
table += horizontal_line + "\n"
table += header + "\n"
table += horizontal_line + "\n"
# Draw the table rows
for id_range in id_ranges:
row = "| "
for column, width in max_widths.items():
value = getattr(id_range, column)
if value is not None:
row += f"{str(value).rjust(width)} | "
else:
# Adding the separator
row += " " * (width + 1) + "| "
table += row + "\n"
table += horizontal_line + "\n"
if stdout:
print(table)
else:
logger.info(table)
# endregion
# Reading from LDAP
# region
def read_ranges(suffix) -> List[IDRange]:
"""Function to read ID ranges from LDAP"""
id_ranges: IDRange = []
try:
ranges = api.Backend.ldap2.get_entries(
DN(api.env.container_ranges, suffix),
ldap.SCOPE_ONELEVEL,
"(objectclass=ipaIDRange)",
)
except errors.NotFound:
logger.error("LDAPError: No ranges found!")
except errors.ExecutionError as e:
logger.error("Exception while reading users: %s", e)
else:
for entry in ranges:
sv = entry.single_value
id_range = IDRange()
id_range.name = sv.get("cn")
id_range.size = int(sv.get("ipaidrangesize"))
id_range.first_id = int(sv.get("ipabaseid"))
id_range.base_rid = (
int(sv.get("ipabaserid")) if sv.get("ipabaserid") else None
)
id_range.secondary_base_rid = (
int(sv.get("ipasecondarybaserid"))
if sv.get("ipasecondarybaserid")
else None
)
id_range.suffix = suffix
id_range.type = sv.get("iparangetype")
id_range.dn = entry.dn
id_range._count()
logger.debug("ID range found: %s", id_range)
id_ranges.append(id_range)
id_ranges.sort(key=lambda x: x.first_id)
return id_ranges
def read_outofrange_identities(suffix, id_ranges) -> List[IDentity]:
"""Function to read out of range users and groups from LDAP"""
users_outofrange = read_ldap_ids(
DN(api.env.container_user, suffix),
True,
id_ranges
)
logger.info("Users out of range found: %s", len(users_outofrange))
del_outofrange = read_ldap_ids(
DN(api.env.container_deleteuser, suffix),
True,
id_ranges
)
logger.info("Preserved users out of range found: %s", len(del_outofrange))
groups_outofrange = read_ldap_ids(
DN(api.env.container_group, suffix),
False,
id_ranges
)
logger.info("Groups out of range found: %s", len(groups_outofrange))
outofrange = users_outofrange + del_outofrange + groups_outofrange
outofrange.sort(key=lambda x: x.number)
return outofrange
def read_ldap_ids(container_dn, user: bool, id_ranges) -> List[IDentity]:
"""Function to read IDs from containter in LDAP"""
id_entities = []
if user:
id_name = "user"
ldap_filter = get_outofrange_filter(
id_ranges,
"posixaccount",
"uidNumber"
)
else:
id_name = "group"
ldap_filter = get_outofrange_filter(
id_ranges,
"posixgroup",
"gidNumber"
)
logger.debug("Searching %ss in %s with filter: %s", id_name, container_dn,
ldap_filter)
try:
identities = api.Backend.ldap2.get_entries(
container_dn,
ldap.SCOPE_ONELEVEL,
ldap_filter,
)
for entry in identities:
id_entities.append(read_identity(entry, user))
except errors.NotFound:
logger.debug("No out of range %ss found in %s!", id_name, container_dn)
except errors.ExecutionError as e:
logger.error("Exception while reading %s: %s", container_dn, e)
return id_entities
def read_identity(ldapentry, user: bool = True) -> IDentity:
"""Function to convert LDAP entry to IDentity object"""
sv = ldapentry.single_value
id_entity = IDentity()
id_entity.dn = ldapentry.dn
id_entity.name = sv.get("cn")
id_entity.number = (
int(sv.get("uidNumber")) if user else int(sv.get("gidNumber"))
)
id_entity.user = user
logger.debug("Out of range found: %s", id_entity.debug())
return id_entity
def get_outofrange_filter(
id_ranges_all: List[IDRange], object_class: str, posix_id: str
) -> str:
"""Function to create LDAP filter for out of range users and groups"""
# we need to look only for ipa-local ranges
id_ranges = get_ipa_local_ranges(id_ranges_all)
ldap_filter = f"(&(objectClass={object_class})(|"
# adding gaps in ranges to the filter
for i in range(len(id_ranges) + 1):
if i == 0:
start_condition = f"({posix_id}>=1)"
else:
start_condition = f"({posix_id}>={id_ranges[i - 1].last_id + 1})"
if i < len(id_ranges):
end_condition = f"({posix_id}<={id_ranges[i].first_id - 1})"
else:
end_condition = f"({posix_id}<=2147483647)"
ldap_filter += f"(&{start_condition}{end_condition})"
ldap_filter += "))"
return ldap_filter
# endregion
# Writing to LDAP
# region
def apply_ridbases(id_range: IDRange) -> None:
"""Funtion to apply RID bases to the range in LDAP"""
try:
api.Backend.ldap2.modify_s(
id_range.dn,
[
(ldap.MOD_ADD, "ipaBaseRID", str(id_range.base_rid)),
(
ldap.MOD_ADD,
"ipaSecondaryBaseRID",
str(id_range.secondary_base_rid),
),
],
)
logger.info("RID bases updated for range '%s'", id_range.name)
except ldap.CONSTRAINT_VIOLATION as e:
logger.error(
"Failed to add RID bases to the range '%s': %s",
id_range.name,
e
)
raise RuntimeError("Constraint violation.\n") from e
except Exception as e:
logger.error(
"Exception while updating RID bases for range '%s': %s",
id_range.name,
e,
)
raise RuntimeError("Failed to update RID bases.\n") from e
def create_range(id_range: IDRange) -> None:
"""Function to create a new range in LDAP"""
try:
logger.info("Creating range '%s'...", id_range.name)
entry = api.Backend.ldap2.make_entry(
DN(id_range.dn),
objectclass=["ipaIDRange", "ipaDomainIDRange"],
ipaidrangesize=[str(id_range.size)],
ipabaseid=[str(id_range.first_id)],
ipabaserid=[str(id_range.base_rid)],
ipasecondarybaserid=[str(id_range.secondary_base_rid)],
iparangetype=[id_range.type],
)
api.Backend.ldap2.add_entry(entry)
logger.info("Range '%s' created successfully", id_range.name)
except Exception as e:
logger.error(
"Exception while creating range '%s': %s",
id_range.name,
e
)
raise RuntimeError("Failed to create range.\n") from e
# endregion
# Working with ranges
# region
def get_ipa_local_ranges(id_ranges: List[IDRange]) -> List[IDRange]:
"""Function to get only ipa-local ranges from the list of ranges"""
ipa_local_ranges = []
for id_range in id_ranges:
if id_range.type == "ipa-local":
ipa_local_ranges.append(id_range)
return ipa_local_ranges
def range_overlap_check(
range1_start: int, range1_end: int, range2_start: int, range2_end: int
) -> bool:
"""Function to check if two ranges overlap"""
# False when overlapping
return not (range1_start <= range2_end and range2_start <= range1_end)
def range_overlap_check_idrange(range1: IDRange, range2: IDRange) -> bool:
"""Function to check if two ranges overlap"""
# False when overlapping
return range_overlap_check(
range1.first_id, range1.last_id, range2.first_id, range2.last_id)
def newrange_overlap_check(
id_ranges: List[IDRange], newrange: IDRange
) -> bool:
"""Function to check if proposed range overlaps with existing ones"""
for id_range in id_ranges:
if not range_overlap_check_idrange(id_range, newrange):
return False
return True
def ranges_overlap_check(id_ranges: List[IDRange]) -> bool:
"""Function to check if any of the existing ranges overlap"""
if len(id_ranges) < 2:
return True
for i in range(len(id_ranges) - 1):
for j in range(i + 1, len(id_ranges)):
if not range_overlap_check_idrange(id_ranges[i], id_ranges[j]):
logger.error(
"Ranges '%s' and '%s' overlap!",
id_ranges[i].name,
id_ranges[j].name,
)
return False
return True
# endregion
# Working with RID bases
# region
def propose_rid_ranges(
id_ranges: List[IDRange], delta: int, proposals: List[IDRange]
) -> None:
"""
Function to propose RID bases for ranges that don't have them set.
- delta represents how far we start new base off existing range,
used in order to allow for future expansion of existing ranges up
to [delta] IDs.
"""
ipa_local_ranges = get_ipa_local_ranges(id_ranges)
for id_range in ipa_local_ranges:
proposed_base_rid = 0
proposed_secondary_base_rid = 0
# Calculate proposed base RID and secondary base RID
if id_range.base_rid is None:
result, proposed_base_rid = propose_rid_base(
id_range, ipa_local_ranges, delta, True
)
if result:
id_range.base_rid = proposed_base_rid
id_range.last_base_rid = proposed_base_rid + id_range.size
else:
# if this fails too, we print the warning and abandon the idea
logger.warning(
"Warning: Proposed base RIDs %s for '%s' both failed, \
please adjust manually",
proposed_base_rid,
id_range.name,
)
continue
if id_range.secondary_base_rid is None:
result, proposed_secondary_base_rid = propose_rid_base(
id_range, ipa_local_ranges, delta, False, proposed_base_rid
)
if result:
id_range.secondary_base_rid = proposed_secondary_base_rid
id_range.last_secondary_rid = (
proposed_secondary_base_rid + id_range.size
)
else:
# if this fails too, we print the warning and abandon the idea
logger.warning(
"Warning: Proposed secondary base RIDs %s for '%s' \
both failed, please adjust manually",
proposed_secondary_base_rid,
id_range.name,
)
continue
# Add range to the proposals if we changed something successfully
if proposed_base_rid > 0 or proposed_secondary_base_rid > 0:
logger.debug(
"Proposed RIDs for range '%s': pri %s, sec %s",
id_range.name,
proposed_base_rid,
proposed_secondary_base_rid,
)
proposals.append(id_range)
def propose_rid_base(
idrange: IDRange,
ipa_local_ranges: List[IDRange],
delta: int,
primary: bool = True,
previous_base_rid: int = -1
) -> Tuple[bool, str]:
"""
Function to propose a base RID for a range, primary or secondary.
We are getting the biggest base RID + size + delta and try
if it's a viable option, check same kind first, then the other.
"""
proposed_base_rid = max_rid(ipa_local_ranges, primary) + delta
if proposed_base_rid == previous_base_rid:
proposed_base_rid += idrange.size + delta
if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size):
return True, proposed_base_rid
# if we fail, we try the same with biggest of a different kind
proposed_base_rid_orig = proposed_base_rid
proposed_base_rid = max_rid(ipa_local_ranges, not primary) + delta
if proposed_base_rid == previous_base_rid:
proposed_base_rid += idrange.size + delta
if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size):
return True, proposed_base_rid
# if it fails, we return both RID proposals for the range
return False, f"{proposed_base_rid_orig} and {proposed_base_rid}"
def max_rid(id_ranges: List[IDRange], primary: bool = True) -> int:
"""Function to get maximum RID of primary or secondary RIDs"""
maximum_rid = 0
for id_range in id_ranges:
# looking only for primary RIDs
if primary:
if id_range.last_base_rid is not None:
maximum_rid = max(maximum_rid, id_range.last_base_rid)
# looking only for secondary RIDs
else:
if id_range.last_secondary_rid is not None:
maximum_rid = max(maximum_rid, id_range.last_secondary_rid)
return maximum_rid
def check_rid_base(id_ranges: List[IDRange], base: int, size: int) -> bool:
"""Function to check if proposed RID base is viable"""
end = base + size + 1
# Checking sanity of RID range
if base + size > 2147483647:
return False
if base < 1000:
return False
# Checking RID range overlaps
for id_range in id_ranges:
# we are interested only in ipa-local ranges
if id_range.type != "ipa-local":
continue
# if there is no base rid set, there is no secondary base rid set,
# so nothing to overlap with
if id_range.base_rid is None:
continue
# checking for an overlap
if not range_overlap_check(
base, end, id_range.base_rid, id_range.last_base_rid
):
logger.debug(
"RID check failure: proposed Primary %s + %s, \
intersects with %s-%s from range '%s'",
base,
size,
id_range.base_rid,
id_range.last_base_rid,
id_range.name,
)
return False
# if there is no secondary base rid set, nothing to overlap with
if id_range.secondary_base_rid is None:
continue
# if either start of end of the range fails inside existing range,
# or existing range is inside proposed one, we have an overlap
if not range_overlap_check(
base, end, id_range.secondary_base_rid, id_range.last_secondary_rid
):
logger.debug(
"RID check failure: proposed Secondary %s + %s, \
intersects with %s-%s from range '%s'",
base,
size,
id_range.secondary_base_rid,
id_range.last_secondary_rid,
id_range.name,
)
return False
return True
def get_ranges_no_base(id_ranges: List[IDRange]) -> List[IDRange]:
"""Function to get ranges without either of base RIDs set"""
ipa_local_ranges = get_ipa_local_ranges(id_ranges)
ranges_no_base = []
for id_range in ipa_local_ranges:
if id_range.base_rid is None or id_range.secondary_base_rid is None:
ranges_no_base.append(id_range)
return ranges_no_base
# endregion
# Working with IDentities out of range
# region
def group_identities_by_threshold(
identities: List[IDentity], threshold: int
) -> List[List[IDentity]]:
"""Function to group out of range IDs by threshold"""
groups: List[List[IDentity]] = []
currentgroup: List[IDentity] = []
if len(identities) == 0:
return groups
for i in range(len(identities) - 1):
# add id to current group
currentgroup.append(identities[i])
# If the difference with the next one is greater than the threshold,
# start a new group
if identities[i + 1].number - identities[i].number > threshold:
groups.append(currentgroup)
currentgroup = []
# Add the last ID number to the last group
currentgroup.append(identities[-1])
groups.append(currentgroup)
return groups
def separate_under1000(
identities: List[IDentity],
) -> Tuple[List[IDentity], List[IDentity]]:
"""Function to separate IDs under 1000, expects sorted list"""
for i, identity in enumerate(identities):
if identity.number >= 1000:
return identities[:i], identities[i:]
return identities, []
def separate_ranges_and_outliers(
groups: List[List[IDentity]], minrangesize=int
) -> Tuple[List[List[IDentity]], List[List[IDentity]]]:
"""Function to separate IDs into outliers and IDs that can get ranges"""
outliers = []
cleangroups = []
for group in groups:
# if group is smaller than minrangesize, add it's memebers to ourliers
if group[-1].number - group[0].number + 1 < minrangesize:
for identity in group:
outliers.append(identity)
# if the group is OK, add it to cleaned groups
else:
cleangroups.append(group)
return outliers, cleangroups
def round_idrange(start: int, end: int, under1000: bool) -> Tuple[int, int]:
"""Function to round up range margins to look pretty"""
# calculating power of the size
sizepower = len(str(end - start + 1))
# multiplier for the nearest rounded number
multiplier = 10 ** (sizepower - 1)
# getting rounded range margins
rounded_start = (start // multiplier) * multiplier
if not under1000:
rounded_start = max(rounded_start, 1000)
else:
rounded_start = max(rounded_start, 1)
rounded_end = ((end + multiplier) // multiplier) * multiplier - 1
return rounded_start, rounded_end
def get_rangename_base(id_ranges: List[IDRange]) -> str:
"""Function to get a base name for new range proposals"""
base_name = ""
# we want to use default range name as a base for new ranges
for id_range in id_ranges:
if id_range.base_rid == 1000:
base_name = id_range.name
# if we didn't find it, propose generic name
if base_name == "":
base_name = "Auto_added_range"
return base_name
def get_rangename(id_ranges: List[IDRange], basename: str) -> str:
"""
Function to get a new range name, we add the counter as 3-digit number
extension and make sure it's unique
"""
counter = 1
full_name = f"{basename}_{counter:03}"
while any(id_range.name == full_name for id_range in id_ranges):
counter += 1
full_name = f"{basename}_{counter:03}"
return full_name
def propose_range(
group: List[IDentity],
id_ranges: List[IDRange],
delta: int,
basename: str,
norounding: bool,
allowunder1000: bool
) -> IDRange:
"""Function to propose a new range for group of IDs out of ranges"""
startid = group[0].number
endid = group[-1].number
logger.debug(
"Proposing a range for existing IDs out of ranges with start id %s \
and end id %s...",
startid,
endid,
)
# creating new range
newrange = IDRange()
newrange.type = "ipa-local"
newrange.name = get_rangename(id_ranges, basename)
newrange.suffix = id_ranges[0].suffix
newrange.dn = f"cn={newrange.name},cn=ranges,cn=etc,{newrange.suffix}"
if norounding:
newrange.first_id = startid
newrange.last_id = endid
newrange.size = newrange.last_id - newrange.first_id + 1
else:
# first trying to round up ranges to look pretty
newrange.first_id, newrange.last_id = round_idrange(
startid,
endid,
allowunder1000
)
newrange.size = newrange.last_id - newrange.first_id + 1
# if this creates an overlap, try without rounding
if not newrange_overlap_check(id_ranges, newrange):
newrange.first_id = startid
newrange.last_id = endid
newrange.size = newrange.last_id - newrange.first_id + 1
# if we still failed, abandon idea
if not newrange_overlap_check(id_ranges, newrange):
logger.error(
"ERROR! Failed to create idrange for existing IDs out of \
ranges with start id %s and end id %s, it overlaps with existing range!",
startid,
endid,
)
return None
# creating RID bases
ipa_local_ranges = get_ipa_local_ranges(id_ranges)
result, proposed_base_rid = propose_rid_base(
newrange, ipa_local_ranges, delta, True
)
if result:
newrange.base_rid = proposed_base_rid
newrange.last_base_rid = proposed_base_rid + newrange.size
else:
# if this fails we print the warning
logger.warning(
"Warning! Proposed base RIDs %s for new range start id %s and \
end id %s both failed, please adjust manually",
proposed_base_rid,
newrange.first_id,
newrange.last_id,
)
result, proposed_secondary_base_rid = propose_rid_base(
newrange, ipa_local_ranges, delta, False, proposed_base_rid
)
if result:
newrange.secondary_base_rid = proposed_secondary_base_rid
newrange.last_secondary_rid = (
proposed_secondary_base_rid + newrange.size
)
else:
# if this fails we print the warning
logger.warning(
"Warning! Proposed secondary base RIDs %s for new range start id \
%s and end id %s both failed, please adjust manually",
proposed_secondary_base_rid,
newrange.first_id,
newrange.last_id,
)
logger.debug("Proposed range: %s", newrange)
return newrange
# endregion