mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
fix(logging): harden log storage and cleanup
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCliproxyDir } from '../cliproxy/config-generator';
|
||||
import { getNativeLogsDir } from '../services/logging';
|
||||
import { getLogArchiveDir, getNativeLogsDir } from '../services/logging';
|
||||
import { info, ok, warn } from '../utils/ui';
|
||||
|
||||
/** Default age in days for error log cleanup */
|
||||
@@ -24,6 +24,10 @@ function getCcsLogsDir(): string {
|
||||
return getNativeLogsDir();
|
||||
}
|
||||
|
||||
function getCcsLogArchiveDir(): string {
|
||||
return getLogArchiveDir();
|
||||
}
|
||||
|
||||
/** Format bytes to human-readable size */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -210,6 +214,7 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
|
||||
const cleanErrors = args.includes('--errors');
|
||||
const logsDir = getLogsDir();
|
||||
const ccsLogsDir = getCcsLogsDir();
|
||||
const ccsArchiveDir = getCcsLogArchiveDir();
|
||||
|
||||
// Parse --days=N option
|
||||
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
|
||||
@@ -227,7 +232,13 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
|
||||
if (cleanErrors) {
|
||||
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
|
||||
} else {
|
||||
await handleMainLogCleanup({ cliproxyLogsDir: logsDir, ccsLogsDir, dryRun, force });
|
||||
await handleMainLogCleanup({
|
||||
cliproxyLogsDir: logsDir,
|
||||
ccsLogsDir,
|
||||
ccsArchiveDir,
|
||||
dryRun,
|
||||
force,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,11 +336,13 @@ async function handleErrorLogCleanup(
|
||||
async function handleMainLogCleanup(options: {
|
||||
cliproxyLogsDir: string;
|
||||
ccsLogsDir: string;
|
||||
ccsArchiveDir: string;
|
||||
dryRun: boolean;
|
||||
force: boolean;
|
||||
}): Promise<void> {
|
||||
const targets = [
|
||||
{ label: 'CCS Logs', dir: options.ccsLogsDir },
|
||||
{ label: 'CCS Log Archives', dir: options.ccsArchiveDir },
|
||||
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
|
||||
].map((target) => ({
|
||||
...target,
|
||||
|
||||
@@ -23,8 +23,8 @@ export function getLegacyCliproxyLogsDir(): string {
|
||||
}
|
||||
|
||||
export function ensureLoggingDirectories(): void {
|
||||
fs.mkdirSync(getNativeLogsDir(), { recursive: true });
|
||||
fs.mkdirSync(getLogArchiveDir(), { recursive: true });
|
||||
fs.mkdirSync(getNativeLogsDir(), { recursive: true, mode: 0o700 });
|
||||
fs.mkdirSync(getLogArchiveDir(), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean {
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { handleCleanupCommand } from '../../../src/commands/cleanup-command';
|
||||
import { getCliproxyDir } from '../../../src/cliproxy/config-generator';
|
||||
import { getNativeLogsDir } from '../../../src/services/logging';
|
||||
import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging';
|
||||
|
||||
describe('cleanup command', () => {
|
||||
let tempHome = '';
|
||||
@@ -27,9 +27,9 @@ describe('cleanup command', () => {
|
||||
tempHome = '';
|
||||
});
|
||||
|
||||
it('reports only top-level log sizes in dry-run mode', async () => {
|
||||
it('reports CCS archives alongside current logs in dry-run mode', async () => {
|
||||
const ccsLogsDir = getNativeLogsDir();
|
||||
const archiveDir = path.join(ccsLogsDir, 'archive');
|
||||
const archiveDir = getLogArchiveDir();
|
||||
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
|
||||
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
@@ -47,8 +47,8 @@ describe('cleanup command', () => {
|
||||
.join('\n');
|
||||
|
||||
expect(output).toContain('CCS Logs: 1 files (100.00 B)');
|
||||
expect(output).toContain('Would delete 1 files (100.00 B)');
|
||||
expect(output).not.toContain('1.95 KB');
|
||||
expect(output).toContain('CCS Log Archives: 1 files (1.95 KB)');
|
||||
expect(output).toContain('Would delete 2 files (2.05 KB)');
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ensureLoggingDirectories,
|
||||
getCurrentLogPath,
|
||||
getLogArchiveDir,
|
||||
getNativeLogsDir,
|
||||
@@ -43,4 +44,14 @@ describe('logging path helpers', () => {
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('creates log directories with restrictive permissions', () => {
|
||||
ensureLoggingDirectories();
|
||||
|
||||
const logsMode = fs.statSync(getNativeLogsDir()).mode & 0o777;
|
||||
const archiveMode = fs.statSync(getLogArchiveDir()).mode & 0o777;
|
||||
|
||||
expect(logsMode).toBe(0o700);
|
||||
expect(archiveMode).toBe(0o700);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as zlib from 'zlib';
|
||||
import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types';
|
||||
import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader';
|
||||
import { clearRecentLogEntries } from '../../../../src/services/logging/log-buffer';
|
||||
import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config';
|
||||
import { getCurrentLogPath, getLogArchiveDir } from '../../../../src/services/logging/log-paths';
|
||||
import {
|
||||
appendStructuredLogEntry,
|
||||
pruneExpiredLogArchives,
|
||||
} from '../../../../src/services/logging/log-storage';
|
||||
import type { LogEntry } from '../../../../src/services/logging/log-types';
|
||||
|
||||
function createEntry(overrides: Partial<LogEntry>): LogEntry {
|
||||
return {
|
||||
id: overrides.id ?? 'entry-1',
|
||||
timestamp: overrides.timestamp ?? new Date().toISOString(),
|
||||
level: overrides.level ?? 'info',
|
||||
source: overrides.source ?? 'unit:test',
|
||||
event: overrides.event ?? 'test.event',
|
||||
message: overrides.message ?? 'message',
|
||||
processId: overrides.processId ?? 1234,
|
||||
runId: overrides.runId ?? 'run-1',
|
||||
context: overrides.context ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('log storage', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-storage-'));
|
||||
process.env.CCS_HOME = tempHome;
|
||||
clearRecentLogEntries();
|
||||
invalidateLoggingConfigCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
clearRecentLogEntries();
|
||||
invalidateLoggingConfigCache();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
tempHome = '';
|
||||
});
|
||||
|
||||
it('rotates the current log into the archive when the file exceeds the age threshold', () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.logging.retain_days = 7;
|
||||
config.logging.rotate_mb = 10;
|
||||
saveUnifiedConfig(config);
|
||||
invalidateLoggingConfigCache();
|
||||
|
||||
const currentLogPath = getCurrentLogPath();
|
||||
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
currentLogPath,
|
||||
`${JSON.stringify(createEntry({ id: 'old-entry' }))}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const staleTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
fs.utimesSync(currentLogPath, staleTimestamp, staleTimestamp);
|
||||
|
||||
appendStructuredLogEntry(
|
||||
createEntry({
|
||||
id: 'new-entry',
|
||||
message: 'new log entry after rotation',
|
||||
})
|
||||
);
|
||||
|
||||
const archiveDir = getLogArchiveDir();
|
||||
const archives = fs.readdirSync(archiveDir);
|
||||
expect(archives).toHaveLength(1);
|
||||
|
||||
const archivedContent = zlib
|
||||
.gunzipSync(fs.readFileSync(path.join(archiveDir, archives[0])))
|
||||
.toString('utf8');
|
||||
expect(archivedContent).toContain('"id":"old-entry"');
|
||||
|
||||
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
|
||||
expect(currentContent).toContain('"id":"new-entry"');
|
||||
expect(currentContent).not.toContain('"id":"old-entry"');
|
||||
});
|
||||
|
||||
it('prunes expired archives according to retention settings', () => {
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.logging.retain_days = 1;
|
||||
saveUnifiedConfig(config);
|
||||
invalidateLoggingConfigCache();
|
||||
|
||||
const archiveDir = getLogArchiveDir();
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
|
||||
const oldArchive = path.join(archiveDir, 'ccs-old.jsonl.gz');
|
||||
const freshArchive = path.join(archiveDir, 'ccs-fresh.jsonl.gz');
|
||||
fs.writeFileSync(oldArchive, zlib.gzipSync('old archive'), { mode: 0o600 });
|
||||
fs.writeFileSync(freshArchive, zlib.gzipSync('fresh archive'), { mode: 0o600 });
|
||||
|
||||
const oldTimestamp = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
|
||||
const freshTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
fs.utimesSync(oldArchive, oldTimestamp, oldTimestamp);
|
||||
fs.utimesSync(freshArchive, freshTimestamp, freshTimestamp);
|
||||
|
||||
pruneExpiredLogArchives();
|
||||
|
||||
expect(fs.existsSync(oldArchive)).toBe(false);
|
||||
expect(fs.existsSync(freshArchive)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user