(chore) setup kmsvc-cli

This commit is contained in:
Story Crater Bot
2026-08-17 10:14:44 -07:00
commit d1c7f75cae
22 changed files with 1664 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
kmsvc "forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk"
"google.golang.org/grpc/credentials"
)
// defaultTokenURL is the Authentik OAuth2 token endpoint used with
// --client-id/--client-secret when --token-url/KMSVC_TOKEN_URL is unset.
const defaultTokenURL = "https://authentik.riotpiao.homelab.com/application/o/token/"
// buildClient constructs a *kmsvc.Client from resolved global flags.
//
// Defaults to TLS: the SDK itself defaults to plaintext (appropriate for
// cluster-internal callers), but kmsvc-cli's own default --server
// (kmsvc.riotpiao.homelab.com:443, see README) is reached through an
// Ingress-terminated HTTPS/gRPC-passthrough endpoint, so a real external
// invocation needs a TLS handshake, not plaintext. --insecure opts back into
// plaintext for cluster-internal/dev targets.
func buildClient(ctx context.Context, flags *globalFlags) (*kmsvc.Client, error) {
if flags.server == "" {
return nil, fmt.Errorf("server address required (--server or KMSVC_SERVER)")
}
token := flags.token
if token == "" && flags.clientID != "" && flags.clientSecret != "" {
tokenURL := flags.tokenURL
if tokenURL == "" {
tokenURL = defaultTokenURL
}
fetched, err := fetchClientCredentialsToken(ctx, tokenURL, flags.clientID, flags.clientSecret)
if err != nil {
return nil, fmt.Errorf("fetch token via client_credentials: %w", err)
}
token = fetched
}
var opts []kmsvc.Option
if token != "" {
opts = append(opts, kmsvc.WithTokenSource(kmsvc.StaticToken(token)))
}
if !flags.insecure {
opts = append(opts, kmsvc.WithTransportCredentials(credentials.NewTLS(nil)))
}
client, err := kmsvc.New(ctx, flags.server, opts...)
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", flags.server, err)
}
return client, nil
}
// fetchClientCredentialsToken performs an OAuth2 client_credentials grant
// against tokenURL, used when --token/KMSVC_TOKEN is unset but
// --client-id/--client-secret (KMSVC_CLIENT_ID/KMSVC_CLIENT_SECRET) are
// configured, so callers don't need a separate curl step to mint a token.
func fetchClientCredentialsToken(ctx context.Context, tokenURL, clientID, clientSecret string) (string, error) {
if tokenURL == "" {
return "", fmt.Errorf("token URL required (--token-url or KMSVC_TOKEN_URL)")
}
form := url.Values{
"grant_type": {"client_credentials"},
"client_id": {clientID},
"client_secret": {clientSecret},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %s", resp.Status)
}
var body struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
}
if body.AccessToken == "" {
return "", fmt.Errorf("token response missing access_token")
}
return body.AccessToken, nil
}
+112
View File
@@ -0,0 +1,112 @@
package cli
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config holds resolved CLI defaults. Precedence (highest to lowest):
// command-line flag > environment variable > ~/.kmsvc/config.yaml > built-in default.
type Config struct {
Server string
Token string
Output string
Insecure bool
ClientID string
ClientSecret string
TokenURL string
}
type fileConfig struct {
Server string `yaml:"server"`
Output string `yaml:"output"`
Insecure bool `yaml:"insecure"`
ClientID string `yaml:"clientId"`
ClientSecret string `yaml:"clientSecret"`
TokenURL string `yaml:"tokenUrl"`
}
// LoadConfig resolves defaults from ~/.kmsvc/config.yaml and environment
// variables (KMSVC_SERVER, KMSVC_TOKEN, KMSVC_OUTPUT, KMSVC_INSECURE,
// KMSVC_CLIENT_ID, KMSVC_CLIENT_SECRET, KMSVC_TOKEN_URL). ClientID/ClientSecret
// additionally fall back to AUTHENTIK_KAFAKA_CLIENT_ID/AUTHENTIK_KAFAKA_CLIENT_SECRET
// (the homelab's Authentik app credentials, typically exported into the shell
// via `vsource` from Vault) when the KMSVC_-prefixed vars aren't set, so this
// CLI doesn't need its own separately-exported copy. Flags are applied on top
// of this by the caller (root.go), so this function never reads flags.
func LoadConfig() Config {
cfg := Config{Output: "table"}
if path, err := configFilePath(); err == nil {
if fc, err := readFileConfig(path); err == nil {
if fc.Server != "" {
cfg.Server = fc.Server
}
if fc.Output != "" {
cfg.Output = fc.Output
}
cfg.Insecure = fc.Insecure
if fc.ClientID != "" {
cfg.ClientID = fc.ClientID
}
if fc.ClientSecret != "" {
cfg.ClientSecret = fc.ClientSecret
}
if fc.TokenURL != "" {
cfg.TokenURL = fc.TokenURL
}
}
}
if v := os.Getenv("KMSVC_SERVER"); v != "" {
cfg.Server = v
}
if v := os.Getenv("KMSVC_TOKEN"); v != "" {
cfg.Token = v
}
if v := os.Getenv("KMSVC_OUTPUT"); v != "" {
cfg.Output = v
}
if v := os.Getenv("KMSVC_INSECURE"); v != "" {
cfg.Insecure = v == "1" || v == "true"
}
if v := os.Getenv("AUTHENTIK_KAFAKA_CLIENT_ID"); v != "" {
cfg.ClientID = v
}
if v := os.Getenv("KMSVC_CLIENT_ID"); v != "" {
cfg.ClientID = v
}
if v := os.Getenv("AUTHENTIK_KAFAKA_CLIENT_SECRET"); v != "" {
cfg.ClientSecret = v
}
if v := os.Getenv("KMSVC_CLIENT_SECRET"); v != "" {
cfg.ClientSecret = v
}
if v := os.Getenv("KMSVC_TOKEN_URL"); v != "" {
cfg.TokenURL = v
}
return cfg
}
func configFilePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".kmsvc", "config.yaml"), nil
}
func readFileConfig(path string) (fileConfig, error) {
var fc fileConfig
data, err := os.ReadFile(path)
if err != nil {
return fc, err
}
if err := yaml.Unmarshal(data, &fc); err != nil {
return fc, err
}
return fc, nil
}
+55
View File
@@ -0,0 +1,55 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfigDefaults(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("KMSVC_SERVER", "")
t.Setenv("KMSVC_TOKEN", "")
t.Setenv("KMSVC_OUTPUT", "")
cfg := LoadConfig()
if cfg.Output != "table" {
t.Errorf("Output = %q, want default %q", cfg.Output, "table")
}
if cfg.Server != "" || cfg.Token != "" {
t.Errorf("expected empty Server/Token with no file or env, got %+v", cfg)
}
}
func TestLoadConfigFileThenEnvOverride(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
dir := filepath.Join(home, ".kmsvc")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
content := "server: file-server:443\noutput: json\n"
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("KMSVC_SERVER", "")
t.Setenv("KMSVC_OUTPUT", "")
t.Setenv("KMSVC_TOKEN", "")
cfg := LoadConfig()
if cfg.Server != "file-server:443" || cfg.Output != "json" {
t.Fatalf("expected file values, got %+v", cfg)
}
// Env var must win over the file.
t.Setenv("KMSVC_SERVER", "env-server:443")
cfg = LoadConfig()
if cfg.Server != "env-server:443" {
t.Errorf("Server = %q, want env override %q", cfg.Server, "env-server:443")
}
if cfg.Output != "json" {
t.Errorf("Output = %q, want file value %q to survive (env unset)", cfg.Output, "json")
}
}
+115
View File
@@ -0,0 +1,115 @@
package cli
import (
"fmt"
kmsvc "forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk"
"github.com/spf13/cobra"
)
func newDLQCmd(flags *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "dlq",
Short: "Inspect and redrive dead-letter queues",
}
cmd.AddCommand(newDLQPeekCmd(flags), newDLQRedriveCmd(flags))
return cmd
}
func newDLQPeekCmd(flags *globalFlags) *cobra.Command {
var queue string
var maxMessages, visibilityTimeout int32
cmd := &cobra.Command{
Use: "peek",
Short: "Receive messages from a DLQ without deleting them",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := buildClient(cmd.Context(), flags)
if err != nil {
return err
}
defer client.Close()
msgs, err := client.ReceiveMessage(cmd.Context(), queue, kmsvc.ReceiveOptions{
MaxNumberOfMessages: maxMessages,
VisibilityTimeoutSeconds: visibilityTimeout,
})
if err != nil {
return err
}
return renderMessages(cmd.OutOrStdout(), flags.output, msgs)
},
}
cmd.Flags().StringVar(&queue, "queue", "", "DLQ name (required)")
cmd.Flags().Int32Var(&maxMessages, "max-messages", 10, "maximum number of messages to peek (1-10)")
cmd.Flags().Int32Var(&visibilityTimeout, "visibility-timeout", 5, "visibility timeout for the peek, in seconds — keep short so messages reappear quickly")
cmd.MarkFlagRequired("queue")
return cmd
}
func newDLQRedriveCmd(flags *globalFlags) *cobra.Command {
var queue, to string
var maxMessages int32
cmd := &cobra.Command{
Use: "redrive",
Short: "Move messages from a DLQ back to their source queue",
Long: "Receives messages from --queue (the DLQ), sends each to --to (the source\n" +
"queue), then deletes it from the DLQ. This is 3+ separate SDK calls, not an\n" +
"atomic operation: if send succeeds but delete fails, the message is reported\n" +
"as sent-but-not-removed (it may be redelivered from both queues), and the\n" +
"command exits non-zero rather than silently continuing.",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := buildClient(cmd.Context(), flags)
if err != nil {
return err
}
defer client.Close()
msgs, err := client.ReceiveMessage(cmd.Context(), queue, kmsvc.ReceiveOptions{
MaxNumberOfMessages: maxMessages,
})
if err != nil {
return fmt.Errorf("receive from %s: %w", queue, err)
}
out := cmd.OutOrStdout()
var failures int
for _, m := range msgs {
sendOut, err := client.SendMessage(cmd.Context(), kmsvc.SendMessageInput{
QueueName: to,
Body: m.Body,
})
if err != nil {
fmt.Fprintf(out, "redrive %s: send to %s failed, message left in DLQ: %v\n", m.MessageID, to, err)
failures++
continue
}
if err := client.DeleteMessage(cmd.Context(), queue, m.ReceiptHandle); err != nil {
fmt.Fprintf(out, "redrive %s: sent to %s as %s, but delete from %s failed — message may be duplicated: %v\n", m.MessageID, to, sendOut.MessageID, queue, err)
failures++
continue
}
fmt.Fprintf(out, "redrive %s: sent to %s as %s, removed from %s\n", m.MessageID, to, sendOut.MessageID, queue)
}
if failures > 0 {
return fmt.Errorf("%d/%d message(s) failed to fully redrive", failures, len(msgs))
}
return nil
},
}
cmd.Flags().StringVar(&queue, "queue", "", "DLQ name (required)")
cmd.Flags().StringVar(&to, "to", "", "source queue to redrive messages back to (required)")
cmd.Flags().Int32Var(&maxMessages, "max-messages", 10, "maximum number of messages to redrive in this run (1-10)")
cmd.MarkFlagRequired("queue")
cmd.MarkFlagRequired("to")
return cmd
}
+98
View File
@@ -0,0 +1,98 @@
package cli
import (
"bytes"
"context"
"strings"
"sync/atomic"
"testing"
kafkamgmtv1 "forgejo.riotpiao.homelab.com/homelab/kmsvc-proto/gen/kafkamgmt/v1"
)
func TestDLQRedriveHappyPath(t *testing.T) {
var sendCalled, deleteCalled atomic.Bool
var sendQueue string
fake := &fakeQueueService{
receiveMessage: func(ctx context.Context, req *kafkamgmtv1.ReceiveMessageRequest) (*kafkamgmtv1.ReceiveMessageResponse, error) {
return &kafkamgmtv1.ReceiveMessageResponse{
Messages: []*kafkamgmtv1.Message{{MessageId: "m1", ReceiptHandle: "rh1", Body: []byte("payload")}},
}, nil
},
sendMessage: func(ctx context.Context, req *kafkamgmtv1.SendMessageRequest) (*kafkamgmtv1.SendMessageResponse, error) {
sendCalled.Store(true)
sendQueue = req.QueueName
if deleteCalled.Load() {
t.Error("delete called before send completed")
}
return &kafkamgmtv1.SendMessageResponse{MessageId: "m1-redriven"}, nil
},
deleteMessage: func(ctx context.Context, req *kafkamgmtv1.DeleteMessageRequest) (*kafkamgmtv1.DeleteMessageResponse, error) {
deleteCalled.Store(true)
if !sendCalled.Load() {
t.Error("delete called before send")
}
return &kafkamgmtv1.DeleteMessageResponse{}, nil
},
}
addr := startTestServer(t, fake)
flags := &globalFlags{server: addr, output: "table", insecure: true}
cmd := newDLQRedriveCmd(flags)
cmd.SetArgs([]string{"--queue", "orders.dlq", "--to", "orders"})
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if !sendCalled.Load() || !deleteCalled.Load() {
t.Fatal("expected both send and delete to be called")
}
if sendQueue != "orders" {
t.Errorf("send queue = %q, want orders", sendQueue)
}
}
func TestDLQRedriveSurfacesDeleteFailure(t *testing.T) {
fake := &fakeQueueService{
receiveMessage: func(ctx context.Context, req *kafkamgmtv1.ReceiveMessageRequest) (*kafkamgmtv1.ReceiveMessageResponse, error) {
return &kafkamgmtv1.ReceiveMessageResponse{
Messages: []*kafkamgmtv1.Message{{MessageId: "m1", ReceiptHandle: "rh1", Body: []byte("payload")}},
}, nil
},
sendMessage: func(ctx context.Context, req *kafkamgmtv1.SendMessageRequest) (*kafkamgmtv1.SendMessageResponse, error) {
return &kafkamgmtv1.SendMessageResponse{MessageId: "m1-redriven"}, nil
},
deleteMessage: func(ctx context.Context, req *kafkamgmtv1.DeleteMessageRequest) (*kafkamgmtv1.DeleteMessageResponse, error) {
return nil, errBoom
},
}
addr := startTestServer(t, fake)
flags := &globalFlags{server: addr, output: "table", insecure: true}
cmd := newDLQRedriveCmd(flags)
cmd.SetArgs([]string{"--queue", "orders.dlq", "--to", "orders"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
err := cmd.Execute()
if err == nil {
t.Fatal("expected redrive to report failure when delete fails")
}
output := out.String()
if !strings.Contains(output, "may be duplicated") {
t.Errorf("output = %q, want a duplicate-risk warning", output)
}
}
var errBoom = &boomError{}
type boomError struct{}
func (e *boomError) Error() string { return "boom" }
+42
View File
@@ -0,0 +1,42 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/tools/clientcmd"
)
// queueGVR identifies the Queue CRD (kmsvc.io/v1, plural "queues") that the
// queue-operator reconciles -- the declarative source of truth for queues,
// not something management-service's gRPC API manages (design.md §2a).
var queueGVR = schema.GroupVersionResource{Group: "kmsvc.io", Version: "v1", Resource: "queues"}
// newDynamicClient builds a k8s dynamic client from the default kubeconfig
// (KUBECONFIG env var, falling back to ~/.kube/config), the same resolution
// kubectl itself uses.
func newDynamicClient() (dynamic.Interface, error) {
path, err := kubeconfigPath()
if err != nil {
return nil, err
}
cfg, err := clientcmd.BuildConfigFromFlags("", path)
if err != nil {
return nil, fmt.Errorf("load kubeconfig %s: %w", path, err)
}
return dynamic.NewForConfig(cfg)
}
func kubeconfigPath() (string, error) {
if v := os.Getenv("KUBECONFIG"); v != "" {
return v, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".kube", "config"), nil
}
+146
View File
@@ -0,0 +1,146 @@
package cli
import (
"fmt"
kmsvc "forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk"
"github.com/spf13/cobra"
)
func newMessageSendCmd(flags *globalFlags) *cobra.Command {
var queue, body, groupID, dedupID string
var delaySeconds int32
cmd := &cobra.Command{
Use: "send-message",
Short: "Send a message to a queue",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := buildClient(cmd.Context(), flags)
if err != nil {
return err
}
defer client.Close()
out, err := client.SendMessage(cmd.Context(), kmsvc.SendMessageInput{
QueueName: queue,
Body: []byte(body),
MessageGroupID: groupID,
MessageDeduplicationID: dedupID,
DelaySeconds: delaySeconds,
})
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "message_id=%s sequence_number=%s\n", out.MessageID, out.SequenceNumber)
return nil
},
}
cmd.Flags().StringVar(&queue, "queue", "", "queue name (required)")
cmd.Flags().StringVar(&body, "body", "", "message body (required)")
cmd.Flags().StringVar(&groupID, "group-id", "", "FIFO message group ID")
cmd.Flags().StringVar(&dedupID, "dedup-id", "", "FIFO message deduplication ID")
cmd.Flags().Int32Var(&delaySeconds, "delay", 0, "delay before the message becomes visible, in seconds")
cmd.MarkFlagRequired("queue")
cmd.MarkFlagRequired("body")
return cmd
}
func newMessageReceiveCmd(flags *globalFlags) *cobra.Command {
var queue string
var maxMessages, waitSeconds, visibilityTimeout int32
cmd := &cobra.Command{
Use: "receive-message",
Short: "Receive messages from a queue (long-poll)",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := buildClient(cmd.Context(), flags)
if err != nil {
return err
}
defer client.Close()
msgs, err := client.ReceiveMessage(cmd.Context(), queue, kmsvc.ReceiveOptions{
MaxNumberOfMessages: maxMessages,
WaitTimeSeconds: waitSeconds,
VisibilityTimeoutSeconds: visibilityTimeout,
})
if err != nil {
return err
}
return renderMessages(cmd.OutOrStdout(), flags.output, msgs)
},
}
cmd.Flags().StringVar(&queue, "queue", "", "queue name (required)")
cmd.Flags().Int32Var(&maxMessages, "max-messages", 1, "maximum number of messages to return (1-10)")
cmd.Flags().Int32Var(&waitSeconds, "wait", 0, "long-poll wait time in seconds (0-20)")
cmd.Flags().Int32Var(&visibilityTimeout, "visibility-timeout", 0, "override the queue's default visibility timeout, in seconds")
cmd.MarkFlagRequired("queue")
return cmd
}
func newMessageDeleteCmd(flags *globalFlags) *cobra.Command {
var queue, receiptHandle string
cmd := &cobra.Command{
Use: "delete-message",
Short: "Delete (acknowledge) a message",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := buildClient(cmd.Context(), flags)
if err != nil {
return err
}
defer client.Close()
if err := client.DeleteMessage(cmd.Context(), queue, receiptHandle); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "deleted")
return nil
},
}
cmd.Flags().StringVar(&queue, "queue", "", "queue name (required)")
cmd.Flags().StringVar(&receiptHandle, "receipt-handle", "", "receipt handle from receive (required)")
cmd.MarkFlagRequired("queue")
cmd.MarkFlagRequired("receipt-handle")
return cmd
}
func newMessageChangeVisibilityCmd(flags *globalFlags) *cobra.Command {
var queue, receiptHandle string
var timeout int32
cmd := &cobra.Command{
Use: "change-message-visibility",
Short: "Change the visibility timeout of an in-flight message",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := buildClient(cmd.Context(), flags)
if err != nil {
return err
}
defer client.Close()
if err := client.ChangeMessageVisibility(cmd.Context(), queue, receiptHandle, timeout); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "updated")
return nil
},
}
cmd.Flags().StringVar(&queue, "queue", "", "queue name (required)")
cmd.Flags().StringVar(&receiptHandle, "receipt-handle", "", "receipt handle from receive (required)")
cmd.Flags().Int32Var(&timeout, "timeout", 0, "new visibility timeout, in seconds (required)")
cmd.MarkFlagRequired("queue")
cmd.MarkFlagRequired("receipt-handle")
cmd.MarkFlagRequired("timeout")
return cmd
}
+97
View File
@@ -0,0 +1,97 @@
package cli
import (
"bytes"
"context"
"strings"
"testing"
kafkamgmtv1 "forgejo.riotpiao.homelab.com/homelab/kmsvc-proto/gen/kafkamgmt/v1"
)
func TestMessageSendCmd(t *testing.T) {
fake := &fakeQueueService{
sendMessage: func(ctx context.Context, req *kafkamgmtv1.SendMessageRequest) (*kafkamgmtv1.SendMessageResponse, error) {
if req.QueueName != "orders" || string(req.MessageBody) != "hello" {
t.Errorf("unexpected request: %+v", req)
}
return &kafkamgmtv1.SendMessageResponse{MessageId: "m1"}, nil
},
}
addr := startTestServer(t, fake)
flags := &globalFlags{server: addr, output: "table", insecure: true}
cmd := newMessageSendCmd(flags)
cmd.SetArgs([]string{"--queue", "orders", "--body", "hello"})
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out.String(), "message_id=m1") {
t.Errorf("output = %q, want message_id=m1", out.String())
}
}
func TestMessageReceiveCmdJSON(t *testing.T) {
fake := &fakeQueueService{
receiveMessage: func(ctx context.Context, req *kafkamgmtv1.ReceiveMessageRequest) (*kafkamgmtv1.ReceiveMessageResponse, error) {
return &kafkamgmtv1.ReceiveMessageResponse{
Messages: []*kafkamgmtv1.Message{{MessageId: "m1", ReceiptHandle: "rh1", Body: []byte("hi")}},
}, nil
},
}
addr := startTestServer(t, fake)
flags := &globalFlags{server: addr, output: "json", insecure: true}
cmd := newMessageReceiveCmd(flags)
cmd.SetArgs([]string{"--queue", "orders"})
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(out.String(), `"message_id":"m1"`) && !strings.Contains(out.String(), `"MessageID":"m1"`) {
t.Errorf("output = %q, want JSON containing message id m1", out.String())
}
}
func TestMessageDeleteCmd(t *testing.T) {
var gotHandle string
fake := &fakeQueueService{
deleteMessage: func(ctx context.Context, req *kafkamgmtv1.DeleteMessageRequest) (*kafkamgmtv1.DeleteMessageResponse, error) {
gotHandle = req.ReceiptHandle
return &kafkamgmtv1.DeleteMessageResponse{}, nil
},
}
addr := startTestServer(t, fake)
flags := &globalFlags{server: addr, output: "table", insecure: true}
cmd := newMessageDeleteCmd(flags)
cmd.SetArgs([]string{"--queue", "orders", "--receipt-handle", "rh-1"})
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if gotHandle != "rh-1" {
t.Errorf("ReceiptHandle = %q, want rh-1", gotHandle)
}
}
func TestMessageSendCmdRequiresQueueAndBody(t *testing.T) {
flags := &globalFlags{server: "unused:1", output: "table"}
cmd := newMessageSendCmd(flags)
cmd.SetArgs([]string{})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for missing required flags")
}
}
+45
View File
@@ -0,0 +1,45 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"text/tabwriter"
kmsvc "forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk"
)
// renderMessages writes msgs to w in the requested format ("table" or
// "json"). Unrecognized formats fall back to "table".
func renderMessages(w io.Writer, format string, msgs []kmsvc.Message) error {
if format == "json" {
return json.NewEncoder(w).Encode(msgs)
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "MESSAGE_ID\tRECEIPT_HANDLE\tRECEIVE_COUNT\tBODY")
for _, m := range msgs {
fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", m.MessageID, m.ReceiptHandle, m.ReceiveCount, string(m.Body))
}
return tw.Flush()
}
// renderBatchResult writes a batch send/delete result to w.
func renderBatchResult(w io.Writer, format string, successful, failed []kmsvc.BatchResultEntry) error {
if format == "json" {
return json.NewEncoder(w).Encode(struct {
Successful []kmsvc.BatchResultEntry `json:"successful"`
Failed []kmsvc.BatchResultEntry `json:"failed"`
}{successful, failed})
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSTATUS\tMESSAGE_ID\tERROR")
for _, e := range successful {
fmt.Fprintf(tw, "%s\tok\t%s\t\n", e.ID, e.MessageID)
}
for _, e := range failed {
fmt.Fprintf(tw, "%s\tfailed\t\t%s\n", e.ID, e.Error)
}
return tw.Flush()
}
+56
View File
@@ -0,0 +1,56 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
kmsvc "forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk"
)
func TestRenderMessagesTable(t *testing.T) {
var buf bytes.Buffer
msgs := []kmsvc.Message{{MessageID: "m1", ReceiptHandle: "rh1", Body: []byte("hi"), ReceiveCount: 2}}
if err := renderMessages(&buf, "table", msgs); err != nil {
t.Fatalf("renderMessages: %v", err)
}
out := buf.String()
if !strings.Contains(out, "MESSAGE_ID") || !strings.Contains(out, "m1") || !strings.Contains(out, "hi") {
t.Errorf("table output missing expected content: %q", out)
}
}
func TestRenderMessagesJSON(t *testing.T) {
var buf bytes.Buffer
msgs := []kmsvc.Message{{MessageID: "m1", ReceiptHandle: "rh1", Body: []byte("hi")}}
if err := renderMessages(&buf, "json", msgs); err != nil {
t.Fatalf("renderMessages: %v", err)
}
var got []kmsvc.Message
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("unmarshal output: %v", err)
}
if len(got) != 1 || got[0].MessageID != "m1" {
t.Errorf("unexpected decoded messages: %+v", got)
}
}
func TestRenderBatchResultTable(t *testing.T) {
var buf bytes.Buffer
successful := []kmsvc.BatchResultEntry{{ID: "1", MessageID: "m1"}}
failed := []kmsvc.BatchResultEntry{{ID: "2", Error: "boom"}}
if err := renderBatchResult(&buf, "table", successful, failed); err != nil {
t.Fatalf("renderBatchResult: %v", err)
}
out := buf.String()
if !strings.Contains(out, "ok") || !strings.Contains(out, "boom") {
t.Errorf("table output missing expected content: %q", out)
}
}
+225
View File
@@ -0,0 +1,225 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// queueSummary is the subset of a Queue CRD's spec/status this CLI surfaces.
// Read directly off the unstructured object rather than a generated
// clientset, since kmsvc-cli otherwise has no dependency on the
// kafaka-management-service module's API types.
type queueSummary struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Phase string `json:"phase"`
FIFO bool `json:"fifoQueue"`
ShardCount int `json:"shardCount"`
MaxReceives int64 `json:"maxReceiveCount"`
}
func newQueueCmd(flags *globalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "queue",
Short: "List and describe Queue CRDs (the queue-operator's source of truth)",
}
cmd.AddCommand(
newQueueListCmd(flags),
newQueueDescribeCmd(flags),
)
return cmd
}
// queueGVK is the apiVersion/kind pair for the Queue CRD, matching queueGVR
// (kmsvc.io/v1, plural "queues") by standard k8s singular-Kind convention.
const (
queueAPIVersion = "kmsvc.io/v1"
queueKind = "Queue"
)
func newQueueCreateCmd(flags *globalFlags) *cobra.Command {
var namespace string
var setFields []string
cmd := &cobra.Command{
Use: "create-queue [name]",
Short: "Create a Queue CRD (operator defaults apply unless overridden with --set)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cl, err := newDynamicClient()
if err != nil {
return err
}
spec := map[string]any{}
for _, kv := range setFields {
if err := applySetField(spec, kv); err != nil {
return err
}
}
obj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": queueAPIVersion,
"kind": queueKind,
"metadata": map[string]any{
"name": args[0],
"namespace": namespace,
},
"spec": spec,
}}
created, err := cl.Resource(queueGVR).Namespace(namespace).Create(cmd.Context(), obj, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("create queue %s: %w", args[0], err)
}
fmt.Fprintf(cmd.OutOrStdout(), "queue/%s created\n", created.GetName())
return nil
},
}
cmd.Flags().StringVarP(&namespace, "namespace", "n", "sqs", "namespace to create the Queue CRD in")
cmd.Flags().StringArrayVar(&setFields, "set", nil, "override a spec field, key=value (e.g. --set fifoQueue=true), repeatable")
return cmd
}
func newQueueDeleteCmd(flags *globalFlags) *cobra.Command {
var namespace string
cmd := &cobra.Command{
Use: "delete-queue [name]",
Short: "Delete a Queue CRD",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cl, err := newDynamicClient()
if err != nil {
return err
}
if err := cl.Resource(queueGVR).Namespace(namespace).Delete(cmd.Context(), args[0], metav1.DeleteOptions{}); err != nil {
return fmt.Errorf("delete queue %s: %w", args[0], err)
}
fmt.Fprintf(cmd.OutOrStdout(), "queue/%s deleted\n", args[0])
return nil
},
}
cmd.Flags().StringVarP(&namespace, "namespace", "n", "sqs", "namespace the Queue CRD lives in")
return cmd
}
// applySetField parses a "key=value" pair and writes it into spec, coercing
// value to bool/int64 when it parses as one, else leaving it as a string —
// mirrors helm --set's pragmatic type inference since the CRD schema isn't
// known to this CLI.
func applySetField(spec map[string]any, kv string) error {
key, value, ok := strings.Cut(kv, "=")
if !ok {
return fmt.Errorf("--set %q: expected key=value", kv)
}
if b, err := strconv.ParseBool(value); err == nil {
spec[key] = b
return nil
}
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
spec[key] = i
return nil
}
spec[key] = value
return nil
}
func newQueueListCmd(flags *globalFlags) *cobra.Command {
var namespace string
cmd := &cobra.Command{
Use: "list",
Short: "List Queue CRDs in a namespace",
RunE: func(cmd *cobra.Command, args []string) error {
cl, err := newDynamicClient()
if err != nil {
return err
}
list, err := cl.Resource(queueGVR).Namespace(namespace).List(cmd.Context(), metav1.ListOptions{})
if err != nil {
return fmt.Errorf("list queues: %w", err)
}
summaries := make([]queueSummary, 0, len(list.Items))
for _, item := range list.Items {
summaries = append(summaries, summarizeQueue(item.Object))
}
return renderQueues(cmd.OutOrStdout(), flags.output, summaries)
},
}
cmd.Flags().StringVarP(&namespace, "namespace", "n", "sqs", "namespace the Queue CRDs live in")
return cmd
}
func newQueueDescribeCmd(flags *globalFlags) *cobra.Command {
var namespace string
cmd := &cobra.Command{
Use: "describe [name]",
Short: "Show full status (shards, phase) for one Queue CRD",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cl, err := newDynamicClient()
if err != nil {
return err
}
obj, err := cl.Resource(queueGVR).Namespace(namespace).Get(cmd.Context(), args[0], metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get queue %s: %w", args[0], err)
}
data, err := json.MarshalIndent(obj.Object, "", " ")
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), string(data))
return nil
},
}
cmd.Flags().StringVarP(&namespace, "namespace", "n", "sqs", "namespace the Queue CRD lives in")
return cmd
}
func summarizeQueue(obj map[string]any) queueSummary {
name, _, _ := unstructured.NestedString(obj, "metadata", "name")
namespace, _, _ := unstructured.NestedString(obj, "metadata", "namespace")
phase, _, _ := unstructured.NestedString(obj, "status", "phase")
fifo, _, _ := unstructured.NestedBool(obj, "spec", "fifoQueue")
maxReceives, _, _ := unstructured.NestedInt64(obj, "spec", "maxReceiveCount")
shardCount := 0
if shards, ok, _ := unstructured.NestedSlice(obj, "status", "shards"); ok {
shardCount = len(shards)
}
return queueSummary{
Name: name,
Namespace: namespace,
Phase: phase,
FIFO: fifo,
ShardCount: shardCount,
MaxReceives: maxReceives,
}
}
func renderQueues(w io.Writer, format string, queues []queueSummary) error {
if format == "json" {
return json.NewEncoder(w).Encode(queues)
}
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tNAMESPACE\tPHASE\tFIFO\tSHARDS\tMAX_RECEIVES")
for _, q := range queues {
fmt.Fprintf(tw, "%s\t%s\t%s\t%t\t%d\t%d\n", q.Name, q.Namespace, q.Phase, q.FIFO, q.ShardCount, q.MaxReceives)
}
return tw.Flush()
}
+70
View File
@@ -0,0 +1,70 @@
package cli
import (
"github.com/spf13/cobra"
)
// globalFlags holds the resolved values of the root command's persistent
// flags after parsing — read by subcommands when they build a client.
type globalFlags struct {
server string
token string
output string
insecure bool
clientID string
clientSecret string
tokenURL string
}
// NewRootCmd builds the kmsvc root command.
func NewRootCmd() *cobra.Command {
cfg := LoadConfig()
flags := &globalFlags{
server: cfg.Server,
output: cfg.Output,
insecure: cfg.Insecure,
clientID: cfg.ClientID,
tokenURL: cfg.TokenURL,
}
root := &cobra.Command{
Use: "kmsvc",
Short: "Kafka Management Service CLI",
SilenceUsage: true,
SilenceErrors: false,
// Secrets resolved from env/config aren't pre-bound to the flag's
// pflag default (which --help prints verbatim) — apply them here
// instead, only when the user didn't pass the flag explicitly.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if !cmd.Flags().Changed("token") {
flags.token = cfg.Token
}
if !cmd.Flags().Changed("client-secret") {
flags.clientSecret = cfg.ClientSecret
}
return nil
},
}
root.PersistentFlags().StringVar(&flags.server, "server", flags.server, "kmsvc gRPC server address (env KMSVC_SERVER)")
root.PersistentFlags().StringVar(&flags.token, "token", "", "bearer token (env KMSVC_TOKEN)")
root.PersistentFlags().StringVar(&flags.output, "output", flags.output, "output format: table|json (env KMSVC_OUTPUT)")
root.PersistentFlags().BoolVar(&flags.insecure, "insecure", flags.insecure, "use plaintext gRPC instead of TLS (env KMSVC_INSECURE) — for cluster-internal/dev targets only")
root.PersistentFlags().StringVar(&flags.clientID, "client-id", flags.clientID, "OAuth2 client_credentials client ID, used to fetch a token when --token is unset (env KMSVC_CLIENT_ID)")
root.PersistentFlags().StringVar(&flags.clientSecret, "client-secret", "", "OAuth2 client_credentials client secret (env KMSVC_CLIENT_SECRET)")
root.PersistentFlags().StringVar(&flags.tokenURL, "token-url", flags.tokenURL, "OAuth2 token endpoint used with --client-id/--client-secret (env KMSVC_TOKEN_URL)")
root.AddCommand(
newMessageSendCmd(flags),
newMessageReceiveCmd(flags),
newMessageDeleteCmd(flags),
newMessageChangeVisibilityCmd(flags),
newDLQCmd(flags),
newQueueCmd(flags),
newQueueCreateCmd(flags),
newQueueDeleteCmd(flags),
newVersionCmd(),
)
return root
}
+69
View File
@@ -0,0 +1,69 @@
package cli
import (
"context"
"net"
"testing"
kafkamgmtv1 "forgejo.riotpiao.homelab.com/homelab/kmsvc-proto/gen/kafkamgmt/v1"
"google.golang.org/grpc"
)
// fakeQueueService is a minimal QueueServiceServer for exercising CLI
// commands end-to-end over a real (loopback) gRPC connection.
type fakeQueueService struct {
kafkamgmtv1.UnimplementedQueueServiceServer
sendMessage func(context.Context, *kafkamgmtv1.SendMessageRequest) (*kafkamgmtv1.SendMessageResponse, error)
receiveMessage func(context.Context, *kafkamgmtv1.ReceiveMessageRequest) (*kafkamgmtv1.ReceiveMessageResponse, error)
deleteMessage func(context.Context, *kafkamgmtv1.DeleteMessageRequest) (*kafkamgmtv1.DeleteMessageResponse, error)
changeMessageVisibility func(context.Context, *kafkamgmtv1.ChangeMessageVisibilityRequest) (*kafkamgmtv1.ChangeMessageVisibilityResponse, error)
}
func (f *fakeQueueService) SendMessage(ctx context.Context, req *kafkamgmtv1.SendMessageRequest) (*kafkamgmtv1.SendMessageResponse, error) {
if f.sendMessage != nil {
return f.sendMessage(ctx, req)
}
return f.UnimplementedQueueServiceServer.SendMessage(ctx, req)
}
func (f *fakeQueueService) ReceiveMessage(ctx context.Context, req *kafkamgmtv1.ReceiveMessageRequest) (*kafkamgmtv1.ReceiveMessageResponse, error) {
if f.receiveMessage != nil {
return f.receiveMessage(ctx, req)
}
return f.UnimplementedQueueServiceServer.ReceiveMessage(ctx, req)
}
func (f *fakeQueueService) DeleteMessage(ctx context.Context, req *kafkamgmtv1.DeleteMessageRequest) (*kafkamgmtv1.DeleteMessageResponse, error) {
if f.deleteMessage != nil {
return f.deleteMessage(ctx, req)
}
return f.UnimplementedQueueServiceServer.DeleteMessage(ctx, req)
}
func (f *fakeQueueService) ChangeMessageVisibility(ctx context.Context, req *kafkamgmtv1.ChangeMessageVisibilityRequest) (*kafkamgmtv1.ChangeMessageVisibilityResponse, error) {
if f.changeMessageVisibility != nil {
return f.changeMessageVisibility(ctx, req)
}
return f.UnimplementedQueueServiceServer.ChangeMessageVisibility(ctx, req)
}
// startTestServer starts fake on a loopback TCP listener and returns its
// address, registering cleanup with t.
func startTestServer(t *testing.T, fake *fakeQueueService) string {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := grpc.NewServer()
kafkamgmtv1.RegisterQueueServiceServer(srv, fake)
go func() {
_ = srv.Serve(lis)
}()
t.Cleanup(srv.Stop)
return lis.Addr().String()
}
+22
View File
@@ -0,0 +1,22 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
// version is injected at release build time via:
// -ldflags "-X forgejo.riotpiao.homelab.com/homelab/kmsvc-cli/internal/cli.version=v1.2.3"
var version = "dev"
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the kmsvc CLI version",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.OutOrStdout(), version)
return nil
},
}
}