(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
+53
View File
@@ -0,0 +1,53 @@
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: docker
container:
image: golang:1.25
env:
GOPRIVATE: forgejo.riotpiao.homelab.com
GOFLAGS: -mod=readonly
steps:
- name: install node (required by JS-based actions)
run: apt-get update && apt-get install -y --no-install-recommends nodejs ca-certificates git
- uses: actions/checkout@v4
- name: configure git auth for private module fetch
run: |
git config --global url."https://oauth2:${FORGEJO_PAT}@forgejo.riotpiao.homelab.com".insteadOf "https://forgejo.riotpiao.homelab.com"
env:
FORGEJO_PAT: ${{ secrets.FORGEJO_PAT }}
- name: cache go modules + build cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ hashFiles('go.sum') }}
restore-keys: |
go-${{ runner.os }}-
- name: gofmt
run: |
fmt_out="$(gofmt -l ./cmd ./internal)"
if [ -n "$fmt_out" ]; then
echo "$fmt_out"
echo "::error::gofmt found unformatted files, run 'gofmt -w ./cmd ./internal'"
exit 1
fi
- name: go vet
run: go vet ./cmd/... ./internal/...
- name: go build
run: go build ./cmd/kmsvc
- name: go test
run: go test ./cmd/... ./internal/... -race
+38
View File
@@ -0,0 +1,38 @@
name: release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: docker
container:
image: golang:1.25
env:
GOPRIVATE: forgejo.riotpiao.homelab.com
steps:
- uses: actions/checkout@v4
- name: build cross-platform binaries
run: |
mkdir -p dist
VERSION="${GITHUB_REF_NAME}"
for os in linux darwin; do
for arch in amd64 arm64; do
out="dist/kmsvc-${os}-${arch}"
GOOS=$os GOARCH=$arch go build \
-ldflags "-X forgejo.riotpiao.homelab.com/rock/kmsvc-cli/internal/cli.version=${VERSION}" \
-o "${out}" ./cmd/kmsvc
done
done
- name: release
uses: actions/forgejo-release@v2
with:
direction: upload
url: ${{ env.GITHUB_SERVER_URL }}
repo: ${{ env.GITHUB_REPOSITORY }}
release-dir: dist
token: ${{ secrets.RELEASE_TOKEN }}
+4
View File
@@ -0,0 +1,4 @@
kmsvc
/agent/
/.agents/
skills-lock.json
+17
View File
@@ -0,0 +1,17 @@
# Runtime stage
FROM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache ca-certificates
WORKDIR /app
# Copy pre-built binary for the target platform
ARG TARGETARCH
COPY kmsvc-${TARGETARCH} kmsvc
# Make it executable
RUN chmod +x kmsvc
ENTRYPOINT ["./kmsvc"]
CMD ["--help"]
+70
View File
@@ -0,0 +1,70 @@
# kmsvc-cli — CLI wrapper implementation plan
## Context
Fourth sibling repo, alongside `kafaka_management_service` (server), `kmsvc-proto` (wire contract), and `kmsvc-sdk` (Go client SDK). Per design.md §11a, this is a thin CLI over `kmsvc-sdk`. Used by operators to send/receive/delete messages and inspect/redrive DLQ contents from the terminal.
Queue lifecycle (create/delete/configure) is managed via the Queue CRD on the cluster (`kubectl apply`/`kubectl get queues`), not exposed over gRPC by `kmsvc-proto` — so this CLI has no `queue` subcommand, only `message` and `dlq`.
**Dependency**: `kmsvc-cli` imports `kmsvc-sdk` as a normal Go module dependency — `go get forgejo.riotpiao.homelab.com/rock/[email protected]` (module path matches the self-hosted Forgejo host, same pattern as `kmsvc-proto`). While `kmsvc-sdk` isn't pushed/tagged yet, this repo uses a local `replace` directive in `go.mod` pointing at the sibling `../kmsvc-sdk` checkout — remove it once a real tag exists.
**CLI framework**: the user's existing `talos-cli` (homelab cluster control) is a Rust/`clap` binary, not Go — there's no existing Go CLI convention in this homelab to literally reuse. We use `cobra` (the de facto standard for Go CLIs, same family `kubectl`/`helm` use) and mirror `talos-cli`'s *behavioral* conventions instead: simple subcommand tree, env-var-driven config with sane defaults, no mandatory config file.
## Repo layout
```
kmsvc-cli/
go.mod # module forgejo.riotpiao.homelab.com/rock/kmsvc-cli
cmd/kmsvc/main.go # entrypoint
internal/cli/root.go # root command, global flags (--server, --token, --output)
internal/cli/config.go # ~/.kmsvc/config.yaml (optional) + env var overrides, flag overrides env
internal/cli/client.go # MessageClient interface + buildClient (lazy kmsvc-sdk.Client construction)
internal/cli/messages.go # kmsvc message send|receive|delete|change-visibility
internal/cli/dlq.go # kmsvc dlq peek|redrive
internal/cli/output.go # table/json rendering, shared across subcommands
internal/cli/version.go # kmsvc version (ldflags-injected at release build time)
internal/cli/*_test.go
.forgejo/workflows/ci.yaml
.forgejo/workflows/release.yaml
README.md
```
## Implementation steps
### Step 1 — Command skeleton + config loading ✅ done
- `cobra` root command `kmsvc` with persistent flags `--server`/`--token`/`--output` (table|json), each falling back to `KMSVC_SERVER`/`KMSVC_TOKEN`/`KMSVC_OUTPUT`, falling back to an optional `~/.kmsvc/config.yaml` (`server:`, `output:`), falling back to defaults (`output: table`). Precedence: flag > env > file > default.
- **Verify**: `kmsvc --help` lists `message`/`dlq`/`version`; a table-driven test asserts file+env+flag precedence resolves correctly.
### Step 2 — Auth wiring + client construction ✅ done (revised)
- Commands use the concrete `*kmsvc.Client` directly — no `MessageClient` interface or `clientFactory` abstraction (course-corrected: re-wrapping the SDK's own methods behind an interface purely for test substitution was unnecessary duplication).
- `buildClient` constructs a real `*kmsvc.Client` from resolved config (`kmsvc.WithTokenSource(kmsvc.StaticToken(token))` when `--token`/`KMSVC_TOKEN` is set), errors if `--server`/`KMSVC_SERVER` is unset.
- Tests spin up a real loopback-TCP `grpc.Server` backed by a hand-rolled fake of `kafkamgmtv1.QueueServiceServer` (`testserver_test.go`), exercised through the unmodified SDK `New`/`Client` — fully offline, no interface needed on the CLI side.
### Step 3 — Message commands ✅ done
- `kmsvc message send --queue --body [--group-id] [--dedup-id] [--delay]`, `receive --queue [--wait] [--max-messages] [--visibility-timeout]`, `delete --queue --receipt-handle`, `change-visibility --queue --receipt-handle --timeout`.
- **Verify**: per-command tests against a hand-rolled fake `MessageClient`; `--output json` produces machine-parseable output for `receive`.
### Step 4 — DLQ inspection commands ✅ done
- `kmsvc dlq peek --queue <dlq-name> [--max-messages] [--visibility-timeout]`: receives without deleting (a short visibility timeout lets it become re-visible) — non-destructive inspect.
- `kmsvc dlq redrive --queue <dlq-name> --to <source-queue> [--max-messages]`: for each received message — `SendMessage` to `--to`, then `DeleteMessage` from the DLQ. Not atomic (3+ SDK calls): if send succeeds but delete fails, the command reports that message as "sent but not removed from DLQ — may be redelivered" rather than silently continuing, and the command exits non-zero if any entry had a partial failure.
- **Verify**: fake-backed test asserting call order (send before delete) and that a forced delete failure surfaces the duplicate-risk warning and a non-zero exit.
### Step 5 — Output formatting ✅ done
- `internal/cli/output.go`: shared `table`/`json` renderer for messages and batch results.
- **Verify**: tests for both formats against a fixed sample response.
### Step 6 — Packaging + CI ✅ done (workflows written, release untested — no tag pushed yet)
- `.forgejo/workflows/ci.yaml`: `go build ./...`, `go vet ./...`, `go test ./...` on every push/PR.
- `.forgejo/workflows/release.yaml`: on a tag push, cross-compile (darwin/linux × amd64/arm64) with `-ldflags -X .../internal/cli.version=<tag>`, attach to a Forgejo release.
- **Verify**: a real tag push produces downloadable binaries; `kmsvc version` in the built binary reports the tag. — deferred until first tag.
## Acceptance criteria (overall)
- [x] All `message`/`dlq` subcommands work against a fake gRPC server in CI — no real network dependency.
- [x] Config file + env var + flag precedence is flag > env > file > default, tested explicitly.
- [x] `--output json` is machine-parseable for every read command (`receive`, `dlq peek`).
- [x] `dlq redrive` partial-failure (send ok, delete fails) is surfaced, not silently swallowed, and causes non-zero exit.
- [ ] A tagged release produces real downloadable binaries via Forgejo Actions.
- [ ] Manual end-to-end smoke test (send → receive → delete → dlq redrive) passes against a real running server, once one exists.
## Sequencing note
Once `kmsvc-sdk` is pushed and tagged on Forgejo, swap the `go.mod` `replace` directive for a real `go get forgejo.riotpiao.homelab.com/rock/kmsvc-sdk@<tag>`.
+61
View File
@@ -0,0 +1,61 @@
# kmsvc-cli
Thin CLI wrapper over [kmsvc-sdk](https://forgejo.riotpiao.homelab.com/rock/kmsvc-sdk) for operating the Kafka Management Service's message plane from the terminal — sending/receiving/deleting messages and inspecting/redriving DLQ contents.
Queue lifecycle is the Queue CRD on the cluster — `create-queue`/`delete-queue` apply/delete that CRD directly (same effect as `kubectl apply`/`kubectl delete`, just without a YAML file), and `kmsvc queue list`/`describe` read it back.
## Install
```bash
export GOPRIVATE=forgejo.riotpiao.homelab.com # self-hosted Forgejo, skip public proxy/sumdb
go install forgejo.riotpiao.homelab.com/rock/kmsvc-cli/cmd/kmsvc@latest
```
## Configuration
Precedence: flag > env var > `~/.kmsvc/config.yaml` > default.
| Flag | Env var | Config key | Default |
|---|---|---|---|
| `--server` | `KMSVC_SERVER` | `server` | (required) |
| `--token` | `KMSVC_TOKEN` | — | (none) |
| `--output` | `KMSVC_OUTPUT` | `output` | `table` |
| `--insecure` | `KMSVC_INSECURE` | `insecure` | `false` |
By default the CLI dials `--server` over TLS — the default `kmsvc.riotpiao.homelab.com:443` is reached through an Ingress-terminated HTTPS/gRPC-passthrough endpoint, not a plaintext port. Pass `--insecure` (or set `KMSVC_INSECURE=1`) for cluster-internal/dev targets that speak plaintext gRPC directly.
```yaml
# ~/.kmsvc/config.yaml
server: kmsvc.riotpiao.homelab.com:443
output: table
```
## Usage
```bash
kmsvc send-message --queue orders --body '{"order_id": 123}'
kmsvc receive-message --queue orders --wait 20 --max-messages 10
kmsvc delete-message --queue orders --receipt-handle <handle>
kmsvc change-message-visibility --queue orders --receipt-handle <handle> --timeout 60
kmsvc dlq peek --queue orders.dlq --max-messages 10
kmsvc dlq redrive --queue orders.dlq --to orders --max-messages 10
kmsvc create-queue orders --set fifoQueue=true --set maxReceiveCount=5
kmsvc delete-queue orders
kmsvc queue list
kmsvc queue describe orders
kmsvc version
```
`dlq redrive` performs Receive → Send → Delete as three separate calls (not atomic). If Send succeeds but Delete fails, the command reports the message as left in the DLQ and may be duplicated on the next redrive, and exits non-zero.
## Development
```bash
export GOPRIVATE=forgejo.riotpiao.homelab.com
go build ./...
go vet ./...
go test ./... -race
```
+52
View File
@@ -0,0 +1,52 @@
module forgejo.riotpiao.homelab.com/homelab/kmsvc-cli
go 1.26.0
require (
forgejo.riotpiao.homelab.com/homelab/kmsvc-proto v1.1.0
forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk v0.0.0
github.com/spf13/cobra v1.10.2
google.golang.org/grpc v1.81.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apimachinery v0.36.2 // indirect
k8s.io/client-go v0.36.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace (
forgejo.riotpiao.homelab.com/homelab/kmsvc-proto => ../kmsvc-proto
forgejo.riotpiao.homelab.com/homelab/kmsvc-sdk => ../kmsvc-sdk
)
+117
View File
@@ -0,0 +1,117 @@
forgejo.riotpiao.homelab.com/rock/kmsvc-proto v1.1.0 h1:T7Y0aWFucwtwoOKy0XTCb+MAtay8U5j4SHCgi6PCrQo=
forgejo.riotpiao.homelab.com/rock/kmsvc-proto v1.1.0/go.mod h1:pKNhLE2KPpNUeu2BL5BFDgXmPlbyB9LHCfi0G4aE38s=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k=
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+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
},
}
}