Skip to content
Merged

Dev #21

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
77 changes: 77 additions & 0 deletions .claude/commands/add-native-task.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
Add a new native task handler to this project. Arguments: $ARGUMENTS

The first token of the arguments is the task slug (e.g. `collect-logs`). Everything after it is the human-readable description. If no arguments are provided, ask the user for the slug and description before proceeding.

## Pattern Reference

Native task handlers live in `internal/service/task/native/`. Each handler is one file in that package. Adding a new file is the **only** change required — no other files need to be modified.

**Key files to understand the pattern:**
- `internal/service/task/native/registry.go` — defines `Handler` and `PostRunner` interfaces
- `internal/service/task/native/sync_inventory.go` — cleanest minimal example
- `internal/service/task/native/sync_software.go` — example that calls existing services

**Interfaces (defined in `registry.go`):**
```go
// Required — every handler must implement both methods
type Handler interface {
Slugs() []string
Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error)
}

// Optional — implement this to auto-run other handlers after success
type PostRunner interface {
PostRun() []string // return slugs to run after Run() succeeds
}
```

**Self-registration:** `func init() { Register(&myHandler{}) }` — runs automatically at startup. No central list to update.

## Steps

1. **Read** `internal/service/task/native/sync_inventory.go` to see the exact file structure.

2. **Derive the filename** from the slug: replace every `-` with `_`, append `.go`.
- `collect-logs` → `collect_logs.go`
- `sync-software` → `sync_software.go`

3. **Derive the struct name** from the slug: convert to UpperCamelCase and append `Handler`.
- `collect-logs` → `CollectLogsHandler` (but keep it unexported: `collectLogsHandler`)

4. **Create** `internal/service/task/native/<filename>` with this structure:
```go
package native

import (
"context"
"fmt"

"sentinelgo/internal/config"
"sentinelgo/internal/taskstore"
)

type <camelSlug>Handler struct{}

func init() { Register(&<camelSlug>Handler{}) }

func (h *<camelSlug>Handler) Slugs() []string {
return []string{"<slug>"}
}

// <description>
func (h *<camelSlug>Handler) Run(ctx context.Context, cfg *config.Config, _ taskstore.Task) (string, error) {
// TODO: implement
return "", fmt.Errorf("<slug>: not implemented")
}
```

5. **Ask the user:** "Should this handler automatically trigger any post-run tasks after it succeeds (e.g. `sync-inventory`)?" If yes, add:
```go
func (h *<camelSlug>Handler) PostRun() []string {
return []string{"<post-run-slug>"}
}
```

6. **Run** `go build ./internal/service/task/native/...` to confirm the new file compiles without errors.

7. **Tell the user** the scaffold is ready and point them to implement the `Run` body. Reference `internal/service/task/native/sync_software.go` as an example of a handler that opens a local store and calls an RPC service, or `internal/service/task/native/sync_inventory.go` for one that collects system info. Both show the correct pattern for using `cfg *config.Config` to access Supabase credentials and config paths.
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ endif
export CGO_ENABLED=0

# Targets
.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format-check setup packages check-no-cgo verify-cross
.PHONY: build clean clean-all all windows linux macos release sign version test-version deps test coverage coverage-html pre-release quality-check format format-check setup packages check-no-cgo verify-cross

all: windows linux macos

Expand Down Expand Up @@ -92,7 +92,7 @@ clean:
rm -rf build/

# Development build (current platform only)
build:
build: format
go build $(LDFLAGS) -o $(DEV_BIN) ./cmd/sentinelgo
@echo "Built $(DEV_BIN) (version $(VERSION))"

