Skip to content

Commit a0ba5a4

Browse files
acmoreclaude
andcommitted
feat(sync): configurable sync direction (spec.sync.direction: bi|up|down)
A single bidirectional folder carries both inputs (code, local->pod) and outputs (results, pod->local), so a bad local write can clobber pod-generated data. Per-path direction inside one folder is impossible in Syncthing (.stignore lives in the directory; per-path folder types were rejected upstream), so the direction is a per-folder setting: - spec.sync.direction: bi (default) | up (local sendonly, pod receiveonly) | down (pod sendonly, local receiveonly — a local write can never clobber pod results). - `okdev sync --mode` now defaults to the configured direction; an explicit flag still wins. Mode values are validated. - `okdev up` starts sync in the configured direction and skips the two-phase bootstrap for directional folders (one side is already sendonly, so the final folder types are safe from the first start). Direction is part of the sync config hash, so editing it takes effect on the next up; explicit "bi" hashes like the legacy default to avoid a restart on upgrade. - `up --reset-workspace` and `sync reset-remote` are rejected with direction "down": they reseed the remote from local, which would sync back as deletions over the pod's data. - `status --details` shows the direction; docs cover choosing a direction and keeping generated outputs out of the synced path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5755e5e commit a0ba5a4

12 files changed

Lines changed: 211 additions & 20 deletions

File tree

docs/command-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ agents can react without launching a diagnostic chain on every blip:
304304
### `okdev sync [--mode up|down|bi] [--foreground] [--reset] [--dry-run]`
305305

