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.
This commit is contained in:
@@ -16,10 +16,12 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
@@ -39,7 +41,7 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
inspectData, err := dockerClient.ContainerInspect(context.Background(), containerId)
|
inspectData, err := docker.ResolveContainer(context.Background(), dockerClient, containerId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
|
http.Error(w, "Failed to inspect container", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -51,13 +53,50 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the URL for the action endpoint
|
// The service port can be declared via the org.dcntrlized.port label. Fall back to the
|
||||||
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
|
// 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
|
// Forward the request to the container's action endpoint
|
||||||
resp, err := http.Get(actionURL)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Failed to forward request to container", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -69,24 +108,26 @@ func RunAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
logger.Debug("Failed to read container error response body: %v", err)
|
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.Info("Container responded with non-2xx code")
|
||||||
logger.Debug("Container: %s | Called URL: %s | Status: %d | Response: %s",
|
logger.Debug("Container: %s | Called URL: %s | Status: %d | Response: %s",
|
||||||
containerId, actionURL, resp.StatusCode, responseBodyString)
|
containerId, actionURL, resp.StatusCode, string(bodyBytes))
|
||||||
http.Error(w, "Container responded with non-2xx code", http.StatusInternalServerError)
|
|
||||||
|
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
|
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
|
// Copy the response from the container back to the original response writer
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
_, err = io.Copy(w, resp.Body)
|
_, err = io.Copy(w, resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to read response from container", http.StatusInternalServerError)
|
logger.Debug("Failed to read response from container %s: %v", containerId, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ func ActionProxy(container_id, action string, payload ActionProxyPayload) (Actio
|
|||||||
|
|
||||||
body := bytes.NewBuffer([]byte(payload.Data))
|
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 {
|
if err != nil {
|
||||||
return ActionProxyResponse{}, err
|
return ActionProxyResponse{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,46 @@ func ExtractNetworkInfo(ctx context.Context, dc *DockerClient, inspectData conta
|
|||||||
return netInfo, true, nil
|
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.
|
// GetContainerMetadata collects the primary useful metadata for a given container.
|
||||||
func GetContainerMetadata(ctx context.Context, dc *DockerClient, containerID string) (*ContainerMetadata, error) {
|
func GetContainerMetadata(ctx context.Context, dc *DockerClient, containerID string) (*ContainerMetadata, error) {
|
||||||
inspectData, err := dc.Client.ContainerInspect(ctx, containerID)
|
inspectData, err := dc.Client.ContainerInspect(ctx, containerID)
|
||||||
|
|||||||
Reference in New Issue
Block a user