Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.16/NOTES-20260715-114855.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: NOTES
body: 'policy (experimental): loaded policies are now validated against the run''s provider schemas before plan/apply, so a policy referencing an attribute a provider does not have fails early instead of partway through a run'
time: 2026-07-15T11:48:55.000000-04:00
custom:
Issue: "38877"
82 changes: 82 additions & 0 deletions internal/policy/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-plugin"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
Expand Down Expand Up @@ -272,6 +273,87 @@ func (c *client) Setup(ctx context.Context, req SetupRequest) SetupResponse {
}
}

// ValidateProviderSchemas validates the loaded policies against the run's
// provider schemas. It serialises each provider's config/resource/data-source
// object types as cty JSON type encodings, calls the plugin, and returns the
// diagnostics — errors when a policy references something a provider lacks.
func (c *client) ValidateProviderSchemas(ctx context.Context, req ValidateProviderSchemasRequest) ValidateProviderSchemasResponse {
ctx, span := tracer().Start(ctx, "policy.client.validate_provider_schemas",
trace.WithAttributes(attribute.Int("policy.provider_schemas.count", len(req.ProviderSchemas))),
)
defer span.End()

protoReq := &proto.ValidateProviderSchemasRequest{}
for _, ps := range req.ProviderSchemas {
protoPS, err := providerSchemaToProto(ps)
if err != nil {
return ValidateProviderSchemasResponse{Diagnostics: Diagnostics{
NewErrorDiagnostic("Failed to encode provider schema",
fmt.Sprintf("Failed to encode the schema for provider %q: %v.", ps.Type, err),
SetupErrorResult,
),
}}
}
protoReq.ProviderSchemas = append(protoReq.ProviderSchemas, protoPS)
}

response, err := c.client.ValidateProviderSchemas(ctx, protoReq)
if err != nil {
return ValidateProviderSchemasResponse{Diagnostics: Diagnostics{
NewErrorDiagnostic("Failed to validate policies against provider schemas",
fmt.Sprintf("Failed to validate policies against provider schemas: %v.", err),
SetupErrorResult,
),
}}
}
return ValidateProviderSchemasResponse{Diagnostics: DiagsFromProto(response.Diagnostics, nil)}
}

// providerSchemaToProto encodes a provider schema's cty object types as cty JSON
// type encodings for the wire.
func providerSchemaToProto(ps ProviderSchema) (*proto.ProviderSchema, error) {
config, err := marshalType(ps.Config)
if err != nil {
return nil, fmt.Errorf("config: %w", err)
}
resources, err := marshalTypeMap(ps.Resources)
if err != nil {
return nil, fmt.Errorf("resources: %w", err)
}
dataSources, err := marshalTypeMap(ps.DataSources)
if err != nil {
return nil, fmt.Errorf("data sources: %w", err)
}
return &proto.ProviderSchema{
Type: ps.Type,
Config: config,
Resources: resources,
DataSources: dataSources,
}, nil
}

func marshalTypeMap(in map[string]cty.Type) (map[string][]byte, error) {
if len(in) == 0 {
return nil, nil
}
out := make(map[string][]byte, len(in))
for name, ty := range in {
raw, err := marshalType(ty)
if err != nil {
return nil, fmt.Errorf("%q: %w", name, err)
}
out[name] = raw
}
return out, nil
}

func marshalType(ty cty.Type) ([]byte, error) {
if ty == cty.NilType {
ty = cty.EmptyObject
}
return ctyjson.MarshalType(ty)
}

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)),
Expand Down
56 changes: 56 additions & 0 deletions internal/policy/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ package policy

