diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 6b8e1d6c..35bd81eb 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -16,10 +16,14 @@ LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="andras@schmelczer.dev" COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js +COPY --from=builder /build/local-client-cli/dist/healthcheck.js /app/healthcheck.js + +HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=1 \ + CMD node /app/healthcheck.js /tmp/vaultlink-health.json WORKDIR /vault VOLUME ["/vault"] -ENTRYPOINT ["node", "/app/cli.js"] +ENTRYPOINT ["node", "/app/cli.js", "--health", "/tmp/vaultlink-health.json"] CMD ["--help"] diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 08ef2a6b..961cadb5 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -12,6 +12,7 @@ export interface CliArgs { ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; logLevel: LogLevel; + health?: string; } export function parseArgs(argv: string[]): CliArgs { @@ -51,6 +52,10 @@ export function parseArgs(argv: string[]): CliArgs { "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", "INFO" ) + .option( + "--health ", + "[OPTIONAL] Path to health status file for Docker healthcheck" + ) .addHelpText( "after", ` @@ -78,6 +83,7 @@ Examples: | number | undefined; const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; + const health = opts.health as string | undefined; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ if (localPath === undefined) { @@ -117,6 +123,7 @@ Examples: maxFileSizeMB: maxFileSizeMb, ignorePatterns: ignorePattern, webSocketRetryIntervalMs: websocketRetryIntervalMs, - logLevel + logLevel, + health }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 5a3c6546..8ef7a45a 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -1,5 +1,7 @@ import * as path from "path"; import * as fs from "fs/promises"; +import * as fsSync from "fs"; +import type { NetworkConnectionStatus } from "sync-client"; import { SyncClient, DEFAULT_SETTINGS, @@ -13,6 +15,19 @@ import { FileWatcher } from "./file-watcher"; import { formatLogLine, colorize, styleText } from "./logger-formatter"; import packageJson from "../package.json"; +function writeHealthStatus( + filePath: string, + connectionStatus: NetworkConnectionStatus +): void { + try { + fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); + } catch (error) { + console.error( + `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + const LOG_LEVEL_ORDER = { [LogLevel.DEBUG]: 0, [LogLevel.INFO]: 1, @@ -78,6 +93,7 @@ async function main(): Promise { syncConcurrency: args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, + diffCacheSizeMB: DEFAULT_SETTINGS.diffCacheSizeMB, ignorePatterns, webSocketRetryIntervalMs: args.webSocketRetryIntervalMs ?? @@ -119,6 +135,15 @@ async function main(): Promise { nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" }); + if (args.health !== undefined) { + const healthFile = args.health; + setInterval(() => { + void client.checkConnection().then((status) => { + writeHealthStatus(healthFile, status); + }); + }, 30 * 1000); // every 30 seconds + } + // Add colored log formatter with level filtering client.logger.addOnMessageListener((logLine) => { // Only show messages at or above the configured log level @@ -132,7 +157,10 @@ async function main(): Promise { const fileWatcher = new FileWatcher(absolutePath, client); client.addWebSocketStatusChangeListener(() => { - client.logger.info("WebSocket status changed"); + const isConnected = client.isWebSocketConnected; + client.logger.info( + `WebSocket status changed: ${isConnected ? "connected" : "disconnected"}` + ); }); client.addRemainingSyncOperationsListener((remaining) => { diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts new file mode 100644 index 00000000..a16292d1 --- /dev/null +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +/** + * Healthcheck script for Docker container + * Checks if the sync client is connected to the server + */ + +import * as fs from "fs"; +import type { NetworkConnectionStatus } from "sync-client"; + +function isHealthStatus(value: unknown): value is NetworkConnectionStatus { + if (typeof value !== "object" || value === null) { + return false; + } + + return true; +} + +function main(): void { + if (process.argv.length < 3) { + console.error("Usage: healthcheck "); + process.exit(1); + } + const [, , healthFile] = process.argv; + + try { + // Check if health file exists + if (!fs.existsSync(healthFile)) { + console.error(`Health file does not exist: ${healthFile}`); + process.exit(1); + } + + // Read and parse health status + const content = fs.readFileSync(healthFile, "utf-8"); + const parsed: unknown = JSON.parse(content); + + // Validate the parsed object using type guard + if (!isHealthStatus(parsed)) { + throw new Error("Invalid health status format"); + } + + const status = parsed; + + if (!status.isSuccessful || !status.isWebSocketConnected) { + console.error("Not connected to server: " + status.serverMessage); + process.exit(1); + } + + console.log("Healthy: Connected to server"); + process.exit(0); + } catch (error) { + console.error( + `Health check failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } +} + +main(); diff --git a/frontend/local-client-cli/webpack.config.js b/frontend/local-client-cli/webpack.config.js index e17754b2..32b3b125 100644 --- a/frontend/local-client-cli/webpack.config.js +++ b/frontend/local-client-cli/webpack.config.js @@ -2,7 +2,10 @@ const path = require("path"); const webpack = require("webpack"); module.exports = { - entry: "./src/cli.ts", + entry: { + cli: "./src/cli.ts", + healthcheck: "./src/healthcheck.ts" + }, target: "node", mode: "production", optimization: { @@ -21,7 +24,7 @@ module.exports = { }, output: { globalObject: "this", - filename: "cli.js", + filename: "[name].js", path: path.resolve(__dirname, "dist") }, plugins: [