Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions decisionmaker/domain/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type PodInfo struct {
}

type Intent struct {
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New field IntentID added but never used. This field is added to the Intent struct but is not referenced anywhere in the codebase (neither in hashing logic, serialization, nor any other operations). If this field is intended for future use, consider adding a comment to clarify its purpose. If it should be included in the hash computation for intent comparison, it must be added to the hashIntent function.

Suggested change
type Intent struct {
type Intent struct {
// IntentID is an optional opaque identifier for this intent.
// It is used by higher-level components (e.g. the manager service or external clients)
// and is intentionally excluded from hashing/comparison logic such as hashIntent,
// which only considers the scheduling properties of an intent.

Copilot uses AI. Check for mistakes.
IntentID string `json:"intentID,omitempty"`
PodName string `json:"podName,omitempty"`
PodID string `json:"podID,omitempty"`
NodeID string `json:"nodeID,omitempty"`
Expand Down
5 changes: 3 additions & 2 deletions decisionmaker/rest/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type SuccessResponse[T any] struct {

type Params struct {
fx.In
Service service.Service
Service *service.Service
TokenConfig config.TokenConfig
}

Expand All @@ -68,7 +68,7 @@ func NewHandler(params Params) (*Handler, error) {
}

type Handler struct {
Service service.Service
Service *service.Service
TokenConfig config.TokenConfig
}

Expand Down Expand Up @@ -162,6 +162,7 @@ func (h *Handler) SetupRoutes(engine *echo.Echo) error {
apiV1 := api.Group("/v1")
// auth routes
apiV1.POST("/intents", h.echoHandler(h.HandleIntents), echo.WrapMiddleware(authMiddleware))
apiV1.GET("/intents/merkle", h.echoHandler(h.GetIntentMerkleRoot), echo.WrapMiddleware(authMiddleware))
apiV1.DELETE("/intents", h.echoHandler(h.DeleteIntent), echo.WrapMiddleware(authMiddleware))
apiV1.GET("/scheduling/strategies", h.echoHandler(h.ListIntents), echo.WrapMiddleware(authMiddleware))
apiV1.POST("/metrics", h.echoHandler(h.UpdateMetrics), echo.WrapMiddleware(authMiddleware))
Expand Down
21 changes: 21 additions & 0 deletions decisionmaker/rest/intent_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"time"

"github.com/Gthulhu/api/decisionmaker/domain"
"github.com/Gthulhu/api/decisionmaker/service"
)

type HandleIntentsRequest struct {
Expand Down Expand Up @@ -101,6 +102,26 @@ func (h *Handler) ListIntents(w http.ResponseWriter, r *http.Request) {
h.JSONResponse(ctx, w, http.StatusOK, response)
}

type MerkleRootResponse struct {
RootHash string `json:"rootHash"`
}

func (h *Handler) GetIntentMerkleRoot(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
resp, err := h.Service.TraverseIntentMerkleTree(ctx, &service.TraverseIntentMerkleTreeOptions{
Depth: 0,
})
if err != nil {
h.ErrorResponse(ctx, w, http.StatusInternalServerError, "Failed to get intent merkle root", err)
return
}
rootHash := ""
if resp != nil && resp.RootNode != nil {
rootHash = resp.RootNode.Hash
}
h.JSONResponse(ctx, w, http.StatusOK, NewSuccessResponse(&MerkleRootResponse{RootHash: rootHash}))
}

func convertMapToLabelSelectors(selectorMap []domain.LabelSelector) []LabelSelector {
labelSelectors := make([]LabelSelector, 0, len(selectorMap))
for _, sel := range selectorMap {
Expand Down
56 changes: 56 additions & 0 deletions decisionmaker/service/intents_svc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package service

import (
"context"
"errors"

"github.com/Gthulhu/api/pkg/util"
)

type TraverseIntentMerkleTreeOptions struct {
RootHash string
Depth int64
}

type Node struct {
Hash string
Left *Node
Right *Node
}

type TraverseIntentMerkleTreeResp struct {
RootNode *Node
}

func (svc *Service) TraverseIntentMerkleTree(ctx context.Context, req *TraverseIntentMerkleTreeOptions) (resp *TraverseIntentMerkleTreeResp, err error) {
if req == nil {
return nil, errors.New("nil request")
}

svc.refreshIntentMerkleTreeIfNeeded()

svc.intentCacheMu.RLock()
root := svc.intentMerkleRoot
svc.intentCacheMu.RUnlock()
if req.RootHash != "" && root != nil {
found := util.FindMerkleNode(root, req.RootHash)
if found == nil {
return &TraverseIntentMerkleTreeResp{RootNode: nil}, nil
}
root = found
}

truncated := util.TruncateMerkleTree(root, req.Depth)
return &TraverseIntentMerkleTreeResp{RootNode: convertMerkleNode(truncated)}, nil
}

func convertMerkleNode(node *util.MerkleNode) *Node {
if node == nil {
return nil
}
return &Node{
Hash: node.Hash,
Left: convertMerkleNode(node.Left),
Right: convertMerkleNode(node.Right),
}
}
210 changes: 210 additions & 0 deletions decisionmaker/service/intents_svc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package service

import (
"context"
"fmt"
"sync"
"testing"

"github.com/Gthulhu/api/decisionmaker/domain"
"github.com/Gthulhu/api/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestTraverseIntentMerkleTreeNilRequest(t *testing.T) {
svc := &Service{}

resp, err := svc.TraverseIntentMerkleTree(context.Background(), nil)
require.Error(t, err)
assert.Nil(t, resp)
}

func TestTraverseIntentMerkleTreeDepthZero(t *testing.T) {
root := util.BuildMerkleTree([]string{
util.HashStringSHA256Hex("leaf-a"),
util.HashStringSHA256Hex("leaf-b"),
})
svc := &Service{intentMerkleRoot: root}

resp, err := svc.TraverseIntentMerkleTree(context.Background(), &TraverseIntentMerkleTreeOptions{
Depth: 0,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.RootNode)
assert.Equal(t, root.Hash, resp.RootNode.Hash)
assert.Nil(t, resp.RootNode.Left)
assert.Nil(t, resp.RootNode.Right)
}

func TestTraverseIntentMerkleTreeFindSubTreeByRootHash(t *testing.T) {
root := util.BuildMerkleTree([]string{
util.HashStringSHA256Hex("leaf-a"),
util.HashStringSHA256Hex("leaf-b"),
util.HashStringSHA256Hex("leaf-c"),
util.HashStringSHA256Hex("leaf-d"),
})
require.NotNil(t, root)
require.NotNil(t, root.Left)
require.NotNil(t, root.Right)

svc := &Service{intentMerkleRoot: root}
resp, err := svc.TraverseIntentMerkleTree(context.Background(), &TraverseIntentMerkleTreeOptions{
RootHash: root.Left.Hash,
Depth: 1,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.RootNode)
assert.Equal(t, root.Left.Hash, resp.RootNode.Hash)
require.NotNil(t, resp.RootNode.Left)
require.NotNil(t, resp.RootNode.Right)
assert.Nil(t, resp.RootNode.Left.Left)
assert.Nil(t, resp.RootNode.Left.Right)
assert.Nil(t, resp.RootNode.Right.Left)
assert.Nil(t, resp.RootNode.Right.Right)
}

func TestTraverseIntentMerkleTreeRootHashNotFound(t *testing.T) {
svc := &Service{
intentMerkleRoot: util.BuildMerkleTree([]string{
util.HashStringSHA256Hex("leaf-a"),
util.HashStringSHA256Hex("leaf-b"),
}),
}

resp, err := svc.TraverseIntentMerkleTree(context.Background(), &TraverseIntentMerkleTreeOptions{
RootHash: "missing-hash",
Depth: 0,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.Nil(t, resp.RootNode)
}

func TestTraverseIntentMerkleTreeRefreshesRootFromIntentCache(t *testing.T) {
intentA := &domain.Intent{
PodName: "pod-a",
PodID: "pod-id-a",
NodeID: "node-a",
K8sNamespace: "default",
CommandRegex: "nginx",
Priority: 1,
ExecutionTime: 100,
PodLabels: map[string]string{
"z": "2",
"a": "1",
},
}
intentB := &domain.Intent{
PodName: "pod-b",
PodID: "pod-id-b",
NodeID: "node-b",
K8sNamespace: "kube-system",
CommandRegex: "busybox",
Priority: 0,
ExecutionTime: 200,
PodLabels: map[string]string{
"k2": "v2",
},
}
svc := &Service{
intentCache: []*domain.Intent{
nil, // ensure nil input is normalized away
intentB,
intentA,
},
}

resp, err := svc.TraverseIntentMerkleTree(context.Background(), &TraverseIntentMerkleTreeOptions{
Depth: 0,
})
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.RootNode)
require.NotNil(t, svc.intentMerkleRoot)
assert.Equal(t, svc.intentMerkleRoot.Hash, resp.RootNode.Hash)
assert.Equal(t, svc.intentMerkleRoot.Hash, svc.intentMerkleRootHash)
}

func TestHashIntentLabelOrderIndependent(t *testing.T) {
intentA := &domain.Intent{
PodName: "pod",
PodID: "pod-id",
NodeID: "node-id",
K8sNamespace: "default",
CommandRegex: "nginx",
Priority: 1,
ExecutionTime: 42,
PodLabels: map[string]string{
"b": "2",
"a": "1",
},
}
intentB := &domain.Intent{
PodName: "pod",
PodID: "pod-id",
NodeID: "node-id",
K8sNamespace: "default",
CommandRegex: "nginx",
Priority: 1,
ExecutionTime: 42,
PodLabels: map[string]string{
"a": "1",
"b": "2",
},
}

hashA := hashIntent(intentA)
hashB := hashIntent(intentB)
assert.Equal(t, hashA, hashB)
assert.Equal(t, util.HashStringSHA256Hex("podName=pod|podID=pod-id|nodeID=node-id|k8sNamespace=default|commandRegex=nginx|priority=1|executionTime=42|podLabels=a=1,b=2"), hashA)
}

func TestTraverseIntentMerkleTreeConcurrentReadWrite(t *testing.T) {
svc := &Service{
intentMerkleRoot: util.BuildMerkleTree([]string{util.HashStringSHA256Hex("initial")}),
}

const workers = 4
const iterations = 200

errCh := make(chan error, workers*iterations)
var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
root := util.BuildMerkleTree([]string{util.HashStringSHA256Hex(fmt.Sprintf("leaf-%d", i))})
svc.intentCacheMu.Lock()
svc.intentMerkleRoot = root
svc.intentCacheMu.Unlock()
}
}()

for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
resp, err := svc.TraverseIntentMerkleTree(context.Background(), &TraverseIntentMerkleTreeOptions{Depth: 0})
if err != nil {
errCh <- err
return
}
if resp == nil || resp.RootNode == nil || resp.RootNode.Hash == "" {
errCh <- fmt.Errorf("unexpected nil/empty root node: %+v", resp)
return
}
}
}()
}

wg.Wait()
close(errCh)
for err := range errCh {
require.NoError(t, err)
}
}
Loading