diff --git a/README.md b/README.md index bf3d9be..fc7e57a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ user service와 system service를 자동으로 감지하고, 번호 ID로 짧게 - user/system 자동 감지 (sudo 알아서 붙임) - `.service` 확장자 생략 가능 - ccze 설치되어 있으면 로그에 색상 자동 적용 +- `sys log` follow 중 unit 이름이 장시간 프로세스 argv에 남지 않도록 처리 ## 설치 @@ -93,7 +94,11 @@ sys log consensus | grep "block" # shell pipe도 가능 | `--head N` | 시작부터 N줄만 보기 (스냅샷, 실시간 안 함) | | `-g PATTERN`, `--grep PATTERN` | 정규식 패턴 필터 (case-insensitive) | -`--head`를 제외한 옵션은 모두 journalctl에 그대로 전달되니, 필요하면 `--since`, `-p` 같은 journalctl 옵션도 자유롭게 사용 가능합니다. 기본적으로 `-f -o cat`이 적용되어 PM2처럼 메타데이터 없이 깔끔하게 실시간 출력됩니다 (`--head` 사용 시는 실시간 모드 꺼짐). +`--head`를 제외한 대부분의 옵션은 journalctl에 그대로 전달되니, 필요하면 `--since`, `-p` 같은 journalctl 옵션도 자유롭게 사용 가능합니다. 기본 follow 모드는 PM2처럼 메타데이터 없이 깔끔하게 실시간 출력됩니다 (`--head` 사용 시는 실시간 모드 꺼짐). 단, follow 모드는 내부적으로 JSON 출력을 사용하므로 `-o`, `--output`, `--output-fields`, `-u`, `--unit`, `--user-unit`, `-f`, `--follow`는 지원하지 않습니다. + +기본 follow 모드는 최근 로그를 짧게 출력한 뒤, 장시간 떠 있는 follower에서는 unit 이름을 프로세스 argv에 남기지 않습니다. 일부 서비스가 프로세스 command line을 넓게 검색하는 경우 `journalctl -u ` 자체를 같은 서비스 프로세스로 오인할 수 있기 때문입니다. + +같은 이유로 follow 모드에서는 추가 journalctl 옵션 인자 안에 대상 unit 이름이 직접 들어가는 경우도 거부합니다. ## 설정 (`~/.config/sys-cli/config`) @@ -175,6 +180,7 @@ sudo loginctl enable-linger $USER - bash 4+ - systemd +- python3 — `sys log` follow 모드의 안전한 journal JSON 필터링에 사용 - (선택) `ccze` — 로그 색상화. install.sh가 자동 설치 여부를 물어봄. ## 업데이트 diff --git a/install.sh b/install.sh index b55d337..0f52586 100755 --- a/install.sh +++ b/install.sh @@ -33,6 +33,15 @@ if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" fi +# python3 (필수) — sys log follow 모드에서 journal JSON 필터링에 사용 +if ! command -v python3 >/dev/null 2>&1; then + echo "" + echo "⚠️ python3가 설치되어 있지 않아요." + echo " sys log follow 모드가 동작하려면 python3가 필요합니다." + echo " Ubuntu에서는 보통 다음으로 설치할 수 있습니다:" + echo " sudo apt install -y python3" +fi + # ccze (선택) — 로그 색상화에 사용 if ! command -v ccze >/dev/null 2>&1; then echo "" diff --git a/sys b/sys index 413afa9..e3894c1 100755 --- a/sys +++ b/sys @@ -73,6 +73,8 @@ sys_help() { echo " -n, --lines N 마지막 N줄부터 시작 (실시간 tail 유지)" echo " --head N 시작부터 N줄만 보기 (스냅샷)" echo " -g, --grep PATTERN 정규식 패턴으로 필터" + echo " -o/--output, -u/--unit, -f/--follow follow 모드에서는 미지원" + echo " follow 모드에서는 UNIT 이름이 포함된 추가 인자 미지원" echo "" echo "CONFIG:" echo " ~/.config/sys-cli/config 에서 SYS_WATCH 등을 설정할 수 있습니다." @@ -364,6 +366,228 @@ else exit 1 fi +run_follow_log_without_unit_argv() { + local match="$1" + local scope_flag="$2" + local use_sudo="$3" + shift 3 + + local filtered_args=("$@") + local prelude_args=("${filtered_args[@]}") + local follow_args=() + local has_lines=false + local colorize=0 + local -a prelude_cmd + local i arg cursor_file cursor + + if ! command -v python3 >/dev/null 2>&1; then + echo "sys: 안전한 log follow 모드에는 python3가 필요해요." >&2 + echo " python3를 설치한 뒤 다시 실행하세요." >&2 + exit 1 + fi + + for ((i = 0; i < ${#filtered_args[@]}; i++)); do + arg="${filtered_args[$i]}" + case "$arg" in + -o|--output|--output-fields) + echo "sys: log follow 모드에서는 output 형식 옵션($arg)을 사용할 수 없어요." >&2 + echo " sys가 내부적으로 journal JSON 형식을 사용해 unit 이름을 argv에서 숨깁니다." >&2 + exit 1 + ;; + -o*|--output=*|--output-fields=*) + echo "sys: log follow 모드에서는 output 형식 옵션($arg)을 사용할 수 없어요." >&2 + echo " sys가 내부적으로 journal JSON 형식을 사용해 unit 이름을 argv에서 숨깁니다." >&2 + exit 1 + ;; + -u|--unit|--unit=*|--user-unit|--user-unit=*|-u*) + echo "sys: log follow 모드에서는 unit 선택 옵션($arg)을 사용할 수 없어요." >&2 + echo " 대상 unit은 'sys log ' 인자로만 지정하세요." >&2 + exit 1 + ;; + -f|--follow) + echo "sys: log 명령은 기본적으로 follow 모드라서 $arg 옵션을 따로 받을 수 없어요." >&2 + exit 1 + ;; + --follow=*) + echo "sys: log 명령은 기본적으로 follow 모드라서 $arg 옵션을 따로 받을 수 없어요." >&2 + exit 1 + ;; + esac + + if [[ "$arg" == *"$match"* || "$arg" == *"${match}.service"* ]]; then + echo "sys: log follow 모드에서는 unit 이름이 포함된 옵션 인자를 받을 수 없어요: $arg" >&2 + echo " 장시간 실행되는 journalctl argv에 unit 이름이 남지 않도록 막는 보호 장치입니다." >&2 + exit 1 + fi + done + + for ((i = 0; i < ${#filtered_args[@]}; i++)); do + arg="${filtered_args[$i]}" + case "$arg" in + -n|--lines) + if [[ $((i + 1)) -ge ${#filtered_args[@]} || ! "${filtered_args[$((i + 1))]}" =~ ^([0-9]+|all)$ ]]; then + echo "sys: $arg 뒤에는 줄 수가 필요해요 (예: -n 100)." >&2 + exit 1 + fi + has_lines=true + ((i++)) + ;; + -n[0-9]*|--lines=*) + if [[ "$arg" == --lines=* && ! "${arg#--lines=}" =~ ^([0-9]+|all)$ ]]; then + echo "sys: --lines 뒤에는 줄 수가 필요해요 (예: --lines=100)." >&2 + exit 1 + fi + has_lines=true + ;; + -n*) + if [[ ! "${arg#-n}" =~ ^([0-9]+|all)$ ]]; then + echo "sys: -n 뒤에는 줄 수가 필요해요 (예: -n100)." >&2 + exit 1 + fi + has_lines=true + ;; + *) + follow_args+=("$arg") + ;; + esac + done + + if [[ "$has_lines" != true ]]; then + prelude_args=(-n 10 "${prelude_args[@]}") + fi + + if [[ "$use_sudo" == "sudo" ]]; then + sudo -v || exit 1 + fi + + [[ -n "$use_sudo" ]] && prelude_cmd+=("$use_sudo") + prelude_cmd+=(journalctl) + [[ -n "$scope_flag" ]] && prelude_cmd+=("$scope_flag") + prelude_cmd+=(-o json --no-pager -u "$match" "${prelude_args[@]}") + + cursor_file="$(mktemp "${TMPDIR:-/tmp}/sys-log-cursor.XXXXXX")" || exit 1 + "${prelude_cmd[@]}" 2>/dev/null | SYS_LOG_CURSOR_FILE="$cursor_file" python3 -c ' +import json +import os +import sys + +cursor_file = os.environ["SYS_LOG_CURSOR_FILE"] + +def message_text(value): + if isinstance(value, list): + return bytes(value).decode("utf-8", "replace") + return str(value) + +for line in sys.stdin: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + cursor = entry.get("__CURSOR") + message = entry.get("MESSAGE") + if message is None: + continue + + sys.stdout.write(message_text(message)) + sys.stdout.write("\n") + sys.stdout.flush() + + if cursor: + with open(cursor_file, "w", encoding="utf-8") as fh: + fh.write(cursor) +' + cursor="$(sed -n '$p' "$cursor_file" 2>/dev/null)" + rm -f "$cursor_file" + + command -v ccze >/dev/null 2>&1 && colorize=1 + + export SYS_LOG_UNIT="${match}.service" + export SYS_LOG_SCOPE="$scope_flag" + export SYS_LOG_USE_SUDO="$use_sudo" + export SYS_LOG_COLORIZE="$colorize" + export SYS_LOG_AFTER_CURSOR="$cursor" + export SYS_LOG_UID="$(id -u)" + + exec -a sys-log bash -c ' + journal_cmd=(journalctl) + [[ -n "${SYS_LOG_SCOPE:-}" ]] && journal_cmd+=("$SYS_LOG_SCOPE") + journal_cmd+=(-o json -f) + if [[ -n "${SYS_LOG_AFTER_CURSOR:-}" ]]; then + journal_cmd+=(-n all --after-cursor "$SYS_LOG_AFTER_CURSOR") + else + journal_cmd+=(-n 0) + fi + journal_cmd+=("$@") + [[ "${SYS_LOG_USE_SUDO:-}" == "sudo" ]] && journal_cmd=(sudo "${journal_cmd[@]}") + + filter_program='\'' +import json +import os +import sys + +unit = os.environ["SYS_LOG_UNIT"] +scope = os.environ.get("SYS_LOG_SCOPE", "") +uid = os.environ.get("SYS_LOG_UID", "") +coredump_message_id = "fc2e22bc6ee647b6b90729ab34a250b1" + +def message_text(value): + if isinstance(value, list): + return bytes(value).decode("utf-8", "replace") + return str(value) + +def matches_unit(entry): + if scope == "--user": + if entry.get("_SYSTEMD_USER_UNIT") == unit: + return True + if entry.get("USER_UNIT") == unit and entry.get("_UID") == uid: + return True + if entry.get("OBJECT_SYSTEMD_USER_UNIT") == unit and entry.get("_UID") == uid: + return True + return ( + entry.get("COREDUMP_USER_UNIT") == unit + and entry.get("_UID") == uid + and entry.get("MESSAGE_ID") == coredump_message_id + ) + + if entry.get("_SYSTEMD_UNIT") == unit: + return True + if entry.get("UNIT") == unit and entry.get("_PID") == "1": + return True + if entry.get("OBJECT_SYSTEMD_UNIT") == unit and entry.get("_UID") == "0": + return True + return ( + entry.get("COREDUMP_UNIT") == unit + and entry.get("_UID") == "0" + and entry.get("MESSAGE_ID") == coredump_message_id + ) + +for line in sys.stdin: + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + if not matches_unit(entry): + continue + + message = entry.get("MESSAGE") + if message is None: + continue + + sys.stdout.write(message_text(message)) + sys.stdout.write("\n") + sys.stdout.flush() + '\'' + + if [[ "${SYS_LOG_COLORIZE:-0}" == "1" ]]; then + "${journal_cmd[@]}" | python3 -c "$filter_program" | ccze -A + else + "${journal_cmd[@]}" | python3 -c "$filter_program" + fi + ' sys-log "${follow_args[@]}" +} + case "$cmd" in status|st) $use_sudo systemctl $scope_flag status "$match" ;; start) $use_sudo systemctl $scope_flag start "$match" ;; @@ -400,12 +624,9 @@ case "$cmd" in $use_sudo journalctl $scope_flag -o cat --no-pager -u "$match" "${filtered_args[@]}" 2>/dev/null | head -n "$head_count" fi else - # 기본 모드: -f 실시간 tail - if command -v ccze >/dev/null 2>&1; then - $use_sudo journalctl $scope_flag -o cat -f -u "$match" "${filtered_args[@]}" | ccze -A - else - $use_sudo journalctl $scope_flag -o cat -f -u "$match" "${filtered_args[@]}" - fi + # 기본 모드: unit 이름이 장시간 journalctl argv 에 남지 않도록 follow 구간은 + # 전체 journal JSON stream 을 읽고 환경변수의 unit 값으로 필터링한다. + run_follow_log_without_unit_argv "$match" "$scope_flag" "$use_sudo" "${filtered_args[@]}" fi ;; enable) $use_sudo systemctl $scope_flag enable "$match" ;;