Skip to content

Commit 6ec08ac

Browse files
Codelaxremyleone
andauthored
feat: add editor (#2878)
Co-authored-by: Rémy Léone <rleone@scaleway.com>
1 parent 908be53 commit 6ec08ac

File tree

15 files changed

+1001
-0
lines changed

15 files changed

+1001
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
2+
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
3+
This command starts your default editor to edit a marshaled version of your resource
4+
Default editor will be taken from $VISUAL, then $EDITOR or an editor based on your system
5+
6+
USAGE:
7+
scw instance security-group edit <security-group-id ...> [arg=value ...]
8+
9+
ARGS:
10+
security-group-id ID of the security group to reset.
11+
[mode=yaml] marshaling used when editing data (yaml | json)
12+
[zone=fr-par-1] Zone to target. If none is passed will use default zone from the config
13+
14+
FLAGS:
15+
-h, --help help for edit
16+
17+
GLOBAL FLAGS:
18+
-c, --config string The path to the config file
19+
-D, --debug Enable debug mode
20+
-o, --output string Output format: json or human, see 'scw help output' for more info (default "human")
21+
-p, --profile string The config profile to use

cmd/scw/testdata/test-all-usage-instance-security-group-usage.golden

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ AVAILABLE COMMANDS:
1515
create-rule Create rule
1616
delete Delete a security group
1717
delete-rule Delete rule
18+
edit Edit all rules of a security group
1819
get Get a security group
1920
get-rule Get rule
2021
list List security groups

docs/commands/instance.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Instance API.
3939
- [Create rule](#create-rule)
4040
- [Delete a security group](#delete-a-security-group)
4141
- [Delete rule](#delete-rule)
42+
- [Edit all rules of a security group](#edit-all-rules-of-a-security-group)
4243
- [Get a security group](#get-a-security-group)
4344
- [Get rule](#get-rule)
4445
- [List security groups](#list-security-groups)
@@ -1292,6 +1293,28 @@ scw instance security-group delete-rule security-group-id=a01a36e5-5c0c-42c1-ae0
12921293

12931294

12941295

1296+
### Edit all rules of a security group
1297+
1298+
This command starts your default editor to edit a marshaled version of your resource
1299+
Default editor will be taken from $VISUAL, then $EDITOR or an editor based on your system
1300+
1301+
**Usage:**
1302+
1303+
```
1304+
scw instance security-group edit <security-group-id ...> [arg=value ...]
1305+
```
1306+
1307+
1308+
**Args:**
1309+
1310+
| Name | | Description |
1311+
|------|---|-------------|
1312+
| security-group-id | Required | ID of the security group to reset. |
1313+
| mode | Default: `yaml`<br />One of: `yaml`, `json` | marshaling used when editing data |
1314+
| zone | Default: `fr-par-1` | Zone to target. If none is passed will use default zone from the config |
1315+
1316+
1317+
12951318
### Get a security group
12961319

12971320
Get the details of a Security Group with the given ID.

internal/config/editor.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"runtime"
6+
)
7+
8+
var (
9+
// List of env variables where to find the editor to use
10+
// Order in slice is override order, the latest will override the first ones
11+
editorEnvVariables = []string{"EDITOR", "VISUAL"}
12+
)
13+
14+
func GetSystemDefaultEditor() string {
15+
switch runtime.GOOS {
16+
case "windows":
17+
return "notepad"
18+
default:
19+
return "vi"
20+
}
21+
}
22+
23+
func GetDefaultEditor() string {
24+
editor := ""
25+
for _, envVar := range editorEnvVariables {
26+
tmp := os.Getenv(envVar)
27+
if tmp != "" {
28+
editor = tmp
29+
}
30+
}
31+
32+
if editor == "" {
33+
return GetSystemDefaultEditor()
34+
}
35+
36+
return editor
37+
}

internal/editor/doc.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package editor
2+
3+
import (
4+
"github.com/scaleway/scaleway-cli/v2/internal/core"
5+
)
6+
7+
var LongDescription = `This command starts your default editor to edit a marshaled version of your resource
8+
Default editor will be taken from $VISUAL, then $EDITOR or an editor based on your system`
9+
10+
func MarshalModeArgSpec() *core.ArgSpec {
11+
return &core.ArgSpec{
12+
Name: "mode",
13+
Short: "marshaling used when editing data",
14+
Required: false,
15+
Default: core.DefaultValueSetter(MarshalModeDefault),
16+
EnumValues: MarshalModeEnum,
17+
}
18+
}

internal/editor/editor.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package editor
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"strings"
8+
9+
"github.com/scaleway/scaleway-cli/v2/internal/config"
10+
)
11+
12+
var SkipEditor = false
13+
var marshalMode = MarshalModeYAML
14+
15+
type GetResourceFunc func(interface{}) (interface{}, error)
16+
type Config struct {
17+
// PutRequest means that the request replace all fields
18+
// If false, fields that were not edited will not be sent
19+
// If true, all fields will be sent
20+
PutRequest bool
21+
22+
MarshalMode MarshalMode
23+
24+
// Template is a template that will be shown before marshaled data in edited file
25+
Template string
26+
27+
// IgnoreFields is a list of json tags that will be removed from marshaled data
28+
// The content of these fields will be lost in edited data
29+
IgnoreFields []string
30+
31+
// If not empty, this will replace edited text as if it was edited in the terminal
32+
// Should be paired with global SkipEditor as true, useful for tests
33+
editedResource string
34+
}
35+
36+
func editorPathAndArgs(fileName string) (string, []string) {
37+
defaultEditor := config.GetDefaultEditor()
38+
editorAndArguments := strings.Fields(defaultEditor)
39+
args := []string{fileName}
40+
41+
if len(editorAndArguments) > 1 {
42+
args = append(editorAndArguments[1:], args...)
43+
}
44+
45+
return editorAndArguments[0], args
46+
}
47+
48+
// edit create a temporary file with given content, start a text editor then return edited content
49+
// temporary file will be deleted on complete
50+
// temporary file is not deleted if edit fails
51+
func edit(content []byte) ([]byte, error) {
52+
if SkipEditor {
53+
return content, nil
54+
}
55+
56+
tmpFileName, err := createTemporaryFile(content, marshalMode)
57+
if err != nil {
58+
return nil, fmt.Errorf("failed to create temporary file: %w", err)
59+
}
60+
defer os.Remove(tmpFileName)
61+
62+
editorPath, args := editorPathAndArgs(tmpFileName)
63+
cmd := exec.Command(editorPath, args...)
64+
cmd.Stdin = os.Stdin
65+
cmd.Stdout = os.Stdout
66+
67+
err = cmd.Run()
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to edit temporary file %q: %w", tmpFileName, err)
70+
}
71+
72+
editedContent, err := os.ReadFile(tmpFileName)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to read temporary file %q: %w", tmpFileName, err)
75+
}
76+
77+
return editedContent, nil
78+
}
79+
80+
// updateResourceEditor takes a complete resource and a partial updateRequest
81+
// will return a copy of updateRequest that has been edited
82+
func updateResourceEditor(resource interface{}, updateRequest interface{}, cfg *Config) (interface{}, error) {
83+
// Create a copy of updateRequest completed with resource content
84+
completeUpdateRequest := copyAndCompleteUpdateRequest(updateRequest, resource)
85+
86+
// TODO: fields present in updateRequest should be removed from marshal
87+
// ex: namespace_id, region, zone
88+
// Currently not an issue as fields that should be removed are mostly path parameter /{zone}/namespace/{namespace_id}
89+
// Path parameter have "-" as json tag and are not marshaled
90+
91+
updateRequestMarshaled, err := marshal(completeUpdateRequest, cfg.MarshalMode)
92+
if err != nil {
93+
return nil, fmt.Errorf("failed to marshal update request: %w", err)
94+
}
95+
96+
if len(cfg.IgnoreFields) > 0 {
97+
updateRequestMarshaled, err = removeFields(updateRequestMarshaled, cfg.MarshalMode, cfg.IgnoreFields)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to remove ignored fields: %w", err)
100+
}
101+
}
102+
103+
if cfg.Template != "" {
104+
updateRequestMarshaled = addTemplate(updateRequestMarshaled, cfg.Template, cfg.MarshalMode)
105+
}
106+
107+
// Start text editor to edit marshaled request
108+
updateRequestMarshaled, err = edit(updateRequestMarshaled)
109+
if err != nil {
110+
return nil, fmt.Errorf("failed to edit marshalled data: %w", err)
111+
}
112+
113+
// If editedResource is present, override edited resource
114+
// This is useful for testing purpose
115+
if cfg.editedResource != "" {
116+
updateRequestMarshaled = []byte(cfg.editedResource)
117+
}
118+
119+
// Create a new updateRequest as destination for edited yaml/json
120+
// Must be a new one to avoid merge of maps content
121+
updateRequestEdited := newRequest(updateRequest)
122+
123+
// TODO: if !putRequest
124+
// fill updateRequestEdited with only edited fields and fields present in updateRequest
125+
// fields should be compared with completeUpdateRequest to find edited ones
126+
127+
// Add back required non-marshaled fields (zone, ID)
128+
copyRequestPathParameters(updateRequestEdited, updateRequest)
129+
130+
err = unmarshal(updateRequestMarshaled, updateRequestEdited, cfg.MarshalMode)
131+
if err != nil {
132+
return nil, fmt.Errorf("failed to unmarshal edited data: %w", err)
133+
}
134+
135+
return updateRequestEdited, nil
136+
}
137+
138+
// UpdateResourceEditor takes a complete resource and a partial updateRequest
139+
// will return a copy of updateRequest that has been edited
140+
// Only edited fields will be present in returned updateRequest
141+
// If putRequest is true, all fields will be present, edited or not
142+
func UpdateResourceEditor(resource interface{}, updateRequest interface{}, cfg *Config) (interface{}, error) {
143+
return updateResourceEditor(resource, updateRequest, cfg)
144+
}

internal/editor/editor_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package editor
2+
3+
import (
4+
"testing"
5+
6+
"github.com/alecthomas/assert"
7+
)
8+
9+
func Test_updateResourceEditor(t *testing.T) {
10+
SkipEditor = true
11+
12+
resource := &struct {
13+
ID string
14+
Name string
15+
}{
16+
"uuid",
17+
"name",
18+
}
19+
updateRequest := &struct {
20+
ID string
21+
Name string
22+
}{
23+
"uuid",
24+
"",
25+
}
26+
27+
_, err := updateResourceEditor(resource, updateRequest, &Config{})
28+
assert.Nil(t, err)
29+
}
30+
31+
func Test_updateResourceEditor_pointers(t *testing.T) {
32+
SkipEditor = true
33+
34+
type UpdateRequest struct {
35+
ID string
36+
Name *string
37+
}
38+
resource := &struct {
39+
ID string
40+
Name string
41+
}{
42+
"uuid",
43+
"name",
44+
}
45+
46+
updateRequest := &UpdateRequest{
47+
"uuid",
48+
nil,
49+
}
50+
51+
editedUpdateRequestI, err := updateResourceEditor(resource, updateRequest, &Config{})
52+
assert.Nil(t, err)
53+
editedUpdateRequest := editedUpdateRequestI.(*UpdateRequest)
54+
55+
assert.NotNil(t, editedUpdateRequest.Name)
56+
assert.Equal(t, resource.Name, *editedUpdateRequest.Name)
57+
}
58+
59+
func Test_updateResourceEditor_map(t *testing.T) {
60+
SkipEditor = true
61+
62+
type UpdateRequest struct {
63+
ID string `json:"id"`
64+
Env *map[string]string `json:"env"`
65+
}
66+
resource := &struct {
67+
ID string `json:"id"`
68+
Env map[string]string `json:"env"`
69+
}{
70+
"uuid",
71+
map[string]string{
72+
"foo": "bar",
73+
},
74+
}
75+
76+
updateRequest := &UpdateRequest{
77+
"uuid",
78+
nil,
79+
}
80+
81+
editedUpdateRequestI, err := updateResourceEditor(resource, updateRequest, &Config{
82+
editedResource: `
83+
id: uuid
84+
env: {}
85+
`,
86+
})
87+
assert.Nil(t, err)
88+
editedUpdateRequest := editedUpdateRequestI.(*UpdateRequest)
89+
assert.NotNil(t, editedUpdateRequest.Env)
90+
assert.True(t, len(*editedUpdateRequest.Env) == 0)
91+
}

0 commit comments

Comments
 (0)