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
3 changes: 2 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ agents can react without launching a diagnostic chain on every blip:
### `okdev sync [--mode up|down|bi] [--foreground] [--reset] [--dry-run]`

- Advanced command. Starts detached background sync by default; use `--foreground` for sync debugging, or explicit one-way sync (`up`/`down`).
- `--mode` defaults to the configured path's `direction` (`spec.sync.paths[].direction`, falling back to `bi`); an explicit flag overrides it for that invocation. See the config manifest for choosing a persistent direction — `down` makes the pod the authority so local writes can never clobber pod-generated results.
- By default each configured mapping syncs in its own `direction` (`spec.sync.paths[].direction`, falling back to `bi`); an explicit `--mode` forces that mode onto every mapping for the invocation. See the config manifest for choosing persistent directions — `down` makes the pod the authority so local writes can never clobber pod-generated results.
- With multiple mappings, each becomes its own syncthing folder; the first (primary) mapping is the one shared to mesh receivers, and `sync reset-remote` clears only the primary remote.
- For default `--mode bi`, no-op when background sync is already active for the session.
- `--background`: explicitly request detached background mode.
- `--reset`: check local-to-hub sync and mesh receiver health, then reset only what is broken. Skips the local sync teardown when the primary sync is already healthy. For sessions with mesh receivers, probes each receiver and re-runs mesh setup only when broken or disconnected receivers are found.
Expand Down
21 changes: 19 additions & 2 deletions docs/config-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ spec:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `engine` | `string` | — | Sync engine (currently only `syncthing`) |
| `paths` | `[]entry` | — | Sync mappings (max 1 entry). Each entry is either the compact string `local:remote`, or a mapping `{local, remote, direction}` with an optional per-path `direction` |
| `paths` | `[]entry` | — | Sync mappings. Each entry is either the compact string `local:remote`, or a mapping `{local, remote, direction}` with an optional per-path `direction`. Multiple mappings are allowed as long as their roots are disjoint on both sides (equal or nested roots are rejected) |
| `paths[].direction` | `string` | `bi` | Sync direction for this mapping's folder: `bi` (bidirectional), `up` (local is the authority: local sendonly, pod receiveonly), `down` (pod is the authority: pod sendonly, local receiveonly) |
| `syncthing.version` | `string` | `v1.29.7` | Local Syncthing binary version |
| `syncthing.autoInstall` | `bool` | `true` | Auto-install local Syncthing |
Expand All @@ -243,7 +243,24 @@ spec:
| `syncthing.compression` | `bool` | `false` | Use Syncthing `always` compression for peer connections instead of the default `metadata` mode |
| `syncthing.versioningDays` | `int` | `30` | Keep files overwritten or deleted by sync in `.stversions` for this many days (staggered versioning, both sides); `0` disables |

**Validation:** `engine` must be `syncthing`; each `paths[]` entry must have non-empty local and remote; `direction` must be `bi`, `up`, or `down`; `versioningDays` must be `>= 0`.
**Validation:** `engine` must be `syncthing`; each `paths[]` entry must have non-empty local and remote; `direction` must be `bi`, `up`, or `down`; mapping roots must be pairwise disjoint on both the local and the remote side (lexical check); `versioningDays` must be `>= 0`.

**Multiple mappings.** Each mapping becomes its own syncthing folder with its own direction. The first entry is the **primary** mapping: it keeps the legacy folder identity (existing sessions upgrade in place), it is the folder shared to mesh receiver pods, and it is the only remote cleared by `okdev up --reset-workspace` / `okdev sync reset-remote` — additional mappings (typically datasets or results) are never touched by resets and stay local↔hub only. Removing a mapping from the config removes its folder from both syncthing instances on the next `okdev up`. A typical split:

```yaml
paths:
- .:/workspace # primary: code, bidirectional
- local: ../collected # sibling of the repo — local roots must
remote: /data/results # not nest inside the primary mapping
direction: down # pod-generated results, pod is authority
- local: ../datasets
remote: /data/datasets
direction: up # dataset push, local is authority
```