306306
- Advanced command. Starts detached background sync by default; use `--foreground` for sync debugging, or explicit one-way sync (`up`/`down`).
307+
- `--mode` defaults to `spec.sync.direction` from the config (falling back to `bi`); an explicit flag overrides it for that invocation. See `spec.sync.direction` in the config manifest for choosing a persistent direction — `down` makes the pod the authority so local writes can never clobber pod-generated results.
307308
- For default `--mode bi`, no-op when background sync is already active for the session.
308309
- `--background`: explicitly request detached background mode.
309310
- `--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.

docs/config-manifest.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ spec:
233233
| Field | Type | Default | Description |
234234
|-------|------|---------|-------------|
235235
| `engine` | `string` | — | Sync engine (currently only `syncthing`) |
236+
| `direction` | `string` | `bi` | Sync direction for the local↔pod folder: `bi` (bidirectional), `up` (local is the authority: local sendonly, pod receiveonly), `down` (pod is the authority: pod sendonly, local receiveonly) |
236237
| `paths` | `[]string` | — | Mappings in `local:remote` format (max 1 entry) |
237238
| `syncthing.version` | `string` | `v1.29.7` | Local Syncthing binary version |
238239
| `syncthing.autoInstall` | `bool` | `true` | Auto-install local Syncthing |
@@ -242,7 +243,15 @@ spec:
242243
| `syncthing.compression` | `bool` | `false` | Use Syncthing `always` compression for peer connections instead of the default `metadata` mode |
243244
| `syncthing.versioningDays` | `int` | `30` | Keep files overwritten or deleted by sync in `.stversions` for this many days (staggered versioning, both sides); `0` disables |
244245

245-
**Validation:** `engine` must be `syncthing`; each `paths[]` entry must be `local:remote`; `versioningDays` must be `>= 0`.
246+
**Validation:** `engine` must be `syncthing`; `direction` must be `bi`, `up`, or `down`; each `paths[]` entry must be `local:remote`; `versioningDays` must be `>= 0`.
247+
248+
**Sync direction.** `direction` applies to the whole synced folder — per-path 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:
249+
250+
- `bi` (default): source code you edit on both sides. Either side can overwrite the other, so **keep generated outputs (results, checkpoints, logs) out of the synced path** — write them to a non-synced location and fetch with `okdev cp` or `okdev jobs logs`. Versioning (`.stversions/`) is the safety net, not the design.
251+
- `up`: the folder is a code drop. Nothing the pod writes can ever touch local files; pod-side writes into the folder are flagged by Syncthing but not propagated.
252+
- `down`: the folder carries pod-generated results. A stray local write (for example an empty file from a failed redirect) can never clobber the pod's data. `okdev up --reset-workspace` and `okdev sync reset-remote` are rejected in this direction because they reseed the remote from local.
253+
254+
An explicit `okdev sync --mode` overrides the configured direction for that invocation. Changing `direction` takes effect on the next `okdev up` (it restarts sync with the new folder types). Mesh receiver pods are always receiveonly regardless of direction.
246255

247256
Local ignore rules come from the synced workspace's `.stignore`. `okdev init` writes a starter `.stignore` for built-in templates, and `okdev up` creates one with default patterns if the local sync root does not already have one. Editing `.stignore` takes effect automatically as Syncthing notices the change, but it does not remove files that were already synced to the remote workspace. For faster initial syncs, consider ignoring large generated build outputs or local test artifacts such as `debug/`, `release/`, caches, and dataset directories when they do not need to exist remotely.
248257

@@ -254,6 +263,7 @@ The `syncthing.version` field controls the local binary on your machine. The Syn
254263
spec:
255264
sync:
256265
engine: syncthing
266+
direction: bi
257267
syncthing:
258268
version: v1.29.7
259269
autoInstall: true

internal/cli/status_details.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ type detailedStatusSSH struct {
114114

115115
type detailedStatusSync struct {
116116
Engine string `json:"engine"`
117+
Direction string `json:"direction,omitempty"`
117118
Health string `json:"health,omitempty"`
118119
HealthDetail string `json:"healthDetail,omitempty"`
119120
ConfiguredPaths []string `json:"configuredPaths,omitempty"`
@@ -422,6 +423,7 @@ func buildDetailedSync(sessionName string, cfg *config.DevEnvironment, cfgPath s
422423
}
423424
detail := detailedStatusSync{
424425
Engine: engine,
426+
Direction: cfg.Spec.Sync.EffectiveDirection(),
425427
ConfiguredPaths: summarizeConfiguredSyncPaths(cfg, cfgPath),
426428
}
427429
if engine != "syncthing" {
@@ -593,6 +595,9 @@ func printDetailedStatus(w io.Writer, detail detailedStatus) {
593595

594596
fmt.Fprintln(w, "\nSync:")
595597
fmt.Fprintf(w, "- engine: %s\n", detail.Sync.Engine)
598+
if detail.Sync.Direction != "" {
599+
fmt.Fprintf(w, "- direction: %s (%s)\n", detail.Sync.Direction, modeSymbol(detail.Sync.Direction))
600+
}
596601
if detail.Sync.BackgroundStatus != "" {
597602
status := detail.Sync.BackgroundStatus
598603
if detail.Sync.BackgroundPID > 0 {

internal/cli/status_details_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
347347
},
348348
Sync: detailedStatusSync{
349349
Engine: "syncthing",
350+
Direction: "down",
350351
BackgroundStatus: "running",
351352
BackgroundPID: 123,
352353
ConfiguredPaths: []string{"/tmp/repo -> /workspace"},
@@ -391,6 +392,7 @@ func TestPrintDetailedStatusIncludesSections(t *testing.T) {
391392
"SSH:",
392393
"managed forward: running",
393394
"Sync:",
395+
"direction: down (<-)",
394396
"background: running (pid 123)",
395397
"conflicts: 2",
396398
"./a.sync-conflict-1",

internal/cli/sync.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"syscall"
1717
"time"
1818

19+
"github.com/acmore/okdev/internal/config"
1920
"github.com/acmore/okdev/internal/kube"
2021
"github.com/acmore/okdev/internal/logx"
2122
"github.com/acmore/okdev/internal/session"
@@ -72,6 +73,10 @@ func newSyncCmd(opts *Options) *cobra.Command {
7273
if engine != "syncthing" {
7374
return fmt.Errorf("unsupported sync engine %q (only syncthing is supported)", engine)
7475
}
76+
mode = resolveSyncMode(cmd.Flags().Changed("mode"), mode, cc.cfg.Spec.Sync.EffectiveDirection())
77+
if err := validateSyncMode(mode); err != nil {
78+
return err
79+
}
7580
if foreground && cmd.Flags().Changed("background") {
7681
return fmt.Errorf("--background and --foreground cannot be used together")
7782
}
@@ -166,7 +171,7 @@ func newSyncCmd(opts *Options) *cobra.Command {
166171
},
167172
}
168173

169-
cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi")
174+
cmd.Flags().StringVar(&mode, "mode", "bi", "Sync mode: up|down|bi (default: spec.sync.direction, or bi)")
170175
cmd.Flags().BoolVar(&background, "background", true, "Run syncthing sync as a detached background process")
171176
cmd.Flags().BoolVar(&foreground, "foreground", false, "Run syncthing sync in the foreground for troubleshooting")
172177
cmd.Flags().BoolVar(&reset, "reset", false, "Check sync health and reset broken components")
@@ -179,6 +184,26 @@ func newSyncCmd(opts *Options) *cobra.Command {
179184
return cmd
180185
}
181186

187+
// resolveSyncMode picks the effective sync mode: an explicit --mode always
188+
// wins; otherwise the configured spec.sync.direction applies.
189+
func resolveSyncMode(flagSet bool, flagMode, configDirection string) string {
190+
if flagSet {
191+
return flagMode
192+
}
193+
return configDirection
194+
}
195+
196+
// validateSyncMode accepts the documented modes plus the internal
197+
// "two-phase" bootstrap mode that `okdev up` spawns for fresh sessions.
198+
func validateSyncMode(mode string) error {
199+
switch mode {
200+
case "up", "down", "bi", "two-phase":
201+
return nil
202+
default:
203+
return fmt.Errorf("invalid sync mode %q: must be one of up, down, bi", mode)
204+
}
205+
}
206+
182207
func newSyncResetRemoteCmd(opts *Options) *cobra.Command {
183208
return &cobra.Command{
184209
Use: "reset-remote",
@@ -197,6 +222,12 @@ synced workspace subtree is cleared.`,
197222
if err := ensureExistingSessionOwnership(cc.opts, cc.kube, cc.namespace, cc.sessionName); err != nil {
198223
return err
199224
}
225+
if cc.cfg.Spec.Sync.EffectiveDirection() == config.SyncDirectionDown {
226+
// reset-remote clears the remote and reseeds it from local;
227+
// with direction "down" the pod is the authority and local
228+
// is receiveonly — the wipe would sync back as deletions.
229+
return fmt.Errorf("sync reset-remote conflicts with spec.sync.direction: down (the pod is the sync authority)")
230+
}
200231
pairs, err := syncengine.ParsePairs(cc.cfg.Spec.Sync.Paths, cc.cfg.EffectiveWorkspaceMountPath(cc.cfgPath))
201232
if err != nil {
202233
return fmt.Errorf("parse sync paths: %w", err)

internal/cli/sync_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,47 @@ func TestSyncthingSessionConfigHashTracksSyncSettings(t *testing.T) {
417417
}
418418
}
419419

