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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ user service와 system service를 자동으로 감지하고, 번호 ID로 짧게
- user/system 자동 감지 (sudo 알아서 붙임)
- `.service` 확장자 생략 가능
- ccze 설치되어 있으면 로그에 색상 자동 적용
- `sys log` follow 중 unit 이름이 장시간 프로세스 argv에 남지 않도록 처리

## 설치

Expand Down Expand Up @@ -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 <unit>` 자체를 같은 서비스 프로세스로 오인할 수 있기 때문입니다.

같은 이유로 follow 모드에서는 추가 journalctl 옵션 인자 안에 대상 unit 이름이 직접 들어가는 경우도 거부합니다.

## 설정 (`~/.config/sys-cli/config`)

Expand Down Expand Up @@ -175,6 +180,7 @@ sudo loginctl enable-linger $USER

- bash 4+
- systemd
- python3 — `sys log` follow 모드의 안전한 journal JSON 필터링에 사용
- (선택) `ccze` — 로그 색상화. install.sh가 자동 설치 여부를 물어봄.

## 업데이트
Expand Down
9 changes: 9 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
233 changes: 227 additions & 6 deletions sys
Original file line number Diff line number Diff line change
Expand Up @@ -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 등을 설정할 수 있습니다."
Expand Down Expand Up @@ -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 <UNIT>' 인자로만 지정하세요." >&2
exit 1
;;
-f|--follow)
echo "sys: log 명령은 기본적으로 follow 모드라서 $arg 옵션을 따로 받을 수 없어요." >&2
exit 1
;;
--follow=*)
echo "sys: log 명령은 기본적으로 follow 모드라서 $arg 옵션을 따로 받을 수 없어요." >&2
exit 1
Comment on lines +407 to +413
;;
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept journalctl's leading-plus line counts

This new validation rejects sys log <unit> -n +100 and the --lines=+100/-n+100 variants even though these were previously passed directly to journalctl and are valid; I checked journalctl --help, which advertises -n --lines[=[+]INTEGER]. In follow mode this now exits before journalctl runs, so users relying on journalctl's leading-plus line-count semantics lose a supported passthrough option.

Useful? React with 👍 / 👎.

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dropping logs when there is no cursor

If the prelude finds no matching entry, SYS_LOG_AFTER_CURSOR is empty and the long-lived follower starts with -n 0; journalctl --help describes -n as the number of entries to show, so this only follows entries that arrive after that second journalctl process is attached. Because the new implementation splits the old single journalctl -f -u invocation into a prelude and a later unscoped follower, any first log line written by the unit in the gap between those two commands is silently missed.

Useful? React with 👍 / 👎.

fi
journal_cmd+=("$@")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle --no-tail before passing it to the follower

When a user runs sys log <unit> --no-tail, journalctl defines --no-tail as showing all lines even in follow mode. The prelude already receives that option and prints the unit's history, but passing the same option through here makes the unscoped follower replay the entire journal again before Python filters it, so the target unit's historical logs are emitted a second time and the command scans all journals unnecessarily. This regresses the documented journalctl passthrough behavior for --no-tail; either consume it in the prelude path or reject it like the other unsupported follow options.

Useful? React with 👍 / 👎.

[[ "${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" ;;
Expand Down Expand Up @@ -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" ;;
Expand Down