activator: layout classification and per-host paths by layout (plan 3, task 2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+237
−40
internal/activator/activator_test.go+7 −7internal/activator/backend_exec.go+21 −33internal/activator/layout.go+94 −0internal/activator/layout_test.go+67 −0internal/activator/paths.go+48 −0
CI passed — run details
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 5893ae761..8127d94f1 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -3260,7 +3260,7 @@ func stageSeedScript(t *testing.T, b *ExecBackend, host, ver, body string) { |
| func TestExecBackendRestoreWarmCacheIsNotFresh(t *testing.T) { |
| b := testExecBackend(t) |
| host := "warm.example.com" |
| - if err := os.WriteFile(b.defaultDBPath(host), []byte("db"), 0o600); err != nil { |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".db"), []byte("db"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| called := false |
| @@ -3307,7 +3307,7 @@ func TestExecBackendRestoreFromReplicaIsNotFresh(t *testing.T) { |
| func TestExecBackendRestoreCorruptReplicaFailsAndQuarantines(t *testing.T) { |
| b := testExecBackend(t) |
| host := "damaged.example.com" |
| - dbPath := b.defaultDBPath(host) |
| + dbPath := filepath.Join(b.DataDir, host+".db") |
| swapRestoreReplica(t, func(_ context.Context, out, _, _, _ string) (bool, error) { |
| if err := os.WriteFile(out, []byte("this is not a sqlite database at all"), 0o600); err != nil { |
| @@ -3436,7 +3436,7 @@ func TestExecBackendSeedRunsDeclaredArgvWithDBPath(t *testing.T) { |
| } |
| gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second)) |
| - wantArgv := []string{"seed", "--fixtures", "--db", b.defaultDBPath(host)} |
| + wantArgv := []string{"seed", "--fixtures", "--db", filepath.Join(b.DataDir, host+".db")} |
| if !slices.Equal(gotArgv, wantArgv) { |
| t.Fatalf("seed argv = %v, want %v", gotArgv, wantArgv) |
| } |
| @@ -3593,7 +3593,7 @@ func TestExecBackendSeedFailureRemovesHalfBuiltDB(t *testing.T) { |
| if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err == nil { |
| t.Fatal("a seed exiting 1 was reported as success") |
| } |
| - if _, err := os.Stat(b.defaultDBPath(host)); err == nil { |
| + if _, err := os.Stat(filepath.Join(b.DataDir, host+".db")); err == nil { |
| t.Fatal("a failed seed left its half-built database behind — the next wake would treat it as a warm cache") |
| } |
| } |
| @@ -3605,14 +3605,14 @@ func TestExecBackendSeedFailureKeepsPreexistingDB(t *testing.T) { |
| b := testExecBackend(t) |
| host := "existing-db.example.com" |
| stageSeedScript(t, b, host, "v1", "echo nope >&2\nexit 1\n") |
| - if err := os.WriteFile(b.defaultDBPath(host), []byte("live data"), 0o600); err != nil { |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+".db"), []byte("live data"), 0o600); err != nil { |
| t.Fatal(err) |
| } |
| if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err == nil { |
| t.Fatal("a seed exiting 1 was reported as success") |
| } |
| - got, err := os.ReadFile(b.defaultDBPath(host)) |
| + got, err := os.ReadFile(filepath.Join(b.DataDir, host+".db")) |
| if err != nil || string(got) != "live data" { |
| t.Fatalf("pre-existing database = %q, %v; want it untouched by a failed seed", got, err) |
| } |
| @@ -3637,7 +3637,7 @@ func TestExecBackendSeedSucceedsDespiteLingeringChild(t *testing.T) { |
| if err := b.Seed(context.Background(), registry.Route{Host: host, Kind: "instance", Seed: `["seed"]`}); err != nil { |
| t.Fatalf("Seed = %v, want nil — the seed exited 0; a leaked output pipe is not a failed seed", err) |
| } |
| - got, err := os.ReadFile(b.defaultDBPath(host)) |
| + got, err := os.ReadFile(filepath.Join(b.DataDir, host+".db")) |
| if err != nil || string(got) != "seeded" { |
| t.Fatalf("seeded database = %q, %v; want it kept — deleting it would make the host fresh again next wake", got, err) |
| } |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 7cafa366e..e6c46dc00 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -106,6 +106,9 @@ type ExecBackend struct { |
| // feature — binds the control socket without those verbs, so an app that |
| // asks gets 404 rather than a socket that isn't there. |
| Timers schedule.TimerStore |
| + // Layout supplies the box facts the classification of §8.1 needs beyond |
| + // the row: journals and peers. Zero value: never fenced, never conflict. |
| + Layout LayoutInputs |
| } |
| // execLogLineMax caps one teed log line (spec §5): longer is truncated with |
| @@ -158,9 +161,6 @@ func (b *ExecBackend) spawnInCgroup(rt registry.Route, build func() *exec.Cmd) ( |
| func (b *ExecBackend) liveDir() string { return filepath.Join(b.OptDir, "live") } |
| func (b *ExecBackend) liveLink(host string) string { return filepath.Join(b.liveDir(), host) } |
| -func (b *ExecBackend) defaultDBPath(host string) string { |
| - return filepath.Join(b.DataDir, host+".db") |
| -} |
| // LiveVersion reports the version host's live symlink currently points at, or |
| // "" (no error) if the host hasn't been put under version management yet. It |
| @@ -196,7 +196,7 @@ func (b *ExecBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, er |
| } |
| return nil, err |
| } |
| - return &execLease{held: held, defaultDB: b.defaultDBPath(host)}, nil |
| + return &execLease{held: held, defaultDB: b.pathsFor(rt).dbPath}, nil |
| } |
| // execLease adapts *lease.Held to the Lease seam. |
| @@ -219,10 +219,7 @@ func (l *execLease) Replicate(ctx context.Context, dbPath string, syncInterval t |
| func (l *execLease) Release(ctx context.Context) error { return l.held.Release(ctx) } |
| func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, error) { |
| - host, dbPath := rt.Host, rt.DBPath |
| - if dbPath == "" { |
| - dbPath = b.defaultDBPath(host) |
| - } |
| + host, dbPath := rt.Host, b.pathsFor(rt).dbPath |
| if _, err := os.Stat(dbPath); err == nil { |
| return false, nil // warm cache — see doWake |
| } else if !os.IsNotExist(err) { |
| @@ -344,21 +341,19 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| if err := b.ensureLiveLink(ctx, host); err != nil { |
| return err |
| } |
| + paths := b.pathsFor(rt) |
| // Mint before the env is read, exactly as Start does — a seed is the FIRST |
| // thing a fresh instance runs, so this is usually where the token comes |
| // into existence, and instanceEnv's contract is that a seed sees the same |
| // environment the instance will. |
| - if err := EnsureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil { |
| + if err := EnsureAdminToken(paths.envFile); err != nil { |
| return fmt.Errorf("admin token for %s: %w", host, err) |
| } |
| - fileEnv, err := b.instanceEnv(host) |
| + fileEnv, err := b.instanceEnv(rt) |
| if err != nil { |
| return err |
| } |
| - dbPath := rt.DBPath |
| - if dbPath == "" { |
| - dbPath = b.defaultDBPath(host) |
| - } |
| + dbPath := paths.dbPath |
| // Whether the database existed BEFORE the seed ran decides whether a |
| // failed seed may clean up after itself (below). On the wake path it is |
| @@ -505,7 +500,7 @@ func (c *capBuffer) String() string { |
| // same env file the spawn path hands the child (the activator holds it either |
| // way). |
| func (b *ExecBackend) NextDue(ctx context.Context, rt registry.Route) (int64, error) { |
| - fileEnv, err := readEnvFile(filepath.Join(b.DataDir, rt.Host+".env")) |
| + fileEnv, err := readEnvFile(b.pathsFor(rt).envFile) |
| if err != nil { |
| return 0, fmt.Errorf("read env file: %w", err) |
| } |
| @@ -557,10 +552,8 @@ func (b *ExecBackend) ensureLiveLink(ctx context.Context, host string) error { |
| func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, string, error) { |
| host := rt.Host |
| - dbPath := rt.DBPath |
| - if dbPath == "" { |
| - dbPath = b.defaultDBPath(host) |
| - } |
| + paths := b.pathsFor(rt) |
| + dbPath := paths.dbPath |
| // Exec exactly what a systemd unit would: the per-instance live symlink |
| // (<OptDir>/live/<host> → a staged version), same --socket/--db flags, |
| @@ -577,11 +570,11 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // instance with no token can neither answer /api/next-due nor accept a |
| // tick, and a wake that quietly skipped it would report a healthy instance |
| // that silently never runs its scheduled work — the #110 kind of lie. |
| - if err := EnsureAdminToken(filepath.Join(b.DataDir, host+".env")); err != nil { |
| + if err := EnsureAdminToken(paths.envFile); err != nil { |
| return nil, "", fmt.Errorf("admin token for %s: %w", host, err) |
| } |
| - fileEnv, err := b.instanceEnv(host) |
| + fileEnv, err := b.instanceEnv(rt) |
| if err != nil { |
| return nil, "", err |
| } |
| @@ -605,7 +598,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // the instance serves requests either way; it just goes without its control |
| // verbs until the next restart, a stuck-stale cache self-heals via the |
| // safety-net TTL, and a missed tick is retried by the runner. |
| - envPath := filepath.Join(b.DataDir, host+".env") |
| + envPath := paths.envFile |
| // Read on EVERY call, never captured: the env file can be rewritten under a |
| // running instance (`carlos env`, a re-mint on a later spawn), and a token |
| // cached at bind time would keep authorising the old secret. |
| @@ -783,12 +776,13 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // belong to a serving process, not to a one-shot init. Both callers mint the |
| // instance's CARLOS_ADMIN_TOKEN into <host>.env first, so "the same |
| // environment" includes the same secret.) |
| -func (b *ExecBackend) instanceEnv(host string) (map[string]string, error) { |
| - cfgEnv, err := readEnvFile(filepath.Join(b.DataDir, host+".config.env")) |
| +func (b *ExecBackend) instanceEnv(rt registry.Route) (map[string]string, error) { |
| + paths := b.pathsFor(rt) |
| + cfgEnv, err := readEnvFile(paths.configEnv) |
| if err != nil && !os.IsNotExist(err) { |
| return nil, fmt.Errorf("read config env file: %w", err) |
| } |
| - ownEnv, err := readEnvFile(filepath.Join(b.DataDir, host+".env")) |
| + ownEnv, err := readEnvFile(paths.envFile) |
| if err != nil && !os.IsNotExist(err) { |
| return nil, fmt.Errorf("read env file: %w", err) |
| } |
| @@ -863,17 +857,11 @@ func mergeEnv(base, overlay map[string]string) map[string]string { |
| return out |
| } |
| -// sidecarEnvPath: the host's sidecar EnvironmentFile, |
| -// <DataDir>/<host>-sidecar.env (Eleven's <host>-bot.env, renamed). |
| -func (b *ExecBackend) sidecarEnvPath(host string) string { |
| - return filepath.Join(b.DataDir, host+"-sidecar.env") |
| -} |
| - |
| // HasSidecar: a host has a sidecar iff its sidecar EnvironmentFile exists. |
| // Derived from the filesystem on purpose, the way systemd units would do it: |
| // no registry column to drift from what's actually staged. |
| func (b *ExecBackend) HasSidecar(rt registry.Route) bool { |
| - _, err := os.Stat(b.sidecarEnvPath(rt.Host)) |
| + _, err := os.Stat(b.pathsFor(rt).sidecarEnv) |
| return err == nil |
| } |
| @@ -884,7 +872,7 @@ func (b *ExecBackend) HasSidecar(rt registry.Route) bool { |
| // symlink exists by now: the instance spawn checked it. The ctx is unused (an |
| // exec doesn't block); it's here for the backend seam's symmetry. |
| func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, error) { |
| - fileEnv, err := readEnvFile(b.sidecarEnvPath(rt.Host)) |
| + fileEnv, err := readEnvFile(b.pathsFor(rt).sidecarEnv) |
| if err != nil { |
| return nil, fmt.Errorf("read sidecar env: %w", err) |
| } |
diff --git a/internal/activator/layout.go b/internal/activator/layout.go| new file mode 100644 |
| index 000000000..4efc111f6 |
| --- /dev/null |
| +++ b/internal/activator/layout.go |
| @@ -0,0 +1,94 @@ |
| +// Ported from plan 3 task 2 (isolated-exec design §8.1): the classification |
| +// that decides whether a route's paths are today's flat layout, the nested |
| +// per-instance layout, or something an operator needs to look at. |
| +package activator |
| + |
| +import ( |
| + "fmt" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Layout is the classification of an activator-managed route's paths |
| +// (isolated-exec design §8.1). It decides the spawn path: flat and foreign |
| +// rows run today's exec child; nested rows run as units; fenced and |
| +// conflict rows refuse to wake. |
| +type Layout string |
| + |
| +const ( |
| + LayoutFlat Layout = "flat" |
| + LayoutNested Layout = "nested" |
| + LayoutFenced Layout = "fenced" |
| + LayoutConflict Layout = "conflict" |
| + LayoutForeign Layout = "foreign" |
| +) |
| + |
| +// LayoutInputs are the box facts Classify needs beyond the row: where the |
| +// data dir is, whether a migration journal exists for a host (plan 4 |
| +// writes them), and the other rows on the box (for identifier conflicts). |
| +// Nil seams mean "never fenced" and "never conflict", which is what unit |
| +// tests without a box want. |
| +type LayoutInputs struct { |
| + DataDir string |
| + // SocketDir is where flat sockets live and under which the nested |
| + // instances directory sits. Empty means provision.SocketDir; a test |
| + // points it at a temp dir so a route can classify as nested without |
| + // writing to /run/carlos. |
| + SocketDir string |
| + Journal func(host string) bool |
| + Peers func() ([]registry.Route, error) |
| + // SameID reports whether two hosts share an instance identifier. Nil |
| + // means provision.InstanceID equality; tests inject a collision. |
| + SameID func(a, b string) bool |
| +} |
| + |
| +// sockets returns the flat and nested socket paths for host under the |
| +// configured socket directory. |
| +func (in LayoutInputs) sockets(host string) (flat, nested string) { |
| + dir := in.SocketDir |
| + if dir == "" { |
| + dir = provision.SocketDir |
| + } |
| + return filepath.Join(dir, host+".sock"), filepath.Join(dir, "instances", provision.InstanceID(host), "app.sock") |
| +} |
| + |
| +// ActivatorManaged is the activator's own predicate for a route it owns |
| +// (activator.go's per-host gate and edge.go's proxy decision): an instance |
| +// route, hibernate-flagged, with a socket, not unit-backed, not |
| +// tunnel-served. An unflagged socket route is an externally supervised |
| +// process and is nobody's to migrate or isolate (design §8.2 step 1). |
| +func ActivatorManaged(rt registry.Route) bool { |
| + return rt.Kind == "instance" && rt.Hibernate && rt.Socket != "" && rt.Backing == "" && rt.Tunnel == "" |
| +} |
| + |
| +// Classify returns the layout and, for anything but flat and nested, the |
| +// reason a member or operator sees. |
| +func Classify(rt registry.Route, in LayoutInputs) (Layout, string) { |
| + if in.Journal != nil && in.Journal(rt.Host) { |
| + return LayoutFenced, "layout migration in flight; see " + provision.LayoutJournalPath(in.DataDir, rt.Host) |
| + } |
| + if in.Peers != nil { |
| + same := in.SameID |
| + if same == nil { |
| + same = func(a, b string) bool { return provision.InstanceID(a) == provision.InstanceID(b) } |
| + } |
| + peers, err := in.Peers() |
| + if err == nil { |
| + for _, p := range peers { |
| + if p.Host != rt.Host && ActivatorManaged(p) && same(p.Host, rt.Host) { |
| + return LayoutConflict, fmt.Sprintf("instance identifier collides with %s; neither host wakes until one is removed", p.Host) |
| + } |
| + } |
| + } |
| + } |
| + flatSock, nestedSock := in.sockets(rt.Host) |
| + switch { |
| + case rt.DBPath == provision.DefaultDBPath(in.DataDir, rt.Host) && rt.Socket == flatSock: |
| + return LayoutFlat, "" |
| + case rt.DBPath == provision.NestedDBPath(in.DataDir, rt.Host) && rt.Socket == nestedSock: |
| + return LayoutNested, "" |
| + } |
| + return LayoutForeign, fmt.Sprintf("db %q socket %q are neither the flat nor the nested defaults", rt.DBPath, rt.Socket) |
| +} |
diff --git a/internal/activator/layout_test.go b/internal/activator/layout_test.go| new file mode 100644 |
| index 000000000..0cff8ec3a |
| --- /dev/null |
| +++ b/internal/activator/layout_test.go |
| @@ -0,0 +1,67 @@ |
| +package activator |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func managed(host, db, sock string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: db, Socket: sock} |
| +} |
| + |
| +func TestClassifyFlatAndNested(t *testing.T) { |
| + in := LayoutInputs{DataDir: "/data"} |
| + if l, _ := Classify(managed("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock"), in); l != LayoutFlat { |
| + t.Errorf("flat row = %s", l) |
| + } |
| + if l, _ := Classify(managed("h.example.com", provision.NestedDBPath("/data", "h.example.com"), provision.NestedSocket("h.example.com")), in); l != LayoutNested { |
| + t.Errorf("nested row = %s", l) |
| + } |
| + if l, r := Classify(managed("h.example.com", "/data/sub/store.db", "/run/carlos/h.example.com.sock"), in); l != LayoutForeign || !strings.Contains(r, "store.db") { |
| + t.Errorf("custom row = %s %q", l, r) |
| + } |
| + // A nested DB with a flat socket is not nested: both columns must agree. |
| + if l, _ := Classify(managed("h.example.com", provision.NestedDBPath("/data", "h.example.com"), "/run/carlos/h.example.com.sock"), in); l != LayoutForeign { |
| + t.Errorf("half-migrated row = %s", l) |
| + } |
| +} |
| + |
| +func TestClassifyFencedWinsOverEverything(t *testing.T) { |
| + in := LayoutInputs{DataDir: "/data", Journal: func(h string) bool { return h == "h.example.com" }} |
| + if l, r := Classify(managed("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock"), in); l != LayoutFenced || r == "" { |
| + t.Errorf("fenced = %s %q", l, r) |
| + } |
| + if l, _ := Classify(managed("o.example.com", "/data/o.example.com.db", "/run/carlos/o.example.com.sock"), in); l != LayoutFlat { |
| + t.Errorf("other host = %s", l) |
| + } |
| +} |
| + |
| +// The identifier collision is exercised through the SameID seam rather than |
| +// a real hash collision: two nested rows for different hosts, SameID forced |
| +// to report them equal, must classify as a conflict that names the other |
| +// host. |
| +func TestClassifyConflictRefusesBothHosts(t *testing.T) { |
| + long := strings.Repeat("a", 60) + ".example.com" |
| + twin := strings.Repeat("a", 60) + ".example.org" |
| + a := managed(long, provision.NestedDBPath("/data", long), provision.NestedSocket(long)) |
| + b := managed(twin, provision.NestedDBPath("/data", twin), provision.NestedSocket(twin)) |
| + in := LayoutInputs{DataDir: "/data", Peers: func() ([]registry.Route, error) { return []registry.Route{a, b}, nil }, SameID: func(x, y string) bool { return true }} |
| + if l, r := Classify(a, in); l != LayoutConflict || !strings.Contains(r, twin) { |
| + t.Errorf("conflict = %s %q", l, r) |
| + } |
| +} |
| + |
| +func TestClassifyIgnoresUnmanagedPeers(t *testing.T) { |
| + a := managed("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock") |
| + ext := registry.Route{Host: "x.example.com", Kind: "instance", Hibernate: false, Socket: "/run/carlos/x.example.com.sock"} |
| + in := LayoutInputs{DataDir: "/data", Peers: func() ([]registry.Route, error) { return []registry.Route{a, ext}, nil }, SameID: func(x, y string) bool { return true }} |
| + if l, _ := Classify(a, in); l != LayoutFlat { |
| + t.Errorf("an unmanaged peer must not conflict: %s", l) |
| + } |
| + if ActivatorManaged(ext) { |
| + t.Error("unflagged socket route reported managed") |
| + } |
| +} |
diff --git a/internal/activator/paths.go b/internal/activator/paths.go| new file mode 100644 |
| index 000000000..e1fe4a40e |
| --- /dev/null |
| +++ b/internal/activator/paths.go |
| @@ -0,0 +1,48 @@ |
| +package activator |
| + |
| +import ( |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// instancePaths is every per-host path the exec backend touches, resolved |
| +// once per call by layout so that no method builds a path from DataDir and |
| +// the host on its own. Flat and foreign rows keep today's flat names; a |
| +// nested row's files live in its instance directory with the same names |
| +// (design §3.1). |
| +type instancePaths struct { |
| + layout Layout |
| + reason string |
| + dataDir string // the directory that holds the four files |
| + runDir string // the directory that holds app.sock and the control socket |
| + dbPath string |
| + envFile string |
| + sidecarEnv string |
| + configEnv string |
| +} |
| + |
| +func (b *ExecBackend) pathsFor(rt registry.Route) instancePaths { |
| + l, reason := Classify(rt, b.layoutInputs()) |
| + p := instancePaths{layout: l, reason: reason, dataDir: b.DataDir, runDir: provision.SocketDir} |
| + if l == LayoutNested { |
| + p.dataDir = provision.InstanceDir(b.DataDir, rt.Host) |
| + p.runDir = provision.InstanceRunDir(rt.Host) |
| + } |
| + p.dbPath = rt.DBPath |
| + if p.dbPath == "" { |
| + p.dbPath = provision.DefaultDBPath(b.DataDir, rt.Host) |
| + } |
| + p.envFile = filepath.Join(p.dataDir, rt.Host+".env") |
| + p.sidecarEnv = filepath.Join(p.dataDir, rt.Host+"-sidecar.env") |
| + p.configEnv = filepath.Join(p.dataDir, rt.Host+".config.env") |
| + return p |
| +} |
| + |
| +// layoutInputs is the backend's LayoutInputs with DataDir filled in. |
| +func (b *ExecBackend) layoutInputs() LayoutInputs { |
| + in := b.Layout |
| + in.DataDir = b.DataDir |
| + return in |
| +} |