Expand Down Expand Up @@ -166,6 +166,10 @@ quality-check:
@command -v golangci-lint >/dev/null 2>&1 || (echo "📦 Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
@export PATH=$$PATH:$$(go env GOPATH)/bin && golangci-lint run

# Auto-format all Go source files
format:
gofmt -s -w .

# Code format check
format-check:
@echo "📝 Checking code formatting..."
Expand Down
56 changes: 10 additions & 46 deletions internal/service/task/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,28 @@ import (

"sentinelgo/internal/config"
"sentinelgo/internal/httpx"
"sentinelgo/internal/sanitize"
"sentinelgo/internal/taskstore"
"sentinelgo/internal/updater"
)

// TaskExecutorService handles the execution of tasks assigned to the agent.
type TaskExecutorService struct {
cfg *config.Config
pollingSvc *TaskPollingService
client *http.Client
runningTasks map[string]bool
cfg *config.Config
pollingSvc *TaskPollingService
client *http.Client
runningTasks map[string]bool
nativeHandlers map[string]NativeTaskHandler
}

// NewTaskExecutorService creates a new task execution service.
func NewTaskExecutorService(cfg *config.Config, pollingSvc *TaskPollingService) *TaskExecutorService {
return &TaskExecutorService{
s := &TaskExecutorService{
cfg: cfg,
pollingSvc: pollingSvc,
client: httpx.NewClient(2 * time.Minute),
runningTasks: make(map[string]bool),
}
s.registerNativeHandlers()
return s
}

// RunExecutionLoop starts a loop that checks for and executes assigned tasks.
Expand Down Expand Up @@ -106,8 +107,8 @@ func (s *TaskExecutorService) executeTask(ctx context.Context, task taskstore.Ta
}

func (s *TaskExecutorService) runTask(ctx context.Context, task taskstore.Task) (string, error) {
if task.Slug == "agent-update" || task.Slug == "update-agent" {
return s.executeAgentUpdate(ctx, task)
if handler, ok := s.nativeHandlers[task.Slug]; ok {
return handler(ctx, task)
}

timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
Expand Down Expand Up @@ -222,40 +223,3 @@ func (s *TaskExecutorService) downloadScript(ctx context.Context, remotePath, lo
_, err = io.Copy(out, io.LimitReader(resp.Body, maxScriptBytes))
return err
}

// executeAgentUpdate handles the agent-update task by directly invoking the updater.
func (s *TaskExecutorService) executeAgentUpdate(ctx context.Context, task taskstore.Task) (string, error) {
log.Printf("Executor: Executing agent-update task %s", task.ID)

if runtime.GOOS == "linux" && os.Getuid() != 0 {
log.Printf("Executor: Agent-update requires root privileges")
return "", fmt.Errorf("agent-update requires root privileges. Please restart the agent with sudo")
}

if !updater.CheckInternetConnectivity() {
log.Printf("Executor: Warning - TCP connectivity check failed, attempting update anyway")
}

if !updater.CheckInternetWithHTTP() {
log.Printf("Executor: Warning - HTTP connectivity check failed, attempting update anyway")
}

currentVersion := s.cfg.CurrentVersion
if currentVersion == "" {
currentVersion = config.Version
}

sanitizedCurrentVersion := sanitize.ForLog(currentVersion)
log.Printf("Executor: Current version: %s, checking for updates...", sanitizedCurrentVersion)

if err := updater.CheckAndApplyWithRetry(ctx, s.cfg, ""); err != nil {
log.Printf("Executor: Agent-update failed: %v", err)
return fmt.Sprintf("Update failed: %v (Current version: %s)", err, sanitizedCurrentVersion), err
}

newVersion := s.cfg.CurrentVersion
sanitizedNewVersion := sanitize.ForLog(newVersion)
log.Printf("Executor: Agent-update successful, updated from %s to %s", sanitizedCurrentVersion, sanitizedNewVersion)

return fmt.Sprintf("Successfully updated from %s to %s. Agent is restarting.", sanitizedCurrentVersion, sanitizedNewVersion), nil
}
62 changes: 62 additions & 0 deletions internal/service/task/native/agent_update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package native

import (
"context"
"fmt"
"log"
"os"
"runtime"

"sentinelgo/internal/config"
"sentinelgo/internal/sanitize"
"sentinelgo/internal/taskstore"
"sentinelgo/internal/updater"
)

type agentUpdateHandler struct{}

func init() { Register(&agentUpdateHandler{}) }

func (h *agentUpdateHandler) Slugs() []string {
return []string{"agent-update"}
}

func (h *agentUpdateHandler) PostRun() []string {
return []string{"sync-inventory"}
}

func (h *agentUpdateHandler) Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error) {
log.Printf("Executor: Executing agent-update task %s", task.ID)

if runtime.GOOS == "linux" && os.Getuid() != 0 {
log.Printf("Executor: Agent-update requires root privileges")
return "", fmt.Errorf("agent-update requires root privileges. Please restart the agent with sudo")
}

if !updater.CheckInternetConnectivity() {
log.Printf("Executor: Warning - TCP connectivity check failed, attempting update anyway")
}

if !updater.CheckInternetWithHTTP() {
log.Printf("Executor: Warning - HTTP connectivity check failed, attempting update anyway")
}

currentVersion := cfg.CurrentVersion
if currentVersion == "" {
currentVersion = config.Version
}

sanitizedCurrentVersion := sanitize.ForLog(currentVersion)
log.Printf("Executor: Current version: %s, checking for updates...", sanitizedCurrentVersion)

if err := updater.CheckAndApplyWithRetry(ctx, cfg, ""); err != nil {
log.Printf("Executor: Agent-update failed: %v", err)
return fmt.Sprintf("Update failed: %v (Current version: %s)", err, sanitizedCurrentVersion), err
}

newVersion := cfg.CurrentVersion
sanitizedNewVersion := sanitize.ForLog(newVersion)
log.Printf("Executor: Agent-update successful, updated from %s to %s", sanitizedCurrentVersion, sanitizedNewVersion)

return fmt.Sprintf("Successfully updated from %s to %s. Agent is restarting.", sanitizedCurrentVersion, sanitizedNewVersion), nil
}
50 changes: 50 additions & 0 deletions internal/service/task/native/registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package native

import (
"context"
"slices"

"sentinelgo/internal/config"
"sentinelgo/internal/taskstore"
)

// Handler is the interface every native task handler must implement.
// Slugs returns the task slug strings this handler claims.
// Run executes the task and returns a human-readable note and any error.
type Handler interface {
Slugs() []string
Run(ctx context.Context, cfg *config.Config, task taskstore.Task) (string, error)
}

// PostRunner is an optional interface a Handler may implement to declare slugs
// that should run automatically after the handler completes successfully.
// Post-run failures are logged but do not affect the primary task's status.
type PostRunner interface {
PostRun() []string
}

var registry []Handler

// Register adds h to the global registry. Call from init() in each handler file.
// All Register calls come from init() functions, which run sequentially before
// any goroutine can access the registry, so no mutex is needed.
func Register(h Handler) {
registry = append(registry, h)
}

// Registry returns a snapshot of all registered handlers.
func Registry() []Handler {
out := make([]Handler, len(registry))
copy(out, registry)
return out
}

// Find returns the first registered handler that claims slug, or nil.
func Find(slug string) Handler {
for _, h := range registry {
if slices.Contains(h.Slugs(), slug) {
return h
}
}
return nil
}
50 changes: 50 additions & 0 deletions internal/service/task/native/sync_inventory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package native

import (
"context"
"fmt"
"time"

"sentinelgo/internal/config"
"sentinelgo/internal/osinfo"
"sentinelgo/internal/osinfo/shared"
agentsvc "sentinelgo/internal/service/agent"
"sentinelgo/internal/taskstore"
)

const syncInventoryTimeout = 90 * time.Second

type syncInventoryHandler struct{}

func init() { Register(&syncInventoryHandler{}) }

func (h *syncInventoryHandler) Slugs() []string {
return []string{"sync-inventory"}
}

func (h *syncInventoryHandler) Run(ctx context.Context, cfg *config.Config, _ taskstore.Task) (string, error) {
tctx, cancel := context.WithTimeout(ctx, syncInventoryTimeout)
defer cancel()

type collectResult struct{ info *shared.SystemInfo }
ch := make(chan collectResult, 1)
go func() { ch <- collectResult{osinfo.Collect()} }()

var sysInfo *shared.SystemInfo
select {
case <-tctx.Done():
return "", fmt.Errorf("sync-inventory: osinfo.Collect timed out after %v", syncInventoryTimeout)
case r := <-ch:
sysInfo = r.info
}

if sysInfo == nil {
return "", fmt.Errorf("sync-inventory: system info collection returned no data")
}

agentSvc := agentsvc.NewAgentService()
if err := agentSvc.UpdateAgentInfo(tctx, cfg, sysInfo); err != nil {
return "", fmt.Errorf("sync-inventory: %w", err)
}
return "inventory synced successfully", nil
}
Loading
Loading