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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ All notable changes to this project are documented in this file.
- Dashboard empty state with "Get Started" prompt when no providers are configured.
- Dynamic version display in sidebar (uses build-time `version` variable instead of hardcoded value).

## 0.3.6 - 2026-03-04

### Added

- Recurring job scheduler in `serve` mode (15s poll interval) with SQLite-backed `jobs` execution for `sync` and `validate`.
- Jobs management surfaces:
- CLI: `jobs list|add|pause|resume|run-now|delete`
- UI: `/jobs`
- API: `/api/jobs` CRUD plus pause/resume/run-now routes.
- Shared `internal/jobsvc` package for cron parsing/validation, next-run calculation, and reusable job execution logic.
- Store job APIs for `GetJob`, `DeleteJob`, and `ListDueJobs`, plus index migration `idx_jobs_status_next_run`.

### Changed

- `config set KEY VALUE` now persists typed YAML values to disk and reloads config in-process.
- Validation jobs now persist invalid files into `failed_files` for follow-up retry/triage workflows.
- Documentation refreshed for scheduler/job behavior, new routes/commands, and persistent config editing.

## 0.3.5 - 2026-02-24

### Changed
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ Top-level sections:

For full details, see [docs/configuration.md](docs/configuration.md).

When `schedule.enabled` is `true`, recurring jobs are executed by the scheduler while `airgap serve` is running.

## Provider Types

Provider configs are stored in SQLite (`provider_configs`). YAML provider entries are used for first-run seeding when the table is empty.
Expand All @@ -95,8 +97,9 @@ Supported as config/target types:
- `serve`: web UI + API server
- `providers list`: list provider configs from SQLite
- `registry push`: push mirrored container images to a registry target
- `jobs list|add|pause|resume|run-now|delete`: manage scheduled sync/validate jobs
- `config show`: print loaded config
- `config set`: currently a stub (prints intended change; does not persist)
- `config set`: persist a typed config value to YAML (`KEY` dot-path + YAML `VALUE`)

## Web UI and API

Expand All @@ -106,6 +109,7 @@ Main pages:
- `/providers/{name}`
- `/transfer`
- `/ocp/clients`
- `/jobs`

API routes are documented in [docs/http-api.md](docs/http-api.md).

Expand Down
124 changes: 123 additions & 1 deletion cmd/airgap/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package main
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"

"github.com/BadgerOps/airgap/internal/config"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -95,7 +99,125 @@ func configSetRun(cmd *cobra.Command, args []string) error {

log.Info("set configuration", "key", key, "value", value)

fmt.Printf("STUB: Would set config key '%s' to '%s'\n", key, value)
targetPath, err := resolveConfigWritePath()
if err != nil {
return err
}

rawCfg, err := loadRawConfig(targetPath)
if err != nil {
return err
}

parsedValue, err := parseConfigValue(value)
if err != nil {
return err
}

if err := setDotKey(rawCfg, key, parsedValue); err != nil {
return err
}

data, err := yaml.Marshal(rawCfg)
if err != nil {
return fmt.Errorf("failed to marshal updated config: %w", err)
}

if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}

loaded, err := config.Load(targetPath)
if err != nil {
return fmt.Errorf("failed to reload updated config: %w", err)
}
globalCfg = loaded

fmt.Printf("Updated %s in %s\n", key, targetPath)

return nil
}

func resolveConfigWritePath() (string, error) {
if strings.TrimSpace(cfgPath) != "" {
return cfgPath, nil
}

found, err := config.FindConfigFile()
if err == nil {
return found, nil
}

// Fallback when no discovered config exists yet.
return "airgap.yaml", nil
}

func loadRawConfig(path string) (map[string]interface{}, error) {
raw := make(map[string]interface{})

data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return raw, nil
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}
if len(strings.TrimSpace(string(data))) == 0 {
return raw, nil
}

if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse existing config: %w", err)
}
if raw == nil {
raw = make(map[string]interface{})
}
return raw, nil
}

func parseConfigValue(raw string) (interface{}, error) {
var value interface{}
if err := yaml.Unmarshal([]byte(raw), &value); err != nil {
return nil, fmt.Errorf("invalid value %q: %w", raw, err)
}
return value, nil
}

