diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bd6aaee..7e8b8a47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,10 +33,6 @@ jobs: env: TF_ACC: "1" GO111MODULE: "on" - LXD_REMOTE: localhost - LXD_SCHEME: https - LXD_ADDR: localhost - LXD_PORT: 8443 LXD_GENERATE_CLIENT_CERTS: "true" LXD_ACCEPT_SERVER_CERTIFICATE: "true" @@ -51,7 +47,7 @@ jobs: run: | sudo snap refresh lxd --channel=${{ matrix.channel }} sudo lxd waitready --timeout 60 - sudo lxd init --auto --network-port="$LXD_PORT" --network-address="$LXD_ADDR" + sudo lxd init --auto --network-port=8443 --network-address=localhost sudo chmod 777 /var/snap/lxd/common/lxd/unix.socket # 5.0/* currently use core20 which ships with a buggy lvm2 package so @@ -64,8 +60,8 @@ jobs: sudo snap restart --reload lxd fi - # Generate trust token. - echo "LXD_TOKEN=$(lxc config trust add --name lxd-terraform-provider --quiet)" >> $GITHUB_ENV + # Add HTTPS remote. + lxc remote add localhost "$(lxc config trust add --name lxd-terraform-provider --quiet)" - name: Configure OVN run: | diff --git a/docs/index.md b/docs/index.md index cfe28e29..f6977fe2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,16 +39,14 @@ provider "lxd" { remote { name = "lxd-server-1" - scheme = "https" - address = "10.1.1.8" + address = "https://10.1.1.8:8443" password = "password" default = true } remote { name = "lxd-server-2" - scheme = "https" - address = "10.1.2.8" + address = "https://10.1.2.8" token = "token" } } @@ -77,7 +75,13 @@ The following arguments are supported: The `remote` block supports: -* `address` - *Optional* - The address of the remote. +* `name` - *Optional* - The name of the remote. + +* `protocol` - *Optional* - The protocol of remote server (`lxd` or `simplestreams`). + +* `address` - *Optional* - The remote address in format `[://][:]`. + Scheme can be set to either `unix` or `https`. If scheme is not set, it will default to `unix` if first character is `/`, otherwise to `https`. + Port can be set only for remote HTTPS servers. Port value defaults to `8443` for `lxd` protocol, and to `443` for `simplestreams` protocol. * `default` - *Optional* - Whether this should be the default remote. This remote will then be used when one is not specified in a resource. @@ -88,8 +92,6 @@ The `remote` block supports: for more information. The default can also be set with the `LXD_REMOTE` Environment variable. -* `name` - *Optional* - The name of the remote. - * `password` - *Optional* - The [trust password](https://documentation.ubuntu.com/lxd/en/latest/authentication/#adding-client-certificates-using-a-trust-password) used for initial authentication with the LXD remote. This method is **not recommended** and has been removed in LXD 6.1. Please, use `token` instead. @@ -97,13 +99,6 @@ The `remote` block supports: * `token` - *Optional* - The one-time trust [token](https://documentation.ubuntu.com/lxd/en/latest/authentication/#adding-client-certificates-using-tokens) used for initial authentication with the LXD remote. -* `port` - *Optional* - The port of the remote. - -* `protocol` - *Optional* - The protocol of remote server (`lxd` or `simplestreams`). - -* `scheme` - *Optional* Whether to connect to the remote via `https` or - `unix` (UNIX socket). Defaults to `unix` for LXD remote and `https` for simplestreams remote. - ## Undefined Remote If you choose to _not_ define a `remote`, this provider will attempt @@ -117,9 +112,8 @@ The required variables are: * `LXD_REMOTE` - The name of the remote. * `LXD_ADDR` - The address of the LXD remote. -* `LXD_PORT` - The port of the LXD remote. * `LXD_PASSWORD` - The password of the LXD remote. -* `LXD_SCHEME` - The scheme to use (`unix` or `https`). +* `LXD_TOKEN` - The trust token of the LXD remote. ## PKI Support diff --git a/go.mod b/go.mod index b47041ac..295e28d0 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 github.com/hashicorp/terraform-plugin-framework-validators v0.13.0 github.com/hashicorp/terraform-plugin-go v0.23.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 github.com/hashicorp/terraform-plugin-testing v1.10.0 github.com/mitchellh/go-homedir v1.1.0 @@ -46,7 +47,6 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.21.0 // indirect github.com/hashicorp/terraform-json v0.22.1 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.3 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect diff --git a/internal/acctest/checks.go b/internal/acctest/checks.go index e6520c1b..4d8370de 100644 --- a/internal/acctest/checks.go +++ b/internal/acctest/checks.go @@ -2,10 +2,13 @@ package acctest import ( "fmt" + "net" "os/exec" "strings" "testing" + "time" + "github.com/canonical/lxd/shared/api" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/terraform-lxd/terraform-provider-lxd/internal/utils" @@ -37,7 +40,7 @@ func PreCheckLxdVersion(t *testing.T, versionConstraint string) { serverVersion := apiServer.Environment.ServerVersion ok, err := utils.CheckVersion(serverVersion, versionConstraint) if err != nil { - t.Fatal(err) + t.Fatalf("Failed to check LXD server version: %v", err) } if !ok { @@ -110,6 +113,74 @@ func PreCheckRoot(t *testing.T) { } } +// PreCheckServerExposed skips the test if the server is not exposed on the localhost +// over port 8443. This is required for remote provider tests. +func PreCheckLocalServerHTTPS(t *testing.T) { + conn, err := net.DialTimeout("tcp", "127.0.0.1:8443", 1*time.Second) + if err != nil { + t.Skip(`Skipping remote provider test. LXD is not available on "https://127.0.0.1:8443"`) + } + + conn.Close() +} + +// ConfigureTrustPassword sets and returns the trust password. If the server +// does not support trust password, the test is skipped. +func ConfigureTrustPassword(t *testing.T) string { + password := "test-pass" + + // Only servers with LXD version < 6.0.0 support trust password. + PreCheckLxdVersion(t, "< 6.0.0") + + server, err := testProvider().InstanceServer("", "", "") + if err != nil { + t.Fatal(err) + } + + apiServer, etag, err := server.GetServer() + if err != nil { + t.Fatal(err) + } + + apiServer.Config["core.trust_password"] = password + + err = server.UpdateServer(apiServer.Writable(), etag) + if err != nil { + t.Fatal(err) + } + + return password +} + +// ConfigureTrustToken ensures the trust token is set to "test-pass". If the server +// does not support trust password, the test is skipped. +func ConfigureTrustToken(t *testing.T) string { + server, err := testProvider().InstanceServer("", "", "") + if err != nil { + t.Fatal(err) + } + + // Create new token. + tokenPost := api.CertificatesPost{ + Name: "tf-test-token", + Type: "client", + Token: true, + } + + op, err := server.CreateCertificateToken(tokenPost) + if err != nil { + t.Fatal(err) + } + + opAPI := op.Get() + token, err := opAPI.ToCertificateAddToken() + if err != nil { + t.Fatal(err) + } + + return token.String() +} + // PrintResourceState is a test check function that prints the entire state // of a resource with the given name. This check should be used only for // debuging purposes. diff --git a/internal/acctest/provider_factory.go b/internal/acctest/provider_factory.go index dc1313ae..da9a3f7a 100644 --- a/internal/acctest/provider_factory.go +++ b/internal/acctest/provider_factory.go @@ -1,10 +1,10 @@ package acctest import ( + "fmt" "strings" "sync" - lxd_config "github.com/canonical/lxd/lxc/config" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/terraform-lxd/terraform-provider-lxd/internal/provider" @@ -35,9 +35,16 @@ func testProvider() *provider_config.LxdProviderConfig { defer testProviderMutex.Unlock() if testProviderConfig == nil { - config := lxd_config.DefaultConfig() - acceptClientCert := true - testProviderConfig = provider_config.NewLxdProvider(config, acceptClientCert) + var err error + + options := provider_config.Options{ + AcceptServerCertificate: true, + } + + testProviderConfig, err = provider_config.NewLxdProviderConfig("test", nil, options) + if err != nil { + panic(fmt.Sprintf("Failed to initialize provider: %v", err)) + } } return testProviderConfig diff --git a/internal/provider-config/config.go b/internal/provider-config/config.go index bbe0f8f0..9d136b72 100644 --- a/internal/provider-config/config.go +++ b/internal/provider-config/config.go @@ -2,77 +2,209 @@ package config import ( "encoding/pem" + "errors" "fmt" + "net/url" "os" + "path/filepath" + "slices" + "strings" "sync" "time" lxd "github.com/canonical/lxd/client" - lxd_config "github.com/canonical/lxd/lxc/config" - lxd_shared "github.com/canonical/lxd/shared" - lxd_api "github.com/canonical/lxd/shared/api" + lxdConfig "github.com/canonical/lxd/lxc/config" + "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared/api" "github.com/terraform-lxd/terraform-provider-lxd/internal/utils" ) // supportedLXDVersions defines LXD versions that are supported by the provider. const supportedLXDVersions = ">= 4.0.0" -// A global mutex. -var mutex sync.RWMutex - -// LxdProviderRemoteConfig represents LXD remote/server data as defined -// in a user's Terraform schema/configuration. -type LxdProviderRemoteConfig struct { - Name string - Address string - Port string - Password string - Protocol string - Token string - Scheme string - Bootstrapped bool +// Options for provider config initialization. +type Options struct { + // ConfigDir represents the directory where certificates are stored and + // LXD configuration is searched for. + ConfigDir string + + // AcceptServerCertificate determines whether server certificate should + // be accepted if missing. + AcceptServerCertificate bool + + // GenerateClientCertificates determines whether the client certificates + // should be generated if missing. + GenerateClientCertificates bool +} + +// LxdRemote contains remote server protocol and address. In addition, it may +// contain either a trust token or password that is used for initial server +// authentication if necessary. +type LxdRemote struct { + Protocol string + Address string + Token string + Password string + IsDefault bool + + // server represents cached client connection to the remote server. + // This is lazy-loaded / stored when a connection is established for + // the first time. + server lxd.Server } -// LxdProviderConfig contains the Provider configuration and initialized +// LxdProviderConfig contains the provider configuration and initialized // remote servers. type LxdProviderConfig struct { - // AcceptServerCertificates toggles if an LXD remote SSL certificate - // should be accepted. - acceptServerCertificate bool + // config is the LXD configuration file that contains remotes + // used by the LXD client. + config *lxdConfig.Config - // LXDConfig is the converted form of terraformLXDConfig - // in LXD's native data structure. This is lazy-loaded / created - // only when a connection to an LXD remote/server happens. - // https://github.com/canonical/lxd/blob/main/lxc/config/config.go - lxdConfig *lxd_config.Config + // version of the provider. + version string - // remotes is a map of LXD remotes which the user has defined in - // the Terraform schema/configuration. - remotes map[string]LxdProviderRemoteConfig + // acceptServerCertificates indicates that SSL certificate from an LXD + // remote should be accepted. + acceptServerCertificate bool - // servers is a map of client connections to LXD remote servers. - // These are lazy-loaded / created only when a connection to an LXD - // remote/server is established. - // - // While a client can also be retrieved from LXDConfig, this map serves - // an additional purpose of ensuring Terraform has successfully - // connected and authenticated to each defined LXD server/remote. - servers map[string]lxd.Server + // remotes is a map of all remotes accessible to the provider. + remotes map[string]LxdRemote - // This is a mutex used to handle concurrent reads/writes. + // mux is a lock that handle concurrent reads/writes to the LXD config. mux sync.RWMutex } -// NewLxdProvider returns initialized LXD provider structure. This struct is -// used to store information about this Terraform provider's configuration for -// reference throughout the lifecycle. -func NewLxdProvider(lxdConfig *lxd_config.Config, acceptServerCert bool) *LxdProviderConfig { - return &LxdProviderConfig{ - acceptServerCertificate: acceptServerCert, - lxdConfig: lxdConfig, - remotes: make(map[string]LxdProviderRemoteConfig), - servers: make(map[string]lxd.Server), +// NewLxdProviderConfig initializes a new immutable provider configuration and populates +// it with remotes that are accessible to the provider throughout its lifecycle. Remotes +// are also loaded from LXD configuration file and from environment variables. +// +// Remotes have the following priority: +// Terraform configuration > environment variables > LXD configuration file. +func NewLxdProviderConfig(version string, remotes map[string]LxdRemote, options Options) (*LxdProviderConfig, error) { + configDir := options.ConfigDir + + // Determine LXD config directory. + if configDir == "" { + // Determine LXD configuration directory. First check for the presence + // of the /var/snap/lxd directory. If the directory exists, return + // snap's config path. Otherwise return the fallback path. + _, err := os.Stat("/var/snap/lxd") + if err == nil || os.IsExist(err) { + configDir = "$HOME/snap/lxd/common/config" + } else { + configDir = "$HOME/.config/lxc" + } + } + + configDir = os.ExpandEnv(configDir) + configPath := filepath.Join(configDir, "config.yml") + + // Try to load config.yml from determined configDir. Otherwise load default config. + config, err := lxdConfig.LoadConfig(configPath) + if err != nil { + config = lxdConfig.DefaultConfig() + config.ConfigDir = configDir + } + + p := &LxdProviderConfig{ + acceptServerCertificate: options.AcceptServerCertificate, + version: version, + config: config, + remotes: make(map[string]LxdRemote), + } + + // Load remotes from config to ensure we have a single source of trusth for all remotes. + for name, remote := range config.Remotes { + r := LxdRemote{ + Protocol: remote.Protocol, + Address: remote.Addr, + } + + if r.Protocol == "" { + r.Protocol = "lxd" + } + + err := p.setRemote(name, r) + if err != nil { + return nil, fmt.Errorf("LXD configuration contains invalid remote %q: %v", name, err) + } + } + + // Load LXD remote from environment variables (if defined). + // This emulates the Terraform provider "remote" config: + // + // remote { + // name = LXD_REMOTE + // address = LXD_ADDR + // token = LXD_TOKEN + // password = LXD_PASSWORD + // } + envRemoteName := os.Getenv("LXD_REMOTE") + if envRemoteName != "" { + protocol := "lxd" + + // Resolve the LXD address from environment variable. + address, err := DetermineLXDAddress(protocol, os.Getenv("LXD_ADDR")) + if err != nil { + return nil, fmt.Errorf("Failed to construct LXD address for remote %q defined through environment variables: %v", envRemoteName, err) + } + + // Deprecated! + envScheme := os.Getenv("LXD_SCHEME") + if envScheme != "" { + return nil, fmt.Errorf("Environment variable LXD_SCHEME is deprecated. Use LXD_ADDR=%q instead", address) + } + + // Deprecated! + envPort := os.Getenv("LXD_PORT") + if envPort != "" { + return nil, fmt.Errorf("Environment variable LXD_PORT is deprecated. Use LXD_ADDR=%q instead", address) + } + + // This will be the default remote unless overridden by an + // explicitly defined remote in the Terraform configuration. + envRemote := LxdRemote{ + Address: address, + Password: os.Getenv("LXD_PASSWORD"), + Token: os.Getenv("LXD_TOKEN"), + Protocol: protocol, + IsDefault: true, + } + + err = p.setRemote(envRemoteName, envRemote) + if err != nil { + return nil, fmt.Errorf("LXD remote %q defined through environment variables is invalid: %v", envRemoteName, err) + } + } + + var defaultRemotes []string + + // Load LXD remote from Terraform configuration. + for name, remote := range remotes { + err := p.setRemote(name, remote) + if err != nil { + return nil, fmt.Errorf("Invalid remote %q: %v", name, err) + } + + if remote.IsDefault { + defaultRemotes = append(defaultRemotes, name) + } } + + // Ensure only one remote is configured as default. + if len(defaultRemotes) > 0 { + return nil, fmt.Errorf("Multiple remotes are configured as default: [%v]", strings.Join(defaultRemotes, ", ")) + } + + // Generate client certificates (if necessary). + if options.GenerateClientCertificates { + err = p.GenerateClientCertificate() + if err != nil { + return nil, err + } + } + + return p, nil } // InstanceServer returns a LXD InstanceServer client for the given remote. @@ -83,9 +215,6 @@ func (p *LxdProviderConfig) InstanceServer(remoteName string, project string, ta return nil, err } - p.mux.RLock() - defer p.mux.RUnlock() - connInfo, err := server.GetConnectionInfo() if err != nil { return nil, err @@ -110,149 +239,105 @@ func (p *LxdProviderConfig) ImageServer(remoteName string) (lxd.ImageServer, err return nil, err } - p.mux.RLock() - defer p.mux.RUnlock() - connInfo, err := server.GetConnectionInfo() if err != nil { return nil, err } - if connInfo.Protocol == "simplestreams" || connInfo.Protocol == "lxd" { - return server.(lxd.ImageServer), nil + if connInfo.Protocol != "simplestreams" && connInfo.Protocol != "lxd" { + return nil, fmt.Errorf("Remote %q (%s / %s) is not an ImageServer", remoteName, connInfo.Protocol, connInfo.Addresses[0]) } - err = fmt.Errorf("Remote %q (%s / %s) is not an ImageServer", remoteName, connInfo.Protocol, connInfo.Addresses[0]) - return nil, err + return server.(lxd.ImageServer), nil } -// getServer returns a server for the named remote. The returned server +// server returns a server for the named remote. The returned server // can be either of type ImageServer or InstanceServer. func (p *LxdProviderConfig) server(remoteName string) (lxd.Server, error) { - // If remoteName is empty, use default LXD remote (most likely "local"). + p.mux.Lock() + defer p.mux.Unlock() + + // If remote is not set, use default remote. if remoteName == "" { - remoteName = p.lxdConfig.DefaultRemote + remoteName = p.config.DefaultRemote } - // Check if there is an already initialized LXD server. - p.mux.Lock() - server, ok := p.servers[remoteName] - p.mux.Unlock() - if ok { - return server, nil + remote, ok := p.remotes[remoteName] + if !ok { + return nil, fmt.Errorf("Remote %q does not exist", remoteName) } - // If the server is not already created, create a new one. - remote := p.remote(remoteName) - if remote != nil && !remote.Bootstrapped { - err := p.createLxdServerClient(*remote) - if err != nil { - return nil, fmt.Errorf("Unable to create server client for remote %q: %v", remoteName, err) - } + // Check if there is an already initialized LXD server. + server := remote.server + if server != nil { + return server, nil } - var err error - - lxdRemoteConfig := p.getLxdConfigRemote(remoteName) - - switch lxdRemoteConfig.Protocol { - case "simplestreams": - server, err = p.getLxdConfigImageServer(remoteName) - if err != nil { - return nil, err - } - default: - server, err = p.getLxdConfigInstanceServer(remoteName) + // Initialize new server for the given remote. + if remote.Protocol == "simplestreams" { + imgServer, err := p.config.GetImageServer(remoteName) if err != nil { return nil, err } - // Ensure that LXD version meets the provider's version constraint. - err := verifyLxdServerVersion(server.(lxd.InstanceServer)) - if err != nil { - return nil, fmt.Errorf("Remote %q: %v", remoteName, err) - } - } - - // Add the server to the lxdServer map (cache). - p.mux.Lock() - defer p.mux.Unlock() - - p.servers[remoteName] = server - - return server, nil -} - -// createLxdServerClient will create an LXD client for a given remote. -// The client is then stored in the lxdProvider.Config collection of clients. -func (p *LxdProviderConfig) createLxdServerClient(remote LxdProviderRemoteConfig) error { - if remote.Address == "" { - return nil - } - - lxdRemote := lxd_config.Remote{ - Addr: determineLxdDaemonAddr(remote), - Protocol: remote.Protocol, - } - - p.setLxdConfigRemote(remote.Name, lxdRemote) - - if remote.Scheme == "https" && remote.Protocol == "lxd" { - // If the LXD remote's certificate does not exist on the client... - p.mux.RLock() - certPath := p.lxdConfig.ServerCertPath(remote.Name) - p.mux.RUnlock() - - if !lxd_shared.PathExists(certPath) { - // Try to obtain an early connection to the remote server. - // If it succeeds, then either the certificates between - // the remote and the client have already been exchanged - // or PKI is being used. - instServer, _ := p.getLxdConfigInstanceServer(remote.Name) + // Cache initialized server. + remote.server = imgServer + } else { + // getLxdServer retrieves the instance server and the corresponding api server + // for the given remote. + getLxdServer := func(remoteName string) (lxd.InstanceServer, *api.Server, error) { + instServer, err := p.config.GetInstanceServer(remoteName) + if err != nil { + return nil, nil, err + } - err := connectToLxdServer(instServer) + apiServer, _, err := instServer.GetServer() if err != nil { - // Either PKI isn't being used or certificates haven't been - // exchanged. Try to add the remote server certificate. - if p.acceptServerCertificate { - err := p.fetchLxdServerCertificate(remote.Name) - if err != nil { - return fmt.Errorf("Failed to get remote server certificate: %v", err) - } - } else { - return fmt.Errorf("Unable to communicate with remote server. Either set " + - "accept_remote_certificate to true or add the remote out of band " + - "of Terraform and try again.") - } + return nil, nil, err } + + return instServer, apiServer, nil } - // Set bootstrapped to true to prevent an infinite loop. - // This is required for situations when a remote might be - // defined in a config.yml file but the client has not yet - // exchanged certificates with the remote. - remote.Bootstrapped = true - p.SetRemote(remote, false) + isHTTPS := strings.HasPrefix(remote.Address, "https://") - // Finally, make sure the client is authenticated. - instServer, err := p.InstanceServer(remote.Name, "", "") + // Try to obtain an early connection to the remote server. + instServer, apiServer, err := getLxdServer(remoteName) if err != nil { - return err - } + // For non-https remotes we should be able to communicate with + // them. It is most likely an issue in the configuration. + if !isHTTPS { + return nil, err + } - p.mux.Lock() - defer p.mux.Unlock() + // Failure for HTTPS remote indicates that either PKI is not being + // used or certificates have not been exchanged yet. + certPath := p.config.ServerCertPath(remoteName) + if shared.PathExists(certPath) { + // Server's certificate exists locally, but we are still + // unable to communicate with the server. + return nil, err + } - server, _, err := instServer.GetServer() - if err != nil { - return err + // Try to accept the remote certificate. + err := p.acceptRemoteCertificate(remoteName, remote.Address) + if err != nil { + return nil, fmt.Errorf("Failed to accept server certificate for remote %q: %v", remoteName, err) + } + + // Retrieve the LXD server again. Now the connection must + // succeed because we have accepted the remote certificate. + instServer, apiServer, err = getLxdServer(remoteName) + if err != nil { + return nil, err + } } - // Authenticate to the remote LXD server. If successful, the LXD server becomes - // trusted to the LXD client, and vice-versa. - if server.Auth != "trusted" { - req := lxd_api.CertificatesPost{} - req.Type = "client" + // Authenticate against HTTPS remote if it is not already trusted. + if isHTTPS && apiServer.Auth != "trusted" { + req := api.CertificatesPost{ + Type: "client", + } if remote.Token != "" { if instServer.HasExtension("explicit_trust_token") { @@ -266,86 +351,116 @@ func (p *LxdProviderConfig) createLxdServerClient(remote LxdProviderRemoteConfig // Create new certificate. errCert := instServer.CreateCertificate(req) - if errCert != nil { - // If request to create a certificate failed, refresh the - // server and check again whether the server is trusted. - server, _, err = instServer.GetServer() - if err != nil { - return err - } - if server.Auth != "trusted" { - return fmt.Errorf("Unable to authenticate with remote server: %v", errCert) - } + // Refresh the server and check again whether the server is trusted. + apiServer, _, err = instServer.GetServer() + if err != nil { + return nil, err } + + if apiServer.Auth != "trusted" { + return nil, fmt.Errorf("Unable to authenticate with remote server: %v", errCert) + } + } + + // Ensure LXD version is supported by the provider. + serverVersion := apiServer.Environment.ServerVersion + + ok, err := utils.CheckVersion(serverVersion, supportedLXDVersions) + if err != nil { + return nil, err + } + + if !ok { + return nil, fmt.Errorf("LXD server with version %q does not meet the required version constraint: %q", serverVersion, supportedLXDVersions) } + + // Cache initialized server. + remote.server = instServer } - return nil + p.remotes[remoteName] = remote + return remote.server, nil } -// fetchServerCertificate will attempt to retrieve a remote LXD server's -// certificate and save it to the servercerts path. -func (p *LxdProviderConfig) fetchLxdServerCertificate(remoteName string) error { - lxdRemote := p.getLxdConfigRemote(remoteName) +// setRemote validates the remote and stores it into the provider config with a given name +// overwriting any existing remote. +func (p *LxdProviderConfig) setRemote(remoteName string, remote LxdRemote) error { + p.mux.Lock() + defer p.mux.Unlock() - certificate, err := lxd_shared.GetRemoteCertificate(lxdRemote.Addr, "terraform-provider-lxd/2.0") - if err != nil { - return err + // Validate remote. + if remoteName == "" { + return errors.New("Remote name cannot be empty") } - certDir := p.lxdConfig.ConfigPath("servercerts") - err = os.MkdirAll(certDir, 0750) - if err != nil { - return err + if remote.Password != "" && remote.Token != "" { + return errors.New("Remote token and password are mutually exclusive") } - certPath := fmt.Sprintf("%s/%s.crt", certDir, remoteName) - certFile, err := os.Create(certPath) - if err != nil { - return err + if !strings.HasPrefix(remote.Address, "https:") && !strings.HasPrefix(remote.Address, "unix:") { + return fmt.Errorf(`"Invalid address %q. Address must start with "https:" or "unix:"`, remote.Address) } - defer certFile.Close() + validProtocols := []string{"lxd", "simplestreams"} + if !slices.Contains(validProtocols, remote.Protocol) { + return fmt.Errorf("Invalid protocol %q. Value must be one of: [%s]", remote.Protocol, strings.Join(validProtocols, ", ")) + } + + // Set default server. Only LXD server can be default server. + if remote.IsDefault { + if remote.Protocol != "lxd" { + return fmt.Errorf(`Remote %q cannot be set as default remote. Default remote must use "lxd"protocol`, remoteName) + } + + p.config.DefaultRemote = remoteName + } + + // Store remote in LXD config to make it accessible within the LXD client. + p.config.Remotes[remoteName] = lxdConfig.Remote{ + Addr: remote.Address, + Protocol: remote.Protocol, + } - return pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}) + p.remotes[remoteName] = remote + return nil } -// verifyLXDVersion verifies whether the version of target LXD server matches the -// provider's required version contraint. -func verifyLxdServerVersion(instServer lxd.InstanceServer) error { - server, _, err := instServer.GetServer() +// acceptRemoteCertificate retrieves the unverified peer certificate found at +// the remote address and stores it locally. +func (p *LxdProviderConfig) acceptRemoteCertificate(remoteName string, url string) error { + // Check if we are allowed to accept the remote certificate. + if !p.acceptServerCertificate { + return errors.New("Unable to communicate with remote server. " + + `Either set "accept_remote_certificate" to true or add ` + + "the remote out of band of Terraform and try again.") + } + + // Try to retrieve server's certificate. + cert, err := shared.GetRemoteCertificate(url, fmt.Sprintf("terraform-provider-lxd/%s", p.version)) if err != nil { return err } - serverVersion := server.Environment.ServerVersion - if serverVersion == "" { - // If server version is empty, it means that authentication - // has failed, therefore we can ignore version check. - return nil - } + certPath := p.config.ServerCertPath(remoteName) + certDir := filepath.Dir(certPath) - ok, err := utils.CheckVersion(serverVersion, supportedLXDVersions) + // Ensure the certificate directory exists. + err = os.MkdirAll(certDir, 0750) if err != nil { return err } - if !ok { - return fmt.Errorf("LXD server with version %q does not meet the required version constraint: %q", serverVersion, supportedLXDVersions) + // Open certificate file. + certFile, err := os.Create(certPath) + if err != nil { + return err } - return nil -} - -// connectToLxdServer makes a simple GET request to the servers API to ensure -// connection can be successfully established. -func connectToLxdServer(instServer lxd.InstanceServer) error { - if instServer == nil { - return fmt.Errorf("Instance server is nil") - } + defer certFile.Close() - _, _, err := instServer.GetServer() + // Store certificate locally. + err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) if err != nil { return err } @@ -353,98 +468,96 @@ func connectToLxdServer(instServer lxd.InstanceServer) error { return nil } -// determineLxdDaemonAddr determines address of the LXD server daemon. -func determineLxdDaemonAddr(remote LxdProviderRemoteConfig) string { - var daemonAddr string +// SelectRemote resolves the provided remote name. If the remote with a +// given name is not found, the default remote is returned. +func (p *LxdProviderConfig) SelectRemote(remoteName string) string { + p.mux.RLock() + defer p.mux.RUnlock() - if remote.Address != "" { - switch remote.Scheme { - case "unix", "": - daemonAddr = fmt.Sprintf("unix:%s", remote.Address) - case "https": - daemonAddr = fmt.Sprintf("https://%s:%s", remote.Address, remote.Port) - } + _, ok := p.remotes[remoteName] + if ok { + return remoteName } - return daemonAddr + return p.config.DefaultRemote } -/* Getters & Setters */ - -// remote returns LXD remote with the given name or default otherwise. -func (p *LxdProviderConfig) remote(name string) *LxdProviderRemoteConfig { +// GenerateClientCertificate generates the client certificate if it does +// not already exist. +func (p *LxdProviderConfig) GenerateClientCertificate() error { p.mux.RLock() defer p.mux.RUnlock() - remote, ok := p.remotes[name] - if !ok { - remote, ok = p.remotes[p.lxdConfig.DefaultRemote] - if !ok { - return nil - } + err := p.config.GenerateClientCertificate() + if err != nil { + return fmt.Errorf("Failed to generate client certificate: %w", err) } - return &remote + return nil } -// SetRemote set LXD remote for the given name. -func (p *LxdProviderConfig) SetRemote(remote LxdProviderRemoteConfig, isDefault bool) { - p.mux.Lock() - defer p.mux.Unlock() +// DefaultTimeout returns the default time period after which a resource +// action (read/create/update/delete) is expected to time out. +func (p *LxdProviderConfig) DefaultTimeout() time.Duration { + return 5 * time.Minute +} - if isDefault { - p.lxdConfig.DefaultRemote = remote.Name - } +// DetermineLXDAddress is a helper function that constructs the server +// address from the provided protocol, scheme, address, and port. +func DetermineLXDAddress(protocol string, address string) (string, error) { + var scheme string - p.remotes[remote.Name] = remote -} + // Try to extract scheme from the address. + if strings.Contains(address, "://") { + scheme = strings.SplitN(address, "://", 2)[0] + } -// SelectRemote returns the specified remote name if it exists, or the default -// remote name otherwise. -func (p *LxdProviderConfig) SelectRemote(name string) string { - p.mux.RLock() - defer p.mux.RUnlock() + // If scheme is still empty, determine it based on the value. + // If address is empty or starts with "/", assume unix socket. + if scheme == "" { + scheme = "https" + if address == "" || strings.HasPrefix(address, "/") { + scheme = "unix" + } + } - _, ok := p.remotes[name] - if ok { - return name + // Error out if simplestreams protocol is used with non-HTTPS scheme. + if scheme != "https" && protocol == "simplestreams" { + return "", fmt.Errorf("Simplestreams remote address %q requires HTTPS scheme", address) } - return p.lxdConfig.DefaultRemote -} + // Prepend the scheme to the address. + if !strings.HasPrefix(address, fmt.Sprintf("%s://", scheme)) { + address = fmt.Sprintf("%s://%s", scheme, address) + } -// setLxdServer set LXD server for the given name. -func (p *LxdProviderConfig) getLxdConfigRemote(name string) lxd_config.Remote { - p.mux.RLock() - defer p.mux.RUnlock() - return p.lxdConfig.Remotes[name] -} + switch scheme { + case "unix": + return address, nil + case "https": + // Parse as URL. + url, err := url.Parse(address) + if err != nil { + return "", fmt.Errorf("Failed to parse address %q: %w", address, err) + } -// setLxdServer set LXD server for the given name. -func (p *LxdProviderConfig) setLxdConfigRemote(name string, remote lxd_config.Remote) { - p.mux.Lock() - defer p.mux.Unlock() - p.lxdConfig.Remotes[name] = remote -} + // Ensure hostname is not empty. + if url.Hostname() == "" { + return "", fmt.Errorf("Invalid HTTPS address %q", address) + } -// getLxdConfigInstanceServer will retrieve an LXD InstanceServer client -// in a conncurrent-safe way. -func (p *LxdProviderConfig) getLxdConfigInstanceServer(remoteName string) (lxd.InstanceServer, error) { - p.mux.RLock() - defer p.mux.RUnlock() - return p.lxdConfig.GetInstanceServer(remoteName) -} + // If port is empty, determine it based on the used protocol. + if url.Port() == "" { + port := "8443" + if protocol == "simplestreams" { + port = "443" + } -// getLxdConfigImageServer will retrieve an LXD ImageServer client -// in a conncurrent-safe way. -func (p *LxdProviderConfig) getLxdConfigImageServer(remoteName string) (lxd.ImageServer, error) { - p.mux.RLock() - defer p.mux.RUnlock() - return p.lxdConfig.GetImageServer(remoteName) -} + address = fmt.Sprintf("%s:%s", address, port) + } -// DefaultTimeout returns the default time period after which a resource -// action (read/create/update/delete) is expected to time out. -func (p *LxdProviderConfig) DefaultTimeout() time.Duration { - return 5 * time.Minute + return address, nil + default: + return "", fmt.Errorf("Invalid scheme %q: Value must be one of: [unix, https]", scheme) + } } diff --git a/internal/provider-config/config_test.go b/internal/provider-config/config_test.go new file mode 100644 index 00000000..d3111a11 --- /dev/null +++ b/internal/provider-config/config_test.go @@ -0,0 +1,90 @@ +package config + +import "testing" + +func TestDetermineLXDAddress(t *testing.T) { + tests := []struct { + Name string + Protocol string + Address string + Expect string + ExpectErr bool + }{ + { + Name: "Empty address", + Address: "", + Expect: "unix://", + }, + { + Name: "Address starts with /", + Address: "/path/to/socket", + Expect: "unix:///path/to/socket", + }, + { + Name: "Address does not start with /", + Address: "localhost:8443", + Expect: "https://localhost:8443", + }, + { + Name: "Only hostname | Protocol lxd", + Address: "localhost", + Expect: "https://localhost:8443", + }, + { + Name: "Only hostname | Protocol simplestreams", + Protocol: "simplestreams", + Address: "localhost", + Expect: "https://localhost:443", + }, + { + Name: "Scheme and hostname | Protocol lxd", + Address: "https://localhost", + Expect: "https://localhost:8443", + }, + { + Name: "Scheme and hostname | Protocol simplestreams", + Protocol: "simplestreams", + Address: "https://localhost", + Expect: "https://localhost:443", + }, + { + Name: "Scheme, hostname, port | URL", + Address: "https://localhost:1234", + Expect: "https://localhost:1234", + }, + // Expected errors. + { + Name: "Unsupported simplestreams scheme", + Protocol: "simplestreams", + Address: "/path/to/socket", + ExpectErr: true, + }, + { + Name: "Missing hostname", + Address: "https://:8443", + ExpectErr: true, + }, + { + Name: "Unsupported scheme", + Address: "http://localhost:8443", + ExpectErr: true, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + addr, err := DetermineLXDAddress(test.Protocol, test.Address) + if err != nil && !test.ExpectErr { + t.Fatalf("Unexpected error: %v", err) + } + + if err == nil && test.ExpectErr { + t.Fatalf("Expected an error, but got none") + } + + if addr != test.Expect { + t.Fatalf("Expected address %q, got %q", test.Expect, addr) + } + }) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 045b5577..2691a87a 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,12 +2,10 @@ package provider import ( "context" - "log" + "fmt" "os" - "path/filepath" - lxd_config "github.com/canonical/lxd/lxc/config" - lxd_shared "github.com/canonical/lxd/shared" + "github.com/canonical/lxd/shared" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/path" @@ -16,11 +14,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/terraform-lxd/terraform-provider-lxd/internal/image" "github.com/terraform-lxd/terraform-provider-lxd/internal/instance" "github.com/terraform-lxd/terraform-provider-lxd/internal/network" "github.com/terraform-lxd/terraform-provider-lxd/internal/profile" "github.com/terraform-lxd/terraform-provider-lxd/internal/project" + config "github.com/terraform-lxd/terraform-provider-lxd/internal/provider-config" provider_config "github.com/terraform-lxd/terraform-provider-lxd/internal/provider-config" "github.com/terraform-lxd/terraform-provider-lxd/internal/storage" "github.com/terraform-lxd/terraform-provider-lxd/internal/truststore" @@ -92,24 +92,30 @@ func (p *LxdProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp * "name": schema.StringAttribute{ Required: true, Description: "Name of the LXD remote. Required when lxd_scheme set to https, to enable locating server certificate.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, }, "address": schema.StringAttribute{ Optional: true, - Description: "The FQDN or IP where the LXD daemon can be contacted. (default = \"\" (read from lxc config))", + Description: "The FQDN or IP where the LXD daemon can be contacted. (default = \"\")", }, + // Deprecated, leave the attribute in so that we error out if it's used. + // DeprecationMessage would just print the warning, but we want to error + // out with a custom message. "port": schema.StringAttribute{ Optional: true, Description: "Port LXD Daemon API is listening on. (default = 8443)", }, + // Deprecated, leave the attribute in so that we error out if it's used. + // DeprecationMessage would just print the warning, but we want to error + // out with a custom message. "scheme": schema.StringAttribute{ Optional: true, Description: "Unix (unix) or HTTPs (https). (default = unix)", - Validators: []validator.String{ - stringvalidator.OneOf("unix", "https"), - }, }, "password": schema.StringAttribute{ @@ -154,151 +160,92 @@ func (p *LxdProvider) Configure(ctx context.Context, req provider.ConfigureReque diags := req.Config.Get(ctx, &data) resp.Diagnostics.Append(diags...) - // Determine LXD configuration directory. First check for the presence - // of the /var/snap/lxd directory. If the directory exists, return - // snap's config path. Otherwise return the fallback path. - configDir := data.ConfigDir.ValueString() - if configDir == "" { - _, err := os.Stat("/var/snap/lxd") - if err == nil || os.IsExist(err) { - configDir = "$HOME/snap/lxd/common/config" - } else { - configDir = "$HOME/.config/lxc" - } - } - - configDir = os.ExpandEnv(configDir) - - // Try to load config.yml from determined configDir. If there's - // an error loading config.yml, default config will be used. - configPath := filepath.Join(configDir, "config.yml") - config, err := lxd_config.LoadConfig(configPath) - if err != nil { - config = lxd_config.DefaultConfig() - config.ConfigDir = configDir - } - - log.Printf("[DEBUG] LXD Config: %#v", config) - - // Determine if the LXD server's SSL certificates should be - // accepted. If this is set to false and if the remote's - // certificates haven't already been accepted, the user will - // need to accept the certificates out of band of Terraform. + // Determine if the LXD server's SSL certificates should be accepted. + // If this is set to false and if the remote's certificates haven't + // already been accepted, the user will need to accept the certificates + // out of band of Terraform. acceptServerCertificate := data.AcceptRemoteCertificate.ValueBool() if data.AcceptRemoteCertificate.IsNull() || data.AcceptRemoteCertificate.IsUnknown() { v, ok := os.LookupEnv("LXD_ACCEPT_SERVER_CERTIFICATE") if ok { - acceptServerCertificate = lxd_shared.IsTrue(v) + acceptServerCertificate = shared.IsTrue(v) } } - // Determine if the client LXD (ie: the workstation running Terraform) - // should generate client certificates if they don't already exist. + // Determine if the missing client certificates should be generated. + // This has no effect if the certificates already exist. generateClientCertificates := data.GenerateClientCertificates.ValueBool() - if data.AcceptRemoteCertificate.IsNull() || data.GenerateClientCertificates.IsUnknown() { + if data.GenerateClientCertificates.IsNull() || data.GenerateClientCertificates.IsUnknown() { v, ok := os.LookupEnv("LXD_GENERATE_CLIENT_CERTS") if ok { - generateClientCertificates = lxd_shared.IsTrue(v) - } - } - - if generateClientCertificates { - err := config.GenerateClientCertificate() - if err != nil { - resp.Diagnostics.AddError("Failed to generate client certificate", err.Error()) - return + generateClientCertificates = shared.IsTrue(v) } } - // Initialize global LxdProvider struct. - // This struct is used to store information about this Terraform - // provider's configuration for reference throughout the lifecycle. - lxdProvider := provider_config.NewLxdProvider(config, acceptServerCertificate) - - // Create LXD remote from environment variables (if defined). - // This emulates the Terraform provider "remote" config: - // - // remote { - // name = LXD_REMOTE - // address = LXD_ADDR - // ... - // } - envName := os.Getenv("LXD_REMOTE") - if envName != "" { - envRemote := provider_config.LxdProviderRemoteConfig{ - Name: envName, - Address: os.Getenv("LXD_ADDR"), - Port: os.Getenv("LXD_PORT"), - Password: os.Getenv("LXD_PASSWORD"), - Token: os.Getenv("LXD_TOKEN"), - Scheme: os.Getenv("LXD_SCHEME"), - Protocol: "lxd", - } - - // This will be the default remote unless overridden by an - // explicitly defined remote in the Terraform configuration. - lxdProvider.SetRemote(envRemote, true) - } + remotes := make(map[string]config.LxdRemote) - // Loop over LXD Remotes defined in the schema and create - // an lxdRemoteConfig for each one. - // - // This does not yet connect to any of the defined remotes, - // it only stores the configuration information until it is - // necessary to connect to the remote. - // - // This lazy loading allows this LXD provider to be used - // in Terraform configurations where the LXD remote might not - // exist yet. + // Read remotes from Terraform schema. for _, remote := range data.Remotes { + name := remote.Name.ValueString() + protocol := remote.Protocol.ValueString() if protocol == "" { protocol = "lxd" } - port := remote.Port.ValueString() - if port == "" { - port = "8443" - if protocol == "simplestreams" { - port = "443" - } + address, err := config.DetermineLXDAddress(protocol, remote.Address.ValueString()) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Invalid remote %q", name), err.Error()) + return } - scheme := remote.Scheme.ValueString() - if scheme == "" { - scheme = "unix" - if protocol == "simplestreams" { - scheme = "https" - } + // Error out if deprecated port and scheme attributes are used. + if remote.Port.ValueString() != "" { + resp.Diagnostics.AddError( + fmt.Sprintf(`Remote %q contains deprecated attribute "port"`, name), + fmt.Sprintf(`Please remove the attribute "port" and set "address" to the fully qualified address instead. For example, "address=%s".`, address), + ) + return } - lxdRemote := provider_config.LxdProviderRemoteConfig{ - Name: remote.Name.ValueString(), - Password: remote.Password.ValueString(), - Token: remote.Token.ValueString(), - Address: remote.Address.ValueString(), - Protocol: protocol, - Port: port, - Scheme: scheme, + if remote.Scheme.ValueString() != "" { + resp.Diagnostics.AddError( + fmt.Sprintf(`Remote %q contains deprecated attribute "scheme"`, name), + fmt.Sprintf(`Please remove the attribute "port" and set "address" to the fully qualified address instead. For example, "address=%s".`, address), + ) + return } - isDefault := remote.Default.ValueBool() - if protocol == "simplestreams" { - // Simplestreams cannot be default. - isDefault = false + remotes[name] = provider_config.LxdRemote{ + Address: address, + Protocol: protocol, + Password: remote.Password.ValueString(), + Token: remote.Token.ValueString(), + IsDefault: remote.Default.ValueBool(), } + } + + options := provider_config.Options{ + ConfigDir: data.ConfigDir.ValueString(), + AcceptServerCertificate: acceptServerCertificate, + GenerateClientCertificates: generateClientCertificates, + } - lxdProvider.SetRemote(lxdRemote, isDefault) + // Initialize LXD provider configuration. + lxdProvider, err := provider_config.NewLxdProviderConfig(p.version, remotes, options) + if err != nil { + resp.Diagnostics.AddError("Failed initialize LXD provider", err.Error()) + return } - log.Printf("[DEBUG] LXD Provider: %#v", &lxdProvider) + tflog.Debug(ctx, "LXD Provider configured", map[string]any{"provider": lxdProvider}) resp.ResourceData = lxdProvider resp.DataSourceData = lxdProvider } func (p *LxdProvider) Resources(_ context.Context) []func() resource.Resource { - return []func() resource.Resource{ + resources := []func() resource.Resource{ image.NewCachedImageResource, image.NewPublishImageResource, instance.NewInstanceResource, @@ -319,6 +266,13 @@ func (p *LxdProvider) Resources(_ context.Context) []func() resource.Resource { truststore.NewTrustCertificateResource, truststore.NewTrustTokenResource, } + + // Resources for testing. + if p.version == "test" { + resources = append(resources, newNoopResource) + } + + return resources } func (p *LxdProvider) DataSources(_ context.Context) []func() datasource.DataSource { diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 00000000..3ef6f079 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,278 @@ +package provider_test + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/terraform-lxd/terraform-provider-lxd/internal/acctest" +) + +func TestAccProvider_configDir(t *testing.T) { + defer resetLXDRemoteEnvVars() + + configDir := t.TempDir() + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Ensure config dir is configurable using Terraform configuration. + Config: testAccProvider_configDir(configDir), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_noop.noop", "remote", "local"), + resource.TestCheckResourceAttr("lxd_noop.noop", "project", "default"), + resource.TestCheckResourceAttrSet("lxd_noop.noop", "server_version"), + testCheckClientCert(configDir, true), + ), + }, + }, + }) + resetLXDRemoteEnvVars() +} + +func TestAccProvider_trustToken(t *testing.T) { + defer resetLXDRemoteEnvVars() + + token := acctest.ConfigureTrustToken(t) + configDir := t.TempDir() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckLocalServerHTTPS(t) + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Ensure authentication fails with incorrect token. + Config: testAccProvider_remoteServer(configDir, "", "invalid", true), + ExpectError: regexp.MustCompile(`not authorized`), + }, + { + // Ensure authentication succeeds with correct token. + Config: testAccProvider_remoteServer(configDir, "", token, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_noop.noop", "remote", "tf-remote"), + resource.TestCheckResourceAttr("lxd_noop.noop", "project", "default"), + resource.TestCheckResourceAttrSet("lxd_noop.noop", "server_version"), + ), + }, + { + // Ensure authentication succeeds if token is provided + // as environment variable. + PreConfig: func() { + configDir = t.TempDir() + os.Setenv("LXD_REMOTE", "tf-remote-token-fqdn") + os.Setenv("LXD_ADDR", "https://127.0.0.1:8443") + os.Setenv("LXD_TOKEN", acctest.ConfigureTrustToken(t)) + }, + Config: testAccProvider_remoteServerEnv(configDir), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_noop.noop", "remote", "tf-remote-token-fqdn"), + resource.TestCheckResourceAttr("lxd_noop.noop", "project", "default"), + resource.TestCheckResourceAttrSet("lxd_noop.noop", "server_version"), + ), + }, + }, + }) +} + +func TestAccProvider_trustPassword(t *testing.T) { + defer resetLXDRemoteEnvVars() + + password := acctest.ConfigureTrustPassword(t) + configDir := t.TempDir() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckLocalServerHTTPS(t) + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Ensure authentication fails with incorrect password. + Config: testAccProvider_remoteServer(configDir, "invalid", "", true), + ExpectError: regexp.MustCompile(`not authorized`), + }, + { + // Ensure authentication succeeds with correct token. + Config: testAccProvider_remoteServer(configDir, password, "", true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_noop.noop", "remote", "tf-remote"), + resource.TestCheckResourceAttr("lxd_noop.noop", "project", "default"), + resource.TestCheckResourceAttrSet("lxd_noop.noop", "server_version"), + ), + }, + { + // Ensure authentication succeeds if password is provided + // as environment variable. + PreConfig: func() { + configDir = t.TempDir() + os.Setenv("LXD_REMOTE", "tf-remote-pass-fqdn") + os.Setenv("LXD_ADDR", "https://127.0.0.1:8443") + os.Setenv("LXD_PASSWORD", acctest.ConfigureTrustPassword(t)) + }, + Config: testAccProvider_remoteServerEnv(configDir), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_noop.noop", "remote", "tf-remote-pass-fqdn"), + resource.TestCheckResourceAttr("lxd_noop.noop", "project", "default"), + resource.TestCheckResourceAttrSet("lxd_noop.noop", "server_version"), + ), + }, + }, + }) +} + +func TestAccProvider_generateClientCertificate(t *testing.T) { + configDir := t.TempDir() + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Ensure certificates are missing. + Config: testAccProvider_localServer(configDir, false), + Check: resource.ComposeTestCheckFunc( + testCheckClientCert(configDir, false), + ), + }, + { + // Ensure certificates are generated. + Config: testAccProvider_localServer(configDir, true), + Check: resource.ComposeTestCheckFunc( + testCheckClientCert(configDir, true), + ), + }, + }, + }) +} + +func TestAccProvider_acceptRemoteCertificate(t *testing.T) { + token := acctest.ConfigureTrustToken(t) + configDir := t.TempDir() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + acctest.PreCheckLocalServerHTTPS(t) + }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + // Ensure authentication fails if remote server is not accepted. + Config: testAccProvider_remoteServer(configDir, "", token, false), + ExpectError: regexp.MustCompile(`Failed to accept server certificate`), + }, + { + // Ensure authentication succeeds if remote server is accepted. + Config: testAccProvider_remoteServer(configDir, "", token, true), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_noop.noop", "remote", "tf-remote"), + resource.TestCheckResourceAttr("lxd_noop.noop", "project", "default"), + resource.TestCheckResourceAttrSet("lxd_noop.noop", "server_version"), + ), + }, + }, + }) +} + +func testAccProvider_configDir(configDir string) string { + return fmt.Sprintf(` +provider "lxd" { + generate_client_certificates = true + config_dir = %q +} + +resource "lxd_noop" "noop" { +} + `, configDir) +} + +func testAccProvider_localServer(configDir string, generateClientCert bool) string { + return fmt.Sprintf(` +provider "lxd" { + generate_client_certificates = %v + accept_remote_certificate = true + config_dir = %q +} + +resource "lxd_noop" "noop" { +} + `, generateClientCert, configDir) +} + +func testAccProvider_remoteServer(configDir string, password string, token string, acceptRemoteCert bool) string { + // Trust password and token are mutually exclusive in the configuration. + authField := "" + if password != "" { + authField = fmt.Sprintf("password = %q", password) + } else if token != "" { + authField = fmt.Sprintf("token = %q", token) + } + + return fmt.Sprintf(` +provider "lxd" { + config_dir = %q + generate_client_certificates = true + accept_remote_certificate = %v + + remote { + name = "tf-remote" + protocol = "lxd" + address = "https://127.0.0.1:8443" + %s + } +} + +resource "lxd_noop" "noop" { + remote = "tf-remote" +} + `, configDir, acceptRemoteCert, authField) +} + +func testAccProvider_remoteServerEnv(configDir string) string { + return fmt.Sprintf(` +provider "lxd" { + generate_client_certificates = true + accept_remote_certificate = true + config_dir = %q +} + +resource "lxd_noop" "noop" { +} + `, configDir) +} + +// testCheckClientCert checks that the client certificate was generated. +func testCheckClientCert(configDir string, shouldExist bool) resource.TestCheckFunc { + return func(_ *terraform.State) error { + for _, fileName := range []string{"client.crt", "client.key"} { + _, err := os.Stat(filepath.Join(configDir, fileName)) + + if shouldExist && err != nil { + return fmt.Errorf("File %q not found: %w", fileName, err) + } + + if !shouldExist && err == nil { + return fmt.Errorf("File %q should not exist", fileName) + } + } + + return nil + } +} + +// resetLXDRemoteEnvVars unsets all environment variables that are supported by +// the provider. +func resetLXDRemoteEnvVars() { + os.Unsetenv("LXD_REMOTE") + os.Unsetenv("LXD_ADDR") + os.Unsetenv("LXD_PASSWORD") + os.Unsetenv("LXD_TOKEN") +} diff --git a/internal/provider/resource_noop.go b/internal/provider/resource_noop.go new file mode 100644 index 00000000..91cb023e --- /dev/null +++ b/internal/provider/resource_noop.go @@ -0,0 +1,139 @@ +/* + * This is a noop resource that is included only when running tests and + * should be used exclusively for testing the LXD "provider" block. + * + * The resource is used to force loading of the provider's remote configuration, + * as it is lazy-loaded. + */ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/terraform-lxd/terraform-provider-lxd/internal/errors" + provider_config "github.com/terraform-lxd/terraform-provider-lxd/internal/provider-config" +) + +type noopModel struct { + Project types.String `tfsdk:"project"` + Remote types.String `tfsdk:"remote"` + ServerVersion types.String `tfsdk:"server_version"` +} + +// noopResource represents noop resource used for testing. +type noopResource struct { + provider *provider_config.LxdProviderConfig +} + +// newNoopResource returns a new noop resource. +func newNoopResource() resource.Resource { + return &noopResource{} +} + +// Metadata for noop resource. +func (r noopResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = fmt.Sprintf("%s_noop", req.ProviderTypeName) +} + +// Schema for noop resource. +func (r noopResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "project": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("default"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + + "remote": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + + "server_version": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + }, + } +} + +func (r *noopResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + data := req.ProviderData + if data == nil { + return + } + + provider, ok := data.(*provider_config.LxdProviderConfig) + if !ok { + resp.Diagnostics.Append(errors.NewProviderDataTypeError(req.ProviderData)) + return + } + + r.provider = provider +} + +func (r noopResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan noopModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.SyncState(ctx, &resp.State, plan)...) +} + +func (r noopResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state noopModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.SyncState(ctx, &resp.State, state)...) +} + +func (r noopResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Nothing to do. All fields trigger a replace. +} + +func (r noopResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Nothing to do. Just remove the resource from the state. +} + +func (r noopResource) SyncState(ctx context.Context, tfState *tfsdk.State, m noopModel) diag.Diagnostics { + remote := r.provider.SelectRemote(m.Remote.ValueString()) + project := m.Project.ValueString() + + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + return diag.Diagnostics{errors.NewInstanceServerError(err)} + } + + apiServer, _, err := server.GetServer() + if err != nil { + return diag.Diagnostics{diag.NewErrorDiagnostic(fmt.Sprintf("Failed to retrieve the API server for remote %q", remote), err.Error())} + } + + m.Remote = types.StringValue(remote) + m.ServerVersion = types.StringValue(apiServer.Environment.Project) + + return tfState.Set(ctx, &m) +}