Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ See the [automation guide](docs/automation.md) and
- Broadcast input, terminal search, transcripts, reconnect controls, and
configurable scrollback.
- Session-aware Files, Commands, Diagnostics, and Notes contexts.
- Named, account-owned workspace presets for pane layouts and saved hosts,
with explicit connections and visible session-continuity guidance.
- **Active Session Monitoring** for supported Linux resource and runtime data.
- **Expanded Diagnostics** for resource history, processes, systemd services,
and Docker containers.
Expand Down
2 changes: 2 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,8 @@ def enforce_security_feature_gate():
from .credential_service import CredentialService
from .onboarding import onboarding_blueprint
app.register_blueprint(onboarding_blueprint)
from .customer_workflows import customer_workflows_blueprint
app.register_blueprint(customer_workflows_blueprint)
from .overview_routes import overview_blueprint
app.register_blueprint(overview_blueprint)
from .discovery_routes import cancel_all as cancel_discovery, discovery_available, discovery_blueprint
Expand Down
44 changes: 43 additions & 1 deletion app/automation_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Authenticated HTTP control plane for native automation and write-only vault."""
import ipaddress
from flask import Blueprint, current_app, jsonify, request, redirect, url_for
import csv
import io
import json
from flask import Blueprint, current_app, jsonify, request, redirect, url_for, Response
from flask_login import current_user, login_required
import config
from .automation_store import AutomationError, runbooks, save_runbook, delete_runbook
Expand Down Expand Up @@ -272,3 +275,42 @@ def schedule_delete(schedule_id):
_secure()
current_app.extensions['automation_schedules'].pause(current_user.id, schedule_id, delete=True)
return jsonify(deleted=True)


@automation_blueprint.get('/api/automation/runs/<run_id>/export')
@login_required
def export_run(run_id):
_auth()
_enabled()
format_name = request.args.get('format', 'json')
if format_name not in {'json', 'csv'}:
raise AutomationError('invalid_request')
# Same validated, size-bounded, owner-fenced source as the run history UI.
run = next((item for item in service().history(current_user.id) if item['id'] == run_id), None)
if run is None:
raise AutomationError('not_found')
if format_name == 'json':
body = json.dumps(run, ensure_ascii=True)
mimetype = 'application/json'
else:
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(['run_id', 'workflow', 'created_at', 'finished_at', 'run_status',
'target_id', 'target', 'host', 'port', 'username', 'group', 'target_status', 'target_reason',
'step', 'status', 'reason', 'exit_code', 'duration', 'output_truncated', 'stdout', 'stderr'])
def safe(value):
value = '' if value is None else str(value)
return "'" + value if value.lstrip('\ufeff \t\r\n\v\f').startswith(('=', '+', '-', '@')) or value.startswith(('\t', '\r', '\n')) else value
for target in run['targets']:
for step in target['steps']:
values = [run['id'], run['name'], run['created_at'], run.get('finished_at'), run['status'],
target['id'], target['name'], target['host'], target['port'], target['username'], target['group'],
target['status'], target['reason'], step['name'], step['status'], step.get('reason'),
step['exit_code'], step['duration'], step.get('output_truncated'),
step.get('stdout', ''), step.get('stderr', '')]
writer.writerow([safe(value) for value in values])
body, mimetype = output.getvalue(), 'text/csv'
response = Response(body, mimetype=mimetype)
response.headers['Content-Disposition'] = f'attachment; filename="workflow-run.{format_name}"'
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
15 changes: 9 additions & 6 deletions app/automation_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,12 +239,15 @@ def tick(self, *, now=None):
# Recover ambiguous claims independently of the next cron occurrence.
# Revoking the revision fences surviving workers; only fresh approval
# can resume scheduling and the unknown outcome remains visible.
db.session.execute(update(AutomationSchedule).where(
AutomationSchedule.claim_id.is_not(None),
or_(AutomationSchedule.claimed_at.is_(None),
AutomationSchedule.claimed_at < now.timestamp() - 1860)
).values(enabled=False, last_status='unknown', claim_id=None,
claimed_at=None, revision=AutomationSchedule.revision + 1))
interrupted = [(row.id, row.revision, row.claim_id)
for row in AutomationSchedule.query.filter(AutomationSchedule.claim_id.is_not(None),
or_(AutomationSchedule.claimed_at.is_(None), AutomationSchedule.claimed_at < now.timestamp() - 1860)).all()]
for sid, rev, claim in interrupted:
db.session.execute(update(AutomationSchedule).where(
AutomationSchedule.id == sid, AutomationSchedule.revision == rev,
AutomationSchedule.claim_id == claim
).values(enabled=False, last_status='unknown', claim_id=None,
claimed_at=None, revision=rev + 1))
db.session.commit()
from .app_settings import is_automation_enabled
if not is_automation_enabled() or not self.app.extensions['runtime_lifecycle'].accepting_work():
Expand Down
42 changes: 24 additions & 18 deletions app/automation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,25 +305,31 @@ def valid(doc):
return read_document(path, dict(version=1, runs=[]), valid)

def history(self, user_id):
with user_data_access(user_id), storage_lock(f'automation-history:{user_id}'):
document = self._read_history(user_path(user_id, 'automation-runs.json'))
with self._lock:
recovered = False
# Match start()'s lock ordering. A new dispatch cannot appear between the
# active snapshot and persisting an orphan's one-time unknown transition.
with self._lock, user_data_access(user_id), storage_lock(f'automation-history:{user_id}'):
path = user_path(user_id, 'automation-runs.json')
document = self._read_history(path)
active = {v['record']['id']: deepcopy(v['record']) for uid, v in self._active.items() if uid == int(user_id)}
result = []
for record in document['runs']:
if record['id'] in active:
record = active[record['id']]
elif record['status'] not in TERMINAL:
record['status'] = 'unknown'
for target in record['targets']:
if target['status'] not in TERMINAL:
target['status'] = 'unknown'
for step in target['steps']:
if step['status'] not in TERMINAL:
step['status'] = 'unknown'
if 'reason' in step:
step['reason'] = 'run_interrupted'
result.append(record)
result = []
for record in document['runs']:
if record['id'] in active:
record = active[record['id']]
elif record['status'] not in TERMINAL:
recovered = True
record['status'] = 'unknown'
for target in record['targets']:
if target['status'] not in TERMINAL:
target['status'] = 'unknown'
for step in target['steps']:
if step['status'] not in TERMINAL:
step['status'] = 'unknown'
if 'reason' in step:
step['reason'] = 'run_interrupted'
result.append(record)
if recovered:
write_document(path, document)
return result

def delete_runs(self, user_id, run_ids=None):
Expand Down
5 changes: 4 additions & 1 deletion app/backup_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,10 @@ def _verify_staged_files(stage, manifest):

def restore_backup(archive, data_dir):
"""Restore an export; discard remote credentials even from older archives."""
return _restore_archive(archive, data_dir, include_remote_credentials=False)
result = _restore_archive(archive, data_dir, include_remote_credentials=False)
from .customer_workflows import mark_restore_recovery
mark_restore_recovery(data_dir)
return result


def _restore_recovery_snapshot(archive, data_dir):
Expand Down
148 changes: 148 additions & 0 deletions app/customer_workflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Account-owned workspace presets and post-restore recovery guidance."""
import json
import re
from datetime import datetime, timezone
from pathlib import Path

