blob: fbed04b7849de56fba3f15e6ea91860fd1a7b085 [file] [log] [blame] [edit]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package http
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/backend/backendbase"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/hashicorp/terraform/internal/states/statemgr"
"github.com/hashicorp/terraform/internal/tfdiags"
)
func New() backend.Backend {
return &Backend{
Base: backendbase.Base{
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"address": {
Type: cty.String,
Optional: true, // Must be set but can be set using the TF_HTTP_ADDRESS environment variable
Description: "The address of the REST endpoint",
},
"update_method": {
Type: cty.String,
Optional: true,
Description: "HTTP method to use when updating state",
},
"lock_address": {
Type: cty.String,
Optional: true,
Description: "The address of the lock REST endpoint",
},
"unlock_address": {
Type: cty.String,
Optional: true,
Description: "The address of the unlock REST endpoint",
},
"lock_method": {
Type: cty.String,
Optional: true,
Description: "The HTTP method to use when locking",
},
"unlock_method": {
Type: cty.String,
Optional: true,
Description: "The HTTP method to use when unlocking",
},
"username": {
Type: cty.String,
Optional: true,
Description: "The username for HTTP basic authentication",
},
"password": {
Type: cty.String,
Optional: true,
Description: "The password for HTTP basic authentication",
},
"skip_cert_verification": {
Type: cty.Bool,
Optional: true,
Description: "Whether to skip TLS verification",
},
"retry_max": {
Type: cty.Number,
Optional: true,
Description: "The number of HTTP request retries",
},
"retry_wait_min": {
Type: cty.Number,
Optional: true,
Description: "The minimum time in seconds to wait between HTTP request attempts",
},
"retry_wait_max": {
Type: cty.Number,
Optional: true,
Description: "The maximum time in seconds to wait between HTTP request attempts",
},
"client_ca_certificate_pem": {
Type: cty.String,
Optional: true,
Description: "A PEM-encoded CA certificate chain used by the client to verify server certificates during TLS authentication",
},
"client_certificate_pem": {
Type: cty.String,
Optional: true,
Description: "A PEM-encoded certificate used by the server to verify the client during mutual TLS (mTLS) authentication",
},
"client_private_key_pem": {
Type: cty.String,
Optional: true,
Description: "A PEM-encoded private key, required if client_certificate_pem is specified",
},
},
},
},
}
}
type Backend struct {
backendbase.Base
client *httpClient
}
func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics {
address := backendbase.GetAttrEnvDefaultFallback(
configVal, "address",
"TF_HTTP_ADDRESS", cty.StringVal(""),
).AsString()
if address == "" {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("address argument is required"),
)
}
updateURL, err := url.Parse(address)
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("failed to parse address URL: %s", err),
)
}
if updateURL.Scheme != "http" && updateURL.Scheme != "https" {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("address must be HTTP or HTTPS"),
)
}
updateMethod := backendbase.GetAttrEnvDefaultFallback(
configVal, "update_method",
"TF_HTTP_UPDATE_METHOD", cty.StringVal("POST"),
).AsString()
var lockURL *url.URL
if v := backendbase.GetAttrEnvDefault(configVal, "lock_address", "TF_HTTP_LOCK_ADDRESS"); !v.IsNull() {
var err error
lockURL, err = url.Parse(v.AsString())
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("failed to parse lock_address URL: %s", err),
)
}
if lockURL.Scheme != "http" && lockURL.Scheme != "https" {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("lock_address must be HTTP or HTTPS"),
)
}
}
lockMethod := backendbase.GetAttrEnvDefaultFallback(
configVal, "lock_method",
"TF_HTTP_LOCK_METHOD", cty.StringVal("LOCK"),
).AsString()
var unlockURL *url.URL
if v := backendbase.GetAttrEnvDefault(configVal, "unlock_address", "TF_HTTP_UNLOCK_ADDRESS"); !v.IsNull() {
var err error
unlockURL, err = url.Parse(v.AsString())
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("failed to parse unlock_address URL: %s", err),
)
}
if unlockURL.Scheme != "http" && unlockURL.Scheme != "https" {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("unlock_address must be HTTP or HTTPS"),
)
}
}
unlockMethod := backendbase.GetAttrEnvDefaultFallback(
configVal, "unlock_method",
"TF_HTTP_UNLOCK_METHOD", cty.StringVal("UNLOCK"),
).AsString()
retryMax, err := backendbase.IntValue(
backendbase.GetAttrEnvDefaultFallback(
configVal, "retry_max",
"TF_HTTP_RETRY_MAX", cty.NumberIntVal(2),
),
)
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("invalid retry_max: %s", err),
)
}
retryWaitMin, err := backendbase.IntValue(
backendbase.GetAttrEnvDefaultFallback(
configVal, "retry_wait_min",
"TF_HTTP_RETRY_WAIT_MIN", cty.NumberIntVal(1),
),
)
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("invalid retry_wait_min: %s", err),
)
}
retryWaitMax, err := backendbase.IntValue(
backendbase.GetAttrEnvDefaultFallback(
configVal, "retry_wait_max",
"TF_HTTP_RETRY_WAIT_MAX", cty.NumberIntVal(30),
),
)
if err != nil {
return backendbase.ErrorAsDiagnostics(
fmt.Errorf("invalid retry_wait_max: %s", err),
)
}
rClient := retryablehttp.NewClient()
rClient.RetryMax = int(retryMax)
rClient.RetryWaitMin = time.Duration(retryWaitMin) * time.Second
rClient.RetryWaitMax = time.Duration(retryWaitMax) * time.Second
rClient.Logger = log.New(logging.LogOutput(), "", log.Flags())
if err = b.configureTLS(rClient, configVal); err != nil {
return backendbase.ErrorAsDiagnostics(err)
}
b.client = &httpClient{
URL: updateURL,
UpdateMethod: updateMethod,
LockURL: lockURL,
LockMethod: lockMethod,
UnlockURL: unlockURL,
UnlockMethod: unlockMethod,
Username: backendbase.GetAttrEnvDefaultFallback(
configVal, "username",
"TF_HTTP_USERNAME", cty.StringVal(""),
).AsString(),
Password: backendbase.GetAttrEnvDefaultFallback(
configVal, "password",
"TF_HTTP_PASSWORD", cty.StringVal(""),
).AsString(),
// accessible only for testing use
Client: rClient,
}
return nil
}
// configureTLS configures TLS when needed; if there are no conditions requiring TLS, no change is made.
func (b *Backend) configureTLS(client *retryablehttp.Client, configVal cty.Value) error {
// If there are no conditions needing to configure TLS, leave the client untouched
skipCertVerification := backendbase.MustBoolValue(
backendbase.GetAttrDefault(configVal, "skip_cert_verification", cty.False),
)
clientCACertificatePem := backendbase.GetAttrEnvDefaultFallback(
configVal, "client_ca_certificate_pem",
"TF_HTTP_CLIENT_CA_CERTIFICATE_PEM", cty.StringVal(""),
).AsString()
clientCertificatePem := backendbase.GetAttrEnvDefaultFallback(
configVal, "client_certificate_pem",
"TF_HTTP_CLIENT_CERTIFICATE_PEM", cty.StringVal(""),
).AsString()
clientPrivateKeyPem := backendbase.GetAttrEnvDefaultFallback(
configVal, "client_private_key_pem",
"TF_HTTP_CLIENT_PRIVATE_KEY_PEM", cty.StringVal(""),
).AsString()
if !skipCertVerification && clientCACertificatePem == "" && clientCertificatePem == "" && clientPrivateKeyPem == "" {
return nil
}
if clientCertificatePem != "" && clientPrivateKeyPem == "" {
return fmt.Errorf("client_certificate_pem is set but client_private_key_pem is not")
}
if clientPrivateKeyPem != "" && clientCertificatePem == "" {
return fmt.Errorf("client_private_key_pem is set but client_certificate_pem is not")
}
// TLS configuration is needed; create an object and configure it
var tlsConfig tls.Config
client.HTTPClient.Transport.(*http.Transport).TLSClientConfig = &tlsConfig
if skipCertVerification {
// ignores TLS verification
tlsConfig.InsecureSkipVerify = true
}
if clientCACertificatePem != "" {
// trust servers based on a CA
tlsConfig.RootCAs = x509.NewCertPool()
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(clientCACertificatePem)) {
return errors.New("failed to append certs")
}
}
if clientCertificatePem != "" && clientPrivateKeyPem != "" {
// attach a client certificate to the TLS handshake (aka mTLS)
certificate, err := tls.X509KeyPair([]byte(clientCertificatePem), []byte(clientPrivateKeyPem))
if err != nil {
return fmt.Errorf("cannot load client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{certificate}
}
return nil
}
func (b *Backend) StateMgr(name string) (statemgr.Full, error) {
if name != backend.DefaultStateName {
return nil, backend.ErrWorkspacesNotSupported
}
return &remote.State{Client: b.client}, nil
}
func (b *Backend) Workspaces() ([]string, error) {
return nil, backend.ErrWorkspacesNotSupported
}
func (b *Backend) DeleteWorkspace(string, bool) error {
return backend.ErrWorkspacesNotSupported
}