Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,48 @@ UTC and have that preference saved locally.
Creating `run` or `exec` jobs requires Ofelia to run with Docker access; the
server rejects such requests if no Docker client is available.

#### Interactive Setup

Use `ofelia init` to create a configuration file interactively:

```sh
ofelia init
```

The wizard guides you through creating jobs step-by-step, prompting for:
- Job type (local, run, exec, service-run)
- Job name and schedule (cron expression)
- Command to execute
- Container name (for Docker-based jobs)
- Optional settings like working directory, user, and environment variables

By default, the configuration is saved to `./ofelia.ini`. Use `--output` (or `-o`)
to specify a different location:

```sh
ofelia init --output=/etc/ofelia/config.ini
```

#### Health Diagnostics

Use `ofelia doctor` to check your Ofelia setup and diagnose common issues:

```sh
ofelia doctor
ofelia doctor --config=/path/to/config.ini
```

The doctor command performs comprehensive health checks:
- **Configuration**: Validates config file syntax and job definitions
- **Docker**: Tests Docker daemon connectivity and permissions
- **Jobs**: Checks for schedule conflicts, invalid cron expressions, and
container references

If no `--config` is specified, doctor searches these locations (in order):
`./ofelia.ini`, `./config.ini`, `/etc/ofelia/config.ini`, `/etc/ofelia.ini`.

Use `--json` for machine-readable output suitable for monitoring integrations.

### Environment variables

