Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bbf6c4e0e | ||
|
|
6fbdbcf1c1 | ||
|
|
f8a9bae8fb |
@@ -1,97 +0,0 @@
|
||||
name: Agent PR Gate
|
||||
'on':
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
syntax_status: ${{ steps.syntax.outcome }}
|
||||
tests_status: ${{ steps.tests.outcome }}
|
||||
criteria_status: ${{ steps.criteria.outcome }}
|
||||
risk_level: ${{ steps.risk.outputs.level }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: |
|
||||
python3 -m pip install --quiet pyyaml pytest
|
||||
|
||||
- id: risk
|
||||
name: Classify PR risk
|
||||
run: |
|
||||
BASE_REF="${GITHUB_BASE_REF:-main}"
|
||||
git fetch origin "$BASE_REF" --depth 1
|
||||
git diff --name-only "origin/$BASE_REF"...HEAD > /tmp/changed_files.txt
|
||||
python3 scripts/agent_pr_gate.py classify-risk --files-file /tmp/changed_files.txt > /tmp/risk.json
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
with open('/tmp/risk.json', 'r', encoding='utf-8') as fh:
|
||||
data = json.load(fh)
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh:
|
||||
fh.write('level=' + data['risk'] + '\n')
|
||||
PY
|
||||
|
||||
- id: syntax
|
||||
name: Syntax and parse checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
|
||||
- id: tests
|
||||
name: Test suite
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pytest -q --ignore=uni-wizard/v2/tests/test_author_whitelist.py
|
||||
|
||||
- id: criteria
|
||||
name: PR criteria verification
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python3 scripts/agent_pr_gate.py validate-pr --event-path "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Fail gate if any required check failed
|
||||
if: steps.syntax.outcome != 'success' || steps.tests.outcome != 'success' || steps.criteria.outcome != 'success'
|
||||
run: exit 1
|
||||
|
||||
report:
|
||||
needs: gate
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Post PR gate report
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/agent_pr_gate.py comment \
|
||||
--event-path "$GITHUB_EVENT_PATH" \
|
||||
--token "$GITEA_TOKEN" \
|
||||
--syntax "${{ needs.gate.outputs.syntax_status }}" \
|
||||
--tests "${{ needs.gate.outputs.tests_status }}" \
|
||||
--criteria "${{ needs.gate.outputs.criteria_status }}" \
|
||||
--risk "${{ needs.gate.outputs.risk_level }}"
|
||||
|
||||
- name: Auto-merge low-risk clean PRs
|
||||
if: needs.gate.result == 'success' && needs.gate.outputs.risk_level == 'low'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/agent_pr_gate.py merge \
|
||||
--event-path "$GITHUB_EVENT_PATH" \
|
||||
--token "$GITEA_TOKEN"
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Smoke Test
|
||||
'on':
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -11,13 +11,10 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install parse dependencies
|
||||
run: |
|
||||
python3 -m pip install --quiet pyyaml
|
||||
- name: Parse check
|
||||
run: |
|
||||
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
|
||||
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' -print0 | xargs -0 -r -n1 python3 -m json.tool > /dev/null
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
echo "PASS: All files parse"
|
||||
@@ -25,3 +22,6 @@ jobs:
|
||||
run: |
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'detect_secrets' | grep -v 'test_trajectory_sanitize'; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
- name: Backup pipeline regression test
|
||||
run: |
|
||||
python3 -m unittest discover -s tests -p 'test_backup_pipeline.py' -v
|
||||
|
||||
98
docs/BACKUP_PIPELINE.md
Normal file
98
docs/BACKUP_PIPELINE.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Encrypted Hermes Backup Pipeline
|
||||
|
||||
Issue: `timmy-home#693`
|
||||
|
||||
This pipeline creates a nightly encrypted archive of `~/.hermes`, stores a local encrypted copy, uploads it to remote storage, and supports restore verification.
|
||||
|
||||
## What gets backed up
|
||||
|
||||
By default the pipeline archives:
|
||||
|
||||
- `~/.hermes/config.yaml`
|
||||
- `~/.hermes/state.db`
|
||||
- `~/.hermes/sessions/`
|
||||
- `~/.hermes/cron/`
|
||||
- any other files under `~/.hermes`
|
||||
|
||||
Override the source with `BACKUP_SOURCE_DIR=/path/to/.hermes`.
|
||||
|
||||
## Backup command
|
||||
|
||||
```bash
|
||||
BACKUP_PASSPHRASE_FILE=~/.config/timmy/backup.passphrase \
|
||||
BACKUP_NAS_TARGET=/Volumes/timmy-nas/hermes-backups \
|
||||
bash scripts/backup_pipeline.sh
|
||||
```
|
||||
|
||||
The script writes:
|
||||
|
||||
- local encrypted copy: `~/.timmy-backups/hermes/<timestamp>/hermes-backup-<timestamp>.tar.gz.enc`
|
||||
- local manifest: `~/.timmy-backups/hermes/<timestamp>/hermes-backup-<timestamp>.json`
|
||||
- log file: `~/.timmy-backups/hermes/logs/backup_pipeline.log`
|
||||
|
||||
## Nightly schedule
|
||||
|
||||
Run every night at 03:00:
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /Users/apayne/.timmy/timmy-home && BACKUP_PASSPHRASE_FILE=/Users/apayne/.config/timmy/backup.passphrase BACKUP_NAS_TARGET=/Volumes/timmy-nas/hermes-backups bash scripts/backup_pipeline.sh >> /Users/apayne/.timmy-backups/hermes/logs/cron.log 2>&1
|
||||
```
|
||||
|
||||
## Remote targets
|
||||
|
||||
At least one remote target must be configured.
|
||||
|
||||
### Local NAS
|
||||
|
||||
Use a mounted path:
|
||||
|
||||
```bash
|
||||
BACKUP_NAS_TARGET=/Volumes/timmy-nas/hermes-backups
|
||||
```
|
||||
|
||||
The pipeline copies the encrypted archive and manifest into `<BACKUP_NAS_TARGET>/<timestamp>/`.
|
||||
|
||||
### S3-compatible storage
|
||||
|
||||
```bash
|
||||
BACKUP_PASSPHRASE_FILE=~/.config/timmy/backup.passphrase \
|
||||
BACKUP_S3_URI=s3://timmy-backups/hermes \
|
||||
AWS_ENDPOINT_URL=https://minio.example.com \
|
||||
bash scripts/backup_pipeline.sh
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `aws` CLI must be installed if `BACKUP_S3_URI` is set.
|
||||
- `AWS_ENDPOINT_URL` is optional and is used for MinIO, R2, and other S3-compatible endpoints.
|
||||
|
||||
## Restore playbook
|
||||
|
||||
Restore an encrypted archive into a clean target root:
|
||||
|
||||
```bash
|
||||
BACKUP_PASSPHRASE_FILE=~/.config/timmy/backup.passphrase \
|
||||
bash scripts/restore_backup.sh \
|
||||
/Volumes/timmy-nas/hermes-backups/20260415-030000/hermes-backup-20260415-030000.tar.gz.enc \
|
||||
/tmp/hermes-restore
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- restored tree lands at `/tmp/hermes-restore/.hermes`
|
||||
- if a sibling manifest exists, the restore script verifies the archive SHA256 before decrypting
|
||||
|
||||
## End-to-end verification
|
||||
|
||||
Run the regression suite:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_backup_pipeline.py' -v
|
||||
```
|
||||
|
||||
This proves:
|
||||
|
||||
1. the backup output is encrypted
|
||||
2. plaintext archives do not leak into the backup destinations
|
||||
3. the restore script recreates the original `.hermes` tree end-to-end
|
||||
4. the pipeline refuses to run without a remote target
|
||||
@@ -12,6 +12,8 @@ Quick-reference index for common operational tasks across the Timmy Foundation i
|
||||
| Check fleet health | fleet-ops | `python3 scripts/fleet_readiness.py` |
|
||||
| Agent scorecard | fleet-ops | `python3 scripts/agent_scorecard.py` |
|
||||
| View fleet manifest | fleet-ops | `cat manifest.yaml` |
|
||||
| Backup Hermes state | timmy-home | `BACKUP_PASSPHRASE_FILE=... BACKUP_NAS_TARGET=... bash scripts/backup_pipeline.sh` |
|
||||
| Restore Hermes state | timmy-home | `BACKUP_PASSPHRASE_FILE=... bash scripts/restore_backup.sh <archive> <restore-root>` |
|
||||
|
||||
## the-nexus (Frontend + Brain)
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
LOW_RISK_PREFIXES = (
|
||||
'docs/', 'reports/', 'notes/', 'tickets/', 'research/', 'briefings/',
|
||||
'twitter-archive/notes/', 'tests/'
|
||||
)
|
||||
LOW_RISK_SUFFIXES = {'.md', '.txt', '.jsonl'}
|
||||
MEDIUM_RISK_PREFIXES = ('.gitea/workflows/',)
|
||||
HIGH_RISK_PREFIXES = (
|
||||
'scripts/', 'deploy/', 'infrastructure/', 'metrics/', 'heartbeat/',
|
||||
'wizards/', 'evennia/', 'uniwizard/', 'uni-wizard/', 'timmy-local/',
|
||||
'evolution/'
|
||||
)
|
||||
HIGH_RISK_SUFFIXES = {'.py', '.sh', '.ini', '.service'}
|
||||
|
||||
|
||||
def read_changed_files(path):
|
||||
return [line.strip() for line in Path(path).read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
|
||||
|
||||
def classify_risk(files):
|
||||
if not files:
|
||||
return 'high'
|
||||
level = 'low'
|
||||
for file_path in files:
|
||||
path = file_path.strip()
|
||||
suffix = Path(path).suffix.lower()
|
||||
if path.startswith(LOW_RISK_PREFIXES):
|
||||
continue
|
||||
if path.startswith(HIGH_RISK_PREFIXES) or suffix in HIGH_RISK_SUFFIXES:
|
||||
return 'high'
|
||||
if path.startswith(MEDIUM_RISK_PREFIXES):
|
||||
level = 'medium'
|
||||
continue
|
||||
if path.startswith(LOW_RISK_PREFIXES) or suffix in LOW_RISK_SUFFIXES:
|
||||
continue
|
||||
level = 'high'
|
||||
return level
|
||||
|
||||
|
||||
def validate_pr_body(title, body):
|
||||
details = []
|
||||
combined = f"{title}\n{body}".strip()
|
||||
if not re.search(r'#\d+', combined):
|
||||
details.append('PR body/title must include an issue reference like #562.')
|
||||
if not re.search(r'(^|\n)\s*(verification|tests?)\s*:', body, re.IGNORECASE):
|
||||
details.append('PR body must include a Verification: section.')
|
||||
return (len(details) == 0, details)
|
||||
|
||||
|
||||
def build_comment_body(syntax_status, tests_status, criteria_status, risk_level):
|
||||
statuses = {
|
||||
'syntax': syntax_status,
|
||||
'tests': tests_status,
|
||||
'criteria': criteria_status,
|
||||
}
|
||||
all_clean = all(value == 'success' for value in statuses.values())
|
||||
action = 'auto-merge' if all_clean and risk_level == 'low' else 'human review'
|
||||
lines = [
|
||||
'## Agent PR Gate',
|
||||
'',
|
||||
'| Check | Status |',
|
||||
'|-------|--------|',
|
||||
f"| Syntax / parse | {syntax_status} |",
|
||||
f"| Test suite | {tests_status} |",
|
||||
f"| PR criteria | {criteria_status} |",
|
||||
f"| Risk level | {risk_level} |",
|
||||
'',
|
||||
]
|
||||
failed = [name for name, value in statuses.items() if value != 'success']
|
||||
if failed:
|
||||
lines.append('### Failure details')
|
||||
for name in failed:
|
||||
lines.append(f'- {name} reported failure. Inspect the workflow logs for that step.')
|
||||
else:
|
||||
lines.append('All automated checks passed.')
|
||||
lines.extend([
|
||||
'',
|
||||
f'Recommendation: {action}.',
|
||||
'Low-risk documentation/test-only PRs may be auto-merged. Operational changes stay in human review.',
|
||||
])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _read_event(event_path):
|
||||
data = json.loads(Path(event_path).read_text(encoding='utf-8'))
|
||||
pr = data.get('pull_request') or {}
|
||||
repo = (data.get('repository') or {}).get('full_name') or os.environ.get('GITHUB_REPOSITORY')
|
||||
pr_number = pr.get('number') or data.get('number')
|
||||
title = pr.get('title') or ''
|
||||
body = pr.get('body') or ''
|
||||
return repo, pr_number, title, body
|
||||
|
||||
|
||||
def _request_json(method, url, token, payload=None):
|
||||
data = None if payload is None else json.dumps(payload).encode('utf-8')
|
||||
headers = {'Authorization': f'token {token}', 'Content-Type': 'application/json'}
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
|
||||
def post_comment(repo, pr_number, token, body):
|
||||
url = f'{API_BASE}/repos/{repo}/issues/{pr_number}/comments'
|
||||
return _request_json('POST', url, token, {'body': body})
|
||||
|
||||
|
||||
def merge_pr(repo, pr_number, token):
|
||||
url = f'{API_BASE}/repos/{repo}/pulls/{pr_number}/merge'
|
||||
return _request_json('POST', url, token, {'Do': 'merge'})
|
||||
|
||||
|
||||
def cmd_classify_risk(args):
|
||||
files = list(args.files or [])
|
||||
if args.files_file:
|
||||
files.extend(read_changed_files(args.files_file))
|
||||
print(json.dumps({'risk': classify_risk(files), 'files': files}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate_pr(args):
|
||||
_, _, title, body = _read_event(args.event_path)
|
||||
ok, details = validate_pr_body(title, body)
|
||||
if ok:
|
||||
print('PR body validation passed.')
|
||||
return 0
|
||||
for detail in details:
|
||||
print(detail)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_comment(args):
|
||||
repo, pr_number, _, _ = _read_event(args.event_path)
|
||||
body = build_comment_body(args.syntax, args.tests, args.criteria, args.risk)
|
||||
post_comment(repo, pr_number, args.token, body)
|
||||
print(f'Commented on PR #{pr_number} in {repo}.')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_merge(args):
|
||||
repo, pr_number, _, _ = _read_event(args.event_path)
|
||||
merge_pr(repo, pr_number, args.token)
|
||||
print(f'Merged PR #{pr_number} in {repo}.')
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description='Agent PR CI helpers for timmy-home.')
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
|
||||
classify = sub.add_parser('classify-risk')
|
||||
classify.add_argument('--files-file')
|
||||
classify.add_argument('files', nargs='*')
|
||||
classify.set_defaults(func=cmd_classify_risk)
|
||||
|
||||
validate = sub.add_parser('validate-pr')
|
||||
validate.add_argument('--event-path', required=True)
|
||||
validate.set_defaults(func=cmd_validate_pr)
|
||||
|
||||
comment = sub.add_parser('comment')
|
||||
comment.add_argument('--event-path', required=True)
|
||||
comment.add_argument('--token', required=True)
|
||||
comment.add_argument('--syntax', required=True)
|
||||
comment.add_argument('--tests', required=True)
|
||||
comment.add_argument('--criteria', required=True)
|
||||
comment.add_argument('--risk', required=True)
|
||||
comment.set_defaults(func=cmd_comment)
|
||||
|
||||
merge = sub.add_parser('merge')
|
||||
merge.add_argument('--event-path', required=True)
|
||||
merge.add_argument('--token', required=True)
|
||||
merge.set_defaults(func=cmd_merge)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,80 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# backup_pipeline.sh — Daily fleet backup pipeline (FLEET-008)
|
||||
# Refs: timmy-home #561
|
||||
# backup_pipeline.sh — Nightly encrypted Hermes backup pipeline
|
||||
# Refs: timmy-home #693, timmy-home #561
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_ROOT="/backups/timmy"
|
||||
DATESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BACKUP_DIR="${BACKUP_ROOT}/${DATESTAMP}"
|
||||
LOG_DIR="/var/log/timmy"
|
||||
ALERT_LOG="${LOG_DIR}/backup_pipeline.log"
|
||||
mkdir -p "$BACKUP_DIR" "$LOG_DIR"
|
||||
DATESTAMP="${BACKUP_TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}"
|
||||
BACKUP_SOURCE_DIR="${BACKUP_SOURCE_DIR:-${HOME}/.hermes}"
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-${HOME}/.timmy-backups/hermes}"
|
||||
BACKUP_LOG_DIR="${BACKUP_LOG_DIR:-${BACKUP_ROOT}/logs}"
|
||||
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
|
||||
BACKUP_S3_URI="${BACKUP_S3_URI:-}"
|
||||
BACKUP_NAS_TARGET="${BACKUP_NAS_TARGET:-}"
|
||||
AWS_ENDPOINT_URL="${AWS_ENDPOINT_URL:-}"
|
||||
BACKUP_NAME="hermes-backup-${DATESTAMP}"
|
||||
LOCAL_BACKUP_DIR="${BACKUP_ROOT}/${DATESTAMP}"
|
||||
STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/timmy-backup.XXXXXX")"
|
||||
PLAINTEXT_ARCHIVE="${STAGE_DIR}/${BACKUP_NAME}.tar.gz"
|
||||
ENCRYPTED_ARCHIVE="${STAGE_DIR}/${BACKUP_NAME}.tar.gz.enc"
|
||||
MANIFEST_PATH="${STAGE_DIR}/${BACKUP_NAME}.json"
|
||||
ALERT_LOG="${BACKUP_LOG_DIR}/backup_pipeline.log"
|
||||
PASSFILE_CLEANUP=""
|
||||
|
||||
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
||||
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
||||
OFFSITE_TARGET="${OFFSITE_TARGET:-}"
|
||||
mkdir -p "$BACKUP_LOG_DIR"
|
||||
|
||||
log() { echo "[$(date -Iseconds)] $1" | tee -a "$ALERT_LOG"; }
|
||||
log() {
|
||||
echo "[$(date -Iseconds)] $1" | tee -a "$ALERT_LOG"
|
||||
}
|
||||
|
||||
send_telegram() {
|
||||
local msg="$1"
|
||||
if [[ -n "$TELEGRAM_BOT_TOKEN" && -n "$TELEGRAM_CHAT_ID" ]]; then
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d "chat_id=${TELEGRAM_CHAT_ID}" -d "text=${msg}" >/dev/null 2>&1 || true
|
||||
fail() {
|
||||
log "ERROR: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -f "$PLAINTEXT_ARCHIVE"
|
||||
rm -rf "$STAGE_DIR"
|
||||
if [[ -n "$PASSFILE_CLEANUP" && -f "$PASSFILE_CLEANUP" ]]; then
|
||||
rm -f "$PASSFILE_CLEANUP"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
resolve_passphrase_file() {
|
||||
if [[ -n "${BACKUP_PASSPHRASE_FILE:-}" ]]; then
|
||||
[[ -f "$BACKUP_PASSPHRASE_FILE" ]] || fail "BACKUP_PASSPHRASE_FILE does not exist: $BACKUP_PASSPHRASE_FILE"
|
||||
echo "$BACKUP_PASSPHRASE_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${BACKUP_PASSPHRASE:-}" ]]; then
|
||||
PASSFILE_CLEANUP="${STAGE_DIR}/backup.passphrase"
|
||||
printf '%s' "$BACKUP_PASSPHRASE" > "$PASSFILE_CLEANUP"
|
||||
chmod 600 "$PASSFILE_CLEANUP"
|
||||
echo "$PASSFILE_CLEANUP"
|
||||
return
|
||||
fi
|
||||
|
||||
fail "Set BACKUP_PASSPHRASE_FILE or BACKUP_PASSPHRASE before running the backup pipeline."
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
local path="$1"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
else
|
||||
python3 - <<'PY' "$path"
|
||||
import hashlib
|
||||
import pathlib
|
||||
import sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
h = hashlib.sha256()
|
||||
with path.open('rb') as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
||||
h.update(chunk)
|
||||
print(h.hexdigest())
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
status=0
|
||||
write_manifest() {
|
||||
python3 - <<'PY' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8"
|
||||
import json
|
||||
import sys
|
||||
manifest_path, source_dir, archive_name, archive_sha256, local_dir, s3_uri, nas_target, created_at = sys.argv[1:]
|
||||
manifest = {
|
||||
"created_at": created_at,
|
||||
"source_dir": source_dir,
|
||||
"archive_name": archive_name,
|
||||
"archive_sha256": archive_sha256,
|
||||
"encryption": {
|
||||
"type": "openssl",
|
||||
"cipher": "aes-256-cbc",
|
||||
"pbkdf2": True,
|
||||
"iterations": 200000,
|
||||
},
|
||||
"destinations": {
|
||||
"local_dir": local_dir,
|
||||
"s3_uri": s3_uri or None,
|
||||
"nas_target": nas_target or None,
|
||||
},
|
||||
}
|
||||
with open(manifest_path, 'w', encoding='utf-8') as handle:
|
||||
json.dump(manifest, handle, indent=2)
|
||||
handle.write('\n')
|
||||
PY
|
||||
}
|
||||
|
||||
# --- Gitea repositories ---
|
||||
if [[ -d /root/gitea ]]; then
|
||||
tar czf "${BACKUP_DIR}/gitea-repos.tar.gz" -C /root gitea 2>/dev/null || true
|
||||
log "Backed up Gitea repos"
|
||||
fi
|
||||
upload_to_nas() {
|
||||
local archive_path="$1"
|
||||
local manifest_path="$2"
|
||||
local target_root="$3"
|
||||
|
||||
# --- Agent configs and state ---
|
||||
for wiz in bezalel allegro ezra timmy; do
|
||||
if [[ -d "/root/wizards/${wiz}" ]]; then
|
||||
tar czf "${BACKUP_DIR}/${wiz}-home.tar.gz" -C /root/wizards "${wiz}" 2>/dev/null || true
|
||||
log "Backed up ${wiz} home"
|
||||
local target_dir="${target_root%/}/${DATESTAMP}"
|
||||
mkdir -p "$target_dir"
|
||||
cp "$archive_path" "$manifest_path" "$target_dir/"
|
||||
log "Uploaded backup to NAS target: $target_dir"
|
||||
}
|
||||
|
||||
upload_to_s3() {
|
||||
local archive_path="$1"
|
||||
local manifest_path="$2"
|
||||
|
||||
command -v aws >/dev/null 2>&1 || fail "BACKUP_S3_URI is set but aws CLI is not installed."
|
||||
|
||||
local args=()
|
||||
if [[ -n "$AWS_ENDPOINT_URL" ]]; then
|
||||
args+=(--endpoint-url "$AWS_ENDPOINT_URL")
|
||||
fi
|
||||
done
|
||||
|
||||
# --- System configs ---
|
||||
cp /etc/crontab "${BACKUP_DIR}/crontab" 2>/dev/null || true
|
||||
cp -r /etc/systemd/system "${BACKUP_DIR}/systemd" 2>/dev/null || true
|
||||
log "Backed up system configs"
|
||||
aws "${args[@]}" s3 cp "$archive_path" "${BACKUP_S3_URI%/}/$(basename "$archive_path")"
|
||||
aws "${args[@]}" s3 cp "$manifest_path" "${BACKUP_S3_URI%/}/$(basename "$manifest_path")"
|
||||
log "Uploaded backup to S3 target: $BACKUP_S3_URI"
|
||||
}
|
||||
|
||||
# --- Evennia worlds (if present) ---
|
||||
if [[ -d /root/evennia ]]; then
|
||||
tar czf "${BACKUP_DIR}/evennia-worlds.tar.gz" -C /root evennia 2>/dev/null || true
|
||||
log "Backed up Evennia worlds"
|
||||
[[ -d "$BACKUP_SOURCE_DIR" ]] || fail "BACKUP_SOURCE_DIR does not exist: $BACKUP_SOURCE_DIR"
|
||||
[[ -n "$BACKUP_NAS_TARGET" || -n "$BACKUP_S3_URI" ]] || fail "Set BACKUP_NAS_TARGET or BACKUP_S3_URI for remote backup storage."
|
||||
|
||||
PASSFILE="$(resolve_passphrase_file)"
|
||||
mkdir -p "$LOCAL_BACKUP_DIR"
|
||||
|
||||
log "Creating archive from $BACKUP_SOURCE_DIR"
|
||||
tar -czf "$PLAINTEXT_ARCHIVE" -C "$(dirname "$BACKUP_SOURCE_DIR")" "$(basename "$BACKUP_SOURCE_DIR")"
|
||||
|
||||
log "Encrypting archive"
|
||||
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
|
||||
-pass "file:${PASSFILE}" \
|
||||
-in "$PLAINTEXT_ARCHIVE" \
|
||||
-out "$ENCRYPTED_ARCHIVE"
|
||||
|
||||
ARCHIVE_SHA256="$(sha256_file "$ENCRYPTED_ARCHIVE")"
|
||||
CREATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
write_manifest "$MANIFEST_PATH" "$BACKUP_SOURCE_DIR" "$(basename "$ENCRYPTED_ARCHIVE")" "$ARCHIVE_SHA256" "$LOCAL_BACKUP_DIR" "$BACKUP_S3_URI" "$BACKUP_NAS_TARGET" "$CREATED_AT"
|
||||
|
||||
cp "$ENCRYPTED_ARCHIVE" "$MANIFEST_PATH" "$LOCAL_BACKUP_DIR/"
|
||||
rm -f "$PLAINTEXT_ARCHIVE"
|
||||
log "Encrypted backup stored locally: ${LOCAL_BACKUP_DIR}/$(basename "$ENCRYPTED_ARCHIVE")"
|
||||
|
||||
if [[ -n "$BACKUP_NAS_TARGET" ]]; then
|
||||
upload_to_nas "$ENCRYPTED_ARCHIVE" "$MANIFEST_PATH" "$BACKUP_NAS_TARGET"
|
||||
fi
|
||||
|
||||
# --- Manifest ---
|
||||
find "$BACKUP_DIR" -type f > "${BACKUP_DIR}/manifest.txt"
|
||||
log "Backup manifest written"
|
||||
|
||||
# --- Offsite sync ---
|
||||
if [[ -n "$OFFSITE_TARGET" ]]; then
|
||||
if rsync -az --delete "${BACKUP_DIR}/" "${OFFSITE_TARGET}/${DATESTAMP}/" 2>/dev/null; then
|
||||
log "Offsite sync completed"
|
||||
else
|
||||
log "WARNING: Offsite sync failed"
|
||||
status=1
|
||||
fi
|
||||
if [[ -n "$BACKUP_S3_URI" ]]; then
|
||||
upload_to_s3 "$ENCRYPTED_ARCHIVE" "$MANIFEST_PATH"
|
||||
fi
|
||||
|
||||
# --- Retention: keep last 7 days ---
|
||||
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
||||
log "Retention applied (7 days)"
|
||||
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
log "Backup pipeline completed: ${BACKUP_DIR}"
|
||||
send_telegram "✅ Daily backup completed: ${DATESTAMP}"
|
||||
else
|
||||
log "Backup pipeline completed with WARNINGS: ${BACKUP_DIR}"
|
||||
send_telegram "⚠️ Daily backup completed with warnings: ${DATESTAMP}"
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name '20*' -mtime "+${BACKUP_RETENTION_DAYS}" -exec rm -rf {} + 2>/dev/null || true
|
||||
log "Retention applied (${BACKUP_RETENTION_DAYS} days)"
|
||||
log "Backup pipeline completed successfully"
|
||||
|
||||
97
scripts/restore_backup.sh
Normal file
97
scripts/restore_backup.sh
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# restore_backup.sh — Restore an encrypted Hermes backup archive
|
||||
# Usage: restore_backup.sh /path/to/hermes-backup-YYYYmmdd-HHMMSS.tar.gz.enc /restore/root
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE_PATH="${1:-}"
|
||||
RESTORE_ROOT="${2:-}"
|
||||
STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/timmy-restore.XXXXXX")"
|
||||
PLAINTEXT_ARCHIVE="${STAGE_DIR}/restore.tar.gz"
|
||||
PASSFILE_CLEANUP=""
|
||||
|
||||
cleanup() {
|
||||
rm -f "$PLAINTEXT_ARCHIVE"
|
||||
rm -rf "$STAGE_DIR"
|
||||
if [[ -n "$PASSFILE_CLEANUP" && -f "$PASSFILE_CLEANUP" ]]; then
|
||||
rm -f "$PASSFILE_CLEANUP"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_passphrase_file() {
|
||||
if [[ -n "${BACKUP_PASSPHRASE_FILE:-}" ]]; then
|
||||
[[ -f "$BACKUP_PASSPHRASE_FILE" ]] || fail "BACKUP_PASSPHRASE_FILE does not exist: $BACKUP_PASSPHRASE_FILE"
|
||||
echo "$BACKUP_PASSPHRASE_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${BACKUP_PASSPHRASE:-}" ]]; then
|
||||
PASSFILE_CLEANUP="${STAGE_DIR}/backup.passphrase"
|
||||
printf '%s' "$BACKUP_PASSPHRASE" > "$PASSFILE_CLEANUP"
|
||||
chmod 600 "$PASSFILE_CLEANUP"
|
||||
echo "$PASSFILE_CLEANUP"
|
||||
return
|
||||
fi
|
||||
|
||||
fail "Set BACKUP_PASSPHRASE_FILE or BACKUP_PASSPHRASE before restoring a backup."
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
local path="$1"
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$path" | awk '{print $1}'
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$path" | awk '{print $1}'
|
||||
else
|
||||
python3 - <<'PY' "$path"
|
||||
import hashlib
|
||||
import pathlib
|
||||
import sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
h = hashlib.sha256()
|
||||
with path.open('rb') as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
||||
h.update(chunk)
|
||||
print(h.hexdigest())
|
||||
PY
|
||||
fi
|
||||
}
|
||||
|
||||
[[ -n "$ARCHIVE_PATH" ]] || fail "Usage: restore_backup.sh /path/to/archive.tar.gz.enc /restore/root"
|
||||
[[ -n "$RESTORE_ROOT" ]] || fail "Usage: restore_backup.sh /path/to/archive.tar.gz.enc /restore/root"
|
||||
[[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH"
|
||||
|
||||
if [[ "$ARCHIVE_PATH" == *.tar.gz.enc ]]; then
|
||||
MANIFEST_PATH="${ARCHIVE_PATH%.tar.gz.enc}.json"
|
||||
else
|
||||
MANIFEST_PATH=""
|
||||
fi
|
||||
|
||||
if [[ -n "$MANIFEST_PATH" && -f "$MANIFEST_PATH" ]]; then
|
||||
EXPECTED_SHA="$(python3 - <<'PY' "$MANIFEST_PATH"
|
||||
import json
|
||||
import sys
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
print(manifest['archive_sha256'])
|
||||
PY
|
||||
)"
|
||||
ACTUAL_SHA="$(sha256_file "$ARCHIVE_PATH")"
|
||||
[[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || fail "Archive SHA256 mismatch: expected $EXPECTED_SHA got $ACTUAL_SHA"
|
||||
fi
|
||||
|
||||
PASSFILE="$(resolve_passphrase_file)"
|
||||
mkdir -p "$RESTORE_ROOT"
|
||||
|
||||
openssl enc -d -aes-256-cbc -salt -pbkdf2 -iter 200000 \
|
||||
-pass "file:${PASSFILE}" \
|
||||
-in "$ARCHIVE_PATH" \
|
||||
-out "$PLAINTEXT_ARCHIVE"
|
||||
|
||||
tar -xzf "$PLAINTEXT_ARCHIVE" -C "$RESTORE_ROOT"
|
||||
echo "Restored backup into $RESTORE_ROOT"
|
||||
@@ -1,68 +0,0 @@
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
|
||||
import agent_pr_gate # noqa: E402
|
||||
|
||||
|
||||
class TestAgentPrGate(unittest.TestCase):
|
||||
def test_classify_risk_low_for_docs_and_tests_only(self):
|
||||
level = agent_pr_gate.classify_risk([
|
||||
'docs/runbook.md',
|
||||
'reports/daily-summary.md',
|
||||
'tests/test_agent_pr_gate.py',
|
||||
])
|
||||
self.assertEqual(level, 'low')
|
||||
|
||||
def test_classify_risk_high_for_operational_paths(self):
|
||||
level = agent_pr_gate.classify_risk([
|
||||
'scripts/failover_monitor.py',
|
||||
'deploy/playbook.yml',
|
||||
])
|
||||
self.assertEqual(level, 'high')
|
||||
|
||||
def test_validate_pr_body_requires_issue_ref_and_verification(self):
|
||||
ok, details = agent_pr_gate.validate_pr_body(
|
||||
'feat: add thing',
|
||||
'What changed only\n\nNo verification section here.'
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('issue reference', ' '.join(details).lower())
|
||||
self.assertIn('verification', ' '.join(details).lower())
|
||||
|
||||
def test_validate_pr_body_accepts_issue_ref_and_verification(self):
|
||||
ok, details = agent_pr_gate.validate_pr_body(
|
||||
'feat: add thing (#562)',
|
||||
'Refs #562\n\nVerification:\n- pytest -q\n'
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(details, [])
|
||||
|
||||
def test_build_comment_body_reports_failures_and_human_review(self):
|
||||
body = agent_pr_gate.build_comment_body(
|
||||
syntax_status='success',
|
||||
tests_status='failure',
|
||||
criteria_status='success',
|
||||
risk_level='high',
|
||||
)
|
||||
self.assertIn('tests', body.lower())
|
||||
self.assertIn('failure', body.lower())
|
||||
self.assertIn('human review', body.lower())
|
||||
|
||||
def test_changed_files_file_loader_ignores_blanks(self):
|
||||
with tempfile.NamedTemporaryFile('w+', delete=False) as handle:
|
||||
handle.write('docs/one.md\n\nreports/two.md\n')
|
||||
path = handle.name
|
||||
try:
|
||||
files = agent_pr_gate.read_changed_files(path)
|
||||
finally:
|
||||
pathlib.Path(path).unlink(missing_ok=True)
|
||||
self.assertEqual(files, ['docs/one.md', 'reports/two.md'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,24 +0,0 @@
|
||||
import pathlib
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
WORKFLOW = ROOT / '.gitea' / 'workflows' / 'agent-pr-gate.yml'
|
||||
|
||||
|
||||
class TestAgentPrWorkflow(unittest.TestCase):
|
||||
def test_workflow_exists(self):
|
||||
self.assertTrue(WORKFLOW.exists(), 'agent-pr-gate workflow should exist')
|
||||
|
||||
def test_workflow_has_pr_gate_and_reporting_jobs(self):
|
||||
data = yaml.safe_load(WORKFLOW.read_text(encoding='utf-8'))
|
||||
self.assertIn('pull_request', data.get('on', {}))
|
||||
jobs = data.get('jobs', {})
|
||||
self.assertIn('gate', jobs)
|
||||
self.assertIn('report', jobs)
|
||||
report_steps = jobs['report']['steps']
|
||||
self.assertTrue(any('Auto-merge low-risk clean PRs' in (step.get('name') or '') for step in report_steps))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
103
tests/test_backup_pipeline.py
Normal file
103
tests/test_backup_pipeline.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKUP_SCRIPT = ROOT / "scripts" / "backup_pipeline.sh"
|
||||
RESTORE_SCRIPT = ROOT / "scripts" / "restore_backup.sh"
|
||||
|
||||
|
||||
class TestBackupPipeline(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
self.base = Path(self.tempdir.name)
|
||||
self.home = self.base / "home"
|
||||
self.source_dir = self.home / ".hermes"
|
||||
self.source_dir.mkdir(parents=True)
|
||||
(self.source_dir / "sessions").mkdir()
|
||||
(self.source_dir / "cron").mkdir()
|
||||
(self.source_dir / "config.yaml").write_text("model: local-first\n")
|
||||
(self.source_dir / "sessions" / "session.jsonl").write_text('{"role":"assistant","content":"hello"}\n')
|
||||
(self.source_dir / "cron" / "jobs.json").write_text('{"jobs": 1}\n')
|
||||
(self.source_dir / "state.db").write_bytes(b"sqlite-state")
|
||||
|
||||
self.backup_root = self.base / "backup-root"
|
||||
self.nas_target = self.base / "nas-target"
|
||||
self.restore_root = self.base / "restore-root"
|
||||
self.log_dir = self.base / "logs"
|
||||
self.passphrase_file = self.base / "backup.passphrase"
|
||||
self.passphrase_file.write_text("correct horse battery staple\n")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def _env(self, *, include_remote: bool = True) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"HOME": str(self.home),
|
||||
"BACKUP_SOURCE_DIR": str(self.source_dir),
|
||||
"BACKUP_ROOT": str(self.backup_root),
|
||||
"BACKUP_LOG_DIR": str(self.log_dir),
|
||||
"BACKUP_PASSPHRASE_FILE": str(self.passphrase_file),
|
||||
}
|
||||
)
|
||||
if include_remote:
|
||||
env["BACKUP_NAS_TARGET"] = str(self.nas_target)
|
||||
return env
|
||||
|
||||
def test_backup_encrypts_and_restore_round_trips(self) -> None:
|
||||
backup = subprocess.run(
|
||||
["bash", str(BACKUP_SCRIPT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=self._env(),
|
||||
cwd=ROOT,
|
||||
)
|
||||
self.assertEqual(backup.returncode, 0, msg=backup.stdout + backup.stderr)
|
||||
|
||||
encrypted_archives = sorted(self.nas_target.rglob("*.tar.gz.enc"))
|
||||
self.assertEqual(len(encrypted_archives), 1, msg=f"expected one encrypted archive, found: {encrypted_archives}")
|
||||
archive_path = encrypted_archives[0]
|
||||
self.assertNotIn(b"model: local-first", archive_path.read_bytes())
|
||||
|
||||
manifests = sorted(self.nas_target.rglob("*.json"))
|
||||
self.assertEqual(len(manifests), 1, msg=f"expected one manifest, found: {manifests}")
|
||||
|
||||
plaintext_archives = sorted(self.backup_root.rglob("*.tar.gz")) + sorted(self.nas_target.rglob("*.tar.gz"))
|
||||
self.assertEqual(plaintext_archives, [], msg=f"plaintext archives leaked: {plaintext_archives}")
|
||||
|
||||
restore = subprocess.run(
|
||||
["bash", str(RESTORE_SCRIPT), str(archive_path), str(self.restore_root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=self._env(),
|
||||
cwd=ROOT,
|
||||
)
|
||||
self.assertEqual(restore.returncode, 0, msg=restore.stdout + restore.stderr)
|
||||
|
||||
restored_hermes = self.restore_root / ".hermes"
|
||||
self.assertTrue(restored_hermes.exists())
|
||||
self.assertEqual((restored_hermes / "config.yaml").read_text(), "model: local-first\n")
|
||||
self.assertEqual((restored_hermes / "sessions" / "session.jsonl").read_text(), '{"role":"assistant","content":"hello"}\n')
|
||||
self.assertEqual((restored_hermes / "cron" / "jobs.json").read_text(), '{"jobs": 1}\n')
|
||||
self.assertEqual((restored_hermes / "state.db").read_bytes(), b"sqlite-state")
|
||||
|
||||
def test_backup_requires_remote_target(self) -> None:
|
||||
backup = subprocess.run(
|
||||
["bash", str(BACKUP_SCRIPT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=self._env(include_remote=False),
|
||||
cwd=ROOT,
|
||||
)
|
||||
self.assertNotEqual(backup.returncode, 0)
|
||||
self.assertIn("BACKUP_NAS_TARGET or BACKUP_S3_URI", backup.stdout + backup.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user