github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/provider/lxd/provider.go (about) 1 // Copyright 2015 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package lxd 5 6 import ( 7 stdcontext "context" 8 "net/http" 9 "os" 10 "path/filepath" 11 "strings" 12 13 "github.com/juju/clock" 14 "github.com/juju/errors" 15 jujuhttp "github.com/juju/http/v2" 16 "github.com/juju/jsonschema" 17 "github.com/juju/schema" 18 "gopkg.in/juju/environschema.v1" 19 "gopkg.in/yaml.v2" 20 21 "github.com/juju/juju/cloud" 22 "github.com/juju/juju/container/lxd" 23 corelogger "github.com/juju/juju/core/logger" 24 "github.com/juju/juju/environs" 25 environscloudspec "github.com/juju/juju/environs/cloudspec" 26 "github.com/juju/juju/environs/config" 27 "github.com/juju/juju/environs/context" 28 "github.com/juju/juju/provider/lxd/lxdnames" 29 ) 30 31 // LXCConfigReader reads files required for the LXC configuration. 32 type LXCConfigReader interface { 33 // ReadConfig takes a path and returns a LXCConfig. 34 // Returns an error if there is an error with the location of the config 35 // file, or there was an error parsing the file. 36 ReadConfig(path string) (LXCConfig, error) 37 38 // ReadCert takes a path and returns a raw certificate, there is no 39 // validation of the certificate. 40 // Returns an error if there is an error with the location of the 41 // certificate. 42 ReadCert(path string) ([]byte, error) 43 } 44 45 // LXCConfig represents a configuration setup of a LXC configuration file. 46 // The LXCConfig expects the configuration file to be in a yaml representation. 47 type LXCConfig struct { 48 DefaultRemote string `yaml:"local"` 49 Remotes map[string]LXCRemoteConfig `yaml:"remotes"` 50 } 51 52 // LXCRemoteConfig defines a the remote servers of a LXC configuration. 53 type LXCRemoteConfig struct { 54 Addr string `yaml:"addr"` 55 Public bool `yaml:"public"` 56 Protocol string `yaml:"protocol"` 57 AuthType string `yaml:"auth_type"` 58 } 59 60 type environProvider struct { 61 environs.ProviderCredentials 62 environs.RequestFinalizeCredential 63 environs.ProviderCredentialsRegister 64 serverFactory ServerFactory 65 lxcConfigReader LXCConfigReader 66 Clock clock.Clock 67 } 68 69 var cloudSchema = &jsonschema.Schema{ 70 Type: []jsonschema.Type{jsonschema.ObjectType}, 71 Required: []string{cloud.EndpointKey, cloud.AuthTypesKey}, 72 Order: []string{cloud.EndpointKey, cloud.AuthTypesKey, cloud.RegionsKey}, 73 Properties: map[string]*jsonschema.Schema{ 74 cloud.EndpointKey: { 75 Singular: "the API endpoint url for the remote LXD server", 76 Type: []jsonschema.Type{jsonschema.StringType}, 77 Format: jsonschema.FormatURI, 78 }, 79 cloud.AuthTypesKey: { 80 Singular: "auth type", 81 Plural: "auth types", 82 Type: []jsonschema.Type{jsonschema.ArrayType}, 83 UniqueItems: jsonschema.Bool(true), 84 Default: string(cloud.CertificateAuthType), 85 Items: &jsonschema.ItemSpec{ 86 Schemas: []*jsonschema.Schema{{ 87 Type: []jsonschema.Type{jsonschema.StringType}, 88 Enum: []interface{}{ 89 string(cloud.CertificateAuthType), 90 }, 91 }}, 92 }, 93 }, 94 cloud.RegionsKey: { 95 Type: []jsonschema.Type{jsonschema.ObjectType}, 96 Singular: "region", 97 Plural: "regions", 98 Default: "default", 99 AdditionalProperties: &jsonschema.Schema{ 100 Type: []jsonschema.Type{jsonschema.ObjectType}, 101 Required: []string{cloud.EndpointKey}, 102 MaxProperties: jsonschema.Int(1), 103 Properties: map[string]*jsonschema.Schema{ 104 cloud.EndpointKey: { 105 Singular: "the API endpoint url for the region", 106 Type: []jsonschema.Type{jsonschema.StringType}, 107 Format: jsonschema.FormatURI, 108 Default: "", 109 PromptDefault: "use cloud api url", 110 }, 111 }, 112 }, 113 }, 114 }, 115 } 116 117 // NewProvider returns a new LXD EnvironProvider. 118 func NewProvider() environs.CloudEnvironProvider { 119 configReader := lxcConfigReader{} 120 factory := NewServerFactory(NewHTTPClientFunc(func() *http.Client { 121 return jujuhttp.NewClient( 122 jujuhttp.WithLogger(logger.ChildWithLabels("http", corelogger.HTTP)), 123 ).Client() 124 })) 125 126 credentials := environProviderCredentials{ 127 certReadWriter: certificateReadWriter{}, 128 certGenerator: certificateGenerator{}, 129 serverFactory: factory, 130 lxcConfigReader: configReader, 131 } 132 return &environProvider{ 133 ProviderCredentials: credentials, 134 RequestFinalizeCredential: credentials, 135 ProviderCredentialsRegister: credentials, 136 serverFactory: factory, 137 lxcConfigReader: configReader, 138 } 139 } 140 141 // Version is part of the EnvironProvider interface. 142 func (*environProvider) Version() int { 143 return 0 144 } 145 146 // Open implements environs.EnvironProvider. 147 func (p *environProvider) Open(_ stdcontext.Context, args environs.OpenParams) (environs.Environ, error) { 148 if err := p.validateCloudSpec(args.Cloud); err != nil { 149 return nil, errors.Annotate(err, "validating cloud spec") 150 } 151 env, err := newEnviron( 152 p, 153 args.Cloud, 154 args.Config, 155 ) 156 return env, errors.Trace(err) 157 } 158 159 // CloudSchema returns the schema used to validate input for add-cloud. 160 func (p *environProvider) CloudSchema() *jsonschema.Schema { 161 return cloudSchema 162 } 163 164 // Ping tests the connection to the cloud, to verify the endpoint is valid. 165 func (p *environProvider) Ping(ctx context.ProviderCallContext, endpoint string) error { 166 // if the endpoint is empty, then don't ping, as we can assume we're using 167 // local lxd 168 if endpoint == "" { 169 return nil 170 } 171 172 // Ensure the Port on the Host, if we get an error it is reasonable to 173 // assume that the address in the spec is invalid. 174 lxdEndpoint, err := lxd.EnsureHostPort(endpoint) 175 if err != nil { 176 return errors.Trace(err) 177 } 178 179 // Connect to the remote server anonymously so we can just verify it exists 180 // as we're not sure that the certificates are loaded in time for when the 181 // ping occurs i.e. interactive add-cloud 182 _, err = lxd.ConnectRemote(lxd.NewInsecureServerSpec(lxdEndpoint)) 183 if err != nil { 184 return errors.Annotatef(err, "no lxd server running at %s", lxdEndpoint) 185 } 186 return nil 187 } 188 189 // PrepareConfig implements environs.EnvironProvider. 190 func (p *environProvider) PrepareConfig(args environs.PrepareConfigParams) (*config.Config, error) { 191 if err := p.validateCloudSpec(args.Cloud); err != nil { 192 return nil, errors.Annotate(err, "validating cloud spec") 193 } 194 // Set the default filesystem-storage source. 195 attrs := make(map[string]interface{}) 196 if _, ok := args.Config.StorageDefaultFilesystemSource(); !ok { 197 attrs[config.StorageDefaultFilesystemSourceKey] = lxdStorageProviderType 198 } 199 if len(attrs) == 0 { 200 return args.Config, nil 201 } 202 cfg, err := args.Config.Apply(attrs) 203 return cfg, errors.Trace(err) 204 } 205 206 // Validate implements environs.EnvironProvider. 207 func (*environProvider) Validate(cfg, old *config.Config) (valid *config.Config, err error) { 208 if _, err := newValidConfig(cfg); err != nil { 209 return nil, errors.Annotate(err, "invalid base config") 210 } 211 return cfg, nil 212 } 213 214 // DetectClouds implements environs.CloudDetector. 215 func (p *environProvider) DetectClouds() ([]cloud.Cloud, error) { 216 usedNames := map[string]bool{lxdnames.ProviderType: true, lxdnames.DefaultCloud: true} 217 clouds := []cloud.Cloud{localhostCloud} 218 for _, dir := range configDirs() { 219 configPath := filepath.Join(dir, "config.yml") 220 config, err := p.lxcConfigReader.ReadConfig(configPath) 221 if err != nil { 222 if !strings.HasSuffix(err.Error(), "permission denied") { 223 logger.Warningf("unable to read/parse LXC config file (%s): %s", configPath, err) 224 } 225 } 226 for name, remote := range config.Remotes { 227 if remote.Protocol != lxdnames.ProviderType { 228 continue 229 } 230 if usedNames[name] { 231 logger.Warningf("ignoring ambigious cloud %s", name) 232 continue 233 } 234 usedNames[name] = true 235 clouds = append(clouds, cloud.Cloud{ 236 Name: name, 237 Type: lxdnames.ProviderType, 238 Endpoint: remote.Addr, 239 Description: "LXD Cluster", 240 AuthTypes: []cloud.AuthType{ 241 cloud.CertificateAuthType, 242 }, 243 Regions: []cloud.Region{{ 244 Name: lxdnames.DefaultRemoteRegion, 245 Endpoint: remote.Addr, 246 }}, 247 }) 248 } 249 } 250 return clouds, nil 251 } 252 253 // DetectCloud implements environs.CloudDetector. 254 func (p *environProvider) DetectCloud(name string) (cloud.Cloud, error) { 255 clouds, err := p.DetectClouds() 256 if err != nil { 257 return cloud.Cloud{}, errors.Trace(err) 258 } 259 match := name 260 if match == lxdnames.DefaultCloudAltName { 261 match = lxdnames.DefaultCloud 262 } 263 for _, cloud := range clouds { 264 if cloud.Name == match { 265 return cloud, nil 266 } 267 } 268 return cloud.Cloud{}, errors.NotFoundf("cloud %s", name) 269 } 270 271 // FinalizeCloud is part of the environs.CloudFinalizer interface. 272 func (p *environProvider) FinalizeCloud( 273 ctx environs.FinalizeCloudContext, 274 in cloud.Cloud, 275 ) (cloud.Cloud, error) { 276 var endpoint string 277 resolveEndpoint := func(name string, ep *string) error { 278 // If the name doesn't equal "localhost" then we shouldn't resolve 279 // the end point, instead we should just accept what we already have. 280 if !lxdnames.IsDefaultCloud(name) || *ep != "" { 281 return nil 282 } 283 if endpoint == "" { 284 // The cloud endpoint is empty, which means 285 // that we should connect to the local LXD. 286 hostAddress, err := getLocalHostAddress(ctx, p.serverFactory) 287 if err != nil { 288 return errors.Trace(err) 289 } 290 endpoint = hostAddress 291 } 292 *ep = endpoint 293 return nil 294 } 295 296 // If any of the endpoints point to localhost, go through and backfill the 297 // cloud spec with local host addresses. 298 if err := resolveEndpoint(in.Name, &in.Endpoint); err != nil { 299 return cloud.Cloud{}, errors.Trace(err) 300 } 301 for i, k := range in.Regions { 302 if err := resolveEndpoint(k.Name, &in.Regions[i].Endpoint); err != nil { 303 return cloud.Cloud{}, errors.Trace(err) 304 } 305 } 306 // If the provider type is not named localhost and there is no region, set the 307 // region to be a default region 308 if !lxdnames.IsDefaultCloud(in.Name) && len(in.Regions) == 0 { 309 in.Regions = append(in.Regions, cloud.Region{ 310 Name: lxdnames.DefaultRemoteRegion, 311 Endpoint: in.Endpoint, 312 }) 313 } 314 return in, nil 315 } 316 317 func getLocalHostAddress(ctx environs.FinalizeCloudContext, serverFactory ServerFactory) (string, error) { 318 svr, err := serverFactory.LocalServer() 319 if err != nil { 320 return "", errors.Trace(err) 321 } 322 323 bridgeName := svr.LocalBridgeName() 324 hostAddress, err := serverFactory.LocalServerAddress() 325 if err != nil { 326 return "", errors.Trace(err) 327 } 328 ctx.Verbosef( 329 "Resolved LXD host address on bridge %s: %s", 330 bridgeName, hostAddress, 331 ) 332 return hostAddress, nil 333 } 334 335 // localhostCloud is the predefined "localhost" LXD cloud. We leave the 336 // endpoints empty to indicate that LXD is on the local host. When the 337 // cloud is finalized (see FinalizeCloud), we resolve the bridge address 338 // of the LXD host, and use that as the endpoint. 339 var localhostCloud = cloud.Cloud{ 340 Name: lxdnames.DefaultCloud, 341 Type: lxdnames.ProviderType, 342 AuthTypes: []cloud.AuthType{ 343 cloud.CertificateAuthType, 344 }, 345 Endpoint: "", 346 Regions: []cloud.Region{{ 347 Name: lxdnames.DefaultLocalRegion, 348 Endpoint: "", 349 }}, 350 Description: cloud.DefaultCloudDescription(lxdnames.ProviderType), 351 } 352 353 // DetectRegions implements environs.CloudRegionDetector. 354 func (*environProvider) DetectRegions() ([]cloud.Region, error) { 355 // For now we just return a hard-coded "localhost" region, 356 // i.e. the local LXD daemon. We may later want to detect 357 // locally-configured remotes. 358 return []cloud.Region{{Name: lxdnames.DefaultLocalRegion}}, nil 359 } 360 361 // Schema returns the configuration schema for an environment. 362 func (*environProvider) Schema() environschema.Fields { 363 fields, err := config.Schema(configSchema) 364 if err != nil { 365 panic(err) 366 } 367 return fields 368 } 369 370 func (p *environProvider) validateCloudSpec(spec environscloudspec.CloudSpec) error { 371 if err := spec.Validate(); err != nil { 372 return errors.Trace(err) 373 } 374 if spec.Credential == nil { 375 return errors.NotValidf("missing credential") 376 } 377 378 // Always validate the spec.Endpoint, to ensure that it's valid. 379 if _, err := endpointURL(spec.Endpoint); err != nil { 380 return errors.Trace(err) 381 } 382 switch authType := spec.Credential.AuthType(); authType { 383 case cloud.CertificateAuthType: 384 if spec.Credential == nil { 385 return errors.NotFoundf("credentials") 386 } 387 if _, _, ok := getCertificates(*spec.Credential); !ok { 388 return errors.NotValidf("certificate credentials") 389 } 390 default: 391 return errors.NotSupportedf("%q auth-type", authType) 392 } 393 return nil 394 } 395 396 // ConfigSchema returns extra config attributes specific 397 // to this provider only. 398 func (p *environProvider) ConfigSchema() schema.Fields { 399 return configFields 400 } 401 402 // ConfigDefaults returns the default values for the 403 // provider specific config attributes. 404 func (p *environProvider) ConfigDefaults() schema.Defaults { 405 return configDefaults 406 } 407 408 // lxcConfigReader is the default implementation for reading files from disk. 409 type lxcConfigReader struct{} 410 411 func (lxcConfigReader) ReadConfig(path string) (LXCConfig, error) { 412 configFile, err := os.ReadFile(path) 413 if err != nil { 414 if cause := errors.Cause(err); os.IsNotExist(cause) { 415 return LXCConfig{}, nil 416 } 417 return LXCConfig{}, errors.Trace(err) 418 } 419 420 var config LXCConfig 421 if err := yaml.Unmarshal(configFile, &config); err != nil { 422 return LXCConfig{}, errors.Trace(err) 423 } 424 425 return config, nil 426 } 427 428 func (lxcConfigReader) ReadCert(path string) ([]byte, error) { 429 certFile, err := os.ReadFile(path) 430 return certFile, errors.Trace(err) 431 }