Skip to content

Commit b54c9c3

Browse files
Ppitokeidarcy
andauthored
chore: Add configurable execution mode for ECS container tasks (ecs | ssm) (#476)
* chore: Add execution mode for ECS container tasks * fix: typo * fix execution validation --------- Co-authored-by: Xing Yahao <48758247+keidarcy@users.noreply.github.com>
1 parent 6ea6191 commit b54c9c3

6 files changed

Lines changed: 251 additions & 55 deletions

File tree

README.md

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -90,21 +90,24 @@ Usage:
9090
e1s [flags]
9191
9292
Flags:
93-
--cluster string specify the default cluster
94-
-c, --config-file string config file (default "$HOME/.config/e1s/config.yml")
95-
-d, --debug sets debug mode
96-
-h, --help help for e1s
97-
-j, --json log output json format
98-
-l, --log-file string specify the log file path (default "${TMPDIR}e1s.log")
99-
--profile string specify the AWS profile
100-
--read-only sets read only mode
101-
-r, --refresh int specify the default refresh rate as an integer, sets -1 to stop auto refresh (sec) (default 30)
102-
--region string specify the AWS region
103-
--service string specify the default service (requires --cluster)
104-
-s, --shell string specify interactive ecs exec shell (default "/bin/sh")
105-
--splash display startup splash screen (AWS load runs before the UI) (default true)
106-
--theme string specify color theme
107-
-v, --version version for e1s
93+
--cluster string specify the default cluster
94+
-c, --config-file string config file (default "$HOME/.config/e1s/config.yml")
95+
-d, --debug sets debug mode
96+
--exec-mode string execution mode for ECS containers: ecs or ssm (default "ecs")
97+
-h, --help help for e1s
98+
-j, --json log output json format
99+
-l, --log-file string specify the log file path (default "${TMPDIR}e1s.log")
100+
--profile string specify the AWS profile
101+
--read-only sets read only mode
102+
-r, --refresh int specify the default refresh rate as an integer, sets -1 to stop auto refresh (sec) (default 30)
103+
--region string specify the AWS region
104+
--service string specify the default service (requires --cluster)
105+
-s, --shell string specify interactive ecs exec shell (default "/bin/sh")
106+
--ssm-custom-command string
107+
custom command template for SSM container execution mode
108+
--splash display startup splash screen (AWS load runs before the UI) (default true)
109+
--theme string specify color theme
110+
-v, --version version for e1s
108111
109112
```
110113

@@ -285,7 +288,7 @@ tail -f /tmp/e1s.log
285288
- Configure themes from the built-in theme set.
286289
- Override individual colors in config.
287290
- Adjust logging, refresh interval, splash behavior, shell, and default navigation targets.
288-
291+
- Change execution mode for ECS container tasks.
289292

290293
### Table filtering
291294

@@ -408,6 +411,28 @@ Implemented by a S3 bucket. Since file transfer though a S3 bucket and aws-cli i
408411
![file-transfer-demo](./assets/e1s-file-transfer-demo.gif)
409412
</details>
410413

414+
415+
### Execution mode for ECS container tasks
416+
417+
Due to certain security restrictions, the `ecs execute-command` may not be operational; this setting allows you to use `ssm start-session` instead.
418+
419+
- `ecs` - use ECS Exec to execute commands in the container.
420+
- `ssm` - use AWS Systems Manager to execute commands in the container.
421+
422+
#### Config file:
423+
```yaml
424+
# Default execution mode for ECS containers
425+
exec-mode: ssm
426+
```
427+
428+
With SSM mode you can also customize the command to run with `--ssm-custom-command` or the config file.<br>
429+
The command takes the containerId and the config shell as arguments.
430+
#### Config file:
431+
```yaml
432+
# Customize command for ssm execution mode
433+
ssm-custom-command: "sudo docker exec -it %s %s"
434+
```
435+
411436
### Full features list
412437

413438
<details>
@@ -452,6 +477,7 @@ Implemented by a S3 bucket. Since file transfer though a S3 bucket and aws-cli i
452477
- [x] Transfer files to and from your local machine and a remote host like `aws s3 cp`
453478
- [x] Customize theme
454479
- [x] Customize colors
480+
- [x] Customize execution mode for ECS container tasks (ecs or ssm)
455481
</details>
456482

457483
## Feature requests & bug reports
@@ -476,4 +502,3 @@ Xing Yahao(https://github.com/keidarcy)
476502
## Stargazers over time
477503

478504
[![Stargazers over time](https://starchart.cc/keidarcy/e1s.svg?variant=adaptive)](https://starchart.cc/keidarcy/e1s)
479-

cmd/e1s/main.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ func init() {
5959
rootCmd.Flags().String("cluster", "", "specify the default cluster")
6060
rootCmd.Flags().String("service", "", "specify the default service (requires --cluster)")
6161
rootCmd.Flags().Bool("splash", true, "display startup splash screen (AWS load runs before the UI)")
62+
rootCmd.Flags().String("exec-mode", "ecs", "execution mode for ECS containers: ecs or ssm")
63+
rootCmd.Flags().String("ssm-custom-command", "", "custom command template for SSM container execution mode")
6264

6365
err := viper.BindPFlags(rootCmd.Flags())
6466
if err != nil {
@@ -79,6 +81,9 @@ Check https://github.com/keidarcy/e1s for more details.`,
7981
if service != "" && cluster == "" {
8082
return fmt.Errorf("when specifying a service with --service, you must also specify a cluster with --cluster")
8183
}
84+
if err := validateExecMode(viper.GetString("exec-mode")); err != nil {
85+
return err
86+
}
8287
return nil
8388
},
8489
Run: func(cmd *cobra.Command, args []string) {
@@ -107,19 +112,23 @@ Check https://github.com/keidarcy/e1s for more details.`,
107112
cluster := viper.GetString("cluster")
108113
service := viper.GetString("service")
109114
splash := viper.GetBool("splash")
115+
execMode := viper.GetString("exec-mode")
116+
ssmCustomCommand := viper.GetString("ssm-custom-command")
110117

111118
option := e1s.Option{
112-
ConfigFile: configFile,
113-
LogFile: logFile,
114-
Debug: debug,
115-
JSON: json,
116-
ReadOnly: readOnly,
117-
Refresh: refresh,
118-
Shell: shell,
119-
Theme: theme,
120-
Cluster: cluster,
121-
Service: service,
122-
Splash: splash,
119+
ConfigFile: configFile,
120+
LogFile: logFile,
121+
Debug: debug,
122+
JSON: json,
123+
ReadOnly: readOnly,
124+
Refresh: refresh,
125+
Shell: shell,
126+
Theme: theme,
127+
Cluster: cluster,
128+
Service: service,
129+
Splash: splash,
130+
ExecMode: execMode,
131+
SsmCustomCommand: ssmCustomCommand,
123132
}
124133

125134
if err := e1s.Start(option); err != nil {
@@ -131,6 +140,15 @@ Check https://github.com/keidarcy/e1s for more details.`,
131140
Version: utils.ShowVersion(),
132141
}
133142

143+
func validateExecMode(execMode string) error {
144+
switch execMode {
145+
case "ecs", "ssm":
146+
return nil
147+
default:
148+
return fmt.Errorf("invalid exec-mode %q: must be ecs or ssm", execMode)
149+
}
150+
}
151+
134152
func main() {
135153
if err := rootCmd.Execute(); err != nil {
136154
fmt.Fprintln(os.Stderr, err)

internal/view/app.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ type Option struct {
6565
Service string
6666
// Splash screen on startup (load AWS config and first resource list in background).
6767
Splash bool
68+
// Execution mode for ECS containers: "ecs" or "ssm".
69+
ExecMode string
70+
// Custom command template for ECS container SSM sessions.
71+
SsmCustomCommand string
6872
}
6973

7074
// viewState holds sort/filter state per page so it can be restored after a reload.

internal/view/shell.go

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package view
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"log/slog"
67
"os"
@@ -150,59 +151,57 @@ func (v *view) preValidateExec() (*[]string, string, error) {
150151
return &args, containerName, nil
151152
}
152153

153-
// Start session for instance
154-
// aws ssm start-session --target ${instance_id}
155-
func (v *view) instanceStartSession() {
156-
if v.app.kind != InstanceKind && v.app.kind != TaskKind {
157-
v.app.Notice.Warn("Invalid kind type to start session")
158-
return
154+
func (v *view) preValidateStartSession() (*[]string, string, error) {
155+
if v.app.kind != ContainerKind && v.app.kind != InstanceKind && v.app.kind != TaskKind {
156+
return nil, "", fmt.Errorf("invalid kind type to start session")
159157
}
160158

161159
if v.app.ReadOnly {
162-
v.app.Notice.Warn("No permission to start session in read only mode")
163-
return
160+
return nil, "", fmt.Errorf("no permission to start session in read only mode")
164161
}
165162

166163
_, err := exec.LookPath(awsCli)
167164
if err != nil {
168-
v.app.Notice.Warnf("failed to find %s path, please check %s", awsCli, "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html")
169-
return
165+
return nil, "", fmt.Errorf("failed to find %s path, please check %s", awsCli, "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html")
170166
}
171167

172168
selected, err := v.getCurrentSelection()
173169
if err != nil {
174-
v.app.Notice.Warnf("failed to handleSelected, err: %v", err)
175-
return
170+
return nil, "", fmt.Errorf("failed to handleSelected, err: %v", err)
176171
}
177172

178173
instanceId := ""
179174
if v.app.kind == InstanceKind {
180175
if selected.instance == nil {
181-
v.app.Notice.Warn("Not a valid instance")
182-
return
176+
return nil, "", fmt.Errorf("not a valid instance")
177+
}
178+
if selected.instance.Ec2InstanceId == nil || *selected.instance.Ec2InstanceId == "" {
179+
return nil, "", fmt.Errorf("not a valid instance id")
183180
}
184181
instanceId = *selected.instance.Ec2InstanceId
185-
} else if v.app.kind == TaskKind {
182+
} else if v.app.kind == ContainerKind || v.app.kind == TaskKind {
186183
if v.app.task.ContainerInstanceArn == nil {
187-
v.app.Notice.Warn("Not a valid task with container instance")
188-
return
184+
return nil, "", fmt.Errorf("not a valid task with container instance")
185+
}
186+
if v.app.Store == nil {
187+
return nil, "", fmt.Errorf("aws store is not initialized")
188+
}
189+
if v.app.cluster == nil || v.app.cluster.ClusterName == nil || *v.app.cluster.ClusterName == "" {
190+
return nil, "", fmt.Errorf("not a valid cluster")
189191
}
190192
instanceId, err = v.app.Store.GetTaskInstanceId(v.app.cluster.ClusterName, v.app.task.ContainerInstanceArn)
191193
if err != nil {
192-
v.app.Notice.Warnf("failed to get task instance id, err: %v", err)
193-
return
194+
return nil, "", fmt.Errorf("failed to get task instance id, err: %v", err)
194195
}
195196
}
196197

197198
if instanceId == "" {
198-
v.app.Notice.Warn("Not a valid instance")
199-
return
199+
return nil, "", fmt.Errorf("not a valid instance")
200200
}
201201

202202
_, err = exec.LookPath(smpCi)
203203
if err != nil {
204-
v.app.Notice.Warnf("failed to find %s path, please check %s", smpCi, "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html")
205-
return
204+
return nil, "", fmt.Errorf("failed to find %s path, please check %s", smpCi, "https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html")
206205
}
207206

208207
args := []string{
@@ -212,16 +211,28 @@ func (v *view) instanceStartSession() {
212211
instanceId,
213212
}
214213

214+
return &args, instanceId, nil
215+
}
216+
217+
// Start session for instance
218+
// aws ssm start-session --target ${instance_id}
219+
func (v *view) instanceStartSession() {
220+
args, instanceId, err := v.preValidateStartSession()
221+
if err != nil {
222+
v.app.Notice.Warnf("Exec command validation failed: %v", err)
223+
return
224+
}
225+
215226
// catch ctrl+C & SIGTERM
216227
interrupt := make(chan os.Signal, 1)
217228
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
218229

219230
v.app.Suspend(func() {
220231
v.app.isSuspended = true
221232
bin, _ := exec.LookPath(awsCli)
222-
slog.Info("exec", "command", bin+" "+strings.Join(args, " "))
233+
slog.Info("exec", "command", bin+" "+strings.Join(*args, " "))
223234

224-
cmd := exec.Command(bin, args...)
235+
cmd := exec.Command(bin, *args...)
225236
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
226237
_, err = cmd.Stdout.Write([]byte(fmt.Sprintf(instanceBannerFmt, *v.app.cluster.ClusterName, instanceId)))
227238
err = cmd.Run()
@@ -232,3 +243,86 @@ func (v *view) instanceStartSession() {
232243
v.app.isSuspended = false
233244
})
234245
}
246+
247+
// Start session for instance
248+
// Equivalent to
249+
// aws ssm start-session
250+
// --target ecs:${cluster_id}_${task_id}_${runtime_id}
251+
// --document-name AWS-StartInteractiveCommand
252+
// --parameters {"command":["${command}"]}
253+
func (v *view) instanceStartSessionDocument() {
254+
args, instanceId, err := v.preValidateStartSession()
255+
if err != nil {
256+
v.app.Notice.Warnf("Exec command validation failed: %v", err)
257+
return
258+
}
259+
260+
selected, err := v.getCurrentSelection()
261+
if err != nil {
262+
v.app.Notice.Warnf("failed to handleSelected, err: %v", err)
263+
return
264+
}
265+
runtimeId, containerName, err := validateContainerSessionTarget(selected)
266+
if err != nil {
267+
v.app.Notice.Warn(err.Error())
268+
return
269+
}
270+
if v.app.task.TaskArn == nil || *v.app.task.TaskArn == "" {
271+
v.app.Notice.Warn("Not a valid task")
272+
return
273+
}
274+
if v.app.cluster.ClusterName == nil || *v.app.cluster.ClusterName == "" {
275+
v.app.Notice.Warn("Not a valid cluster")
276+
return
277+
}
278+
279+
ssmCommand := "docker exec -it %s %s"
280+
if v.app.Option.SsmCustomCommand != "" {
281+
ssmCommand = v.app.Option.SsmCustomCommand
282+
}
283+
params := map[string][]string{
284+
"command": {fmt.Sprintf(ssmCommand, runtimeId, v.app.Option.Shell)},
285+
}
286+
parameterJson, _ := json.Marshal(params)
287+
288+
extraArgs := []string{
289+
"--document-name",
290+
"AWS-StartInteractiveCommand",
291+
"--parameters",
292+
string(parameterJson),
293+
}
294+
295+
cmdArgs := append(*args, extraArgs...)
296+
// catch ctrl+C & SIGTERM
297+
interrupt := make(chan os.Signal, 1)
298+
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
299+
300+
v.app.Suspend(func() {
301+
v.app.isSuspended = true
302+
bin, _ := exec.LookPath(awsCli)
303+
slog.Info("exec", "command", bin+" "+strings.Join(cmdArgs, " "))
304+
305+
cmd := exec.Command(bin, cmdArgs...)
306+
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
307+
_, err = cmd.Stdout.Write([]byte(fmt.Sprintf(execBannerFmt, *v.app.cluster.ClusterName, instanceId, utils.ArnToName(v.app.task.TaskArn), containerName)))
308+
err = cmd.Run()
309+
310+
// return signal
311+
signal.Stop(interrupt)
312+
close(interrupt)
313+
v.app.isSuspended = false
314+
})
315+
}
316+
317+
func validateContainerSessionTarget(selected Entity) (runtimeId string, containerName string, err error) {
318+
if selected.container == nil {
319+
return "", "", fmt.Errorf("not a valid container")
320+
}
321+
if selected.container.RuntimeId == nil || *selected.container.RuntimeId == "" {
322+
return "", "", fmt.Errorf("not a valid runtime id")
323+
}
324+
if selected.container.Name == nil || *selected.container.Name == "" {
325+
return "", "", fmt.Errorf("not a valid container name")
326+
}
327+
return *selected.container.RuntimeId, *selected.container.Name, nil
328+
}

0 commit comments

Comments
 (0)