internal/provider/telemetry_resource.go (303 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// SPDX-License-Identifier: MPL-2.0
package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"time"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
)
// Ensure provider defined types fully satisfy framework interfaces.
var _ resource.Resource = &TelemetryResource{}
var _ resource.ResourceWithImportState = &TelemetryResource{}
var traceLog = tflog.Trace
var errorLog = tflog.Error
func NewTelemetryResource() resource.Resource {
return &TelemetryResource{}
}
// TelemetryResource defines the resource implementation.
type TelemetryResource struct {
providerEndpointFunc func() string
enabled bool
defaultEndpointOnProviderBlock bool
moduleSourceRegex []*regexp.Regexp
}
// TelemetryResourceModel describes the resource data model.
type TelemetryResourceModel struct {
Id types.String `tfsdk:"id"`
Tags types.Map `tfsdk:"tags"`
Endpoint types.String `tfsdk:"endpoint"`
//TODO: Remove these fields in v1
Nonce types.Number `tfsdk:"nonce"`
EphemeralNumber types.Number `tfsdk:"ephemeral_number"`
}
func (r *TelemetryResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_telemetry"
}
func (r *TelemetryResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
// This description is used by the documentation generator and the language server.
MarkdownDescription: "`modtm_telemetry` resource gathers and sends telemetry data to a specified endpoint. The aim is to provide visibility into the lifecycle of your Terraform modules - whether they are being created, updated, or deleted.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Resource identifier",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"tags": schema.MapAttribute{
Required: true,
MarkdownDescription: "Tags to be sent to telemetry endpoint. The following tags are reserved and cannot be used: `event`. When specififying `module_path`, the `source` and `version` tags will be automatically added to the tags sent to the telemetry endpoint.",
ElementType: basetypes.StringType{},
Validators: []validator.Map{
mapValidator{},
},
},
"endpoint": schema.StringAttribute{
Optional: true,
MarkdownDescription: "Telemetry endpoint to send data to, will override provider's default `endpoint` setting.\n" +
"You can set `endpoint` in this resource, when there's no explicit `setting` in the provider block, it will override provider's default `endpoint`.\n\n" +
"|Explicit `endpoint` in `provider` block | `MODTM_ENDPOINT` environment variable set | Explicit `endpoint` in resource block | Telemetry endpoint |\n" +
"|--|--|--|--|\n" +
"| ✓ | ✓ | ✓ | Explicit `endpoint` in `provider` block | \n" +
"| ✓ | ✓ | × | Explicit `endpoint` in `provider` block | \n" +
"| ✓ | × | ✓ | Explicit `endpoint` in `provider` block | \n" +
"| ✓ | × | × | Explicit `endpoint` in `provider` block | \n" +
"| × | ✓ | ✓ | `MODTM_ENDPOINT` environment variable | \n" +
"| × | ✓ | × | `MODTM_ENDPOINT` environment variable | \n" +
"| × | × | ✓ | Explicit `endpoint` in resource block | \n" +
"| × | × | × | Default Microsoft telemetry service endpoint | \n",
},
//TODO: Remove these fields in v1
"nonce": schema.NumberAttribute{
Optional: true,
Computed: true,
DeprecationMessage: "This field has been deprecated and will be removed in `v1`. Do not use it.",
Description: "A nonce that works with tags-generation tools like BridgeCrew Yor",
MarkdownDescription: "A nonce that works with tags-generation tools like [BridgeCrew Yor](https://yor.io/)",
},
"ephemeral_number": schema.NumberAttribute{
Optional: true,
Computed: true,
DeprecationMessage: "This field has been deprecated and will be removed in `v1`. Do not use it.",
Description: "An ephemeral number that works with tags-generation tools like BridgeCrew Yor",
MarkdownDescription: "An ephemeral number that works with tags-generation tools like [BridgeCrew Yor](https://yor.io/)",
},
},
}
}
func (r *TelemetryResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
c, ok := req.ProviderData.(providerConfig)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected providerConfig, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)
return
}
r.providerEndpointFunc = c.endpointFunc
r.enabled = c.enabled
r.defaultEndpointOnProviderBlock = c.defaultEndpoint
r.moduleSourceRegex = c.moduleSourceRegex
}
func (r *TelemetryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
data := &TelemetryResourceModel{}
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
newId := uuid.NewString()
data.Id = types.StringValue(newId)
if data.Nonce.IsUnknown() {
data.Nonce = types.NumberNull()
}
if data.EphemeralNumber.IsUnknown() {
data.EphemeralNumber = types.NumberNull()
}
traceLog(ctx, fmt.Sprintf("created telemetry resource with id %s", newId))
data.sendTags(ctx, r, "create")
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TelemetryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
data := &TelemetryResourceModel{}
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, data)...)
if resp.Diagnostics.HasError() {
resp.Diagnostics.Append()
}
traceLog(ctx, fmt.Sprintf("read telemetry resource with id %s", data.Id.String()))
data.sendTags(ctx, r, "read")
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TelemetryResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
data := &TelemetryResourceModel{}
resp.Diagnostics.Append(req.Plan.Get(ctx, data)...)
if resp.Diagnostics.HasError() {
return
}
if data.Nonce.IsUnknown() {
data.Nonce = types.NumberNull()
}
if data.EphemeralNumber.IsUnknown() {
data.EphemeralNumber = types.NumberNull()
}
traceLog(ctx, fmt.Sprintf("update telemetry resource with id %s", data.Id.String()))
data.sendTags(ctx, r, "update")
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *TelemetryResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
data := &TelemetryResourceModel{}
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, data)...)
if resp.Diagnostics.HasError() {
return
}
traceLog(ctx, fmt.Sprintf("delete telemetry resource with id %s", data.Id.String()))
data.sendTags(ctx, r, "delete")
}
func (r *TelemetryResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
// Since it's a fake resource, we won't support import
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
// sendPostRequest sends an HTTP POST request to the specified URL with the given body.
func sendPostRequest(ctx context.Context, url string, tags map[string]string) {
jsonStr, err := json.Marshal(tags)
if err != nil {
errorLog(ctx, fmt.Sprintf("error on unmarshal telemetry resource: %s", err.Error()))
return
}
event := tags["event"]
client := &http.Client{}
traceLog(ctx, fmt.Sprintf("sending tags to %s", url))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
if err != nil {
errorLog(ctx, fmt.Sprintf("error on composing http request for %s telemetry resource: %+v", event, err))
return
}
req.Header.Set("Content-Type", "application/json")
c := make(chan int)
errChan := make(chan error)
go func() {
defer close(c)
resp, err := client.Do(req)
if err != nil {
errorLog(ctx, fmt.Sprintf("error on %s telemetry resource: %+v", event, err))
errChan <- err
return
}
traceLog(ctx, fmt.Sprintf("response Status for %s telemetry resource: %s", event, resp.Status))
defer func() {
_ = resp.Body.Close()
}()
c <- 1
}()
select {
case <-c:
return
case <-errChan:
return
case <-time.After(5 * time.Second):
errorLog(ctx, fmt.Sprintf("timeout on %s telemetry resource", event))
return
}
}
// sendTags sends the tags to the telemetry endpoint.
// It adds (and ovwewrites) the `source`, `version`, `event`, and `resource_id` tags to the tags map.
func (r *TelemetryResourceModel) sendTags(ctx context.Context, res *TelemetryResource, event string) {
if !res.enabled {
return
}
tags := r.readTags()
tags["event"] = event
tags["resource_id"] = r.readResourceId()
src, ok := tags["module_source"]
if !ok {
return
}
match := false
for _, regex := range res.moduleSourceRegex {
if regex.MatchString(src) {
match = true
break
}
}
if !match {
return
}
var endpoint string
if !res.defaultEndpointOnProviderBlock || r.Endpoint.IsNull() {
endpoint = res.providerEndpointFunc()
} else {
endpoint = r.readEndpoint()
}
if endpoint != "" {
sendPostRequest(ctx, endpoint, tags)
}
}
func (r *TelemetryResourceModel) readEndpoint() string {
raw := r.Endpoint.String()
endpoint, err := strconv.Unquote(raw)
if err != nil {
return raw
}
return endpoint
}
func (r *TelemetryResourceModel) readResourceId() string {
resourceId, err := strconv.Unquote(r.Id.String())
if err != nil {
return r.Id.String()
}
return resourceId
}
func (r *TelemetryResourceModel) readTags() map[string]string {
tags := make(map[string]string)
for k, v := range r.Tags.Elements() {
raw := v.String()
value, err := strconv.Unquote(raw)
if err != nil {
value = raw
}
tags[k] = value
}
return tags
}
// parseModulesJson reads the modules.json file and returns the module entry with the specified key.
func parseModulesJson(modulePath string) (*modulesJsonModulesModel, error) {
dataDir := envOrDefault("TF_DATA_DIR", ".terraform")
modulesJsonPath := filepath.Join(dataDir, "modules", "modules.json")
content, err := os.ReadFile(filepath.Clean(modulesJsonPath))
if err != nil {
return nil, fmt.Errorf("parseModulesJson: error reading modules.json file: %w", err)
}
var modules modulesJsonModel
if err = json.Unmarshal(content, &modules); err != nil {
return nil, fmt.Errorf("parseModulesJson: error unmarshalling modules.json file: %w", err)
}
for _, moduleEntry := range modules.Modules {
if moduleEntry.Dir == modulePath {
return &moduleEntry, nil
}
}
return nil, fmt.Errorf("parseModulesJson: module with dir %s not found in modules.json", modulePath)
}
// modulesJsonModel represents the base structure of the modules.json file.
type modulesJsonModel struct {
Modules []modulesJsonModulesModel `json:"Modules"`
}
// modulesJsonModulesModel represents the structure of the modules.json file's `Modules` array.
type modulesJsonModulesModel struct {
Key string `json:"Key"`
Source string `json:"Source"`
Version string `json:"Version"`
Dir string `json:"Dir"`
}
func envOrDefault(env, defaultValue string) string {
val, ok := os.LookupEnv(env)
if !ok {
return defaultValue
}
return val
}