Skip to content

Commit 9f38c6e

Browse files
willamhouclaudehappy-otter
authored
fix: address codex audit findings (4 issues) (#24)
After Codex audited project completeness against my self-assessment, it flagged 4 concrete gaps in the autonomous-ops story that I'd glossed over. This PR fixes all four. 1. cmd/claw4k8s/pipeline.go — noopLLMClient returned ("", "", nil), so pipeline.analyze short-circuited the retry loop and returned empty analysis instead of falling through to the synthetic fallback. Operators saw escalations stuck at AwaitingApproval with empty status.analysis. Pipeline now treats (empty, empty, nil) as a "no LLM" signal and uses the fallback message immediately. Test verifies single Analyze call (no retries) + non-empty fallback text. 2. cmd/kubectl-claw/ — new kubectl plugin providing approve/reject subcommands. Without it, AwaitingApproval escalations had no Approved writer — operators had to hand-craft kubectl patch commands. Plugin sets phase=Approved/Rejected with proper approvedBy / approvedAt / rejectionReason fields. 5 tests cover happy path, wrong-phase rejection, empty proposedAction guard, and terminal-phase reject guard. 3. Dockerfile.claw4k8s + release/ci matrix — k8sops adapter pointed to ghcr.io/prismer-ai/claw4k8s:latest, but no Dockerfile or release target produced that image. Companion Claw deployments would have ImagePullBackOff. New Dockerfile builds the cmd/claw4k8s binary; added to both release.yml and ci.yml docker-build matrices. 4. runtimes/hermesrs/ — Dockerfile + README scaffolding for the hermes-agent-rs runtime referenced by HermesRSAdapter. Supports both vendored source (./src) and upstream-clone (HERMES_REPO build arg) modes. Not in CI matrix yet — release pending stable upstream tag. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Signed-off-by: willamhou <willamhou@ceresman.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent 67aef4a commit 9f38c6e

9 files changed

Lines changed: 573 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ jobs:
119119
matrix:
120120
target:
121121
- { name: operator, file: Dockerfile, context: . }
122+
- { name: claw4k8s, file: Dockerfile.claw4k8s, context: . }
122123
- { name: init, file: Dockerfile.init, context: . }
123124
- { name: ipcbus, file: Dockerfile.ipcbus, context: . }
124125
- { name: channel-slack, file: Dockerfile.channel-slack, context: . }

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ jobs:
3939
- image: k8s4claw
4040
dockerfile: Dockerfile
4141
context: .
42+
- image: claw4k8s
43+
dockerfile: Dockerfile.claw4k8s
44+
context: .
4245
- image: claw-init
4346
dockerfile: Dockerfile.init
4447
context: .

Dockerfile.claw4k8s

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM golang:1.25-alpine AS builder
2+
3+
WORKDIR /workspace
4+
5+
COPY go.mod go.sum ./
6+
RUN go mod download
7+
8+
COPY . .
9+
RUN CGO_ENABLED=0 GOOS=linux go build -a \
10+
-o claw4k8s ./cmd/claw4k8s/
11+
12+
FROM gcr.io/distroless/static:nonroot
13+
14+
WORKDIR /
15+
16+
COPY --from=builder /workspace/claw4k8s .
17+
18+
USER 65532:65532
19+
20+
ENTRYPOINT ["/claw4k8s"]

cmd/claw4k8s/pipeline.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ func (p *Pipeline) analyze(ctx context.Context, esc *v1alpha1.ClawOpsEscalation)
3232
for i := 0; i < retries; i++ {
3333
analysis, action, err = p.LLM.Analyze(ctx, prompt)
3434
if err == nil {
35+
// Empty result with no error means the client signalled "no LLM
36+
// available" (e.g. noopLLMClient). Skip retries and fall through
37+
// to the synthetic fallback so escalations still surface useful
38+
// context for human review.
39+
if analysis == "" && action == "" {
40+
break
41+
}
3542
return analysis, action, nil
3643
}
3744
if i < len(delays) && i < retries-1 {

cmd/claw4k8s/pipeline_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,45 @@ func TestPipeline_LLMFailureFallback(t *testing.T) {
6969
assert.Contains(t, analysis, "LLM analysis unavailable")
7070
assert.Empty(t, action)
7171
}
72+
73+
// countingLLMClient records how many times Analyze is invoked.
74+
type countingLLMClient struct {
75+
calls int
76+
analysis string
77+
action string
78+
err error
79+
}
80+
81+
func (c *countingLLMClient) Analyze(_ context.Context, _ string) (string, string, error) {
82+
c.calls++
83+
return c.analysis, c.action, c.err
84+
}
85+
86+
// TestPipeline_NoopFallback verifies that an LLM client returning ("", "", nil)
87+
// (e.g. noopLLMClient when LLM_GATEWAY_URL is unset) skips retries and produces
88+
// a synthetic fallback analysis. Without this, the pipeline returned empty
89+
// analysis, leaving operators with no escalation context.
90+
func TestPipeline_NoopFallback(t *testing.T) {
91+
llm := &countingLLMClient{} // returns ("", "", nil)
92+
pipeline := &Pipeline{LLM: llm, MaxRetries: 3}
93+
94+
now := metav1.Now()
95+
esc := &v1alpha1.ClawOpsEscalation{
96+
Spec: v1alpha1.ClawOpsEscalationSpec{
97+
Severity: v1alpha1.SeverityHigh,
98+
Trigger: v1alpha1.TriggerInfo{
99+
Type: v1alpha1.TriggerOOMKilled,
100+
Message: "OOM",
101+
FirstSeen: &now,
102+
Count: 2,
103+
},
104+
},
105+
}
106+
107+
analysis, action, err := pipeline.analyze(context.Background(), esc)
108+
require.NoError(t, err)
109+
assert.Contains(t, analysis, "LLM analysis unavailable",
110+
"empty (no err) result must trigger synthetic fallback")
111+
assert.Empty(t, action)
112+
assert.Equal(t, 1, llm.calls, "noop signal must skip retries (1 call, not MaxRetries)")
113+
}

cmd/kubectl-claw/main.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// kubectl-claw is a kubectl plugin for k8s4claw operations.
2+
//
3+
// Subcommands:
4+
//
5+
// approve <escalation> Mark a ClawOpsEscalation as Approved.
6+
// reject <escalation> Mark a ClawOpsEscalation as Rejected.
7+
//
8+
// Install: place this binary on $PATH as `kubectl-claw`. Then run
9+
// `kubectl claw approve <name>`.
10+
package main
11+
12+
import (
13+
"context"
14+
"flag"
15+
"fmt"
16+
"os"
17+
18+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19+
"k8s.io/client-go/kubernetes/scheme"
20+
ctrl "sigs.k8s.io/controller-runtime"
21+
"sigs.k8s.io/controller-runtime/pkg/client"
22+
23+
v1alpha1 "github.com/Prismer-AI/k8s4claw/api/v1alpha1"
24+
)
25+
26+
const usage = `kubectl-claw — operator-side approval CLI for k8s4claw
27+
28+
Usage:
29+
kubectl claw approve <escalation> [-n namespace] [--by user@example.com]
30+
kubectl claw reject <escalation> [-n namespace] [--reason "..."]
31+
32+
Examples:
33+
kubectl claw approve my-claw-ops-abc -n ai-agents --by sre@corp.com
34+
kubectl claw reject my-claw-ops-xyz -n default --reason "manual rollback already done"
35+
`
36+
37+
func main() {
38+
if len(os.Args) < 2 {
39+
fmt.Fprint(os.Stderr, usage)
40+
os.Exit(2)
41+
}
42+
43+
cmd := os.Args[1]
44+
args := os.Args[2:]
45+
46+
switch cmd {
47+
case "approve":
48+
os.Exit(runApprove(args))
49+
case "reject":
50+
os.Exit(runReject(args))
51+
case "-h", "--help", "help":
52+
fmt.Print(usage)
53+
default:
54+
fmt.Fprintf(os.Stderr, "unknown subcommand: %q\n\n", cmd)
55+
fmt.Fprint(os.Stderr, usage)
56+
os.Exit(2)
57+
}
58+
}
59+
60+
// commonFlags returns the parsed name + namespace + extra arg.
61+
func commonFlags(args []string, extraName string) (name, namespace, extra string, err error) {
62+
fs := flag.NewFlagSet(extraName, flag.ContinueOnError)
63+
fs.StringVar(&namespace, "n", "default", "namespace")
64+
fs.StringVar(&namespace, "namespace", "default", "namespace")
65+
fs.StringVar(&extra, extraName, "", extraName+" annotation")
66+
if err := fs.Parse(args); err != nil {
67+
return "", "", "", err
68+
}
69+
if fs.NArg() < 1 {
70+
return "", "", "", fmt.Errorf("missing escalation name")
71+
}
72+
return fs.Arg(0), namespace, extra, nil
73+
}
74+
75+
func newClient() (client.Client, error) {
76+
cfg, err := ctrl.GetConfig()
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
79+
}
80+
if err := v1alpha1.AddToScheme(scheme.Scheme); err != nil {
81+
return nil, fmt.Errorf("failed to register scheme: %w", err)
82+
}
83+
return client.New(cfg, client.Options{Scheme: scheme.Scheme})
84+
}
85+
86+
func runApprove(args []string) int {
87+
name, ns, by, err := commonFlags(args, "by")
88+
if err != nil {
89+
fmt.Fprintln(os.Stderr, err)
90+
return 2
91+
}
92+
if by == "" {
93+
// Default to whoami-style identity from KUBECONFIG context.
94+
by = currentUser()
95+
}
96+
97+
c, err := newClient()
98+
if err != nil {
99+
fmt.Fprintln(os.Stderr, err)
100+
return 1
101+
}
102+
ctx := context.Background()
103+
104+
var esc v1alpha1.ClawOpsEscalation
105+
if err := c.Get(ctx, client.ObjectKey{Name: name, Namespace: ns}, &esc); err != nil {
106+
fmt.Fprintf(os.Stderr, "failed to get escalation %s/%s: %v\n", ns, name, err)
107+
return 1
108+
}
109+
110+
if esc.Status.Phase != v1alpha1.EscalationPhaseAwaitingApproval {
111+
fmt.Fprintf(os.Stderr, "escalation %s is in phase %q (must be %q to approve)\n",
112+
name, esc.Status.Phase, v1alpha1.EscalationPhaseAwaitingApproval)
113+
return 1
114+
}
115+
if esc.Status.ProposedAction == "" {
116+
fmt.Fprintf(os.Stderr, "escalation %s has empty proposedAction — nothing to approve\n", name)
117+
return 1
118+
}
119+
120+
now := metav1.Now()
121+
esc.Status.Phase = v1alpha1.EscalationPhaseApproved
122+
esc.Status.ApprovedBy = by
123+
esc.Status.ApprovedAt = &now
124+
125+
if err := c.Status().Update(ctx, &esc); err != nil {
126+
fmt.Fprintf(os.Stderr, "failed to update status: %v\n", err)
127+
return 1
128+
}
129+
fmt.Printf("approved %s/%s by %s\nproposedAction will be executed by ClawOpsController\n", ns, name, by)
130+
return 0
131+
}
132+
133+
func runReject(args []string) int {
134+
name, ns, reason, err := commonFlags(args, "reason")
135+
if err != nil {
136+
fmt.Fprintln(os.Stderr, err)
137+
return 2
138+
}
139+
if reason == "" {
140+
reason = "rejected via kubectl-claw"
141+
}
142+
143+
c, err := newClient()
144+
if err != nil {
145+
fmt.Fprintln(os.Stderr, err)
146+
return 1
147+
}
148+
ctx := context.Background()
149+
150+
var esc v1alpha1.ClawOpsEscalation
151+
if err := c.Get(ctx, client.ObjectKey{Name: name, Namespace: ns}, &esc); err != nil {
152+
fmt.Fprintf(os.Stderr, "failed to get escalation %s/%s: %v\n", ns, name, err)
153+
return 1
154+
}
155+
156+
if v1alpha1.IsTerminalPhase(esc.Status.Phase) {
157+
fmt.Fprintf(os.Stderr, "escalation %s is already terminal (phase=%q)\n", name, esc.Status.Phase)
158+
return 1
159+
}
160+
161+
esc.Status.Phase = v1alpha1.EscalationPhaseRejected
162+
esc.Status.RejectionReason = reason
163+
164+
if err := c.Status().Update(ctx, &esc); err != nil {
165+
fmt.Fprintf(os.Stderr, "failed to update status: %v\n", err)
166+
return 1
167+
}
168+
fmt.Printf("rejected %s/%s: %s\n", ns, name, reason)
169+
return 0
170+
}
171+
172+
// currentUser returns a best-effort identity string for the approval audit trail.
173+
// Falls back to USER env var, then "unknown".
174+
func currentUser() string {
175+
if u := os.Getenv("USER"); u != "" {
176+
return u
177+
}
178+
return "unknown"
179+
}

0 commit comments

Comments
 (0)