135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
|
|
"""Exercise orphan cleanup against temporary kubelet and log inventories."""
|
||
|
|
|
||
|
|
import importlib.util
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import time
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
SOURCE = Path(__file__).resolve().parents[2] / 'services/maintenance/node-ops/scripts/node_pod_log_cleanup.py'
|
||
|
|
spec = importlib.util.spec_from_file_location('node_pod_log_cleanup', SOURCE)
|
||
|
|
cleaner = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(cleaner)
|
||
|
|
ACTIVE = '11111111-1111-1111-1111-111111111111'
|
||
|
|
ORPHAN = '22222222-2222-2222-2222-222222222222'
|
||
|
|
|
||
|
|
|
||
|
|
def inventory(root):
|
||
|
|
"""Create one current pod with a UID-only kubelet directory."""
|
||
|
|
(root / 'var/lib/kubelet/pods' / ACTIVE).mkdir(parents=True)
|
||
|
|
|
||
|
|
|
||
|
|
def logs(root, uid, relative='var/log/pods', old=True):
|
||
|
|
"""Create the real namespace_name_UID directory layout with a synthetic log."""
|
||
|
|
path = root / relative / ('namespace_name_' + uid)
|
||
|
|
(path / 'container').mkdir(parents=True)
|
||
|
|
(path / 'container/0.log').write_text('SYNTHETIC LOG CONTENT')
|
||
|
|
if old:
|
||
|
|
stamp = time.time() - 10 * 86400
|
||
|
|
for entry in [*path.rglob('*'), path]:
|
||
|
|
os.utime(entry, (stamp, stamp))
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
def test_active_uid_preserves_old_current_and_hdd_logs(tmp_path):
|
||
|
|
"""Old quiet containers remain protected in both log locations."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
paths = [logs(tmp_path, ACTIVE, relative=r) for r in ('var/log/pods', 'var/log.hdd/pods')]
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['removed'] == 0
|
||
|
|
assert all((p / 'container/0.log').exists() for p in paths)
|
||
|
|
|
||
|
|
|
||
|
|
def test_only_expired_orphans_are_removed(tmp_path):
|
||
|
|
"""Dry-run reports the same eligible directory without deleting it."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
path = logs(tmp_path, ORPHAN)
|
||
|
|
assert cleaner.cleanup(tmp_path, 3, dry_run=True)['eligible'] == 1
|
||
|
|
assert path.exists()
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['removed'] == 1
|
||
|
|
assert not path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_recent_log_preserves_old_parent_directory(tmp_path):
|
||
|
|
"""Writing a log does not update the pod directory's own mtime."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
path = logs(tmp_path, ORPHAN)
|
||
|
|
os.utime(path / 'container/0.log', None)
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['skipped'] == 1
|
||
|
|
assert path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize('empty', [False, True])
|
||
|
|
def test_unavailable_inventory_cannot_authorize_deletion(tmp_path, empty):
|
||
|
|
"""Missing and empty inventories both preserve all candidate logs."""
|
||
|
|
if empty:
|
||
|
|
(tmp_path / 'var/lib/kubelet/pods').mkdir(parents=True)
|
||
|
|
path = logs(tmp_path, ORPHAN)
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['inventory_unavailable'] == 1
|
||
|
|
assert path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_symlinks_unknown_names_and_nondirectories_are_preserved(tmp_path):
|
||
|
|
"""Only recognized pod directories enter deletion; links never escape root."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
parent = tmp_path / 'var/log/pods'
|
||
|
|
parent.mkdir(parents=True)
|
||
|
|
outside = tmp_path / 'outside'
|
||
|
|
outside.mkdir()
|
||
|
|
(parent / ('ns_link_' + ORPHAN)).symlink_to(outside)
|
||
|
|
(parent / 'unrecognized').mkdir()
|
||
|
|
(parent / ('ns_file_' + ORPHAN)).write_text('metadata')
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['removed'] == 0
|
||
|
|
assert outside.exists()
|
||
|
|
assert (parent / 'unrecognized').exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_scan_failure_preserves_candidate(tmp_path, monkeypatch):
|
||
|
|
"""An unreadable log subtree must not look like an old empty directory."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
path = logs(tmp_path, ORPHAN)
|
||
|
|
|
||
|
|
def fail_scan(*args, **kwargs):
|
||
|
|
"""Simulate the walker reporting a storage error."""
|
||
|
|
kwargs['onerror'](OSError('synthetic scan failure'))
|
||
|
|
|
||
|
|
monkeypatch.setattr(cleaner.os, 'walk', fail_scan)
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['skipped'] == 1
|
||
|
|
assert path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize('new_active', [True, False])
|
||
|
|
def test_inventory_change_before_deletion_is_rechecked(tmp_path, monkeypatch, new_active):
|
||
|
|
"""A newly active UID or vanished inventory invalidates the initial decision."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
path = logs(tmp_path, ORPHAN)
|
||
|
|
original = cleaner.newest_mtime
|
||
|
|
|
||
|
|
def change_inventory(candidate):
|
||
|
|
"""Alter only inventory metadata after the age scan."""
|
||
|
|
newest = original(candidate)
|
||
|
|
pods = tmp_path / 'var/lib/kubelet/pods'
|
||
|
|
if new_active:
|
||
|
|
(pods / ORPHAN).mkdir()
|
||
|
|
else:
|
||
|
|
(pods / ACTIVE).rmdir()
|
||
|
|
return newest
|
||
|
|
|
||
|
|
monkeypatch.setattr(cleaner, 'newest_mtime', change_inventory)
|
||
|
|
assert cleaner.cleanup(tmp_path, 3)['removed'] == 0
|
||
|
|
assert path.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_cli_reports_counts_and_rejects_zero_retention(tmp_path, monkeypatch, capsys):
|
||
|
|
"""No content or candidate path enters the operator's cleanup summary."""
|
||
|
|
inventory(tmp_path)
|
||
|
|
path = logs(tmp_path, ORPHAN)
|
||
|
|
monkeypatch.setattr('sys.argv', ['clean', '--host-root', str(tmp_path), '--dry-run'])
|
||
|
|
cleaner.main()
|
||
|
|
result = json.loads(capsys.readouterr().out)
|
||
|
|
assert result['eligible'] == 1 and result['removed'] == 0
|
||
|
|
assert path.exists()
|
||
|
|
with pytest.raises(ValueError, match='at least one day'):
|
||
|
|
cleaner.cleanup(tmp_path, 0)
|