From 689ef5c18d7557ce6bf3b04de5a65e7d7f323839 Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Tue, 14 Jul 2026 12:35:26 -0700 Subject: [PATCH 1/9] Add sync_isilon_allocations management command - New coldfront/plugins/isilon/management/commands/sync_isilon_allocations.py: reconciles Isilon/PowerScale Directory SmartQuotas (hard-limited) against ColdFront Project/Allocation state, per-resource or via --resource flag - Add utils.py helpers: IsilonDirectoryQuota wraps the raw SDK quota object (path/cf_path/hard_limit_bytes/usage_bytes/has_hard_limit), plus is_isilon_path_ignored, get_directory_group, find_matching_pending_allocation, update_allocation_quota_and_usage, sync_allocation_for_quota, and sync_isilon_resource_allocations for the create/activate/update reconciliation - Add ISILON_PATH_IGNORE setting so specific unlimited-quota paths can be excluded from the "no hard limit" warning - Add 12 TestCase-based unit tests mocking IsilonConnection, covering the quota-with-no-limit, missing-project, existing-allocation-update, pending-request-activation, new-allocation-creation, and quota-unchanged/usage-refresh paths Co-Authored-By: Claude Sonnet 5 --- coldfront/config/plugins/isilon.py | 1 + .../commands/sync_isilon_allocations.py | 47 +++++ coldfront/plugins/isilon/tests.py | 184 ++++++++++++++++++ coldfront/plugins/isilon/utils.py | 182 ++++++++++++++++- 4 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 coldfront/plugins/isilon/management/commands/sync_isilon_allocations.py diff --git a/coldfront/config/plugins/isilon.py b/coldfront/config/plugins/isilon.py index 8333804703..181203cb19 100644 --- a/coldfront/config/plugins/isilon.py +++ b/coldfront/config/plugins/isilon.py @@ -9,6 +9,7 @@ ISILON_NFS_ROOT_CLIENTS = ENV.str('ISILON_NFS_ROOT_CLIENTS', '') ISILON_NFS_FASSE_CLIENTS = ENV.str('ISILON_NFS_FASSE_CLIENTS', '') ISILON_NFS_CANNON_CLIENTS = ENV.str('ISILON_NFS_CANNON_CLIENTS', '') +ISILON_PATH_IGNORE = ENV.list('ISILON_PATH_IGNORE', default=[]) LOGGING['handlers']['isilon'] = { 'class': 'logging.handlers.TimedRotatingFileHandler', 'filename': 'logs/isilon.log', diff --git a/coldfront/plugins/isilon/management/commands/sync_isilon_allocations.py b/coldfront/plugins/isilon/management/commands/sync_isilon_allocations.py new file mode 100644 index 0000000000..1941064408 --- /dev/null +++ b/coldfront/plugins/isilon/management/commands/sync_isilon_allocations.py @@ -0,0 +1,47 @@ +import logging + +from django.core.management.base import BaseCommand + +from coldfront.core.resource.models import Resource +from coldfront.plugins.isilon.utils import print_log_error, sync_isilon_resource_allocations + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Sync Isilon/PowerScale directory smartquotas into ColdFront allocations + """ + help = 'Sync Isilon/PowerScale directory smartquotas into ColdFront allocations' + + def add_arguments(self, parser): + parser.add_argument( + '-r', '--resource', help='Sync only the named isilon/powerscale Storage resource' + ) + + def handle(self, *args, **options): + isilon_resources = Resource.objects.filter( + resourceattribute__value__in=('isilon', 'powerscale'), + is_available=True, + ) + if options.get('resource'): + isilon_resources = isilon_resources.filter(name=options['resource']) + + if not isilon_resources.exists(): + logger.warning('No active isilon/powerscale resources found matching %s', options.get('resource')) + return + + all_missing_projects = [] + for resource in isilon_resources: + try: + report = sync_isilon_resource_allocations(resource) + except Exception as e: + print_log_error(e, f'Could not sync allocations for resource {resource.name}') + continue + all_missing_projects.extend(report['missing_projects']) + logger.info('isilon allocation sync report for %s: %s', resource.name, report) + + if all_missing_projects: + logger.warning( + 'sync_isilon_allocations: no matching ColdFront Project found for groups: %s', + sorted(set(all_missing_projects)), + ) diff --git a/coldfront/plugins/isilon/tests.py b/coldfront/plugins/isilon/tests.py index 600348500f..1886a2affb 100644 --- a/coldfront/plugins/isilon/tests.py +++ b/coldfront/plugins/isilon/tests.py @@ -1,12 +1,32 @@ '''tests for Isilon plugin''' +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings + from coldfront.core.allocation.models import Allocation, AllocationStatusChoice from coldfront.core.project.models import Project from coldfront.core.resource.models import Resource +from coldfront.core.test_helpers.factories import ( + AAttributeTypeFactory, + AllocationAttributeFactory, + AllocationAttributeTypeFactory, + AllocationFactory, + AllocationStatusChoiceFactory, + ProjectFactory, + RAttributeTypeFactory, + ResourceAttributeFactory, + ResourceAttributeTypeFactory, + ResourceFactory, +) from coldfront.plugins.isilon.utils import ( IsilonConnection, + IsilonDirectoryQuota, create_isilon_allocation_quota, get_isilon_url, + is_isilon_path_ignored, + sync_isilon_resource_allocations, + update_allocation_quota_and_usage, ) @@ -65,3 +85,167 @@ def test_create_isilon_allocation_quota(): # delete the directory isilon_connection.namespace_client.delete_directory(path) + +def make_mock_quota(path, hard_bytes, usage_bytes): + """Build a MagicMock standing in for an isilon_sdk SmartQuota object.""" + quota = MagicMock() + quota.path = path + quota.thresholds.hard = hard_bytes + quota.usage.fslogical = usage_bytes + return quota + + +TIB = 1024**4 + + +class IsilonDirectoryQuotaTests(TestCase): + """Tests for the IsilonDirectoryQuota wrapper in isilon/utils.py""" + + def test_has_hard_limit_and_byte_fields(self): + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + directory_quota = IsilonDirectoryQuota(quota) + self.assertTrue(directory_quota.has_hard_limit) + self.assertEqual(directory_quota.hard_limit_bytes, TIB) + self.assertEqual(directory_quota.usage_bytes, TIB // 2) + + def test_no_hard_limit(self): + quota = make_mock_quota('/ifs/rc_labs/scratch_tmp', None, 0) + directory_quota = IsilonDirectoryQuota(quota) + self.assertFalse(directory_quota.has_hard_limit) + + def test_cf_path_strips_leading_ifs(self): + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, 0) + self.assertEqual(IsilonDirectoryQuota(quota).cf_path, 'rc_labs/poisson_lab') + + def test_cf_path_without_leading_ifs(self): + quota = make_mock_quota('rc_fasse_labs/poisson_lab', TIB, 0) + self.assertEqual(IsilonDirectoryQuota(quota).cf_path, 'rc_fasse_labs/poisson_lab') + + +class SyncIsilonAllocationsTests(TestCase): + """Tests for sync_isilon_allocations reconciliation logic in isilon/utils.py""" + + def setUp(self): + for status in ('Active', 'New', 'On Hold', 'In Progress', 'Pending Activation', 'Denied'): + AllocationStatusChoiceFactory(name=status) + + self.subdir_type = AllocationAttributeTypeFactory( + name='Subdirectory', attribute_type=AAttributeTypeFactory(name='Text'), has_usage=False, + ) + self.quota_bytes_type = AllocationAttributeTypeFactory( + name='Quota_In_Bytes', attribute_type=AAttributeTypeFactory(name='Int'), has_usage=True, + ) + self.quota_tib_type = AllocationAttributeTypeFactory( + name='Storage Quota (TiB)', attribute_type=AAttributeTypeFactory(name='Float'), has_usage=True, + ) + + self.project = ProjectFactory(title='poisson_lab') + self.resource = ResourceFactory(name='isilon01', resource_type__name='Storage') + ResourceAttributeFactory( + resource=self.resource, + resource_attribute_type=ResourceAttributeTypeFactory( + name='storage_protocol', attribute_type=RAttributeTypeFactory(name='Text'), + ), + value='isilon', + ) + ResourceAttributeFactory( + resource=self.resource, + resource_attribute_type=ResourceAttributeTypeFactory( + name='url', attribute_type=RAttributeTypeFactory(name='Text'), + ), + value='https://isilon01.example.edu:8080', + ) + + def sync_with_quotas(self, quotas, group_name='poisson_lab'): + mock_conn = MagicMock() + mock_conn.quota_client.list_quota_quotas.return_value.quotas = quotas + mock_conn.namespace_client.get_acl.return_value.group.name = group_name + with patch('coldfront.plugins.isilon.utils.IsilonConnection', return_value=mock_conn): + return sync_isilon_resource_allocations(self.resource) + + def test_no_hard_limit_warns_and_is_skipped(self): + quota = make_mock_quota('/ifs/rc_labs/scratch_tmp', None, 0) + report = self.sync_with_quotas([quota]) + self.assertIn('/ifs/rc_labs/scratch_tmp', report['no_limit']) + self.assertEqual(Allocation.objects.count(), 0) + + @override_settings(ISILON_PATH_IGNORE=['/ifs/rc_labs/scratch_tmp']) + def test_no_hard_limit_ignored_path_not_warned(self): + quota = make_mock_quota('/ifs/rc_labs/scratch_tmp', None, 0) + report = self.sync_with_quotas([quota]) + self.assertNotIn('/ifs/rc_labs/scratch_tmp', report['no_limit']) + + def test_group_without_matching_project_is_reported(self): + quota = make_mock_quota('/ifs/rc_labs/ghost_lab', TIB, 0) + report = self.sync_with_quotas([quota], group_name='ghost_lab') + self.assertIn('ghost_lab', report['missing_projects']) + self.assertEqual(Allocation.objects.count(), 0) + + def test_new_allocation_created_when_none_exists(self): + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + report = self.sync_with_quotas([quota]) + + self.assertIn('rc_labs/poisson_lab', report['created']) + allocation = Allocation.objects.get(project=self.project) + self.assertEqual(allocation.status.name, 'Active') + self.assertEqual(allocation.path, 'rc_labs/poisson_lab') + self.assertEqual( + int(float(allocation.get_attribute('Quota_In_Bytes', typed=False))), TIB + ) + + def test_existing_allocation_is_updated_not_duplicated(self): + allocation = AllocationFactory(project=self.project, status__name='Active') + allocation.resources.add(self.resource) + AllocationAttributeFactory( + allocation=allocation, allocation_attribute_type=self.subdir_type, value='rc_labs/poisson_lab', + ) + + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 4) + report = self.sync_with_quotas([quota]) + + self.assertIn('rc_labs/poisson_lab', report['updated']) + self.assertEqual(Allocation.objects.filter(project=self.project).count(), 1) + allocation.refresh_from_db() + self.assertEqual( + int(float(allocation.get_attribute('Quota_In_Bytes', typed=False))), TIB + ) + + def test_pending_allocation_request_is_activated(self): + pending = AllocationFactory( + project=self.project, status__name='New', justification='requesting storage', + ) + pending.resources.add(self.resource) + AllocationAttributeFactory( + allocation=pending, allocation_attribute_type=self.quota_tib_type, value=1.0, + ) + + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + report = self.sync_with_quotas([quota]) + + self.assertIn('rc_labs/poisson_lab', report['activated']) + self.assertEqual(Allocation.objects.filter(project=self.project).count(), 1) + pending.refresh_from_db() + self.assertEqual(pending.status.name, 'Active') + self.assertEqual(pending.path, 'rc_labs/poisson_lab') + + def test_update_allocation_quota_and_usage_skips_rewrite_when_unchanged(self): + allocation = AllocationFactory(project=self.project, status__name='Active') + allocation.resources.add(self.resource) + + changed = update_allocation_quota_and_usage(allocation, TIB, 100) + self.assertTrue(changed) + bytes_attr = allocation.allocationattribute_set.get(allocation_attribute_type=self.quota_bytes_type) + history_count = bytes_attr.history.count() + + changed_again = update_allocation_quota_and_usage(allocation, TIB, 200) + self.assertFalse(changed_again) + bytes_attr.refresh_from_db() + self.assertEqual(int(float(bytes_attr.value)), TIB) + self.assertEqual(bytes_attr.allocationattributeusage.value, 200) + self.assertEqual(bytes_attr.history.count(), history_count) + + def test_is_isilon_path_ignored(self): + self.assertFalse(is_isilon_path_ignored('/ifs/rc_labs/poisson_lab')) + with override_settings(ISILON_PATH_IGNORE=['/ifs/rc_labs/poisson_lab']): + self.assertTrue(is_isilon_path_ignored('/ifs/rc_labs/poisson_lab')) + diff --git a/coldfront/plugins/isilon/utils.py b/coldfront/plugins/isilon/utils.py index c8b35adb06..dca27937bb 100644 --- a/coldfront/plugins/isilon/utils.py +++ b/coldfront/plugins/isilon/utils.py @@ -2,9 +2,16 @@ import isilon_sdk.v9_12_0 as isilon_api from isilon_sdk.v9_12_0.rest import ApiException +from django.utils import timezone from coldfront.core.utils.common import import_from_settings -from coldfront.core.allocation.models import AllocationAttributeType, AllocationAttribute +from coldfront.core.allocation.models import ( + Allocation, + AllocationAttribute, + AllocationAttributeType, + AllocationStatusChoice, +) +from coldfront.core.project.models import Project from coldfront.config.plugins.isilon import ISILON_AUTH_MODEL logger = logging.getLogger(__name__) @@ -517,3 +524,176 @@ def update_coldfront_quota_and_usage(alloc, usage_attribute_type, value_list): usage.value = value_list[1] usage.save() return usage_attribute + + +class IsilonDirectoryQuota: + """Wraps a SmartQuotas API directory quota object with the fields + sync_isilon_allocations needs, so callers don't reach into the raw isilon_sdk + object (or re-derive the same values) at multiple call sites. + """ + def __init__(self, quota): + self.quota = quota + self.path = quota.path + self.has_hard_limit = quota.thresholds.hard is not None + self.hard_limit_bytes = quota.thresholds.hard + self.usage_bytes = quota.usage.fslogical + + @property + def cf_path(self): + """The quota's path in the form stored on an Allocation's Subdirectory attribute.""" + path = self.path.lstrip('/') + if path.startswith('ifs/'): + path = path[len('ifs/'):] + return path + + +def is_isilon_path_ignored(path): + """Return True if `path` is in the ISILON_PATH_IGNORE setting.""" + return path in import_from_settings('ISILON_PATH_IGNORE', []) + + +def get_directory_group(isilon_conn, directory_quota): + """Return the name of the group that owns a directory smartquota's path.""" + acl = isilon_conn.namespace_client.get_acl( + namespace_path=directory_quota.path.lstrip('/'), acl=True + ) + return acl.group.name + + +def find_matching_pending_allocation(project, resource, quota_bytes): + """Find an open allocation request (New/On Hold/In Progress/Pending Activation) for + `project`/`resource` whose requested quota size matches `quota_bytes` and that + doesn't already have a Subdirectory attribute set. + """ + pending_statuses = import_from_settings( + 'PENDING_ALLOCATION_STATUSES', ['New', 'In Progress', 'On Hold', 'Pending Activation'] + ) + quota_tib = quota_bytes / 1024**4 + candidates = project.allocation_set.filter( + resources=resource, status__name__in=pending_statuses, + ).exclude( + allocationattribute__allocation_attribute_type__name='Subdirectory' + ) + for candidate in candidates: + bytes_value = candidate.get_attribute('Quota_In_Bytes', typed=False) + if bytes_value is not None and int(float(bytes_value)) == int(quota_bytes): + return candidate + tib_value = candidate.get_attribute('Storage Quota (TiB)', typed=False) + if tib_value is not None and abs(float(tib_value) - quota_tib) < 0.01: + return candidate + return None + + +def update_allocation_quota_and_usage(allocation, quota_bytes, usage_bytes): + """Update Quota_In_Bytes and Storage Quota (TiB) attributes/usage on `allocation`. + + Only rewrites the quota value when it has actually changed, to avoid noisy + HistoricalRecords churn on every sync run; usage is always refreshed. + Returns True if the quota value changed. + """ + quota_bytes_type = AllocationAttributeType.objects.get(name='Quota_In_Bytes') + quota_tib_type = AllocationAttributeType.objects.get(name='Storage Quota (TiB)') + quota_tib = quota_bytes / 1024**4 + usage_tib = usage_bytes / 1024**4 + + bytes_attr = allocation.allocationattribute_set.filter( + allocation_attribute_type=quota_bytes_type + ).first() + quota_changed = bytes_attr is None or int(float(bytes_attr.value)) != int(quota_bytes) + + if quota_changed: + update_coldfront_quota_and_usage(allocation, quota_bytes_type, [quota_bytes, usage_bytes]) + update_coldfront_quota_and_usage(allocation, quota_tib_type, [quota_tib, usage_tib]) + else: + bytes_attr.allocationattributeusage.value = usage_bytes + bytes_attr.allocationattributeusage.save() + tib_attr = allocation.allocationattribute_set.get(allocation_attribute_type=quota_tib_type) + tib_attr.allocationattributeusage.value = usage_tib + tib_attr.allocationattributeusage.save() + return quota_changed + + +def sync_allocation_for_quota(project, resource, directory_quota, report): + """Reconcile a single Directory SmartQuota (already matched to `project`) with + ColdFront allocation state: update an existing Allocation, activate a matching + pending allocation request, or create a new Allocation. + """ + subdir_type = AllocationAttributeType.objects.get(name='Subdirectory') + cf_path = directory_quota.cf_path + quota_bytes = directory_quota.hard_limit_bytes + usage_bytes = directory_quota.usage_bytes + + existing_allocation = Allocation.objects.filter( + project=project, + resources=resource, + allocationattribute__allocation_attribute_type=subdir_type, + allocationattribute__value=cf_path, + ).first() + if existing_allocation: + update_allocation_quota_and_usage(existing_allocation, quota_bytes, usage_bytes) + report['updated'].append(cf_path) + return existing_allocation + + pending_allocation = find_matching_pending_allocation(project, resource, quota_bytes) + if pending_allocation: + AllocationAttribute.objects.create( + allocation=pending_allocation, + allocation_attribute_type=subdir_type, + value=cf_path, + ) + pending_allocation.status = AllocationStatusChoice.objects.get(name='Active') + if not pending_allocation.start_date: + pending_allocation.start_date = timezone.now().date() + pending_allocation.save() + update_allocation_quota_and_usage(pending_allocation, quota_bytes, usage_bytes) + report['activated'].append(cf_path) + return pending_allocation + + new_allocation = Allocation.objects.create( + project=project, + status=AllocationStatusChoice.objects.get(name='Active'), + start_date=timezone.now().date(), + justification=f'Auto-created by sync_isilon_allocations for {project.title} at {cf_path}', + ) + new_allocation.resources.add(resource) + AllocationAttribute.objects.create( + allocation=new_allocation, allocation_attribute_type=subdir_type, value=cf_path, + ) + update_allocation_quota_and_usage(new_allocation, quota_bytes, usage_bytes) + report['created'].append(cf_path) + return new_allocation + + +def sync_isilon_resource_allocations(resource): + """Sync all Directory smartquotas with a hard limit on `resource` into ColdFront + Allocations. Returns a report dict summarizing what happened. + """ + report = { + 'created': [], + 'activated': [], + 'updated': [], + 'missing_projects': [], + 'no_limit': [], + } + isilon_url = get_isilon_url(resource) + isilon_conn = IsilonConnection(isilon_url) + + quotas = isilon_conn.quota_client.list_quota_quotas(type='directory').quotas + + for quota in quotas: + directory_quota = IsilonDirectoryQuota(quota) + if not directory_quota.has_hard_limit: + if not is_isilon_path_ignored(directory_quota.path): + logger.warning('No hard quota limit set for %s on %s', directory_quota.path, resource.name) + report['no_limit'].append(directory_quota.path) + continue + + group_name = get_directory_group(isilon_conn, directory_quota) + project = Project.objects.filter(title=group_name).first() + if project is None: + report['missing_projects'].append(group_name) + continue + + sync_allocation_for_quota(project, resource, directory_quota, report) + + return report From 1a601bbb3d9bf2fe2ea0038b14faca292d61b01e Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Tue, 14 Jul 2026 13:05:05 -0700 Subject: [PATCH 2/9] Match pending allocation requests attached to a resource's storage tier - find_matching_pending_allocation now also matches requests attached to resource.parent_resource (e.g. a bos-isilon quota should match a pending request against "Tier 1"), since storage requests are commonly made against the tier rather than the specific cluster a quota lands on - sync_allocation_for_quota repoints the activated allocation's resources from the tier to the specific resource once matched, so later syncs find it via the specific-resource lookup used elsewhere - Add test covering a Tier-1-attached pending request being activated and repointed to the specific cluster resource Co-Authored-By: Claude Sonnet 5 --- coldfront/plugins/isilon/tests.py | 23 +++++++++++++++++++++++ coldfront/plugins/isilon/utils.py | 15 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/coldfront/plugins/isilon/tests.py b/coldfront/plugins/isilon/tests.py index 1886a2affb..bded5b0bb5 100644 --- a/coldfront/plugins/isilon/tests.py +++ b/coldfront/plugins/isilon/tests.py @@ -228,6 +228,29 @@ def test_pending_allocation_request_is_activated(self): self.assertEqual(pending.status.name, 'Active') self.assertEqual(pending.path, 'rc_labs/poisson_lab') + def test_pending_request_on_tier_resource_is_activated_and_repointed(self): + tier_resource = ResourceFactory(name='Tier 1', resource_type__name='Storage Tier') + self.resource.parent_resource = tier_resource + self.resource.save() + + pending = AllocationFactory( + project=self.project, status__name='New', justification='requesting tier 1 storage', + ) + pending.resources.add(tier_resource) + AllocationAttributeFactory( + allocation=pending, allocation_attribute_type=self.quota_tib_type, value=1.0, + ) + + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + report = self.sync_with_quotas([quota]) + + self.assertIn('rc_labs/poisson_lab', report['activated']) + self.assertEqual(Allocation.objects.filter(project=self.project).count(), 1) + pending.refresh_from_db() + self.assertEqual(pending.status.name, 'Active') + self.assertEqual(pending.path, 'rc_labs/poisson_lab') + self.assertEqual(list(pending.resources.all()), [self.resource]) + def test_update_allocation_quota_and_usage_skips_rewrite_when_unchanged(self): allocation = AllocationFactory(project=self.project, status__name='Active') allocation.resources.add(self.resource) diff --git a/coldfront/plugins/isilon/utils.py b/coldfront/plugins/isilon/utils.py index dca27937bb..cafee47271 100644 --- a/coldfront/plugins/isilon/utils.py +++ b/coldfront/plugins/isilon/utils.py @@ -2,6 +2,7 @@ import isilon_sdk.v9_12_0 as isilon_api from isilon_sdk.v9_12_0.rest import ApiException +from django.db.models import Q from django.utils import timezone from coldfront.core.utils.common import import_from_settings @@ -564,13 +565,20 @@ def find_matching_pending_allocation(project, resource, quota_bytes): """Find an open allocation request (New/On Hold/In Progress/Pending Activation) for `project`/`resource` whose requested quota size matches `quota_bytes` and that doesn't already have a Subdirectory attribute set. + + Requests are often made against the storage tier (e.g. 'Tier 1') rather than the + specific cluster resource a quota ends up provisioned on, so this also matches + requests attached to `resource.parent_resource`. """ pending_statuses = import_from_settings( 'PENDING_ALLOCATION_STATUSES', ['New', 'In Progress', 'On Hold', 'Pending Activation'] ) quota_tib = quota_bytes / 1024**4 + resource_filter = Q(resources=resource) + if resource.parent_resource: + resource_filter |= Q(resources=resource.parent_resource) candidates = project.allocation_set.filter( - resources=resource, status__name__in=pending_statuses, + resource_filter, status__name__in=pending_statuses, ).exclude( allocationattribute__allocation_attribute_type__name='Subdirectory' ) @@ -645,6 +653,11 @@ def sync_allocation_for_quota(project, resource, directory_quota, report): if not pending_allocation.start_date: pending_allocation.start_date = timezone.now().date() pending_allocation.save() + # requests are often made against the resource's storage tier rather than the + # specific cluster the quota actually landed on - repoint to the specific resource + if not pending_allocation.resources.filter(pk=resource.pk).exists(): + pending_allocation.resources.clear() + pending_allocation.resources.add(resource) update_allocation_quota_and_usage(pending_allocation, quota_bytes, usage_bytes) report['activated'].append(cf_path) return pending_allocation From e9a21c80276400d1a0fc290b0d9f456dbf442f57 Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Tue, 14 Jul 2026 13:08:57 -0700 Subject: [PATCH 3/9] Fix crash when a directory's owning group can't be resolved - sync_isilon_resource_allocations was passing a None group_name straight into missing_projects when get_directory_group's ACL lookup couldn't resolve an owner (e.g. an orphaned/unmapped GID), which then crashed sorted(set(...)) in the management command since None isn't orderable against str - Treat an unresolved group name as its own report bucket (report['unresolved_group'], keyed by path) distinct from missing_projects (which now only ever holds known group names with no matching Project), and skip the project lookup entirely in that case - Add a regression test for a quota whose ACL group name is None Co-Authored-By: Claude Sonnet 5 --- coldfront/plugins/isilon/tests.py | 7 +++++++ coldfront/plugins/isilon/utils.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/coldfront/plugins/isilon/tests.py b/coldfront/plugins/isilon/tests.py index bded5b0bb5..c0fd672b35 100644 --- a/coldfront/plugins/isilon/tests.py +++ b/coldfront/plugins/isilon/tests.py @@ -181,6 +181,13 @@ def test_group_without_matching_project_is_reported(self): self.assertIn('ghost_lab', report['missing_projects']) self.assertEqual(Allocation.objects.count(), 0) + def test_unresolved_group_name_is_reported_not_crashed(self): + quota = make_mock_quota('/ifs/rc_labs/orphaned_dir', TIB, 0) + report = self.sync_with_quotas([quota], group_name=None) + self.assertIn('/ifs/rc_labs/orphaned_dir', report['unresolved_group']) + self.assertEqual(report['missing_projects'], []) + self.assertEqual(Allocation.objects.count(), 0) + def test_new_allocation_created_when_none_exists(self): quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) report = self.sync_with_quotas([quota]) diff --git a/coldfront/plugins/isilon/utils.py b/coldfront/plugins/isilon/utils.py index cafee47271..c1ea45ecb8 100644 --- a/coldfront/plugins/isilon/utils.py +++ b/coldfront/plugins/isilon/utils.py @@ -686,6 +686,7 @@ def sync_isilon_resource_allocations(resource): 'activated': [], 'updated': [], 'missing_projects': [], + 'unresolved_group': [], 'no_limit': [], } isilon_url = get_isilon_url(resource) @@ -702,6 +703,13 @@ def sync_isilon_resource_allocations(resource): continue group_name = get_directory_group(isilon_conn, directory_quota) + if not group_name: + logger.warning( + 'Could not resolve an owning group for %s on %s', directory_quota.path, resource.name + ) + report['unresolved_group'].append(directory_quota.path) + continue + project = Project.objects.filter(title=group_name).first() if project is None: report['missing_projects'].append(group_name) From ddde1a181f51f4315181e737c11fa04d654bf1df Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Tue, 14 Jul 2026 13:36:21 -0700 Subject: [PATCH 4/9] Strip domain prefix from Isilon ACL group names before Project matching - get_directory_group can return a domain-qualified name (e.g. 'RC\poisson_lab') rather than the bare group name ColdFront Project titles use, causing valid groups to be reported as missing_projects. Strip any 'DOMAIN\' prefix before returning the name. - Add tests for the prefixed, unprefixed, and unresolved (None) cases on get_directory_group directly, plus an end-to-end sync test confirming a domain-qualified name still matches its Project. Co-Authored-By: Claude Sonnet 5 --- coldfront/plugins/isilon/tests.py | 31 +++++++++++++++++++++++++++++++ coldfront/plugins/isilon/utils.py | 12 ++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/coldfront/plugins/isilon/tests.py b/coldfront/plugins/isilon/tests.py index c0fd672b35..ba37c6b13f 100644 --- a/coldfront/plugins/isilon/tests.py +++ b/coldfront/plugins/isilon/tests.py @@ -23,6 +23,7 @@ IsilonConnection, IsilonDirectoryQuota, create_isilon_allocation_quota, + get_directory_group, get_isilon_url, is_isilon_path_ignored, sync_isilon_resource_allocations, @@ -122,6 +123,28 @@ def test_cf_path_without_leading_ifs(self): self.assertEqual(IsilonDirectoryQuota(quota).cf_path, 'rc_fasse_labs/poisson_lab') +class GetDirectoryGroupTests(TestCase): + """Tests for get_directory_group's domain-prefix stripping in isilon/utils.py""" + + def test_strips_domain_prefix(self): + directory_quota = IsilonDirectoryQuota(make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, 0)) + mock_conn = MagicMock() + mock_conn.namespace_client.get_acl.return_value.group.name = 'RC\\poisson_lab' + self.assertEqual(get_directory_group(mock_conn, directory_quota), 'poisson_lab') + + def test_leaves_unqualified_name_unchanged(self): + directory_quota = IsilonDirectoryQuota(make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, 0)) + mock_conn = MagicMock() + mock_conn.namespace_client.get_acl.return_value.group.name = 'poisson_lab' + self.assertEqual(get_directory_group(mock_conn, directory_quota), 'poisson_lab') + + def test_none_group_name_stays_none(self): + directory_quota = IsilonDirectoryQuota(make_mock_quota('/ifs/rc_labs/orphaned_dir', TIB, 0)) + mock_conn = MagicMock() + mock_conn.namespace_client.get_acl.return_value.group.name = None + self.assertIsNone(get_directory_group(mock_conn, directory_quota)) + + class SyncIsilonAllocationsTests(TestCase): """Tests for sync_isilon_allocations reconciliation logic in isilon/utils.py""" @@ -200,6 +223,14 @@ def test_new_allocation_created_when_none_exists(self): int(float(allocation.get_attribute('Quota_In_Bytes', typed=False))), TIB ) + def test_domain_qualified_group_name_is_stripped_before_project_match(self): + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + report = self.sync_with_quotas([quota], group_name='RC\\poisson_lab') + + self.assertIn('rc_labs/poisson_lab', report['created']) + self.assertEqual(report['missing_projects'], []) + self.assertTrue(Allocation.objects.filter(project=self.project).exists()) + def test_existing_allocation_is_updated_not_duplicated(self): allocation = AllocationFactory(project=self.project, status__name='Active') allocation.resources.add(self.resource) diff --git a/coldfront/plugins/isilon/utils.py b/coldfront/plugins/isilon/utils.py index c1ea45ecb8..6b9d079723 100644 --- a/coldfront/plugins/isilon/utils.py +++ b/coldfront/plugins/isilon/utils.py @@ -554,11 +554,19 @@ def is_isilon_path_ignored(path): def get_directory_group(isilon_conn, directory_quota): - """Return the name of the group that owns a directory smartquota's path.""" + """Return the name of the group that owns a directory smartquota's path. + + Isilon sometimes returns this as a domain-qualified name (e.g. 'RC\\poisson_lab') + rather than the bare group name ColdFront Project titles use, so strip any + 'DOMAIN\\' prefix. + """ acl = isilon_conn.namespace_client.get_acl( namespace_path=directory_quota.path.lstrip('/'), acl=True ) - return acl.group.name + group_name = acl.group.name + if group_name and '\\' in group_name: + group_name = group_name.rsplit('\\', 1)[-1] + return group_name def find_matching_pending_allocation(project, resource, quota_bytes): From 1777cd36a09a0ad99be2bc867bc5031ca9727a51 Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Wed, 15 Jul 2026 14:47:28 -0700 Subject: [PATCH 5/9] Set RequiresPayment allocation attribute for created/activated allocations - sync_allocation_for_quota now writes a RequiresPayment AllocationAttribute mirroring resource.requires_payment whenever it creates a new Allocation or activates a pending allocation request - Activation uses update_or_create since a pending request may already carry a RequiresPayment value from when it was originally submitted, which should be corrected to match the resource the quota actually landed on - Existing (already-synced) allocations are untouched, since the ask was scoped to created/activated allocations only - Add coverage for both branches, including overwriting a stale value on activation and a resource with requires_payment=True Co-Authored-By: Claude Sonnet 5 --- coldfront/plugins/isilon/tests.py | 46 +++++++++++++++++++++++++++++++ coldfront/plugins/isilon/utils.py | 10 +++++++ 2 files changed, 56 insertions(+) diff --git a/coldfront/plugins/isilon/tests.py b/coldfront/plugins/isilon/tests.py index ba37c6b13f..81d401f6b0 100644 --- a/coldfront/plugins/isilon/tests.py +++ b/coldfront/plugins/isilon/tests.py @@ -161,6 +161,9 @@ def setUp(self): self.quota_tib_type = AllocationAttributeTypeFactory( name='Storage Quota (TiB)', attribute_type=AAttributeTypeFactory(name='Float'), has_usage=True, ) + self.requires_payment_type = AllocationAttributeTypeFactory( + name='RequiresPayment', attribute_type=AAttributeTypeFactory(name='Yes/No'), has_usage=False, + ) self.project = ProjectFactory(title='poisson_lab') self.resource = ResourceFactory(name='isilon01', resource_type__name='Storage') @@ -222,6 +225,20 @@ def test_new_allocation_created_when_none_exists(self): self.assertEqual( int(float(allocation.get_attribute('Quota_In_Bytes', typed=False))), TIB ) + self.assertEqual( + allocation.get_attribute('RequiresPayment', typed=False), str(self.resource.requires_payment) + ) + + def test_new_allocation_requires_payment_matches_paid_resource(self): + # bypass Resource's post_save signal (unrelated ifx billing side effect) via .update() + Resource.objects.filter(pk=self.resource.pk).update(requires_payment=True) + self.resource.refresh_from_db() + + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + self.sync_with_quotas([quota]) + + allocation = Allocation.objects.get(project=self.project) + self.assertEqual(allocation.get_attribute('RequiresPayment', typed=False), 'True') def test_domain_qualified_group_name_is_stripped_before_project_match(self): quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) @@ -265,6 +282,35 @@ def test_pending_allocation_request_is_activated(self): pending.refresh_from_db() self.assertEqual(pending.status.name, 'Active') self.assertEqual(pending.path, 'rc_labs/poisson_lab') + self.assertEqual( + pending.get_attribute('RequiresPayment', typed=False), str(self.resource.requires_payment) + ) + + def test_activation_overwrites_stale_requires_payment_value(self): + pending = AllocationFactory( + project=self.project, status__name='New', justification='requesting storage', + ) + pending.resources.add(self.resource) + AllocationAttributeFactory( + allocation=pending, allocation_attribute_type=self.quota_tib_type, value=1.0, + ) + AllocationAttributeFactory( + allocation=pending, allocation_attribute_type=self.requires_payment_type, value=False, + ) + + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + self.sync_with_quotas([quota]) + + pending.refresh_from_db() + self.assertEqual( + pending.allocationattribute_set.filter( + allocation_attribute_type=self.requires_payment_type + ).count(), + 1, + ) + self.assertEqual( + pending.get_attribute('RequiresPayment', typed=False), str(self.resource.requires_payment) + ) def test_pending_request_on_tier_resource_is_activated_and_repointed(self): tier_resource = ResourceFactory(name='Tier 1', resource_type__name='Storage Tier') diff --git a/coldfront/plugins/isilon/utils.py b/coldfront/plugins/isilon/utils.py index 6b9d079723..bc901468c8 100644 --- a/coldfront/plugins/isilon/utils.py +++ b/coldfront/plugins/isilon/utils.py @@ -635,6 +635,7 @@ def sync_allocation_for_quota(project, resource, directory_quota, report): pending allocation request, or create a new Allocation. """ subdir_type = AllocationAttributeType.objects.get(name='Subdirectory') + requires_payment_type = AllocationAttributeType.objects.get(name='RequiresPayment') cf_path = directory_quota.cf_path quota_bytes = directory_quota.hard_limit_bytes usage_bytes = directory_quota.usage_bytes @@ -666,6 +667,11 @@ def sync_allocation_for_quota(project, resource, directory_quota, report): if not pending_allocation.resources.filter(pk=resource.pk).exists(): pending_allocation.resources.clear() pending_allocation.resources.add(resource) + AllocationAttribute.objects.update_or_create( + allocation=pending_allocation, + allocation_attribute_type=requires_payment_type, + defaults={'value': resource.requires_payment}, + ) update_allocation_quota_and_usage(pending_allocation, quota_bytes, usage_bytes) report['activated'].append(cf_path) return pending_allocation @@ -680,6 +686,10 @@ def sync_allocation_for_quota(project, resource, directory_quota, report): AllocationAttribute.objects.create( allocation=new_allocation, allocation_attribute_type=subdir_type, value=cf_path, ) + AllocationAttribute.objects.create( + allocation=new_allocation, allocation_attribute_type=requires_payment_type, + value=resource.requires_payment, + ) update_allocation_quota_and_usage(new_allocation, quota_bytes, usage_bytes) report['created'].append(cf_path) return new_allocation From c5420db608218df8eab492341346f80121668b0a Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Thu, 16 Jul 2026 10:32:04 -0700 Subject: [PATCH 6/9] remove unneeded logging statements --- coldfront/plugins/fasrc/utils.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/coldfront/plugins/fasrc/utils.py b/coldfront/plugins/fasrc/utils.py index bf3f865028..e44df516df 100644 --- a/coldfront/plugins/fasrc/utils.py +++ b/coldfront/plugins/fasrc/utils.py @@ -204,7 +204,6 @@ def pull_quota_data(self): ---------- volumes : List of volume names to collect. Optional, default None. """ - logger = logging.getLogger('coldfront.import_quotas') query = ATTAllocationQuery() query.produce_query_statement('isilon', volumes=self.volumes) query.produce_query_statement('quota', volumes=self.volumes) @@ -216,7 +215,6 @@ def pull_quota_data(self): def matched_dict_processing(allocation, data_dicts, paired_allocs, log_message): - logger = logging.getLogger('coldfront.import_quotas') if len(data_dicts) == 1: logger.debug(log_message) paired_allocs[allocation] = data_dicts[0] @@ -228,7 +226,6 @@ def matched_dict_processing(allocation, data_dicts, paired_allocs, log_message): def pair_allocations_data(project, quota_dicts): """pair allocations with usage dicts""" - logger = logging.getLogger('coldfront.import_quotas') allocs = project.allocation_set.filter( status__name__in=['Active','Pending Deactivation'], resources__resource_type__name='Storage' @@ -259,7 +256,6 @@ def pair_allocations_data(project, quota_dicts): def push_quota_data(result_file): """update group quota & usage values in Coldfront from a JSON of quota data. """ - logger = logging.getLogger('coldfront.import_quotas') errored_allocations = {} missing_allocations = [] result_json = read_json(result_file) @@ -332,7 +328,6 @@ def match_entries_with_projects(result_json): def pull_push_quota_data(volumes=None): - logger = logging.getLogger('coldfront.import_quotas') att_data = QuotaDataPuller(volumes=volumes).pull('ATTQuery') resp_json_by_lab = {entry['lab']:[] for entry in att_data} for entry in att_data: From a817bd5e1808342497b3bf0e9e1840fab79629aa Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Thu, 16 Jul 2026 11:07:51 -0700 Subject: [PATCH 7/9] add sync_isilon_allocations task --- coldfront/plugins/isilon/tasks.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/coldfront/plugins/isilon/tasks.py b/coldfront/plugins/isilon/tasks.py index 5d7fcf018e..4cad0d6b89 100644 --- a/coldfront/plugins/isilon/tasks.py +++ b/coldfront/plugins/isilon/tasks.py @@ -4,3 +4,11 @@ def pull_isilon_quotas(): """Pull Isilon quotas """ management.call_command('pull_isilon_quotas') + +def sync_isilon_allocations(resource_name=None): + """Sync Isilon/PowerScale directory smartquotas into ColdFront allocations + """ + if resource_name: + management.call_command('sync_isilon_allocations', resource=resource_name) + else: + management.call_command('sync_isilon_allocations') From 7125ca39bc8ed9b663ebf15e9f3b143d8f80fec8 Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Thu, 16 Jul 2026 11:08:22 -0700 Subject: [PATCH 8/9] remove isilon allocations from ATT discovery process --- coldfront/plugins/fasrc/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coldfront/plugins/fasrc/utils.py b/coldfront/plugins/fasrc/utils.py index e44df516df..5f1ef927d9 100644 --- a/coldfront/plugins/fasrc/utils.py +++ b/coldfront/plugins/fasrc/utils.py @@ -205,7 +205,7 @@ def pull_quota_data(self): volumes : List of volume names to collect. Optional, default None. """ query = ATTAllocationQuery() - query.produce_query_statement('isilon', volumes=self.volumes) + # query.produce_query_statement('isilon', volumes=self.volumes) query.produce_query_statement('quota', volumes=self.volumes) query.produce_query_statement('volume', volumes=self.volumes) query.produce_query_statement('tapeallocation') From 55893dd80906742353fc7a38f3873869fbd21196 Mon Sep 17 00:00:00 2001 From: Claire Peters Date: Thu, 16 Jul 2026 11:32:17 -0700 Subject: [PATCH 9/9] Deactivate active isilon allocations whose quota is gone from the volume - sync_isilon_resource_allocations now tracks every quota path seen on a resource (hard-limited or not, resolved group or not - presence on the volume is what matters) and, after processing, deactivates any Active allocation on that resource whose recorded path isn't among them - deactivate_missing_allocations() sets status to Inactive, matching the existing precedent in pull_vast_quotas.py - Allocations with no recorded Subdirectory path are left alone, since absence from the volume isn't a meaningful signal for them - Add coverage for: missing-path deactivation, an allocation whose quota is still present (even without a hard limit) staying Active, and a pathless allocation being left alone Co-Authored-By: Claude Sonnet 5 --- coldfront/plugins/isilon/tests.py | 41 ++++++++++++++++++++++++++++++- coldfront/plugins/isilon/utils.py | 29 +++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/coldfront/plugins/isilon/tests.py b/coldfront/plugins/isilon/tests.py index 81d401f6b0..f643d2d1f7 100644 --- a/coldfront/plugins/isilon/tests.py +++ b/coldfront/plugins/isilon/tests.py @@ -149,7 +149,7 @@ class SyncIsilonAllocationsTests(TestCase): """Tests for sync_isilon_allocations reconciliation logic in isilon/utils.py""" def setUp(self): - for status in ('Active', 'New', 'On Hold', 'In Progress', 'Pending Activation', 'Denied'): + for status in ('Active', 'Inactive', 'New', 'On Hold', 'In Progress', 'Pending Activation', 'Denied'): AllocationStatusChoiceFactory(name=status) self.subdir_type = AllocationAttributeTypeFactory( @@ -356,3 +356,42 @@ def test_is_isilon_path_ignored(self): with override_settings(ISILON_PATH_IGNORE=['/ifs/rc_labs/poisson_lab']): self.assertTrue(is_isilon_path_ignored('/ifs/rc_labs/poisson_lab')) + def test_active_allocation_missing_from_volume_is_deactivated(self): + allocation = AllocationFactory(project=self.project, status__name='Active') + allocation.resources.add(self.resource) + AllocationAttributeFactory( + allocation=allocation, allocation_attribute_type=self.subdir_type, value='rc_labs/ghost_lab', + ) + + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', TIB, TIB // 2) + report = self.sync_with_quotas([quota]) + + self.assertIn('rc_labs/ghost_lab', report['deactivated']) + allocation.refresh_from_db() + self.assertEqual(allocation.status.name, 'Inactive') + + def test_active_allocation_still_on_volume_is_not_deactivated(self): + allocation = AllocationFactory(project=self.project, status__name='Active') + allocation.resources.add(self.resource) + AllocationAttributeFactory( + allocation=allocation, allocation_attribute_type=self.subdir_type, value='rc_labs/poisson_lab', + ) + + # quota still exists on the volume, just with no hard limit set + quota = make_mock_quota('/ifs/rc_labs/poisson_lab', None, 0) + report = self.sync_with_quotas([quota]) + + self.assertEqual(report['deactivated'], []) + allocation.refresh_from_db() + self.assertEqual(allocation.status.name, 'Active') + + def test_active_allocation_with_no_path_is_not_deactivated(self): + allocation = AllocationFactory(project=self.project, status__name='Active') + allocation.resources.add(self.resource) + + report = self.sync_with_quotas([]) + + self.assertEqual(report['deactivated'], []) + allocation.refresh_from_db() + self.assertEqual(allocation.status.name, 'Active') + diff --git a/coldfront/plugins/isilon/utils.py b/coldfront/plugins/isilon/utils.py index bc901468c8..57e26028fb 100644 --- a/coldfront/plugins/isilon/utils.py +++ b/coldfront/plugins/isilon/utils.py @@ -695,14 +695,36 @@ def sync_allocation_for_quota(project, resource, directory_quota, report): return new_allocation +def deactivate_missing_allocations(resource, found_paths, report): + """Deactivate Active allocations on `resource` whose Subdirectory path is no + longer among the volume's quotas. Allocations with no recorded path are left + alone, since absence from the volume isn't meaningful for them. + """ + inactive_status = AllocationStatusChoice.objects.get(name='Inactive') + active_allocations = Allocation.objects.filter(resources=resource, status__name='Active') + for allocation in active_allocations: + path = allocation.path + if not path or path in found_paths: + continue + allocation.status = inactive_status + allocation.save() + logger.warning( + 'Deactivating allocation %s on %s - path %s not found on volume', + allocation.pk, resource.name, path, + ) + report['deactivated'].append(path) + + def sync_isilon_resource_allocations(resource): """Sync all Directory smartquotas with a hard limit on `resource` into ColdFront - Allocations. Returns a report dict summarizing what happened. + Allocations, and deactivate Active allocations whose path is no longer on the + volume. Returns a report dict summarizing what happened. """ report = { 'created': [], 'activated': [], 'updated': [], + 'deactivated': [], 'missing_projects': [], 'unresolved_group': [], 'no_limit': [], @@ -711,9 +733,12 @@ def sync_isilon_resource_allocations(resource): isilon_conn = IsilonConnection(isilon_url) quotas = isilon_conn.quota_client.list_quota_quotas(type='directory').quotas + found_paths = set() for quota in quotas: directory_quota = IsilonDirectoryQuota(quota) + found_paths.add(directory_quota.cf_path) + if not directory_quota.has_hard_limit: if not is_isilon_path_ignored(directory_quota.path): logger.warning('No hard quota limit set for %s on %s', directory_quota.path, resource.name) @@ -735,4 +760,6 @@ def sync_isilon_resource_allocations(resource): sync_allocation_for_quota(project, resource, directory_quota, report) + deactivate_missing_allocations(resource, found_paths, report) + return report