Skip to content

Commit cdb1e60

Browse files
authored
Merge pull request #31 from kapetan-io/thrawn/cli-subcommands
Implement CLI framework with subcommands
2 parents 434ec49 + 369397c commit cdb1e60

File tree

5 files changed

+233
-118
lines changed

5 files changed

+233
-118
lines changed

cmd/querator/main.go

Lines changed: 49 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,72 @@
11
package main
22

33
import (
4-
"context"
5-
"flag"
64
"fmt"
7-
"io"
5+
"github.com/spf13/cobra"
86
"os"
9-
"os/signal"
10-
"runtime"
11-
"strings"
12-
"syscall"
13-
14-
"github.com/kapetan-io/querator/config"
15-
"github.com/kapetan-io/querator/daemon"
16-
"gopkg.in/yaml.v3"
177
)
188

19-
var Version = "dev-build"
9+
var (
10+
Version = "dev-build"
11+
flags FlagParams
12+
)
2013

2114
type FlagParams struct {
22-
ConfigFile string
23-
ShowVersion bool
24-
}
15+
// Global Flags
16+
Endpoint string
2517

26-
func main() {
27-
if err := Start(context.Background(), os.Args[1:], os.Stdout); err != nil {
28-
os.Exit(1)
29-
}
18+
// Server Flags
19+
ConfigFile string
20+
Address string
21+
LogLevel string
3022
}
3123

