feat: add volume, images and networks

This commit is contained in:
2026-05-18 19:30:33 +02:00
parent 4f53aefb6e
commit bd3121d688
10 changed files with 1221 additions and 208 deletions

View File

@ -559,6 +559,188 @@ func TestDeleteAgent_NonExistent(t *testing.T) {
}
}
// ── ListImages ────────────────────────────────────────────────────────────────
func TestListImages_Empty(t *testing.T) {
h, _, _, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
w := httptest.NewRecorder()
h.ListImages(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []map[string]any
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 0 {
t.Errorf("expected empty list, got %d", len(out))
}
}
func TestListImages_WithData(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
reg.UpdateResources("a1",
nil,
[]*agentv1.ImageInfo{
{Id: "sha256:abc", Tags: []string{"nginx:latest"}, Size: 50000000, CreatedAt: 1700000000},
{Id: "sha256:def", Tags: []string{"redis:7"}, Size: 30000000, CreatedAt: 1700000001},
},
nil,
nil,
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
w := httptest.NewRecorder()
h.ListImages(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
ID string `json:"id"`
Tags []string `json:"tags"`
Size int64 `json:"size"`
CreatedAt int64 `json:"created_at"`
}
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 2 {
t.Fatalf("expected 2 images, got %d", len(out))
}
if out[0].AgentID != "a1" {
t.Errorf("expected agent_id 'a1', got %q", out[0].AgentID)
}
if out[0].ID != "sha256:abc" && out[1].ID != "sha256:abc" {
t.Error("expected sha256:abc in results")
}
}
// ── ListVolumes ───────────────────────────────────────────────────────────────
func TestListVolumes_Empty(t *testing.T) {
h, _, _, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
w := httptest.NewRecorder()
h.ListVolumes(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []map[string]any
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 0 {
t.Errorf("expected empty list, got %d", len(out))
}
}
func TestListVolumes_WithData(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
reg.UpdateResources("a1",
nil,
nil,
[]*agentv1.VolumeInfo{
{Name: "data", Driver: "local", Mountpoint: "/var/lib/docker/volumes/data/_data"},
{Name: "cache", Driver: "local", Mountpoint: "/var/lib/docker/volumes/cache/_data"},
},
nil,
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
w := httptest.NewRecorder()
h.ListVolumes(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
}
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 2 {
t.Fatalf("expected 2 volumes, got %d", len(out))
}
names := map[string]bool{out[0].Name: true, out[1].Name: true}
if !names["data"] || !names["cache"] {
t.Errorf("expected volumes 'data' and 'cache', got %v", names)
}
}
// ── ListNetworks ──────────────────────────────────────────────────────────────
func TestListNetworks_Empty(t *testing.T) {
h, _, _, _ := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
w := httptest.NewRecorder()
h.ListNetworks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []map[string]any
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 0 {
t.Errorf("expected empty list, got %d", len(out))
}
}
func TestListNetworks_WithData(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
reg.UpdateResources("a1",
nil,
nil,
nil,
[]*agentv1.NetworkInfo{
{Id: "net1", Name: "bridge", Driver: "bridge", Scope: "local"},
{Id: "net2", Name: "host", Driver: "host", Scope: "local"},
},
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
w := httptest.NewRecorder()
h.ListNetworks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []struct {
AgentID string `json:"agent_id"`
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Scope string `json:"scope"`
}
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 2 {
t.Fatalf("expected 2 networks, got %d", len(out))
}
ids := map[string]bool{out[0].ID: true, out[1].ID: true}
if !ids["net1"] || !ids["net2"] {
t.Errorf("expected net1 and net2, got %v", ids)
}
}
// ── ContainerAction ───────────────────────────────────────────────────────────
func TestContainerAction_AgentNotConnected(t *testing.T) {

View File

@ -178,6 +178,91 @@ func (h *Handler) ContainerAction(w http.ResponseWriter, r *http.Request) {
jsonOK(w, map[string]string{"command_id": cmdID})
}
// ── Images ────────────────────────────────────────────────────────────────────
func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
type imageDTO struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Alias string `json:"alias"`
ID string `json:"id"`
Tags []string `json:"tags"`
Size int64 `json:"size"`
CreatedAt int64 `json:"created_at"`
}
var out []imageDTO
for _, agent := range h.registry.List() {
for _, img := range agent.Images {
out = append(out, imageDTO{
AgentID: agent.ID,
Hostname: agent.Hostname,
Alias: agent.Alias,
ID: img.GetId(),
Tags: img.GetTags(),
Size: img.GetSize(),
CreatedAt: img.GetCreatedAt(),
})
}
}
jsonOK(w, out)
}
// ── Volumes ───────────────────────────────────────────────────────────────────
func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
type volumeDTO struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Alias string `json:"alias"`
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
}
var out []volumeDTO
for _, agent := range h.registry.List() {
for _, vol := range agent.Volumes {
out = append(out, volumeDTO{
AgentID: agent.ID,
Hostname: agent.Hostname,
Alias: agent.Alias,
Name: vol.GetName(),
Driver: vol.GetDriver(),
Mountpoint: vol.GetMountpoint(),
})
}
}
jsonOK(w, out)
}
// ── Networks ──────────────────────────────────────────────────────────────────
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
type networkDTO struct {
AgentID string `json:"agent_id"`
Hostname string `json:"hostname"`
Alias string `json:"alias"`
ID string `json:"id"`
Name string `json:"name"`
Driver string `json:"driver"`
Scope string `json:"scope"`
}
var out []networkDTO
for _, agent := range h.registry.List() {
for _, net := range agent.Networks {
out = append(out, networkDTO{
AgentID: agent.ID,
Hostname: agent.Hostname,
Alias: agent.Alias,
ID: net.GetId(),
Name: net.GetName(),
Driver: net.GetDriver(),
Scope: net.GetScope(),
})
}
}
jsonOK(w, out)
}
// ── Agent token provisioning ──────────────────────────────────────────────────
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {

View File

@ -46,6 +46,9 @@ func NewRouter(h *Handler) http.Handler {
r.Patch("/agents/{agentID}", h.UpdateAgent)
r.Delete("/agents/{agentID}", h.DeleteAgent)
r.Get("/containers", h.ListContainers)
r.Get("/images", h.ListImages)
r.Get("/volumes", h.ListVolumes)
r.Get("/networks", h.ListNetworks)
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
r.Get("/events", h.EventsWS)

View File

@ -108,12 +108,21 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error {
switch p := msg.Payload.(type) {
case *agentv1.AgentMessage_Snapshot:
g.registry.UpdateContainers(agentID, p.Snapshot.Containers)
g.registry.UpdateResources(agentID, p.Snapshot.Containers, p.Snapshot.Images, p.Snapshot.Volumes, p.Snapshot.Networks)
g.broker.Publish(broker.Event{
Type: "containers.updated",
AgentID: agentID,
Payload: p.Snapshot.Containers,
})
g.broker.Publish(broker.Event{
Type: "resources.updated",
AgentID: agentID,
Payload: map[string]any{
"images": p.Snapshot.Images,
"volumes": p.Snapshot.Volumes,
"networks": p.Snapshot.Networks,
},
})
case *agentv1.AgentMessage_Result:
g.broker.Publish(broker.Event{

View File

@ -16,6 +16,9 @@ type AgentState struct {
OS string
LastSeenAt time.Time
Containers []*agentv1.ContainerInfo
Images []*agentv1.ImageInfo
Volumes []*agentv1.VolumeInfo
Networks []*agentv1.NetworkInfo
cmdCh chan *agentv1.ServerMessage
}
@ -80,6 +83,18 @@ func (r *Registry) UpdateContainers(id string, containers []*agentv1.ContainerIn
}
}
func (r *Registry) UpdateResources(id string, containers []*agentv1.ContainerInfo, images []*agentv1.ImageInfo, volumes []*agentv1.VolumeInfo, networks []*agentv1.NetworkInfo) {
r.mu.Lock()
defer r.mu.Unlock()
if s, ok := r.agents[id]; ok {
s.Containers = containers
s.Images = images
s.Volumes = volumes
s.Networks = networks
s.LastSeenAt = time.Now()
}
}
// UpdateAlias refreshes the alias for a live agent (called after an admin update).
func (r *Registry) UpdateAlias(id, alias string) {
r.mu.Lock()