Repository: sacloud/usacloud (go)

Issue:
パラメータのJSON/YAML形式での指定
ネストしたパラメータをコマンドのパラメータとして渡す際にJSON/YAML文字列/ファイルから指定可能にするか検討する。

対象の例: ディスクの修正APIでのスタートアップスクリプトの指定

```go
// libsacloud serviceでのリクエスト抜粋
type EditRequest struct {
	/* ... */
	Notes            []*sacloud.DiskEditNote 
}

// sacloud.DiskEditNote
type DiskEditNote struct {
	ID        types.ID
	APIKeyID  types.ID
	Variables map[string]interface{}
}
```

コマンドラインからは以下のように指定するイメージ。

```bash
# エスケープは省略して記載
$ usacloud disk edit "example" --startup-scripts '[{"ID":"123456789012", "Variables": {"key1":"value1","key2":"value2"}}]'

# ファイルからも指定可能
$ usacloud disk edit "example" --startup-scripts parameters.json

# ファイルの場合、拡張子からJSON or YAMLを判定
$ usacloud disk edit "example" --startup-scripts parameters.yaml
$ usacloud disk edit "example" --startup-scripts parameters.yml
```

### 気になる点

- 従来の`--parameters`や`--parameters-file`との併用時はどうなるか?
- 利用シーンは多くないのではないか?

Pull request description:
Parameter Customizer / Allows the flag value to be a file path or content
closes #568 

#596 の正式版

- core.ParameterCustomizerを導入
- フラグにパス or JSONを指定可能に

Note: 現状ではJSONのみに対応しYAMLは今後要望があれば対応とする。

Patch:
diff --git a/go.mod b/go.mod
index 3008fb51..170f8171 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/olekukonko/tablewriter v0.0.0-20180506121414-d4647c9c7a84
 	github.com/sacloud/go-jmespath v0.0.0-20190125082617-862639817e08
-	github.com/sacloud/libsacloud/v2 v2.8.9-0.20201113055740-e862733551c8
+	github.com/sacloud/libsacloud/v2 v2.8.11-0.20201120031150-fb22798fc6db
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.6.1
diff --git a/go.sum b/go.sum
index 21b4cb24..838e0641 100644
--- a/go.sum
+++ b/go.sum
@@ -131,8 +131,8 @@ github.com/sacloud/ftps v1.1.0 h1:cYv+b6qhrIT8msfx64XXRJzbv5S+Dqwb/rXa5Y641XA=
 github.com/sacloud/ftps v1.1.0/go.mod h1:h4awhOi3PEyhKLj1FpXjoVV5yVkmRUU+d5L95EwX2JU=
 github.com/sacloud/go-jmespath v0.0.0-20190125082617-862639817e08 h1:hGFzyq3AgiEHgI9xYPrtbK466gP7AimFCjrn3nZktc8=
 github.com/sacloud/go-jmespath v0.0.0-20190125082617-862639817e08/go.mod h1:PLy4tK2PN6G8fxd/cCWH6db2mJGmUlflYs4ASdfNulg=
-github.com/sacloud/libsacloud/v2 v2.8.9-0.20201113055740-e862733551c8 h1:8A6Uhjkah6dwHXAyfLoaQq8whEP0ccYvicUG88Ag78U=
-github.com/sacloud/libsacloud/v2 v2.8.9-0.20201113055740-e862733551c8/go.mod h1:gRNUdloSQqgQfTEMpot80TK7REly0TSME6dn5aZl3GY=
+github.com/sacloud/libsacloud/v2 v2.8.11-0.20201120031150-fb22798fc6db h1:QrTqKgoL22HAokmPT4Jo01XQsJGQi8pgcwJlD8dhcpM=
+github.com/sacloud/libsacloud/v2 v2.8.11-0.20201120031150-fb22798fc6db/go.mod h1:gRNUdloSQqgQfTEMpot80TK7REly0TSME6dn5aZl3GY=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
diff --git a/pkg/cmd/cflag/common.go b/pkg/cmd/cflag/common.go
index 89e8227e..19fe41e5 100644
--- a/pkg/cmd/cflag/common.go
+++ b/pkg/cmd/cflag/common.go
@@ -16,6 +16,5 @@ package cflag
 
 type CommonParameterValueHolder interface {
 	ParametersFlagValue() string
-	ParameterFileFlagValue() string
 	GenerateSkeletonFlagValue() bool
 }
