Support executing command in containers

This feature is only valid on single node since container API is not Swarmable
This commit is contained in:
cuigh 2018-06-14 18:41:22 +08:00
parent e025057893
commit 999b9ec5a1
16 changed files with 7670 additions and 38 deletions

28
Gopkg.lock generated
View File

@ -138,6 +138,32 @@
revision = "bb7a9ca6e4fbc2129e3db588a34bc970ffe811a9"
version = "v2.5.1"
[[projects]]
branch = "master"
name = "github.com/gobwas/httphead"
packages = ["."]
revision = "2c6c146eadee0b69f856f87e3e9f1d0cd6aad2f5"
[[projects]]
branch = "master"
name = "github.com/gobwas/pool"
packages = [
".",
"internal/pmath",
"pbufio",
"pbytes"
]
revision = "fa3125c39d7eca32e1387bb69b1b38dcb31b1e0b"
[[projects]]
branch = "master"
name = "github.com/gobwas/ws"
packages = [
".",
"wsutil"
]
revision = "b93773f50025fc1c14bbd7e97a3b170aae9a0977"
[[projects]]
name = "github.com/gogo/protobuf"
packages = ["proto"]
@ -236,6 +262,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "fe9c254e0c7e739370b6380ed53816426b8245a1610db2cfc612aec63aa01701"
inputs-digest = "d650099fe870caa619261ca2780130300821767813a6fb79199aaae1cc6e69f1"
solver-name = "gps-cdcl"
solver-version = 1

View File

@ -1,5 +1,9 @@
required = ["github.com/docker/distribution"]
[[override]]
branch = "master"
name = "github.com/gobwas/pool"
[[constraint]]
branch = "master"
name = "github.com/cuigh/auxo"

View File

