Harden portable Redmine CLI runtime and cache writes
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Portable Redmine entry points. Configuration is data, never shell code."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
class ConfigError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def env_values(path):
|
||||
values = {}
|
||||
try:
|
||||
lines = path.read_text(encoding='utf-8').splitlines()
|
||||
except OSError as exc:
|
||||
raise ConfigError(f'Cannot read env file {path}: {exc.strerror}') from exc
|
||||
for n, line in enumerate(lines, 1):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
match = re.fullmatch(r'(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)', line)
|
||||
if not match:
|
||||
raise ConfigError(f'Malformed env assignment in {path}:{n}; expected NAME=value (no shell commands).')
|
||||
key, value = match.groups()
|
||||
if value.startswith(('"', "'")):
|
||||
quote = value[0]
|
||||
end = value.find(quote, 1)
|
||||
if end < 0 or (value[end+1:].strip() and not value[end+1:].lstrip().startswith('#')):
|
||||
raise ConfigError(f'Malformed quoted value in {path}:{n}.')
|
||||
value = value[1:end]
|
||||
else:
|
||||
value = re.split(r'\s+#', value, maxsplit=1)[0].strip()
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def absolute(value, base=None):
|
||||
p = Path(str(value)).expanduser()
|
||||
return (p if p.is_absolute() else (base or Path.cwd()) / p).resolve()
|
||||
|
||||
|
||||
def split_options(argv):
|
||||
values, rest = {}, []
|
||||
takes = {'--env-file','--base-cache','--base-url','--project','--cache-dir','--cache','--api-key-file','--mode'}
|
||||
switches = {'--show-config','--doctor','--doctor-nmi'}
|
||||
i, saw_command = 0, False
|
||||
while i < len(argv):
|
||||
token = argv[i]
|
||||
flag, sep, val = token.partition('=')
|
||||
if token == '--':
|
||||
rest.extend(argv[i:]); break
|
||||
if flag in switches:
|
||||
if sep:
|
||||
raise ConfigError(f'{flag} does not take a value.')
|
||||
values[flag] = True
|
||||
elif flag in takes or (flag == '--config' and not saw_command):
|
||||
if not sep:
|
||||
i += 1
|
||||
if i >= len(argv) or argv[i].startswith('--'):
|
||||
raise ConfigError(f'{flag} requires a value.')
|
||||
val = argv[i]
|
||||
if not val.strip():
|
||||
raise ConfigError(f'{flag} cannot be empty.')
|
||||
values[flag] = val
|
||||
else:
|
||||
rest.append(token)
|
||||
if not token.startswith('-'):
|
||||
saw_command = True
|
||||
i += 1
|
||||
return values, rest
|
||||
|
||||
|
||||
def resolve(tool, lane, options, environ=None):
|
||||
env = dict(os.environ if environ is None else environ)
|
||||
if tool == 'contacts' and '--cache-dir' in options:
|
||||
raise ConfigError('Contacts uses --cache FILE, not --cache-dir. Use --base-cache DIR for a common root.')
|
||||
if tool == 'tickets' and '--cache' in options:
|
||||
raise ConfigError('Tickets uses --cache-dir DIR, not --cache. Use REDMINE_CONTACTS_CACHE for the full CRM handoff.')
|
||||
mode = options.get('--mode', lane)
|
||||
mode = {'production':'live','prod':'live','development':'dev'}.get(mode, mode)
|
||||
if mode not in {'live','dev'}:
|
||||
raise ConfigError('--mode must be production/live or dev.')
|
||||
home = Path(env.get('HOME', str(Path.home())))
|
||||
candidates = ([ROOT/'.env.dev', ROOT/'bin/.env.dev', home/'.redmine.dev'] if mode == 'dev'
|
||||
else [ROOT/'.env', ROOT/'bin/.env', home/'.redmine.live'])
|
||||
explicit = options.get('--env-file') or env.get('REDMINE_ENV_FILE')
|
||||
selected = absolute(explicit) if explicit else next((p for p in candidates if p.is_file()), None)
|
||||
data = env_values(selected) if selected else {}
|
||||
sources = {}
|
||||
config = {}
|
||||
config_path = None
|
||||
if '--config' in options:
|
||||
if tool != 'tickets':
|
||||
raise ConfigError('--config is a tickets option; use --env-file for contacts.')
|
||||
config_path = absolute(options['--config'])
|
||||
try:
|
||||
text = config_path.read_text(encoding='utf-8')
|
||||
try:
|
||||
raw = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as exc:
|
||||
raise ConfigError('YAML config needs existing PyYAML; use JSON instead.') from exc
|
||||
raw = yaml.safe_load(text)
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError(f'Config must be an object: {config_path}')
|
||||
raw = raw.get('redmine_tickets', raw)
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError(f'redmine_tickets must be an object: {config_path}')
|
||||
config = {str(k).replace('_','-'):v for k,v in raw.items()}
|
||||
except OSError as exc:
|
||||
raise ConfigError(f'Cannot read config {config_path}: {exc.strerror}') from exc
|
||||
except ValueError as exc:
|
||||
raise ConfigError(f'Invalid config file: {config_path}') from exc
|
||||
|
||||
def pick(name, flag, keys, default=None, cfg=None):
|
||||
if flag and flag in options:
|
||||
sources[name] = f'CLI {flag}'
|
||||
return options[flag]
|
||||
for mapping, source in [(env,'environment'), (data,f'env file {selected}')]:
|
||||
for key in keys:
|
||||
if key in mapping:
|
||||
sources[name] = f'{source}: {key}'
|
||||
return mapping[key]
|
||||
if cfg and cfg in config:
|
||||
sources[name] = f'config {config_path}: {cfg}'
|
||||
return config[cfg]
|
||||
sources[name] = 'default'
|
||||
return default
|
||||
|
||||
def path_value(name, value):
|
||||
if value is None or str(value).strip() == '':
|
||||
raise ConfigError(f'{name} cannot be empty; source: {sources.get(name)}')
|
||||
src = sources.get(name, '')
|
||||
base = selected.parent if src.startswith('env file ') else (config_path.parent if src.startswith('config ') else None)
|
||||
return absolute(value, base)
|
||||
|
||||
url = str(pick('base_url','--base-url',['REDMINE_BASE_URL','REDMINE_ENDPOINT'],
|
||||
'https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170', 'base-url')).rstrip('/')
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {'http','https'} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ConfigError('REDMINE_BASE_URL/--base-url must be an http(s) URL without credentials/query/fragment.')
|
||||
project = str(pick('project','--project',['REDMINE_PROJECT'],'customer-service','project'))
|
||||
if not project.strip() or project in {'.','..'} or '/' in project or '\\' in project:
|
||||
raise ConfigError('REDMINE_PROJECT/--project must be a project identifier, not a path.')
|
||||
base = path_value('base_cache', pick('base_cache','--base-cache',['REDMINE_BASE_CACHE'],ROOT/('cache-dev' if mode == 'dev' else 'cache')))
|
||||
slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+','_',s)
|
||||
host_project = slug(parsed.netloc + parsed.path) + '_' + slug(project)
|
||||
is_default = project == 'customer-service' and url == ('https://rm.ldrprep.com' if mode == 'live' else 'http://192.168.50.170')
|
||||
ticket_default = base / ('redmine_tickets' if is_default else 'redmine_tickets_' + slug(parsed.scheme + '_' + host_project))
|
||||
tickets = path_value('ticket_cache', pick('ticket_cache','--cache-dir',['REDMINE_TICKETS_CACHE','REDMINE_CACHE_DIR'],ticket_default,'cache-dir'))
|
||||
# Preserve the historical single-file snapshot when it already exists.
|
||||
# Fresh installs get a namespaced file under redmine_contacts/ so different
|
||||
# hosts/projects never collide. This is compatibility, not a migration.
|
||||
legacy_contacts = base / 'redmine_contacts'
|
||||
contacts_default = legacy_contacts if legacy_contacts.is_file() and is_default else legacy_contacts / (host_project+'.json')
|
||||
contacts = path_value('contacts_cache', pick('contacts_cache','--cache',['REDMINE_CONTACTS_CACHE'],contacts_default))
|
||||
if contacts.is_dir():
|
||||
raise ConfigError(f'Contacts --cache expects a JSON file, not a directory: {contacts}')
|
||||
key = pick('api_key',None,['REDMINE_API_KEY'])
|
||||
key_file = pick('api_key_file','--api-key-file',['REDMINE_API_KEY_FILE'],None,'api-key-file')
|
||||
# Explicit key-file is a CLI credential override. Config key files are fallback.
|
||||
if key_file and ('--api-key-file' in options or not key):
|
||||
key_path = path_value('api_key_file',key_file)
|
||||
try:
|
||||
key = key_path.read_text(encoding='utf-8').strip()
|
||||
except OSError as exc:
|
||||
raise ConfigError(f'Cannot read API key file {key_path}: {exc.strerror}') from exc
|
||||
sources['api_key'] = f'key file {key_path}'
|
||||
key = str(key or '').strip()
|
||||
child_env = dict(env)
|
||||
for k,v in data.items():
|
||||
child_env.setdefault(k,v)
|
||||
child_env.update(REDMINE_BASE_URL=url, REDMINE_PROJECT=project, REDMINE_BASE_CACHE=str(base),
|
||||
REDMINE_CONTACTS_CACHE=str(contacts), REDMINE_TICKETS_CACHE=str(tickets), REDMINE_API_KEY=key)
|
||||
if selected:
|
||||
child_env['REDMINE_ENV_FILE'] = str(selected)
|
||||
# Relative bridge files in env profiles are relative to that profile.
|
||||
for k in ['NMI_ENV_FILE','NMI_CLI']:
|
||||
if k not in env and data.get(k) and (k == 'NMI_ENV_FILE' or '/' in data[k]):
|
||||
child_env[k] = str(absolute(data[k],selected.parent))
|
||||
child_env.setdefault('NMI_MODE','production' if mode == 'live' else 'dev')
|
||||
report = {'mode':mode,'base_url':url,'project':project,'base_cache':str(base),
|
||||
'ticket_cache':str(tickets),'contacts_cache':str(contacts),
|
||||
'ticket_contacts_cache':str(tickets/'contacts'),
|
||||
'contacts_cache_layout':'legacy-single-file' if contacts == legacy_contacts else 'namespaced-file',
|
||||
'cache_note':'Full CRM snapshot and ticket contacts mirror are different formats; existing legacy snapshot is preserved; no migration.',
|
||||
'env_file':str(selected) if selected else None,
|
||||
'searched_env_paths':[str(selected)] if explicit else [str(p) for p in candidates],
|
||||
'config_file':str(config_path) if config_path else None,
|
||||
'api_key_present':bool(key),'sources':sources,
|
||||
'nmi_mode':child_env['NMI_MODE'], 'lock_timeout':child_env.get('REDMINE_LOCK_TIMEOUT','60')}
|
||||
injected = ['--base-url',url,'--project',project, '--cache' if tool=='contacts' else '--cache-dir',str(contacts if tool=='contacts' else tickets)]
|
||||
if tool == 'tickets' and config.get('allow-prod-writes') in (True,'true','yes',1,'1'):
|
||||
injected.append('--allow-prod-writes')
|
||||
return report, child_env, injected
|
||||
|
||||
|
||||
HELP = '''Wrapper configuration (same for rcontacts-live, rtickets-live / rtl, rtd):
|
||||
--env-file PATH Explicit NAME=value file; REDMINE_ENV_FILE also works
|
||||
--base-cache DIR Common root; REDMINE_BASE_CACHE (default checkout/cache)
|
||||
--show-config Redacted effective configuration and source paths (offline)
|
||||
--doctor Validate local config, key presence and cache access (offline)
|
||||
--doctor-nmi Run the actual NMI CLI's offline doctor
|
||||
--mode dev Explicit development; rtd defaults to this
|
||||
Precedence: CLI > exported environment > selected env file > ticket config > defaults.
|
||||
Live env search: checkout/.env, checkout/bin/.env, ~/.redmine.live (first existing).
|
||||
Dev env search: checkout/.env.dev, checkout/bin/.env.dev, ~/.redmine.dev.
|
||||
Required online: REDMINE_API_KEY (or tickets --api-key-file).
|
||||
Optional: REDMINE_BASE_URL, REDMINE_PROJECT, REDMINE_BASE_CACHE,
|
||||
REDMINE_CONTACTS_CACHE (JSON file), REDMINE_TICKETS_CACHE (directory).
|
||||
NMI: NMI_CLI, NMI_ENV_FILE, NMI_MODE; use --doctor-nmi for required credential names.
|
||||
Exact --cache (contacts file) / --cache-dir (tickets directory) override defaults.
|
||||
Relative CLI/exported paths use cwd; file-defined paths use that file's directory.
|
||||
Existing apply/customer-email/production-write safety gates are unchanged.
|
||||
'''
|
||||
|
||||
|
||||
def contacts_handoff(args, contact_id):
|
||||
"""Copy/paste-safe bridge to the full CRM, without putting secrets in argv."""
|
||||
url = str(args.base_url)
|
||||
project = str(args.project)
|
||||
env_file = os.environ.get('REDMINE_ENV_FILE')
|
||||
cache = os.environ.get('REDMINE_CONTACTS_CACHE')
|
||||
if not cache:
|
||||
parsed = urlsplit(url)
|
||||
slug = lambda s: re.sub(r'[^A-Za-z0-9_.-]+', '_', s)
|
||||
base = Path(os.environ.get('REDMINE_BASE_CACHE') or Path(args.cache_dir).parent)
|
||||
cache = str(base / 'redmine_contacts' / (slug(parsed.netloc + parsed.path) + '_' + slug(project) + '.json'))
|
||||
cmd = [str(ROOT / 'bin/rcontacts-live')]
|
||||
if env_file:
|
||||
cmd.extend(['--env-file', env_file])
|
||||
if getattr(args, 'api_key_file', None):
|
||||
cmd.extend(['--api-key-file', str(absolute(args.api_key_file))])
|
||||
cmd.extend(['--base-url', url, '--project', project, '--cache', str(absolute(cache)), 'show', '--contact-id', str(contact_id)])
|
||||
return shlex.join(cmd)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
tool, lane, *args = argv
|
||||
script = ROOT / ('redmine_contacts.py' if tool == 'contacts' else 'redmine_tickets.py')
|
||||
try:
|
||||
opts, rest = split_options(args)
|
||||
if not args or '--help' in rest or '-h' in rest:
|
||||
if not args or rest in (['--help'],['-h']):
|
||||
print(HELP, flush=True)
|
||||
os.execv(sys.executable,[sys.executable,str(script),*(rest or ['--help'])])
|
||||
report, child_env, injected = resolve(tool,lane,opts)
|
||||
if report['mode'] == 'dev':
|
||||
print(f"DEVELOPMENT runtime: {report['base_url']} (NMI_MODE={report['nmi_mode']})",file=sys.stderr)
|
||||
if opts.get('--doctor-nmi'):
|
||||
from redmine_ticket_nmi import discover_nmi_bin
|
||||
import shutil
|
||||
php = shutil.which('php',path=child_env.get('PATH'))
|
||||
if not php:
|
||||
raise ConfigError('PHP CLI is not on PATH; required by NMI.')
|
||||
binary = discover_nmi_bin(environ=child_env)
|
||||
if not binary.is_file():
|
||||
raise ConfigError(f'NMI CLI not found: {binary}; set NMI_CLI.')
|
||||
try:
|
||||
proc = subprocess.run([php,str(binary),'--doctor','--json'],env=child_env,timeout=15)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise ConfigError('NMI offline doctor timed out after 15 seconds.') from exc
|
||||
return proc.returncode
|
||||
if opts.get('--show-config') or opts.get('--doctor'):
|
||||
problems = []
|
||||
if not report['api_key_present']:
|
||||
problems.append('REDMINE_API_KEY is missing/empty; set it in the environment or a listed env file (or use --api-key-file).')
|
||||
import math
|
||||
try:
|
||||
seconds = float(report['lock_timeout'])
|
||||
if not math.isfinite(seconds) or seconds < 0:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
problems.append('REDMINE_LOCK_TIMEOUT must be a finite nonnegative number of seconds.')
|
||||
for target in [Path(report['ticket_cache']),Path(report['contacts_cache']).parent]:
|
||||
existing=target
|
||||
while not existing.exists() and existing != existing.parent:
|
||||
existing=existing.parent
|
||||
if not existing.is_dir() or not os.access(existing,os.W_OK|os.X_OK):
|
||||
problems.append(f'Cache parent is not writable: {existing}')
|
||||
report.update(ready=not problems,problems=problems,network_checked=False)
|
||||
print(json.dumps(report,indent=2))
|
||||
return 2 if opts.get('--doctor') and problems else 0
|
||||
if not report['api_key_present']:
|
||||
local = {'search','view','walk','browse','tui','textual-tui','contacts-search','ticket-contact','control-tag','draft','materialize-queues','saved-filters','entities'}
|
||||
command = next((a for a in rest if not a.startswith('-')), '')
|
||||
cache_only = (tool=='contacts' and ('--offline-cache' in rest or '--source=cache' in rest or ['--source','cache']==rest[-2:])) or (tool=='tickets' and command in local)
|
||||
if not cache_only:
|
||||
paths = ', '.join(report['searched_env_paths'])
|
||||
raise ConfigError(f'REDMINE_API_KEY is missing/empty. Checked environment and {paths}. Set REDMINE_API_KEY, --env-file, or --api-key-file; run --doctor.')
|
||||
os.execve(sys.executable,[sys.executable,str(script),*injected,*rest],child_env)
|
||||
except (ConfigError,OSError) as exc:
|
||||
print(f'Configuration error: {exc}',file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user