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
2 changes: 1 addition & 1 deletion .github/workflows/web-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11
version: 11.20.0

- name: Set up Node
uses: actions/setup-node@v6
Expand Down
3 changes: 2 additions & 1 deletion AGENT.md → AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ An open container deployment platform. See README.md for architecture.

- Web tests: `cd web && pnpm test`
- Web typecheck: `cd web && ./node_modules/.bin/tsc --noEmit`
- Web lint/format: `cd web && npx biome check --write <files>`
- Web lint: `cd web && pnpm lint`
- Web format: `cd web && pnpm exec oxfmt --write <files>`
- Go (agent/cli): `go build ./...`, `go test ./...`, `gofmt -l .`
- After deleting or renaming a Next.js route, stale generated types in
`web/.next/types` can fail the typecheck — delete them; they regenerate.
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md

This file was deleted.

1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
4 changes: 2 additions & 2 deletions agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The agent supports two modes:

### All Nodes
- WireGuard (`wg` and `wg-quick` commands)
- Podman
- Podman 4.8 or newer (required for command execution cleanup)
- BuildKit + buildctl
- Railpack

Expand Down Expand Up @@ -226,7 +226,7 @@ WantedBy=multi-user.target
```

`KillMode=process` ensures only the agent process is killed on restart, not container processes.
The rootful Podman API socket at `/run/podman/podman.sock` is required for container metrics collection.
The rootful Podman API socket at `/run/podman/podman.sock` is required for container metrics collection and command execution. Command timeouts force-remove the foreground exec session; deliberately backgrounded descendants are not guaranteed to be removed.

```bash
sudo systemctl daemon-reload
Expand Down
18 changes: 18 additions & 0 deletions agent/internal/agent/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"time"
"unicode/utf8"

"techulus/cloud-agent/internal/build"
"techulus/cloud-agent/internal/container"
Expand All @@ -19,6 +20,23 @@ import (
"techulus/cloud-agent/internal/registryauth"
)

func (a *Agent) ProcessCommand(item agenthttp.WorkQueueItem) (container.CommandResult, error) {
var payload struct {
CommandRunID string `json:"commandRunId"`
ServiceID string `json:"serviceId"`
DeploymentID string `json:"deploymentId"`
ContainerID string `json:"containerId"`
Command string `json:"command"`
}
if err := json.Unmarshal([]byte(item.Payload), &payload); err != nil {
return container.CommandResult{}, fmt.Errorf("failed to parse command payload: %w", err)
}
if payload.CommandRunID != item.ID || payload.ServiceID == "" || payload.DeploymentID == "" || payload.ContainerID == "" || payload.Command == "" || utf8.RuneCountInString(payload.Command) > 4096 {
return container.CommandResult{}, fmt.Errorf("invalid command payload")
}
return container.ExecCommand(payload.ContainerID, payload.ServiceID, payload.DeploymentID, payload.Command)
}

func (a *Agent) ProcessRestart(item agenthttp.WorkQueueItem) error {
var payload struct {
DeploymentID string `json:"deploymentId"`
Expand Down
26 changes: 26 additions & 0 deletions agent/internal/agent/handlers_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package agent

import (
"testing"

agenthttp "techulus/cloud-agent/internal/http"
)

func TestProcessCommandRequiresOwnershipIdentifiers(t *testing.T) {
tests := []struct {
name string
payload string
}{
{"service ID", `{"commandRunId":"run","deploymentId":"deployment","containerId":"container","command":"true"}`},
{"deployment ID", `{"commandRunId":"run","serviceId":"service","containerId":"container","command":"true"}`},
{"container ID", `{"commandRunId":"run","serviceId":"service","deploymentId":"deployment","command":"true"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Agent{}).ProcessCommand(agenthttp.WorkQueueItem{ID: "run", Payload: tt.payload})
if err == nil || err.Error() != "invalid command payload" {
t.Fatalf("expected invalid payload for missing %s, got %v", tt.name, err)
}
})
}
}
35 changes: 32 additions & 3 deletions agent/internal/agent/workqueue.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"time"

"techulus/cloud-agent/internal/container"
agenthttp "techulus/cloud-agent/internal/http"
)

Expand Down Expand Up @@ -93,7 +94,19 @@ func (a *Agent) processLeasedWorkItem(item agenthttp.WorkQueueItem) {
status := "completed"
errorMsg := ""
restartAfterReport := false
if err := a.ProcessWorkItem(item); err != nil {
var commandResult *container.CommandResult
var processErr error
if item.Type == "command" {
result, err := a.ProcessCommand(item)
processErr = err
if err == nil {
commandResult = &result
}
} else {
processErr = a.ProcessWorkItem(item)
}
if processErr != nil {
err := processErr
if errors.Is(err, errAgentUpgradeRestartNeeded) {
restartAfterReport = true
} else {
Expand All @@ -109,12 +122,28 @@ func (a *Agent) processLeasedWorkItem(item agenthttp.WorkQueueItem) {
if !restartAfterReport && a.activeWorkItem != nil && a.activeWorkItem.ID == item.ID && a.activeWorkItem.Attempt == item.Attempt {
a.activeWorkItem = nil
}
a.pendingWorkResults = append(a.pendingWorkResults, agenthttp.CompletedWorkItem{
completed := agenthttp.CompletedWorkItem{
ID: item.ID,
Attempt: item.Attempt,
Status: status,
Error: errorMsg,
})
}
if commandResult != nil {
completed.Result = agenthttp.CommandWorkItemResult{
Type: "command",
Output: commandResult.Output,
ExitCode: &commandResult.ExitCode,
OutputTruncated: commandResult.Truncated,
TimedOut: commandResult.TimedOut,
}
if commandResult.TimedOut {
completed.Status = "failed"
completed.Error = "command timed out after 60 seconds"
} else if commandResult.ExitCode != 0 {
completed.Status = "failed"
}
}
a.pendingWorkResults = append(a.pendingWorkResults, completed)
a.workMutex.Unlock()

a.RequestStatusReport("work item " + status)
Expand Down
Loading
Loading