@ -2837,45 +2837,64 @@ var Swirl;
})(Swirl || (Swirl = {}));
var Swirl;
(function (Swirl) {
var Service;
(function (Service) {
class LogsPage {
var Container;
(function (Container) {
class ExecPage {
constructor() {
this.refreshInterval = 3000;
this.$line = $("#txt-line");
this.$timestamps = $("#cb-timestamps");
this.$refresh = $("#cb-refresh");
this.$stdout = $("#txt-stdout");
this.$stderr = $("#txt-stderr");
this.$refresh.change(e => {
let elem = (e.target);
if (elem.checked) {
this.refreshData();
}
else if (this.timer > 0) {
window.clearTimeout(this.timer);
this.timer = 0;
}
});
this.refreshData();
this.$cmd = $("#txt-cmd");
this.$connect = $("#btn-connect");
this.$disconnect = $("#btn-disconnect");
this.$connect.click(this.connect.bind(this));
this.$disconnect.click(this.disconnect.bind(this));
Terminal.applyAddon(fit);
}
refreshData() {
let args = {
line: this.$line.val(),
timestamps: this.$timestamps.prop("checked"),
connect(e) {
this.$connect.hide();
this.$disconnect.show();
let url = location.host + location.pathname.substring(0, location.pathname.lastIndexOf("/")) + "/connect?cmd=" + encodeURIComponent(this.$cmd.val());
let ws = new WebSocket("ws://" + url);
ws.onopen = () => {
this.term = new Terminal();
this.term.on('data', (data) => {
if (ws.readyState == WebSocket.OPEN) {
ws.send(data);
}
});
this.term.open(document.getElementById('terminal-container'));
this.term.focus();
let width = Math.floor(($('#terminal-container').width() - 20) / 8.39);
let height = 30;
this.term.resize(width, height);
this.term.setOption('cursorBlink', true);
this.term.fit();
window.onresize = () => {
this.term.fit();
};
ws.onmessage = (e) => {
this.term.write(e.data);
};
ws.onerror = function (error) {
console.log("error: " + error);
};
ws.onclose = () => {
console.log("close");
};
};
$ajax.get('fetch_logs', args).json((r) => {
this.$stdout.val(r.stdout);
this.$stderr.val(r.stderr);
this.$stdout.get(0).scrollTop = this.$stdout.get(0).scrollHeight;
this.$stderr.get(0).scrollTop = this.$stderr.get(0).scrollHeight;
});
if (this.$refresh.prop("checked")) {
this.timer = setTimeout(this.refreshData.bind(this), this.refreshInterval);
this.ws = ws;
}
disconnect(e) {
if (this.ws && this.ws.readyState != WebSocket.CLOSED) {
this.ws.close();
}
if (this.term) {
this.term.destroy();
this.term = null;
}
this.$connect.show();
this.$disconnect.hide();
}
}
Service.LogsPage = LogsPage;
})(Service = Swirl.Service || (Swirl.Service = {}));
Container.ExecPage = ExecPage;
})(Container = Swirl.Container || (Swirl.Container = {}));
})(Swirl || (Swirl = {}));
//# sourceMappingURL=swirl.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,70 @@
///<reference path="../core/core.ts" />
namespace Swirl.Container {
export class ExecPage {
private $cmd: JQuery;
private $connect: JQuery;
private $disconnect: JQuery;
private ws: WebSocket;
private term: Terminal;
constructor() {
this.$cmd = $("#txt-cmd");
this.$connect = $("#btn-connect");
this.$disconnect = $("#btn-disconnect");
this.$connect.click(this.connect.bind(this));
this.$disconnect.click(this.disconnect.bind(this));
Terminal.applyAddon(fit);
}
private connect(e: JQueryEventObject) {
this.$connect.hide();
this.$disconnect.show();
let url = location.host + location.pathname.substring(0, location.pathname.lastIndexOf("/")) + "/connect?cmd=" + encodeURIComponent(this.$cmd.val());
let ws = new WebSocket("ws://" + url);
ws.onopen = () => {
this.term = new Terminal();
this.term.on('data', (data: any) => {
if (ws.readyState == WebSocket.OPEN) {
ws.send(data);
}
});
this.term.open(document.getElementById('terminal-container'));
this.term.focus();
let width = Math.floor(($('#terminal-container').width() - 20) / 8.39);
let height = 30;
this.term.resize(width, height);
this.term.setOption('cursorBlink', true);
this.term.fit();
window.onresize = () => {
this.term.fit();
};
ws.onmessage = (e) => {
this.term.write(e.data);
};
ws.onerror = function (error) {
console.log("error: " + error);
};
ws.onclose = () => {
console.log("close");
};
};
this.ws = ws;
}
private disconnect(e: JQueryEventObject) {
if (this.ws && this.ws.readyState != WebSocket.CLOSED) {
this.ws.close();
}
if (this.term) {
this.term.destroy();
this.term = null;
}
this.$connect.show();
this.$disconnect.hide();
}
}
}

53
assets/xterm/fit/fit.js Normal file
View File

@ -0,0 +1,53 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.fit = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function proposeGeometry(term) {
if (!term.element.parentElement) {
return null;
}
var parentElementStyle = window.getComputedStyle(term.element.parentElement);
var parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
var parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
var elementStyle = window.getComputedStyle(term.element);
var elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'))
};
var elementPaddingVer = elementPadding.top + elementPadding.bottom;
var elementPaddingHor = elementPadding.right + elementPadding.left;
var availableHeight = parentElementHeight - elementPaddingVer;
var availableWidth = parentElementWidth - elementPaddingHor - term.viewport.scrollBarWidth;
var geometry = {
cols: Math.floor(availableWidth / term.renderer.dimensions.actualCellWidth),
rows: Math.floor(availableHeight / term.renderer.dimensions.actualCellHeight)
};
return geometry;
}
exports.proposeGeometry = proposeGeometry;
function fit(term) {
var geometry = proposeGeometry(term);
if (geometry) {
if (term.rows !== geometry.rows || term.cols !== geometry.cols) {
term.renderer.clear();
term.resize(geometry.cols, geometry.rows);
}
}
}
exports.fit = fit;
function apply(terminalConstructor) {
terminalConstructor.prototype.proposeGeometry = function () {
return proposeGeometry(this);
};
terminalConstructor.prototype.fit = function () {
fit(this);
};
}
exports.apply = apply;
},{}]},{},[1])(1)
});
//# sourceMappingURL=fit.js.map

159
assets/xterm/xterm.css Normal file
View File

