Skip to content

Commit d604de5

Browse files
authored
feat(sync): add remote ignore patterns (#70)
* feat(sync): add remote ignore patterns * test(e2e): cover remote sync ignore
1 parent 242a045 commit d604de5

7 files changed

Lines changed: 146 additions & 1 deletion

File tree

docs/config-manifest.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ spec:
234234
|-------|------|---------|-------------|
235235
| `engine` | `string` | — | Sync engine (currently only `syncthing`) |
236236
| `paths` | `[]string` | — | Mappings in `local:remote` format (max 1 entry) |
237+
| `remoteIgnore` | `[]string` | — | Syncthing ignore patterns written to the remote `.stignore` before sync starts |
237238
| `syncthing.version` | `string` | `v1.29.7` | Local Syncthing binary version |
238239
| `syncthing.autoInstall` | `bool` | `true` | Auto-install local Syncthing |
239240
| `syncthing.image` | `string` | `ghcr.io/acmore/okdev:<version>` | Sidecar image (fallback: `edge`) |
@@ -245,6 +246,8 @@ spec:
245246

246247
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.
247248

249+
Use `remoteIgnore` for paths that should remain local-only after you copy or sync them down from a session. okdev writes these patterns to `.stignore` in the remote sync root before configuring Syncthing, so the remote side will not index or pull matching files from your local workspace on the next start. The patterns use Syncthing `.stignore` syntax.
250+
248251
The `syncthing.version` field controls the local binary on your machine. The Syncthing binary inside the sidecar comes from `spec.sidecar.image`.
249252

250253
```yaml
@@ -259,6 +262,9 @@ spec:
259262
compression: false
260263
paths:
261264
- .:/workspace
265+
remoteIgnore:
266+
- profiles/
267+
- "*.prof"
262268
```
263269

264270
---

internal/cli/syncthing.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ func runSyncthingSync(cmd *cobra.Command, opts *Options, cfg *config.DevEnvironm
9999
if _, err := execInSyncthingContainer(ctx, k, namespace, pod, fmt.Sprintf("mkdir -p %s", syncengine.ShellEscape(pair.Remote))); err != nil {
100100
return err
101101
}
102+
if err := writeRemoteSTIgnoreInPod(ctx, k, namespace, pod, pair.Remote, cfg.Spec.Sync.RemoteIgnore); err != nil {
103+
return err
104+
}
102105
localHome, err := localSyncthingHome(sessionName)
103106
if err != nil {
104107
return err
@@ -486,6 +489,26 @@ func writeLocalSTIgnore(localPath string) error {
486489
return writeSTIgnore(localPath, defaultSyncExcludes)
487490
}
488491

492+
func writeRemoteSTIgnoreInPod(ctx context.Context, k interface {
493+
ExecShInContainer(context.Context, string, string, string, string) ([]byte, error)
494+
}, namespace, pod, remotePath string, excludes []string) error {
495+
content, ok := buildSTIgnoreContent(excludes)
496+
if !ok {
497+
return nil
498+
}
499+
remotePath = strings.TrimRight(strings.TrimSpace(remotePath), "/")
500+
if remotePath == "" || remotePath == "." || remotePath == "/" {
501+
return fmt.Errorf("refusing to write remote .stignore at unsafe sync root %q", remotePath)
502+
}
503+
stignorePath := path.Join(remotePath, ".stignore")
504+
esc := syncengine.ShellEscape
505+
script := fmt.Sprintf("mkdir -p %s && printf %%s %s > %s", esc(remotePath), esc(content), esc(stignorePath))
506+
if _, err := execInSyncthingContainer(ctx, k, namespace, pod, script); err != nil {
507+
return fmt.Errorf("write remote .stignore: %w", err)
508+
}
509+
return nil
510+
}
511+
489512
func buildSTIgnoreContent(excludes []string) (string, bool) {
490513
if len(excludes) == 0 {
491514
return "", false

internal/cli/syncthing_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,44 @@ func TestWriteLocalSTIgnorePreservesExistingFileWhenDefaultsImplicit(t *testing.
310310
}
311311
}
312312

313+
func TestWriteRemoteSTIgnoreInPodWritesConfiguredPatterns(t *testing.T) {
314+
rec := &syncthingExecRecorder{}
315+
err := writeRemoteSTIgnoreInPod(context.Background(), rec, "default", "pod-a", "/workspace", []string{"profiles/", "*.prof"})
316+
if err != nil {
317+
t.Fatal(err)
318+
}
319+
if rec.namespace != "default" || rec.pod != "pod-a" || rec.container != syncthingContainerName {
320+
t.Fatalf("unexpected exec target namespace=%q pod=%q container=%q", rec.namespace, rec.pod, rec.container)
321+
}
322+
for _, want := range []string{
323+
"mkdir -p '/workspace'",
324+
"printf %s 'profiles/\n*.prof\n'",
325+
"> '/workspace/.stignore'",
326+
} {
327+
if !strings.Contains(rec.script, want) {
328+
t.Fatalf("expected remote .stignore script to contain %q, got %q", want, rec.script)
329+
}
330+
}
331+
}
332+
333+
func TestWriteRemoteSTIgnoreInPodSkipsEmptyPatterns(t *testing.T) {
334+
rec := &syncthingExecRecorder{}
335+
if err := writeRemoteSTIgnoreInPod(context.Background(), rec, "default", "pod-a", "/workspace", nil); err != nil {
336+
t.Fatal(err)
337+
}
338+
if rec.script != "" {
339+
t.Fatalf("expected no remote .stignore write, got %q", rec.script)
340+
}
341+
}
342+
343+
func TestWriteRemoteSTIgnoreInPodRejectsUnsafeRoot(t *testing.T) {
344+
rec := &syncthingExecRecorder{}
345+
err := writeRemoteSTIgnoreInPod(context.Background(), rec, "default", "pod-a", "/", []string{"profiles/"})
346+
if err == nil || !strings.Contains(err.Error(), "unsafe sync root") {
347+
t.Fatalf("expected unsafe root error, got %v", err)
348+
}
349+
}
350+
313351
func TestStopLocalSyncthingForHomeStopsRecordedPID(t *testing.T) {
314352
home := t.TempDir()
315353
cmd := exec.Command("sleep", "30")

internal/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type MetadataMap struct {
118118
type SyncSpec struct {
119119
Paths []string `yaml:"paths"`
120120
PreservePaths []string `yaml:"preservePaths"`
121+
RemoteIgnore []string `yaml:"remoteIgnore,omitempty"`
121122
Engine string `yaml:"engine"`
122123
Syncthing SyncthingSpec `yaml:"syncthing"`
123124
}

internal/config/loader.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ func Load(configPath string) (*DevEnvironment, string, error) {
2727
return nil, "", fmt.Errorf("read config %q: %w", path, err)
2828
}
2929
if removed := removedSyncIgnoreField(raw); removed != "" {
30-
return nil, "", fmt.Errorf("validate config %q: %w", path, &MigrationEligibleError{Err: fmt.Errorf("%s is removed; manage local ignores with .stignore in the synced local workspace instead", removed)})
30+
msg := fmt.Sprintf("%s is removed; manage local ignores with .stignore in the synced local workspace instead", removed)
31+
if removed == "spec.sync.remoteExclude" {
32+
msg = "spec.sync.remoteExclude is removed; use spec.sync.remoteIgnore for managed remote .stignore patterns"
33+
}
34+
return nil, "", fmt.Errorf("validate config %q: %w", path, &MigrationEligibleError{Err: errors.New(msg)})
3135
}
3236

3337
var cfg DevEnvironment

internal/config/loader_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,35 @@ spec:
281281
}
282282
}
283283

284+
func TestLoadAcceptsSyncRemoteIgnore(t *testing.T) {
285+
dir := t.TempDir()
286+
path := filepath.Join(dir, DefaultFile)
287+
raw := []byte(`
288+
apiVersion: okdev.io/v1alpha1
289+
kind: DevEnvironment
290+
metadata:
291+
name: test
292+
spec:
293+
sync:
294+
engine: syncthing
295+
paths: [".:/workspace"]
296+
remoteIgnore:
297+
- profiles/
298+
- "*.prof"
299+
`)
300+
if err := os.WriteFile(path, raw, 0o644); err != nil {
301+
t.Fatalf("write config: %v", err)
302+
}
303+
304+
cfg, _, err := Load(path)
305+
if err != nil {
306+
t.Fatalf("Load: %v", err)
307+
}
308+
if got := cfg.Spec.Sync.RemoteIgnore; len(got) != 2 || got[0] != "profiles/" || got[1] != "*.prof" {
309+
t.Fatalf("unexpected remoteIgnore %+v", got)
310+
}
311+
}
312+
284313
func TestLoadRejectsRemovedSyncRemoteExclude(t *testing.T) {
285314
dir := t.TempDir()
286315
path := filepath.Join(dir, DefaultFile)

scripts/e2e_kind_smoke.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ fi
7474
replace_all_in_file "$CFG_PATH" 'persistentSession: true' 'persistentSession: false'
7575
insert_after_line_once "$CFG_PATH" ' ssh:' ' persistentSession: false'
7676
insert_after_line_once "$CFG_PATH" ' ssh:' ' forwardAgent: true'
77+
python3 - "$CFG_PATH" <<'PY'
78+
import pathlib
79+
import sys
80+
81+
path = pathlib.Path(sys.argv[1])
82+
text = path.read_text()
83+
block = ' remoteIgnore:\n - profiles/\n - "*.prof"\n'
84+
if "remoteIgnore:" not in text:
85+
marker = "\n ports:\n"
86+
if marker not in text:
87+
raise SystemExit("ports block not found")
88+
text = text.replace(marker, "\n" + block + " ports:\n", 1)
89+
path.write_text(text)
90+
PY
7791

7892
echo "Generated config:"
7993
cat "$CFG_PATH"
@@ -194,6 +208,36 @@ if [[ "$SYNC_OK" != "true" ]]; then
194208
exit 1
195209
fi
196210

211+
echo "Verifying remoteIgnore writes remote .stignore and keeps profiling data local-only"
212+
REMOTE_STIGNORE=$("$OKDEV_BIN" --config "$CFG_PATH" --session "$SESSION_NAME" exec --no-tty --cmd 'cat /workspace/.stignore 2>/dev/null || true')
213+
if [[ "$REMOTE_STIGNORE" != *"profiles/"* || "$REMOTE_STIGNORE" != *"*.prof"* ]]; then
214+
echo "ERROR: expected remote .stignore to contain remoteIgnore patterns" >&2
215+
echo "$REMOTE_STIGNORE" >&2
216+
exit 1
217+
fi
218+
mkdir -p "$SYNC_DIR/profiles"
219+
echo "local profile payload" >"$SYNC_DIR/profiles/run.prof"
220+
echo "remote ignore control" >"$SYNC_DIR/remote-ignore-control.txt"
221+
CONTROL_SYNCED=false
222+
for i in $(seq 1 30); do
223+
REMOTE_CONTROL=$("$OKDEV_BIN" --config "$CFG_PATH" --session "$SESSION_NAME" exec --no-tty --cmd 'if [ -f /workspace/remote-ignore-control.txt ]; then cat /workspace/remote-ignore-control.txt; fi' || true)
224+
if [[ "$REMOTE_CONTROL" == "remote ignore control" ]]; then
225+
CONTROL_SYNCED=true
226+
break
227+
fi
228+
sleep 2
229+
done
230+
if [[ "$CONTROL_SYNCED" != "true" ]]; then
231+
echo "ERROR: expected non-ignored control file to sync while testing remoteIgnore" >&2
232+
exit 1
233+
fi
234+
REMOTE_PROFILE_STATE=$("$OKDEV_BIN" --config "$CFG_PATH" --session "$SESSION_NAME" exec --no-tty --cmd 'if [ -e /workspace/profiles/run.prof ]; then echo present; else echo absent; fi' || true)
235+
if [[ "$REMOTE_PROFILE_STATE" != "absent" ]]; then
236+
echo "ERROR: expected remoteIgnore to keep profiling data out of remote workspace, got $REMOTE_PROFILE_STATE" >&2
237+
exit 1
238+
fi
239+
echo "remoteIgnore behavior verified"
240+
197241
echo "Verifying repeated okdev up reuses active sync"
198242
SYNC_PID_BEFORE=""
199243
for i in $(seq 1 5); do

0 commit comments

Comments
 (0)