- Move ci-test.sh and setup.sh to scripts/ - Trim docs/src/zig-cli.md to current structure - Replace hardcoded secrets with placeholders in configs - Update .gitignore to block .env*, secrets/, keys, build artifacts - Slim README.md to reflect current CLI/TUI split - Add cleanup trap to ci-test.sh - Ensure no secrets are committed
371 lines
9.5 KiB
Go
371 lines
9.5 KiB
Go
// Package config provides TUI configuration management
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/jfraeys/fetch_ml/internal/auth"
|
|
utils "github.com/jfraeys/fetch_ml/internal/config"
|
|
)
|
|
|
|
// CLIConfig represents the TOML config structure used by the CLI
|
|
type CLIConfig struct {
|
|
WorkerHost string `toml:"worker_host"`
|
|
WorkerUser string `toml:"worker_user"`
|
|
WorkerBase string `toml:"worker_base"`
|
|
WorkerPort int `toml:"worker_port"`
|
|
APIKey string `toml:"api_key"`
|
|
|
|
// User context (filled after authentication)
|
|
CurrentUser *UserContext `toml:"-"`
|
|
}
|
|
|
|
// UserContext represents the authenticated user information
|
|
type UserContext struct {
|
|
Name string `json:"name"`
|
|
Admin bool `json:"admin"`
|
|
Roles []string `json:"roles"`
|
|
Permissions map[string]bool `json:"permissions"`
|
|
}
|
|
|
|
// LoadCLIConfig loads the CLI's TOML configuration from the provided path.
|
|
// If path is empty, ~/.ml/config.toml is used. The resolved path is returned.
|
|
// Environment variables with FETCH_ML_CLI_ prefix override config file values.
|
|
func LoadCLIConfig(configPath string) (*CLIConfig, string, error) {
|
|
if configPath == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
configPath = filepath.Join(home, ".ml", "config.toml")
|
|
} else {
|
|
configPath = utils.ExpandPath(configPath)
|
|
if !filepath.IsAbs(configPath) {
|
|
if abs, err := filepath.Abs(configPath); err == nil {
|
|
configPath = abs
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if TOML config exists
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
return nil, configPath, fmt.Errorf("CLI config not found at %s (run 'ml init' first)", configPath)
|
|
} else if err != nil {
|
|
return nil, configPath, fmt.Errorf("cannot access CLI config %s: %w", configPath, err)
|
|
}
|
|
|
|
if err := auth.CheckConfigFilePermissions(configPath); err != nil {
|
|
log.Printf("Warning: %v", err)
|
|
}
|
|
|
|
//nolint:gosec // G304: Config path is user-controlled but trusted
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return nil, configPath, fmt.Errorf("failed to read CLI config: %w", err)
|
|
}
|
|
|
|
config := &CLIConfig{}
|
|
parseTOML(data, config)
|
|
|
|
if err := config.Validate(); err != nil {
|
|
return nil, configPath, err
|
|
}
|
|
|
|
// Apply environment variable overrides with FETCH_ML_CLI_ prefix
|
|
if host := os.Getenv("FETCH_ML_CLI_HOST"); host != "" {
|
|
config.WorkerHost = host
|
|
}
|
|
if user := os.Getenv("FETCH_ML_CLI_USER"); user != "" {
|
|
config.WorkerUser = user
|
|
}
|
|
if base := os.Getenv("FETCH_ML_CLI_BASE"); base != "" {
|
|
config.WorkerBase = base
|
|
}
|
|
if port := os.Getenv("FETCH_ML_CLI_PORT"); port != "" {
|
|
if p, err := parseInt(port); err == nil {
|
|
config.WorkerPort = p
|
|
}
|
|
}
|
|
if apiKey := os.Getenv("FETCH_ML_CLI_API_KEY"); apiKey != "" {
|
|
config.APIKey = apiKey
|
|
}
|
|
|
|
return config, configPath, nil
|
|
}
|
|
|
|
// parseTOML is a simple TOML parser for the CLI config format
|
|
func parseTOML(data []byte, config *CLIConfig) {
|
|
lines := strings.Split(string(data), "\n")
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
|
|
// Remove quotes if present
|
|
if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) {
|
|
value = value[1 : len(value)-1]
|
|
}
|
|
|
|
switch key {
|
|
case "worker_host":
|
|
config.WorkerHost = value
|
|
case "worker_user":
|
|
config.WorkerUser = value
|
|
case "worker_base":
|
|
config.WorkerBase = value
|
|
case "worker_port":
|
|
if p, err := parseInt(value); err == nil {
|
|
config.WorkerPort = p
|
|
}
|
|
case "api_key":
|
|
config.APIKey = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// ToTUIConfig converts CLI config to TUI config structure
|
|
func (c *CLIConfig) ToTUIConfig() *Config {
|
|
// Get smart defaults for current environment
|
|
smart := utils.GetSmartDefaults()
|
|
|
|
tuiConfig := &Config{
|
|
Host: c.WorkerHost,
|
|
User: c.WorkerUser,
|
|
Port: c.WorkerPort,
|
|
BasePath: c.WorkerBase,
|
|
|
|
// Set defaults for TUI-specific fields using smart defaults
|
|
RedisAddr: smart.RedisAddr(),
|
|
RedisDB: 0,
|
|
PodmanImage: "ml-worker:latest",
|
|
ContainerWorkspace: utils.DefaultContainerWorkspace,
|
|
ContainerResults: utils.DefaultContainerResults,
|
|
GPUAccess: false,
|
|
}
|
|
|
|
// Set up auth config with CLI API key
|
|
tuiConfig.Auth = auth.Config{
|
|
Enabled: true,
|
|
APIKeys: map[auth.Username]auth.APIKeyEntry{
|
|
"cli_user": {
|
|
Hash: auth.APIKeyHash(c.APIKey),
|
|
Admin: true,
|
|
Roles: []string{"user", "admin"},
|
|
Permissions: map[string]bool{
|
|
"read": true,
|
|
"write": true,
|
|
"delete": true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Set known hosts path
|
|
tuiConfig.KnownHosts = smart.KnownHostsPath()
|
|
|
|
return tuiConfig
|
|
}
|
|
|
|
// Validate validates the CLI config
|
|
func (c *CLIConfig) Validate() error {
|
|
var errors []string
|
|
|
|
if c.WorkerHost == "" {
|
|
errors = append(errors, "worker_host is required")
|
|
} else if len(strings.TrimSpace(c.WorkerHost)) == 0 {
|
|
errors = append(errors, "worker_host cannot be empty or whitespace")
|
|
}
|
|
|
|
if c.WorkerUser == "" {
|
|
errors = append(errors, "worker_user is required")
|
|
} else if len(strings.TrimSpace(c.WorkerUser)) == 0 {
|
|
errors = append(errors, "worker_user cannot be empty or whitespace")
|
|
}
|
|
|
|
if c.WorkerBase == "" {
|
|
errors = append(errors, "worker_base is required")
|
|
} else {
|
|
// Expand and validate path
|
|
c.WorkerBase = utils.ExpandPath(c.WorkerBase)
|
|
if !filepath.IsAbs(c.WorkerBase) {
|
|
errors = append(errors, "worker_base must be an absolute path")
|
|
}
|
|
}
|
|
|
|
if c.WorkerPort == 0 {
|
|
errors = append(errors, "worker_port is required")
|
|
} else if err := utils.ValidatePort(c.WorkerPort); err != nil {
|
|
errors = append(errors, fmt.Sprintf("invalid worker_port: %v", err))
|
|
}
|
|
|
|
if c.APIKey == "" {
|
|
errors = append(errors, "api_key is required")
|
|
} else if len(c.APIKey) < 16 {
|
|
errors = append(errors, "api_key must be at least 16 characters")
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return fmt.Errorf("validation failed: %s", strings.Join(errors, "; "))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AuthenticateWithServer validates the API key and sets user context
|
|
func (c *CLIConfig) AuthenticateWithServer() error {
|
|
if c.APIKey == "" {
|
|
return fmt.Errorf("no API key configured")
|
|
}
|
|
|
|
// Create temporary auth config for validation
|
|
authConfig := &auth.Config{
|
|
Enabled: true,
|
|
APIKeys: map[auth.Username]auth.APIKeyEntry{
|
|
"temp": {
|
|
Hash: auth.APIKeyHash(auth.HashAPIKey(c.APIKey)),
|
|
Admin: false,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Validate API key and get user info
|
|
user, err := authConfig.ValidateAPIKey(c.APIKey)
|
|
if err != nil {
|
|
return fmt.Errorf("API key validation failed: %w", err)
|
|
}
|
|
|
|
// Set user context
|
|
c.CurrentUser = &UserContext{
|
|
Name: user.Name,
|
|
Admin: user.Admin,
|
|
Roles: user.Roles,
|
|
Permissions: user.Permissions,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckPermission checks if the current user has a specific permission
|
|
func (c *CLIConfig) CheckPermission(permission string) bool {
|
|
if c.CurrentUser == nil {
|
|
return false
|
|
}
|
|
|
|
// Admin users have all permissions
|
|
if c.CurrentUser.Admin {
|
|
return true
|
|
}
|
|
|
|
// Check explicit permission
|
|
if c.CurrentUser.Permissions[permission] {
|
|
return true
|
|
}
|
|
|
|
// Check wildcard permission
|
|
if c.CurrentUser.Permissions["*"] {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// CanViewJob checks if user can view a specific job
|
|
func (c *CLIConfig) CanViewJob(jobUserID string) bool {
|
|
if c.CurrentUser == nil {
|
|
return false
|
|
}
|
|
|
|
// Admin can view all jobs
|
|
if c.CurrentUser.Admin {
|
|
return true
|
|
}
|
|
|
|
// Users can view their own jobs
|
|
return jobUserID == c.CurrentUser.Name
|
|
}
|
|
|
|
// CanModifyJob checks if user can modify a specific job
|
|
func (c *CLIConfig) CanModifyJob(jobUserID string) bool {
|
|
if c.CurrentUser == nil {
|
|
return false
|
|
}
|
|
|
|
// Need jobs:update permission
|
|
if !c.CheckPermission("jobs:update") {
|
|
return false
|
|
}
|
|
|
|
// Admin can modify all jobs
|
|
if c.CurrentUser.Admin {
|
|
return true
|
|
}
|
|
|
|
// Users can only modify their own jobs
|
|
return jobUserID == c.CurrentUser.Name
|
|
}
|
|
|
|
// Exists checks if a CLI configuration file exists
|
|
func Exists(configPath string) bool {
|
|
if configPath == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
configPath = filepath.Join(home, ".ml", "config.toml")
|
|
}
|
|
|
|
_, err := os.Stat(configPath)
|
|
return !os.IsNotExist(err)
|
|
}
|
|
|
|
// GenerateDefaultConfig creates a default TOML configuration file
|
|
func GenerateDefaultConfig(configPath string) error {
|
|
// Create directory if it doesn't exist
|
|
if err := os.MkdirAll(filepath.Dir(configPath), 0750); err != nil {
|
|
return fmt.Errorf("failed to create config directory: %w", err)
|
|
}
|
|
|
|
// Generate default configuration
|
|
defaultContent := `# Fetch ML CLI Configuration
|
|
# This file contains connection settings for the ML platform
|
|
|
|
# Worker connection settings
|
|
worker_host = "localhost" # Hostname or IP of the worker
|
|
worker_user = "your_username" # SSH username for the worker
|
|
worker_base = "~/ml_jobs" # Base directory for ML jobs on worker
|
|
worker_port = 22 # SSH port (default: 22)
|
|
|
|
# Authentication
|
|
api_key = "your_api_key_here" # Your API key (get from admin)
|
|
|
|
# Environment variable overrides:
|
|
# FETCH_ML_CLI_HOST, FETCH_ML_CLI_USER, FETCH_ML_CLI_BASE,
|
|
# FETCH_ML_CLI_PORT, FETCH_ML_CLI_API_KEY
|
|
`
|
|
|
|
// Write configuration file
|
|
if err := os.WriteFile(configPath, []byte(defaultContent), 0600); err != nil {
|
|
return fmt.Errorf("failed to write config file: %w", err)
|
|
}
|
|
|
|
// Set proper permissions
|
|
if err := auth.CheckConfigFilePermissions(configPath); err != nil {
|
|
log.Printf("Warning: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|