You can configure the same options with environment variables. When set,
Expand Down
46 changes: 41 additions & 5 deletions cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,31 @@ import (

// DoctorCommand runs comprehensive health checks on Ofelia configuration and environment
type DoctorCommand struct {
ConfigFile string `long:"config" description:"Path to configuration file" default:"/etc/ofelia/config.ini"`
ConfigFile string `long:"config" description:"Path to configuration file"`
LogLevel string `long:"log-level" env:"OFELIA_LOG_LEVEL" description:"Set log level"`
JSON bool `long:"json" description:"Output results as JSON"`
Logger core.Logger

// configAutoDetected tracks whether auto-detection was used (for error hints)
configAutoDetected bool
}

// commonConfigPaths lists config file locations to search (in order of priority)
var commonConfigPaths = []string{
"./ofelia.ini",
"./config.ini",
"/etc/ofelia/config.ini",
"/etc/ofelia.ini",
}

// findConfigFile searches for a config file in common locations
func findConfigFile() string {
for _, path := range commonConfigPaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return "" // No config found
}

// CheckResult represents the result of a single health check
Expand All @@ -41,6 +62,16 @@ func (c *DoctorCommand) Execute(_ []string) error {
c.Logger.Warningf("Failed to apply log level (using default): %v", err)
}

// Auto-detect config file if not specified
if c.ConfigFile == "" {
c.configAutoDetected = true
if found := findConfigFile(); found != "" {
c.ConfigFile = found
} else {
c.ConfigFile = "/etc/ofelia/config.ini" // Fallback for error messages
}
}

report := &DoctorReport{
Healthy: true,
Checks: []CheckResult{},
Expand Down Expand Up @@ -103,15 +134,20 @@ func (c *DoctorCommand) checkConfiguration(report *DoctorReport) {
if _, err := os.Stat(c.ConfigFile); err != nil {
if os.IsNotExist(err) {
report.Healthy = false
hints := []string{
"Run 'ofelia init' to create a config file interactively",
}
// Only show "Searched:" hint when auto-detection was attempted
if c.configAutoDetected {
hints = append(hints, "Searched: "+strings.Join(commonConfigPaths, ", "))
}
hints = append(hints, "Or specify path with: --config=/path/to/config.ini")
report.Checks = append(report.Checks, CheckResult{
Category: "Configuration",
Name: "File Exists",
Status: "fail",
Message: fmt.Sprintf("Config file not found: %s", c.ConfigFile),
Hints: []string{
"Create a sample config file manually or copy from examples",
"Or specify path with: --config=/path/to/config.ini",
},
Hints: hints,
})
return
}
Expand Down
180 changes: 180 additions & 0 deletions cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,183 @@ command = echo test`
}
}
}

// TestFindConfigFile tests the config file auto-detection logic
func TestFindConfigFile(t *testing.T) {
// Save original working directory
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}

t.Run("finds ofelia.ini in current directory", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

// Create ./ofelia.ini
configPath := filepath.Join(tmpDir, "ofelia.ini")
if err := os.WriteFile(configPath, []byte("[global]\n"), 0o644); err != nil {
t.Fatalf("Failed to create config: %v", err)
}

found := findConfigFile()
if found != "./ofelia.ini" {
t.Errorf("findConfigFile() = %q, want %q", found, "./ofelia.ini")
}
})

t.Run("finds config.ini when ofelia.ini not present", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

// Create ./config.ini (but not ./ofelia.ini)
configPath := filepath.Join(tmpDir, "config.ini")
if err := os.WriteFile(configPath, []byte("[global]\n"), 0o644); err != nil {
t.Fatalf("Failed to create config: %v", err)
}

found := findConfigFile()
if found != "./config.ini" {
t.Errorf("findConfigFile() = %q, want %q", found, "./config.ini")
}
})

t.Run("priority order - ofelia.ini wins over config.ini", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

// Create both files
if err := os.WriteFile(filepath.Join(tmpDir, "ofelia.ini"), []byte("[global]\n"), 0o644); err != nil {
t.Fatalf("Failed to create ofelia.ini: %v", err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("[global]\n"), 0o644); err != nil {
t.Fatalf("Failed to create config.ini: %v", err)
}

found := findConfigFile()
if found != "./ofelia.ini" {
t.Errorf("findConfigFile() = %q, want %q (first in priority)", found, "./ofelia.ini")
}
})

t.Run("returns empty string when no config exists", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

// Don't create any config files
found := findConfigFile()
if found != "" {
t.Errorf("findConfigFile() = %q, want empty string", found)
}
})
}

// TestDoctorCommand_AutoDetection tests the auto-detection flow in Execute
func TestDoctorCommand_AutoDetection(t *testing.T) {
// Save original working directory
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}

t.Run("auto-detects config when ConfigFile is empty", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

// Create a valid config file
configContent := `[global]
[job-local "test"]
schedule = @daily
command = echo test`
if err := os.WriteFile(filepath.Join(tmpDir, "ofelia.ini"), []byte(configContent), 0o644); err != nil {
t.Fatalf("Failed to create config: %v", err)
}

cmd := &DoctorCommand{
ConfigFile: "", // Empty - should auto-detect
Logger: &test.Logger{},
JSON: true,
}

// Should succeed by finding ./ofelia.ini
if err := cmd.Execute(nil); err != nil {
t.Errorf("Expected auto-detection to find config, got error: %v", err)
}

// Verify auto-detection flag was set
if !cmd.configAutoDetected {
t.Error("configAutoDetected should be true when ConfigFile was empty")
}
})

t.Run("explicit config bypasses auto-detection", func(t *testing.T) {
tmpDir := t.TempDir()

// Create a config file at explicit path
configContent := `[global]
[job-local "test"]
schedule = @daily
command = echo test`
configPath := filepath.Join(tmpDir, "explicit.ini")
if err := os.WriteFile(configPath, []byte(configContent), 0o644); err != nil {
t.Fatalf("Failed to create config: %v", err)
}

cmd := &DoctorCommand{
ConfigFile: configPath, // Explicit path provided
Logger: &test.Logger{},
JSON: true,
}

// Should succeed using explicit path
if err := cmd.Execute(nil); err != nil {
t.Errorf("Expected explicit config to work, got error: %v", err)
}

// Verify auto-detection flag was NOT set
if cmd.configAutoDetected {
t.Error("configAutoDetected should be false when explicit ConfigFile was provided")
}
})

t.Run("auto-detection fallback when no config exists", func(t *testing.T) {
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change directory: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()

// Don't create any config files
cmd := &DoctorCommand{
ConfigFile: "", // Empty - will fail to auto-detect
Logger: &test.Logger{},
JSON: true,
}

// Should fail because no config exists
err := cmd.Execute(nil)
if err == nil {
t.Error("Expected error when no config file exists")
}

// Verify auto-detection flag was set (it was attempted)
if !cmd.configAutoDetected {
t.Error("configAutoDetected should be true even when auto-detection fails")
}
})
}
2 changes: 1 addition & 1 deletion ofelia.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func main() {
"doctor",
"diagnose Ofelia configuration and environment health",
"",
&cli.DoctorCommand{Logger: logger, LogLevel: pre.LogLevel, ConfigFile: pre.ConfigFile},
&cli.DoctorCommand{Logger: logger, LogLevel: pre.LogLevel},
)

if _, err := parser.ParseArgs(args); err != nil {
Expand Down