func setDotKey(cfg map[string]interface{}, key string, value interface{}) error {
parts := strings.Split(key, ".")
if len(parts) == 0 {
return fmt.Errorf("config key is required")
}

current := cfg
for i := 0; i < len(parts)-1; i++ {
part := strings.TrimSpace(parts[i])
if part == "" {
return fmt.Errorf("invalid key %q: empty segment", key)
}

existing, ok := current[part]
if !ok {
next := make(map[string]interface{})
current[part] = next
current = next
continue
}

next, ok := existing.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid key %q: segment %q is not a map", key, part)
}
current = next
}

last := strings.TrimSpace(parts[len(parts)-1])
if last == "" {
return fmt.Errorf("invalid key %q: empty segment", key)
}
current[last] = value
return nil
}
125 changes: 125 additions & 0 deletions cmd/airgap/config_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/BadgerOps/airgap/internal/config"
"gopkg.in/yaml.v3"
)

func TestConfigSetRunPersistsTypedValues(t *testing.T) {
tmp := t.TempDir()
prevWD, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd failed: %v", err)
}
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir failed: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(prevWD)
})

prevCfgPath := cfgPath
prevGlobalCfg := globalCfg
t.Cleanup(func() {
cfgPath = prevCfgPath
globalCfg = prevGlobalCfg
})

cfgPath = filepath.Join(tmp, "airgap.yaml")
globalCfg = config.DefaultConfig()

if err := configSetRun(nil, []string{"server.listen", "127.0.0.1:9000"}); err != nil {
t.Fatalf("configSetRun(server.listen) failed: %v", err)
}
if err := configSetRun(nil, []string{"providers.custom.sources", `["https://example.com/a","https://example.com/b"]`}); err != nil {
t.Fatalf("configSetRun(providers.custom.sources) failed: %v", err)
}
if err := configSetRun(nil, []string{"schedule.enabled", "false"}); err != nil {
t.Fatalf("configSetRun(schedule.enabled) failed: %v", err)
}

data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("reading config file failed: %v", err)
}
raw := map[string]interface{}{}
if err := yaml.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal updated config failed: %v", err)
}

serverRaw, ok := raw["server"].(map[string]interface{})
if !ok {
t.Fatalf("server map missing or wrong type: %#v", raw["server"])
}
if got := serverRaw["listen"]; got != "127.0.0.1:9000" {
t.Fatalf("server.listen mismatch: got %#v", got)
}

scheduleRaw, ok := raw["schedule"].(map[string]interface{})
if !ok {
t.Fatalf("schedule map missing or wrong type: %#v", raw["schedule"])
}
if got, ok := scheduleRaw["enabled"].(bool); !ok || got {
t.Fatalf("schedule.enabled mismatch: got %#v", scheduleRaw["enabled"])
}

if globalCfg.Server.Listen != "127.0.0.1:9000" {
t.Fatalf("globalCfg not reloaded: got %q", globalCfg.Server.Listen)
}
}

func TestConfigSetRunCreatesDefaultConfigFile(t *testing.T) {
tmp := t.TempDir()
prevWD, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd failed: %v", err)
}
if err := os.Chdir(tmp); err != nil {
t.Fatalf("Chdir failed: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(prevWD)
})

prevCfgPath := cfgPath
prevGlobalCfg := globalCfg
t.Cleanup(func() {
cfgPath = prevCfgPath
globalCfg = prevGlobalCfg
})

cfgPath = ""
globalCfg = config.DefaultConfig()

if err := configSetRun(nil, []string{"server.listen", "127.0.0.1:8081"}); err != nil {
t.Fatalf("configSetRun failed: %v", err)
}

target := filepath.Join(tmp, "airgap.yaml")
if _, err := os.Stat(target); err != nil {
t.Fatalf("expected fallback config file at %s: %v", target, err)
}
}

func TestConfigSetRunValidationErrors(t *testing.T) {
prevCfgPath := cfgPath
prevGlobalCfg := globalCfg
t.Cleanup(func() {
cfgPath = prevCfgPath
globalCfg = prevGlobalCfg
})

cfgPath = filepath.Join(t.TempDir(), "airgap.yaml")
globalCfg = config.DefaultConfig()

if err := configSetRun(nil, []string{"server..listen", "127.0.0.1:9000"}); err == nil {
t.Fatal("expected error for invalid key with empty segment")
}
if err := configSetRun(nil, []string{"server.listen", "["}); err == nil {
t.Fatal("expected error for invalid YAML value")
}
}
Loading