diff --git a/cmd/scw/testdata/test-all-usage-instance-security-group-edit-usage.golden b/cmd/scw/testdata/test-all-usage-instance-security-group-edit-usage.golden new file mode 100644 index 0000000000..f76f642996 --- /dev/null +++ b/cmd/scw/testdata/test-all-usage-instance-security-group-edit-usage.golden @@ -0,0 +1,21 @@ +🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 +πŸŸ₯πŸŸ₯πŸŸ₯ STDERR️️ πŸŸ₯πŸŸ₯πŸŸ₯️ +This command starts your default editor to edit a marshaled version of your resource +Default editor will be taken from $VISUAL, then $EDITOR or an editor based on your system + +USAGE: + scw instance security-group edit [arg=value ...] + +ARGS: + security-group-id ID of the security group to reset. + [mode=yaml] marshaling used when editing data (yaml | json) + [zone=fr-par-1] Zone to target. If none is passed will use default zone from the config + +FLAGS: + -h, --help help for edit + +GLOBAL FLAGS: + -c, --config string The path to the config file + -D, --debug Enable debug mode + -o, --output string Output format: json or human, see 'scw help output' for more info (default "human") + -p, --profile string The config profile to use diff --git a/cmd/scw/testdata/test-all-usage-instance-security-group-usage.golden b/cmd/scw/testdata/test-all-usage-instance-security-group-usage.golden index 8081b2705a..b6aff66dc0 100644 --- a/cmd/scw/testdata/test-all-usage-instance-security-group-usage.golden +++ b/cmd/scw/testdata/test-all-usage-instance-security-group-usage.golden @@ -15,6 +15,7 @@ AVAILABLE COMMANDS: create-rule Create rule delete Delete a security group delete-rule Delete rule + edit Edit all rules of a security group get Get a security group get-rule Get rule list List security groups diff --git a/docs/commands/instance.md b/docs/commands/instance.md index 621b15b694..1ba75c1831 100644 --- a/docs/commands/instance.md +++ b/docs/commands/instance.md @@ -39,6 +39,7 @@ Instance API. - [Create rule](#create-rule) - [Delete a security group](#delete-a-security-group) - [Delete rule](#delete-rule) + - [Edit all rules of a security group](#edit-all-rules-of-a-security-group) - [Get a security group](#get-a-security-group) - [Get rule](#get-rule) - [List security groups](#list-security-groups) @@ -1292,6 +1293,28 @@ scw instance security-group delete-rule security-group-id=a01a36e5-5c0c-42c1-ae0 +### Edit all rules of a security group + +This command starts your default editor to edit a marshaled version of your resource +Default editor will be taken from $VISUAL, then $EDITOR or an editor based on your system + +**Usage:** + +``` +scw instance security-group edit [arg=value ...] +``` + + +**Args:** + +| Name | | Description | +|------|---|-------------| +| security-group-id | Required | ID of the security group to reset. | +| mode | Default: `yaml`
One of: `yaml`, `json` | marshaling used when editing data | +| zone | Default: `fr-par-1` | Zone to target. If none is passed will use default zone from the config | + + + ### Get a security group Get the details of a Security Group with the given ID. diff --git a/internal/config/editor.go b/internal/config/editor.go new file mode 100644 index 0000000000..b876813adf --- /dev/null +++ b/internal/config/editor.go @@ -0,0 +1,37 @@ +package config + +import ( + "os" + "runtime" +) + +var ( + // List of env variables where to find the editor to use + // Order in slice is override order, the latest will override the first ones + editorEnvVariables = []string{"EDITOR", "VISUAL"} +) + +func GetSystemDefaultEditor() string { + switch runtime.GOOS { + case "windows": + return "notepad" + default: + return "vi" + } +} + +func GetDefaultEditor() string { + editor := "" + for _, envVar := range editorEnvVariables { + tmp := os.Getenv(envVar) + if tmp != "" { + editor = tmp + } + } + + if editor == "" { + return GetSystemDefaultEditor() + } + + return editor +} diff --git a/internal/editor/doc.go b/internal/editor/doc.go new file mode 100644 index 0000000000..bbcc517d9b --- /dev/null +++ b/internal/editor/doc.go @@ -0,0 +1,18 @@ +package editor + +import ( + "github.com/scaleway/scaleway-cli/v2/internal/core" +) + +var LongDescription = `This command starts your default editor to edit a marshaled version of your resource +Default editor will be taken from $VISUAL, then $EDITOR or an editor based on your system` + +func MarshalModeArgSpec() *core.ArgSpec { + return &core.ArgSpec{ + Name: "mode", + Short: "marshaling used when editing data", + Required: false, + Default: core.DefaultValueSetter(MarshalModeDefault), + EnumValues: MarshalModeEnum, + } +} diff --git a/internal/editor/editor.go b/internal/editor/editor.go new file mode 100644 index 0000000000..9cdf415c0e --- /dev/null +++ b/internal/editor/editor.go @@ -0,0 +1,144 @@ +package editor + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/scaleway/scaleway-cli/v2/internal/config" +) + +var SkipEditor = false +var marshalMode = MarshalModeYAML + +type GetResourceFunc func(interface{}) (interface{}, error) +type Config struct { + // PutRequest means that the request replace all fields + // If false, fields that were not edited will not be sent + // If true, all fields will be sent + PutRequest bool + + MarshalMode MarshalMode + + // Template is a template that will be shown before marshaled data in edited file + Template string + + // IgnoreFields is a list of json tags that will be removed from marshaled data + // The content of these fields will be lost in edited data + IgnoreFields []string + + // If not empty, this will replace edited text as if it was edited in the terminal + // Should be paired with global SkipEditor as true, useful for tests + editedResource string +} + +func editorPathAndArgs(fileName string) (string, []string) { + defaultEditor := config.GetDefaultEditor() + editorAndArguments := strings.Fields(defaultEditor) + args := []string{fileName} + + if len(editorAndArguments) > 1 { + args = append(editorAndArguments[1:], args...) + } + + return editorAndArguments[0], args +} + +// edit create a temporary file with given content, start a text editor then return edited content +// temporary file will be deleted on complete +// temporary file is not deleted if edit fails +func edit(content []byte) ([]byte, error) { + if SkipEditor { + return content, nil + } + + tmpFileName, err := createTemporaryFile(content, marshalMode) + if err != nil { + return nil, fmt.Errorf("failed to create temporary file: %w", err) + } + defer os.Remove(tmpFileName) + + editorPath, args := editorPathAndArgs(tmpFileName) + cmd := exec.Command(editorPath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + + err = cmd.Run() + if err != nil { + return nil, fmt.Errorf("failed to edit temporary file %q: %w", tmpFileName, err) + } + + editedContent, err := os.ReadFile(tmpFileName) + if err != nil { + return nil, fmt.Errorf("failed to read temporary file %q: %w", tmpFileName, err) + } + + return editedContent, nil +} + +// updateResourceEditor takes a complete resource and a partial updateRequest +// will return a copy of updateRequest that has been edited +func updateResourceEditor(resource interface{}, updateRequest interface{}, cfg *Config) (interface{}, error) { + // Create a copy of updateRequest completed with resource content + completeUpdateRequest := copyAndCompleteUpdateRequest(updateRequest, resource) + + // TODO: fields present in updateRequest should be removed from marshal + // ex: namespace_id, region, zone + // Currently not an issue as fields that should be removed are mostly path parameter /{zone}/namespace/{namespace_id} + // Path parameter have "-" as json tag and are not marshaled + + updateRequestMarshaled, err := marshal(completeUpdateRequest, cfg.MarshalMode) + if err != nil { + return nil, fmt.Errorf("failed to marshal update request: %w", err) + } + + if len(cfg.IgnoreFields) > 0 { + updateRequestMarshaled, err = removeFields(updateRequestMarshaled, cfg.MarshalMode, cfg.IgnoreFields) + if err != nil { + return nil, fmt.Errorf("failed to remove ignored fields: %w", err) + } + } + + if cfg.Template != "" { + updateRequestMarshaled = addTemplate(updateRequestMarshaled, cfg.Template, cfg.MarshalMode) + } + + // Start text editor to edit marshaled request + updateRequestMarshaled, err = edit(updateRequestMarshaled) + if err != nil { + return nil, fmt.Errorf("failed to edit marshalled data: %w", err) + } + + // If editedResource is present, override edited resource + // This is useful for testing purpose + if cfg.editedResource != "" { + updateRequestMarshaled = []byte(cfg.editedResource) + } + + // Create a new updateRequest as destination for edited yaml/json + // Must be a new one to avoid merge of maps content + updateRequestEdited := newRequest(updateRequest) + + // TODO: if !putRequest + // fill updateRequestEdited with only edited fields and fields present in updateRequest + // fields should be compared with completeUpdateRequest to find edited ones + + // Add back required non-marshaled fields (zone, ID) + copyRequestPathParameters(updateRequestEdited, updateRequest) + + err = unmarshal(updateRequestMarshaled, updateRequestEdited, cfg.MarshalMode) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal edited data: %w", err) + } + + return updateRequestEdited, nil +} + +// UpdateResourceEditor takes a complete resource and a partial updateRequest +// will return a copy of updateRequest that has been edited +// Only edited fields will be present in returned updateRequest +// If putRequest is true, all fields will be present, edited or not +func UpdateResourceEditor(resource interface{}, updateRequest interface{}, cfg *Config) (interface{}, error) { + return updateResourceEditor(resource, updateRequest, cfg) +} diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go new file mode 100644 index 0000000000..6b7e13ef1e --- /dev/null +++ b/internal/editor/editor_test.go @@ -0,0 +1,91 @@ +package editor + +import ( + "testing" + + "github.com/alecthomas/assert" +) + +func Test_updateResourceEditor(t *testing.T) { + SkipEditor = true + + resource := &struct { + ID string + Name string + }{ + "uuid", + "name", + } + updateRequest := &struct { + ID string + Name string + }{ + "uuid", + "", + } + + _, err := updateResourceEditor(resource, updateRequest, &Config{}) + assert.Nil(t, err) +} + +func Test_updateResourceEditor_pointers(t *testing.T) { + SkipEditor = true + + type UpdateRequest struct { + ID string + Name *string + } + resource := &struct { + ID string + Name string + }{ + "uuid", + "name", + } + + updateRequest := &UpdateRequest{ + "uuid", + nil, + } + + editedUpdateRequestI, err := updateResourceEditor(resource, updateRequest, &Config{}) + assert.Nil(t, err) + editedUpdateRequest := editedUpdateRequestI.(*UpdateRequest) + + assert.NotNil(t, editedUpdateRequest.Name) + assert.Equal(t, resource.Name, *editedUpdateRequest.Name) +} + +func Test_updateResourceEditor_map(t *testing.T) { + SkipEditor = true + + type UpdateRequest struct { + ID string `json:"id"` + Env *map[string]string `json:"env"` + } + resource := &struct { + ID string `json:"id"` + Env map[string]string `json:"env"` + }{ + "uuid", + map[string]string{ + "foo": "bar", + }, + } + + updateRequest := &UpdateRequest{ + "uuid", + nil, + } + + editedUpdateRequestI, err := updateResourceEditor(resource, updateRequest, &Config{ + editedResource: ` +id: uuid +env: {} +`, + }) + assert.Nil(t, err) + editedUpdateRequest := editedUpdateRequestI.(*UpdateRequest) + assert.NotNil(t, editedUpdateRequest.Env) + assert.True(t, len(*editedUpdateRequest.Env) == 0) +} diff --git a/internal/editor/marshal.go b/internal/editor/marshal.go new file mode 100644 index 0000000000..e04e3d284d --- /dev/null +++ b/internal/editor/marshal.go @@ -0,0 +1,85 @@ +package editor + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ghodss/yaml" +) + +type MarshalMode = string + +const ( + MarshalModeYAML = MarshalMode("yaml") + MarshalModeJSON = MarshalMode("json") +) + +var MarshalModeDefault = MarshalModeYAML +var MarshalModeEnum = []MarshalMode{MarshalModeYAML, MarshalModeJSON} + +func marshal(i interface{}, mode MarshalMode) ([]byte, error) { + if mode == "" { + mode = MarshalModeDefault + } + + var marshaledData []byte + var err error + + switch mode { + case MarshalModeYAML: + marshaledData, err = yaml.Marshal(i) + case MarshalModeJSON: + marshaledData, err = json.MarshalIndent(i, "", " ") + } + if err != nil { + return marshaledData, err + } + if marshaledData != nil { + return marshaledData, err + } + + return nil, fmt.Errorf("unknown marshal mode %q", mode) +} + +func unmarshal(data []byte, i interface{}, mode MarshalMode) error { + if mode == "" { + mode = MarshalModeDefault + } + + switch mode { + case MarshalModeYAML: + return yaml.Unmarshal(data, i) + case MarshalModeJSON: + return json.Unmarshal(data, i) + } + + return fmt.Errorf("unknown marshal mode %q", mode) +} + +// removeFields remove some fields from marshaled data +func removeFields(data []byte, mode MarshalMode, fields []string) ([]byte, error) { + i := map[string]interface{}{} + err := unmarshal(data, &i, mode) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal: %w", err) + } + deleteRecursive(i, fields...) + + return marshal(i, mode) +} + +func addTemplate(content []byte, template string, mode MarshalMode) []byte { + if mode != MarshalModeYAML || len(template) == 0 { + return content + } + newContent := []byte(nil) + + for _, line := range strings.Split(template, "\n") { + newContent = append(newContent, []byte("#"+line+"\n")...) + } + + newContent = append(newContent, content...) + + return newContent +} diff --git a/internal/editor/reflect.go b/internal/editor/reflect.go new file mode 100644 index 0000000000..d083696250 --- /dev/null +++ b/internal/editor/reflect.go @@ -0,0 +1,147 @@ +package editor + +import ( + "reflect" +) + +func areSameType(v1 reflect.Value, v2 reflect.Value) bool { + v1t := v1.Type() + v2t := v2.Type() + + if v1t == v2t { + return true + } + + // If both are slice, compare underlying type + if v1t.Kind() == reflect.Slice && v2t.Kind() == reflect.Slice { + v1t = v1t.Elem() + v2t = v2t.Elem() + } + + if v1t.Kind() == reflect.Pointer { + v1t = v1t.Elem() + } + if v2t.Kind() == reflect.Pointer { + v2t = v2t.Elem() + } + + // If both are struct consider them equal, valueMapperWithoutOpt will try to map fields + if v1t.Kind() == reflect.Struct && v2t.Kind() == reflect.Struct { + return true + } + + return v1t == v2t +} + +func hasTag(tags []string, actualTag string) bool { + for _, tag := range tags { + if tag == actualTag { + return true + } + } + return false +} + +func valueMapperWithoutOpt(dest reflect.Value, src reflect.Value, includeFields []string, excludeFields []string) { + switch dest.Kind() { + case reflect.Struct: + for i := 0; i < dest.NumField(); i++ { + destField := dest.Field(i) + fieldType := dest.Type().Field(i) + srcField := src.FieldByName(fieldType.Name) + + // If field is not in list, do not set it + if includeFields != nil && !hasTag(includeFields, fieldType.Tag.Get("json")) || + excludeFields != nil && hasTag(excludeFields, fieldType.Tag.Get("json")) { + continue + } + + // TODO: Move to default + if !srcField.IsValid() || srcField.IsZero() || !areSameType(srcField, destField) { + continue + } + + valueMapperWithoutOpt(destField, srcField, includeFields, excludeFields) + } + case reflect.Pointer: + // If destination is a pointer, we allocate destination if needed + if dest.IsZero() { + dest.Set(reflect.New(dest.Type().Elem())) + } + + if src.Kind() == reflect.Pointer { + src = src.Elem() + } + dest = dest.Elem() + + valueMapperWithoutOpt(dest, src, includeFields, excludeFields) + // TODO: clean pointer if not filled + // If dest is nil and src is a struct with includeFields disabled because of json tags + // Then dest should not be allocated and should remain nil + case reflect.Slice: + // If destination is a slice, allocate the slice and map each value + srcLen := src.Len() + dest.Set(reflect.MakeSlice(dest.Type(), srcLen, srcLen)) + for i := 0; i < srcLen; i++ { + valueMapperWithoutOpt(dest.Index(i), src.Index(i), includeFields, excludeFields) + } + default: + dest.Set(src) + } +} + +type valueMapperConfig struct { + includeFields []string + excludeFields []string +} +type valueMapperOpt func(cfg *valueMapperConfig) + +// mapWithTag will map only fields that have one of these tags as json tag +func mapWithTag(includeFields ...string) valueMapperOpt { + return func(cfg *valueMapperConfig) { + cfg.includeFields = append(cfg.includeFields, includeFields...) + } +} + +// mapWithTag will map only fields that don't have one of these tags as json tag +// +//nolint:deadcode,unused +func mapWithoutTag(excludeFields ...string) valueMapperOpt { + return func(cfg *valueMapperConfig) { + cfg.excludeFields = append(cfg.excludeFields, excludeFields...) + } +} + +// valueMapper get all fields present both in src and dest and set them in dest +// if argument is not zero-value in dest, it is not set +// fields is a list of jsonTags, if not nil, only fields with a tag in this list will be mapped +func valueMapper(dest reflect.Value, src reflect.Value, opts ...valueMapperOpt) { + cfg := valueMapperConfig{} + for _, opt := range opts { + opt(&cfg) + } + valueMapperWithoutOpt(dest, src, cfg.includeFields, cfg.excludeFields) +} + +func deleteRecursiveMap(m map[string]interface{}, keys ...string) { + for _, key := range keys { + delete(m, key) + } + + for _, val := range m { + deleteRecursive(val, keys...) + } +} + +func deleteRecursive(elem interface{}, keys ...string) { + value := reflect.ValueOf(elem) + + switch value.Kind() { + case reflect.Map: + deleteRecursiveMap(elem.(map[string]interface{}), keys...) + case reflect.Slice: + for i := 0; i < value.Len(); i++ { + deleteRecursive(value.Index(i).Interface(), keys...) + } + } +} diff --git a/internal/editor/reflect_test.go b/internal/editor/reflect_test.go new file mode 100644 index 0000000000..d6df41f9f5 --- /dev/null +++ b/internal/editor/reflect_test.go @@ -0,0 +1,238 @@ +package editor + +import ( + "net" + "reflect" + "testing" + + "github.com/alecthomas/assert" + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" +) + +func Test_valueMapper(t *testing.T) { + src := struct { + Arg1 string + Arg2 int + }{"1", 1} + dest := struct { + Arg1 string + Arg2 int + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.Equal(t, src.Arg1, dest.Arg1) + assert.Equal(t, src.Arg2, dest.Arg2) +} + +func Test_valueMapperInvalidType(t *testing.T) { + src := struct { + Arg1 string + Arg2 int + }{"1", 1} + dest := struct { + Arg1 string + Arg2 bool + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.Equal(t, src.Arg1, dest.Arg1) + assert.Zero(t, dest.Arg2) +} + +func Test_valueMapperDifferentFields(t *testing.T) { + src := struct { + Arg1 string + Arg2 int + }{"1", 1} + dest := struct { + Arg3 string + Arg4 bool + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.Zero(t, dest.Arg3) + assert.Zero(t, dest.Arg4) +} + +func Test_valueMapperPointers(t *testing.T) { + src := struct { + Arg1 string + Arg2 int + }{"1", 1} + dest := struct { + Arg1 *string + Arg2 *int + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.NotNil(t, dest.Arg1) + assert.EqualValues(t, src.Arg1, *dest.Arg1) + assert.NotNil(t, dest.Arg2) + assert.EqualValues(t, src.Arg2, *dest.Arg2) +} + +func Test_valueMapperPointersWithPointers(t *testing.T) { + src := struct { + Arg1 *string + Arg2 *int32 + }{scw.StringPtr("1"), scw.Int32Ptr(1)} + dest := struct { + Arg1 *string + Arg2 *int32 + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.NotNil(t, dest.Arg1) + assert.EqualValues(t, src.Arg1, dest.Arg1) + assert.NotNil(t, dest.Arg2) + assert.EqualValues(t, src.Arg2, dest.Arg2) +} + +func Test_valueMapperSlice(t *testing.T) { + src := struct { + Arg1 []string + Arg2 []int + }{ + []string{"1", "2", "3"}, + []int{1, 2, 3}, + } + dest := struct { + Arg1 []string + Arg2 []int + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.NotNil(t, dest.Arg1) + assert.EqualValues(t, src.Arg1, dest.Arg1) + assert.NotNil(t, dest.Arg2) + assert.EqualValues(t, src.Arg2, dest.Arg2) +} + +func Test_valueMapperSliceOfPointers(t *testing.T) { + src := struct { + Arg1 []string + Arg2 []int + }{ + []string{"1", "2", "3"}, + []int{1, 2, 3}, + } + dest := struct { + Arg1 []*string + Arg2 []*int + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.NotNil(t, dest.Arg1) + assert.Equal(t, len(src.Arg1), len(dest.Arg1)) + for i := range src.Arg1 { + assert.NotNil(t, dest.Arg1[i]) + assert.Equal(t, src.Arg1[i], *dest.Arg1[i]) + } + + assert.NotNil(t, dest.Arg2) + assert.Equal(t, len(src.Arg2), len(dest.Arg2)) + for i := range src.Arg2 { + assert.NotNil(t, dest.Arg2[i]) + assert.Equal(t, src.Arg2[i], *dest.Arg2[i]) + } +} + +func Test_valueMapperSliceStructPointer(t *testing.T) { + _, ipnet, err := net.ParseCIDR("192.168.0.0/24") + assert.Nil(t, err) + + src := instance.ListSecurityGroupRulesResponse{ + TotalCount: 0, + Rules: []*instance.SecurityGroupRule{ + { + ID: "id", + Protocol: "protocol", + Direction: "direction", + Action: "action", + IPRange: scw.IPNet{ + IPNet: *ipnet, + }, + DestPortFrom: scw.Uint32Ptr(1000), + DestPortTo: scw.Uint32Ptr(2000), + Position: 12, + Editable: true, + Zone: "zone", + }, + }, + } + dest := instance.SetSecurityGroupRulesRequest{ + Rules: nil, + } + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src)) + assert.NotNil(t, dest.Rules) + assert.Equal(t, 1, len(dest.Rules)) + expectedRule := src.Rules[0] + actualRule := dest.Rules[0] + assert.NotNil(t, actualRule.ID) + assert.Equal(t, expectedRule.ID, *actualRule.ID) + assert.Equal(t, expectedRule.Protocol, actualRule.Protocol) + assert.Equal(t, expectedRule.Direction, actualRule.Direction) + assert.Equal(t, expectedRule.Action, actualRule.Action) + assert.Equal(t, expectedRule.IPRange, actualRule.IPRange) + assert.NotNil(t, actualRule.DestPortFrom) + assert.Equal(t, expectedRule.DestPortFrom, actualRule.DestPortFrom) + assert.NotNil(t, actualRule.DestPortTo) + assert.Equal(t, expectedRule.DestPortTo, actualRule.DestPortTo) + assert.Equal(t, expectedRule.Position, actualRule.Position) + assert.NotNil(t, actualRule.Editable) + assert.Equal(t, expectedRule.Editable, *actualRule.Editable) + assert.NotNil(t, actualRule.Zone) + assert.Equal(t, expectedRule.Zone, *actualRule.Zone) +} + +func Test_valueMapperTags(t *testing.T) { + src := struct { + Arg1 string `json:"map"` + Arg2 int `json:"nomap"` + }{"1", 1} + dest := struct { + Arg1 string `json:"map"` + Arg2 int `json:"nomap"` + }{} + + valueMapper(reflect.ValueOf(&dest), reflect.ValueOf(&src), mapWithTag("map")) + assert.Equal(t, src.Arg1, dest.Arg1) + assert.NotEqual(t, src.Arg2, dest.Arg2) +} + +func Test_deleteRecursive(t *testing.T) { + m := map[string]interface{}{ + "delete": "1", + "nodelete": 1, + } + + deleteRecursive(m, "delete") + + _, deleteExists := m["delete"] + _, nodeleteExists := m["nodelete"] + + assert.False(t, deleteExists) + assert.True(t, nodeleteExists) +} + +func Test_deleteRecursiveSlice(t *testing.T) { + m := map[string]interface{}{ + "slice": []map[string]interface{}{ + { + "delete": "1", + "nodelete": 1, + }, + }, + } + + deleteRecursive(m, "delete") + + slice := m["slice"].([]map[string]interface{}) + _, deleteExists := slice[0]["delete"] + _, nodeleteExists := slice[0]["nodelete"] + + assert.False(t, deleteExists) + assert.True(t, nodeleteExists) +} diff --git a/internal/editor/request.go b/internal/editor/request.go new file mode 100644 index 0000000000..f3b43e78b2 --- /dev/null +++ b/internal/editor/request.go @@ -0,0 +1,46 @@ +package editor + +import "reflect" + +// createGetRequest creates a GetRequest from given type and populate it with content from updateRequest +func createGetRequest(updateRequest interface{}, getRequestType reflect.Type) interface{} { + updateRequestV := reflect.ValueOf(updateRequest) + + getRequest := reflect.New(getRequestType).Interface() + getRequestV := reflect.ValueOf(getRequest) + + // Fill GetRequest args using Update arg content + // This should copy important argument like ID, zone + valueMapper(getRequestV, updateRequestV) + + return getRequest +} + +// copyAndCompleteUpdateRequest return a copy of updateRequest completed with resource content +func copyAndCompleteUpdateRequest(updateRequest interface{}, resource interface{}) interface{} { + resourceV := reflect.ValueOf(resource) + updateRequestV := reflect.ValueOf(updateRequest) + + // Create a new updateRequest that will be edited + // It will allow user to edit it, then we will extract diff to perform update + newUpdateRequestV := reflect.New(updateRequestV.Type().Elem()) + valueMapper(newUpdateRequestV, updateRequestV) + valueMapper(newUpdateRequestV, resourceV) + + return newUpdateRequestV.Interface() +} + +func newRequest(request interface{}) interface{} { + requestType := reflect.TypeOf(request) + + if requestType.Kind() == reflect.Pointer { + requestType = requestType.Elem() + } + + return reflect.New(requestType).Interface() +} + +// copyRequestPathParameters will copy all path parameters present in src to their correct fields in dest +func copyRequestPathParameters(dest interface{}, src interface{}) { + valueMapper(reflect.ValueOf(dest), reflect.ValueOf(src), mapWithTag("-")) +} diff --git a/internal/editor/request_test.go b/internal/editor/request_test.go new file mode 100644 index 0000000000..b62e06699b --- /dev/null +++ b/internal/editor/request_test.go @@ -0,0 +1,22 @@ +package editor + +import ( + "reflect" + "testing" + + "github.com/alecthomas/assert" +) + +func Test_createGetResourceRequest(t *testing.T) { + type GetRequest struct { + ID string + } + updateRequest := struct { + ID string + Name string + }{"uuid", ""} + expectedRequest := &GetRequest{"uuid"} + + actualRequest := createGetRequest(updateRequest, reflect.TypeOf(GetRequest{})) + assert.Equal(t, expectedRequest, actualRequest) +} diff --git a/internal/editor/tempfile.go b/internal/editor/tempfile.go new file mode 100644 index 0000000000..87b31f560d --- /dev/null +++ b/internal/editor/tempfile.go @@ -0,0 +1,34 @@ +package editor + +import ( + "fmt" + "os" +) + +func temporaryFileNamePattern(marshalMode MarshalMode) string { + pattern := "scw-updateResourceEditor" + switch marshalMode { + case MarshalModeYAML: + pattern += "*.yml" + case MarshalModeJSON: + pattern += "*.json" + } + return pattern +} + +func createTemporaryFile(content []byte, marshalMode MarshalMode) (string, error) { + tmpFile, err := os.CreateTemp(os.TempDir(), temporaryFileNamePattern(marshalMode)) + if err != nil { + return "", fmt.Errorf("failed to create file: %w", err) + } + _, err = tmpFile.Write(content) + if err != nil { + return "", fmt.Errorf("failed to write to file %q: %w", tmpFile.Name(), err) + } + err = tmpFile.Close() + if err != nil { + return "", fmt.Errorf("failed to close file %q: %w", tmpFile.Name(), err) + } + + return tmpFile.Name(), nil +} diff --git a/internal/namespaces/instance/v1/custom.go b/internal/namespaces/instance/v1/custom.go index 1221b5c869..7d1076e958 100644 --- a/internal/namespaces/instance/v1/custom.go +++ b/internal/namespaces/instance/v1/custom.go @@ -142,6 +142,7 @@ func GetCommands() *core.Commands { cmds.Merge(core.NewCommands( securityGroupClearCommand(), securityGroupUpdateCommand(), + securityGroupEditCommand(), )) // diff --git a/internal/namespaces/instance/v1/custom_security_group.go b/internal/namespaces/instance/v1/custom_security_group.go index e30d2bacce..d2383b04ba 100644 --- a/internal/namespaces/instance/v1/custom_security_group.go +++ b/internal/namespaces/instance/v1/custom_security_group.go @@ -10,6 +10,7 @@ import ( "github.com/fatih/color" "github.com/scaleway/scaleway-cli/v2/internal/core" + "github.com/scaleway/scaleway-cli/v2/internal/editor" "github.com/scaleway/scaleway-cli/v2/internal/human" "github.com/scaleway/scaleway-cli/v2/internal/interactive" "github.com/scaleway/scaleway-cli/v2/internal/terminal" @@ -488,6 +489,98 @@ func securityGroupUpdateCommand() *core.Command { } } +var instanceSecurityGroupEditYamlExample = `rules: +- action: drop + dest_port_from: 1200 + dest_port_to: 1300 + direction: inbound + ip_range: 192.168.0.0/24 + protocol: TCP +- action: drop + direction: inbound + protocol: ICMP + ip_range: 0.0.0.0/0 +- action: accept + dest_port_from: 25565 + direction: outbound + ip_range: 0.0.0.0/0 + protocol: UDP +` + +type instanceSecurityGroupEditArgs struct { + Zone scw.Zone + SecurityGroupID string + Mode editor.MarshalMode +} + +func securityGroupEditCommand() *core.Command { + return &core.Command{ + Short: `Edit all rules of a security group`, + Long: editor.LongDescription, + Namespace: "instance", + Resource: "security-group", + Verb: "edit", + ArgsType: reflect.TypeOf(instanceSecurityGroupEditArgs{}), + ArgSpecs: core.ArgSpecs{ + { + Name: "security-group-id", + Short: `ID of the security group to reset.`, + Required: true, + Positional: true, + }, + editor.MarshalModeArgSpec(), + core.ZoneArgSpec(), + }, + Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) { + args := argsI.(*instanceSecurityGroupEditArgs) + + client := core.ExtractClient(ctx) + api := instance.NewAPI(client) + + rules, err := api.ListSecurityGroupRules(&instance.ListSecurityGroupRulesRequest{ + Zone: args.Zone, + SecurityGroupID: args.SecurityGroupID, + }, scw.WithAllPages(), scw.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("failed to list security-group rules: %w", err) + } + + // Get only rules that can be edited + editableRules := []*instance.SecurityGroupRule(nil) + for _, rule := range rules.Rules { + if rule.Editable { + editableRules = append(editableRules, rule) + } + } + rules.Rules = editableRules + + setRequest := &instance.SetSecurityGroupRulesRequest{ + Zone: args.Zone, + SecurityGroupID: args.SecurityGroupID, + } + + editedSetRequest, err := editor.UpdateResourceEditor(rules, setRequest, &editor.Config{ + PutRequest: true, + MarshalMode: args.Mode, + Template: instanceSecurityGroupEditYamlExample, + IgnoreFields: []string{"editable"}, + }) + if err != nil { + return nil, err + } + + setRequest = editedSetRequest.(*instance.SetSecurityGroupRulesRequest) + + resp, err := api.SetSecurityGroupRules(setRequest, scw.WithContext(ctx)) + if err != nil { + return nil, err + } + + return resp.Rules, nil + }, + } +} + func getDefaultProjectSecurityGroup(ctx context.Context, zone scw.Zone) (*instance.SecurityGroup, error) { api := instance.NewAPI(core.ExtractClient(ctx))