from flask import Blueprint, current_app, jsonify, request
from flask_login import current_user, login_required

import config
from .data_access import user_data_access
from .storage_utils import atomic_write_json, storage_lock
from .user_settings import get_user_settings, save_user_settings

customer_workflows_blueprint = Blueprint('customer_workflows', __name__)
_IDENTIFIER = re.compile(r'^[A-Za-z0-9_-]{1,128}$')


def valid_presets(value):
if not isinstance(value, list) or len(value) > 12:
return False
ids = set()
for preset in value:
if not isinstance(preset, dict) or set(preset) != {'id', 'name', 'layout', 'profile_ids'}:
return False
identifier, name = preset['id'], preset['name']
hosts, layout = preset['profile_ids'], preset['layout']
if (not isinstance(identifier, str) or not _IDENTIFIER.fullmatch(identifier)
or identifier in ids or not isinstance(name, str) or not name.strip()
or len(name) > 80 or any(not char.isprintable() for char in name)
or type(layout) is not int or layout not in (1, 2, 4)
or not isinstance(hosts, list) or len(hosts) > layout
or any(not isinstance(host, str) or not _IDENTIFIER.fullmatch(host) for host in hosts)
or len(set(hosts)) != len(hosts)):
return False
ids.add(identifier)
return True


@customer_workflows_blueprint.route('/api/account/workspace-presets', methods=['GET', 'PUT'])
@login_required
def workspace_presets():
from .profile_manager import load_profiles
with user_data_access(current_user.id):
if request.method == 'PUT':
body = request.get_json(silent=True)
if not isinstance(body, dict) or set(body) != {'presets'} or not valid_presets(body['presets']):
return jsonify(error='Invalid workspace presets'), 400
owned = {item['id'] for item in load_profiles(current_user.id)}
previous = {item['id']: set(item['profile_ids']) for item in
get_user_settings(current_user.id).get('workspace_presets', [])}
if any(host not in owned and host not in previous.get(preset['id'], set())
for preset in body['presets'] for host in preset['profile_ids']):
return jsonify(error='Choose existing hosts from your account'), 400
if not save_user_settings(current_user.id, {'workspace_presets': body['presets']}):
return jsonify(error='Could not save workspace presets'), 500
response = jsonify(presets=get_user_settings(current_user.id).get('workspace_presets', []))
response.headers['Cache-Control'] = 'no-store'
return response


