From a008245db9bed12035e494edb71b280251c5cc15 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 10 Aug 2026 16:28:06 +0100 Subject: [PATCH] feat(actions): resolve containers by label and forward full requests - New ResolveContainer inspects a container by id, name, service label or image name (case-insensitive), falling back to label matching when Docker cannot resolve the reference. - The action proxy uses the org.dcntrlized.port label, falling back to the first mapped guest port, and no longer hardcodes the host port. - Action forwarding preserves the request method, body, query string and hop-by-hop-safe headers, and passes non-2xx status and body through. --- src/handlers/actions/actions.go | 73 +++++++++++++++++++++++++-------- src/lib/actions/actions.go | 7 +++- src/lib/docker/client.go | 40 ++++++++++++++++++ 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/handlers/actions/actions.go b/src/handlers/actions/actions.go index 1f1bf3f..ea54ae9 100644 --- a/src/handlers/actions/actions.go +++ b/src/handlers/actions/actions.go @@ -16,10 +16,12 @@ package actions import ( + "bytes" "context" "fmt" "io" "net/http" + "strings" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" @@ -39,7 +41,7 @@ func RunAction(w http.ResponseWriter, r *http.Request) { return } - inspectData, err := dockerClient.ContainerInspect(context.Background(), containerId) + inspectData, err := docker.ResolveContainer(context.Background(), dockerClient, containerId) if err != nil { http.Error(w, "Failed to inspect container", http.StatusInternalServerError) return @@ -51,13 +53,50 @@ func RunAction(w http.ResponseWriter, r *http.Request) { return } - // Construct the URL for the action endpoint - actionURL := fmt.Sprintf("http://%s:%d/action/%s", network.IPAddress.Address, network.Ports[0].Host, actionName) // Assuming network.Ports[0] is the main port. Might change in the future + // The service port can be declared via the org.dcntrlized.port label. Fall back to the + // first mapped guest port when it is not set. + port := inspectData.Config.Labels["org.dcntrlized.port"] + if port == "" && len(network.Ports) > 0 { + port = fmt.Sprintf("%d", network.Ports[0].Guest) + } + if port == "" { + http.Error(w, "Container has no configured service port", http.StatusInternalServerError) + return + } + + // Construct the URL for the action endpoint, preserving the query string + actionURL := fmt.Sprintf("http://%s:%s/action/%s", network.IPAddress.Address, port, actionName) + if r.URL.RawQuery != "" { + actionURL += "?" + r.URL.RawQuery + } + + // Read and replay the request body so it can be forwarded with the same method + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusInternalServerError) + return + } + + req, err := http.NewRequestWithContext(r.Context(), r.Method, actionURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, "Failed to build forward request", http.StatusInternalServerError) + return + } + + // Forward hop-by-hop-safe headers (e.g. Content-Type, X-Access-Token) + for header, values := range r.Header { + if strings.EqualFold(header, "Host") || strings.EqualFold(header, "Content-Length") { + continue + } + for _, value := range values { + req.Header.Add(header, value) + } + } // Forward the request to the container's action endpoint - resp, err := http.Get(actionURL) + resp, err := http.DefaultClient.Do(req) if err != nil { - logger.Debug("Failed to forward action request %s to container %s: %w", actionName, containerId, err) + logger.Debug("Failed to forward action request %s to container %s: %v", actionName, containerId, err) http.Error(w, "Failed to forward request to container", http.StatusInternalServerError) return } @@ -69,24 +108,26 @@ func RunAction(w http.ResponseWriter, r *http.Request) { logger.Debug("Failed to read container error response body: %v", err) } - responseBodyString := string(bodyBytes) - - if resp.StatusCode == 404 { - logger.Info("The requested action does not exist") - http.Error(w, "The requested action does not exist", http.StatusNotFound) - } - logger.Info("Container responded with non-2xx code") logger.Debug("Container: %s | Called URL: %s | Status: %d | Response: %s", - containerId, actionURL, resp.StatusCode, responseBodyString) - http.Error(w, "Container responded with non-2xx code", http.StatusInternalServerError) + containerId, actionURL, resp.StatusCode, string(bodyBytes)) + + if resp.StatusCode == http.StatusNotFound { + logger.Info("The requested action does not exist") + http.Error(w, "The requested action does not exist", http.StatusNotFound) + return + } + + // Pass the container's status and body through so callers can react to it + w.WriteHeader(resp.StatusCode) + w.Write(bodyBytes) return } + // Copy the response from the container back to the original response writer w.WriteHeader(resp.StatusCode) _, err = io.Copy(w, resp.Body) if err != nil { - http.Error(w, "Failed to read response from container", http.StatusInternalServerError) - return + logger.Debug("Failed to read response from container %s: %v", containerId, err) } } diff --git a/src/lib/actions/actions.go b/src/lib/actions/actions.go index 8c515f0..2463acc 100644 --- a/src/lib/actions/actions.go +++ b/src/lib/actions/actions.go @@ -44,7 +44,12 @@ func ActionProxy(container_id, action string, payload ActionProxyPayload) (Actio body := bytes.NewBuffer([]byte(payload.Data)) - req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], container.Network.Ports[0].Host, payload.Action), body) + port := "80" + if len(container.Network.Ports) > 0 { + port = fmt.Sprintf("%d", container.Network.Ports[0].Guest) + } + + req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], port, payload.Action), body) if err != nil { return ActionProxyResponse{}, err } diff --git a/src/lib/docker/client.go b/src/lib/docker/client.go index 4c44f98..6835e88 100644 --- a/src/lib/docker/client.go +++ b/src/lib/docker/client.go @@ -209,6 +209,46 @@ func ExtractNetworkInfo(ctx context.Context, dc *DockerClient, inspectData conta return netInfo, true, nil } +// ResolveContainer inspects the given container reference. It accepts a full or partial +// container ID, a container name, or a service/image name (matched case-insensitively). +func ResolveContainer(ctx context.Context, dc *DockerClient, ref string) (container.InspectResponse, error) { + inspectData, err := dc.Client.ContainerInspect(ctx, ref) + if err == nil { + return inspectData, nil + } + + // Docker only resolves IDs and container names, so fall back to matching the + // org.dcntrlized.service label or the image name when the reference isn't one. + containers, listErr := dc.Client.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs(filters.Arg("label", "org.dcntrlized.service")), + }) + if listErr != nil { + return container.InspectResponse{}, err + } + + refLower := strings.ToLower(ref) + var matches []container.Summary + for _, c := range containers { + if len(c.Names) == 0 { + continue + } + service := strings.ToLower(c.Labels["org.dcntrlized.service"]) + image := strings.ToLower(strings.SplitN(c.Image, ":", 2)[0]) + name := strings.ToLower(strings.TrimPrefix(c.Names[0], "/")) + if service == refLower || image == refLower || name == refLower { + matches = append(matches, c) + } + } + if len(matches) == 1 { + return dc.Client.ContainerInspect(ctx, matches[0].ID) + } + if len(matches) > 1 { + return container.InspectResponse{}, fmt.Errorf("multiple containers match %q", ref) + } + return container.InspectResponse{}, fmt.Errorf("container %q not found", ref) +} + // GetContainerMetadata collects the primary useful metadata for a given container. func GetContainerMetadata(ctx context.Context, dc *DockerClient, containerID string) (*ContainerMetadata, error) { inspectData, err := dc.Client.ContainerInspect(ctx, containerID)