Documentation
¶
Overview ¶
Package devconfig owns w17ctl's dev-machine-local state — `~/.w17/config.yaml`. This is the THIRD config home in the w17 model, alongside:
- proto — code-aware (WHAT gets generated)
- w17/lock.yaml — code-agnostic, SHARED + signed (reproducible codegen across the team)
- ~/.w17/config.yaml — dev-machine-LOCAL (this package)
It holds the registry of installed projects, the unique host-port assignments w17ctl hands each project (so two projects' exposed ports never collide on one machine), and per-project run presets.
None of this belongs in the signed lock: port maps must differ machine-to-machine (that is the whole point), and a "run only the admin binary on my weak laptop" preset is a personal-machine concern, not a shared, reproducible build input.
Index ¶
- Constants
- func BindHost(bind string) string
- func BuildUpEnv(p *Project, preset *Preset) []string
- func DefaultDir() (string, error)
- func DefaultPath() (string, error)
- func ParseBind(s string) (string, error)
- func PinnedPorts(root string, slots []Slot) map[string]int
- func PortInUse(port int) bool
- func PresetServices(explicit []string, preset *Preset) []string
- func ResolveBindHost(mode Mode, bind string) string
- func Save(path string, c *Config) error
- func SaveDefault(c *Config) error
- func ValidateProjectName(name string) error
- type Config
- func (c *Config) AllocatePorts(name string, slots []Slot) bool
- func (c *Config) AllocatePortsPinned(name string, slots []Slot, pinned map[string]int) (changed bool, honoured []PortPin)
- func (c *Config) DeleteRemote(name string) bool
- func (c *Config) DuplicatePaths() map[string][]string
- func (c *Config) EffectivePortBase() int
- func (c *Config) FindByPath(absPath string) (string, *Project)
- func (c *Config) ResolveMode(absPath, flag string) (Mode, error)
- func (c *Config) ResolveProject(lockProject, absPath string) (string, *Project, string, error)
- func (c *Config) ResolveRemote(absPath, flag string) (string, *Remote, error)
- func (c *Config) SetRemote(name string, r *Remote)
- type Mode
- type PortPin
- type Preset
- type Project
- type Remote
- type Slot
Constants ¶
const ( // BindLoopback binds 127.0.0.1: ports reach only the local box (or, in // remote mode, only through the SSH tunnel). The secure default. BindLoopback = "loopback" // BindPublic binds 0.0.0.0: ports reach any interface — on a remote // host with a public IP that means the internet (subject to firewall). BindPublic = "public" )
Bind modes — the host interface docker publishes ports on.
const DefaultPortBase = 14000
DefaultPortBase is where w17ctl starts allocating host ports when the config does not pin its own port_base. Chosen well above the conventional dialect/gateway defaults (5432 / 6379 / 3306 / 8080 / 16686 / 4222) so a freshly-allocated stack never lands on a port a hand-run `docker compose` would also pick. The 13xxx band is deliberately left free for hand-run localhost services (e.g. the dockerized console on :13000) — allocation only ever climbs from 14000 upward, so it never treads on that reserved band.
Variables ¶
This section is empty.
Functions ¶
func BindHost ¶
BindHost maps an EXPLICIT bind mode to the docker host-publish interface — "public" → 0.0.0.0, "loopback" → 127.0.0.1. Used for the `stack bind` confirmation message; the unset ("") default is mode-aware, so use ResolveBindHost when a project has not pinned a bind.
func BuildUpEnv ¶
BuildUpEnv merges a project's managed host-port assignments with a preset's extra env into a sorted KEY=VALUE list for the compose subprocess. Preset env wins on conflict (it is layered last).
This is the single source of truth for "what env does `stack up` / the UI inject" — both the w17ctl CLI and the local UI call it, so a human clicking the UI and an agent driving the CLI produce identical compose invocations.
func DefaultDir ¶
DefaultDir is the w17ctl home — `$W17_HOME` if set, else `$HOME/.w17`. The w17ctl binary itself lives here (~/.w17/w17ctl).
func DefaultPath ¶
DefaultPath is the config file path — `<DefaultDir>/config.yaml`.
func PinnedPorts ¶
PinnedPorts reads the host ports a project has CHOSEN, from the process environment and from the project's `.env`.
It exists because the registry was answering a question the developer had already answered. `AllocatePorts` assigns a free port to any slot without one, and `BuildUpEnv` then passes that value to the compose subprocess — where it wins over `.env`, because a variable in the environment outranks the file. A developer who wrote
FINPLATFORM_GATEWAY_HOST_PORT=16720
got 14003 and no explanation. On a host where each workspace has its own tunnelled block of ports, a number outside the block is not merely surprising: nothing outside it is reachable at all, so the stack comes up and cannot be used.
The registry keeps its real job — picking a port for a slot nobody has chosen, and not handing the same one to two projects. A pinned value is recorded in it for that second reason: an explicit choice has to be visible to the next project's allocator, or avoiding collisions stops working the moment anyone pins anything.
Precedence within this function is the environment over `.env`, which is the order docker compose itself uses, so `W17_X=1 w17ctl stack up` behaves the way the same override behaves for a direct `docker compose up`.
func PortInUse ¶
PortInUse exposes the shared OS port probe to callers outside the package (the `stack up` preflight). Kept as a thin wrapper over the same package var the allocator uses so both see identical behaviour (and the same test stub).
func PresetServices ¶
PresetServices picks the services to bring up: an explicit list wins; otherwise the preset's selection; otherwise nil (the whole stack).
func ResolveBindHost ¶
ResolveBindHost is the mode-aware host-publish interface w17ctl injects as W17_BIND_HOST at `stack up`. An explicit bind wins; when unset the default depends on the mode:
- remote → 127.0.0.1 (secure: the stack runs on a shared server, and the only consumer is the SSH tunnel, which dials loopback);
- local → 0.0.0.0 (historical: local container consumers — e2e test containers via host.docker.internal, LAN devices — need it, and a laptop behind NAT is not internet-exposed).
The generated compose defaults W17_BIND_HOST to 0.0.0.0, so a raw `docker compose up` (e.g. the e2e harness) keeps the historical bind; the loopback security on remote comes from w17ctl always injecting it.
func Save ¶
Save writes the config to path atomically (temp file + rename), creating the parent directory if needed.
func ValidateProjectName ¶
ResolveProject answers "which registry entry is this checkout" the way the checkout itself answers it: by the `project:` its lock names.
The lock carries that name and was right the whole time; nothing asked it. Falling back to the path is kept for a checkout with no lock yet, and then a duplicate path is REFUSED rather than silently picked — a wrong store is a dump of somebody else's database, or a wipe of it.
Returns a nil project with no error when the checkout simply is not registered: that is the ordinary pre-`stack up` state, not a fault. ValidateProjectName refuses a registry key that cannot safely be ONE path component.
The key is not only a label: `cmd/stack/mode.go` and `project ps` join it onto the remote base directory (`path.Join(r.Path, project)`), so a name like `../../other` puts `stack up` and `project ps` outside the base an operator configured. It reaches that join from three places — `project rename`, `project import` and EnsureRegistered — and two of those take the name from `w17/lock.yaml`, a file that travels with a repository. So the check belongs here, beside the registry, rather than at the one call site a reviewer happened to be reading.
Types ¶
type Config ¶
type Config struct {
// Version is the schema version (1 today). Forward-compat: an
// unknown future version still loads — fields we don't know are
// preserved on save only if round-tripped, which we do not do;
// keep additions backward-compatible.
Version int `yaml:"version"`
// PortBase overrides DefaultPortBase for this machine. Zero =
// DefaultPortBase.
PortBase int `yaml:"port_base,omitempty"`
// Projects is the registry, keyed by project name (lock.project).
Projects map[string]*Project `yaml:"projects,omitempty"`
// Remotes are named remote docker hosts, keyed by a user-chosen name.
// Populated by `stack remote add`; consumed in remote mode to run the
// docker stack on a beefy SSH-reachable box. Empty = local-only, the default.
Remotes map[string]*Remote `yaml:"remotes,omitempty"`
// DefaultMode is the global default execution mode when a project does
// not pin its own: "local" (default) or "remote". Empty == local.
DefaultMode string `yaml:"default_mode,omitempty"`
// DefaultRemote is the Remotes key used in remote mode when a project
// does not pin one. Empty == no default (an explicit pin/flag is then
// required to resolve a remote).
DefaultRemote string `yaml:"default_remote,omitempty"`
}
Config is the whole `~/.w17/config.yaml` document.
func Load ¶
Load reads the config at path. A missing file is NOT an error — it returns a fresh, empty config (first run). The returned config has non-nil Projects and a resolved Version so callers can mutate it directly.
func (*Config) AllocatePorts ¶
AllocatePorts ensures every slot in `slots` has a unique host port assigned to the named project, allocating new ones from the config's port base upward while never colliding with a port already handed to ANY registered project. It is idempotent: an existing slot keeps its port across runs (so a project's gateway stays on the same host port even as other slots are added), and slots no longer present are pruned. Returns true when the project's Ports map changed.
Beyond avoiding cross-project assignments in this config, it also skips any port currently bound on the OS (via [portInUse]) so a fresh slot never lands on a port a hand-run service (e.g. the dockerized console) happens to hold right now. The probe is best-effort and only consulted when picking a NEW port — existing sticky assignments are never disturbed.
func (*Config) AllocatePortsPinned ¶
func (c *Config) AllocatePortsPinned(name string, slots []Slot, pinned map[string]int) (changed bool, honoured []PortPin)
AllocatePortsPinned is AllocatePorts with the developer's own choices honoured: any slot present in `pinned` takes that value instead of an allocated one, and the pin is RECORDED in the registry so the next project's allocator avoids it.
Returns the slots whose registry value it changed to honour a pin, so the caller can say so. Silently moving a port is what this exists to stop — the report is half the fix, not a nicety.
func (*Config) DeleteRemote ¶
DeleteRemote removes the named remote and cascades: it clears DefaultRemote when it pointed at the removed one, and clears every project's Remote pin that referenced it (so nothing dangles at a now-unknown remote). Returns false — changing nothing — if no such remote exists.
func (*Config) DuplicatePaths ¶
DuplicatePaths reports every path registered under more than one project name, with those names, sorted.
Two entries on one path is not a state any command can resolve correctly: the registry is keyed by name and asked by path, so the question has more than one true answer and every caller picks one. Reporting it is the only honest handling — see a consumer, where the three answers differed by store PORT and the dump went to another workspace's database.
func (*Config) EffectivePortBase ¶
EffectivePortBase returns PortBase or DefaultPortBase.
func (*Config) FindByPath ¶
FindByPath returns the registered project (and its name) whose Path matches the given absolute path, or "", nil if none.
⚠️ DETERMINISTIC BY NAME, and that is a fix rather than a detail. This used to `range c.Projects` and return the first match — over a MAP, whose iteration order Go randomises per run. With two registry entries sharing a path, the answer to "which project am I in" therefore changed between invocations of the same command.
A consumer had three entries on one path. `stack up` published the ports of one, `stack build` dialled another, and their lock named a third — so a dump reached a database in a DIFFERENT WORKSPACE on the same machine, and the empty snapshots that came back are what a branch switch then restored over their live stores.
Sorting makes the wrong answer at least a STABLE wrong answer, which is what makes it findable. Duplicates themselves are refused by DuplicatePaths, and ResolveProject prefers the lock's own name over any of this.
func (*Config) ResolveMode ¶
ResolveMode walks the resolution chain for the stack execution mode of the project at absPath, mirroring core.ResolveConsoleAddr. Precedence (first non-empty wins):
- flag — kong's --mode value (the W17_STACK_MODE env var is folded in by kong's `env` tag, so this single argument covers both flag+env).
- the project's pinned Mode (`stack local` / `stack remote use`).
- the global DefaultMode.
- local — the bottom default.
Every source is validated; an invalid value anywhere is a hard error rather than a silent fall-through, so a typo in config surfaces.
func (*Config) ResolveProject ¶
func (*Config) ResolveRemote ¶
ResolveRemote resolves which registered remote the project at absPath uses in remote mode. Precedence (first non-empty wins): flag → the project's pinned Remote → DefaultRemote. The resolved name must exist in Remotes; an empty resolution or an unknown name is an actionable error.
type Mode ¶
type Mode string
Mode is a stack execution mode — where `w17ctl stack` builds and runs the docker stack.
const ( // ModeLocal runs the docker stack on the local machine — the default, // historical behaviour. ModeLocal Mode = "local" // ModeRemote runs the docker stack on a registered remote docker host // over SSH, tunneling each published port back to the identical // localhost port so the dev experience is unchanged. ModeRemote Mode = "remote" )
type PortPin ¶
type PortPin struct {
Key string
Was int
Now int
// ClashesWith names another registered project already holding this port.
// The pin is still honoured — an explicit choice is the answer, and
// refusing it would put this back where it started — but two stacks that
// both publish it cannot run at once, and docker's own message at bind
// time says only "port is already allocated", never which project owns it.
ClashesWith string
}
PortPin is one slot whose registry assignment was replaced by an explicit choice: `Was` is what the registry held (0 when it held nothing).
type Preset ¶
type Preset struct {
// Services is the list of compose service names to bring up. Empty
// = the whole stack.
Services []string `yaml:"services,omitempty"`
// Env is extra environment layered onto the compose subprocess on
// top of the managed port vars (e.g. W17_OTEL_ENDPOINT="" to skip
// the tracing backend on a weak machine). A preset CAN override a
// managed port var here, but that is rarely wanted.
Env map[string]string `yaml:"env,omitempty"`
}
Preset is a named run profile: a subset of services to start plus extra env overrides layered on top of the w17ctl-managed port env.
type Project ¶
type Project struct {
// Path is the absolute filesystem path to the project root (the
// directory holding w17/lock.yaml).
Path string `yaml:"path"`
// Ports maps a port-slot key (the override env-var name, e.g.
// SHOP_GATEWAY_HOST_PORT) to the unique host port w17ctl assigned
// it. Rewritten into the compose subprocess env on `stack up`.
Ports map[string]int `yaml:"ports,omitempty"`
// ActivePreset is the preset applied by a bare `stack up` (empty =
// full stack, no env overlay, all services).
ActivePreset string `yaml:"active_preset,omitempty"`
// Presets are named run profiles for this project.
Presets map[string]*Preset `yaml:"presets,omitempty"`
// Autosync is this dev's local override of the project's
// dev-DB workflow mode. nil = no override (follow the lock's project
// default); non-nil wins over the lock. true = Mode A (autosync —
// the initiative follows the git branch, with auto snapshot/restore),
// false = Mode B (manual — active initiative set via `initiative
// activate`).
Autosync *bool `yaml:"autosync,omitempty"`
// ActiveInitiative is the dev's currently-active initiative in manual
// (autosync-off) mode — the persistent pointer `initiative activate`
// sets, which `stack build` + `db snapshot` then scope to. Empty =
// the implicit "default" initiative. Ignored in autosync mode (the
// git branch drives the initiative there).
ActiveInitiative string `yaml:"active_initiative,omitempty"`
// Mode pins this project's stack execution mode, winning over the
// global DefaultMode. "" = follow DefaultMode; "local" | "remote".
// Set by `stack local` / `stack remote use`.
Mode string `yaml:"mode,omitempty"`
// Remote pins which registered remote this project uses in remote
// mode, winning over DefaultRemote. "" = follow DefaultRemote; else a
// Remotes key.
Remote string `yaml:"remote,omitempty"`
// Bind is the host interface docker publishes this project's ports on,
// injected as W17_BIND_HOST into the compose env (like the port map).
// "" = loopback (127.0.0.1, secure default) | "loopback" | "public"
// (0.0.0.0 — reachable off-box). Set by `stack bind`. It is a
// dev-machine runtime concern (not a codegen input), so it lives here
// next to the ports, NOT in the lock.
Bind string `yaml:"bind,omitempty"`
// PublicAck records that the developer has acknowledged (once, by
// typing the confirmation phrase) that this project's containers are
// exposed to the network — the guard for the dangerous public+remote
// combination. Reset whenever Bind leaves "public".
PublicAck bool `yaml:"public_ack,omitempty"`
}
Project is one registered project's local state.
func (*Project) Activate ¶
Activate marks the named preset as the one a bare `stack up` applies. Returns false — changing nothing — if no such preset exists. Pass "" via ClearActivePreset to reset to the full stack.
func (*Project) ClearActivePreset ¶
func (p *Project) ClearActivePreset()
ClearActivePreset resets the project to the full stack (no active preset) — what a bare `stack up` runs.
func (*Project) DeletePreset ¶
DeletePreset removes the named preset and clears ActivePreset when it pointed at the removed one (a removed active preset falls back to the full stack). Returns false — changing nothing — if no such preset exists.
type Remote ¶
type Remote struct {
// SSH is the destination passed to ssh/rsync: user@host[:port]. An
// ssh_config alias (a plain host name) is fine.
SSH string `yaml:"ssh"`
// Path is the base directory on the server; each project gets a
// subdir <Path>/<project> that w17ctl rsyncs the tree into.
Path string `yaml:"path"`
}
Remote is one registered remote docker host — the SSH-reachable box w17ctl offloads the docker stack to in remote mode. The server stays dumb (dockerd + sshd only); all orchestration runs from w17ctl.
type Slot ¶
type Slot struct {
// Key is the stable slot identifier — equal to EnvVar, so a slot's
// assigned port survives across re-introspection.
Key string
// EnvVar is the environment variable that overrides the host port
// (e.g. SHOP_GATEWAY_HOST_PORT). w17ctl sets this in the compose
// subprocess env on `stack up`.
EnvVar string
// Default is the compose default (the `:-N` value), 0 if the
// mapping used `${VAR}` with no default.
Default int
// Container is the in-container port the host port maps to (for
// display only).
Container int
// Service is the compose service name the port belongs to.
Service string
// Source is the project-relative compose file the slot came from.
Source string
}
Slot is one host-published, w17ctl-overridable port found in the project's rendered compose tree. The override knob is an env var of the form `${VAR:-default}` in a service's `ports:` mapping — that is the single source of truth for "which ports can w17ctl rewrite".
func IntrospectSlots ¶
IntrospectSlots scans a project's rendered compose tree for managed host-port slots and the full set of compose service names. It reads `<root>/compose.w17.yaml` (infra: DBs, NATS, tracing) plus every `<root>/<servicesDir>/*/compose.yaml` (the per-bundle binaries).
Best-effort: a compose file that is missing or unparsable is skipped rather than failing the scan — slots only exist once codegen has run, and a half-generated tree should still let the rest be managed. Returns slots sorted by (Source, Service, EnvVar) and service names sorted alphabetically.