Note the disjoint rule applies to the **local** side too: a results directory *inside* the synced repo (e.g. `./collected` under `.`) is rejected — place it next to the repo instead, or narrow the primary mapping to a subdirectory.

**Volumes are provisioned automatically.** An additional mapping's remote root must be visible to the okdev sidecar (its syncthing serves the remote side), which means it must live on a volume — a root on the container's own filesystem cannot sync. You don't need to write any volume YAML: okdev auto-provisions an emptyDir at any extra remote root not already covered by a target-container volume mount, and mirrors the target container's mounts into the sidecar. Adding or removing a mapping changes the pod spec, so the next `okdev up` asks for `--reconcile` (pod recreate). Auto-provisioned emptyDirs don't survive pod recreation — fine for both directions (`up` re-pushes from local, `down` results are mirrored locally); mount your own PVC at the root if the data must persist, and okdev will use it instead. Sync startup still probes each extra root and fails with a clear error if it is not shared with the sidecar.

**Sync direction.** `direction` applies to a mapping's whole synced folder — finer per-subpath direction inside one folder is impossible with Syncthing (ignore rules live in the directory itself; per-path folder types were rejected upstream). Pick the direction by what the folder carries:

Expand Down
5 changes: 4 additions & 1 deletion internal/cli/mesh.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ type meshSummary struct {
Receivers []meshReceiverStatus
}

