diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a4be505 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# syntax=docker/dockerfile:1 + +# We use the latest Go 1.x version unless asked to use something else. +# The GitHub Actions CI job sets this argument for a consistent Go version. +ARG GO_VERSION=1 + +# Setup the base environment. The BUILDPLATFORM is set automatically by Docker. +# The --platform=${BUILDPLATFORM} flag tells Docker to build the function using +# the OS and architecture of the host running the build, not the OS and +# architecture that we're building the function for. +FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS build + +WORKDIR /inspector + +# We don't want or need CGo support, so we disable it. +ENV CGO_ENABLED=0 + +# We run go mod download in a separate step so that we can cache its results. +# This lets us avoid re-downloading modules if we don't need to. The type=target +# mount tells Docker to mount the current directory read-only in the WORKDIR. +# The type=cache mount tells Docker to cache the Go modules cache across builds. +RUN --mount=target=. --mount=type=cache,target=/go/pkg/mod go mod download + +# The TARGETOS and TARGETARCH args are set by docker. We set GOOS and GOARCH to +# these values to ask Go to compile a binary for these architectures. If +# TARGETOS and TARGETOS are different from BUILDPLATFORM, Go will cross compile +# for us (e.g. compile a linux/amd64 binary on a linux/arm64 build machine). +ARG TARGETOS +ARG TARGETARCH + +# Build the main binary. The type=target mount tells Docker to mount the +# current directory read-only in the WORKDIR. The type=cache mount tells Docker +# to cache the Go modules cache across builds. +RUN --mount=target=. \ + --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /inspector-sidecar . + +# Produce the Function image. We use a very lightweight 'distroless' image that +# does not include any of the build tools used in previous stages. +FROM gcr.io/distroless/static-debian12:nonroot AS image +WORKDIR / +COPY --from=build /inspector-sidecar /inspector-sidecar +USER nonroot:nonroot +ENTRYPOINT ["/inspector-sidecar"] diff --git a/README.md b/README.md index 5094e8c..cec4f01 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,37 @@ # Pipeline Inspector Sidecar -This repo is a small and streamlined implementation of a sidecar container for Crossplane, -capturing Functions' Requests and Responses and printing them to the pod logs. +A minimal reference implementation of a sidecar container for Crossplane that captures +function pipeline execution data (requests and responses) and logs them to stdout. The full design of this feature can be found in the [design doc](https://github.com/crossplane/crossplane/blob/main/design/one-pager-pipeline-inspector.md). +## Features + +- Captures `RunFunctionRequest` and `RunFunctionResponse` data for each function invocation +- Supports JSON and human-readable text output formats +- Correlates pipeline steps using trace IDs, span IDs, and step indices +- Runs as a non-root user in a minimal distroless container + +## CLI Flags + +| Flag | Environment Variable | Default | Description | +|------|---------------------|---------|-------------| +| `--socket-path` | `PIPELINE_INSPECTOR_SOCKET` | `/var/run/pipeline-inspector/socket` | Unix socket path to listen on | +| `--format` | - | `json` | Output format (`json` or `text`) | +| `--max-recv-msg-size` | `MAX_RECV_MSG_SIZE` | `4194304` (4MB) | Maximum gRPC receive message size in bytes | +| `--shutdown-timeout` | `SHUTDOWN_TIMEOUT` | `5s` | Graceful shutdown timeout | + ## Usage This repository publishes release images to -`xpkg.crossplane.io/crossplane/inspector-sidecar`. This image can then be -included as a sidecar container for Crossplane through the Helm chart's -values. +`xpkg.crossplane.io/crossplane/inspector-sidecar`. This image can be +included as a sidecar container for Crossplane through the Helm chart's values. ```yaml # Example: # helm upgrade --install crossplane crossplane/crossplane \ # -n crossplane-system --create-namespace \ -# -f pipeline-inspector-values.yaml \ -# --set image.tag=v0.0.0-hack +# -f pipeline-inspector-values.yaml args: - --enable-pipeline-inspector @@ -33,9 +47,11 @@ extraVolumeMountsCrossplane: sidecarsCrossplane: - name: pipeline-inspector - image: crossplane/inspector-sidecar - command: - - /usr/local/bin/pipeline-inspector + image: xpkg.crossplane.io/crossplane/inspector-sidecar:latest + args: + - --format=json + # Increase if your function payloads exceed 4MB + # - --max-recv-msg-size=8388608 volumeMounts: - name: pipeline-inspector-socket mountPath: /var/run/pipeline-inspector @@ -46,14 +62,47 @@ sidecarsCrossplane: limits: cpu: 100m memory: 128Mi +``` +## Output Formats + +### JSON Format (default) + +```json +{"meta":{"compositeResourceApiVersion":"example.org/v1","compositeResourceKind":"XDatabase","compositeResourceName":"my-db","compositeResourceNamespace":"default","compositeResourceUid":"abc-123","compositionName":"my-composition","functionName":"function-patch-and-transform","iteration":0,"spanId":"span-456","stepIndex":0,"timestamp":"2026-01-15T10:30:00Z","traceId":"trace-789"},"payload":{...},"type":"REQUEST"} ``` -When this container starts up, it starts a gRPC server that listens on a unix -domain socket at the default path of `/var/run/pipeline-inspector/socket`, -which Crossplane is going to send RunFunctionRequests and RunFunctionResponses -from Functions. +### Text Format -The gRPC server implementation in this repo accepts incoming payloads -and simply writes them to `stdout` so they will be included in the provider -pod's logs. +Use `--format=text` for human-readable output: + +``` +=== REQUEST === + XR: example.org/v1/XDatabase (my-db) + XR UID: abc-123 + XR NS: default + Composition: my-composition + Function: function-patch-and-transform (step 0, iteration 0) + Trace ID: trace-789 + Span ID: span-456 + Timestamp: 2026-01-15T10:30:00.000Z + Payload: + apiVersion: apiextensions.crossplane.io/v1 + ... +``` + +## Building + +```bash +# Build locally +go build -o inspector-sidecar . + +# Build Docker image +docker build -t inspector-sidecar . +``` + +## Testing + +```bash +go test ./... +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cc8fc70 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/crossplane/inspector-sidecar + +go 1.24.9 + +require ( + github.com/alecthomas/kong v1.10.0 + github.com/crossplane/crossplane-runtime/v2 v2.2.0-rc.0.0.20260127103424-627736f1b9f1 + github.com/go-logr/zapr v1.3.0 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.75.1 + google.golang.org/protobuf v1.36.11 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + github.com/go-logr/logr v1.4.3 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + k8s.io/klog/v2 v2.130.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3a5577 --- /dev/null +++ b/go.sum @@ -0,0 +1,72 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.10.0 h1:8K4rGDpT7Iu+jEXCIJUeKqvpwZHbsFRoebLbnzlmrpw= +github.com/alecthomas/kong v1.10.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/crossplane/crossplane-runtime/v2 v2.2.0-rc.0.0.20260127103424-627736f1b9f1 h1:y5C/HJq9SXLYynEh4ZK6Tf7tDrsJNS+a5Ysjg9Eyc2I= +github.com/crossplane/crossplane-runtime/v2 v2.2.0-rc.0.0.20260127103424-627736f1b9f1/go.mod h1:WVVus9FBbAVjAmFxrOGDdZBFuUv9TqR916JmVl3PVRk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..877548f --- /dev/null +++ b/main.go @@ -0,0 +1,136 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +*/ + +// Package main implements a reference Pipeline Inspector sidecar that logs +// function pipeline execution data to stdout. +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "syscall" + "time" + + "github.com/alecthomas/kong" + "github.com/go-logr/zapr" + "go.uber.org/zap" + "google.golang.org/grpc" + + pipelinev1alpha1 "github.com/crossplane/crossplane-runtime/v2/apis/pipelineinspector/proto/v1alpha1" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/inspector-sidecar/server" +) + +// CLI arguments. +type CLI struct { + Debug bool `help:"Emit debug logs in addition to info logs." short:"d"` + SocketPath string `default:"/var/run/pipeline-inspector/socket" env:"PIPELINE_INSPECTOR_SOCKET" help:"Unix socket path to listen on."` + Format string `default:"json" enum:"json,text" help:"Output format (json or text)."` + MaxRecvMsgSize int `default:"4194304" env:"MAX_RECV_MSG_SIZE" help:"Maximum gRPC receive message size in bytes (default 4MB)."` + ShutdownTimeout time.Duration `default:"5s" env:"SHUTDOWN_TIMEOUT" help:"Graceful shutdown timeout."` +} + +func main() { + var cli CLI + kong.Parse(&cli) + + if err := run(cli); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run(cli CLI) error { + // Create logger. + log, err := newLogger(cli.Debug) + if err != nil { + return fmt.Errorf("cannot create logger: %w", err) + } + + // Remove existing socket file if it exists. + if err := os.Remove(cli.SocketPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove existing socket: %w", err) + } + + // Listen on Unix socket. + lc := net.ListenConfig{} + listener, err := lc.Listen(context.Background(), "unix", cli.SocketPath) + if err != nil { + return fmt.Errorf("cannot listen on socket: %w", err) + } + defer func() { _ = listener.Close() }() + + log.Info("Pipeline Inspector listening", "socket", cli.SocketPath, "format", cli.Format) + + // Create gRPC server. + grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(cli.MaxRecvMsgSize)) + inspector := server.NewInspector(cli.Format, server.WithLogger(log)) + pipelinev1alpha1.RegisterPipelineInspectorServiceServer(grpcServer, inspector) + + // Handle shutdown signals. + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + go func() { + <-ctx.Done() + log.Info("Shutting down") + + // Create a timeout context for graceful shutdown. + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cli.ShutdownTimeout) + defer shutdownCancel() + + // Try graceful shutdown first. + stopped := make(chan struct{}) + go func() { + grpcServer.GracefulStop() + close(stopped) + }() + + select { + case <-shutdownCtx.Done(): + log.Info("Graceful shutdown timed out, forcing stop") + grpcServer.Stop() + case <-stopped: + // Graceful shutdown completed. + } + }() + + // Serve requests. + if err := grpcServer.Serve(listener); err != nil { + return fmt.Errorf("server error: %w", err) + } + + return nil +} + +// newLogger creates a new logger based on the debug flag. +func newLogger(debug bool) (logging.Logger, error) { + var zl *zap.Logger + var err error + + if debug { + zl, err = zap.NewDevelopment() + } else { + zl, err = zap.NewProduction() + } + if err != nil { + return nil, err + } + + return logging.NewLogrLogger(zapr.NewLogger(zl)), nil +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..2b1be99 --- /dev/null +++ b/server/server.go @@ -0,0 +1,176 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +*/ + +// Package server implements a gRPC server for the Pipeline Inspector. +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + "sigs.k8s.io/yaml" + + pipelinev1alpha1 "github.com/crossplane/crossplane-runtime/v2/apis/pipelineinspector/proto/v1alpha1" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +// Inspector implements the PipelineInspectorService by logging to a writer. +type Inspector struct { + pipelinev1alpha1.UnimplementedPipelineInspectorServiceServer + + format string + out io.Writer + log logging.Logger +} + +// Option configures an Inspector. +type Option func(*Inspector) + +// WithOutput sets the output writer (default: os.Stdout). +func WithOutput(w io.Writer) Option { + return func(i *Inspector) { + i.out = w + } +} + +// WithLogger sets the logger for the Inspector. +func WithLogger(l logging.Logger) Option { + return func(i *Inspector) { + i.log = l + } +} + +// NewInspector creates a new Inspector with the given output format. +func NewInspector(format string, opts ...Option) *Inspector { + i := &Inspector{ + format: format, + out: os.Stdout, + log: logging.NewNopLogger(), + } + for _, opt := range opts { + opt(i) + } + return i +} + +// EmitRequest logs the function request before execution. +func (i *Inspector) EmitRequest(_ context.Context, req *pipelinev1alpha1.EmitRequestRequest) (*pipelinev1alpha1.EmitRequestResponse, error) { + // Decode JSON payload from bytes. + payload := decodeJSONPayload(req.GetRequest()) + i.logEvent("REQUEST", req.GetMeta(), payload, "") + return &pipelinev1alpha1.EmitRequestResponse{}, nil +} + +// EmitResponse logs the function response after execution. +func (i *Inspector) EmitResponse(_ context.Context, req *pipelinev1alpha1.EmitResponseRequest) (*pipelinev1alpha1.EmitResponseResponse, error) { + // Decode JSON payload from bytes. + payload := decodeJSONPayload(req.GetResponse()) + i.logEvent("RESPONSE", req.GetMeta(), payload, req.GetError()) + return &pipelinev1alpha1.EmitResponseResponse{}, nil +} + +// decodeJSONPayload decodes JSON bytes into a map for display. +func decodeJSONPayload(data []byte) any { + if len(data) == 0 { + return nil + } + var result any + if err := json.Unmarshal(data, &result); err != nil { + // If we can't decode, return the raw string. + return string(data) + } + return result +} + +func (i *Inspector) logEvent(eventType string, meta *pipelinev1alpha1.StepMeta, payload any, errMsg string) { + if i.format == "text" { + i.logText(eventType, meta, payload, errMsg) + return + } + i.logJSON(eventType, meta, payload, errMsg) +} + +func (i *Inspector) logJSON(eventType string, meta *pipelinev1alpha1.StepMeta, payload any, errMsg string) { + // Marshal meta using protojson to preserve proto field names. + metaJSON, err := protojson.Marshal(meta) + if err != nil { + i.log.Debug("Cannot marshal meta", "error", err) + return + } + + // Unmarshal meta into a map so we can include it in the final event. + var metaMap map[string]any + if err := json.Unmarshal(metaJSON, &metaMap); err != nil { + i.log.Debug("Cannot unmarshal meta", "error", err) + return + } + + event := map[string]any{ + "type": eventType, + "meta": metaMap, + "payload": payload, + } + if errMsg != "" { + event["error"] = errMsg + } + + eventJSON, err := json.Marshal(event) + if err != nil { + i.log.Debug("Cannot marshal event", "error", err) + return + } + + _, _ = fmt.Fprintln(i.out, string(eventJSON)) +} + +func (i *Inspector) logText(eventType string, meta *pipelinev1alpha1.StepMeta, payload any, errMsg string) { + _, _ = fmt.Fprintf(i.out, "=== %s ===\n", eventType) + _, _ = fmt.Fprintf(i.out, " XR: %s/%s (%s)\n", meta.GetCompositeResourceApiVersion(), meta.GetCompositeResourceKind(), meta.GetCompositeResourceName()) + _, _ = fmt.Fprintf(i.out, " XR UID: %s\n", meta.GetCompositeResourceUid()) + if ns := meta.GetCompositeResourceNamespace(); ns != "" { + _, _ = fmt.Fprintf(i.out, " XR NS: %s\n", ns) + } + _, _ = fmt.Fprintf(i.out, " Composition: %s\n", meta.GetCompositionName()) + _, _ = fmt.Fprintf(i.out, " Function: %s (step %d, iteration %d)\n", meta.GetFunctionName(), meta.GetStepIndex(), meta.GetIteration()) + _, _ = fmt.Fprintf(i.out, " Trace ID: %s\n", meta.GetTraceId()) + _, _ = fmt.Fprintf(i.out, " Span ID: %s\n", meta.GetSpanId()) + _, _ = fmt.Fprintf(i.out, " Timestamp: %s\n", meta.GetTimestamp().AsTime().Format("2006-01-02T15:04:05.000Z07:00")) + if errMsg != "" { + _, _ = fmt.Fprintf(i.out, " Error: %s\n", errMsg) + } + + // Pretty-print payload as YAML for readability. + if payload != nil { + payloadYAML, err := yaml.Marshal(payload) + if err == nil { + _, _ = fmt.Fprintf(i.out, " Payload:\n%s\n", indentLines(string(payloadYAML), " ")) + } + } + _, _ = fmt.Fprintln(i.out) +} + +// indentLines adds the given prefix to each line of the input string. +func indentLines(s, prefix string) string { + var result strings.Builder + for line := range strings.SplitSeq(strings.TrimSuffix(s, "\n"), "\n") { + result.WriteString(prefix + line + "\n") + } + return result.String() +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..e049d4e --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,394 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +*/ + +package server + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + pipelinev1alpha1 "github.com/crossplane/crossplane-runtime/v2/apis/pipelineinspector/proto/v1alpha1" +) + +func TestEmitRequest_JSON(t *testing.T) { + var buf bytes.Buffer + inspector := NewInspector("json", WithOutput(&buf)) + + meta := &pipelinev1alpha1.StepMeta{ + TraceId: "trace-123", + SpanId: "span-456", + StepIndex: 0, + Iteration: 0, + FunctionName: "function-patch-and-transform", + CompositionName: "my-composition", + CompositeResourceUid: "uid-789", + CompositeResourceName: "my-xr", + CompositeResourceNamespace: "default", + CompositeResourceApiVersion: "example.org/v1", + CompositeResourceKind: "XDatabase", + Timestamp: timestamppb.New(time.Now()), + } + + req := &pipelinev1alpha1.EmitRequestRequest{ + Request: []byte(`{"apiVersion":"apiextensions.crossplane.io/v1"}`), + Meta: meta, + } + + _, err := inspector.EmitRequest(context.Background(), req) + if err != nil { + t.Fatalf("EmitRequest failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, `"type":"REQUEST"`) { + t.Errorf("expected type REQUEST in output, got: %s", output) + } + if !strings.Contains(output, `"functionName":"function-patch-and-transform"`) { + t.Errorf("expected functionName in output, got: %s", output) + } + + // Verify it's valid JSON. + var result map[string]any + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Errorf("output is not valid JSON: %v", err) + } + + // Verify meta is included as a nested object. + if _, ok := result["meta"]; !ok { + t.Errorf("expected meta field in output, got: %s", output) + } +} + +func TestEmitResponse_JSON(t *testing.T) { + var buf bytes.Buffer + inspector := NewInspector("json", WithOutput(&buf)) + + meta := &pipelinev1alpha1.StepMeta{ + TraceId: "trace-123", + SpanId: "span-456", + FunctionName: "my-function", + Timestamp: timestamppb.New(time.Now()), + } + + req := &pipelinev1alpha1.EmitResponseRequest{ + Response: []byte(`{"desired":{}}`), + Error: "", + Meta: meta, + } + + _, err := inspector.EmitResponse(context.Background(), req) + if err != nil { + t.Fatalf("EmitResponse failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, `"type":"RESPONSE"`) { + t.Errorf("expected type RESPONSE in output, got: %s", output) + } + + // Verify it's valid JSON. + var result map[string]any + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Errorf("output is not valid JSON: %v", err) + } +} + +func TestEmitResponse_WithError(t *testing.T) { + var buf bytes.Buffer + inspector := NewInspector("json", WithOutput(&buf)) + + meta := &pipelinev1alpha1.StepMeta{ + FunctionName: "my-function", + Timestamp: timestamppb.New(time.Now()), + } + req := &pipelinev1alpha1.EmitResponseRequest{ + Response: nil, + Error: "function execution failed", + Meta: meta, + } + + _, _ = inspector.EmitResponse(context.Background(), req) + + output := buf.String() + if !strings.Contains(output, `"error":"function execution failed"`) { + t.Errorf("expected error field in output, got: %s", output) + } +} + +func TestEmitRequest_Text(t *testing.T) { + var buf bytes.Buffer + inspector := NewInspector("text", WithOutput(&buf)) + + meta := &pipelinev1alpha1.StepMeta{ + CompositeResourceApiVersion: "example.org/v1", + CompositeResourceKind: "XDatabase", + CompositeResourceName: "my-xr", + CompositeResourceUid: "uid-123", + CompositeResourceNamespace: "my-namespace", + CompositionName: "my-composition", + FunctionName: "my-function", + TraceId: "trace-abc", + SpanId: "span-def", + StepIndex: 1, + Iteration: 2, + Timestamp: timestamppb.New(time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)), + } + + req := &pipelinev1alpha1.EmitRequestRequest{ + Request: []byte(`{"apiVersion":"apiextensions.crossplane.io/v1"}`), + Meta: meta, + } + + _, _ = inspector.EmitRequest(context.Background(), req) + + output := buf.String() + + // Verify header. + if !strings.Contains(output, "=== REQUEST ===") { + t.Errorf("expected REQUEST header, got: %s", output) + } + + // Verify XR info. + if !strings.Contains(output, "XR: example.org/v1/XDatabase (my-xr)") { + t.Errorf("expected XR info in output, got: %s", output) + } + + // Verify UID is included. + if !strings.Contains(output, "XR UID: uid-123") { + t.Errorf("expected XR UID in output, got: %s", output) + } + + // Verify namespace is included. + if !strings.Contains(output, "XR NS: my-namespace") { + t.Errorf("expected XR namespace in output, got: %s", output) + } + + // Verify composition name. + if !strings.Contains(output, "Composition: my-composition") { + t.Errorf("expected composition name in output, got: %s", output) + } + + // Verify function info. + if !strings.Contains(output, "Function: my-function (step 1, iteration 2)") { + t.Errorf("expected function info in output, got: %s", output) + } + + // Verify trace and span IDs. + if !strings.Contains(output, "Trace ID: trace-abc") { + t.Errorf("expected trace ID in output, got: %s", output) + } + if !strings.Contains(output, "Span ID: span-def") { + t.Errorf("expected span ID in output, got: %s", output) + } +} + +func TestEmitRequest_Text_NoNamespace(t *testing.T) { + var buf bytes.Buffer + inspector := NewInspector("text", WithOutput(&buf)) + + // Cluster-scoped resource has empty namespace. + meta := &pipelinev1alpha1.StepMeta{ + CompositeResourceApiVersion: "example.org/v1", + CompositeResourceKind: "XClusterDatabase", + CompositeResourceName: "my-cluster-xr", + CompositeResourceUid: "uid-456", + CompositeResourceNamespace: "", // Empty for cluster-scoped. + CompositionName: "cluster-composition", + FunctionName: "my-function", + Timestamp: timestamppb.New(time.Now()), + } + + req := &pipelinev1alpha1.EmitRequestRequest{ + Request: []byte(`{}`), + Meta: meta, + } + + _, _ = inspector.EmitRequest(context.Background(), req) + + output := buf.String() + + // Verify namespace line is NOT included for cluster-scoped resources. + if strings.Contains(output, "XR NS:") { + t.Errorf("expected no XR NS line for cluster-scoped resource, got: %s", output) + } +} + +func TestEmitResponse_Text_WithError(t *testing.T) { + var buf bytes.Buffer + inspector := NewInspector("text", WithOutput(&buf)) + + meta := &pipelinev1alpha1.StepMeta{ + FunctionName: "failing-function", + Timestamp: timestamppb.New(time.Now()), + } + + req := &pipelinev1alpha1.EmitResponseRequest{ + Response: nil, + Error: "something went wrong", + Meta: meta, + } + + _, _ = inspector.EmitResponse(context.Background(), req) + + output := buf.String() + if !strings.Contains(output, "Error: something went wrong") { + t.Errorf("expected error in output, got: %s", output) + } +} + +func TestDecodeJSONPayload(t *testing.T) { + tests := []struct { + name string + input []byte + wantNil bool + wantType string + }{ + { + name: "empty input", + input: nil, + wantNil: true, + }, + { + name: "valid json object", + input: []byte(`{"key":"value"}`), + wantType: "map[string]interface {}", + }, + { + name: "valid json array", + input: []byte(`[1,2,3]`), + wantType: "[]interface {}", + }, + { + name: "invalid json returns string", + input: []byte(`{not valid`), + wantType: "string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := decodeJSONPayload(tt.input) + if tt.wantNil { + if result != nil { + t.Errorf("expected nil, got %v", result) + } + return + } + gotType := typeString(result) + if gotType != tt.wantType { + t.Errorf("expected type %s, got %s", tt.wantType, gotType) + } + }) + } +} + +func typeString(v any) string { + if v == nil { + return "" + } + switch v.(type) { + case map[string]any: + return "map[string]interface {}" + case []any: + return "[]interface {}" + case string: + return "string" + default: + return "unknown" + } +} + +func TestIndentLines(t *testing.T) { + tests := []struct { + name string + input string + prefix string + expected string + }{ + { + name: "single line", + input: "hello", + prefix: " ", + expected: " hello\n", + }, + { + name: "multiple lines", + input: "line1\nline2\nline3", + prefix: " ", + expected: " line1\n line2\n line3\n", + }, + { + name: "with trailing newline", + input: "line1\nline2\n", + prefix: " ", + expected: " line1\n line2\n", + }, + { + name: "empty string", + input: "", + prefix: " ", + expected: " \n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := indentLines(tt.input, tt.prefix) + if result != tt.expected { + t.Errorf("indentLines(%q, %q) = %q, want %q", tt.input, tt.prefix, result, tt.expected) + } + }) + } +} + +func TestNewInspector_Defaults(t *testing.T) { + inspector := NewInspector("json") + + if inspector.format != "json" { + t.Errorf("expected format 'json', got %s", inspector.format) + } + if inspector.out == nil { + t.Error("expected out to be set") + } + if inspector.log == nil { + t.Error("expected log to be set") + } +} + +func TestNewInspector_WithOptions(t *testing.T) { + var out bytes.Buffer + inspector := NewInspector("text", WithOutput(&out)) + + if inspector.format != "text" { + t.Errorf("expected format 'text', got %s", inspector.format) + } + + // Verify custom writers are used. + inspector.EmitRequest(context.Background(), &pipelinev1alpha1.EmitRequestRequest{ + Meta: &pipelinev1alpha1.StepMeta{ + Timestamp: timestamppb.New(time.Now()), + }, + }) + + if out.Len() == 0 { + t.Error("expected output to be written to custom writer") + } +}