v0.8.1 b8e893f

cli_run.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"fmt"
8	"io"
9	"strconv"
10	"strings"
11	"time"
12
13	"connectrpc.com/connect"
14	"github.com/spf13/cobra"
15
16	"bnema.dev/ermite-api/gen/ermite"
17	"bnema.dev/ermite/internal/adapters/client"
18)
19
20// jsonRun is the stable JSON shape of a run.
21type jsonRun struct {
22	ID           int64   `json:"id"`
23	Repo         string  `json:"repo"`
24	Number       int64   `json:"number"`
25	Attempt      int32   `json:"attempt"`
26	Workflow     string  `json:"workflow"`
27	Name         string  `json:"name"`
28	WorkflowPath string  `json:"workflow_path"`
29	Title        string  `json:"title"`
30	Event        string  `json:"event"`
31	Ref          string  `json:"ref"`
32	Sha          string  `json:"sha"`
33	Actor        string  `json:"actor"`
34	Status       string  `json:"status"`
35	Conclusion   string  `json:"conclusion"`
36	Error        string  `json:"error"`
37	CreatedAt    *string `json:"created_at"`
38	StartedAt    *string `json:"started_at"`
39	CompletedAt  *string `json:"completed_at"`
40}
41
42type jsonStep struct {
43	Number      int32   `json:"number"`
44	Name        string  `json:"name"`
45	State       string  `json:"state"`
46	Conclusion  string  `json:"conclusion"`
47	StartedAt   *string `json:"started_at"`
48	CompletedAt *string `json:"completed_at"`
49	LogStart    int64   `json:"log_start"`
50	LogEnd      int64   `json:"log_end"`
51}
52
53type jsonRunJob struct {
54	ID          string     `json:"id"`
55	Attempt     int32      `json:"attempt"`
56	Key         string     `json:"key"`
57	Name        string     `json:"name"`
58	Status      string     `json:"status"`
59	Conclusion  string     `json:"conclusion"`
60	Error       string     `json:"error"`
61	Labels      []string   `json:"labels"`
62	Needs       []string   `json:"needs"`
63	Runner      string     `json:"runner"`
64	Isolation   string     `json:"isolation"`
65	Steps       []jsonStep `json:"steps"`
66	StartedAt   *string    `json:"started_at"`
67	CompletedAt *string    `json:"completed_at"`
68}
69
70type jsonRunDetail struct {
71	jsonRun
72	LatestAttempt int32        `json:"latest_attempt"`
73	Jobs          []jsonRunJob `json:"jobs"`
74}
75
76const jsonRunHelp = `
77JSON fields of a run (--json): id, repo, number, attempt, workflow, name (the name of the run: its run-name, else the workflow), workflow_path, title, event,
78ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
79skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
80started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
81jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
82with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
83
84func statusText(s ermite.CiStatus) string {
85	return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
86}
87
88func conclusionText(c ermite.CiConclusion) string {
89	if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
90		return ""
91	}
92	return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
93}
94
95func isolationText(i ermite.RunnerIsolation) string {
96	if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
97		return ""
98	}
99	return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
100}
101
102func stepStateText(s ermite.StepState) string {
103	return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
104}
105
106// runResult is "status" while the run is going, the conclusion once over.
107func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
108	if status == ermite.CiStatus_CI_STATUS_COMPLETED {
109		return conclusionText(c)
110	}
111	return statusText(status)
112}
113
114func toJSONRun(r *ermite.Run) jsonRun {
115	return jsonRun{
116		ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
117		Name: r.GetName(), WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
118		Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
119		CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
120	}
121}
122
123func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
124	steps := make([]jsonStep, 0, len(j.GetSteps()))
125	for _, s := range j.GetSteps() {
126		steps = append(steps, jsonStep{
127			Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
128			StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
129		})
130	}
131	labels, needs := j.GetLabels(), j.GetNeeds()
132	if labels == nil {
133		labels = []string{}
134	}
135	if needs == nil {
136		needs = []string{}
137	}
138	return jsonRunJob{
139		ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
140		Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
141		Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
142		StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
143	}
144}
145
146func newRunCmd(env *cliEnv) *cobra.Command {
147	run := &cobra.Command{
148		Use:   "run",
149		Short: "List, read, watch, cancel and re-run CI runs",
150		Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
151			"Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
152	}
153	run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
154	return run
155}
156
157// parseRunID reads a run id argument.
158func parseRunID(s string) (int64, error) {
159	id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
160	if err != nil || id < 1 {
161		return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
162	}
163	return id, nil
164}
165
166func newRunListCmd(env *cliEnv) *cobra.Command {
167	var workflow, branch, status string
168	var limit int
169	cmd := &cobra.Command{
170		Use:   "list <repo>",
171		Short: "List the runs of a repository",
172		Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
173			"file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
174		Args:              cobra.ExactArgs(1),
175		ValidArgsFunction: completeRepoNames(env),
176	}
177	cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
178	cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
179	cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
180	cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
181	asJSON := addJSONFlag(cmd)
182	cmd.RunE = func(cmd *cobra.Command, args []string) error {
183		st, err := parseStatusFlag(status)
184		if err != nil {
185			return err
186		}
187		if limit < 1 {
188			return fmt.Errorf("--limit must be at least 1")
189		}
190		c, err := env.client(cmd)
191		if err != nil {
192			return err
193		}
194		ctx := env.ctx(cmd)
195		var runs []*ermite.Run
196		token := ""
197		for len(runs) < limit {
198			res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
199				Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
200				Workflow: workflow, Branch: branch, Status: st,
201			}))
202			if err != nil {
203				return err
204			}
205			runs = append(runs, res.Msg.GetRuns()...)
206			if token = res.Msg.GetNextPageToken(); token == "" {
207				break
208			}
209		}
210		if *asJSON {
211			out := make([]jsonRun, 0, len(runs))
212			for _, r := range runs {
213				out = append(out, toJSONRun(r))
214			}
215			return writeJSON(cmd.OutOrStdout(), out)
216		}
217		if len(runs) == 0 {
218			noticef(cmd, "no runs")
219			return nil
220		}
221		rows := make([][]string, 0, len(runs))
222		for _, r := range runs {
223			rows = append(rows, []string{
224				strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
225				strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
226				r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
227			})
228		}
229		return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
230	}
231	return cmd
232}
233
234func shortSha(s string) string {
235	if len(s) > 8 {
236		return s[:8]
237	}
238	return s
239}
240
241func parseStatusFlag(s string) (ermite.CiStatus, error) {
242	switch strings.ToLower(s) {
243	case "":
244		return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
245	case "queued":
246		return ermite.CiStatus_CI_STATUS_QUEUED, nil
247	case "in_progress", "in-progress":
248		return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
249	case "completed":
250		return ermite.CiStatus_CI_STATUS_COMPLETED, nil
251	}
252	return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
253}
254
255func newRunViewCmd(env *cliEnv) *cobra.Command {
256	var attempt int32
257	var logFlag bool
258	var jobFlag string
259	cmd := &cobra.Command{
260		Use:   "view <repo> <run id>",
261		Short: "Show a run with its jobs and steps, or the log of a job",
262		Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
263			"With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
264			"or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
265		Args:              cobra.ExactArgs(2),
266		ValidArgsFunction: completeRepoNames(env),
267	}
268	cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
269	cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
270	cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
271	asJSON := addJSONFlag(cmd)
272	cmd.RunE = func(cmd *cobra.Command, args []string) error {
273		id, err := parseRunID(args[1])
274		if err != nil {
275			return err
276		}
277		c, err := env.client(cmd)
278		if err != nil {
279			return err
280		}
281		ctx := env.ctx(cmd)
282		res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
283		if err != nil {
284			return err
285		}
286		if logFlag {
287			return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
288		}
289		if *asJSON {
290			d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
291			for _, j := range res.Msg.GetJobs() {
292				d.Jobs = append(d.Jobs, toJSONRunJob(j))
293			}
294			return writeJSON(cmd.OutOrStdout(), d)
295		}
296		return printRun(cmd.OutOrStdout(), res.Msg)
297	}
298	return cmd
299}
300
301func printRun(w io.Writer, r *ermite.GetRunResponse) error {
302	run := r.GetRun()
303	pairs := [][2]string{
304		{"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
305		{"result", runResult(run.GetStatus(), run.GetConclusion())},
306		{"ref", run.GetRef()},
307		{"commit", run.GetSha()},
308		{"title", run.GetTitle()},
309		{"actor", run.GetActor()},
310		{"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
311		{"created", shortTime(run.GetCreatedAt(), "-")},
312	}
313	if run.GetError() != "" {
314		pairs = append(pairs, [2]string{"error", run.GetError()})
315	}
316	if err := fields(w, pairs); err != nil {
317		return err
318	}
319	if len(r.GetJobs()) == 0 {
320		return nil
321	}
322	_, _ = fmt.Fprintln(w)
323	for _, j := range r.GetJobs() {
324		line := fmt.Sprintf("%s  %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
325		if j.GetRunnerName() != "" {
326			line += "  [" + j.GetRunnerName() + "]"
327		}
328		_, _ = fmt.Fprintln(w, clean(line))
329		if j.GetError() != "" {
330			_, _ = fmt.Fprintln(w, "    "+clean(j.GetError()))
331		}
332		for _, s := range j.GetSteps() {
333			res := stepStateText(s.GetState())
334			if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
335				res = conclusionText(s.GetConclusion())
336			}
337			_, _ = fmt.Fprintln(w, clean(fmt.Sprintf("    %-9s %2d  %s", res, s.GetNumber(), s.GetName())))
338		}
339	}
340	return nil
341}
342
343// pickJobs selects the jobs --job names (an id or a name), or all of them.
344func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
345	if sel == "" {
346		return jobs, nil
347	}
348	var out []*ermite.RunJob
349	for _, j := range jobs {
350		if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
351			out = append(out, j)
352		}
353	}
354	if len(out) == 0 {
355		return nil, fmt.Errorf("the run has no job %q", sel)
356	}
357	return out, nil
358}
359
360// printRunLog prints the log of the chosen jobs; with follow it waits for the
361// lines of a job that is running.
362func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
363	jobs, err := pickJobs(r.GetJobs(), sel)
364	if err != nil {
365		return err
366	}
367	for _, j := range jobs {
368		if len(jobs) > 1 {
369			_, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
370		}
371		if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
372			return err
373		}
374	}
375	return nil
376}
377
378// streamJobLog writes the lines of one job. Continued pieces are joined to the
379// line before them, as the raw log has them.
380func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
381	if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow {
382		noticef2(w, "job %q has not started: no log yet", j.GetName())
383		return nil
384	}
385	stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
386		Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
387	}))
388	if err != nil {
389		return err
390	}
391	defer func() { _ = stream.Close() }()
392	open := false
393	for stream.Receive() {
394		for _, l := range stream.Msg().GetLines() {
395			if l.GetContinued() && open {
396				_, _ = fmt.Fprint(w, clean(l.GetText()))
397				continue
398			}
399			if open {
400				_, _ = fmt.Fprintln(w)
401			}
402			_, _ = fmt.Fprint(w, clean(l.GetText()))
403			open = true
404		}
405	}
406	if open {
407		_, _ = fmt.Fprintln(w)
408	}
409	if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
410		return err
411	}
412	return nil
413}
414
415func noticef2(w io.Writer, format string, args ...any) {
416	_, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
417}
418
419func newRunWatchCmd(env *cliEnv) *cobra.Command {
420	var interval time.Duration
421	var logFlag bool
422	cmd := &cobra.Command{
423		Use:   "watch <repo> <run id>",
424		Short: "Wait for a run to complete and follow its jobs",
425		Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
426			"of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
427			"`ermite run watch` can gate a script.",
428		Args:              cobra.ExactArgs(2),
429		ValidArgsFunction: completeRepoNames(env),
430	}
431	cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
432	cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
433	cmd.RunE = func(cmd *cobra.Command, args []string) error {
434		id, err := parseRunID(args[1])
435		if err != nil {
436			return err
437		}
438		c, err := env.client(cmd)
439		if err != nil {
440			return err
441		}
442		ctx := env.ctx(cmd)
443		w := cmd.OutOrStdout()
444		last := ""
445		for {
446			res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
447			if err != nil {
448				return err
449			}
450			if line := watchLine(res.Msg); line != last {
451				_, _ = fmt.Fprintln(w, line)
452				last = line
453			}
454			if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
455				if logFlag {
456					if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
457						return err
458					}
459				}
460				if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
461					return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
462				}
463				return nil
464			}
465			select {
466			case <-ctx.Done():
467				return ctx.Err()
468			case <-time.After(interval):
469			}
470		}
471	}
472	return cmd
473}
474
475// watchLine is a one-line state of a run for `run watch`.
476func watchLine(r *ermite.GetRunResponse) string {
477	var parts []string
478	for _, j := range r.GetJobs() {
479		parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
480	}
481	return clean(fmt.Sprintf("run %d  %s  %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
482}
483
484func newRunCancelCmd(env *cliEnv) *cobra.Command {
485	cmd := &cobra.Command{
486		Use:               "cancel <repo> <run id>",
487		Short:             "Cancel a run",
488		Long:              "Ask the runners to stop the jobs of a run that is not over; jobs that no runner has yet are cancelled at once. Needs the ci:write scope.",
489		Args:              cobra.ExactArgs(2),
490		ValidArgsFunction: completeRepoNames(env),
491	}
492	asJSON := addJSONFlag(cmd)
493	cmd.RunE = func(cmd *cobra.Command, args []string) error {
494		id, err := parseRunID(args[1])
495		if err != nil {
496			return err
497		}
498		c, err := env.client(cmd)
499		if err != nil {
500			return err
501		}
502		res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
503		if err != nil {
504			return err
505		}
506		if *asJSON {
507			return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
508		}
509		noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
510		return nil
511	}
512	return cmd
513}
514
515func newRunRerunCmd(env *cliEnv) *cobra.Command {
516	var failed bool
517	cmd := &cobra.Command{
518		Use:               "rerun <repo> <run id>",
519		Short:             "Run a completed run again",
520		Long:              "Start a new attempt of a completed run, with the same commit and workflow file. With --failed, only the jobs that did not succeed (and the jobs that need them) run again. Needs the ci:write scope." + jsonRunHelp,
521		Args:              cobra.ExactArgs(2),
522		ValidArgsFunction: completeRepoNames(env),
523	}
524	cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
525	asJSON := addJSONFlag(cmd)
526	cmd.RunE = func(cmd *cobra.Command, args []string) error {
527		id, err := parseRunID(args[1])
528		if err != nil {
529			return err
530		}
531		c, err := env.client(cmd)
532		if err != nil {
533			return err
534		}
535		res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
536		if err != nil {
537			return err
538		}
539		if *asJSON {
540			return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
541		}
542		noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
543		return nil
544	}
545	return cmd
546}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close