diff --git a/cli/src/core/environment.zig b/cli/src/core/environment.zig new file mode 100644 index 0000000..7f9ad98 --- /dev/null +++ b/cli/src/core/environment.zig @@ -0,0 +1,272 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +/// Environment detection for smart defaults +/// Detects local dev, container, CI, or production environments +pub const Environment = enum { + local, // Local development + container, // Docker, Kubernetes, etc. + ci, // CI/CD pipeline (GitHub Actions, GitLab CI, etc.) + production, // Production deployment + + const Self = @This(); + + /// Auto-detect current environment + pub fn detect() Self { + // Check for CI environments first (highest priority) + if (isCI()) return .ci; + + // Check for container environment + if (isContainer()) return .container; + + // Check for production + if (isProduction()) return .production; + + // Default to local + return .local; + } + + /// Check if running in CI environment + fn isCI() bool { + const ci_vars = &[_][]const u8{ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_URL", + "CIRCLECI", + "TRAVIS", + "BUILDKITE", + "DRONE", + "CODEBUILD_BUILD_ID", + }; + + for (ci_vars) |var_name| { + if (std.posix.getenv(var_name)) |_| return true; + } + + return false; + } + + /// Check if running in container + fn isContainer() bool { + // Check for .dockerenv file + if (std.fs.accessAbsolute("/.dockerenv", .{})) { + return true; + } else |_| {} + + // Check for Kubernetes + if (std.posix.getenv("KUBERNETES_SERVICE_HOST")) |_| return true; + + // Check for container-specific env vars + if (std.posix.getenv("CONTAINER")) |_| return true; + + // Check cgroup for container indicators + if (checkCgroup()) return true; + + return false; + } + + /// Check cgroup for container indicators + fn checkCgroup() bool { + const cgroup_path = "/proc/self/cgroup"; + const file = std.fs.openFileAbsolute(cgroup_path, .{}) catch return false; + defer file.close(); + + const content = file.readToEndAlloc(std.heap.c_allocator, 4096) catch return false; + defer std.heap.c_allocator.free(content); + + // Check for container indicators in cgroup + if (std.mem.indexOf(u8, content, "docker") != null) return true; + if (std.mem.indexOf(u8, content, "kubepods") != null) return true; + if (std.mem.indexOf(u8, content, "containerd") != null) return true; + + return false; + } + + /// Check if production environment + fn isProduction() bool { + const prod_vars = &[_][]const u8{ + "FETCH_ML_ENV", + "ENV", + "NODE_ENV", + "RAILS_ENV", + "APP_ENV", + }; + + for (prod_vars) |var_name| { + if (std.posix.getenv(var_name)) |val| { + if (std.mem.eql(u8, val, "production") or + std.mem.eql(u8, val, "prod")) + { + return true; + } + } + } + + return false; + } + + /// Get description of environment + pub fn description(self: Self) []const u8 { + return switch (self) { + .local => "Local development", + .container => "Container environment", + .ci => "CI/CD pipeline", + .production => "Production deployment", + }; + } +}; + +/// Smart defaults configuration +pub const SmartDefaults = struct { + env: Environment, + + const Self = @This(); + + pub fn init() Self { + return .{ + .env = Environment.detect(), + }; + } + + /// Get default host based on environment + pub fn host(self: Self) []const u8 { + return switch (self.env) { + .local => "localhost", + .container => "host.docker.internal", + .ci => "localhost", + .production => "0.0.0.0", + }; + } + + /// Get default base path for experiments + pub fn basePath(self: Self) []const u8 { + return switch (self.env) { + .local => "~/ml-experiments", + .container => "/workspace/ml-experiments", + .ci => "/tmp/ml-experiments", + .production => "/var/lib/fetch_ml/experiments", + }; + } + + /// Get default data directory + pub fn dataDir(self: Self) []const u8 { + return switch (self.env) { + .local => "~/ml-data", + .container => "/workspace/data", + .ci => "/tmp/ml-data", + .production => "/var/lib/fetch_ml/data", + }; + } + + /// Get default config path + pub fn configPath(self: Self) []const u8 { + return switch (self.env) { + .local => "~/.ml/config.toml", + .container => "/workspace/.ml/config.toml", + .ci => "/tmp/.ml/config.toml", + .production => "/etc/fetch_ml/config.toml", + }; + } + + /// Get default Redis address + pub fn redisAddr(self: Self) []const u8 { + return switch (self.env) { + .local => "localhost:6379", + .container => "redis:6379", + .ci => "localhost:6379", + .production => "redis:6379", + }; + } + + /// Get default SSH key path + pub fn sshKeyPath(self: Self) []const u8 { + return switch (self.env) { + .local => "~/.ssh/id_rsa", + .container => "/workspace/.ssh/id_rsa", + .ci => "/tmp/.ssh/id_rsa", + .production => "/etc/fetch_ml/ssh/id_rsa", + }; + } + + /// Get default worker count + pub fn workerCount(self: Self) u32 { + return switch (self.env) { + .local => 2, + .container => 2, + .ci => 1, // Minimal for fast testing + .production => @intCast(std.Thread.getCpuCount() catch 4), + }; + } + + /// Get default poll interval (seconds) + pub fn pollInterval(self: Self) u32 { + return switch (self.env) { + .local => 5, + .container => 5, + .ci => 1, // Fast polling for CI + .production => 10, + }; + } + + /// Get default log level + pub fn logLevel(self: Self) []const u8 { + return switch (self.env) { + .local => "info", + .container => "info", + .ci => "debug", // Verbose for debugging + .production => "warn", // Quieter in production + }; + } + + /// Get default verbosity (for CLI output) + pub fn verbose(self: Self) bool { + return switch (self.env) { + .local => true, + .container => false, + .ci => true, // Verbose for CI logs + .production => false, + }; + } + + /// Get default JSON output mode + pub fn jsonOutput(self: Self) bool { + return switch (self.env) { + .local => false, + .container => true, // JSON for containerized environments + .ci => true, // JSON for CI parsing + .production => true, + }; + } + + /// Print environment info (for debugging) + pub fn printInfo(self: Self, writer: anytype) !void { + try writer.print("Environment: {s} ({s})\n", .{ + @tagName(self.env), + self.env.description(), + }); + try writer.print(" Host: {s}\n", .{self.host()}); + try writer.print(" Base Path: {s}\n", .{self.basePath()}); + try writer.print(" Config: {s}\n", .{self.configPath()}); + try writer.print(" Redis: {s}\n", .{self.redisAddr()}); + try writer.print(" Workers: {d}\n", .{self.workerCount()}); + try writer.print(" Poll Interval: {d}s\n", .{self.pollInterval()}); + try writer.print(" Log Level: {s}\n", .{self.logLevel()}); + } +}; + +/// Convenience function to get smart defaults +pub fn getSmartDefaults() SmartDefaults { + return SmartDefaults.init(); +} + +test "environment detection" { + // Just verify the functions don't crash + const env = Environment.detect(); + _ = env.description(); + + const smart = SmartDefaults.init(); + _ = smart.host(); + _ = smart.basePath(); + _ = smart.configPath(); +} diff --git a/cli/src/core/signals.zig b/cli/src/core/signals.zig new file mode 100644 index 0000000..8d024b2 --- /dev/null +++ b/cli/src/core/signals.zig @@ -0,0 +1,165 @@ +const std = @import("std"); + +/// Ctrl+C / SIGINT handling for graceful shutdown +/// Implements UX contract semantics from cli-tui-ux-contract-v1.md +pub const SignalHandler = struct { + /// Current operation type for context-aware shutdown + pub const Operation = enum { + idle, + queue, // Job submission + dry_run, // Validation only + watch, // Status watch mode + sync, // File synchronization + hash, // Dataset hashing + other, + }; + + var current_operation: Operation = .idle; + var shutdown_requested: bool = false; + var original_termios: ?std.posix.termios = null; + + /// Initialize signal handler + pub fn init() void { + // Set up Ctrl+C handler + const act = std.posix.Sigaction{ + .handler = .{ .handler = handleSignal }, + .mask = std.posix.empty_sigset, + .flags = 0, + }; + + std.posix.sigaction(std.posix.SIG.INT, &act, null) catch |err| { + std.log.warn("Failed to set up SIGINT handler: {}", .{err}); + }; + + // Also handle SIGTERM for container environments + std.posix.sigaction(std.posix.SIG.TERM, &act, null) catch |err| { + std.log.warn("Failed to set up SIGTERM handler: {}", .{err}); + }; + } + + /// Signal handler callback + fn handleSignal(sig: i32) callconv(.C) void { + _ = sig; + shutdown_requested = true; + + // Context-aware behavior per UX contract + switch (current_operation) { + .dry_run => { + // Immediate exit, no side effects + std.log.info("Dry-run interrupted - exiting immediately", .{}); + std.process.exit(0); + }, + .queue => { + // Attempt to cancel submission, show status + std.log.info("Queue interrupted - attempting to cancel...", .{}); + // Note: actual cancellation logic handled by caller + }, + .watch => { + // Exit watch mode gracefully + std.log.info("Watch mode interrupted - exiting", .{}); + // Caller should detect shutdown_requested and exit cleanly + }, + .sync => { + // Stop after current file, preserve partial state + std.log.info("Sync interrupted - stopping after current operation", .{}); + }, + .hash => { + // Stop hashing, preserve partial results + std.log.info("Hash operation interrupted", .{}); + }, + .idle, .other => { + std.log.info("Interrupted - cleaning up...", .{}); + }, + } + } + + /// Set current operation for context-aware handling + pub fn setOperation(op: Operation) void { + current_operation = op; + } + + /// Clear current operation + pub fn clearOperation() void { + current_operation = .idle; + } + + /// Check if shutdown was requested + pub fn isShutdownRequested() bool { + return shutdown_requested; + } + + /// Reset shutdown flag (for testing) + pub fn reset() void { + shutdown_requested = false; + current_operation = .idle; + } + + /// Run cleanup and exit gracefully + pub fn shutdown(exit_code: u8) noreturn { + // Restore terminal state if modified + if (original_termios) |termios| { + _ = std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, &termios) catch {}; + } + + std.log.info("Shutting down gracefully (exit code: {d})", .{exit_code}); + std.process.exit(exit_code); + } + + /// Wait for condition or shutdown request + pub fn waitOrShutdown(condition: *bool, timeout_ms: u32) bool { + const start = std.time.milliTimestamp(); + while (!condition.*) { + if (shutdown_requested) return false; + + const now = std.time.milliTimestamp(); + if (now - start > timeout_ms) return false; + + std.time.sleep(100_000); // 100ms + } + return true; + } + + /// Print current status before exiting + pub fn printStatus(writer: anytype) !void { + try writer.print("\nInterrupted during: {s}\n", .{@tagName(current_operation)}); + try writer.writeAll("Current operation status preserved.\n"); + } +}; + +/// Convenience functions +pub fn init() void { + SignalHandler.init(); +} + +pub fn setOperation(op: SignalHandler.Operation) void { + SignalHandler.setOperation(op); +} + +pub fn clearOperation() void { + SignalHandler.clearOperation(); +} + +pub fn isShutdownRequested() bool { + return SignalHandler.isShutdownRequested(); +} + +pub fn checkAndShutdown(exit_code: u8) void { + if (SignalHandler.isShutdownRequested()) { + SignalHandler.shutdown(exit_code); + } +} + +/// RAII guard for operation context +pub const OperationGuard = struct { + pub fn init(op: SignalHandler.Operation) OperationGuard { + setOperation(op); + return .{}; + } + + pub fn deinit(_: OperationGuard) void { + clearOperation(); + } +}; + +/// Export for integration with main.zig +pub const default_signal_handler = SignalHandler.init; diff --git a/cli/src/utils/secrets.zig b/cli/src/utils/secrets.zig new file mode 100644 index 0000000..dd2f872 --- /dev/null +++ b/cli/src/utils/secrets.zig @@ -0,0 +1,247 @@ +const std = @import("std"); + +/// Secrets redaction for secure logging +/// Prevents accidental exposure of sensitive data in logs/output +pub const SecretsFilter = struct { + allocator: std.mem.Allocator, + patterns: std.ArrayList(Pattern), + + const Pattern = struct { + name: []const u8, + regex: []const u8, // Simplified pattern matching + prefix: []const u8, + suffix: []const u8, + }; + + const Self = @This(); + + /// Common secret patterns + const DEFAULT_PATTERNS = &[_]Pattern{ + .{ + .name = "API Key", + .regex = "", + .prefix = "api_key", + .suffix = "", + }, + .{ + .name = "API Key (alt)", + .regex = "", + .prefix = "api-key", + .suffix = "", + }, + .{ + .name = "Authorization", + .regex = "", + .prefix = "Authorization: Bearer ", + .suffix = "", + }, + .{ + .name = "Password", + .regex = "", + .prefix = "password", + .suffix = "", + }, + .{ + .name = "Secret", + .regex = "", + .prefix = "secret", + .suffix = "", + }, + .{ + .name = "Token", + .regex = "", + .prefix = "token", + .suffix = "", + }, + .{ + .name = "Private Key", + .regex = "", + .prefix = "-----BEGIN", + .suffix = "-----END", + }, + .{ + .name = "AWS Access Key", + .regex = "", + .prefix = "AKIA", + .suffix = "", + }, + .{ + .name = "GitHub Token", + .regex = "", + .prefix = "ghp_", + .suffix = "", + }, + .{ + .name = "Slack Token", + .regex = "", + .prefix = "xoxb-", + .suffix = "", + }, + }; + + pub fn init(allocator: std.mem.Allocator) Self { + var patterns = std.ArrayList(Pattern).init(allocator); + + // Add default patterns + for (DEFAULT_PATTERNS) |p| { + patterns.append(p) catch {}; + } + + return .{ + .allocator = allocator, + .patterns = patterns, + }; + } + + pub fn deinit(self: *Self) void { + self.patterns.deinit(); + } + + /// Add custom pattern + pub fn addPattern(self: *Self, name: []const u8, prefix: []const u8) !void { + try self.patterns.append(.{ + .name = name, + .regex = "", + .prefix = prefix, + .suffix = "", + }); + } + + /// Scan and redact secrets from text + pub fn redact(self: *Self, text: []const u8) ![]const u8 { + // Simple prefix-based redaction + var result = try self.allocator.dupe(u8, text); + errdefer self.allocator.free(result); + + for (self.patterns.items) |pattern| { + result = try self.redactPattern(result, pattern); + } + + return result; + } + + /// Redact a specific pattern + fn redactPattern(self: *Self, text: []const u8, pattern: Pattern) ![]const u8 { + var result = std.ArrayList(u8).init(self.allocator); + defer result.deinit(); + + var i: usize = 0; + while (i < text.len) { + // Check for pattern prefix + if (i + pattern.prefix.len <= text.len and + std.mem.eql(u8, text[i..][0..pattern.prefix.len], pattern.prefix)) + { + // Found pattern, write prefix and redaction marker + try result.appendSlice(pattern.prefix); + try result.appendSlice(" [REDACTED]"); + + // Skip until end of line or suffix + i += pattern.prefix.len; + const end = if (pattern.suffix.len > 0) + std.mem.indexOf(u8, text[i..], pattern.suffix) orelse text.len + else + std.mem.indexOfScalar(u8, text[i..], '\n') orelse text.len; + + i += end; + if (pattern.suffix.len > 0 and i < text.len) { + try result.appendSlice(pattern.suffix); + i += pattern.suffix.len; + } + } else { + try result.append(text[i]); + i += 1; + } + } + + return result.toOwnedSlice(); + } + + /// Quick check if text might contain secrets + pub fn mightContainSecrets(self: Self, text: []const u8) bool { + for (self.patterns.items) |pattern| { + if (std.mem.indexOf(u8, text, pattern.prefix)) |_| { + return true; + } + } + return false; + } +}; + +/// Global secrets filter for application-wide use +var global_filter: ?SecretsFilter = null; + +/// Initialize global secrets filter +pub fn init(allocator: std.mem.Allocator) void { + if (global_filter == null) { + global_filter = SecretsFilter.init(allocator); + } +} + +/// Redact secrets from text using global filter +pub fn redact(text: []const u8) ![]const u8 { + if (global_filter) |*filter| { + return filter.redact(text); + } + return text; // No filter, return as-is +} + +/// Check if text might contain secrets +pub fn mightContainSecrets(text: []const u8) bool { + if (global_filter) |filter| { + return filter.mightContainSecrets(text); + } + return false; +} + +/// Safe print that redacts secrets +pub fn safePrint(writer: anytype, format: []const u8, args: anytype) !void { + var buf: [4096]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, format, args) catch { + try writer.print(format, args); + return; + }; + + const redacted = redact(msg) catch msg; + defer if (redacted.ptr != msg.ptr) { + if (global_filter) |*f| f.allocator.free(redacted); + }; + + try writer.writeAll(redacted); +} + +/// Secure logging function +pub fn secureLog(comptime level: std.log.Level, comptime format: []const u8, args: anytype) void { + var buf: [4096]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, format, args) catch { + // Fallback to raw log if formatting fails + std.log.log(level, format, args); + return; + }; + + // Check if we need redaction + if (mightContainSecrets(msg)) { + const redacted = redact(msg) catch msg; + defer if (redacted.ptr != msg.ptr) { + if (global_filter) |*f| f.allocator.free(redacted); + }; + std.log.log(level, "{s}", .{redacted}); + } else { + std.log.log(level, "{s}", .{msg}); + } +} + +test "secrets redaction" { + const allocator = std.testing.allocator; + + var filter = SecretsFilter.init(allocator); + defer filter.deinit(); + + // Test redaction + const input = "api_key=secret123 password=mypass"; + const output = try filter.redact(input); + defer allocator.free(output); + + try std.testing.expect(std.mem.indexOf(u8, output, "secret123") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "mypass") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "[REDACTED]") != null); +}