diff --git a/pkg/cmd/cflag/common_parameter.go b/pkg/cmd/cflag/common_parameter.go
index 3265413c..2be693d5 100644
--- a/pkg/cmd/cflag/common_parameter.go
+++ b/pkg/cmd/cflag/common_parameter.go
@@ -17,7 +17,6 @@ package cflag
 // CommonParameter 全コマンド共通フィールド
 type CommonParameter struct {
 	Parameters       string `cli:",category=input,desc=Input parameters in JSON format" json:"-"`
-	ParameterFile    string `cli:",category=input,desc=Input parameters in JSON format(from file)" json:"-"`
 	GenerateSkeleton bool   `cli:",category=input,desc=Output skeleton of parameters with JSON format" json:"-"`
 }
 
@@ -25,10 +24,6 @@ func (p *CommonParameter) ParametersFlagValue() string {
 	return p.Parameters
 }
 
-func (p *CommonParameter) ParameterFileFlagValue() string {
-	return p.ParameterFile
-}
-
 func (p *CommonParameter) GenerateSkeletonFlagValue() bool {
 	return p.GenerateSkeleton
 }
diff --git a/pkg/cmd/commands/disk/edit.go b/pkg/cmd/commands/disk/edit.go
index 5364930a..ae9e6491 100644
--- a/pkg/cmd/commands/disk/edit.go
+++ b/pkg/cmd/commands/disk/edit.go
@@ -19,14 +19,14 @@ import (
 	"github.com/sacloud/libsacloud/v2/sacloud/types"
 	"github.com/sacloud/usacloud/pkg/cmd/cflag"
 	"github.com/sacloud/usacloud/pkg/cmd/core"
+	"github.com/sacloud/usacloud/pkg/util"
 )
 
