yandex-import: allow set custom API endpoint (#9850)
* Separate Access Config from yandex builder Config * make use of Access Config explicit * Move `MaxRetries` into AccessConfig * NewDriverYC use AccessConfig instead Config * yandex-import PP use common Access Config Now support set custom API Endpoint * yandex-export PP use common Access Config Now support set custom API Endpoint too (as yandex-import) * fix test * Tiny doc updates.
This commit is contained in:
parent
f578b93f7e
commit
804fefef17
|
@ -0,0 +1,67 @@
|
|||
//go:generate struct-markdown
|
||||
|
||||
package yandex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/packer/packer"
|
||||
"github.com/hashicorp/packer/template/interpolate"
|
||||
"github.com/yandex-cloud/go-sdk/iamkey"
|
||||
)
|
||||
|
||||
const defaultEndpoint = "api.cloud.yandex.net:443"
|
||||
|
||||
// AccessConfig is for common configuration related to Yandex.Cloud API access
|
||||
type AccessConfig struct {
|
||||
// Non standard API endpoint. Default is `api.cloud.yandex.net:443`.
|
||||
Endpoint string `mapstructure:"endpoint" required:"false"`
|
||||
// Path to file with Service Account key in json format. This
|
||||
// is an alternative method to authenticate to Yandex.Cloud. Alternatively you may set environment variable
|
||||
// `YC_SERVICE_ACCOUNT_KEY_FILE`.
|
||||
ServiceAccountKeyFile string `mapstructure:"service_account_key_file" required:"false"`
|
||||
// OAuth token to use to authenticate to Yandex.Cloud. Alternatively you may set
|
||||
// value by environment variable `YC_TOKEN`.
|
||||
Token string `mapstructure:"token" required:"true"`
|
||||
// The maximum number of times an API request is being executed.
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
}
|
||||
|
||||
func (c *AccessConfig) Prepare(ctx *interpolate.Context) []error {
|
||||
var errs []error
|
||||
|
||||
if c.Endpoint == "" {
|
||||
c.Endpoint = defaultEndpoint
|
||||
}
|
||||
|
||||
// provision config by OS environment variables
|
||||
if c.Token == "" {
|
||||
c.Token = os.Getenv("YC_TOKEN")
|
||||
}
|
||||
|
||||
if c.ServiceAccountKeyFile == "" {
|
||||
c.ServiceAccountKeyFile = os.Getenv("YC_SERVICE_ACCOUNT_KEY_FILE")
|
||||
}
|
||||
|
||||
if c.Token != "" && c.ServiceAccountKeyFile != "" {
|
||||
errs = append(errs, errors.New("one of token or service account key file must be specified, not both"))
|
||||
}
|
||||
|
||||
if c.Token != "" {
|
||||
packer.LogSecretFilter.Set(c.Token)
|
||||
}
|
||||
|
||||
if c.ServiceAccountKeyFile != "" {
|
||||
if _, err := iamkey.ReadFromJSONFile(c.ServiceAccountKeyFile); err != nil {
|
||||
errs = append(errs, fmt.Errorf("fail to read service account key file: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -50,7 +50,7 @@ func (b *Builder) Prepare(raws ...interface{}) ([]string, []string, error) {
|
|||
// Run executes a yandex Packer build and returns a packer.Artifact
|
||||
// representing a Yandex.Cloud compute image.
|
||||
func (b *Builder) Run(ctx context.Context, ui packer.Ui, hook packer.Hook) (packer.Artifact, error) {
|
||||
driver, err := NewDriverYC(ui, &b.config)
|
||||
driver, err := NewDriverYC(ui, &b.config.AccessConfig)
|
||||
ctx = requestid.ContextWithClientTraceID(ctx, uuid.New().String())
|
||||
|
||||
if err != nil {
|
||||
|
|
|
@ -16,11 +16,8 @@ import (
|
|||
"github.com/hashicorp/packer/helper/config"
|
||||
"github.com/hashicorp/packer/packer"
|
||||
"github.com/hashicorp/packer/template/interpolate"
|
||||
|
||||
"github.com/yandex-cloud/go-sdk/iamkey"
|
||||
)
|
||||
|
||||
const defaultEndpoint = "api.cloud.yandex.net:443"
|
||||
const defaultGpuPlatformID = "gpu-standard-v1"
|
||||
const defaultPlatformID = "standard-v1"
|
||||
const defaultMaxRetries = 3
|
||||
|
@ -31,23 +28,15 @@ var reImageFamily = regexp.MustCompile(`^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$`)
|
|||
type Config struct {
|
||||
common.PackerConfig `mapstructure:",squash"`
|
||||
Communicator communicator.Config `mapstructure:",squash"`
|
||||
AccessConfig `mapstructure:",squash"`
|
||||
|
||||
// Non standard api endpoint URL.
|
||||
Endpoint string `mapstructure:"endpoint" required:"false"`
|
||||
// The folder ID that will be used to launch instances and store images.
|
||||
// Alternatively you may set value by environment variable YC_FOLDER_ID.
|
||||
// Alternatively you may set value by environment variable `YC_FOLDER_ID`.
|
||||
// To use a different folder for looking up the source image or saving the target image to
|
||||
// check options 'source_image_folder_id' and 'target_image_folder_id'.
|
||||
FolderID string `mapstructure:"folder_id" required:"true"`
|
||||
// Path to file with Service Account key in json format. This
|
||||
// is an alternative method to authenticate to Yandex.Cloud. Alternatively you may set environment variable
|
||||
// YC_SERVICE_ACCOUNT_KEY_FILE.
|
||||
ServiceAccountKeyFile string `mapstructure:"service_account_key_file" required:"false"`
|
||||
// Service account identifier to assign to instance
|
||||
// Service account identifier to assign to instance.
|
||||
ServiceAccountID string `mapstructure:"service_account_id" required:"false"`
|
||||
// OAuth token to use to authenticate to Yandex.Cloud. Alternatively you may set
|
||||
// value by environment variable YC_TOKEN.
|
||||
Token string `mapstructure:"token" required:"true"`
|
||||
// The name of the disk, if unset the instance name
|
||||
// will be used.
|
||||
DiskName string `mapstructure:"disk_name" required:"false"`
|
||||
|
@ -59,8 +48,7 @@ type Config struct {
|
|||
ImageDescription string `mapstructure:"image_description" required:"false"`
|
||||
// The family name of the resulting image.
|
||||
ImageFamily string `mapstructure:"image_family" required:"false"`
|
||||
// Key/value pair labels to
|
||||
// apply to the created image.
|
||||
// Key/value pair labels to apply to the created image.
|
||||
ImageLabels map[string]string `mapstructure:"image_labels" required:"false"`
|
||||
// Minimum size of the disk that will be created from built image, specified in gigabytes.
|
||||
// Should be more or equal to `disk_size_gb`.
|
||||
|
@ -78,16 +66,14 @@ type Config struct {
|
|||
InstanceMemory int `mapstructure:"instance_mem_gb" required:"false"`
|
||||
// The name assigned to the instance.
|
||||
InstanceName string `mapstructure:"instance_name" required:"false"`
|
||||
// Key/value pair labels to apply to
|
||||
// the launched instance.
|
||||
// Key/value pair labels to apply to the launched instance.
|
||||
Labels map[string]string `mapstructure:"labels" required:"false"`
|
||||
// Identifier of the hardware platform configuration for the instance. This defaults to `standard-v1`.
|
||||
PlatformID string `mapstructure:"platform_id" required:"false"`
|
||||
// The maximum number of times an API request is being executed
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
// Metadata applied to the launched instance.
|
||||
Metadata map[string]string `mapstructure:"metadata" required:"false"`
|
||||
// Metadata applied to the launched instance. Value are file paths.
|
||||
// Metadata applied to the launched instance.
|
||||
// The values in this map are the paths to the content files for the corresponding metadata keys.
|
||||
MetadataFromFile map[string]string `mapstructure:"metadata_from_file"`
|
||||
// Launch a preemptible instance. This defaults to `false`.
|
||||
Preemptible bool `mapstructure:"preemptible"`
|
||||
|
@ -95,12 +81,11 @@ type Config struct {
|
|||
SerialLogFile string `mapstructure:"serial_log_file" required:"false"`
|
||||
// The source image family to create the new image
|
||||
// from. You can also specify source_image_id instead. Just one of a source_image_id or
|
||||
// source_image_family must be specified. Example: `ubuntu-1804-lts`
|
||||
// source_image_family must be specified. Example: `ubuntu-1804-lts`.
|
||||
SourceImageFamily string `mapstructure:"source_image_family" required:"true"`
|
||||
// The ID of the folder containing the source image.
|
||||
SourceImageFolderID string `mapstructure:"source_image_folder_id" required:"false"`
|
||||
// The source image ID to use to create the new image
|
||||
// from.
|
||||
// The source image ID to use to create the new image from.
|
||||
SourceImageID string `mapstructure:"source_image_id" required:"false"`
|
||||
// The source image name to use to create the new image
|
||||
// from. Name will be looked up in `source_image_folder_id`.
|
||||
|
@ -142,8 +127,11 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Accumulate any errors
|
||||
var errs *packer.MultiError
|
||||
|
||||
errs = packer.MultiErrorAppend(errs, c.AccessConfig.Prepare(&c.ctx)...)
|
||||
|
||||
if c.SerialLogFile != "" {
|
||||
if _, err := os.Stat(c.SerialLogFile); os.IsExist(err) {
|
||||
errs = packer.MultiErrorAppend(errs,
|
||||
|
@ -236,10 +224,6 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {
|
|||
}
|
||||
}
|
||||
|
||||
if c.Endpoint == "" {
|
||||
c.Endpoint = defaultEndpoint
|
||||
}
|
||||
|
||||
if c.Zone == "" {
|
||||
c.Zone = defaultZone
|
||||
}
|
||||
|
@ -248,35 +232,10 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {
|
|||
c.MaxRetries = defaultMaxRetries
|
||||
}
|
||||
|
||||
// provision config by OS environment variables
|
||||
if c.Token == "" {
|
||||
c.Token = os.Getenv("YC_TOKEN")
|
||||
}
|
||||
|
||||
if c.ServiceAccountKeyFile == "" {
|
||||
c.ServiceAccountKeyFile = os.Getenv("YC_SERVICE_ACCOUNT_KEY_FILE")
|
||||
}
|
||||
|
||||
if c.FolderID == "" {
|
||||
c.FolderID = os.Getenv("YC_FOLDER_ID")
|
||||
}
|
||||
|
||||
if c.Token != "" && c.ServiceAccountKeyFile != "" {
|
||||
errs = packer.MultiErrorAppend(
|
||||
errs, errors.New("one of token or service account key file must be specified, not both"))
|
||||
}
|
||||
|
||||
if c.Token != "" {
|
||||
packer.LogSecretFilter.Set(c.Token)
|
||||
}
|
||||
|
||||
if c.ServiceAccountKeyFile != "" {
|
||||
if _, err := iamkey.ReadFromJSONFile(c.ServiceAccountKeyFile); err != nil {
|
||||
errs = packer.MultiErrorAppend(
|
||||
errs, fmt.Errorf("fail to read service account key file: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
if c.FolderID == "" {
|
||||
errs = packer.MultiErrorAppend(
|
||||
errs, errors.New("a folder_id must be specified"))
|
||||
|
|
|
@ -64,10 +64,11 @@ type FlatConfig struct {
|
|||
WinRMInsecure *bool `mapstructure:"winrm_insecure" cty:"winrm_insecure" hcl:"winrm_insecure"`
|
||||
WinRMUseNTLM *bool `mapstructure:"winrm_use_ntlm" cty:"winrm_use_ntlm" hcl:"winrm_use_ntlm"`
|
||||
Endpoint *string `mapstructure:"endpoint" required:"false" cty:"endpoint" hcl:"endpoint"`
|
||||
FolderID *string `mapstructure:"folder_id" required:"true" cty:"folder_id" hcl:"folder_id"`
|
||||
ServiceAccountKeyFile *string `mapstructure:"service_account_key_file" required:"false" cty:"service_account_key_file" hcl:"service_account_key_file"`
|
||||
ServiceAccountID *string `mapstructure:"service_account_id" required:"false" cty:"service_account_id" hcl:"service_account_id"`
|
||||
Token *string `mapstructure:"token" required:"true" cty:"token" hcl:"token"`
|
||||
MaxRetries *int `mapstructure:"max_retries" cty:"max_retries" hcl:"max_retries"`
|
||||
FolderID *string `mapstructure:"folder_id" required:"true" cty:"folder_id" hcl:"folder_id"`
|
||||
ServiceAccountID *string `mapstructure:"service_account_id" required:"false" cty:"service_account_id" hcl:"service_account_id"`
|
||||
DiskName *string `mapstructure:"disk_name" required:"false" cty:"disk_name" hcl:"disk_name"`
|
||||
DiskSizeGb *int `mapstructure:"disk_size_gb" required:"false" cty:"disk_size_gb" hcl:"disk_size_gb"`
|
||||
DiskType *string `mapstructure:"disk_type" required:"false" cty:"disk_type" hcl:"disk_type"`
|
||||
|
@ -83,7 +84,6 @@ type FlatConfig struct {
|
|||
InstanceName *string `mapstructure:"instance_name" required:"false" cty:"instance_name" hcl:"instance_name"`
|
||||
Labels map[string]string `mapstructure:"labels" required:"false" cty:"labels" hcl:"labels"`
|
||||
PlatformID *string `mapstructure:"platform_id" required:"false" cty:"platform_id" hcl:"platform_id"`
|
||||
MaxRetries *int `mapstructure:"max_retries" cty:"max_retries" hcl:"max_retries"`
|
||||
Metadata map[string]string `mapstructure:"metadata" required:"false" cty:"metadata" hcl:"metadata"`
|
||||
MetadataFromFile map[string]string `mapstructure:"metadata_from_file" cty:"metadata_from_file" hcl:"metadata_from_file"`
|
||||
Preemptible *bool `mapstructure:"preemptible" cty:"preemptible" hcl:"preemptible"`
|
||||
|
@ -168,10 +168,11 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
|
|||
"winrm_insecure": &hcldec.AttrSpec{Name: "winrm_insecure", Type: cty.Bool, Required: false},
|
||||
"winrm_use_ntlm": &hcldec.AttrSpec{Name: "winrm_use_ntlm", Type: cty.Bool, Required: false},
|
||||
"endpoint": &hcldec.AttrSpec{Name: "endpoint", Type: cty.String, Required: false},
|
||||
"folder_id": &hcldec.AttrSpec{Name: "folder_id", Type: cty.String, Required: false},
|
||||
"service_account_key_file": &hcldec.AttrSpec{Name: "service_account_key_file", Type: cty.String, Required: false},
|
||||
"service_account_id": &hcldec.AttrSpec{Name: "service_account_id", Type: cty.String, Required: false},
|
||||
"token": &hcldec.AttrSpec{Name: "token", Type: cty.String, Required: false},
|
||||
"max_retries": &hcldec.AttrSpec{Name: "max_retries", Type: cty.Number, Required: false},
|
||||
"folder_id": &hcldec.AttrSpec{Name: "folder_id", Type: cty.String, Required: false},
|
||||
"service_account_id": &hcldec.AttrSpec{Name: "service_account_id", Type: cty.String, Required: false},
|
||||
"disk_name": &hcldec.AttrSpec{Name: "disk_name", Type: cty.String, Required: false},
|
||||
"disk_size_gb": &hcldec.AttrSpec{Name: "disk_size_gb", Type: cty.Number, Required: false},
|
||||
"disk_type": &hcldec.AttrSpec{Name: "disk_type", Type: cty.String, Required: false},
|
||||
|
@ -187,7 +188,6 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
|
|||
"instance_name": &hcldec.AttrSpec{Name: "instance_name", Type: cty.String, Required: false},
|
||||
"labels": &hcldec.AttrSpec{Name: "labels", Type: cty.Map(cty.String), Required: false},
|
||||
"platform_id": &hcldec.AttrSpec{Name: "platform_id", Type: cty.String, Required: false},
|
||||
"max_retries": &hcldec.AttrSpec{Name: "max_retries", Type: cty.Number, Required: false},
|
||||
"metadata": &hcldec.AttrSpec{Name: "metadata", Type: cty.Map(cty.String), Required: false},
|
||||
"metadata_from_file": &hcldec.AttrSpec{Name: "metadata_from_file", Type: cty.Map(cty.String), Required: false},
|
||||
"preemptible": &hcldec.AttrSpec{Name: "preemptible", Type: cty.Bool, Required: false},
|
||||
|
|
|
@ -33,27 +33,27 @@ type driverYC struct {
|
|||
ui packer.Ui
|
||||
}
|
||||
|
||||
func NewDriverYC(ui packer.Ui, config *Config) (Driver, error) {
|
||||
func NewDriverYC(ui packer.Ui, ac *AccessConfig) (Driver, error) {
|
||||
log.Printf("[INFO] Initialize Yandex.Cloud client...")
|
||||
|
||||
sdkConfig := ycsdk.Config{}
|
||||
|
||||
if config.Endpoint != "" {
|
||||
sdkConfig.Endpoint = config.Endpoint
|
||||
if ac.Endpoint != "" {
|
||||
sdkConfig.Endpoint = ac.Endpoint
|
||||
}
|
||||
|
||||
switch {
|
||||
case config.Token == "" && config.ServiceAccountKeyFile == "":
|
||||
case ac.Token == "" && ac.ServiceAccountKeyFile == "":
|
||||
log.Printf("[INFO] Use Instance Service Account for authentication")
|
||||
sdkConfig.Credentials = ycsdk.InstanceServiceAccount()
|
||||
|
||||
case config.Token != "":
|
||||
case ac.Token != "":
|
||||
log.Printf("[INFO] Use OAuth token for authentication")
|
||||
sdkConfig.Credentials = ycsdk.OAuthToken(config.Token)
|
||||
sdkConfig.Credentials = ycsdk.OAuthToken(ac.Token)
|
||||
|
||||
case config.ServiceAccountKeyFile != "":
|
||||
log.Printf("[INFO] Use Service Account key file %q for authentication", config.ServiceAccountKeyFile)
|
||||
key, err := iamkey.ReadFromJSONFile(config.ServiceAccountKeyFile)
|
||||
case ac.ServiceAccountKeyFile != "":
|
||||
log.Printf("[INFO] Use Service Account key file %q for authentication", ac.ServiceAccountKeyFile)
|
||||
key, err := iamkey.ReadFromJSONFile(ac.ServiceAccountKeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ func NewDriverYC(ui packer.Ui, config *Config) (Driver, error) {
|
|||
requestIDInterceptor := requestid.Interceptor()
|
||||
|
||||
retryInterceptor := retry.Interceptor(
|
||||
retry.WithMax(config.MaxRetries),
|
||||
retry.WithMax(ac.MaxRetries),
|
||||
retry.WithCodes(codes.Unavailable),
|
||||
retry.WithAttemptHeader(true),
|
||||
retry.WithBackoff(retry.BackoffExponentialWithJitter(defaultExponentialBackoffBase, defaultExponentialBackoffCap)))
|
||||
|
|
|
@ -11,8 +11,6 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yandex-cloud/go-sdk/iamkey"
|
||||
|
||||
"github.com/hashicorp/hcl/v2/hcldec"
|
||||
"github.com/hashicorp/packer/builder"
|
||||
"github.com/hashicorp/packer/builder/yandex"
|
||||
|
@ -28,6 +26,7 @@ const defaultStorageEndpoint = "storage.yandexcloud.net"
|
|||
|
||||
type Config struct {
|
||||
common.PackerConfig `mapstructure:",squash"`
|
||||
yandex.AccessConfig `mapstructure:",squash"`
|
||||
|
||||
// List of paths to Yandex Object Storage where exported image will be uploaded.
|
||||
// Please be aware that use of space char inside path not supported.
|
||||
|
@ -36,7 +35,7 @@ type Config struct {
|
|||
// Paths to Yandex Object Storage where exported image will be uploaded.
|
||||
Paths []string `mapstructure:"paths" required:"true"`
|
||||
// The folder ID that will be used to launch a temporary instance.
|
||||
// Alternatively you may set value by environment variable YC_FOLDER_ID.
|
||||
// Alternatively you may set value by environment variable `YC_FOLDER_ID`.
|
||||
FolderID string `mapstructure:"folder_id" required:"true"`
|
||||
// Service Account ID with proper permission to modify an instance, create and attach disk and
|
||||
// make upload to specific Yandex Object Storage paths.
|
||||
|
@ -53,13 +52,6 @@ type Config struct {
|
|||
SubnetID string `mapstructure:"subnet_id" required:"false"`
|
||||
// The name of the zone to launch the instance. This defaults to `ru-central1-a`.
|
||||
Zone string `mapstructure:"zone" required:"false"`
|
||||
// OAuth token to use to authenticate to Yandex.Cloud. Alternatively you may set
|
||||
// value by environment variable YC_TOKEN.
|
||||
Token string `mapstructure:"token" required:"false"`
|
||||
// Path to file with Service Account key in json format. This
|
||||
// is an alternative method to authenticate to Yandex.Cloud. Alternatively you may set environment variable
|
||||
// YC_SERVICE_ACCOUNT_KEY_FILE.
|
||||
ServiceAccountKeyFile string `mapstructure:"service_account_key_file" required:"false"`
|
||||
|
||||
ctx interpolate.Context
|
||||
}
|
||||
|
@ -85,7 +77,10 @@ func (p *PostProcessor) Configure(raws ...interface{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
errs := new(packer.MultiError)
|
||||
// Accumulate any errors
|
||||
var errs *packer.MultiError
|
||||
|
||||
errs = packer.MultiErrorAppend(errs, p.config.AccessConfig.Prepare(&p.config.ctx)...)
|
||||
|
||||
if len(p.config.Paths) == 0 {
|
||||
errs = packer.MultiErrorAppend(
|
||||
|
@ -100,31 +95,6 @@ func (p *PostProcessor) Configure(raws ...interface{}) error {
|
|||
}
|
||||
}
|
||||
|
||||
// provision config by OS environment variables
|
||||
if p.config.Token == "" {
|
||||
p.config.Token = os.Getenv("YC_TOKEN")
|
||||
}
|
||||
|
||||
if p.config.ServiceAccountKeyFile == "" {
|
||||
p.config.ServiceAccountKeyFile = os.Getenv("YC_SERVICE_ACCOUNT_KEY_FILE")
|
||||
}
|
||||
|
||||
if p.config.Token != "" && p.config.ServiceAccountKeyFile != "" {
|
||||
errs = packer.MultiErrorAppend(
|
||||
errs, fmt.Errorf("one of token or service account key file must be specified, not both"))
|
||||
}
|
||||
|
||||
if p.config.Token != "" {
|
||||
packer.LogSecretFilter.Set(p.config.Token)
|
||||
}
|
||||
|
||||
if p.config.ServiceAccountKeyFile != "" {
|
||||
if _, err := iamkey.ReadFromJSONFile(p.config.ServiceAccountKeyFile); err != nil {
|
||||
errs = packer.MultiErrorAppend(
|
||||
errs, fmt.Errorf("fail to read service account key file: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
if p.config.FolderID == "" {
|
||||
p.config.FolderID = os.Getenv("YC_FOLDER_ID")
|
||||
}
|
||||
|
@ -203,8 +173,6 @@ func (p *PostProcessor) PostProcess(ctx context.Context, ui packer.Ui, artifact
|
|||
}
|
||||
|
||||
yandexConfig := ycSaneDefaults()
|
||||
yandexConfig.Token = p.config.Token
|
||||
yandexConfig.ServiceAccountKeyFile = p.config.ServiceAccountKeyFile
|
||||
yandexConfig.DiskName = exporterName
|
||||
yandexConfig.InstanceName = exporterName
|
||||
yandexConfig.DiskSizeGb = p.config.DiskSizeGb
|
||||
|
@ -221,7 +189,7 @@ func (p *PostProcessor) PostProcess(ctx context.Context, ui packer.Ui, artifact
|
|||
yandexConfig.PlatformID = p.config.PlatformID
|
||||
}
|
||||
|
||||
driver, err := yandex.NewDriverYC(ui, &yandexConfig)
|
||||
driver, err := yandex.NewDriverYC(ui, &p.config.AccessConfig)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
|
|
|
@ -16,6 +16,10 @@ type FlatConfig struct {
|
|||
PackerOnError *string `mapstructure:"packer_on_error" cty:"packer_on_error" hcl:"packer_on_error"`
|
||||
PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables" hcl:"packer_user_variables"`
|
||||
PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables" hcl:"packer_sensitive_variables"`
|
||||
Endpoint *string `mapstructure:"endpoint" required:"false" cty:"endpoint" hcl:"endpoint"`
|
||||
ServiceAccountKeyFile *string `mapstructure:"service_account_key_file" required:"false" cty:"service_account_key_file" hcl:"service_account_key_file"`
|
||||
Token *string `mapstructure:"token" required:"true" cty:"token" hcl:"token"`
|
||||
MaxRetries *int `mapstructure:"max_retries" cty:"max_retries" hcl:"max_retries"`
|
||||
Paths []string `mapstructure:"paths" required:"true" cty:"paths" hcl:"paths"`
|
||||
FolderID *string `mapstructure:"folder_id" required:"true" cty:"folder_id" hcl:"folder_id"`
|
||||
ServiceAccountID *string `mapstructure:"service_account_id" required:"true" cty:"service_account_id" hcl:"service_account_id"`
|
||||
|
@ -24,8 +28,6 @@ type FlatConfig struct {
|
|||
PlatformID *string `mapstructure:"platform_id" required:"false" cty:"platform_id" hcl:"platform_id"`
|
||||
SubnetID *string `mapstructure:"subnet_id" required:"false" cty:"subnet_id" hcl:"subnet_id"`
|
||||
Zone *string `mapstructure:"zone" required:"false" cty:"zone" hcl:"zone"`
|
||||
Token *string `mapstructure:"token" required:"false" cty:"token" hcl:"token"`
|
||||
ServiceAccountKeyFile *string `mapstructure:"service_account_key_file" required:"false" cty:"service_account_key_file" hcl:"service_account_key_file"`
|
||||
}
|
||||
|
||||
// FlatMapstructure returns a new FlatConfig.
|
||||
|
@ -47,6 +49,10 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
|
|||
"packer_on_error": &hcldec.AttrSpec{Name: "packer_on_error", Type: cty.String, Required: false},
|
||||
"packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false},
|
||||
"packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false},
|
||||
"endpoint": &hcldec.AttrSpec{Name: "endpoint", Type: cty.String, Required: false},
|
||||
"service_account_key_file": &hcldec.AttrSpec{Name: "service_account_key_file", Type: cty.String, Required: false},
|
||||
"token": &hcldec.AttrSpec{Name: "token", Type: cty.String, Required: false},
|
||||
"max_retries": &hcldec.AttrSpec{Name: "max_retries", Type: cty.Number, Required: false},
|
||||
"paths": &hcldec.AttrSpec{Name: "paths", Type: cty.List(cty.String), Required: false},
|
||||
"folder_id": &hcldec.AttrSpec{Name: "folder_id", Type: cty.String, Required: false},
|
||||
"service_account_id": &hcldec.AttrSpec{Name: "service_account_id", Type: cty.String, Required: false},
|
||||
|
@ -55,8 +61,6 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
|
|||
"platform_id": &hcldec.AttrSpec{Name: "platform_id", Type: cty.String, Required: false},
|
||||
"subnet_id": &hcldec.AttrSpec{Name: "subnet_id", Type: cty.String, Required: false},
|
||||
"zone": &hcldec.AttrSpec{Name: "zone", Type: cty.String, Required: false},
|
||||
"token": &hcldec.AttrSpec{Name: "token", Type: cty.String, Required: false},
|
||||
"service_account_key_file": &hcldec.AttrSpec{Name: "service_account_key_file", Type: cty.String, Required: false},
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package yandexexport
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/packer/builder/yandex"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/hashicorp/packer/helper/multistep"
|
||||
|
@ -26,8 +27,10 @@ func TestPostProcessor_Configure(t *testing.T) {
|
|||
name: "no one creds",
|
||||
fields: fields{
|
||||
config: Config{
|
||||
Token: "",
|
||||
ServiceAccountKeyFile: "",
|
||||
AccessConfig: yandex.AccessConfig{
|
||||
Token: "",
|
||||
ServiceAccountKeyFile: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
|
@ -36,8 +39,10 @@ func TestPostProcessor_Configure(t *testing.T) {
|
|||
name: "both token and sa key file",
|
||||
fields: fields{
|
||||
config: Config{
|
||||
Token: "some-value",
|
||||
ServiceAccountKeyFile: "path/not-exist.file",
|
||||
AccessConfig: yandex.AccessConfig{
|
||||
Token: "some-value",
|
||||
ServiceAccountKeyFile: "path/not-exist.file",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
|
@ -46,8 +51,10 @@ func TestPostProcessor_Configure(t *testing.T) {
|
|||
name: "use sa key file",
|
||||
fields: fields{
|
||||
config: Config{
|
||||
Token: "",
|
||||
ServiceAccountKeyFile: "testdata/fake-sa-key.json",
|
||||
AccessConfig: yandex.AccessConfig{
|
||||
Token: "",
|
||||
ServiceAccountKeyFile: "testdata/fake-sa-key.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
|
|
|
@ -19,24 +19,18 @@ import (
|
|||
yandexexport "github.com/hashicorp/packer/post-processor/yandex-export"
|
||||
"github.com/hashicorp/packer/template/interpolate"
|
||||
"github.com/yandex-cloud/go-genproto/yandex/cloud/iam/v1/awscompatibility"
|
||||
"github.com/yandex-cloud/go-sdk/iamkey"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
common.PackerConfig `mapstructure:",squash"`
|
||||
yandex.AccessConfig `mapstructure:",squash"`
|
||||
|
||||
// The folder ID that will be used to store imported Image.
|
||||
FolderID string `mapstructure:"folder_id" required:"true"`
|
||||
// Service Account ID with proper permission to use Storage service
|
||||
// for operations 'upload' and 'delete' object to `bucket`
|
||||
// for operations 'upload' and 'delete' object to `bucket`.
|
||||
ServiceAccountID string `mapstructure:"service_account_id" required:"true"`
|
||||
|
||||
// OAuth token to use to authenticate to Yandex.Cloud.
|
||||
Token string `mapstructure:"token" required:"false"`
|
||||
// Path to file with Service Account key in json format. This
|
||||
// is an alternative method to authenticate to Yandex.Cloud.
|
||||
ServiceAccountKeyFile string `mapstructure:"service_account_key_file" required:"false"`
|
||||
|
||||
// The name of the bucket where the qcow2 file will be uploaded to for import.
|
||||
// This bucket must exist when the post-processor is run.
|
||||
//
|
||||
|
@ -49,7 +43,7 @@ type Config struct {
|
|||
ObjectName string `mapstructure:"object_name" required:"false"`
|
||||
// Whether skip removing the qcow2 file uploaded to Storage
|
||||
// after the import process has completed. Possible values are: `true` to
|
||||
// leave it in the bucket, `false` to remove it. (Default: `false`).
|
||||
// leave it in the bucket, `false` to remove it. Default is `false`.
|
||||
SkipClean bool `mapstructure:"skip_clean" required:"false"`
|
||||
|
||||
// The name of the image, which contains 1-63 characters and only
|
||||
|
@ -85,27 +79,10 @@ func (p *PostProcessor) Configure(raws ...interface{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
errs := new(packer.MultiError)
|
||||
// Accumulate any errors
|
||||
var errs *packer.MultiError
|
||||
|
||||
// provision config by OS environment variables
|
||||
if p.config.Token == "" {
|
||||
p.config.Token = os.Getenv("YC_TOKEN")
|
||||
}
|
||||
|
||||
if p.config.ServiceAccountKeyFile == "" {
|
||||
p.config.ServiceAccountKeyFile = os.Getenv("YC_SERVICE_ACCOUNT_KEY_FILE")
|
||||
}
|
||||
|
||||
if p.config.Token != "" {
|
||||
packer.LogSecretFilter.Set(p.config.Token)
|
||||
}
|
||||
|
||||
if p.config.ServiceAccountKeyFile != "" {
|
||||
if _, err := iamkey.ReadFromJSONFile(p.config.ServiceAccountKeyFile); err != nil {
|
||||
errs = packer.MultiErrorAppend(
|
||||
errs, fmt.Errorf("fail to read service account key file: %s", err))
|
||||
}
|
||||
}
|
||||
errs = packer.MultiErrorAppend(errs, p.config.AccessConfig.Prepare(&p.config.ctx)...)
|
||||
|
||||
if p.config.FolderID == "" {
|
||||
p.config.FolderID = os.Getenv("YC_FOLDER_ID")
|
||||
|
@ -160,12 +137,8 @@ func (p *PostProcessor) PostProcess(ctx context.Context, ui packer.Ui, artifact
|
|||
return nil, false, false, fmt.Errorf("error rendering object_name template: %s", err)
|
||||
}
|
||||
|
||||
cfg := &yandex.Config{
|
||||
Token: p.config.Token,
|
||||
ServiceAccountKeyFile: p.config.ServiceAccountKeyFile,
|
||||
}
|
||||
client, err := yandex.NewDriverYC(ui, &p.config.AccessConfig)
|
||||
|
||||
client, err := yandex.NewDriverYC(ui, cfg)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
|
|
|
@ -16,10 +16,12 @@ type FlatConfig struct {
|
|||
PackerOnError *string `mapstructure:"packer_on_error" cty:"packer_on_error" hcl:"packer_on_error"`
|
||||
PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables" hcl:"packer_user_variables"`
|
||||
PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables" hcl:"packer_sensitive_variables"`
|
||||
Endpoint *string `mapstructure:"endpoint" required:"false" cty:"endpoint" hcl:"endpoint"`
|
||||
ServiceAccountKeyFile *string `mapstructure:"service_account_key_file" required:"false" cty:"service_account_key_file" hcl:"service_account_key_file"`
|
||||
Token *string `mapstructure:"token" required:"true" cty:"token" hcl:"token"`
|
||||
MaxRetries *int `mapstructure:"max_retries" cty:"max_retries" hcl:"max_retries"`
|
||||
FolderID *string `mapstructure:"folder_id" required:"true" cty:"folder_id" hcl:"folder_id"`
|
||||
ServiceAccountID *string `mapstructure:"service_account_id" required:"true" cty:"service_account_id" hcl:"service_account_id"`
|
||||
Token *string `mapstructure:"token" required:"false" cty:"token" hcl:"token"`
|
||||
ServiceAccountKeyFile *string `mapstructure:"service_account_key_file" required:"false" cty:"service_account_key_file" hcl:"service_account_key_file"`
|
||||
Bucket *string `mapstructure:"bucket" required:"false" cty:"bucket" hcl:"bucket"`
|
||||
ObjectName *string `mapstructure:"object_name" required:"false" cty:"object_name" hcl:"object_name"`
|
||||
SkipClean *bool `mapstructure:"skip_clean" required:"false" cty:"skip_clean" hcl:"skip_clean"`
|
||||
|
@ -48,10 +50,12 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec {
|
|||
"packer_on_error": &hcldec.AttrSpec{Name: "packer_on_error", Type: cty.String, Required: false},
|
||||
"packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false},
|
||||
"packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false},
|
||||
"endpoint": &hcldec.AttrSpec{Name: "endpoint", Type: cty.String, Required: false},
|
||||
"service_account_key_file": &hcldec.AttrSpec{Name: "service_account_key_file", Type: cty.String, Required: false},
|
||||
"token": &hcldec.AttrSpec{Name: "token", Type: cty.String, Required: false},
|
||||
"max_retries": &hcldec.AttrSpec{Name: "max_retries", Type: cty.Number, Required: false},
|
||||
"folder_id": &hcldec.AttrSpec{Name: "folder_id", Type: cty.String, Required: false},
|
||||
"service_account_id": &hcldec.AttrSpec{Name: "service_account_id", Type: cty.String, Required: false},
|
||||
"token": &hcldec.AttrSpec{Name: "token", Type: cty.String, Required: false},
|
||||
"service_account_key_file": &hcldec.AttrSpec{Name: "service_account_key_file", Type: cty.String, Required: false},
|
||||
"bucket": &hcldec.AttrSpec{Name: "bucket", Type: cty.String, Required: false},
|
||||
"object_name": &hcldec.AttrSpec{Name: "object_name", Type: cty.String, Required: false},
|
||||
"skip_clean": &hcldec.AttrSpec{Name: "skip_clean", Type: cty.Bool, Required: false},
|
||||
|
|
|
@ -67,12 +67,15 @@ can be configured for this builder.
|
|||
|
||||
### Required:
|
||||
|
||||
@include 'builder/yandex/AccessConfig-required.mdx'
|
||||
|
||||
@include 'builder/yandex/Config-required.mdx'
|
||||
|
||||
### Optional:
|
||||
|
||||
@include 'builder/yandex/Config-not-required.mdx'
|
||||
@include 'builder/yandex/AccessConfig-not-required.mdx'
|
||||
|
||||
@include 'builder/yandex/Config-not-required.mdx'
|
||||
|
||||
## Build template data
|
||||
|
||||
|
|
|
@ -33,10 +33,14 @@ image.
|
|||
|
||||
### Required:
|
||||
|
||||
@include 'builder/yandex/AccessConfig-required.mdx'
|
||||
|
||||
@include 'post-processor/yandex-export/Config-required.mdx'
|
||||
|
||||
### Optional:
|
||||
|
||||
@include 'builder/yandex/AccessConfig-not-required.mdx'
|
||||
|
||||
@include 'post-processor/yandex-export/Config-not-required.mdx'
|
||||
|
||||
## Basic Example
|
||||
|
|
|
@ -25,10 +25,14 @@ file.
|
|||
|
||||
### Required:
|
||||
|
||||
@include 'builder/yandex/AccessConfig-required.mdx'
|
||||
|
||||
@include 'post-processor/yandex-import/Config-required.mdx'
|
||||
|
||||
### Optional:
|
||||
|
||||
@include 'builder/yandex/AccessConfig-not-required.mdx'
|
||||
|
||||
@include 'post-processor/yandex-import/Config-not-required.mdx'
|
||||
|
||||
## Basic Example
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<!-- Code generated from the comments of the AccessConfig struct in builder/yandex/access_config.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `endpoint` (string) - Non standard API endpoint. Default is `api.cloud.yandex.net:443`.
|
||||
|
||||
- `service_account_key_file` (string) - Path to file with Service Account key in json format. This
|
||||
is an alternative method to authenticate to Yandex.Cloud. Alternatively you may set environment variable
|
||||
`YC_SERVICE_ACCOUNT_KEY_FILE`.
|
||||
|
||||
- `max_retries` (int) - The maximum number of times an API request is being executed.
|
|
@ -0,0 +1,4 @@
|
|||
<!-- Code generated from the comments of the AccessConfig struct in builder/yandex/access_config.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `token` (string) - OAuth token to use to authenticate to Yandex.Cloud. Alternatively you may set
|
||||
value by environment variable `YC_TOKEN`.
|
|
@ -0,0 +1,3 @@
|
|||
<!-- Code generated from the comments of the AccessConfig struct in builder/yandex/access_config.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
AccessConfig is for common configuration related to Yandex.Cloud API access
|
|
@ -1,12 +1,6 @@
|
|||
<!-- Code generated from the comments of the Config struct in builder/yandex/config.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `endpoint` (string) - Non standard api endpoint URL.
|
||||
|
||||
- `service_account_key_file` (string) - Path to file with Service Account key in json format. This
|
||||
is an alternative method to authenticate to Yandex.Cloud. Alternatively you may set environment variable
|
||||
YC_SERVICE_ACCOUNT_KEY_FILE.
|
||||
|
||||
- `service_account_id` (string) - Service account identifier to assign to instance
|
||||
- `service_account_id` (string) - Service account identifier to assign to instance.
|
||||
|
||||
- `disk_name` (string) - The name of the disk, if unset the instance name
|
||||
will be used.
|
||||
|
@ -19,8 +13,7 @@
|
|||
|
||||
- `image_family` (string) - The family name of the resulting image.
|
||||
|
||||
- `image_labels` (map[string]string) - Key/value pair labels to
|
||||
apply to the created image.
|
||||
- `image_labels` (map[string]string) - Key/value pair labels to apply to the created image.
|
||||
|
||||
- `image_min_disk_size_gb` (int) - Minimum size of the disk that will be created from built image, specified in gigabytes.
|
||||
Should be more or equal to `disk_size_gb`.
|
||||
|
@ -38,16 +31,14 @@
|
|||
|
||||
- `instance_name` (string) - The name assigned to the instance.
|
||||
|
||||
- `labels` (map[string]string) - Key/value pair labels to apply to
|
||||
the launched instance.
|
||||
- `labels` (map[string]string) - Key/value pair labels to apply to the launched instance.
|
||||
|
||||
- `platform_id` (string) - Identifier of the hardware platform configuration for the instance. This defaults to `standard-v1`.
|
||||
|
||||
- `max_retries` (int) - The maximum number of times an API request is being executed
|
||||
|
||||
- `metadata` (map[string]string) - Metadata applied to the launched instance.
|
||||
|
||||
- `metadata_from_file` (map[string]string) - Metadata applied to the launched instance. Value are file paths.
|
||||
- `metadata_from_file` (map[string]string) - Metadata applied to the launched instance.
|
||||
The values in this map are the paths to the content files for the corresponding metadata keys.
|
||||
|
||||
- `preemptible` (bool) - Launch a preemptible instance. This defaults to `false`.
|
||||
|
||||
|
@ -55,8 +46,7 @@
|
|||
|
||||
- `source_image_folder_id` (string) - The ID of the folder containing the source image.
|
||||
|
||||
- `source_image_id` (string) - The source image ID to use to create the new image
|
||||
from.
|
||||
- `source_image_id` (string) - The source image ID to use to create the new image from.
|
||||
|
||||
- `source_image_name` (string) - The source image name to use to create the new image
|
||||
from. Name will be looked up in `source_image_folder_id`.
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
<!-- Code generated from the comments of the Config struct in builder/yandex/config.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `folder_id` (string) - The folder ID that will be used to launch instances and store images.
|
||||
Alternatively you may set value by environment variable YC_FOLDER_ID.
|
||||
Alternatively you may set value by environment variable `YC_FOLDER_ID`.
|
||||
To use a different folder for looking up the source image or saving the target image to
|
||||
check options 'source_image_folder_id' and 'target_image_folder_id'.
|
||||
|
||||
- `token` (string) - OAuth token to use to authenticate to Yandex.Cloud. Alternatively you may set
|
||||
value by environment variable YC_TOKEN.
|
||||
|
||||
- `source_image_family` (string) - The source image family to create the new image
|
||||
from. You can also specify source_image_id instead. Just one of a source_image_id or
|
||||
source_image_family must be specified. Example: `ubuntu-1804-lts`
|
||||
source_image_family must be specified. Example: `ubuntu-1804-lts`.
|
||||
|
|
|
@ -11,10 +11,3 @@
|
|||
zone in which the VM is launched.
|
||||
|
||||
- `zone` (string) - The name of the zone to launch the instance. This defaults to `ru-central1-a`.
|
||||
|
||||
- `token` (string) - OAuth token to use to authenticate to Yandex.Cloud. Alternatively you may set
|
||||
value by environment variable YC_TOKEN.
|
||||
|
||||
- `service_account_key_file` (string) - Path to file with Service Account key in json format. This
|
||||
is an alternative method to authenticate to Yandex.Cloud. Alternatively you may set environment variable
|
||||
YC_SERVICE_ACCOUNT_KEY_FILE.
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
Paths to Yandex Object Storage where exported image will be uploaded.
|
||||
|
||||
- `folder_id` (string) - The folder ID that will be used to launch a temporary instance.
|
||||
Alternatively you may set value by environment variable YC_FOLDER_ID.
|
||||
Alternatively you may set value by environment variable `YC_FOLDER_ID`.
|
||||
|
||||
- `service_account_id` (string) - Service Account ID with proper permission to modify an instance, create and attach disk and
|
||||
make upload to specific Yandex Object Storage paths.
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
<!-- Code generated from the comments of the Config struct in post-processor/yandex-import/post-processor.go; DO NOT EDIT MANUALLY -->
|
||||
|
||||
- `token` (string) - OAuth token to use to authenticate to Yandex.Cloud.
|
||||
|
||||
- `service_account_key_file` (string) - Path to file with Service Account key in json format. This
|
||||
is an alternative method to authenticate to Yandex.Cloud.
|
||||
|
||||
- `bucket` (string) - The name of the bucket where the qcow2 file will be uploaded to for import.
|
||||
This bucket must exist when the post-processor is run.
|
||||
|
||||
|
@ -17,7 +12,7 @@
|
|||
|
||||
- `skip_clean` (bool) - Whether skip removing the qcow2 file uploaded to Storage
|
||||
after the import process has completed. Possible values are: `true` to
|
||||
leave it in the bucket, `false` to remove it. (Default: `false`).
|
||||
leave it in the bucket, `false` to remove it. Default is `false`.
|
||||
|
||||
- `image_name` (string) - The name of the image, which contains 1-63 characters and only
|
||||
supports lowercase English characters, numbers and hyphen.
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
- `folder_id` (string) - The folder ID that will be used to store imported Image.
|
||||
|
||||
- `service_account_id` (string) - Service Account ID with proper permission to use Storage service
|
||||
for operations 'upload' and 'delete' object to `bucket`
|
||||
for operations 'upload' and 'delete' object to `bucket`.
|
||||
|
|
Loading…
Reference in New Issue