def valid_recovery_timestamp(value):
if not isinstance(value, str) or not 1 <= len(value) <= 64:
return False
try:
timestamp = datetime.fromisoformat(value)
return timestamp.tzinfo is not None and timestamp.utcoffset() is not None
except (ValueError, TypeError, OverflowError):
return False


def _restore_marker():
# A corrupt or missing reminder is never proof of restored credentials.
try:
with (Path(config.DATA_DIR) / 'recovery-check.json').open(encoding='utf-8') as stream:
content = stream.read(513)
if len(content) > 512:
return None
marker = json.loads(content)
candidate = marker.get('restored_at') if isinstance(marker, dict) else None
return candidate if valid_recovery_timestamp(candidate) else None
except (OSError, ValueError, TypeError):
return None


def mark_restore_recovery(data_dir):
try:
with storage_lock('restore-recovery-check'):
atomic_write_json(Path(data_dir) / 'recovery-check.json', {
'restored_at': datetime.now(timezone.utc).isoformat(),
'remote_credentials_excluded': True,
})
except OSError:
from .audit_logger import log_warning
log_warning('Restore completed but optional recovery reminder could not be saved')


@customer_workflows_blueprint.route('/api/account/recovery-check', methods=['GET', 'POST'])
@login_required
def recovery_check():
from .profile_manager import load_profiles
from .key_manager import load_keys
from .jump_host_manager import load_jump_hosts
from .host_key_store import HostKeyStore
with user_data_access(current_user.id):
if request.method == 'POST':
body = request.get_json(silent=True)
if (not isinstance(body, dict) or set(body) != {'restored_at'}
or not valid_recovery_timestamp(body.get('restored_at'))):
return jsonify(error='Invalid recovery review'), 400
# Serialize marker comparison and account acknowledgement against a
# concurrent restore. An older checklist cannot dismiss a new restore.
with storage_lock('restore-recovery-check'):
current = _restore_marker()
if current is None or body['restored_at'] != current:
return jsonify(error='The restore marker changed. Review the current checklist.', code='restore_changed'), 409
if not save_user_settings(current_user.id, {'recovery_reviewed_at': current}):
return jsonify(error='Could not save recovery review'), 500
response = jsonify(restored_at=current, needs_review=False)
response.headers['Cache-Control'] = 'no-store'
return response
profiles = load_profiles(current_user.id)
keys = {key['id'] for key in load_keys(current_user.id)}
jumps = {jump['id'] for jump in load_jump_hosts(current_user.id)}
credentials = current_app.extensions['credentials']
resources = {item['id']: item for item in credentials.resources(current_user.id) if item.get('kind') == 'ssh'}
trust = HostKeyStore(current_user.id, config.KNOWN_HOSTS_FILE, config.USERS_DIR).trust_checker()
hosts = []
for host in profiles:
if host['auth_type'] == 'key':
credential = 'ready' if host.get('key_id') in keys else 'key_required'
elif host['auth_type'] == 'password':
resource = resources.get(host['id'])
credential = 'ready' if resource and credentials.credential_status(current_user.id, resource) == 'ready' else 'password_required'
else:
credential = 'interactive'
hosts.append(dict(id=host['id'], name=host['name'], credential=credential,
route='missing_jump' if host.get('jump_host_id') and host['jump_host_id'] not in jumps else 'configured',
host_key='trusted' if trust(host['host'], host.get('port', 22)) else 'review_required'))
schedules = current_app.extensions['automation_schedules'].list(current_user.id)
with storage_lock('restore-recovery-check'):
restored_at = _restore_marker()
needs_review = bool(restored_at and get_user_settings(current_user.id).get('recovery_reviewed_at') != restored_at)
response = jsonify(restored_at=restored_at, needs_review=needs_review,
coverage={'configuration': 'included', 'account_authentication': 'included', 'remote_credentials': 'excluded', 'live_sessions': 'excluded'},
hosts=hosts, schedules=[{'id': row['id'], 'name': row['name'], 'enabled': row['enabled'], 'status': row['last_status']} for row in schedules])
response.headers['Cache-Control'] = 'no-store'
return response
Loading
Loading