Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion cmd/claw-api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,22 @@ func newHandler(manifest *manifestpkg.PodManifest, scheduleManifest *schedulepkg
func (h *apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/health":
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
principalCount := 0
inert := []string(nil)
warnings := []string(nil)
if h.store != nil {
principalCount = len(h.store.Principals)
inert = h.store.InertPrincipalNames()
warnings = h.store.NormalizationWarnings
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"principals": map[string]any{
"count": principalCount,
"inert": inert,
"normalization_warnings": warnings,
},
})
return
case r.Method == http.MethodGet && r.URL.Path == "/fleet/status":
h.handleStatus(w, r)
Expand Down
57 changes: 55 additions & 2 deletions cmd/claw-api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,61 @@ func TestHandlerHealthEndpoint(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("unexpected health body: %s", w.Body.String())
var body struct {
OK bool `json:"ok"`
Principals struct {
Count int `json:"count"`
Inert []string `json:"inert"`
NormalizationWarnings []string `json:"normalization_warnings"`
} `json:"principals"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal health body: %v body=%s", err, w.Body.String())
}
if !body.OK || body.Principals.Count != 0 || len(body.Principals.Inert) != 0 || len(body.Principals.NormalizationWarnings) != 0 {
t.Fatalf("unexpected health body: %+v raw=%s", body, w.Body.String())
}
}

func TestHandlerHealthEndpointIncludesPrincipalNormalizationDetails(t *testing.T) {
h := newHandler(&manifestpkg.PodManifest{PodName: "ops"}, nil, nil, nil, &clawapi.Store{
Principals: []clawapi.Principal{{
Name: "future",
Token: "capi_x",
Verbs: nil,
Pods: []string{"ops"},
}},
NormalizationWarnings: []string{
`ignoring unknown verb "schedule.pause" for principal "future"`,
`principal "future" has no recognized verbs; token will authorize nothing`,
},
}, nil, nil, clawapi.DefaultThresholds(), t.TempDir())
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()

h.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var body struct {
Principals struct {
Count int `json:"count"`
Inert []string `json:"inert"`
NormalizationWarnings []string `json:"normalization_warnings"`
} `json:"principals"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal health body: %v body=%s", err, w.Body.String())
}
if body.Principals.Count != 1 {
t.Fatalf("expected principal count 1, got %+v", body.Principals)
}
if !strings.Contains(strings.Join(body.Principals.NormalizationWarnings, "\n"), `has no recognized verbs`) {
t.Fatalf("expected normalization warning in health body, got %+v", body.Principals)
}
if len(body.Principals.Inert) != 1 || body.Principals.Inert[0] != "future" {
t.Fatalf("expected inert principal in health body, got %+v", body.Principals)
}
}

Expand Down
3 changes: 3 additions & 0 deletions cmd/claw/compose_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,9 @@ func prepareClawAPIRuntime(runtimeDir string, p *pod.Pod, resolvedClaws map[stri
principals[i] = m.Principal
}
store := clawapi.Store{Principals: principals}
for _, warning := range clawapi.PrincipalVersionSkewWarnings(&store, p.ClawAPI.Image) {
fmt.Printf("[claw] warning: %s\n", warning)
}
if err := writeClawAPIPrincipalStore(runtimeDir, p.ClawAPI.PrincipalsHostPath, store); err != nil {
return nil, fmt.Errorf("write claw-api principals: %w", err)
}
Expand Down
35 changes: 35 additions & 0 deletions cmd/claw/compose_up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,41 @@ func TestPrepareClawAPIRuntimeWithMasterAndInvokeAlsoWritesSchedulerPrincipal(t
}
}

func TestPrepareClawAPIRuntimeWarnsWhenClawAPIImageMayNotSupportPrincipalVerb(t *testing.T) {
runtimeDir := t.TempDir()
p := &pod.Pod{
Name: "ops",
Services: map[string]*pod.Service{
"westin": {
Claw: &pod.ClawBlock{
Invoke: []pod.InvokeEntry{{
Schedule: "0 9 * * 1-5",
Message: "Open the market.",
}},
},
},
},
ClawAPI: &pod.ClawAPIConfig{
Image: "ghcr.io/mostlydev/claw-api:v0.4.2",
Addr: ":8080",
PrincipalsHostPath: filepath.Join(runtimeDir, "claw-api", "principals.json"),
},
}

out, err := captureStdout(t, func() error {
_, err := prepareClawAPIRuntime(runtimeDir, p, map[string]*driver.ResolvedClaw{
"westin": {Count: 1},
})
return err
})
if err != nil {
t.Fatalf("prepareClawAPIRuntime: %v", err)
}
if !strings.Contains(out, `ghcr.io/mostlydev/claw-api:v0.4.2`) || !strings.Contains(out, `known minimum v0.6.0`) {
t.Fatalf("expected skew warning in output, got %q", out)
}
}

func TestPrepareClawAPIRuntimeRejectsInjectIntoReservedMasterService(t *testing.T) {
runtimeDir := t.TempDir()
p := &pod.Pod{
Expand Down
98 changes: 97 additions & 1 deletion internal/clawapi/principal.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"os"
"path"
"strings"

"golang.org/x/mod/semver"
)

const (
Expand All @@ -29,7 +31,8 @@ var AllWriteVerbs = []string{VerbFleetRestart, VerbFleetQuarantine, VerbFleetBud
var AllVerbs = append(append([]string{}, AllReadVerbs...), AllWriteVerbs...)

type Store struct {
Principals []Principal `json:"principals"`
Principals []Principal `json:"principals"`
NormalizationWarnings []string `json:"-"`
}

type Principal struct {
Expand Down Expand Up @@ -60,9 +63,23 @@ func LoadStoreWithWarnings(filePath string) (*Store, []string, error) {
if err != nil {
return nil, nil, fmt.Errorf("validate claw-api principals %q: %w", filePath, err)
}
store.NormalizationWarnings = append([]string(nil), warnings...)
return &store, warnings, nil
}

func (s *Store) InertPrincipalNames() []string {
if s == nil {
return nil
}
names := make([]string, 0)
for _, principal := range s.Principals {
if len(principal.Verbs) == 0 {
names = append(names, principal.Name)
}
}
return names
}

func (s *Store) ResolveBearer(header string) (*Principal, error) {
if s == nil {
return nil, fmt.Errorf("principal store not configured")
Expand Down Expand Up @@ -293,6 +310,85 @@ func containsGlobMeta(pattern string) bool {
return strings.ContainsAny(pattern, "*?[\\")
}

var minClawAPIImageVersionByVerb = map[string]string{
VerbScheduleRead: "v0.6.0",
VerbScheduleControl: "v0.6.0",
}

func PrincipalVersionSkewWarnings(store *Store, imageRef string) []string {
if store == nil {
return nil
}
imageRef = strings.TrimSpace(imageRef)
if imageRef == "" {
return nil
}

tag, tagged := imageTagFromRef(imageRef)
version, versioned := normalizeSemverTag(tag)

seen := make(map[string]struct{})
warnings := make([]string, 0)
for _, principal := range store.Principals {
for _, verb := range principal.Verbs {
minVersion, tracked := minClawAPIImageVersionByVerb[verb]
if !tracked {
continue
}

var warning string
switch {
case !tagged || !versioned:
warning = fmt.Sprintf("claw-api image %q is not version-pinned; cannot verify support for principal %q verb %q (known minimum %s)", imageRef, principal.Name, verb, minVersion)
case semver.Compare(version, minVersion) < 0:
warning = fmt.Sprintf("claw-api image %q may not support principal %q verb %q (known minimum %s)", imageRef, principal.Name, verb, minVersion)
default:
continue
}
if _, ok := seen[warning]; ok {
continue
}
seen[warning] = struct{}{}
warnings = append(warnings, warning)
}
}
return warnings
}

func imageTagFromRef(imageRef string) (string, bool) {
imageRef = strings.TrimSpace(imageRef)
if imageRef == "" {
return "", false
}
if i := strings.Index(imageRef, "@"); i >= 0 {
imageRef = imageRef[:i]
}
slash := strings.LastIndex(imageRef, "/")
colon := strings.LastIndex(imageRef, ":")
if colon <= slash {
return "", false
}
tag := strings.TrimSpace(imageRef[colon+1:])
if tag == "" {
return "", false
}
return tag, true
}

func normalizeSemverTag(tag string) (string, bool) {
tag = strings.TrimSpace(tag)
if tag == "" {
return "", false
}
if tag[0] >= '0' && tag[0] <= '9' {
tag = "v" + tag
}
if !semver.IsValid(tag) {
return "", false
}
return tag, true
}

func secureEqual(a, b string) bool {
ab := []byte(a)
bb := []byte(b)
Expand Down
50 changes: 50 additions & 0 deletions internal/clawapi/principal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ func TestLoadStoreWithWarningsKeepsPrincipalWhenAllVerbsAreUnknown(t *testing.T)
if len(warnings) != 2 {
t.Fatalf("expected 2 warnings, got %v", warnings)
}
if !reflect.DeepEqual(store.NormalizationWarnings, warnings) {
t.Fatalf("expected warnings to be retained on store, got %v want %v", store.NormalizationWarnings, warnings)
}
if !strings.Contains(warnings[0], `ignoring unknown verb "schedule.pause"`) {
t.Fatalf("expected unknown-verb warning, got %v", warnings)
}
Expand All @@ -160,6 +163,53 @@ func TestLoadStoreWithWarningsKeepsPrincipalWhenAllVerbsAreUnknown(t *testing.T)
if principal.AllowsVerb(VerbFleetStatus) {
t.Fatalf("did not expect inert principal to authorize fleet.status")
}
if !reflect.DeepEqual(store.InertPrincipalNames(), []string{"future"}) {
t.Fatalf("expected inert principal summary, got %v", store.InertPrincipalNames())
}
}

func TestPrincipalVersionSkewWarningsWarnForOlderImage(t *testing.T) {
store := &Store{Principals: []Principal{{
Name: "claw-scheduler",
Token: "capi_sched",
Verbs: []string{VerbScheduleRead, VerbScheduleControl},
Pods: []string{"ops"},
}}}

warnings := PrincipalVersionSkewWarnings(store, "ghcr.io/mostlydev/claw-api:v0.4.2")
if len(warnings) != 2 {
t.Fatalf("expected 2 warnings, got %v", warnings)
}
if !strings.Contains(warnings[0], `ghcr.io/mostlydev/claw-api:v0.4.2`) || !strings.Contains(warnings[0], `known minimum v0.6.0`) {
t.Fatalf("expected version warning, got %v", warnings)
}
}

func TestPrincipalVersionSkewWarningsSkipSupportedImage(t *testing.T) {
store := &Store{Principals: []Principal{{
Name: "claw-scheduler",
Token: "capi_sched",
Verbs: []string{VerbScheduleRead, VerbScheduleControl},
Pods: []string{"ops"},
}}}

if warnings := PrincipalVersionSkewWarnings(store, "ghcr.io/mostlydev/claw-api:v0.6.0"); len(warnings) != 0 {
t.Fatalf("expected no warnings, got %v", warnings)
}
}

func TestPrincipalVersionSkewWarningsWarnWhenImageTagUnknown(t *testing.T) {
store := &Store{Principals: []Principal{{
Name: "claw-scheduler",
Token: "capi_sched",
Verbs: []string{VerbScheduleRead},
Pods: []string{"ops"},
}}}

warnings := PrincipalVersionSkewWarnings(store, "ghcr.io/mostlydev/claw-api:latest")
if len(warnings) != 1 || !strings.Contains(warnings[0], `is not version-pinned`) {
t.Fatalf("expected uncertainty warning, got %v", warnings)
}
}

func TestPrincipalPodScopeGrantsComposeServiceAccess(t *testing.T) {
Expand Down
Loading