Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:23

0001 #!/usr/bin/env python3
0002 """osgsub-reporter.py — what only the OSG submit host can see.
0003 
0004 Runs on osgsub01 and posts one record per run to swf-monitor
0005 (docs/OSG_SUBMIT_REPORTER.md): the exclusions actually in force in the
0006 submit descriptions, the queue-to-file mapping harvester uses, what the
0007 pool currently offers, and what the local schedd is doing. The monitor
0008 cannot read any of it — ssh to this host is refused except through the
0009 facility gateway with agent forwarding — so the host pushes.
0010 
0011 Written for this host as it is: python 3.6, standard library only, no
0012 virtual environment. Every source that cannot be read is delivered as
0013 an error field rather than dropped, because a missing field and an
0014 unreadable source are otherwise the same thing to a reader.
0015 
0016 Usage::
0017 
0018     osgsub-reporter.py                 # collect and post
0019     osgsub-reporter.py --print         # collect and print, post nothing
0020     osgsub-reporter.py --no-pool       # skip the condor queries
0021 
0022 Configuration, from the environment or an environment file named by
0023 --env (mode 600): SWF_MONITOR_URL, SWF_REPORT_TOKEN.
0024 """
0025 from __future__ import print_function
0026 
0027 import argparse
0028 import json
0029 import os
0030 import re
0031 import ssl
0032 import subprocess
0033 import sys
0034 import time
0035 from datetime import datetime
0036 
0037 HOST = 'osgsub01'
0038 SDF_DIR = '/var/data/atlpan/harvester_common'
0039 QUEUE_CONFIG = '/opt/harvester/etc/panda/panda_queueconfig.json'
0040 DEFAULT_STATE = os.path.expanduser('~/.swf-osgsub-reporter')
0041 POST_TIMEOUT_S = 30
0042 CONDOR_TIMEOUT_S = 120
0043 BUFFER_KEEP = 48
0044 
0045 # The node exclusions are written as one term per site inside a negated
0046 # disjunction; this reads the pairs back out of whatever is deployed,
0047 # so the record reflects the file rather than anything we believe.
0048 PAIR_RE = re.compile(
0049     r'GLIDEIN_Site\s*=\?=\s*"([^"]+)"\s*&&\s*'
0050     r'regexp\(\s*"\^\(([^)]*)\)\(\[\.\]\|\$\)"',
0051     re.IGNORECASE)
0052 
0053 
0054 def now_iso():
0055     return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
0056 
0057 
0058 def run(cmd, timeout=CONDOR_TIMEOUT_S):
0059     """A command's stdout, or None with the reason on the record."""
0060     try:
0061         done = subprocess.run(cmd, stdout=subprocess.PIPE,
0062                               stderr=subprocess.PIPE,
0063                               universal_newlines=True, timeout=timeout)
0064     except Exception as exc:                                  # noqa: BLE001
0065         return None, '{}: {}'.format(exc.__class__.__name__, exc)
0066     if done.returncode != 0:
0067         return None, 'exit {}: {}'.format(done.returncode,
0068                                           (done.stderr or '').strip()[:400])
0069     return done.stdout, None
0070 
0071 
0072 def parse_sdf(path):
0073     """The exclusions and submission shape of one submit description."""
0074     out = {'file': os.path.basename(path)}
0075     try:
0076         with open(path) as handle:
0077             text = handle.read()
0078         out['modified'] = datetime.utcfromtimestamp(
0079             os.path.getmtime(path)).strftime('%Y-%m-%dT%H:%M:%SZ')
0080     except OSError as exc:
0081         out['error'] = 'unreadable: {}'.format(exc)
0082         return out
0083 
0084     sites = re.search(r'^\+UNDESIRED_Sites\s*=\s*"([^"]*)"', text, re.M)
0085     out['excluded_sites'] = (
0086         [s.strip() for s in sites.group(1).split(',') if s.strip()]
0087         if sites else [])
0088 
0089     req = re.search(r'^Requirements\s*=\s*(.+)$', text, re.M)
0090     out['requirements'] = req.group(1).strip() if req else None
0091     pairs = []
0092     if req:
0093         for site, names in PAIR_RE.findall(req.group(1)):
0094             for node in names.split('|'):
0095                 if node.strip():
0096                     pairs.append({'site': site, 'node': node.strip()})
0097     out['excluded_site_nodes'] = pairs
0098 
0099     for field, pattern in (
0100             ('executable', r'^executable\s*=\s*(.+)$'),
0101             ('job_duration_category', r'^\+JobDurationCategory\s*=\s*"([^"]*)"'),
0102             ('request_cpus', r'^request_cpus\s*=\s*(.+)$'),
0103             ('request_memory', r'^request_memory\s*=\s*(.+)$'),
0104             ('request_disk', r'^request_disk\s*=\s*(.+)$')):
0105         found = re.search(pattern, text, re.M)
0106         out[field] = found.group(1).strip() if found else None
0107     return out
0108 
0109 
0110 def submit_descriptions():
0111     """Every submit description on the host, parsed."""
0112     try:
0113         names = sorted(n for n in os.listdir(SDF_DIR) if n.endswith('.sdf'))
0114     except OSError as exc:
0115         return {'error': 'cannot list {}: {}'.format(SDF_DIR, exc)}
0116     return {n: parse_sdf(os.path.join(SDF_DIR, n)) for n in names}
0117 
0118 
0119 def queue_map():
0120     """Which queue uses which submit description, and its worker limits."""
0121     try:
0122         with open(QUEUE_CONFIG) as handle:
0123             config = json.load(handle)
0124     except (OSError, ValueError) as exc:
0125         return {'error': 'cannot read {}: {}'.format(QUEUE_CONFIG, exc)}
0126     out = {}
0127     for queue, block in config.items():
0128         blob = json.dumps(block)
0129         found = re.findall(r'submit_pilot2[\w\-.]*\.sdf', blob)
0130         if not found:
0131             continue
0132         submitter = block.get('submitter') or {}
0133         out[queue] = {
0134             'submit_description': sorted(set(found))[0],
0135             'max_workers': submitter.get('maxWorkers'),
0136             'max_new_workers_per_cycle':
0137                 submitter.get('maxNewWorkersPerCycle'),
0138             'workflow': block.get('workflow'),
0139         }
0140     return out
0141 
0142 
0143 def pool(requirements, pairs):
0144     """What the pool offers, what the queue admits, what we exclude."""
0145     out = {}
0146     total, err = run(['condor_status', '-af', 'Name'])
0147     if err:
0148         return {'error': 'condor_status unavailable: {}'.format(err)}
0149     out['slots_total'] = len(total.strip().splitlines()) if total.strip() else 0
0150 
0151     if requirements:
0152         admitted, err = run(['condor_status', '-constraint', requirements,
0153                              '-af', 'Name'])
0154         out['slots_admitted'] = (
0155             len(admitted.strip().splitlines()) if admitted and admitted.strip()
0156             else (None if err else 0))
0157         if err:
0158             out['slots_admitted_error'] = err
0159 
0160     if pairs:
0161         terms = []
0162         for site in sorted(set(p['site'] for p in pairs)):
0163             names = '|'.join(p['node'] for p in pairs if p['site'] == site)
0164             terms.append('(GLIDEIN_Site =?= "{}" && regexp("^({})([.]|$)", '
0165                          'Machine) =?= True)'.format(site, names))
0166         banned = ' || '.join(terms)
0167         hit, err = run(['condor_status', '-constraint', banned,
0168                         '-af', 'Machine', 'GLIDEIN_Site'])
0169         if err:
0170             out['slots_excluded_error'] = err
0171         else:
0172             lines = [ln.strip() for ln in (hit or '').splitlines() if ln.strip()]
0173             out['slots_excluded_now'] = len(lines)
0174             out['excluded_present'] = sorted(set(lines))
0175 
0176     sites, err = run(['condor_status', '-af', 'GLIDEIN_Site'])
0177     if err:
0178         out['sites_error'] = err
0179     else:
0180         counts = {}
0181         for line in (sites or '').splitlines():
0182             name = line.strip() or 'unknown'
0183             counts[name] = counts.get(name, 0) + 1
0184         out['slots_by_site'] = counts
0185     return out
0186 
0187 
0188 def schedd():
0189     """What the local schedd is holding, by queue where it is named."""
0190     out, err = run(['condor_q', '-all', '-af', 'JobStatus', 'HoldReason',
0191                     'MATCH_EXP_JOB_GLIDEIN_ResourceName'])
0192     if err:
0193         return {'error': 'condor_q unavailable: {}'.format(err)}
0194     states = {1: 'idle', 2: 'running', 5: 'held'}
0195     counts = {'idle': 0, 'running': 0, 'held': 0, 'other': 0}
0196     held_reasons = {}
0197     for line in (out or '').splitlines():
0198         parts = line.split(None, 1)
0199         if not parts:
0200             continue
0201         try:
0202             code = int(parts[0])
0203         except ValueError:
0204             continue
0205         counts[states.get(code, 'other')] = counts.get(
0206             states.get(code, 'other'), 0) + 1
0207         if code == 5 and len(parts) > 1:
0208             reason = parts[1].strip()[:300]
0209             held_reasons[reason] = held_reasons.get(reason, 0) + 1
0210     out_rec = {'workers': counts}
0211     if held_reasons:
0212         out_rec['held_reasons'] = held_reasons
0213     return out_rec
0214 
0215 
0216 IDLE_ANALYZE_MIN = 100
0217 ANALYSIS_LOG = 'match-analysis.jsonl'
0218 ANALYSIS_KEEP_S = 7 * 24 * 3600
0219 QUEUE_ARG_RE = re.compile(r'-q (\S+)')
0220 ANALYSIS_PATTERNS = (
0221     ('slots', re.compile(r'Of (\d+) slots on (\d+) machines')),
0222     ('rejected_by_job', re.compile(r'(\d+) slots are rejected by your job')),
0223     ('reject_job', re.compile(r'(\d+) slots reject your job')),
0224     ('willing', re.compile(r'(\d+) slots match and are willing')),
0225     ('if_drained', re.compile(r'(\d+) slots would match if drained')),
0226     ('last_match', re.compile(r'Last successful match: (.+)$')),
0227     ('last_failed_match', re.compile(r'Last failed match: (.+)$')),
0228     ('failed_reason', re.compile(r'^\s*Reason: (.+)$')),
0229 )
0230 
0231 
0232 def _analysis(job_id):
0233     """condor's own account of why one idle pilot is not running: the
0234     pool's slots split by who rejects whom, and the last match times."""
0235     out, err = run(['condor_q', '-better-analyze', job_id])
0236     if err:
0237         return {'error': err}
0238     found = {}
0239     for line in (out or '').splitlines():
0240         for key, pattern in ANALYSIS_PATTERNS:
0241             m = pattern.search(line)
0242             if m and key not in found:
0243                 found[key] = (int(m.group(1)) if m.group(1).isdigit()
0244                               else m.group(1).strip())
0245     return found
0246 
0247 
0248 def idle_analysis(state_dir):
0249     """Per OSG queue with at least IDLE_ANALYZE_MIN idle pilots: the idle
0250     count, the oldest idle pilot's age, and condor's match analysis of
0251     that pilot. Each run's result is also appended to a seven-day log in
0252     the state directory, since the schedd keeps about an hour and a half
0253     of history and a stall outlasts it (BNL_OSG_EPIC_PROD_1, 10/6 22:00 to
0254     10/7 03:00 UTC, unexplained once the history had rotated)."""
0255     out, err = run(['condor_q', '-all', '-constraint', 'JobStatus == 1',
0256                     '-af', 'ClusterId', 'ProcId', 'QDate', 'Args'])
0257     if err:
0258         return {'error': 'condor_q unavailable: {}'.format(err)}
0259     queues = {}
0260     for line in (out or '').splitlines():
0261         parts = line.split(None, 3)
0262         if len(parts) < 4:
0263             continue
0264         m = QUEUE_ARG_RE.search(parts[3])
0265         if not m:
0266             continue
0267         q = queues.setdefault(m.group(1), {'idle': 0, 'oldest_qdate': None})
0268         q['idle'] += 1
0269         try:
0270             qdate = int(parts[2])
0271         except ValueError:
0272             continue
0273         if q['oldest_qdate'] is None or qdate < q['oldest_qdate']:
0274             q['oldest_qdate'] = qdate
0275             q['job'] = '{}.{}'.format(parts[0], parts[1])
0276     now = time.time()
0277     result = {}
0278     for name, q in sorted(queues.items()):
0279         entry = {'idle': q['idle']}
0280         if q['oldest_qdate'] is not None:
0281             entry['oldest_idle_minutes'] = int((now - q['oldest_qdate']) / 60)
0282         if q['idle'] >= IDLE_ANALYZE_MIN and q.get('job'):
0283             entry['job'] = q['job']
0284             entry['analysis'] = _analysis(q['job'])
0285         result[name] = entry
0286     _append_analysis(state_dir, {'at': now_iso(), 'queues': result})
0287     return result
0288 
0289 
0290 def _append_analysis(state_dir, line):
0291     """Append one run's analysis and keep seven days of them."""
0292     path = os.path.join(state_dir, ANALYSIS_LOG)
0293     cutoff = datetime.utcfromtimestamp(time.time() - ANALYSIS_KEEP_S).strftime(
0294         '%Y-%m-%dT%H:%M:%SZ')
0295     try:
0296         os.makedirs(state_dir, exist_ok=True)
0297         kept = []
0298         if os.path.exists(path):
0299             with open(path) as handle:
0300                 for raw in handle:
0301                     try:
0302                         if json.loads(raw).get('at', '') >= cutoff:
0303                             kept.append(raw if raw.endswith('\n') else raw + '\n')
0304                     except ValueError:
0305                         continue
0306         kept.append(json.dumps(line, sort_keys=True) + '\n')
0307         tmp = path + '.tmp'
0308         with open(tmp, 'w') as handle:
0309             handle.writelines(kept)
0310         os.replace(tmp, path)
0311     except OSError as exc:
0312         print('could not write the match analysis log: {}'.format(exc),
0313               file=sys.stderr)
0314 
0315 
0316 NEGOTIATOR_TIMEOUT_S = 30
0317 
0318 
0319 def _config_value(name):
0320     """One condor configuration value, or None when it is not defined."""
0321     out, err = run(['condor_config_val', name], timeout=NEGOTIATOR_TIMEOUT_S)
0322     return None if err else (out or '').strip() or None
0323 
0324 
0325 def negotiators():
0326     """Each pool negotiator's last cycle, and this schedd's submitters it
0327     held at their fair-share limit in that cycle. A held submitter gets
0328     no new slots in that pool, whatever its free slots would accept:
0329     on 10/24 the OSPool negotiator held atlpan@osgsub01 while free
0330     UChicago slots matched our pilot both ways. One small ad per
0331     negotiator, about 0.2 s a pool; the pools are the schedd's own
0332     collector and the pools it flocks to."""
0333     host = _config_value('FULL_HOSTNAME')
0334     pools = []
0335     for name in ('COLLECTOR_HOST', 'FLOCK_TO'):
0336         for pool in (_config_value(name) or '').replace(',', ' ').split():
0337             if pool not in pools:
0338                 pools.append(pool)
0339     result = {'pools': pools, 'submitter_host': host, 'negotiators': [],
0340               'errors': {}}
0341     if not host:
0342         result['errors']['FULL_HOSTNAME'] = 'not defined'
0343         return result
0344     seen = set()
0345     for pool in pools:
0346         out, err = run(['condor_status', '-pool', pool, '-negotiator', '-af',
0347                         'Name', 'LastNegotiationCycleTime0',
0348                         'LastNegotiationCycleSubmittersShareLimit0'],
0349                        timeout=NEGOTIATOR_TIMEOUT_S)
0350         if err:
0351             result['errors'][pool] = err
0352             continue
0353         for line in (out or '').splitlines():
0354             parts = line.split(None, 2)
0355             if len(parts) < 2 or parts[0] in seen:
0356                 continue
0357             seen.add(parts[0])
0358             limited = [] if len(parts) < 3 or parts[2] == 'undefined' else [
0359                 s.strip() for s in parts[2].split(',')]
0360             try:
0361                 cycle_at = datetime.utcfromtimestamp(int(parts[1])).strftime(
0362                     '%Y-%m-%dT%H:%M:%SZ')
0363             except ValueError:
0364                 cycle_at = None
0365             result['negotiators'].append({
0366                 'name': parts[0], 'pool': pool, 'cycle_at': cycle_at,
0367                 'share_limited': [s for s in limited
0368                                   if s.endswith('@' + host)]})
0369     return result
0370 
0371 
0372 HARVESTER_LOG_DIR = '/var/log/harvester'
0373 
0374 
0375 def _proc_present(pattern):
0376     """Whether any process matches, without requiring pgrep to exist."""
0377     out, err = run(['pgrep', '-f', pattern], timeout=15)
0378     if err and 'exit 1' in err:          # pgrep exits 1 when nothing matches
0379         return False
0380     if err:
0381         return None
0382     return bool((out or '').strip())
0383 
0384 
0385 def health():
0386     """The submit host's own state, for the platform component.
0387 
0388     Daemon liveness and log freshness on the idiom the PanDA server host
0389     reporter uses (docs/SNAPPER_PLATFORM.md, Server host): a daemon is
0390     named by its log, and the age of that log's last write is what says
0391     whether it is working. Held workers with their reasons are the
0392     signal this host alone carries: a site refusing every submission
0393     shows here and nowhere else.
0394     """
0395     out = {}
0396 
0397     daemons = {}
0398     try:
0399         names = [n for n in os.listdir(HARVESTER_LOG_DIR)
0400                  if n.startswith('panda-') and n.endswith('.log')]
0401     except OSError as exc:
0402         daemons = {'error': 'cannot list {}: {}'.format(HARVESTER_LOG_DIR, exc)}
0403         names = []
0404     now = time.time()
0405     for name in sorted(names):
0406         label = name[len('panda-'):-len('.log')]
0407         path = os.path.join(HARVESTER_LOG_DIR, name)
0408         try:
0409             daemons[label] = {
0410                 'log_age_seconds': int(now - os.path.getmtime(path)),
0411                 'log_bytes': os.path.getsize(path),
0412             }
0413         except OSError as exc:
0414             daemons[label] = {'error': str(exc)}
0415     out['harvester_daemons'] = daemons
0416     out['harvester_process'] = _proc_present('panda_harvester-uwsgi')
0417     out['schedd_process'] = _proc_present('condor_schedd')
0418 
0419     try:
0420         with open('/proc/loadavg') as handle:
0421             parts = handle.read().split()
0422         out['load'] = {'1m': float(parts[0]), '5m': float(parts[1]),
0423                        '15m': float(parts[2])}
0424     except (OSError, ValueError, IndexError) as exc:
0425         out['load_error'] = str(exc)
0426 
0427     mem = {}
0428     try:
0429         with open('/proc/meminfo') as handle:
0430             for line in handle:
0431                 key, _, rest = line.partition(':')
0432                 if key in ('MemTotal', 'MemAvailable', 'SwapTotal',
0433                            'SwapFree'):
0434                     mem[key] = int(rest.split()[0])
0435         out['memory_kb'] = mem
0436     except (OSError, ValueError, IndexError) as exc:
0437         out['memory_error'] = str(exc)
0438 
0439     volumes = {}
0440     for mount in ('/', '/var'):
0441         try:
0442             st = os.statvfs(mount)
0443             total = st.f_blocks * st.f_frsize
0444             free = st.f_bavail * st.f_frsize
0445             volumes[mount] = {
0446                 'total_bytes': total, 'free_bytes': free,
0447                 'used_percent': round(100.0 * (total - free) / total, 1)
0448                 if total else None}
0449         except OSError as exc:
0450             volumes[mount] = {'error': str(exc)}
0451     out['volumes'] = volumes
0452     return out
0453 
0454 
0455 def collect(with_pool=True, state_dir=DEFAULT_STATE):
0456     """The record this run delivers."""
0457     record = {'host': HOST, 'collected_at': now_iso(),
0458               'reporter_version': '1.2'}
0459     started = time.time()
0460     record['submit_descriptions'] = submit_descriptions()
0461     record['queues'] = queue_map()
0462 
0463     # The production queues share one description; the pool figures are
0464     # taken against it, since that is what governs where pilots land.
0465     prod = (record['submit_descriptions'] or {}).get(
0466         'submit_pilot2_push_bnl_osg.sdf') or {}
0467     record['exclusions'] = {
0468         'submit_description': prod.get('file'),
0469         'modified': prod.get('modified'),
0470         'sites': prod.get('excluded_sites'),
0471         'site_nodes': prod.get('excluded_site_nodes'),
0472         'queues': sorted(q for q, v in (record['queues'] or {}).items()
0473                          if isinstance(v, dict)
0474                          and v.get('submit_description')
0475                          == 'submit_pilot2_push_bnl_osg.sdf'),
0476     }
0477     record['health'] = health()
0478     if with_pool:
0479         record['pool'] = pool(prod.get('requirements'),
0480                               prod.get('excluded_site_nodes') or [])
0481         record['schedd'] = schedd()
0482         record['idle_analysis'] = idle_analysis(state_dir)
0483         record['negotiators'] = negotiators()
0484     record['collect_seconds'] = round(time.time() - started, 2)
0485     return record
0486 
0487 
0488 def load_env(path):
0489     """Read KEY=value lines from an environment file, if there is one."""
0490     if not path or not os.path.exists(path):
0491         return
0492     with open(path) as handle:
0493         for line in handle:
0494             line = line.strip()
0495             if not line or line.startswith('#') or '=' not in line:
0496                 continue
0497             key, value = line.split('=', 1)
0498             os.environ.setdefault(key.strip(), value.strip().strip('"\''))
0499 
0500 
0501 def post(record, url, token):
0502     """Deliver one record. Returns None on success, the reason on failure."""
0503     import urllib.request
0504     body = json.dumps(record).encode('utf-8')
0505     endpoint = '{}/api/host-reports/{}/'.format(url.rstrip('/'), HOST)
0506     request = urllib.request.Request(
0507         endpoint, data=body, method='POST',
0508         headers={'Content-Type': 'application/json',
0509                  'Authorization': 'Token {}'.format(token)})
0510     # The monitor is inside the facility and serves a certificate this
0511     # host's trust store does not carry; the token is the authentication.
0512     context = ssl._create_unverified_context()
0513     try:
0514         with urllib.request.urlopen(request, timeout=POST_TIMEOUT_S,
0515                                     context=context) as response:
0516             response.read()
0517         return None
0518     except Exception as exc:                                  # noqa: BLE001
0519         return '{}: {}'.format(exc.__class__.__name__, exc)
0520 
0521 
0522 def buffered(state_dir):
0523     """Records held from earlier runs the monitor could not take."""
0524     try:
0525         names = sorted(os.listdir(state_dir))
0526     except OSError:
0527         return []
0528     return [os.path.join(state_dir, n) for n in names
0529             if n.startswith('record-') and n.endswith('.json')]
0530 
0531 
0532 def buffer_record(state_dir, record):
0533     """Hold a record for the next run, keeping the buffer bounded."""
0534     try:
0535         os.makedirs(state_dir, exist_ok=True)
0536         path = os.path.join(state_dir, 'record-{}.json'.format(
0537             record['collected_at'].replace(':', '')))
0538         with open(path, 'w') as handle:
0539             json.dump(record, handle)
0540         held = buffered(state_dir)
0541         for stale in held[:-BUFFER_KEEP]:
0542             os.unlink(stale)
0543     except OSError as exc:
0544         print('could not buffer the record: {}'.format(exc), file=sys.stderr)
0545 
0546 
0547 def main():
0548     parser = argparse.ArgumentParser()
0549     parser.add_argument('--print', dest='show', action='store_true',
0550                         help='print the record, post nothing')
0551     parser.add_argument('--no-pool', dest='pool', action='store_false',
0552                         help='skip the condor queries')
0553     parser.add_argument('--env', default=os.path.expanduser(
0554         '~/.swf-osgsub-reporter.env'))
0555     parser.add_argument('--state', default=DEFAULT_STATE)
0556     args = parser.parse_args()
0557 
0558     record = collect(with_pool=args.pool, state_dir=args.state)
0559     if args.show:
0560         print(json.dumps(record, indent=2, sort_keys=True))
0561         return 0
0562 
0563     load_env(args.env)
0564     url = os.environ.get('SWF_MONITOR_URL')
0565     token = os.environ.get('SWF_REPORT_TOKEN')
0566     if not url or not token:
0567         print('SWF_MONITOR_URL and SWF_REPORT_TOKEN are required '
0568               '(environment or --env file)', file=sys.stderr)
0569         buffer_record(args.state, record)
0570         return 2
0571 
0572     # The backlog first, oldest first, so a reader sees the sequence.
0573     for path in buffered(args.state):
0574         try:
0575             with open(path) as handle:
0576                 held = json.load(handle)
0577         except (OSError, ValueError):
0578             os.unlink(path)
0579             continue
0580         if post(held, url, token) is None:
0581             os.unlink(path)
0582         else:
0583             break
0584 
0585     failure = post(record, url, token)
0586     if failure:
0587         print('post failed, record buffered: {}'.format(failure),
0588               file=sys.stderr)
0589         buffer_record(args.state, record)
0590         return 1
0591     excl = record.get('exclusions') or {}
0592     print('reported: {} site(s), {} node(s) excluded, {} slot(s) removed now'
0593           .format(len(excl.get('sites') or []),
0594                   len(excl.get('site_nodes') or []),
0595                   (record.get('pool') or {}).get('slots_excluded_now', '?')))
0596     return 0
0597 
0598 
0599 if __name__ == '__main__':
0600     sys.exit(main())