Skip to content

Commit 698d126

Browse files
authored
Merge pull request #160 from flatrun/feat/albacore-quick-wins
feat(agent): Improve nginx vhost generation, CLI help, and service discovery
2 parents 54cfa43 + b05710c commit 698d126

11 files changed

Lines changed: 587 additions & 64 deletions

File tree

cmd/agent/main.go

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"log"
88
"os"
99
"os/signal"
10+
"strings"
1011
"syscall"
1112
"time"
1213

@@ -16,42 +17,112 @@ import (
1617
"github.com/flatrun/agent/pkg/updater"
1718
"github.com/flatrun/agent/pkg/version"
1819
"github.com/moby/moby/client"
20+
"github.com/spf13/cobra"
1921
)
2022

2123
func main() {
22-
if len(os.Args) > 1 {
23-
switch os.Args[1] {
24-
case "update":
25-
handleUpdate(os.Args[2:])
26-
return
27-
case "setup":
28-
handleSetup(os.Args[2:])
29-
return
30-
case "version":
31-
printVersion()
32-
return
24+
root := newRootCmd()
25+
root.SetArgs(normalizeLegacyFlags(os.Args[1:]))
26+
if err := root.Execute(); err != nil {
27+
os.Exit(1)
28+
}
29+
}
30+
31+
// normalizeLegacyFlags keeps the single-dash long forms the previous flag-based
32+
// CLI accepted (-config, -version) working under pflag, which otherwise reads a
33+
// single dash as a cluster of shorthand flags. This preserves existing launch
34+
// commands such as `flatrun-agent -config /etc/flatrun/config.yml`.
35+
func normalizeLegacyFlags(args []string) []string {
36+
out := make([]string, 0, len(args))
37+
for _, a := range args {
38+
if a == "-config" || a == "-version" ||
39+
strings.HasPrefix(a, "-config=") || strings.HasPrefix(a, "-version=") {
40+
a = "-" + a
3341
}
42+
out = append(out, a)
3443
}
44+
return out
45+
}
3546

36-
configPath := flag.String("config", "", "Path to configuration file")
37-
showVersion := flag.Bool("version", false, "Print version information")
38-
flag.Parse()
47+
func newRootCmd() *cobra.Command {
48+
var configPath string
49+
var showVersion bool
3950

40-
if *showVersion {
41-
printVersion()
42-
return
51+
root := &cobra.Command{
52+
Use: "flatrun-agent",
53+
Short: "Flatrun Agent - flat-file container orchestration",
54+
SilenceUsage: true,
55+
RunE: func(cmd *cobra.Command, args []string) error {
56+
if showVersion {
57+
printVersion()
58+
return nil
59+
}
60+
// A bare invocation is most likely someone looking for guidance,
61+
// not an attempt to start the daemon: the service is always launched
62+
// with --config. Show help rather than failing on a missing default
63+
// config. Pass --config (or use `serve`) to actually start.
64+
if !cmd.Flags().Changed("config") {
65+
return cmd.Help()
66+
}
67+
return runServer(configPath)
68+
},
69+
}
70+
root.CompletionOptions.DisableDefaultCmd = true
71+
root.PersistentFlags().StringVar(&configPath, "config", "", "Path to configuration file")
72+
root.Flags().BoolVar(&showVersion, "version", false, "Print version information")
73+
74+
serve := &cobra.Command{
75+
Use: "serve",
76+
Short: "Start the agent server",
77+
SilenceUsage: true,
78+
RunE: func(cmd *cobra.Command, args []string) error {
79+
return runServer(configPath)
80+
},
4381
}
4482

45-
resolvedConfigPath := config.FindConfigPath(*configPath)
83+
setup := &cobra.Command{
84+
Use: "setup <target> <service> [options]",
85+
Short: "Deploy an infrastructure service from embedded templates",
86+
DisableFlagParsing: true,
87+
Run: func(cmd *cobra.Command, args []string) {
88+
handleSetup(args)
89+
},
90+
}
91+
92+
update := &cobra.Command{
93+
Use: "update [options]",
94+
Short: "Update the agent to the latest version",
95+
DisableFlagParsing: true,
96+
Run: func(cmd *cobra.Command, args []string) {
97+
handleUpdate(args)
98+
},
99+
}
100+
101+
versionCmd := &cobra.Command{
102+
Use: "version",
103+
Short: "Print version information",
104+
Run: func(cmd *cobra.Command, args []string) {
105+
printVersion()
106+
},
107+
}
108+
109+
root.AddCommand(serve, setup, update, versionCmd)
110+
return root
111+
}
112+
113+
func runServer(configPath string) error {
114+
resolvedConfigPath := config.FindConfigPath(configPath)
46115
cfg, err := config.Load(resolvedConfigPath)
47116
if err != nil {
48-
log.Fatalf("Failed to load config from %s: %v", resolvedConfigPath, err)
117+
return fmt.Errorf("failed to load config from %s: %w", resolvedConfigPath, err)
49118
}
50119

51-
ensureDockerReachable(cfg.DockerSocket)
120+
if err := ensureDockerReachable(cfg.DockerSocket); err != nil {
121+
return err
122+
}
52123

53124
if err := os.MkdirAll(cfg.DeploymentsPath, 0755); err != nil {
54-
log.Fatalf("Failed to create deployments directory '%s': %v", cfg.DeploymentsPath, err)
125+
return fmt.Errorf("failed to create deployments directory %q: %w", cfg.DeploymentsPath, err)
55126
}
56127

57128
log.Printf("Starting Flatrun Agent v%s", version.Version)
@@ -61,7 +132,7 @@ func main() {
61132

62133
fileWatcher, err := watcher.New(cfg.DeploymentsPath)
63134
if err != nil {
64-
log.Fatalf("Failed to create file watcher: %v", err)
135+
return fmt.Errorf("failed to create file watcher: %w", err)
65136
}
66137
defer fileWatcher.Close()
67138

@@ -80,9 +151,10 @@ func main() {
80151

81152
log.Println("Shutting down Flatrun Agent...")
82153
_ = apiServer.Stop()
154+
return nil
83155
}
84156

85-
func ensureDockerReachable(dockerHost string) {
157+
func ensureDockerReachable(dockerHost string) error {
86158
log.Println("Checking if Docker is reachable...")
87159

88160
opts := []client.Opt{client.FromEnv}
@@ -92,19 +164,18 @@ func ensureDockerReachable(dockerHost string) {
92164

93165
cli, err := client.New(opts...)
94166
if err != nil {
95-
log.Fatalf("Failed to create Docker client: %v", err)
167+
return fmt.Errorf("failed to create Docker client: %w", err)
96168
}
97169
defer cli.Close()
98170

99171
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
100172
defer cancel()
101173

102174
if _, err := cli.Ping(ctx, client.PingOptions{}); err != nil {
103-
log.Fatalf("Docker is not reachable: %v. "+
104-
"Ensure the Docker daemon is running and the socket "+
105-
"in config is correct.", err)
175+
return fmt.Errorf("docker is not reachable: %w. Ensure the Docker daemon is running and the socket in config is correct", err)
106176
}
107177
log.Println("Docker is reachable")
178+
return nil
108179
}
109180

110181
func printVersion() {

cmd/agent/main_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestRootCmd_BareShowsHelp(t *testing.T) {
10+
cmd := newRootCmd()
11+
var out bytes.Buffer
12+
cmd.SetOut(&out)
13+
cmd.SetErr(&out)
14+
cmd.SetArgs([]string{})
15+
16+
if err := cmd.Execute(); err != nil {
17+
t.Fatalf("bare invocation should not error, got: %v", err)
18+
}
19+
20+
got := out.String()
21+
for _, want := range []string{"serve", "setup", "update", "version"} {
22+
if !strings.Contains(got, want) {
23+
t.Errorf("help output missing %q command, got:\n%s", want, got)
24+
}
25+
}
26+
}
27+
28+
func TestNormalizeLegacyFlags(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
in []string
32+
want []string
33+
}{
34+
{"single-dash config", []string{"-config", "/etc/x.yml"}, []string{"--config", "/etc/x.yml"}},
35+
{"single-dash config equals", []string{"-config=/etc/x.yml"}, []string{"--config=/etc/x.yml"}},
36+
{"single-dash version", []string{"-version"}, []string{"--version"}},
37+
{"double-dash untouched", []string{"--config", "/etc/x.yml"}, []string{"--config", "/etc/x.yml"}},
38+
{"subcommand untouched", []string{"setup", "infra", "nginx"}, []string{"setup", "infra", "nginx"}},
39+
{"unrelated short flag untouched", []string{"update", "-check"}, []string{"update", "-check"}},
40+
}
41+
42+
for _, tt := range tests {
43+
t.Run(tt.name, func(t *testing.T) {
44+
got := normalizeLegacyFlags(tt.in)
45+
if strings.Join(got, " ") != strings.Join(tt.want, " ") {
46+
t.Errorf("normalizeLegacyFlags(%v) = %v, want %v", tt.in, got, tt.want)
47+
}
48+
})
49+
}
50+
}
51+
52+
func TestRootCmd_RegistersSubcommands(t *testing.T) {
53+
cmd := newRootCmd()
54+
for _, name := range []string{"serve", "setup", "update", "version"} {
55+
sub, _, err := cmd.Find([]string{name})
56+
if err != nil || sub.Name() != name {
57+
t.Errorf("expected subcommand %q to be registered, got %v (err %v)", name, sub, err)
58+
}
59+
}
60+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222
github.com/lib/pq v1.10.9
2323
github.com/moby/moby/client v0.2.2
2424
github.com/robfig/cron/v3 v3.0.1
25+
github.com/spf13/cobra v1.10.2
2526
github.com/testcontainers/testcontainers-go v0.41.0
2627
github.com/testcontainers/testcontainers-go/modules/compose v0.41.0
2728
golang.org/x/crypto v0.48.0
@@ -152,7 +153,6 @@ require (
152153
github.com/sigstore/sigstore-go v1.1.4-0.20251124094504-b5fe07a5a7d7 // indirect
153154
github.com/sirupsen/logrus v1.9.4 // indirect
154155
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
155-
github.com/spf13/cobra v1.10.2 // indirect
156156
github.com/spf13/pflag v1.0.10 // indirect
157157
github.com/stretchr/testify v1.11.1 // indirect
158158
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect

internal/docker/discovery.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,9 @@ func (d *Discovery) UpdateComposeFile(name string, content string) error {
582582
if newMeta.Networking.Service != "" {
583583
existing.Networking.Service = newMeta.Networking.Service
584584
}
585-
d.SaveMetadata(name, existing)
585+
if err := d.SaveMetadata(name, existing); err != nil {
586+
return fmt.Errorf("failed to sync service metadata: %w", err)
587+
}
586588
}
587589
}
588590
}
@@ -665,13 +667,20 @@ func (d *Discovery) generateMetadataFromCompose(composePath, name string) *model
665667

666668
// pickPrimaryService selects the service to use for networking metadata.
667669
// For single-service composes, returns that service. For multi-service,
668-
// returns the first service with exposed ports.
670+
// it prefers a service named "app" or "web" that exposes ports, then falls
671+
// back to the first service with exposed ports. The preference keeps the
672+
// selection deterministic, since Go map iteration order is random.
669673
func (d *Discovery) pickPrimaryService(services map[string]composeService) (string, composeService) {
670674
if len(services) == 1 {
671675
for name, svc := range services {
672676
return name, svc
673677
}
674678
}
679+
for name, svc := range services {
680+
if (name == "app" || name == "web") && (len(svc.Ports) > 0 || len(svc.Expose) > 0) {
681+
return name, svc
682+
}
683+
}
675684
for name, svc := range services {
676685
if len(svc.Ports) > 0 || len(svc.Expose) > 0 {
677686
return name, svc

internal/docker/discovery_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,26 @@ func TestGenerateMetadataFromCompose_ServiceName(t *testing.T) {
498498
}
499499
}
500500

501+
func TestPickPrimaryService_PrefersAppOrWeb(t *testing.T) {
502+
d := NewDiscovery(t.TempDir())
503+
504+
withPorts := composeService{Ports: []interface{}{"80:80"}}
505+
services := map[string]composeService{
506+
"queue": withPorts,
507+
"app": withPorts,
508+
"redis": withPorts,
509+
}
510+
511+
// Map iteration order is random, so a service with ports could be returned
512+
// in any order. Repeat to confirm "app" is selected deterministically.
513+
for i := 0; i < 100; i++ {
514+
name, _ := d.pickPrimaryService(services)
515+
if name != "app" {
516+
t.Fatalf("pickPrimaryService = %q, want \"app\"", name)
517+
}
518+
}
519+
}
520+
501521
func TestGenerateMetadataFromCompose_Expose(t *testing.T) {
502522
tests := []struct {
503523
name string

0 commit comments

Comments
 (0)