import (
"context"
"errors"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
ctyjson "github.com/zclconf/go-cty/cty/json"
"google.golang.org/grpc"
gproto "google.golang.org/protobuf/proto"

Expand All @@ -25,6 +28,7 @@ type stubPolicyClient struct {
evaluateResourceFn func(*proto.PolicyEvaluateResourceRequest) (*proto.PolicyEvaluateResourceResponse, error)
evaluateProviderFn func(*proto.PolicyEvaluateProviderRequest) (*proto.PolicyEvaluateProviderResponse, error)
evaluateModuleFn func(*proto.PolicyEvaluateModuleRequest) (*proto.PolicyEvaluateModuleResponse, error)
validateSchemasFn func(context.Context, *proto.ValidateProviderSchemasRequest) (*proto.ValidateProviderSchemasResponse, error)
}

func (s *stubPolicyClient) Setup(ctx context.Context, req *proto.PolicySetupRequest, _ ...grpc.CallOption) (*proto.PolicySetupResponse, error) {
Expand All @@ -43,6 +47,58 @@ func (s *stubPolicyClient) EvaluateModule(ctx context.Context, req *proto.Policy
return s.evaluateModuleFn(req)
}

func (s *stubPolicyClient) ValidateProviderSchemas(ctx context.Context, req *proto.ValidateProviderSchemasRequest, _ ...grpc.CallOption) (*proto.ValidateProviderSchemasResponse, error) {
return s.validateSchemasFn(ctx, req)
}

func TestProviderSchemaToProto(t *testing.T) {
got, err := providerSchemaToProto(ProviderSchema{
Type: "test",
Config: cty.NilType,
Resources: map[string]cty.Type{"test_empty": cty.NilType},
DataSources: map[string]cty.Type{"test_data": cty.EmptyObject},
})
if err != nil {
t.Fatalf("unexpected error encoding absent schema bodies: %s", err)
}
if len(got.LocalNames) != 0 {
t.Fatalf("Terraform must not send configuration local names as policy aliases: %v", got.LocalNames)
}

for name, raw := range map[string][]byte{
"config": got.Config,
"test_empty": got.Resources["test_empty"],
"test_data": got.DataSources["test_data"],
} {
typ, err := ctyjson.UnmarshalType(raw)
if err != nil {
t.Fatalf("%s did not contain a valid cty type: %s", name, err)
}
if !typ.Equals(cty.EmptyObject) {
t.Errorf("%s encoded %s, want empty object", name, typ.FriendlyName())
}
}
}

func TestClientValidateProviderSchemasRPCError(t *testing.T) {
c := &client{client: &stubPolicyClient{
validateSchemasFn: func(ctx context.Context, req *proto.ValidateProviderSchemasRequest) (*proto.ValidateProviderSchemasResponse, error) {
return nil, errors.New("transport unavailable")
},
}}

resp := c.ValidateProviderSchemas(t.Context(), ValidateProviderSchemasRequest{
ProviderSchemas: []ProviderSchema{{Type: "test", Config: cty.EmptyObject}},
})
if !resp.Diagnostics.HasErrors() {
t.Fatal("expected the RPC error to become a diagnostic")
}
detail := resp.Diagnostics[0].Description().Detail
if !strings.Contains(detail, "transport unavailable") || !strings.Contains(detail, "provider schemas") {
t.Fatalf("RPC diagnostic is not actionable: %q", detail)
}
}

func TestClientEvaluate(t *testing.T) {
ctx := t.Context()

Expand Down
22 changes: 22 additions & 0 deletions internal/policy/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ type MockClient struct {
EvaluateModuleRequest EvaluationRequest[*proto.PolicyEvaluateModuleRequest_ModuleMetadata]
EvaluateModuleFn func(context.Context, EvaluationRequest[*proto.PolicyEvaluateModuleRequest_ModuleMetadata]) EvaluationResponse

// ValidateProviderSchemas method tracking
ValidateProviderSchemasCalled bool
ValidateProviderSchemasResponse *ValidateProviderSchemasResponse
ValidateProviderSchemasRequest ValidateProviderSchemasRequest
ValidateProviderSchemasFn func(context.Context, ValidateProviderSchemasRequest) ValidateProviderSchemasResponse

// Stop method tracking
StopCalled bool
StopFn func()
Expand Down Expand Up @@ -115,6 +121,22 @@ func (p *MockClient) EvaluateModule(ctx context.Context, r EvaluationRequest[*pr
return resp
}

func (p *MockClient) ValidateProviderSchemas(ctx context.Context, req ValidateProviderSchemasRequest) (resp ValidateProviderSchemasResponse) {
defer p.beginWrite()()

p.ValidateProviderSchemasCalled = true
p.ValidateProviderSchemasRequest = req
if p.ValidateProviderSchemasFn != nil {
return p.ValidateProviderSchemasFn(ctx, req)
}

if p.ValidateProviderSchemasResponse != nil {
return *p.ValidateProviderSchemasResponse
}

return resp
}

func (p *MockClient) Stop() {
defer p.beginWrite()()
p.StopCalled = true
Expand Down
29 changes: 29 additions & 0 deletions internal/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ type Client interface {
EvaluateResource(context.Context, EvaluationRequest[*proto.PolicyEvaluateResourceRequest_ResourceMetadata]) EvaluationResponse
EvaluateProvider(context.Context, EvaluationRequest[*proto.PolicyEvaluateProviderRequest_ProviderMetadata]) EvaluationResponse
EvaluateModule(context.Context, EvaluationRequest[*proto.PolicyEvaluateModuleRequest_ModuleMetadata]) EvaluationResponse
// ValidateProviderSchemas validates the loaded policies against the run's
// provider schemas, so a policy that references an attribute a provider does
// not have fails early. Called after Setup, once schemas are resolved.
ValidateProviderSchemas(context.Context, ValidateProviderSchemasRequest) ValidateProviderSchemasResponse
Stop()
}

Expand All @@ -29,6 +33,31 @@ type CallbackService interface {
RegisterCallbackService(context.Context) (*callback.Server, Diagnostics)
}

type (
// ValidateProviderSchemasRequest carries the provider schemas to validate the
// loaded policies against.
ValidateProviderSchemasRequest struct {
ProviderSchemas []ProviderSchema
}

// ProviderSchema is one provider's schema as cty object types: its
// configuration and the object type of each resource and data source it
// offers. Policy-language aliases are resolved by the policy plugin from the
// policy configuration; Terraform configuration local names are unrelated.
ProviderSchema struct {
Type string
Config cty.Type
Resources map[string]cty.Type
DataSources map[string]cty.Type
}

// ValidateProviderSchemasResponse carries the diagnostics from validating the
// loaded policies against the schemas.
ValidateProviderSchemasResponse struct {
Diagnostics Diagnostics
}
)

type (
SetupResponse struct {
// serverCapabilities contains the map of a policy path to the capabilities of the server.
Expand Down
Loading