32-
func Start(ctx context.Context, args []string, w io.Writer) error {
33-
flags, err := parseFlags(args)
34-
if err != nil {
35-
return err
36-
}
24+
func main() {
25+
var root = &cobra.Command{
26+
Use: "querator",
27+
Short: "Querator is a distributed queue system",
28+
Long: `Querator is a distributed queue system that provides reliable,
29+
scalable message queuing with lease-based processing semantics.
3730
38-
if flags.ShowVersion {
39-
_, _ = fmt.Fprintf(w, "querator %s\n", Version)
40-
return nil
31+
Use 'querator server' to start the daemon, or use the various subcommands
32+
to interact with a running Querator instance via its HTTP API.`,
4133
}
4234

43-
var file config.File
44-
if flags.ConfigFile != "" {
45-
reader, err := os.Open(flags.ConfigFile)
46-
if err != nil {
47-
return fmt.Errorf("while opening config file: %w", err)
48-
}
49-
decoder := yaml.NewDecoder(reader)
50-
if err := decoder.Decode(&file); err != nil {
51-
return fmt.Errorf("while reading config file: %w", err)
52-
}
53-
file.ConfigFile = flags.ConfigFile
54-
}
35+
root.PersistentFlags().StringVar(&flags.Endpoint, "endpoint",
36+
getEnv("QUERATOR_ENDPOINT", "http://localhost:2319"),
37+
"Querator server endpoint for API calls")
5538

56-
var conf daemon.Config
57-
if err := config.ApplyConfigFile(ctx, &conf, file, w); err != nil {
58-
return fmt.Errorf("while applying config file: %w", err)
59-
}
39+
// ======== Version =========
40+
root.AddCommand(&cobra.Command{
41+
Use: "version",
42+
Short: "Print the version number",
43+
Long: "Print the version number of Querator",
44+
Run: func(cmd *cobra.Command, args []string) {
45+
fmt.Printf("querator %s\n", Version)
46+
},
47+
})
6048

61-
conf.Log.Info(fmt.Sprintf("Querator %s (%s/%s)", Version, runtime.GOARCH, runtime.GOOS))
62-
d, err := daemon.NewDaemon(ctx, conf)
63-
if err != nil {
64-
return fmt.Errorf("while creating daemon: %w", err)
65-
}
49+
// ======== Server =========
50+
serverCommand.Flags().StringVar(&flags.ConfigFile, "config",
51+
getEnv("QUERATOR_CONFIG", ""),
52+
"Configuration file path")
53+
serverCommand.Flags().StringVar(&flags.Address, "address",
54+
getEnv("QUERATOR_ADDRESS", "localhost:2319"),
55+
"HTTP address to bind")
56+
serverCommand.Flags().StringVar(&flags.LogLevel, "log-level",
57+
getEnv("QUERATOR_LOG_LEVEL", "info"),
58+
"Logging level (debug,error,warn,info)")
59+
root.AddCommand(serverCommand)
6660

67-
c := make(chan os.Signal, 1)
68-
signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
69-
select {
70-
case <-c:
71-
return d.Shutdown(ctx)
72-
case <-ctx.Done():
73-
return d.Shutdown(context.Background())
61+
if err := root.Execute(); err != nil {
62+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
63+
os.Exit(1)
7464
}
7565
}
7666

77-
func parseFlags(args []string) (FlagParams, error) {
78-
var flagParams FlagParams
79-
80-
flags := flag.NewFlagSet("querator", flag.ContinueOnError)
81-
flags.SetOutput(io.Discard)
82-
flags.StringVar(&flagParams.ConfigFile, "config", "", "environment config file")
83-
flags.BoolVar(&flagParams.ShowVersion, "version", false, "show version information")
84-
if err := flags.Parse(args); err != nil {
85-
if !strings.Contains(err.Error(), "flag provided but not defined") {
86-
return FlagParams{}, fmt.Errorf("while parsing flags: %w", err)
87-
}
67+
func getEnv(envVar, defaultValue string) string {
68+
if value := os.Getenv(envVar); value != "" {
69+
return value
8870
}
89-
return flagParams, nil
71+
return defaultValue
9072
}

cmd/querator/main_test.go

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,129 @@
11
package main_test
22

33
import (
4-
"bufio"
5-
"bytes"
64
"context"
75
"crypto/tls"
86
"errors"
97
"fmt"
108
"github.com/stretchr/testify/assert"
119
"golang.org/x/net/proxy"
1210
"net"
11+
"os"
12+
"os/exec"
1313
"strings"
1414
"testing"
1515
"time"
16-
17-
cli "github.com/kapetan-io/querator/cmd/querator"
1816
)
1917

2018
func TestCLI(t *testing.T) {
21-
tests := []struct {
22-
args []string
23-
config string
24-
name string
25-
contains []string
26-
}{
27-
{
28-
name: "ShouldStartWithNoConfigProvided",
29-
args: []string{""},
30-
contains: []string{"Server Started"},
31-
},
32-
{
33-
name: "ShouldStartWithSampleConfig",
34-
args: []string{"-config=../../example.yaml"},
35-
contains: []string{
36-
"Server Started",
37-
"Loaded config from file",
38-
},
39-
},
40-
}
19+
// Build the binary for testing
20+
binPath := buildTestBinary(t)
21+
defer func() {
22+
_ = os.Remove(binPath)
23+
}()
24+
25+
t.Run("HelpCommand", func(t *testing.T) {
26+
cmd := exec.Command(binPath, "--help")
27+
output, err := cmd.CombinedOutput()
28+
assert.NoError(t, err)
29+
30+
outputStr := string(output)
31+
assert.Contains(t, outputStr, "Querator is a distributed queue system")
32+
assert.Contains(t, outputStr, "server")
33+
assert.Contains(t, outputStr, "version")
34+
assert.Contains(t, outputStr, "--endpoint")
35+
})
36+
37+
t.Run("VersionCommand", func(t *testing.T) {
38+
cmd := exec.Command(binPath, "version")
39+
output, err := cmd.CombinedOutput()
40+
assert.NoError(t, err)
41+
42+
outputStr := string(output)
43+
assert.Contains(t, outputStr, "querator dev-build")
44+
})
45+
46+
t.Run("ServerHelpCommand", func(t *testing.T) {
47+
cmd := exec.Command(binPath, "server", "--help")
48+
output, err := cmd.CombinedOutput()
49+
assert.NoError(t, err)
50+
51+
outputStr := string(output)
52+
assert.Contains(t, outputStr, "Start the Querator daemon")
53+
assert.Contains(t, outputStr, "--config")
54+
assert.Contains(t, outputStr, "--address")
55+
assert.Contains(t, outputStr, "--log-level")
56+
})
57+
58+
t.Run("EndpointGlobalFlag", func(t *testing.T) {
59+
cmd := exec.Command(binPath, "--endpoint", "http://test:8080", "version")
60+
output, err := cmd.CombinedOutput()
61+
assert.NoError(t, err)
62+
63+
outputStr := string(output)
64+
assert.Contains(t, outputStr, "querator dev-build")
65+
})
66+
67+
t.Run("InvalidCommand", func(t *testing.T) {
68+
cmd := exec.Command(binPath, "invalid-command")
69+
output, err := cmd.CombinedOutput()
70+
assert.Error(t, err)
71+
72+
outputStr := string(output)
73+
assert.Contains(t, outputStr, "unknown command")
74+
})
75+
76+
t.Run("ServerWithoutConfig", func(t *testing.T) {
77+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
78+
defer cancel()
4179

42-
for _, tt := range tests {
43-
t.Run(tt.name, func(t *testing.T) {
44-
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
45-
var buf bytes.Buffer
46-
w := bufio.NewWriter(&buf)
47-
48-
waitCh := make(chan struct{})
49-
go func() {
50-
err := cli.Start(ctx, tt.args, w)
51-
if err != nil {
52-
t.Logf("cli.Start() returned error: '%v'", err)
53-
}
54-
close(waitCh)
55-
}()
56-
57-
err := waitForConnect(ctx, "localhost:2319", nil)
58-
assert.NoError(t, err)
59-
time.Sleep(time.Second * 1)
60-
cancel()
61-
62-
<-waitCh
63-
_ = w.Flush()
64-
for _, s := range tt.contains {
65-
//t.Logf("Checking for '%s' in output", s)
66-
assert.Contains(t, buf.String(), s)
80+
cmd := exec.CommandContext(ctx, binPath, "server")
81+
82+
// Start the server
83+
err := cmd.Start()
84+
assert.NoError(t, err)
85+
86+
// Ensure process cleanup
87+
defer func() {
88+
if cmd.Process != nil {
89+
_ = cmd.Process.Signal(os.Interrupt)
90+
_ = cmd.Wait()
6791
}
68-
})
69-
}
92+
}()
93+
94+
// Wait for server to accept connections
95+
err = waitForConnect(ctx, "localhost:2319", nil)
96+
assert.NoError(t, err)
97+
98+
// If we can connect, the server started successfully
99+
// This test verifies the server starts without config
100+
})
101+
102+
t.Run("ServerWithConfig", func(t *testing.T) {
103+
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
104+
defer cancel()
105+
106+
cmd := exec.CommandContext(ctx, binPath, "server", "--config", "../../example.yaml")
107+
108+
// Start the server
109+
err := cmd.Start()
110+
assert.NoError(t, err)
111+
112+
// Wait for server to accept connections
113+
err = waitForConnect(ctx, "localhost:2319", nil)
114+
assert.NoError(t, err)
115+
116+
// If we can connect, the server started successfully with config
117+
// This test verifies the server starts with the example.yaml config
118+
})
119+
}
120+
121+
func buildTestBinary(t *testing.T) string {
122+
binPath := "./querator-test"
123+
cmd := exec.Command("go", "build", "-o", binPath, ".")
124+
err := cmd.Run()
125+
assert.NoError(t, err, "Failed to build test binary")
126+
return binPath
70127
}
71128

72129
// waitForConnect waits until the passed address is accepting connections.

cmd/querator/server.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"os/signal"
9+
"runtime"
10+
"syscall"
11+
12+
"github.com/kapetan-io/querator/config"
13+
"github.com/kapetan-io/querator/daemon"
14+
"github.com/spf13/cobra"
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
var serverCommand = &cobra.Command{
19+
Use: "server",
20+
Short: "Start the Querator daemon",
21+
Long: `Start the Querator daemon server.
22+
23+
The server command starts the HTTP API server that handles queue operations.
24+
Configuration can be provided via flags, environment variables, or a config file.`,
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
return startServer(context.Background(), os.Stdout)
27+
},
28+
}
29+
30+
func startServer(ctx context.Context, w io.Writer) error {
31+
var file config.File
32+
if flags.ConfigFile != "" {
33+
reader, err := os.Open(flags.ConfigFile)
34+
if err != nil {
35+
return fmt.Errorf("while opening config file: %w", err)
36+
}
37+
defer func() { _ = reader.Close() }()
38+
39+
decoder := yaml.NewDecoder(reader)
40+
if err := decoder.Decode(&file); err != nil {
41+
return fmt.Errorf("while reading config file: %w", err)
42+
}
43+
file.ConfigFile = flags.ConfigFile
44+
}
45+
46+
var conf daemon.Config
47+
if err := config.ApplyConfigFile(ctx, &conf, file, w); err != nil {
48+
return fmt.Errorf("while applying config file: %w", err)
49+
}
50+
51+
conf.Log.Info(fmt.Sprintf("Querator %s (%s/%s)", Version, runtime.GOARCH, runtime.GOOS))
52+
d, err := daemon.NewDaemon(ctx, conf)
53+
if err != nil {
54+
return fmt.Errorf("while creating daemon: %w", err)
55+
}
56+
57+
c := make(chan os.Signal, 1)
58+
signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
59+
select {
60+
case <-c:
61+
return d.Shutdown(ctx)
62+
case <-ctx.Done():
63+
return d.Shutdown(context.Background())
64+
}
65+
}

0 commit comments

Comments
 (0)