@ -0,0 +1,159 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
font-family: courier-new, courier, monospace;
font-feature-settings: "liga" 0;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 10;
}
.xterm .xterm-helper-textarea {
/*
* HACK: to fix IE's blinking cursor
* Move textarea out of the screen to the far left, so that the cursor is not visible.
*/
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -10;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm {
cursor: text;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer {
cursor: pointer;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 100;
color: transparent;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}

7071
assets/xterm/xterm.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@ import (
"bytes"
"context"
"io"
"strconv"
"strings"
"github.com/cuigh/swirl/misc"
"github.com/cuigh/swirl/model"
@ -111,3 +111,45 @@ func ContainerLogs(id string, line int, timestamps bool) (stdout, stderr *bytes.
}
return
}
// ContainerExecCreate creates an exec instance.
func ContainerExecCreate(id string, cmd string) (resp types.IDResponse, err error) {
err = mgr.Do(func(ctx context.Context, cli *client.Client) (err error) {
opts := types.ExecConfig{
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
//User: "root",
Cmd: strings.Split(cmd, " "),
}
//cli.DialSession()
resp, err = cli.ContainerExecCreate(ctx, id, opts)
return
})
return
}
// ContainerExecAttach attaches a connection to an exec process in the server.
func ContainerExecAttach(id string) (resp types.HijackedResponse, err error) {
err = mgr.Do(func(ctx context.Context, cli *client.Client) (err error) {
opts := types.ExecStartCheck{
Detach: false,
Tty: true,
}
resp, err = cli.ContainerExecAttach(ctx, id, opts)
return err
})
return
}
// ContainerExecStart starts an exec instance.
func ContainerExecStart(id string) error {
return mgr.Do(func(ctx context.Context, cli *client.Client) (err error) {
opts := types.ExecStartCheck{
Detach: false,
Tty: true,
}
return cli.ContainerExecStart(ctx, id, opts)
})
}

View File

@ -27,6 +27,8 @@ button.next: Next
button.import: Import
button.export: Export
button.more: More
button.connect: Connect
button.disconnect: Disconnect
# field
field.name: Name
@ -92,6 +94,7 @@ menu.edit: Edit
menu.log: Logs
menu.perm: Permission
menu.stats: Stats
menu.exec: Exec
# login page
login.title: Sign in to Swirl

View File

@ -27,6 +27,8 @@ button.next: 后一页
button.import: 导入
button.export: 导出
button.more: 更多
button.connect: 连接
button.disconnect: 断开
# field
field.name: 名称
@ -92,6 +94,7 @@ menu.edit: 编辑
menu.log: 日志
menu.perm: 权限
menu.stats: 状态
menu.exec: 执行
# login page
login.title: 登录到 Swirl

View File

@ -1,14 +1,18 @@
package controller
import (
"io"
"strings"
"github.com/cuigh/auxo/data"
"github.com/cuigh/auxo/log"
"github.com/cuigh/auxo/net/web"
"github.com/cuigh/auxo/util/cast"
"github.com/cuigh/swirl/biz/docker"
"github.com/cuigh/swirl/misc"
"github.com/cuigh/swirl/model"
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
)
// ContainerController is a controller of docker container
@ -19,6 +23,8 @@ type ContainerController struct {
Logs web.HandlerFunc `path:"/:id/logs" name:"container.logs" authorize:"!" desc:"container logs page"`
FetchLogs web.HandlerFunc `path:"/:id/fetch_logs" name:"container.fetch_logs" authorize:"?" desc:"fetch container logs"`
Delete web.HandlerFunc `path:"/delete" method:"post" name:"container.delete" authorize:"!" desc:"delete container"`
Exec web.HandlerFunc `path:"/:id/exec" name:"container.exec" authorize:"!" desc:"run a command in a running container"`
Connect web.HandlerFunc `path:"/:id/connect" name:"container.connect" authorize:"!" desc:"connect to a running container"`
}
// Container creates an instance of ContainerController
@ -28,8 +34,10 @@ func Container() (c *ContainerController) {
Detail: containerDetail,
Raw: containerRaw,
Logs: containerLogs,
Delete: containerDelete,
FetchLogs: containerFetchLogs,
Delete: containerDelete,
Exec: containerExec,
Connect: containerConnect,
}
}
@ -118,3 +126,103 @@ func containerDelete(ctx web.Context) error {
}
return ajaxSuccess(ctx, nil)
}
func containerExec(ctx web.Context) error {
id := ctx.P("id")
container, _, err := docker.ContainerInspectRaw(id)
if err != nil {
return err
}
m := newModel(ctx).Set("Container", container)
return ctx.Render("container/exec", m)
}
func containerConnect(ctx web.Context) error {
id := ctx.P("id")
_, _, err := docker.ContainerInspectRaw(id)
if err != nil {
return err
}
conn, _, _, err := ws.UpgradeHTTP(ctx.Request(), ctx.Response(), nil)
if err != nil {
return err
}
cmd := ctx.Q("cmd")
idResp, err := docker.ContainerExecCreate(id, cmd)
if err != nil {
return err
}
resp, err := docker.ContainerExecAttach(idResp.ID)
if err != nil {
return err
}
err = docker.ContainerExecStart(idResp.ID)
if err != nil {
return err
}
var (
closed = false
logger = log.Get("exec")
disposer = func() {
if !closed {
closed = true
conn.Close()
resp.Close()
}
}
)
// input
go func() {
defer disposer()
for {
msg, op, err := wsutil.ReadClientData(conn)
if err != nil {
if !closed {
logger.Error("Failed to read data from client: ", err)
}
break
}
if op == ws.OpClose {
break
}
_, err = resp.Conn.Write(msg)
if err != nil {
logger.Error("Failed to write data to container: ", err)
break
}
}
}()
// output
go func() {
defer disposer()
buf := make([]byte, 1024)
for {
n, err := resp.Reader.Read(buf)
if err == io.EOF {
break
} else if err != nil {
logger.Error("Failed to read data from container: ", err)
break
}
err = wsutil.WriteServerMessage(conn, ws.OpText, buf[:n])
if err != nil {
logger.Error("Failed to write data to client: ", err)
break
}
}
}()
return nil
}

