(chore) setup kmsvc-cli
This commit is contained in:
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
kmsvc
|
||||||
|
/agent/
|
||||||
|
/.agents/
|
||||||
|
skills-lock.json
|
||||||
+17
@@ -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"]
|
||||||
@@ -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>`.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user