blob: f89517406b305383ebb8a2c7928d09102963c33f [file]
// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package policy
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"time"
"github.com/apparentlymart/go-versions/versions"
"github.com/apparentlymart/go-versions/versions/constraints"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
"github.com/zclconf/go-cty/cty"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
"github.com/hashicorp/terraform/internal/policy/callback"
"github.com/hashicorp/terraform/internal/policy/proto"
"github.com/hashicorp/terraform/version"
)
const (
TerraformPolicyPluginEnvVar = "TF_POLICY_PLUGIN"
TerraformPolicyLogLevelEnvVar = "TF_POLICY_LOG_LEVEL"
callbackServiceTimeout = 10 * time.Second
)
var _ CallbackService = (*client)(nil)
var _ Client = (*client)(nil)
// NewPolicyClient initializes and connects to a new tfpolicy-plugin process
func NewPolicyClient(ctx context.Context, policyPluginPath string, policyPaths []string, ent *Entitlement) (Client, Diagnostics) {
var diags Diagnostics
client, err := Connect(ctx, policyPluginPath)
if err != nil {
diags = append(diags, NewErrorDiagnostic(
"Failed to connect to policy engine",
fmt.Sprintf("Failed to connect to policy engine: %s.", err),
SetupErrorResult,
))
return nil, diags
}
var callbackServiceID uint32
// initialize the callback service if the client supports it
if srv, ok := client.(CallbackService); ok {
callbackServer, cbDiags := srv.RegisterCallbackService(ctx)
if cbDiags != nil {
client.Stop()
return nil, cbDiags
}
callbackServiceID = callbackServer.ID
}
resp := client.Setup(ctx, SetupRequest{
SourceLocations: policyPaths,
CallbackService: callbackServiceID,
Entitlement: ent,
})
diags = append(diags, resp.Diagnostics...)
if diags.HasErrors() {
client.Stop()
return nil, diags
}
var requiredVersions constraints.IntersectionSpec
for _, config := range resp.ServerConfigurations() {
version, err := constraints.ParseRubyStyleMulti(config.RequiredVersion)
if err != nil {
diags = append(diags, NewErrorDiagnostic(
"Failed to validate required Terraform version",
fmt.Sprintf("The policy file %s had a Terraform version constraint that could not be parsed: %s.", config.File, err),
SetupErrorResult,
))
continue
}
requiredVersions = append(requiredVersions, version...)
}
if diags.HasErrors() {
client.Stop()
return nil, diags
}
terraformVersion, err := versions.ParseVersion(version.Version)
if err != nil {
client.Stop()
// This is crazy, it means the internal version number is invalid.
panic(err)
}
constraint := versions.MeetingConstraints(requiredVersions)
if !constraint.Has(terraformVersion) {
diags = append(diags, NewErrorDiagnostic(
"Invalid Terraform version for policies",
fmt.Sprintf("The current version of Terraform is %s, and it is not compatible with the versions of Terraform required by the selected policies.", version.String()),
SetupErrorResult,
))
client.Stop()
return nil, diags
}
return client, diags
}
// Connect creates a connection to tfpolicy-plugin. If policyPluginPath is empty, the command lookup
// will default to the executable "tfpolicy-plugin" in the $PATH.
func Connect(ctx context.Context, policyPluginPath string) (Client, error) {
ctx, span := tracer().Start(ctx, "policy.client.connect")
defer span.End()
pgm := "tfpolicy-plugin" // by default, just use this if it's in the path
if policyPluginPath != "" {
pgm = policyPluginPath
}
cmd := exec.CommandContext(ctx, pgm, "rpcapi")
plugin := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "TF_POLICY_PLUGIN",
MagicCookieValue: "6F11ED78A2AB",
},
Plugins: map[string]plugin.Plugin{
"policy": new(policy),
},
Cmd: cmd,
AllowedProtocols: []plugin.Protocol{
plugin.ProtocolGRPC,
},
// This propagates the active trace context via gRPC metadata so the plugin's handler
// spans become children of the terraform-side span that invoked them.
GRPCDialOptions: []grpc.DialOption{
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
},
Logger: hclog.New(&hclog.LoggerOptions{
Level: func() hclog.Level {
level := hclog.LevelFromString(os.Getenv(TerraformPolicyLogLevelEnvVar))
if level == hclog.NoLevel {
return hclog.Error
}
return level
}(),
}),
})
rpc, err := plugin.Client()
if err != nil {
plugin.Kill()
err = fmt.Errorf("failed to connect to plugin: %v", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
raw, err := rpc.Dispense("policy")
if err != nil {
plugin.Kill()
err = fmt.Errorf("failed to dispense plugin: %v", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
sc := raw.(*client)
sc.plugin = plugin
return sc, nil
}
type client struct {
plugin *plugin.Client
broker *plugin.GRPCBroker
client proto.PolicyClient
callbackRegistry callback.Registry
cbServer *callback.Server
}
func (c *client) RegisterCallbackService(ctx context.Context) (*callback.Server, Diagnostics) {
if c.cbServer != nil {
panic("callback service already registered")
}
cbServiceID := c.broker.NextId()
c.cbServer = &callback.Server{
ID: cbServiceID,
Registry: c.callbackRegistry,
}
serverCh := make(chan *grpc.Server, 1)
// Start the callback service server on the broker.
go c.broker.AcceptAndServe(cbServiceID, func(opts []grpc.ServerOption) *grpc.Server {
// Install the OpenTelemetry gRPC server stats handler so that the
// trace context the plugin client injects into outgoing callback RPC
// metadata is extracted and made the parent of the callback handler
// spans.
opts = append(opts, grpc.StatsHandler(otelgrpc.NewServerHandler()))
server := grpc.NewServer(opts...)
proto.RegisterCallbackServiceServer(server, c.cbServer)
serverCh <- server
return server
})
select {
// Wait for the server to be ready before returning.
case server := <-serverCh:
c.cbServer.Grpc = server
// If the context is done, return early with an error.
case <-ctx.Done():
return nil, Diagnostics{
NewErrorDiagnostic("Failed to register callback service",
fmt.Sprintf("Failed to register callback service: %v.", ctx.Err()),
SetupErrorResult,
),
}
// If the wait has exceeded the timeout, return early with an error.
case <-time.After(callbackServiceTimeout):
return nil, Diagnostics{
NewErrorDiagnostic("Failed to register callback service",
fmt.Sprintf("Failed to register callback service: timed out after %s.", callbackServiceTimeout),
SetupErrorResult,
),
}
}
return c.cbServer, nil
}
func (c *client) Setup(ctx context.Context, req SetupRequest) SetupResponse {
ctx, span := tracer().Start(ctx, "policy.client.setup")
defer span.End()
log.Printf("[DEBUG] Setting up Terraform Policy connection")
protoReq := &proto.PolicySetupRequest{
ClientCapabilities: new(proto.PolicySetupRequest_ClientCapabilities),
SourceLocations: req.SourceLocations,
CallbackService: req.CallbackService,
}
if req.Entitlement != nil {
protoReq.Entitlement = &proto.PolicySetupRequest_Entitlement{
Host: req.Entitlement.Host,
Token: req.Entitlement.Token,
Org: req.Entitlement.Org,
}
}
response, err := c.client.Setup(ctx, protoReq)
if err != nil {
return SetupResponse{Diagnostics: Diagnostics{
NewErrorDiagnostic("Failed to setup Terraform Policy connection",
fmt.Sprintf("Failed to setup Terraform Policy connection: %v.", err),
SetupErrorResult,
),
}}
}
return SetupResponse{
serverCapabilities: response.ServerCapabilities,
Diagnostics: DiagsFromProto(response.Diagnostics, nil),
}
}
func (c *client) EvaluateResource(ctx context.Context, req EvaluationRequest[*proto.PolicyEvaluateResourceRequest_ResourceMetadata]) EvaluationResponse {
ctx, span := tracer().Start(ctx, "policy.client.evaluate_resource",
trace.WithAttributes(attribute.String("policy.resource.type", req.Target)),
)
defer span.End()
log.Printf("[DEBUG] Evaluating policy for resource %s", req.Target)
var diags []*proto.Diagnostic
req = normalizeRequest(req)
attrs, err := resourceAttributesToProto(req.Attrs)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to serialize attributes",
Detail: fmt.Sprintf("Failed to serialize attributes: %v.", err),
}))
}
priorAttrs, err := resourceAttributesToProto(req.PriorAttrs)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to serialize prior attributes",
Detail: fmt.Sprintf("Failed to serialize prior attributes: %v.", err),
}))
}
evalID := c.callbackRegistry.NextID()
request := &proto.PolicyEvaluateResourceRequest{
EvaluationId: evalID,
Resource: req.Target,
Attrs: attrs,
PriorAttrs: priorAttrs,
Metadata: req.Meta,
}
// Register the callback functions with the callback service, so that they are available
// for use during evaluation.
c.callbackRegistry.Register(evalID, req.Callbacks)
// We can unregister the callback functions after the evaluation is complete.
defer c.callbackRegistry.Unregister(evalID)
response, err := c.client.EvaluateResource(ctx, request)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to evaluate Terraform Policy",
Detail: fmt.Sprintf("Failed to evaluate Terraform Policy: %v.", err),
}))
}
return EvaluationFromProtoResponse(response.Result, response.PolicyDetails)
}
func (c *client) EvaluateProvider(ctx context.Context, req EvaluationRequest[*proto.PolicyEvaluateProviderRequest_ProviderMetadata]) EvaluationResponse {
ctx, span := tracer().Start(ctx, "policy.client.evaluate_provider",
trace.WithAttributes(attribute.String("policy.provider.type", req.Target)),
)
defer span.End()
log.Printf("[DEBUG] Evaluating policy for provider %s", req.Target)
var diags []*proto.Diagnostic
req = normalizeRequest(req)
attrs, err := resourceAttributesToProto(req.Attrs)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to serialize attributes",
Detail: fmt.Sprintf("Failed to serialize attributes: %v.", err),
}))
}
request := &proto.PolicyEvaluateProviderRequest{
ProviderType: req.Target,
Attrs: attrs,
Metadata: req.Meta,
}
response, err := c.client.EvaluateProvider(ctx, request)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to evaluate Terraform Policy",
Detail: fmt.Sprintf("Failed to evaluate Terraform Policy: %v.", err),
}))
}
return EvaluationFromProtoResponse(response.Result, response.PolicyDetails)
}
func (c *client) EvaluateModule(ctx context.Context, req EvaluationRequest[*proto.PolicyEvaluateModuleRequest_ModuleMetadata]) EvaluationResponse {
ctx, span := tracer().Start(ctx, "policy.client.evaluate_module",
trace.WithAttributes(attribute.String("policy.module.source", req.Target)),
)
defer span.End()
log.Printf("[DEBUG] Evaluating policy for module %s", req.Target)
var diags []*proto.Diagnostic
req = normalizeRequest(req)
attrs, err := resourceAttributesToProto(req.Attrs)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to serialize attributes",
Detail: fmt.Sprintf("Failed to serialize attributes: %v.", err),
}))
}
request := &proto.PolicyEvaluateModuleRequest{
ModuleSource: req.Target,
Attrs: attrs,
Metadata: req.Meta,
}
response, err := c.client.EvaluateModule(ctx, request)
if err != nil {
return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{
Severity: proto.Severity_ERROR,
Summary: "Failed to evaluate Terraform Policy",
Detail: fmt.Sprintf("Failed to evaluate Terraform Policy: %v.", err),
}))
}
return EvaluationFromProtoResponse(response.Result, response.PolicyDetails)
}
func (c *client) Stop() {
log.Println("[DEBUG] stopping policy client")
if c.cbServer != nil {
c.cbServer.Stop()
}
c.plugin.Kill()
}
func normalizeRequest[T any](req EvaluationRequest[T]) EvaluationRequest[T] {
attrs := req.Attrs
priorAttrs := req.PriorAttrs
if attrs.Raw == cty.NilVal {
attrs.Raw = cty.EmptyObjectVal
}
if priorAttrs.Raw == cty.NilVal {
priorAttrs.Raw = cty.EmptyObjectVal
}
return EvaluationRequest[T]{
Target: req.Target,
Attrs: attrs,
PriorAttrs: priorAttrs,
Meta: req.Meta,
Callbacks: req.Callbacks,
}
}
type policy struct {
plugin.NetRPCUnsupportedPlugin
}
func (s *policy) GRPCServer(*plugin.GRPCBroker, *grpc.Server) error {
// This package is only implementing the client side of the Terraform Policy
// plugin.
return fmt.Errorf("server configuration not supported")
}
func (s *policy) GRPCClient(_ context.Context, broker *plugin.GRPCBroker, conn *grpc.ClientConn) (interface{}, error) {
return &client{
plugin: nil, // this will be set by the Connect function
broker: broker,
client: proto.NewPolicyClient(conn),
callbackRegistry: callback.NewRegistry(),
}, nil
}