// meshFolderPath returns the folder the mesh shares to receiver pods: the
// primary (first) mapping's remote. Additional mappings stay local<->hub
// only — datasets and result folders do not fan out to receivers.
func meshFolderPath(syncPairs []syncengine.Pair, workspaceMountPath string) string {
if len(syncPairs) == 1 && strings.TrimSpace(syncPairs[0].Remote) != "" {
if len(syncPairs) >= 1 && strings.TrimSpace(syncPairs[0].Remote) != "" {
return syncPairs[0].Remote
}
return workspaceMountPath
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/status_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ func summarizeConfiguredSyncPaths(cfg *config.DevEnvironment, cfgPath string) []
}
lines := make([]string, 0, len(pairs))
for _, pair := range pairs {
lines = append(lines, fmt.Sprintf("%s -> %s", displayLocalSyncPath(pair.Local), pair.Remote))
lines = append(lines, fmt.Sprintf("%s %s %s", displayLocalSyncPath(pair.Local), syncArrow(pairModeSymbol(pair)), pair.Remote))
}
return lines
}
Expand Down
42 changes: 28 additions & 14 deletions internal/cli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,16 @@ func newSyncCmd(opts *Options) *cobra.Command {
if err != nil {
return err
}
mode = resolveSyncMode(cmd.Flags().Changed("mode"), mode, pairs[0].Direction)
mode = resolveSyncMode(cmd.Flags().Changed("mode"), mode)
if err := validateSyncMode(mode); err != nil {
return err
}
if dryRun {
fmt.Fprintf(cmd.OutOrStdout(), "DRY RUN: sync session=%s namespace=%s engine=%s mode=%s\n", cc.sessionName, cc.namespace, engine, mode)
displayMode := mode
if displayMode == "" {
displayMode = "per-path"
}
fmt.Fprintf(cmd.OutOrStdout(), "DRY RUN: sync session=%s namespace=%s engine=%s mode=%s\n", cc.sessionName, cc.namespace, engine, displayMode)
fmt.Fprintf(cmd.OutOrStdout(), "- paths: %v\n", pairs)
if reset {
scope := "all"
Expand Down Expand Up @@ -145,7 +149,7 @@ func newSyncCmd(opts *Options) *cobra.Command {
return nil
}

if !background && mode == "bi" && os.Getenv("OKDEV_SYNCTHING_BACKGROUND_CHILD") != "1" {
if !background && (mode == "" || mode == "bi") && os.Getenv("OKDEV_SYNCTHING_BACKGROUND_CHILD") != "1" {
if pidPath, err := syncthingPIDPath(cc.sessionName); err == nil {
if pid, ok := readSyncthingPID(pidPath); ok && processAlive(pid) && processLooksLikeSyncthingSync(pid) {
fmt.Fprintf(cmd.OutOrStdout(), "Syncthing sync already active in background for session %s\n", cc.sessionName)
Expand All @@ -171,7 +175,7 @@ func newSyncCmd(opts *Options) *cobra.Command {
},
}

cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi (default: spec.sync.direction, or bi)")
cmd.Flags().StringVar(&mode, "mode", "", "Force a sync mode for all mappings: up|down|bi (default: each mapping's configured direction)")
cmd.Flags().BoolVar(&background, "background", true, "Run syncthing sync as a detached background process")
cmd.Flags().BoolVar(&foreground, "foreground", false, "Run syncthing sync in the foreground for troubleshooting")
cmd.Flags().BoolVar(&reset, "reset", false, "Check sync health and reset broken components")
Expand All @@ -184,20 +188,22 @@ func newSyncCmd(opts *Options) *cobra.Command {
return cmd
}

// resolveSyncMode picks the effective sync mode: an explicit --mode always
// wins; otherwise the configured spec.sync.direction applies.
func resolveSyncMode(flagSet bool, flagMode, configDirection string) string {
// resolveSyncMode picks the effective sync mode: an explicit --mode forces
// every folder; otherwise the empty mode means each mapping follows its own
// configured direction.
func resolveSyncMode(flagSet bool, flagMode string) string {
if flagSet {
return flagMode
}
return configDirection
return ""
}

// validateSyncMode accepts the documented modes plus the internal
// "two-phase" bootstrap mode that `okdev up` spawns for fresh sessions.
// validateSyncMode accepts the documented modes plus the internal values:
// "" (per-path directions from config) and "two-phase" (the bootstrap mode
// `okdev up` spawns for fresh sessions).
func validateSyncMode(mode string) error {
switch mode {
case "up", "down", "bi", "two-phase":
case "", "up", "down", "bi", "two-phase":
return nil
default:
return fmt.Errorf("invalid sync mode %q: must be one of up, down, bi", mode)
Expand Down Expand Up @@ -226,15 +232,18 @@ synced workspace subtree is cleared.`,
if err != nil {
return fmt.Errorf("parse sync paths: %w", err)
}
if len(pairs) != 1 {
return fmt.Errorf("reset-remote requires exactly one sync path mapping, got %d", len(pairs))
if len(pairs) == 0 {
return fmt.Errorf("reset-remote requires a sync path mapping")
}
if pairs[0].Direction == config.SyncDirectionDown {
// reset-remote clears the remote and reseeds it from local;
// with direction "down" the pod is the authority and local
// is receiveonly — the wipe would sync back as deletions.
return fmt.Errorf("sync reset-remote conflicts with sync direction \"down\" (the pod is the sync authority)")
}
if len(pairs) > 1 {
fmt.Fprintf(cmd.OutOrStdout(), "Note: only the primary mapping's remote (%s) is reset; %d additional mapping(s) are untouched.\n", pairs[0].Remote, len(pairs)-1)
}

target, err := resolveTargetRef(cmd.Context(), cc.opts, cc.cfg, cc.namespace, cc.sessionName, cc.kube)
if err != nil {
Expand Down Expand Up @@ -448,7 +457,12 @@ func startDetachedSyncthingSync(opts *Options, mode, sessionName, namespace, cfg
if opts.Verbose {
args = append(args, "--verbose")
}
args = append(args, "sync", "--mode", mode)
args = append(args, "sync")
if mode != "" {
// Empty mode means "follow each mapping's configured direction";
// the child re-reads the config, so no flag is needed.
args = append(args, "--mode", mode)
}

cmd := execCommand(exe, args...)
cmd.Env = append(os.Environ(), "OKDEV_SYNCTHING_BACKGROUND_CHILD=1")
Expand Down
41 changes: 26 additions & 15 deletions internal/cli/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/acmore/okdev/internal/config"
"github.com/acmore/okdev/internal/kube"
syncengine "github.com/acmore/okdev/internal/sync"
)

func stubPkill(t *testing.T) {
Expand Down Expand Up @@ -405,50 +406,60 @@ func TestSyncthingConfigChanged(t *testing.T) {
func TestSyncthingSessionConfigHashTracksSyncSettings(t *testing.T) {
cfg := &config.DevEnvironment{}
cfg.SetDefaults()
hashA := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace")
pairs := []syncengine.Pair{{Local: "/tmp/local", Remote: "/workspace", Direction: "bi"}}
hashA := syncthingSessionConfigHash(cfg, pairs)
if hashA == "" {
t.Fatal("expected non-empty sync config hash")
}

cfg.Spec.Sync.Syncthing.WatcherDelaySeconds = 7
hashB := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace")
hashB := syncthingSessionConfigHash(cfg, pairs)
if hashA == hashB {
t.Fatal("expected watcher delay change to affect sync config hash")
}
}

func TestSyncthingSessionConfigHashTracksDirection(t *testing.T) {
func TestSyncthingSessionConfigHashTracksDirectionAndPairs(t *testing.T) {
cfg := &config.DevEnvironment{}
cfg.SetDefaults()
base := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace")
base := syncthingSessionConfigHash(cfg, []syncengine.Pair{{Local: "/tmp/local", Remote: "/workspace", Direction: "bi"}})

// Explicit "bi" hashes identically to the legacy empty value so
// upgrading okdev (or spelling out the default) does not force a sync
// restart on existing sessions.
cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: ".", Remote: "/workspace", Direction: "bi"}}
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got != base {
t.Fatal("explicit bi direction must hash like the legacy default")
if got := syncthingSessionConfigHash(cfg, []syncengine.Pair{{Local: "/tmp/local", Remote: "/workspace", Direction: "bi"}}); got != base {
t.Fatal("bi direction must hash stably")
}

cfg.Spec.Sync.Paths = []config.SyncPathSpec{{Local: ".", Remote: "/workspace", Direction: "down"}}
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got == base {
if got := syncthingSessionConfigHash(cfg, []syncengine.Pair{{Local: "/tmp/local", Remote: "/workspace", Direction: "down"}}); got == base {
t.Fatal("expected direction change to affect sync config hash")
}

// Adding a second mapping changes the hash so the next up restarts
// sync and configures the new folder.
multi := []syncengine.Pair{
{Local: "/tmp/local", Remote: "/workspace", Direction: "bi"},
{Local: "/tmp/results", Remote: "/data/results", Direction: "down"},
}
if got := syncthingSessionConfigHash(cfg, multi); got == base {
t.Fatal("expected extra mapping to affect sync config hash")
}
}

func TestResolveSyncMode(t *testing.T) {
// Explicit --mode wins over the configured direction.
if got := resolveSyncMode(true, "up", "down"); got != "up" {
// Explicit --mode forces all mappings.
if got := resolveSyncMode(true, "up"); got != "up" {
t.Fatalf("explicit flag must win, got %q", got)
}
// Without the flag, spec.sync.direction applies.
if got := resolveSyncMode(false, "bi", "down"); got != "down" {
t.Fatalf("config direction must apply, got %q", got)
// Without the flag, the empty mode means each mapping follows its own
// configured direction.
if got := resolveSyncMode(false, "bi"); got != "" {
t.Fatalf("expected per-path mode, got %q", got)
}
}

func TestValidateSyncMode(t *testing.T) {
for _, ok := range []string{"up", "down", "bi", "two-phase"} {
for _, ok := range []string{"", "up", "down", "bi", "two-phase"} {
if err := validateSyncMode(ok); err != nil {
t.Fatalf("mode %q should validate, got %v", ok, err)
}
Expand Down
Loading
Loading