420+
func TestSyncthingSessionConfigHashTracksDirection(t *testing.T) {
421+
cfg := &config.DevEnvironment{}
422+
cfg.SetDefaults()
423+
base := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace")
424+
425+
// Explicit "bi" hashes identically to the legacy empty value so
426+
// upgrading okdev (or spelling out the default) does not force a sync
427+
// restart on existing sessions.
428+
cfg.Spec.Sync.Direction = "bi"
429+
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got != base {
430+
t.Fatal("explicit bi direction must hash like the legacy default")
431+
}
432+
433+
cfg.Spec.Sync.Direction = "down"
434+
if got := syncthingSessionConfigHash(cfg, "/tmp/local", "/workspace"); got == base {
435+
t.Fatal("expected direction change to affect sync config hash")
436+
}
437+
}
438+
439+
func TestResolveSyncMode(t *testing.T) {
440+
// Explicit --mode wins over the configured direction.
441+
if got := resolveSyncMode(true, "up", "down"); got != "up" {
442+
t.Fatalf("explicit flag must win, got %q", got)
443+
}
444+
// Without the flag, spec.sync.direction applies.
445+
if got := resolveSyncMode(false, "bi", "down"); got != "down" {
446+
t.Fatalf("config direction must apply, got %q", got)
447+
}
448+
}
449+
450+
func TestValidateSyncMode(t *testing.T) {
451+
for _, ok := range []string{"up", "down", "bi", "two-phase"} {
452+
if err := validateSyncMode(ok); err != nil {
453+
t.Fatalf("mode %q should validate, got %v", ok, err)
454+
}
455+
}
456+
if err := validateSyncMode("push"); err == nil {
457+
t.Fatal("expected invalid mode to be rejected")
458+
}
459+
}
460+
420461
func TestProcStatIsZombie(t *testing.T) {
421462
if !procStatIsZombie([]byte("123 (okdev) Z 1 2 3")) {
422463
t.Fatal("expected zombie proc stat to be detected")

internal/cli/syncthing.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2465,9 +2465,14 @@ func resetRemoteWorkspace(ctx context.Context, k interface {
24652465
}
24662466

24672467
type syncthingSessionConfigState struct {
2468-
Engine string `json:"engine"`
2469-
LocalPath string `json:"localPath"`
2470-
RemotePath string `json:"remotePath"`
2468+
Engine string `json:"engine"`
2469+
LocalPath string `json:"localPath"`
2470+
RemotePath string `json:"remotePath"`
2471+
// Direction is part of the hash so editing spec.sync.direction makes
2472+
// the next `okdev up` restart sync with the new folder types. The
2473+
// default "bi" is hashed as the empty legacy value to avoid a
2474+
// restart on upgrade for existing sessions.
2475+
Direction string `json:"direction,omitempty"`
24712476
WatcherDelaySeconds int `json:"watcherDelaySeconds,omitempty"`
24722477
RescanIntervalSeconds int `json:"rescanIntervalSeconds,omitempty"`
24732478
RelaysEnabled bool `json:"relaysEnabled,omitempty"`
@@ -2479,10 +2484,15 @@ func syncthingSessionConfigHash(cfg *config.DevEnvironment, localPath, remotePat
24792484
if cfg == nil {
24802485
return ""
24812486
}
2487+
direction := cfg.Spec.Sync.EffectiveDirection()
2488+
if direction == config.SyncDirectionBi {
2489+
direction = ""
2490+
}
24822491
state := syncthingSessionConfigState{
24832492
Engine: strings.TrimSpace(cfg.Spec.Sync.Engine),
24842493
LocalPath: localPath,
24852494
RemotePath: remotePath,
2495+
Direction: direction,
24862496
WatcherDelaySeconds: cfg.Spec.Sync.Syncthing.WatcherDelaySeconds,
24872497
RescanIntervalSeconds: cfg.Spec.Sync.Syncthing.RescanIntervalSeconds,
24882498
RelaysEnabled: cfg.Spec.Sync.Syncthing.RelaysEnabled,

internal/cli/up.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ func upValidate(cmd *cobra.Command, opts *Options, flags upOptions) (*upState, e
156156
if cmd.Flags().Changed("tmux") && cmd.Flags().Changed("no-tmux") {
157157
return nil, fmt.Errorf("--tmux and --no-tmux cannot be used together")
158158
}
159+
if flags.resetWorkspace && cc.cfg.Spec.Sync.EffectiveDirection() == config.SyncDirectionDown {
160+
// reset-workspace clears the remote and reseeds it from local; with
161+
// direction "down" the pod is the authority and local is
162+
// receiveonly, so the combination would wipe the remote and then
163+
// pull the deletions back over the local files.
164+
return nil, fmt.Errorf("--reset-workspace conflicts with spec.sync.direction: down (the pod is the sync authority); remove the flag or switch direction")
165+
}
159166
enableTmux := cc.cfg.Spec.SSH.PersistentSessionEnabled()
160167
if cmd.Flags().Changed("tmux") {
161168
enableTmux = flags.tmux
@@ -1092,12 +1099,15 @@ func upSetupSync(state *upState, target workload.TargetRef) (string, string, str
10921099
}
10931100
configChanged = changed
10941101
}
1102+
direction := state.command.cfg.Spec.Sync.EffectiveDirection()
10951103
active := syncthingSessionActive(state.command.sessionName)
10961104
restartRequired := state.flags.resetWorkspace || configChanged || !active
1097-
startMode = syncStartMode(state.flags.resetWorkspace, targetReset, hasConfigState, configChanged, active)
1105+
startMode = syncStartMode(direction, state.flags.resetWorkspace, targetReset, hasConfigState, configChanged, active)
10981106
// Signal waitForInitialSync to probe the remote folder type for an
1099-
// incomplete bootstrap, reusing the port-forward it already opens.
1100-
bootstrapResume = len(state.syncPairs) == 1 && hasConfigState && !state.flags.resetWorkspace && !targetReset
1107+
// incomplete bootstrap, reusing the port-forward it already opens. Only
1108+
// meaningful for bi: directional folders never run the two-phase
1109+
// bootstrap, so there is nothing to resume.
1110+
bootstrapResume = len(state.syncPairs) == 1 && hasConfigState && !state.flags.resetWorkspace && !targetReset && direction == config.SyncDirectionBi
11011111
if restartRequired {
11021112
if err := refreshSyncthingSessionProcesses(state.command.sessionName); err != nil {
11031113
return "", "", "", false, fmt.Errorf("refresh local syncthing session state: %w", err)
@@ -1832,7 +1842,15 @@ func modeSymbol(mode string) string {
18321842
}
18331843
}
18341844

1835-
func syncStartMode(resetWorkspace, targetReset, hasConfigState, configChanged, active bool) string {
1845+
func syncStartMode(direction string, resetWorkspace, targetReset, hasConfigState, configChanged, active bool) string {
1846+
// A directional folder never needs the two-phase bootstrap: that
1847+
// protocol exists to protect the remote from the local side's initial
1848+
// emptiness before flipping both sides to sendreceive. With up/down one
1849+
// side is already sendonly, so the final folder types are safe from the
1850+
// first start.
1851+
if direction == config.SyncDirectionUp || direction == config.SyncDirectionDown {
1852+
return direction
1853+
}
18361854
switch {
18371855
case resetWorkspace:
18381856
return "two-phase"

internal/cli/up_ui_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,23 +119,31 @@ func TestSyncHelpers(t *testing.T) {
119119
func TestSyncStartMode(t *testing.T) {
120120
cases := []struct {
121121
name string
122+
direction string
122123
resetWorkspace bool
123124
targetReset bool
124125
hasConfigState bool
125126
configChanged bool
126127
active bool
127128
want string
128129
}{
129-
{name: "first bootstrap", hasConfigState: false, active: false, want: "two-phase"},
130-
{name: "explicit reset", resetWorkspace: true, hasConfigState: true, configChanged: true, active: true, want: "two-phase"},
131-
{name: "target recreated", targetReset: true, hasConfigState: true, active: false, want: "two-phase"},
132-
{name: "config changed on active session", hasConfigState: true, configChanged: true, active: true, want: "bi"},
133-
{name: "daemon recovery", hasConfigState: true, active: false, want: "bi"},
134-
{name: "steady state", hasConfigState: true, active: true, want: "bi"},
130+
{name: "first bootstrap", direction: "bi", hasConfigState: false, active: false, want: "two-phase"},
131+
{name: "explicit reset", direction: "bi", resetWorkspace: true, hasConfigState: true, configChanged: true, active: true, want: "two-phase"},
132+
{name: "target recreated", direction: "bi", targetReset: true, hasConfigState: true, active: false, want: "two-phase"},
133+
{name: "config changed on active session", direction: "bi", hasConfigState: true, configChanged: true, active: true, want: "bi"},
134+
{name: "daemon recovery", direction: "bi", hasConfigState: true, active: false, want: "bi"},
135+
{name: "steady state", direction: "bi", hasConfigState: true, active: true, want: "bi"},
136+
// Directional folders skip the two-phase bootstrap entirely: one
137+
// side is already sendonly, so the final types are safe from the
138+
// first start.
139+
{name: "up direction fresh", direction: "up", hasConfigState: false, active: false, want: "up"},
140+
{name: "up direction target reset", direction: "up", targetReset: true, hasConfigState: true, active: false, want: "up"},
141+
{name: "down direction fresh", direction: "down", hasConfigState: false, active: false, want: "down"},
142+
{name: "down direction steady", direction: "down", hasConfigState: true, active: true, want: "down"},
135143
}
136144

137145
for _, tc := range cases {
138-
if got := syncStartMode(tc.resetWorkspace, tc.targetReset, tc.hasConfigState, tc.configChanged, tc.active); got != tc.want {
146+
if got := syncStartMode(tc.direction, tc.resetWorkspace, tc.targetReset, tc.hasConfigState, tc.configChanged, tc.active); got != tc.want {
139147
t.Fatalf("%s: got %q want %q", tc.name, got, tc.want)
140148
}
141149
}

internal/config/config.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,39 @@ type MetadataMap struct {
140140
Labels map[string]string `yaml:"labels"`
141141
}
142142

143+
// Sync directions; see SyncSpec.Direction.
144+
const (
145+
SyncDirectionBi = "bi"
146+
SyncDirectionUp = "up"
147+
SyncDirectionDown = "down"
148+
)
149+
143150
type SyncSpec struct {
144-
Paths []string `yaml:"paths"`
145-
PreservePaths []string `yaml:"preservePaths"`
146-
Engine string `yaml:"engine"`
147-
Syncthing SyncthingSpec `yaml:"syncthing"`
151+
Paths []string `yaml:"paths"`
152+
PreservePaths []string `yaml:"preservePaths"`
153+
Engine string `yaml:"engine"`
154+
// Direction sets the syncthing folder types on the local<->pod edge:
155+
// bi — both sides sendreceive (default; either side can overwrite
156+
// the other — keep generated outputs out of the synced path)
157+
// up — local sendonly, pod receiveonly: code push-only; nothing on
158+
// the pod can alter local files
159+
// down — local receiveonly, pod sendonly: the pod is the authority;
160+
// a local write can never clobber pod-side results
161+
// Direction is per-folder by necessity: syncthing stores .stignore in
162+
// the directory itself, so per-path direction inside one folder is
163+
// impossible (per-path folder types were rejected upstream,
164+
// syncthing/syncthing#8061). Mesh receivers stay receiveonly regardless.
165+
Direction string `yaml:"direction,omitempty"`
166+
Syncthing SyncthingSpec `yaml:"syncthing"`
167+
}
168+
169+
// EffectiveDirection resolves Direction, defaulting to bidirectional.
170+
func (s SyncSpec) EffectiveDirection() string {
171+
d := strings.TrimSpace(s.Direction)
172+
if d == "" {
173+
return SyncDirectionBi
174+
}
175+
return d
148176
}
149177

150178
type SyncthingSpec struct {
@@ -359,6 +387,11 @@ func (d *DevEnvironment) Validate() error {
359387
if d.Spec.Sync.Syncthing.VersioningDays != nil && *d.Spec.Sync.Syncthing.VersioningDays < 0 {
360388
return errors.New("spec.sync.syncthing.versioningDays must be >= 0")
361389
}
390+
switch strings.TrimSpace(d.Spec.Sync.Direction) {
391+
case "", SyncDirectionBi, SyncDirectionUp, SyncDirectionDown:
392+
default:
393+
return fmt.Errorf("spec.sync.direction must be one of %s, %s, %s", SyncDirectionBi, SyncDirectionUp, SyncDirectionDown)
394+
}
362395
if err := validateSyncPaths(d.Spec.Sync.Paths); err != nil {
363396
return err
364397
}

0 commit comments

Comments
 (0)