-// TODO 未実装
-var editCommand = &core.Command{ // nolint TODO あとでnolintを消す
+var editCommand = &core.Command{
 	Name:         "edit",
 	Category:     "operation",
 	Order:        10,
-	SelectorType: core.SelectorTypeRequireSingle,
+	SelectorType: core.SelectorTypeRequireMulti,
 
 	ParameterInitializer: func() interface{} {
 		return newEditParameter()
@@ -34,16 +34,12 @@ var editCommand = &core.Command{ // nolint TODO あとでnolintを消す
 }
 
 type editParameter struct {
-	cflag.ZoneParameter `cli:",squash" mapconv:",squash"`
-	cflag.IDParameter   `cli:",squash" mapconv:",squash"`
+	cflag.ZoneParameter   `cli:",squash" mapconv:",squash"`
+	cflag.IDParameter     `cli:",squash" mapconv:",squash"`
+	cflag.CommonParameter `cli:",squash" mapconv:"-"`
 
-	EditParameter editServiceParameter `cli:",squash" mapconv:",omitempty"`
+	NoWait bool `request:"-"` // trueの場合ディスクの修正完了まで待たずに即時復帰する
 
-	cflag.ConfirmParameter `cli:",squash" mapconv:"-"`
-	cflag.OutputParameter  `cli:",squash" mapconv:"-"`
-}
-
-type editServiceParameter struct {
 	HostName string
 	Password string
 
@@ -55,10 +51,15 @@ type editServiceParameter struct {
 	NetworkMaskLen int
 	DefaultRoute   string
 
-	SSHKeys   []string
-	SSHKeyIDs []types.ID
+	SSHKeys   []string   `cli:"ssh-keys"`
+	SSHKeyIDs []types.ID `cli:"ssh-key-ids"`
 
-	Notes []*sacloud.DiskEditNote // TODO 2段階以上にネストしたパラメータをどう扱うか? => https://github.com/sacloud/usacloud/issues/568
+	NoteIDs   []types.ID              `cli:"note-ids" mapconv:"-"`
+	NotesData string                  `cli:"notes" mapconv:"-"`
+	Notes     []*sacloud.DiskEditNote `cli:"-"` // --parametersでファイルからパラメータ指定する場合向け
+
+	cflag.ConfirmParameter `cli:",squash" mapconv:"-"`
+	cflag.OutputParameter  `cli:",squash" mapconv:"-"`
 }
 
 func newEditParameter() *editParameter {
@@ -68,6 +69,22 @@ func newEditParameter() *editParameter {
 }
 
 func init() {
-	// TODO libsacloud service側でeditが未実装
-	//Resource.AddCommand(editCommand)
+	Resource.AddCommand(editCommand)
+}
+
+// Customize パラメータ変換処理
+func (p *editParameter) Customize() error {
+	var notes []*sacloud.DiskEditNote
+	if p.NotesData != "" {
+		if err := util.MarshalJSONFromPathOrContent(p.NotesData, &notes); err != nil {
+			return err
+		}
+	}
+
+	for _, id := range p.NoteIDs {
+		notes = append(notes, &sacloud.DiskEditNote{ID: id})
+	}
+
+	p.Notes = append(p.Notes, notes...)
+	return nil
 }
diff --git a/pkg/cmd/commands/disk/zz_edit_gen.go b/pkg/cmd/commands/disk/zz_edit_gen.go
new file mode 100644
index 00000000..e1bcff38
--- /dev/null
+++ b/pkg/cmd/commands/disk/zz_edit_gen.go
@@ -0,0 +1,123 @@
+// Copyright 2017-2020 The Usacloud Authors
+//
+// 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.
+
+// Code generated by 'github.com/sacloud/usacloud/tools/gen-commands'; DO NOT EDIT
+
+package disk
+
+import (
+	"github.com/sacloud/usacloud/pkg/cmd/core"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+func (p *editParameter) CleanupEmptyValue(fs *pflag.FlagSet) {
+
+}
+
+func (p *editParameter) buildFlags(fs *pflag.FlagSet) {
+
+	fs.StringVarP(&p.Zone, "zone", "", p.Zone, "")
+	fs.StringVarP(&p.Parameters, "parameters", "", p.Parameters, "Input parameters in JSON format")
+	fs.BoolVarP(&p.GenerateSkeleton, "generate-skeleton", "", p.GenerateSkeleton, "Output skeleton of parameters with JSON format")
+	fs.BoolVarP(&p.NoWait, "no-wait", "", p.NoWait, "")
+	fs.StringVarP(&p.HostName, "host-name", "", p.HostName, "")
+	fs.StringVarP(&p.Password, "password", "", p.Password, "")
+	fs.BoolVarP(&p.DisablePWAuth, "disable-pw-auth", "", p.DisablePWAuth, "")
+	fs.BoolVarP(&p.EnableDHCP, "enable-dhcp", "", p.EnableDHCP, "")
+	fs.BoolVarP(&p.ChangePartitionUUID, "change-partition-uuid", "", p.ChangePartitionUUID, "")
+	fs.StringVarP(&p.IPAddress, "ip-address", "", p.IPAddress, "")
+	fs.IntVarP(&p.NetworkMaskLen, "network-mask-len", "", p.NetworkMaskLen, "")
+	fs.StringVarP(&p.DefaultRoute, "default-route", "", p.DefaultRoute, "")
+	fs.StringSliceVarP(&p.SSHKeys, "ssh-keys", "", p.SSHKeys, "")
+	fs.VarP(core.NewIDSliceFlag(&p.SSHKeyIDs, &p.SSHKeyIDs), "ssh-key-ids", "", "")
+	fs.VarP(core.NewIDSliceFlag(&p.NoteIDs, &p.NoteIDs), "note-ids", "", "")
+	fs.StringVarP(&p.NotesData, "notes", "", p.NotesData, "")
+	fs.BoolVarP(&p.AssumeYes, "assumeyes", "y", p.AssumeYes, "Assume that the answer to any question which would be asked is yes")
+	fs.StringVarP(&p.OutputType, "output-type", "o", p.OutputType, "Output format: one of the following [table/json/yaml] (aliases: --out)")
+	fs.BoolVarP(&p.Quiet, "quiet", "q", p.Quiet, "Output IDs only")
+	fs.StringVarP(&p.Format, "format", "", p.Format, "Output format in Go templates (aliases: --fmt)")
+	fs.StringVarP(&p.FormatFile, "format-file", "", p.FormatFile, "Output format in Go templates(from file)")
+	fs.StringVarP(&p.Query, "query", "", p.Query, "JMESPath query")
+	fs.StringVarP(&p.QueryFile, "query-file", "", p.QueryFile, "JMESPath query(from file)")
+	fs.SetNormalizeFunc(p.normalizeFlagName)
+}
+
+func (p *editParameter) normalizeFlagName(_ *pflag.FlagSet, name string) pflag.NormalizedName {
+	switch name {
+	case "out":
+		name = "output-type"
+	case "fmt":
+		name = "format"
+	}
+	return pflag.NormalizedName(name)
+}
+
+func (p *editParameter) buildFlagsUsage(cmd *cobra.Command) {
+	var sets []*core.FlagSet
+	{
+		var fs *pflag.FlagSet
+		fs = pflag.NewFlagSet("disk", pflag.ContinueOnError)
+		fs.AddFlag(cmd.LocalFlags().Lookup("zone"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("no-wait"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("host-name"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("password"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("disable-pw-auth"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("enable-dhcp"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("change-partition-uuid"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("ip-address"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("network-mask-len"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("default-route"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("ssh-keys"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("ssh-key-ids"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("note-ids"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("notes"))
+		sets = append(sets, &core.FlagSet{
+			Title: "Disk options",
+			Flags: fs,
+		})
+	}
+	{
+		var fs *pflag.FlagSet
+		fs = pflag.NewFlagSet("Input", pflag.ContinueOnError)
+		fs.AddFlag(cmd.LocalFlags().Lookup("parameters"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("generate-skeleton"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("assumeyes"))
+		sets = append(sets, &core.FlagSet{
+			Title: "Input options",
+			Flags: fs,
+		})
+	}
+	{
+		var fs *pflag.FlagSet
+		fs = pflag.NewFlagSet("output", pflag.ContinueOnError)
+		fs.AddFlag(cmd.LocalFlags().Lookup("output-type"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("quiet"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("format"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("format-file"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("query"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("query-file"))
+		sets = append(sets, &core.FlagSet{
+			Title: "Output options",
+			Flags: fs,
+		})
+	}
+
+	core.BuildFlagsUsage(cmd, sets)
+}
+
+func (p *editParameter) SetupCobraCommandFlags(cmd *cobra.Command) {
+	p.buildFlags(cmd.Flags())
+	p.buildFlagsUsage(cmd)
+}
diff --git a/pkg/cmd/commands/disk/zz_monitor_disk_gen.go b/pkg/cmd/commands/disk/zz_monitor_disk_gen.go
index cf91c1c5..38886ada 100644
--- a/pkg/cmd/commands/disk/zz_monitor_disk_gen.go
+++ b/pkg/cmd/commands/disk/zz_monitor_disk_gen.go
@@ -55,7 +55,8 @@ func (p *monitorParameter) buildFlagsUsage(cmd *cobra.Command) {
 	{
 		var fs *pflag.FlagSet
 		fs = pflag.NewFlagSet("disk", pflag.ContinueOnError)
-		fs.AddFlag(cmd.LocalFlags().Lookup("zone"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("start"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("end"))
 		sets = append(sets, &core.FlagSet{
 			Title: "Disk options",
 			Flags: fs,
@@ -64,8 +65,7 @@ func (p *monitorParameter) buildFlagsUsage(cmd *cobra.Command) {
 	{
 		var fs *pflag.FlagSet
 		fs = pflag.NewFlagSet("disk", pflag.ContinueOnError)
-		fs.AddFlag(cmd.LocalFlags().Lookup("start"))
-		fs.AddFlag(cmd.LocalFlags().Lookup("end"))
+		fs.AddFlag(cmd.LocalFlags().Lookup("zone"))
 		sets = append(sets, &core.FlagSet{
 			Title: "Disk options",
 			Flags: fs,
diff --git a/pkg/cmd/core/command.go b/pkg/cmd/core/command.go
index 3707e2f9..a355a21a 100644
--- a/pkg/cmd/core/command.go
+++ b/pkg/cmd/core/command.go
@@ -176,6 +176,12 @@ func (c *Command) Run(ctx cli.Context, cmd *cobra.Command, args []string) error
 		return err
 	}
 
+	if customizer, ok := c.currentParameter.(ParameterCustomizer); ok {
+		if err := customizer.Customize(); err != nil {
+			return err
+		}
+	}
+
 	targets, err := c.expandResourceContextsFromArgs(ctx, args)
 	if err != nil {
 		return err
diff --git a/pkg/cmd/core/customize_parameter.go b/pkg/cmd/core/customize_parameter.go
new file mode 100644
index 00000000..73a28a98
--- /dev/null
+++ b/pkg/cmd/core/customize_parameter.go
@@ -0,0 +1,19 @@
+// Copyright 2017-2020 The Usacloud Authors
+//
+// 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 core
+
+type ParameterCustomizer interface {
+	Customize() error
+}
diff --git a/pkg/cmd/core/parameter_loader.go b/pkg/cmd/core/parameter_loader.go
index 3f693823..3db38109 100644
--- a/pkg/cmd/core/parameter_loader.go
+++ b/pkg/cmd/core/parameter_loader.go
@@ -16,29 +16,21 @@ package core
 
 import (
 	"encoding/json"
-	"fmt"
-	"io/ioutil"
 
 	"github.com/sacloud/usacloud/pkg/cmd/cflag"
+	"github.com/sacloud/usacloud/pkg/util"
 )
 
 func loadParameters(parameters cflag.CommonParameterValueHolder) error {
 	p := parameters.ParametersFlagValue()
-	pf := parameters.ParameterFileFlagValue()
 
-	if p == "" && pf == "" {
+	if p == "" {
 		return nil
 	}
 
-	strParameter := p
-	if strParameter == "" {
-		// Note: この段階ではファイルサイズのバリデーションは済んでいる前提
-		b, err := ioutil.ReadFile(pf)
-		if err != nil {
-			return fmt.Errorf("reading parameters from %s is failed: %s", pf, err)
-		}
-		strParameter = string(b)
+	data, err := util.BytesFromPathOrContent(p)
+	if err != nil {
+		return err
 	}
-
-	return json.Unmarshal([]byte(strParameter), parameters)
+	return json.Unmarshal(data, parameters)
 }
diff --git a/pkg/cmd/core/skeleton.go b/pkg/cmd/core/skeleton.go
index 0910ff2b..373283ca 100644
--- a/pkg/cmd/core/skeleton.go
+++ b/pkg/cmd/core/skeleton.go
@@ -21,17 +21,8 @@ import (
 	"github.com/sacloud/usacloud/pkg/cli"
 )
 
-type SkeletonHandler interface {
-	FillValueToSkeleton()
-}
-
 func generateSkeleton(ctx cli.Context, params interface{}) error {
-	v, ok := params.(SkeletonHandler)
-	if ok {
-		v.FillValueToSkeleton()
-	}
-
-	data, err := json.MarshalIndent(v, "", "\t")
+	data, err := json.MarshalIndent(params, "", "\t")
 	if err != nil {
 		return fmt.Errorf("marshaling to JSON is failed: %s", err)
 	}
diff --git a/pkg/cmd/services/disk_services_gen.go b/pkg/cmd/services/disk_services_gen.go
index 5fdfb51a..15822770 100644
--- a/pkg/cmd/services/disk_services_gen.go
+++ b/pkg/cmd/services/disk_services_gen.go
@@ -60,6 +60,42 @@ func init() {
 			return results, nil
 		},
 	)
+	setDefaultServiceFunc("disk", "edit",
+		func(ctx cli.Context, parameter interface{}) ([]interface{}, error) {
+			svc := service.New(ctx.Client())
+
+			req := &service.EditRequest{}
+			if err := conv.ConvertTo(parameter, req); err != nil {
+				return nil, err
+			}
+			if err := req.Validate(); err != nil {
+				return nil, err
+			}
+
+			err := svc.EditWithContext(ctx, req)
+			if err != nil {
+				return nil, err
+			}
+
+			return nil, nil
+
+		},
+	)
+	setDefaultListAllFunc("disk", "edit",
+		func(ctx cli.Context, parameter interface{}) ([]interface{}, error) {
+			svc := service.New(ctx.Client())
+			res, err := svc.FindWithContext(ctx, &service.FindRequest{Zone: (parameter.(cflag.ZoneParameterValueHandler)).ZoneFlagValue()})
+			if err != nil {
+				return nil, err
+			}
+
+			var results []interface{}
+			for _, v := range res {
+				results = append(results, v)
+			}
+			return results, nil
+		},
+	)
 	setDefaultServiceFunc("disk", "disconnect-from-server",
 		func(ctx cli.Context, parameter interface{}) ([]interface{}, error) {
 			svc := service.New(ctx.Client())
diff --git a/pkg/util/struct.go b/pkg/util/struct.go
new file mode 100644
index 00000000..0e1322b7
--- /dev/null
+++ b/pkg/util/struct.go
@@ -0,0 +1,59 @@
+// Copyright 2017-2020 The Usacloud Authors
+//
+// 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 util
+
+import (
+	"encoding/json"
+	"errors"
+	"io/ioutil"
+)
+
+func MarshalJSONFromPathOrContent(pathOrContent string, destination interface{}) error {
+	// 今の所JSONのみ対応
+	data, err := BytesFromPathOrContent(pathOrContent)
+	if err != nil {
+		return err
+	}
+	return json.Unmarshal(data, destination)
+}
+
+func StringFromPathOrContent(pathOrContent string) (string, error) {
+	data, err := BytesFromPathOrContent(pathOrContent)
+	if err != nil {
+		return "", err
+	}
+	return string(data), nil
+}
+
+func BytesFromPathOrContent(pathOrContent string) ([]byte, error) {
+	if pathOrContent == "" {
+		return nil, errors.New("pathOrContent required")
+	}
+
+	if isJSON(pathOrContent) {
+		return []byte(pathOrContent), nil
+	}
+
+	data, err := ioutil.ReadFile(pathOrContent)
+	if err != nil {
+		return nil, err
+	}
+	return data, nil
+}
+
+func isJSON(s string) bool {
+	m := make(map[string]interface{})
+	return json.Unmarshal([]byte(s), &m) == nil
+}
diff --git a/pkg/vdef/converter_filters.go b/pkg/vdef/converter_filters.go
index c9e92f93..eb5b47ab 100644
--- a/pkg/vdef/converter_filters.go
+++ b/pkg/vdef/converter_filters.go
@@ -16,10 +16,11 @@ package vdef
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 	"time"
 
+	"github.com/sacloud/usacloud/pkg/util"
+
 	"github.com/sacloud/libsacloud/v2/pkg/mapconv"
 )
 
@@ -118,17 +119,5 @@ func pathOrContent(v interface{}) (interface{}, error) {
 		return nil, nil
 	}
 
-	file, err := os.Open(s)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return s, nil // そのまま返す
-		}
-		return nil, err
-	}
-	defer file.Close()
-	data, err := ioutil.ReadAll(file)
-	if err != nil {
-		return nil, err
-	}
-	return string(data), nil
+	return util.StringFromPathOrContent(s)
 }
diff --git a/vendor/github.com/sacloud/libsacloud/v2/helper/service/disk/edit_request.go b/vendor/github.com/sacloud/libsacloud/v2/helper/service/disk/edit_request.go
new file mode 100644
index 00000000..ada60232
--- /dev/null
+++ b/vendor/github.com/sacloud/libsacloud/v2/helper/service/disk/edit_request.go
@@ -0,0 +1,97 @@
+// Copyright 2016-2020 The Libsacloud Authors
+//
+// 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 disk
+
+import (
+	"github.com/sacloud/libsacloud/v2/helper/validate"
+	"github.com/sacloud/libsacloud/v2/sacloud"
+	"github.com/sacloud/libsacloud/v2/sacloud/types"
+)
+
+type EditRequest struct {
+	Zone string   `request:"-" validate:"required"`
+	ID   types.ID `request:"-"`
+
+	NoWait bool `request:"-"` // trueの場合ディスクの修正完了まで待たずに即時復帰する
+
+	HostName string
+	Password string
+
+	DisablePWAuth       bool
+	EnableDHCP          bool
+	ChangePartitionUUID bool
+
+	IPAddress      string
+	NetworkMaskLen int
+	DefaultRoute   string
+
+	SSHKeys   []string
+	SSHKeyIDs []types.ID
+
+	Notes []*sacloud.DiskEditNote // スタートアップスクリプトをIDで指定(変数や埋め込むAPIキーを指定可能)
+}
+
+func (req *EditRequest) Validate() error {
+	return validate.Struct(req)
+}
+
+func (req *EditRequest) ToRequestParameter() (*sacloud.DiskEditRequest, error) {
+	// TODO builderからコピーしたもの。builderとの統合後に整理する
+	editReq := &sacloud.DiskEditRequest{
+		Background:          true,
+		Password:            req.Password,
+		DisablePWAuth:       req.DisablePWAuth,
+		EnableDHCP:          req.EnableDHCP,
+		ChangePartitionUUID: req.ChangePartitionUUID,
+		HostName:            req.HostName,
+	}
+
+	if req.IPAddress != "" {
+		editReq.UserIPAddress = req.IPAddress
+	}
+	if req.NetworkMaskLen > 0 || req.DefaultRoute != "" {
+		editReq.UserSubnet = &sacloud.DiskEditUserSubnet{
+			NetworkMaskLen: req.NetworkMaskLen,
+			DefaultRoute:   req.DefaultRoute,
+		}
+	}
+
+	// ssh key
+	var sshKeys []*sacloud.DiskEditSSHKey
+	for _, key := range req.SSHKeys {
+		sshKeys = append(sshKeys, &sacloud.DiskEditSSHKey{
+			PublicKey: key,
+		})
+	}
+	for _, id := range req.SSHKeyIDs {
+		sshKeys = append(sshKeys, &sacloud.DiskEditSSHKey{
+			ID: id,
+		})
+	}
+	editReq.SSHKeys = sshKeys
+
+	// startup script
+	var notes []*sacloud.DiskEditNote
+	for _, note := range req.Notes {
+		notes = append(notes, &sacloud.DiskEditNote{
+			ID:        note.ID,
+			APIKeyID:  note.APIKeyID,
+			Variables: note.Variables,
+		})
+	}
+	editReq.Notes = notes
+
+	return editReq, nil
+}
diff --git a/vendor/github.com/sacloud/libsacloud/v2/helper/service/disk/edit_service.go b/vendor/github.com/sacloud/libsacloud/v2/helper/service/disk/edit_service.go
new file mode 100644
index 00000000..d969ec11
--- /dev/null
+++ b/vendor/github.com/sacloud/libsacloud/v2/helper/service/disk/edit_service.go
@@ -0,0 +1,48 @@
+// Copyright 2016-2020 The Libsacloud Authors
+//
+// 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 disk
+
+import (
+	"context"
+
+	"github.com/sacloud/libsacloud/v2/helper/wait"
+	"github.com/sacloud/libsacloud/v2/sacloud"
+)
+
+func (s *Service) Edit(req *EditRequest) error {
+	return s.EditWithContext(context.Background(), req)
+}
+
+func (s *Service) EditWithContext(ctx context.Context, req *EditRequest) error {
+	if err := req.Validate(); err != nil {
+		return err
+	}
+
+	params, err := req.ToRequestParameter()
+	if err != nil {
+		return err
+	}
+
+	client := sacloud.NewDiskOp(s.caller)
+	if err := client.Config(ctx, req.Zone, req.ID, params); err != nil {
+		return err
+	}
+
+	if !req.NoWait {
+		_, err := wait.UntilDiskIsReady(ctx, client, req.Zone, req.ID)
+		return err
+	}
+	return nil
+}
diff --git a/vendor/github.com/sacloud/libsacloud/v2/sacloud/naked/proxylb.go b/vendor/github.com/sacloud/libsacloud/v2/sacloud/naked/proxylb.go
index 17870532..9a19e542 100644
--- a/vendor/github.com/sacloud/libsacloud/v2/sacloud/naked/proxylb.go
+++ b/vendor/github.com/sacloud/libsacloud/v2/sacloud/naked/proxylb.go
@@ -191,6 +191,16 @@ type ProxyLBCertificates struct {
 	AdditionalCerts ProxyLBAdditionalCerts `yaml:"additional_certs"`
 }
 
+// MarshalJSON nullの場合に空配列を出力するための実装
+func (s ProxyLBCertificates) MarshalJSON() ([]byte, error) {
+	if s.AdditionalCerts == nil {
+		s.AdditionalCerts = make([]*ProxyLBCertificate, 0)
+	}
+	type alias ProxyLBCertificates
+	tmp := alias(s)
+	return json.Marshal(&tmp)
+}
+
 // UnmarshalJSON UnmarshalJSON(AdditionalCertsが空の場合に空文字を返す問題への対応)
 func (p *ProxyLBAdditionalCerts) UnmarshalJSON(data []byte) error {
 	targetData := strings.Replace(strings.Replace(string(data), " ", "", -1), "\n", "", -1)
diff --git a/vendor/github.com/sacloud/libsacloud/v2/sacloud/zz_models.go b/vendor/github.com/sacloud/libsacloud/v2/sacloud/zz_models.go
index 44ccd4ec..82600df6 100644
--- a/vendor/github.com/sacloud/libsacloud/v2/sacloud/zz_models.go
+++ b/vendor/github.com/sacloud/libsacloud/v2/sacloud/zz_models.go
@@ -20092,7 +20092,7 @@ func (o *ProxyLBAdditionalCert) SetCertificateCommonName(v string) {
 
 // ProxyLBSetCertificatesRequest represents API parameter/response structure
 type ProxyLBSetCertificatesRequest struct {
-	PrimaryCerts    *ProxyLBPrimaryCert
+	PrimaryCerts    *ProxyLBPrimaryCert      `mapconv:"PrimaryCert"`
 	AdditionalCerts []*ProxyLBAdditionalCert `mapconv:"[]AdditionalCerts, recursive"`
 }
 
@@ -20104,7 +20104,7 @@ func (o *ProxyLBSetCertificatesRequest) Validate() error {
 // setDefaults implements sacloud.argumentDefaulter
 func (o *ProxyLBSetCertificatesRequest) setDefaults() interface{} {
 	return &struct {
-		PrimaryCerts    *ProxyLBPrimaryCert
+		PrimaryCerts    *ProxyLBPrimaryCert      `mapconv:"PrimaryCert"`
 		AdditionalCerts []*ProxyLBAdditionalCert `mapconv:"[]AdditionalCerts, recursive"`
 	}{
 		PrimaryCerts:    o.GetPrimaryCerts(),
diff --git a/vendor/github.com/sacloud/libsacloud/v2/version.go b/vendor/github.com/sacloud/libsacloud/v2/version.go
index fe90118c..a2a8f292 100644
--- a/vendor/github.com/sacloud/libsacloud/v2/version.go
+++ b/vendor/github.com/sacloud/libsacloud/v2/version.go
@@ -15,4 +15,4 @@
 package libsacloud
 
 // Version バージョン
-const Version = "2.8.8"
+const Version = "2.8.10"
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 29d99388..0dade14e 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -47,7 +47,7 @@ github.com/pmezard/go-difflib/difflib
 github.com/sacloud/ftps
 # github.com/sacloud/go-jmespath v0.0.0-20190125082617-862639817e08
 github.com/sacloud/go-jmespath
-# github.com/sacloud/libsacloud/v2 v2.8.9-0.20201113055740-e862733551c8
+# github.com/sacloud/libsacloud/v2 v2.8.11-0.20201120031150-fb22798fc6db
 github.com/sacloud/libsacloud/v2
 github.com/sacloud/libsacloud/v2/helper/api
 github.com/sacloud/libsacloud/v2/helper/builder


Test patch:
diff --git a/pkg/util/struct_test.go b/pkg/util/struct_test.go
new file mode 100644
index 00000000..890f01c8
--- /dev/null
+++ b/pkg/util/struct_test.go
@@ -0,0 +1,121 @@
+// Copyright 2017-2020 The Usacloud Authors
+//
+// 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 util
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func setup() (*os.File, func(), error) {
+	tempFile, err := ioutil.TempFile("", "*_test.json")
+	if err != nil {
+		return nil, nil, err
+	}
+	return tempFile, func() { os.Remove(tempFile.Name()) }, nil
+}
+
+func TestBytesFromPathOrContent(t *testing.T) {
+	in := `{"Foo":"Bar"}`
+
+	// setup
+	tempFile, cleaner, err := setup()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer cleaner()
+
+	if _, err := tempFile.WriteString(in); err != nil {
+		t.Fatal(err)
+	}
+
+	// from string
+	fromString, err := BytesFromPathOrContent(in)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(fromString) != in {
+		t.Fatalf("got unexpected value: expected:%s got:%s", in, string(fromString))
+	}
+
+	// from File
+	fromFile, err := BytesFromPathOrContent(tempFile.Name())
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(fromFile) != in {
+		t.Fatalf("got unexpected value: expected:%s got:%s", in, string(fromFile))
+	}
+}
+
+func TestMarshalJSONFromPathOrContent(t *testing.T) {
+	in := `{"Foo":"Bar"}`
+
+	// setup
+	tempFile, cleaner, err := setup()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer cleaner()
+
+	if _, err := tempFile.WriteString(in); err != nil {
+		t.Fatal(err)
+	}
+
+	type target struct {
+		Foo string
+	}
+
+	cases := []struct {
+		in        string
+		expect    *target
+		errString string
+	}{
+		{
+			in:     tempFile.Name(),
+			expect: &target{Foo: "Bar"},
+		},
+		{
+			in:     in,
+			expect: &target{Foo: "Bar"},
+		},
+		{
+			in:        "",
+			errString: "pathOrContent required",
+		},
+		{
+			in:        `{"Foo": 1}`,
+			errString: "json: cannot unmarshal number into Go struct field target.Foo of type string",
+		},
+		{
+			in:     `{"Bar": 1}`, // 存在しないキーはエラーとしない(json.Unmarshalと同じ)
+			expect: &target{},
+		},
+	}
+
+	for _, tc := range cases {
+		dest := &target{}
+		err := MarshalJSONFromPathOrContent(tc.in, dest)
+		require.Equal(t, tc.errString == "", err == nil)
+		if err != nil {
+			require.EqualError(t, err, tc.errString)
+			continue
+		}
+		require.EqualValues(t, tc.expect, dest)
+	}
+}