diff --git a/app/cmd/cmd.go b/app/cmd/cmd.go index f3dfec9886439dd87108798d0cf528ffdb0ce8ca..71ecaa8f0ee9bc54f69f97155f1db7c61b1e4b41 100755 --- a/app/cmd/cmd.go +++ b/app/cmd/cmd.go @@ -34,6 +34,7 @@ func NewNkdCommand(in io.Reader, out, err io.Writer) *cobra.Command { cmds.AddCommand(NewDeployCommand()) cmds.AddCommand(NewDestroyCommand()) cmds.AddCommand(NewUpgradeCommand()) + cmds.AddCommand(NewExtendCommand()) return cmds } diff --git a/app/cmd/deploy.go b/app/cmd/deploy.go index 14565516d0b32777847e45cbaaf84086e9491fc9..fdc3ae00b3226e0d768d4de2d191c798e06bc073 100755 --- a/app/cmd/deploy.go +++ b/app/cmd/deploy.go @@ -22,22 +22,18 @@ import ( ) func NewDeployCommand() *cobra.Command { - var dir, role string - cmd := &cobra.Command{ Use: "deploy", - Short: "Use this command to deploy kubernetes node", + Short: "Deploy kubernetes cluster", RunE: func(cmd *cobra.Command, args []string) error { cluster := &infra.Cluster{ - Dir: dir, - Role: role, + Dir: "./", + Node: "master", } + return cluster.Create() }, } - cmd.PersistentFlags().StringVarP(&dir, "dir", "d", "", "directory for deployment") - cmd.PersistentFlags().StringVarP(&role, "role", "r", "", "node role for deployment") - return cmd } diff --git a/app/cmd/destroy.go b/app/cmd/destroy.go index 56124d301a620e75a93cbca77e1ef0ef250587cc..1dc6e6b06111917d4ac7c658dede490897d26c3b 100644 --- a/app/cmd/destroy.go +++ b/app/cmd/destroy.go @@ -16,28 +16,22 @@ limitations under the License. package cmd import ( - "nestos-kubernetes-deployer/app/phases/infra" + "nestos-kubernetes-deployer/app/cmd/phases/destroy" "github.com/spf13/cobra" ) func NewDestroyCommand() *cobra.Command { - var dir, role string - cmd := &cobra.Command{ Use: "destroy", - Short: "Use this command to destroy kubernetes node", + Short: "Destroy kubernetes cluster", RunE: func(cmd *cobra.Command, args []string) error { - cluster := &infra.Cluster{ - Dir: dir, - Role: role, - } - return cluster.Destroy() + return cmd.Help() }, } - cmd.PersistentFlags().StringVarP(&dir, "dir", "d", "", "directory for deployment") - cmd.PersistentFlags().StringVarP(&role, "role", "r", "", "node role for deployment") + cmd.AddCommand(destroy.NewDestroyMasterCommand()) + cmd.AddCommand(destroy.NewDestroyWorkerCommand()) return cmd } diff --git a/app/cmd/extend.go b/app/cmd/extend.go index 3814ce6539b59bacbb3910acfe77d69e73c6b8c4..3d2d8e9b88cdcbbd6e0f3877a9e9d6c1bc241229 100755 --- a/app/cmd/extend.go +++ b/app/cmd/extend.go @@ -14,3 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ package cmd + +import ( + "nestos-kubernetes-deployer/app/cmd/phases/extend" + + "github.com/spf13/cobra" +) + +func NewExtendCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "extend", + Short: "Extend kubernetes cluster", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(extend.NewExtendMasterCommand()) + cmd.AddCommand(extend.NewExtendWorkerCommand()) + + return cmd +} diff --git a/app/cmd/phases/destroy/destroy.go b/app/cmd/phases/destroy/destroy.go new file mode 100644 index 0000000000000000000000000000000000000000..9712db6eecf6ccff70489cc24015edc5eb9241e2 --- /dev/null +++ b/app/cmd/phases/destroy/destroy.go @@ -0,0 +1,57 @@ +/* +Copyright 2023 KylinSoft Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package destroy + +import ( + "nestos-kubernetes-deployer/app/phases/infra" + + "github.com/spf13/cobra" +) + +func NewDestroyMasterCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "master", + Short: "destroy kubernetes master node", + RunE: func(cmd *cobra.Command, args []string) error { + cluster := &infra.Cluster{ + Dir: "./", + Node: "master", + } + + return cluster.Destroy() + }, + } + + return cmd +} + +func NewDestroyWorkerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "worker", + Short: "destroy kubernetes worker node", + RunE: func(cmd *cobra.Command, args []string) error { + cluster := &infra.Cluster{ + Dir: "./", + Node: "worker", + } + + return cluster.Destroy() + }, + } + + return cmd +} diff --git a/app/cmd/phases/extend/extend.go b/app/cmd/phases/extend/extend.go new file mode 100644 index 0000000000000000000000000000000000000000..cae783190b08e07c11db67df6de6a3ddc41c6ad4 --- /dev/null +++ b/app/cmd/phases/extend/extend.go @@ -0,0 +1,69 @@ +/* +Copyright 2023 KylinSoft Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package extend + +import ( + "nestos-kubernetes-deployer/app/phases/infra" + + "github.com/spf13/cobra" +) + +func NewExtendMasterCommand() *cobra.Command { + var num string + cmd := &cobra.Command{ + Use: "master", + Short: "Extend kubernetes master node", + RunE: func(cmd *cobra.Command, args []string) error { + cluster := &infra.Cluster{ + Dir: "./", + Node: "master", + Num: num, + } + + if num == "" { + return cmd.Help() + } + return cluster.Extend() + }, + } + cmd.PersistentFlags().StringVarP(&num, "num", "n", "", "number of the extend nodes") + + return cmd +} + +func NewExtendWorkerCommand() *cobra.Command { + var num string + cmd := &cobra.Command{ + Use: "worker", + Short: "Extend kubernetes worker node", + RunE: func(cmd *cobra.Command, args []string) error { + cluster := &infra.Cluster{ + Dir: "./", + Node: "worker", + Num: num, + } + + if num == "" { + return cmd.Help() + } + return cluster.Extend() + }, + } + cmd.PersistentFlags().StringVarP(&num, "num", "n", "", "number of the extend nodes") + + return cmd +} diff --git a/app/cmd/phases/init/tf.go b/app/cmd/phases/init/tf.go index 42adeb1de4f347015781d8609b6f06e609e36e2b..607454620bd26cc2bf69536dd9e090d261f95dba 100644 --- a/app/cmd/phases/init/tf.go +++ b/app/cmd/phases/init/tf.go @@ -13,29 +13,64 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + package phases import ( "fmt" "nestos-kubernetes-deployer/app/cmd/phases/workflow" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/pkg/errors" ) func NewGenerateTFCmd() workflow.Phase { return workflow.Phase{ - Name: "cert", - Short: "Run certs to generate certs", - Run: runGenerateCertsConfig, + Name: "tf", + Short: "Run tf to generate certs", + Run: runGenerateTFConfig, } } func runGenerateTFConfig(r workflow.RunData, node string) error { - if node == "worker" { - fmt.Println(r.(InitData).WorkerCfg()) + outputFile, err := os.Create(filepath.Join("./", fmt.Sprintf("%s.tf", node))) + if err != nil { + return errors.Wrap(err, "failed to create terraform config file") + } + defer outputFile.Close() + + // 从文件中读取 terraformConfig + terraformConfig, err := os.ReadFile(filepath.Join("resource/templates/terraform", fmt.Sprintf("%s.tf.tpl", node))) + if err != nil { + return errors.Wrap(err, "failed to read terraform config template") + } + + // 使用模板填充数据 + tmpl, err := template.New("terraform").Parse(string(terraformConfig)) + if err != nil { + return errors.Wrap(err, "failed to create terraform config template") + } + + quotedStrs := make([]string, len(r.(InitData).MasterCfg().System.Ips)) + for i, s := range r.(InitData).MasterCfg().System.Ips { + quotedStrs[i] = fmt.Sprintf(`"%s"`, s) + } + joinedStr := strings.Join(quotedStrs, ",") + + r.(InitData).MasterCfg().System.Ips = []string{joinedStr} + + // 将填充后的数据写入文件 + if node == "master" { + err = tmpl.Execute(outputFile, r.(InitData).MasterCfg()) } else { - fmt.Println(node) - fmt.Println(r.(InitData).MasterCfg()) + err = tmpl.Execute(outputFile, r.(InitData).WorkerCfg()) + } + if err != nil { + return errors.Wrap(err, "failed to write terraform config") } - // data := r.(InitData) - // fmt.Println(data) + return nil } diff --git a/app/phases/infra/infra.go b/app/phases/infra/infra.go index 8d58c10cf1b4afe757526996ce410ad8e937ccb3..b050542fa8dca7d2304e0df07cd6012b9c6c43d2 100644 --- a/app/phases/infra/infra.go +++ b/app/phases/infra/infra.go @@ -17,7 +17,6 @@ limitations under the License. package infra import ( - "nestos-kubernetes-deployer/app/apis/nkd" "nestos-kubernetes-deployer/app/phases/infra/terraform" "os" "path/filepath" @@ -25,12 +24,12 @@ import ( "github.com/hashicorp/terraform-exec/tfexec" "github.com/pkg/errors" "github.com/sirupsen/logrus" - "gopkg.in/yaml.v2" ) type Cluster struct { - Dir string // 由用户自定义,会在该路径下创建工作目录 /nkd - Role string // 节点类别 + Dir string // 由用户自定义,会在该路径下创建工作目录 .nkd + Node string // 节点类别 + Num string // 扩展节点个数 } type File struct { @@ -39,150 +38,74 @@ type File struct { } func (c *Cluster) Create() error { - // 若未指定c.Dir,则默认为当前路径 - if c.Dir == "" { - c.Dir = "./" - } - - if c.Role == "" { - return errors.Errorf("please provide the role of the node, master or worker") - } - // 工作目录,包含terraform执行文件以及所需plugins - workDir := filepath.Join(c.Dir, "nkd") - // 从文件中读取 infraData - infraData, err := os.ReadFile(filepath.Join(workDir, "config.yaml")) - if err != nil { - return errors.WithMessage(err, "failed to read yaml file") - } - - // 解析 yaml 数据 - var configData nkd.Master - err = yaml.Unmarshal(infraData, &configData) - if err != nil { - return errors.WithMessage(err, "failed to parse yaml data") - } - - // 获取当前选择的平台 - platform := configData.Infra.Platform + workDir := "./" // tf配置文件所在目录 - tfDir := filepath.Join(workDir, platform, c.Role) - - // ToDo: 支持terraform apply参数 - applyOptsFile := &File{ - Filename: ".applyOptsFile", - Data: []byte{}, - } + tfDir := filepath.Join(workDir, c.Node) - logrus.Infof("start to create %s in %s", c.Role, platform) - - outputs, err := executeApplyTerraform(tfDir, workDir, applyOptsFile) + outputs, err := executeApplyTerraform(tfDir, workDir) if err != nil { - return errors.Wrapf(err, "failed to create %s in %s", c.Role, platform) + return errors.Wrap(err, "failed to execute terraform apply") } - - logrus.Info(string(outputs.Data)) - logrus.Infof("succeed in creating %s in %s", c.Role, platform) + logrus.Info(string(outputs)) return nil } -func executeApplyTerraform(tfDir string, terraformDir string, tfvarsFile *File) (*File, error) { +func executeApplyTerraform(tfDir string, terraformDir string) ([]byte, error) { var applyOpts []tfexec.ApplyOption - - if err := os.WriteFile(filepath.Join(tfDir, tfvarsFile.Filename), tfvarsFile.Data, 0o600); err != nil { - return nil, err - } - applyOpts = append(applyOpts, tfexec.VarFile(filepath.Join(tfDir, tfvarsFile.Filename))) - return applyTerraform(tfDir, terraformDir, applyOpts...) } -func applyTerraform(tfDir string, terraformDir string, applyOpts ...tfexec.ApplyOption) (*File, error) { +func applyTerraform(tfDir string, terraformDir string, applyOpts ...tfexec.ApplyOption) ([]byte, error) { applyErr := terraform.TFApply(tfDir, terraformDir, applyOpts...) + if applyErr != nil { + return nil, applyErr + } _, err := os.Stat(filepath.Join(tfDir, "terraform.tfstate")) if os.IsNotExist(err) { - return nil, errors.Wrap(err, "failed to read tfstate") - } - - if applyErr != nil { - return nil, errors.Wrap(applyErr, "failed to apply Terraform") + return nil, err } outputs, err := terraform.Outputs(tfDir, terraformDir) if err != nil { - return nil, errors.Wrap(err, "failed to get outputs file") - } - - outputsFile := &File{ - Filename: "tfOutputs", - Data: outputs, + return nil, err } - return outputsFile, nil + return outputs, nil } func (c *Cluster) Destroy() error { - // 若未指定c.Dir,则默认为当前路径 - if c.Dir == "" { - c.Dir = "./" - } - - if c.Role == "" { - return errors.Errorf("please provide the role of the node, master or worker") - } - - // 从文件中读取 infraData - workDir := filepath.Join(c.Dir, "nkd") - infraData, err := os.ReadFile(filepath.Join(workDir, "config.yaml")) - if err != nil { - return errors.Wrap(err, "failed to read yaml data") - } - - // 解析 yaml 数据 - var configData nkd.Master - err = yaml.Unmarshal(infraData, &configData) - if err != nil { - return errors.Wrap(err, "failed to parse yaml data") - } - - // 获取当前选择的平台 - platform := configData.Infra.Platform - tfDir := filepath.Join(workDir, platform, c.Role) - - logrus.Infof("start to destroy %s in %s", c.Role, platform) + // 工作目录,包含terraform执行文件以及所需plugins + workDir := "./" - // ToDo: 支持terraform destroy参数 - destroyOptsFile := &File{ - Filename: ".destroyOptsFile", - Data: []byte{}, - } + // tf配置文件所在目录 + tfDir := filepath.Join(workDir, c.Node) - err = executeDestroyTerraform(tfDir, workDir, destroyOptsFile) + err := executeDestroyTerraform(tfDir, workDir) if err != nil { - return errors.Wrapf(err, "failed to destroy %s in %s", c.Role, platform) + return errors.Wrap(err, "failed to execute terraform destroy") } - os.RemoveAll(tfDir) - - logrus.Infof("succeed in destroying %s in %s", c.Role, platform) return nil } -func executeDestroyTerraform(tfDir string, terraformDir string, tfvarsFile *File) error { +func executeDestroyTerraform(tfDir string, terraformDir string) error { var destroyOpts []tfexec.DestroyOption - destroyOpts = append(destroyOpts, tfexec.VarFile(filepath.Join(tfDir, tfvarsFile.Filename))) - return destroyTerraform(tfDir, terraformDir, destroyOpts...) } func destroyTerraform(tfDir string, terraformDir string, destroyOpts ...tfexec.DestroyOption) error { destroyErr := terraform.TFDestroy(tfDir, terraformDir, destroyOpts...) if destroyErr != nil { - return errors.Wrap(destroyErr, "failed to destroy Terraform") + return destroyErr } return nil } + +func (c *Cluster) Extend() error { + return nil +} diff --git a/app/phases/infra/terraform/state.go b/app/phases/infra/terraform/state.go index 446ddbdca5634baed38d426fd7c030fa6c1b6e5d..46ffa88b396f2cb5c29710c2ce636bfc61b05306 100644 --- a/app/phases/infra/terraform/state.go +++ b/app/phases/infra/terraform/state.go @@ -27,7 +27,7 @@ import ( func Outputs(dir string, terraformDir string) ([]byte, error) { tf, err := newTFExec(dir, terraformDir) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to destroy a new tfexec") } tfoutput, err := tf.Output(context.Background()) diff --git a/app/phases/infra/terraform/terraform.go b/app/phases/infra/terraform/terraform.go index b6fb898366cf00ad2c44088c3bc2f48c3751b130..6248805452fba82fdd6ab663888688294d285337 100644 --- a/app/phases/infra/terraform/terraform.go +++ b/app/phases/infra/terraform/terraform.go @@ -101,7 +101,7 @@ func TFApply(tfDir string, terraformDir string, applyOpts ...tfexec.ApplyOption) // terraform destroy func TFDestroy(tfDir string, terraformDir string, destroyOpts ...tfexec.DestroyOption) error { if err := TFInit(tfDir, terraformDir); err != nil { - return err + return errors.Wrap(err, "failed to init terraform") } tf, err := newTFExec(tfDir, terraformDir) diff --git a/resource/templates/terraform/main.tf.template b/resource/templates/terraform/master.tf.tpl similarity index 47% rename from resource/templates/terraform/main.tf.template rename to resource/templates/terraform/master.tf.tpl index ec69fa51e3c93f2d2fb2ccd6ff18580f77275aae..96169f03f77087f39de716d1452325d365d5b276 100644 --- a/resource/templates/terraform/main.tf.template +++ b/resource/templates/terraform/master.tf.tpl @@ -8,43 +8,32 @@ terraform { } provider "openstack" { - user_name = "{{.Openstack.User_name}}" - password = "{{.Openstack.Password}}" - tenant_name = "{{.Openstack.Tenant_name}}" - auth_url = "{{.Openstack.Auth_url}}" - region = "{{.Openstack.Region}}" + user_name = "{{.Infra.Openstack.User_name}}" + password = "{{.Infra.Openstack.Password}}" + tenant_name = "{{.Infra.Openstack.Tenant_name}}" + auth_url = "{{.Infra.Openstack.Auth_url}}" + region = "{{.Infra.Openstack.Region}}" } -resource "openstack_images_image_v2" "image" { - name = "{{.Image.Name}}" - - image_source_url = "{{.Image.Image_source_url}}" - image_source_username = "{{.Image.Image_source_username}}" - image_source_password = "{{.Image.Image_source_password}}" - web_download = "{{.Image.Web_download}}" - - local_file_path = "{{.Image.local_file_path}}" - - container_format = "{{.Image.Container_format}}" - disk_format = "{{.Image.Disk_format}}" +variable "instance_count" { + default = "3" } -resource "openstack_compute_flavor_v2" "flavor" { - name = "{{.Flavor.Name}}" - ram = "{{.Flavor.Ram}}" - vcpus = "{{.Flavor.Vcpus}}" - disk = "{{.Flavor.Disk}}" - is_public = "{{.Flavor.Is_public}}" +variable "instance_name" { + default = "{{.System.HostName}}" } -resource "openstack_compute_keypair_v2" "keypair" { - name = "{{.Keypair.Name}}" - public_key = "{{.Keypair.Public_key}}" +resource "openstack_compute_flavor_v2" "flavor" { + name = var.instance_names[count.index] + ram = "{{.Infra.Vmsize.Ram}}" + vcpus = "{{.Infra.Vmsize.Vcpus}}" + disk = "{{.Infra.Vmsize.Disk}}" + is_public = "true" } resource "openstack_compute_secgroup_v2" "secgroup" { - name = "{{.Secgroup.Name}}" - description = "{{.Secgroup.Name}}" + name = "k8s_master_secgroup" + description = "secgroup for k8s master" rule { from_port = 22 @@ -62,22 +51,22 @@ resource "openstack_compute_secgroup_v2" "secgroup" { } resource "openstack_compute_instance_v2" "instance" { - count = "{{.Instance.Count}}" - name = "{{.Instance.Name}}" - image_name = openstack_images_image_v2.image.name + count = var.instance_count + name = format("${var.instance_name}%02d", count.index + 1) + image_name = "{{.Infra.Openstack.Glance}}" flavor_name = openstack_compute_flavor_v2.flavor.name - key_pair = openstack_compute_keypair_v2.keypair.name security_groups = [openstack_compute_secgroup_v2.secgroup.name] - user_data = "{{.Instance.User_data}}" + user_data = file("/etc/nkd/instance.ign") network { - name = "{{.Instance.Network.Name}}" + name = "{{.Infra.Openstack.Internal_network}}" + fixed_ip_v4 = element({{.System.Ips}}, count.index) } } resource "openstack_networking_floatingip_v2" "floatip" { count = length(openstack_compute_instance_v2.instance) - pool = "{{.Floatip.Pool}}" + pool = "{{.Infra.Openstack.External_network}}" } resource "openstack_compute_floatingip_associate_v2" "fip_associate" { @@ -89,7 +78,6 @@ resource "openstack_compute_floatingip_associate_v2" "fip_associate" { output "instance_info" { value = { instance_status = openstack_compute_instance_v2.instance.*.power_state - access_ip_v4 = openstack_compute_instance_v2.instance.*.access_ip_v4 floating_ip = openstack_networking_floatingip_v2.floatip.*.address } -} +} \ No newline at end of file diff --git a/resource/templates/terraform/worker.tf.tpl b/resource/templates/terraform/worker.tf.tpl new file mode 100644 index 0000000000000000000000000000000000000000..9a052e95f1c3a225d49d4e1a9a0bd4d8a6a1b244 --- /dev/null +++ b/resource/templates/terraform/worker.tf.tpl @@ -0,0 +1,83 @@ +terraform { + required_providers { + openstack = { + source = "terraform-provider-openstack/openstack" + version = "1.52.1" + } + } +} + +provider "openstack" { + user_name = "{{.Infra.Openstack.User_name}}" + password = "{{.Infra.Openstack.Password}}" + tenant_name = "{{.Infra.Openstack.Tenant_name}}" + auth_url = "{{.Infra.Openstack.Auth_url}}" + region = "{{.Infra.Openstack.Region}}" +} + +variable "instance_count" { + default = "3" +} + +variable "instance_name" { + default = "{{.System.HostName}}" +} + +resource "openstack_compute_flavor_v2" "flavor" { + name = var.instance_names[count.index] + ram = "{{.Infra.Vmsize.Ram}}" + vcpus = "{{.Infra.Vmsize.Vcpus}}" + disk = "{{.Infra.Vmsize.Disk}}" + is_public = "true" +} + +resource "openstack_compute_secgroup_v2" "secgroup" { + name = "k8s_worker_secgroup" + description = "secgroup for k8s worker" + + rule { + from_port = 22 + to_port = 22 + ip_protocol = "tcp" + cidr = "0.0.0.0/0" + } + + rule { + from_port = -1 + to_port = -1 + ip_protocol = "icmp" + cidr = "0.0.0.0/0" + } +} + +resource "openstack_compute_instance_v2" "instance" { + count = var.instance_count + name = format("${var.instance_name}%02d", count.index + 1) + image_name = "{{.Infra.Openstack.Glance}}" + flavor_name = openstack_compute_flavor_v2.flavor.name + security_groups = [openstack_compute_secgroup_v2.secgroup.name] + user_data = file("/etc/nkd/instance.ign") + + network { + name = "{{.Infra.Openstack.Internal_network}}" + fixed_ip_v4 = element({{.System.Ips}}, count.index) + } +} + +resource "openstack_networking_floatingip_v2" "floatip" { + count = length(openstack_compute_instance_v2.instance) + pool = "{{.Infra.Openstack.External_network}}" +} + +resource "openstack_compute_floatingip_associate_v2" "fip_associate" { + count = length(openstack_compute_instance_v2.instance) + floating_ip = openstack_networking_floatingip_v2.floatip.*.address[count.index] + instance_id = openstack_compute_instance_v2.instance.*.id[count.index] +} + +output "instance_info" { + value = { + instance_status = openstack_compute_instance_v2.instance.*.power_state + floating_ip = openstack_networking_floatingip_v2.floatip.*.address + } +} \ No newline at end of file