diff --git a/manifest.json b/manifest.json index c47084e..d7a5cc0 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,8 @@ "name": "Lattice Bundle Reference Plugin", "type": "system", "capabilities": [ - "network:plan" + "network:plan", + "http:operator-target" ], "version": "0.2.1-alpha.3", "publisher": "latticenet", @@ -68,8 +69,19 @@ "scopes": [ "network:plan" ] + }, + { + "name": "probe_operator_target", + "effect": "read", + "scopes": [ + "network:plan" + ], + "operator_target_fields": [ + "base_url" + ] } - ] + ], + "backing": "runtime" } ] } diff --git a/system-go/main.go b/system-go/main.go index 4c58c31..33db839 100644 --- a/system-go/main.go +++ b/system-go/main.go @@ -4,8 +4,11 @@ import ( "bufio" "encoding/json" "fmt" + "io" + "net/url" "os" "sort" + "strconv" "strings" ) @@ -15,14 +18,27 @@ const ( pluginVersion = "0.2.1-alpha.3" ) -var interfaces = []string{"example.describe", "example.plan"} +var capabilities = []string{"network:plan", "http:operator-target"} +var interfaces = []string{ + "example.lattice-plugin/reference.describe", + "example.lattice-plugin/reference.plan", + "example.lattice-plugin/reference.probe_operator_target", +} var requiredScopes = []string{"network:plan"} type request struct { - Action string `json:"action"` - Service string `json:"service,omitempty"` - Method string `json:"method,omitempty"` - Payload map[string]any `json:"payload"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type callPayload struct { + Service string `json:"service"` + Method string `json:"method"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type operatorTargetRequest struct { + BaseURL string `json:"base_url"` } type response struct { @@ -33,20 +49,62 @@ type response struct { Error string `json:"error,omitempty"` } +type hostCallEnvelope struct { + HostCall hostCall `json:"host_call"` +} + +type hostCall struct { + ID string `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +type hostResponseEnvelope struct { + HostResponse hostResponse `json:"host_response"` +} + +type hostResponse struct { + ID string `json:"id"` + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` +} + func main() { scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + respScanner, closeResponses := hostResponseScanner() + defer closeResponses() + rt := &runtime{host: &stdioHostCaller{responses: respScanner, output: os.Stdout}} for scanner.Scan() { var req request if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { write(response{OK: false, Error: "invalid request: " + err.Error()}) continue } - write(handle(req)) + write(rt.handle(req)) } } +type runtime struct { + host hostCaller +} + +type hostCaller interface { + call(method string, params any) (json.RawMessage, error) +} + +type stdioHostCaller struct { + responses *bufio.Scanner + nextID int + output io.Writer +} + func handle(req request) response { + return (&runtime{}).handle(req) +} + +func (rt *runtime) handle(req request) response { switch req.Action { case "describe": body, _ := json.Marshal(describeBody()) @@ -54,30 +112,44 @@ func handle(req request) response { case "health": return response{OK: true, Message: "example plugin healthy"} case "plan": - return response{OK: true, Plan: renderPlan(req.Payload), Message: "dry-run plan generated"} + plan, err := renderPlan(req.Payload) + if err != nil { + return response{OK: false, Error: err.Error()} + } + return response{OK: true, Plan: plan, Message: "dry-run plan generated"} case "call": - return handleCall(req) + return rt.handleCall(req.Payload) default: return response{OK: false, Error: fmt.Sprintf("unsupported action %q", req.Action)} } } -func handleCall(req request) response { - if req.Service != "example.lattice-plugin/reference" { - return response{OK: false, Error: fmt.Sprintf("unsupported service %q", req.Service)} +func (rt *runtime) handleCall(payload json.RawMessage) response { + var call callPayload + if err := json.Unmarshal(payload, &call); err != nil { + return response{OK: false, Error: "invalid call payload: " + err.Error()} + } + if call.Service != pluginID+"/reference" { + return response{OK: false, Error: fmt.Sprintf("unsupported service %q", call.Service)} } - switch req.Method { + switch call.Method { case "describe": body, _ := json.Marshal(describeBody()) return response{OK: true, Result: body, Message: "describe result generated"} case "plan": + plan, err := renderPlan(call.Payload) + if err != nil { + return response{OK: false, Error: err.Error()} + } body, _ := json.Marshal(map[string]any{ - "plan": renderPlan(req.Payload), + "plan": plan, }) return response{OK: true, Result: body, Message: "plan result generated"} + case "probe_operator_target": + return rt.probeOperatorTarget(call.Payload) default: - return response{OK: false, Error: fmt.Sprintf("unsupported method %q", req.Method)} + return response{OK: false, Error: fmt.Sprintf("unsupported method %q", call.Method)} } } @@ -86,29 +158,171 @@ func describeBody() map[string]any { "id": pluginID, "name": pluginName, "version": pluginVersion, + "capabilities": capabilities, "interfaces": interfaces, "required_scopes": requiredScopes, "manages": []string{ "example deterministic dry-run plans", "self-contained bundle packaging and sandbox bridge patterns", + "host-routed operator target probes with no direct sockets", }, - "engine": "bundle v2 stdio-json-v1 system runtime", + "engine": "bundle v2 stdio-json-v1 system runtime with fd-3 host calls", } } -func renderPlan(payload map[string]any) string { +func (rt *runtime) probeOperatorTarget(payload json.RawMessage) response { + var req operatorTargetRequest + if err := json.Unmarshal(payload, &req); err != nil { + return response{OK: false, Error: "invalid operator target payload: " + err.Error()} + } + target, err := normalizeOperatorTargetURL(req.BaseURL) + if err != nil { + return response{OK: false, Error: err.Error()} + } + raw, err := rt.callHost("http.operator.do", map[string]any{ + "method": "GET", + "url": target, + }) + if err != nil { + return response{OK: false, Error: "operator target probe failed"} + } + var hostResp struct { + StatusCode int `json:"status_code"` + } + if err := json.Unmarshal(raw, &hostResp); err != nil { + return response{OK: false, Error: "decode operator target response: " + err.Error()} + } + body, _ := json.Marshal(map[string]any{ + "reachable": hostResp.StatusCode >= 200 && hostResp.StatusCode < 500, + "status_code": hostResp.StatusCode, + }) + return response{OK: true, Result: body, Message: "operator target probe complete"} +} + +func (rt *runtime) callHost(method string, params any) (json.RawMessage, error) { + if rt.host == nil { + return nil, fmt.Errorf("host response fd unavailable") + } + return rt.host.call(method, params) +} + +func (host *stdioHostCaller) call(method string, params any) (json.RawMessage, error) { + if host == nil || host.responses == nil || host.output == nil { + return nil, fmt.Errorf("host response fd unavailable") + } + host.nextID++ + id := fmt.Sprintf("h%d", host.nextID) + if err := json.NewEncoder(host.output).Encode(hostCallEnvelope{ + HostCall: hostCall{ID: id, Method: method, Params: params}, + }); err != nil { + return nil, fmt.Errorf("write host_call: %w", err) + } + if !host.responses.Scan() { + if err := host.responses.Err(); err != nil { + return nil, fmt.Errorf("read host_response: %w", err) + } + return nil, fmt.Errorf("read host_response: eof") + } + var env hostResponseEnvelope + if err := json.Unmarshal(host.responses.Bytes(), &env); err != nil { + return nil, fmt.Errorf("decode host_response: %w", err) + } + if env.HostResponse.ID != id { + return nil, fmt.Errorf("host_response id mismatch: got %q want %q", env.HostResponse.ID, id) + } + if !env.HostResponse.OK { + if env.HostResponse.Error == "" { + env.HostResponse.Error = "host call failed" + } + return nil, fmt.Errorf("%s: %s", method, env.HostResponse.Error) + } + return env.HostResponse.Result, nil +} + +func hostResponseScanner() (*bufio.Scanner, func()) { + fd := 3 + if raw := strings.TrimSpace(os.Getenv("LATTICE_HOST_RESPONSE_FD")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 3 { + return nil, func() {} + } + fd = parsed + } + file := os.NewFile(uintptr(fd), "lattice-host-response") + if file == nil { + return nil, func() {} + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + return scanner, func() { _ = file.Close() } +} + +func renderPlan(payload json.RawMessage) (string, error) { + values, err := decodeObjectPayload(payload) + if err != nil { + return "", err + } parts := []string{"# Example Lattice system plugin plan"} - keys := make([]string, 0, len(payload)) - for key := range payload { + keys := make([]string, 0, len(values)) + for key := range values { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { - value := payload[key] + value := values[key] parts = append(parts, fmt.Sprintf("# %s = %v", key, value)) } parts = append(parts, "# No host changes are made by this template.") - return strings.Join(parts, "\n") + return strings.Join(parts, "\n"), nil +} + +func decodeObjectPayload(payload json.RawMessage) (map[string]any, error) { + if len(strings.TrimSpace(string(payload))) == 0 { + return map[string]any{}, nil + } + var values map[string]any + if err := json.Unmarshal(payload, &values); err != nil { + return nil, fmt.Errorf("payload must be a JSON object: %w", err) + } + if values == nil { + return map[string]any{}, nil + } + return values, nil +} + +func normalizeOperatorTargetURL(value string) (string, error) { + raw := strings.TrimSpace(value) + if raw == "" { + return "", fmt.Errorf("base_url is required") + } + if len(raw) > 2048 || hasControl(raw) { + return "", fmt.Errorf("base_url must be printable and at most 2048 characters") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("base_url must be an absolute http(s) URL") + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + default: + return "", fmt.Errorf("base_url must use http or https") + } + if parsed.User != nil { + return "", fmt.Errorf("base_url must not include credentials") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("base_url must not include query or fragment") + } + return parsed.String(), nil +} + +func hasControl(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7f { + return true + } + } + return false } func write(resp response) { diff --git a/system-go/main_test.go b/system-go/main_test.go index 575c97b..0d122e5 100644 --- a/system-go/main_test.go +++ b/system-go/main_test.go @@ -1,36 +1,42 @@ package main import ( + "bufio" + "bytes" "encoding/json" + "errors" "os" "strings" "testing" ) type manifestContract struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Interfaces []struct { + Service string `json:"service"` + Backing string `json:"backing"` + Methods []struct { + Name string `json:"name"` + OperatorTargetFields []string `json:"operator_target_fields"` + } `json:"methods"` + } `json:"interfaces"` } func TestDescribeMatchesManifestContract(t *testing.T) { - raw, err := os.ReadFile("../manifest.json") - if err != nil { - t.Fatal(err) - } - var manifest manifestContract - if err := json.Unmarshal(raw, &manifest); err != nil { - t.Fatal(err) - } + manifest := readManifest(t) resp := handle(request{Action: "describe"}) if !resp.OK { t.Fatalf("describe ok = false, error = %q", resp.Error) } var body struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` } if err := json.Unmarshal(resp.Result, &body); err != nil { t.Fatal(err) @@ -44,6 +50,37 @@ func TestDescribeMatchesManifestContract(t *testing.T) { if body.Version != manifest.Version { t.Fatalf("describe version = %q, manifest version = %q", body.Version, manifest.Version) } + if !sameStrings(body.Capabilities, manifest.Capabilities) { + t.Fatalf("describe capabilities = %+v, manifest capabilities = %+v", body.Capabilities, manifest.Capabilities) + } +} + +func TestManifestDeclaresRuntimeBackingAndOperatorTargetBinding(t *testing.T) { + manifest := readManifest(t) + + if len(manifest.Interfaces) != 1 { + t.Fatalf("manifest interfaces = %d, want 1", len(manifest.Interfaces)) + } + if manifest.Interfaces[0].Backing != "runtime" { + t.Fatalf("manifest backing = %q, want runtime", manifest.Interfaces[0].Backing) + } + if !contains(manifest.Capabilities, "http:operator-target") { + t.Fatal("manifest must declare http:operator-target for the operator probe method") + } + + found := false + for _, method := range manifest.Interfaces[0].Methods { + if method.Name != "probe_operator_target" { + continue + } + found = true + if !sameStrings(method.OperatorTargetFields, []string{"base_url"}) { + t.Fatalf("operator target fields = %+v, want [base_url]", method.OperatorTargetFields) + } + } + if !found { + t.Fatal("manifest missing probe_operator_target method") + } } func TestHealthReportsReady(t *testing.T) { @@ -58,10 +95,13 @@ func TestHealthReportsReady(t *testing.T) { } func TestRenderPlanIsDeterministicAndNonMutating(t *testing.T) { - plan := renderPlan(map[string]any{ + plan, err := renderPlan(mustRaw(t, map[string]any{ "public_tcp": []any{80, 443}, "node_id": "node-a", - }) + })) + if err != nil { + t.Fatal(err) + } nodeAt := strings.Index(plan, "# node_id = node-a") tcpAt := strings.Index(plan, "# public_tcp = [80 443]") @@ -76,47 +116,111 @@ func TestRenderPlanIsDeterministicAndNonMutating(t *testing.T) { } } -func TestCallActionSupportsReferenceDescribeAndPlan(t *testing.T) { - describeResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/reference", - Method: "describe", - }) - if !describeResp.OK { - t.Fatalf("call describe ok = false, error = %q", describeResp.Error) +func TestCallActionSupportsManifestDeclaredRuntimeMethods(t *testing.T) { + manifest := readManifest(t) + host := &fakeHostCaller{responses: []json.RawMessage{json.RawMessage(`{"status_code":204}`)}} + rt := &runtime{host: host} + + for _, iface := range manifest.Interfaces { + if iface.Backing != "runtime" { + continue + } + for _, method := range iface.Methods { + payload := json.RawMessage(`{}`) + if method.Name == "plan" { + payload = mustRaw(t, map[string]any{ + "node_id": "node-a", + "public_tcp": []any{80, 443}, + }) + } + if method.Name == "probe_operator_target" { + payload = mustRaw(t, map[string]any{ + "base_url": "http://127.0.0.1:3000/health", + }) + } + resp := rt.handle(request{ + Action: "call", + Payload: mustRaw(t, callPayload{ + Service: iface.Service, + Method: method.Name, + Payload: payload, + }), + }) + if !resp.OK { + t.Fatalf("%s/%s ok = false, error = %q", iface.Service, method.Name, resp.Error) + } + } + } + + if len(host.calls) != 1 || host.calls[0].method != "http.operator.do" { + t.Fatalf("operator probe should use exactly one host call, got %+v", host.calls) } - var describeBody struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` + if got := host.calls[0].params["url"]; got != "http://127.0.0.1:3000/health" { + t.Fatalf("operator probe url = %v", got) } - if err := json.Unmarshal(describeResp.Result, &describeBody); err != nil { - t.Fatal(err) +} + +func TestOperatorTargetProbeFailsClosedBeforeHostCall(t *testing.T) { + host := &fakeHostCaller{} + rt := &runtime{host: host} + + resp := rt.handle(request{ + Action: "call", + Payload: mustRaw(t, callPayload{ + Service: pluginID + "/reference", + Method: "probe_operator_target", + Payload: mustRaw(t, map[string]any{"base_url": "file:///etc/passwd"}), + }), + }) + if resp.OK { + t.Fatal("invalid operator target returned ok=true") } - if describeBody.ID != pluginID || describeBody.Name != pluginName || describeBody.Version != pluginVersion { - t.Fatalf("unexpected describe body: %+v", describeBody) + if !strings.Contains(resp.Error, "absolute http(s) URL") { + t.Fatalf("unexpected error: %q", resp.Error) + } + if len(host.calls) != 0 { + t.Fatalf("invalid operator target reached host: %+v", host.calls) } - planResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/reference", - Method: "plan", - Payload: map[string]any{ - "node_id": "node-a", - "public_tcp": []any{80, 443}, - }, + secretURL := "http://127.0.0.1:3000/very-secret" + host = &fakeHostCaller{errs: []error{errors.New("dial " + secretURL + ": refused")}} + resp = (&runtime{host: host}).handle(request{ + Action: "call", + Payload: mustRaw(t, callPayload{ + Service: pluginID + "/reference", + Method: "probe_operator_target", + Payload: mustRaw(t, map[string]any{"base_url": secretURL}), + }), }) - if !planResp.OK { - t.Fatalf("call plan ok = false, error = %q", planResp.Error) + if resp.OK { + t.Fatal("host failure returned ok=true") } - var planBody struct { - Plan string `json:"plan"` + if strings.Contains(resp.Error, "very-secret") || resp.Error != "operator target probe failed" { + t.Fatalf("operator target error leaked host detail: %q", resp.Error) } - if err := json.Unmarshal(planResp.Result, &planBody); err != nil { +} + +func TestStdioHostCallerRoundTrip(t *testing.T) { + responses := bufio.NewScanner(strings.NewReader(`{"host_response":{"id":"h1","ok":true,"result":{"status_code":204}}}` + "\n")) + var output bytes.Buffer + host := &stdioHostCaller{responses: responses, output: &output} + + raw, err := host.call("http.operator.do", map[string]any{ + "method": "GET", + "url": "http://127.0.0.1:3000/health", + }) + if err != nil { t.Fatal(err) } - if !strings.Contains(planBody.Plan, "# node_id = node-a") { - t.Fatalf("plan result missing node id:\n%s", planBody.Plan) + if !bytes.Contains(raw, []byte(`"status_code":204`)) { + t.Fatalf("host result = %s, want status_code 204", raw) + } + var out hostCallEnvelope + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &out); err != nil { + t.Fatalf("decode host call %q: %v", output.String(), err) + } + if out.HostCall.ID != "h1" || out.HostCall.Method != "http.operator.do" { + t.Fatalf("host call envelope = %+v", out.HostCall) } } @@ -133,9 +237,11 @@ func TestUnsupportedActionFailsClosed(t *testing.T) { func TestCallActionFailsClosedForUnknownServiceOrMethod(t *testing.T) { serviceResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/other", - Method: "plan", + Action: "call", + Payload: mustRaw(t, callPayload{ + Service: "example.lattice-plugin/other", + Method: "plan", + }), }) if serviceResp.OK { t.Fatal("unknown service returned ok=true") @@ -145,9 +251,11 @@ func TestCallActionFailsClosedForUnknownServiceOrMethod(t *testing.T) { } methodResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/reference", - Method: "apply", + Action: "call", + Payload: mustRaw(t, callPayload{ + Service: "example.lattice-plugin/reference", + Method: "apply", + }), }) if methodResp.OK { t.Fatal("unknown method returned ok=true") @@ -156,3 +264,75 @@ func TestCallActionFailsClosedForUnknownServiceOrMethod(t *testing.T) { t.Fatalf("unexpected method error: %q", methodResp.Error) } } + +type recordedHostCall struct { + method string + params map[string]any +} + +type fakeHostCaller struct { + responses []json.RawMessage + errs []error + calls []recordedHostCall +} + +func (f *fakeHostCaller) call(method string, params any) (json.RawMessage, error) { + raw, _ := json.Marshal(params) + decoded := map[string]any{} + _ = json.Unmarshal(raw, &decoded) + f.calls = append(f.calls, recordedHostCall{method: method, params: decoded}) + if len(f.errs) > 0 { + err := f.errs[0] + f.errs = f.errs[1:] + return nil, err + } + if len(f.responses) == 0 { + return nil, errors.New("missing fake host response") + } + out := f.responses[0] + f.responses = f.responses[1:] + return out, nil +} + +func readManifest(t *testing.T) manifestContract { + t.Helper() + raw, err := os.ReadFile("../manifest.json") + if err != nil { + t.Fatal(err) + } + var manifest manifestContract + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + return manifest +} + +func mustRaw(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +}