package edgeconfig import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "strings" "sync" "time" ) // reloadPath is the endpoint the nginx-side handler serves. The handler // (baked into the nginx image) does: content check → nginx -t → nginx -s // reload, and is idempotent — reloading an unchanged config is a no-op. const reloadPath = "/reload" // HTTPReloader fans reloads out to a fixed list of nginx instances by // host:port. This is the dev/compose provider (Docker embedded DNS names) // and the transport KubeReloader uses per discovered pod. type HTTPReloader struct { client *http.Client targets []string // host:port list, e.g. ["nginx:8081"] } // NewHTTPReloader targets is a comma-separated host:port list // (EDGE_RELOAD_TARGETS=nginx:8081 or "10.0.0.4:8081,10.0.0.5:8081"). func NewHTTPReloader(targets string) (*HTTPReloader, error) { var out []string for _, t := range strings.Split(targets, ",") { t = strings.TrimSpace(t) if t == "" { continue } if _, _, err := net.SplitHostPort(t); err != nil { return nil, fmt.Errorf("edgeconfig: bad target %q: %w", t, err) } out = append(out, t) } if len(out) == 0 { return nil, fmt.Errorf("edgeconfig: no reload targets configured") } return &HTTPReloader{ client: &http.Client{Timeout: 10 * time.Second}, targets: out, }, nil } // Reload posts to every target concurrently and aggregates results. // A target that fails validation (nginx -t) returns its error in the // result; the caller decides on retry/alert. Never panics on partial // failure — partial success is reported, not swallowed. func (h *HTTPReloader) Reload(ctx context.Context) Result { res := Result{Targets: make([]TargetResult, len(h.targets))} var wg sync.WaitGroup for i, t := range h.targets { wg.Add(1) go func(i int, target string) { defer wg.Done() res.Targets[i] = TargetResult{Target: target, Err: h.post(ctx, target)} }(i, t) } wg.Wait() return res } func (h *HTTPReloader) post(ctx context.Context, target string) error { url := "http://" + target + reloadPath req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) if err != nil { return err } resp, err := h.client.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) if resp.StatusCode != http.StatusOK { return fmt.Errorf("reload failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } return nil } // NullReloader succeeds without doing anything — tests and deployments // where the static floor config is sufficient. type NullReloader struct{} func (NullReloader) Reload(context.Context) Result { return Result{} } // HandlerResult is the JSON body the nginx-side handler replies with; // shared here so producers can decode it in tests/integrations. type HandlerResult struct { Reloaded bool `json:"reloaded"` // false = content unchanged, no-op Checksum uint32 `json:"checksum"` // adler32 of current fragment set Message string `json:"message,omitempty"` // validation error detail on failure } // PostReload is a helper for tests and the kube provider's per-pod call: // one POST to target, decoding the handler's JSON reply. func PostReload(ctx context.Context, client *http.Client, target string) (HandlerResult, error) { if client == nil { client = &http.Client{Timeout: 10 * time.Second} } req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+target+reloadPath, bytes.NewReader(nil)) if err != nil { return HandlerResult{}, err } resp, err := client.Do(req) if err != nil { return HandlerResult{}, err } defer resp.Body.Close() var hr HandlerResult if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<10)).Decode(&hr); err != nil { return HandlerResult{}, fmt.Errorf("decode reload reply (%s): %w", resp.Status, err) } if resp.StatusCode != http.StatusOK { return hr, fmt.Errorf("reload failed: %s: %s", resp.Status, hr.Message) } return hr, nil }