My Go binary decides what it is by reading its own name
stamusctl ships one source tree as two programs, a CLI and a daemon, and the process picks its role by looking at os.Args[0]. Why I keep building things that way, what breaks under go run, go test and hot reload, and what I would change.
stamusctl is a CLI and, under the name stamusd, a REST daemon exposing the same operations. Same main.go, same source tree, and the process decides which of the two it is when it starts. It is not the only project where I have done this, and I expect to keep doing it.
I covered it in a few lines when I wrote about the daemon. This post goes back with more honesty, because rereading the code with fresh eyes, I still like the pattern and I don’t like several of the ways I implemented it.
The case for one binary
The CLI and the daemon call the same handlers, so “anything the CLI can do, the daemon can do over HTTP” is true by construction, with no second codebase to keep in sync. A new operation is written once and shows up in both.
One source tree also means one version number. The daemon at 1.4 runs exactly the code the CLI at 1.4 runs, and nobody has to ask which CLI goes with which daemon.
This is not an exotic idea. Grafana’s Loki and Mimir ship as a single binary, and a -target flag picks which components a process runs. Loki with -target=all is its monolithic mode, everything in one process. Split the same binary across deployments with different targets and you get the microservices mode. Same artifact, different flag, very different topology.
How stamusctl picks a role
Grafana picks with a flag, and in most projects I would have done the same. stamusctl is different because of who types it. Its users are not necessarily comfortable in a terminal, and every CLI command would have had to carry that flag. We didn’t want to put that burden on them.
So stamusctl picks with the binary’s own name, the way busybox does: one executable, and ls or cat depending on what it was called. The entry point, trimmed:
func main() {
if exec := os.Getenv("STAMUS_APP_NAME"); exec != "" {
app.Name = exec
}
switch app.Name {
case "stamusctl":
ctl.Execute()
case "stamusd":
daemon.Execute()
default:
ctl.Execute()
}
}
app.Name is set in an init() from filepath.Base(os.Args[0]). With the name, you type stamusctl compose init and nothing else, while the container’s entrypoint is stamusd, and the only people who ever think about the mode are the ones building the thing.
The name is also the least reliable input a Go process has, and the rest of this post is the list of places where that showed up.
What os.Args[0] actually is
To see it without the real codebase in the way, here is a toy with the same shape:
func main() {
switch name := filepath.Base(os.Args[0]); name {
case "shop-api":
fmt.Println("running api")
case "shop-worker":
fmt.Println("running worker")
default:
fmt.Printf("running api (default), name=%q\n", name)
}
}
Built and run four ways:
$ go build -o bin/shop-worker ./cmd && ./bin/shop-worker
running worker
$ go run ./cmd
running api (default), name="cmd"
$ cp bin/shop-worker bin/shop-wroker && ./bin/shop-wroker
running api (default), name="shop-wroker"
$ go test -v ./cmd # logging os.Args[0] from a test
main_test.go:8: /tmp/go-build2195783459/b001/cmd.test
go run names the temporary binary after the package directory, so the role is cmd. go test names it cmd.test. A typo in a Dockerfile or a deploy script produces a name the switch has never heard of. In all three cases the program does not fail. It falls into default and runs as whatever the default role is.
What I did about it
Each of those cases left a local patch in stamusctl. Reading them together is not flattering.
go run came first. The first time I ran go run ./cmd expecting the daemon, I got the CLI, and my reaction was roughly “ah, fudge, this one is going to be painful, let’s add an environment variable to bypass it”. So go run got an override: STAMUS_APP_NAME=stamusd go run ./cmd gives you the daemon. The Makefile now sets that variable on every go run it does, six times (four shell completions, a compose init -h check and the docs generator), including the ones that want the CLI and would get it by default anyway.
Then I did it again. The init() in the app package reads a second variable, STAMUSCTL_NAME, for the same purpose. I had already patched this once, never wrote it down anywhere, forgot about it, and patched it a second time when the problem came back. Two overrides for one decision, one in main and one in init, and main wins because it runs later (Go finishes every init before main starts).
Hot reload was the next one. When I started working on the daemon from a dev container with air rebuilding on every save, the API simply never came up. No crash, no error worth reading: air built the binary under its own default name, the name matched nothing, the process booted the CLI, and a CLI with no arguments has nothing to serve. The fix was to make air’s config build to ./dist/stamusd. That path exists for one reason, any other file name boots the CLI, and the name of a build artifact in a dev tool’s config file is now part of how the program decides what it is.
The binary name also picked up a second job. The version code checks whether the executable is called cmd, and if so, reads the version from the VERSION file in the working directory, because that means it is running under go run from the repository root. The name answers two questions now, “which program am I” and “am I in development”, and the second answer is a guess about how go run names things.
Test detection went the same way: the app package scans os.Args for a -test. prefix and switches to an in-memory filesystem when it finds one. It works, and it is one more place where the process inspects how it was launched instead of being told.
Then the default. An unknown name runs the CLI. The daemon image starts stamusd run, so a typo in that entrypoint boots the CLI with the argument run, and the CLI happens to have no run command, so it exits with an error. That is loud, and loud is good, but it is loud by luck. If the CLI ever grows a run subcommand, the same typo starts something else entirely. “Every launch path remembered its override” is the same guarantee as “every method remembered to call raise”, and I spent a whole DDD post explaining why I don’t trust that one.
What I would change
I would still pick the name for stamusctl’s users. Every problem above sits on the side of the people building and shipping it: go run, tests, hot reload, overrides nobody wrote down, and the entrypoint typo waiting to happen. None of them come from someone typing stamusctl. So the fix is to stop making the name the only way in.
The name stays the front door, mapped to a role in one place, so users still type stamusctl and nothing else.
Developers get an explicit role as an argument, the way Loki’s -target works. go run ./cmd daemon then means the daemon, whatever the binary happens to be called, and no environment variable is involved.
An unknown role exits with an error. No default branch that quietly picks one:
var aliases = map[string]string{
"shop-api": "api",
"shop-worker": "worker",
}
// role returns the role to run and the arguments left for it.
func role() (string, []string, error) {
name := filepath.Base(os.Args[0])
if r, ok := aliases[name]; ok {
return r, os.Args[1:], nil
}
if len(os.Args) > 1 {
switch os.Args[1] {
case "api", "worker":
return os.Args[1], os.Args[2:], nil
}
}
return "", nil, fmt.Errorf("cannot tell which role to run as %q: pass one of api, worker", name)
}
The alias check comes first on purpose. A first draft that read the role from the first argument before looking at the name broke immediately on a CLI with its own subcommands: stamusctl compose init would have asked for a role called compose. Aliases first, explicit role second, error last:
$ ./shop-api compose init # alias: role api, args [compose init]
$ go run ./cmd worker --verbose # explicit: role worker, args [--verbose]
$ go run ./cmd
cannot tell which role to run as "cmd": pass one of api, worker
“Am I in development” and “am I under test” get their own explicit inputs too, a build tag or a variable, instead of being inferred from the name.
So why is it not done yet? None of this is a rewrite. It is a small change in the entry point and a pass over the Makefile and the air config. The patches work well enough day to day, which is exactly how they kept piling up.
When one binary is the wrong call
The pattern assumes the roles belong together. When they stop sharing code, or ship on different schedules, or one of them pulls in a heavy dependency the other should never load, the shared binary stops paying for itself. The same goes for a security boundary: if one role faces the internet and the other holds credentials it should never touch, I would rather the exposed one not contain the other’s code at all.
Below that line, I keep coming back to it. One build, one version, and the topology decided at deploy time, which is what Loki and Mimir sell as a feature. The project I am building now takes that further: there are no names to read, the deployment hands the binary the list of modules it should run, and splitting a product across machines is a line in a Helm values file. That one is in Rust, and it gets its own post.