View File

@ -37,6 +37,7 @@
<a class="navbar-item is-tab is-active" href="/container/{{.Container.ContainerJSONBase.ID}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/raw">{{ i18n("menu.raw") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/logs">{{ i18n("menu.log") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/exec">{{ i18n("menu.exec") }}</a>
</div>
</div>
</nav>

71
views/container/exec.jet Normal file
View File

@ -0,0 +1,71 @@
{{ extends "../_layouts/default" }}
{{ block style() }}
<link rel="stylesheet" href="/assets/xterm/xterm.css?v=3.3.0">
{{ end }}
{{ block script() }}
<script src="/assets/xterm/xterm.js?v=3.3.0"></script>
<script src="/assets/xterm/fit/fit.js?v=3.3.0"></script>
<script>$(() => new Swirl.Container.ExecPage())</script>
{{ end }}
{{ block body() }}
<section class="hero is-info">
<div class="hero-body">
<div class="container has-text-centered">
<h1 class="title is-2 is-uppercase">{{ i18n("container.title") }}</h1>
<h2 class="subtitle is-5">{{ i18n("container.description") }}</h2>
</div>
</div>
</section>
<div class="container">
<nav class="breadcrumb has-succeeds-separator is-small is-marginless" aria-label="breadcrumbs">
<ul>
<li><a href="/">{{ i18n("menu.home") }}</a></li>
<li><a href="/container/">{{ i18n("menu.container") }}</a></li>
<li><a>{{ i18n("menu.exec") }}</a></li>
</ul>
</nav>
</div>
<section class="hero is-small is-light">
<div class="hero-body">
<div class="container">
<h2 class="title is-2">
{{ .Container.ContainerJSONBase.Name }}
</h2>
</div>
</div>
</section>
<nav class="navbar has-shadow">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/raw">{{ i18n("menu.raw") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/logs">{{ i18n("menu.log") }}</a>
<a class="navbar-item is-tab is-active" href="/container/{{.Container.ContainerJSONBase.ID}}/exec">{{ i18n("menu.exec") }}</a>
</div>
</div>
</nav>
<section class="section">
<div class="container">
<div class="field has-addons">
<p class="control">
<a class="button is-static">Command</a>
</p>
<p class="control is-expanded">
<input id="txt-cmd" name="cmd" value="/bin/sh" class="input">
</p>
<p class="control">
<button id="btn-connect" class="button is-primary">{{ i18n("button.connect") }}</button>
<button id="btn-disconnect" class="button is-danger" style="display: none">{{ i18n("button.disconnect") }}</button>
</p>
</div>
<div id="terminal-container"></div>
</div>
</section>
{{ end }}

View File

@ -40,6 +40,7 @@
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/raw">{{ i18n("menu.raw") }}</a>
<a class="navbar-item is-tab is-active" href="/container/{{.Container.ContainerJSONBase.ID}}/logs">{{ i18n("menu.log") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/exec">{{ i18n("menu.exec") }}</a>
</div>
</div>
</nav>

View File

@ -42,6 +42,7 @@
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/detail">{{ i18n("menu.detail") }}</a>
<a class="navbar-item is-tab is-active" href="/container/{{.Container.ContainerJSONBase.ID}}/raw">{{ i18n("menu.raw") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/logs">{{ i18n("menu.log") }}</a>
<a class="navbar-item is-tab" href="/container/{{.Container.ContainerJSONBase.ID}}/exec">{{ i18n("menu.exec") }}</a>
</div>
</div>
</nav>