Last active
November 21, 2019 17:22
-
-
Save mattpolzin/50bedc5ac935a308c38c22d22ef5945b to your computer and use it in GitHub Desktop.
Monitor a Docker Stack from Mac OS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/swift sh | |
// https://github.com/vapor/console-kit | |
import ConsoleKit // vapor/console-kit ~> 4.0.0-beta.1 | |
import Foundation | |
// MARK: - Shell | |
let shellSuccessCode: Int32 = 0 | |
struct ShellOut { | |
let status: Int32 | |
let stdout: String | |
var isSuccess: Bool { status == shellSuccessCode } | |
var toArray: [String] { stdout.split(separator: "\n").map(String.init) } | |
} | |
@discardableResult | |
func shell(_ command: String) -> ShellOut { | |
let task = Process() | |
task.launchPath = "/bin/bash" | |
task.arguments = ["-c", command] | |
let pipe = Pipe() | |
task.standardOutput = pipe | |
task.launch() | |
var data = Data() | |
var newData: Data | |
repeat { | |
newData = pipe.fileHandleForReading.availableData | |
data += newData | |
} while !newData.isEmpty | |
let output: String = String(data: data, encoding: .utf8)! | |
task.waitUntilExit() | |
return .init(status: task.terminationStatus, stdout: output) | |
} | |
// MARK: - Types | |
enum DockerForMac { | |
static var isRunning: Bool { | |
shell("pgrep -f Docker.app").isSuccess | |
} | |
} | |
struct DockerStack: Equatable { | |
let name: String | |
static let cmd: String = "docker stack ls --format \"{{ .Name }}\"" | |
static var all: [DockerStack] { | |
let stacks = shell(cmd).toArray | |
return stacks.map { .init(name: String($0)) } | |
} | |
} | |
struct DockerNode { | |
let status: Status | |
let availability: Availability | |
let managerStatus: ManagerStatus | |
static let cmd: String = "docker node ls --format \"{{ .Status }},{{ .Availability }},{{ .ManagerStatus }}\"" | |
static var all: [DockerNode] { | |
let nodes = shell(cmd).toArray | |
return nodes | |
.compactMap { node in | |
let values = node.split(separator: ",").map(String.init) | |
guard let status = Status(rawValue: values[0]), | |
let availability = Availability(rawValue: values[1]), | |
let managerStatus = values.count == 3 ? ManagerStatus(rawValue: values[2]) : .notManager else { | |
return nil | |
} | |
return .init( | |
status: status, | |
availability: availability, | |
managerStatus: managerStatus | |
) | |
} | |
} | |
var isActiveLeader: Bool { return availability == .active && managerStatus == .leader } | |
enum Status: String { | |
case ready = "Ready" | |
} | |
enum Availability: String { | |
case active = "Active" | |
case pause = "Pause" | |
case drain = "Drain" | |
} | |
enum ManagerStatus: String { | |
case leader = "Leader" | |
case reachable = "Reachable" | |
case unavailable = "Unavailable" | |
case notManager = "N/A" | |
} | |
} | |
struct DockerService { | |
let name: String | |
let stack: String | |
let desiredState: State | |
let currentState: CurrentState | |
let error: String? | |
static let psCmd: String = "docker service ps $(docker service ls -q) --no-trunc --format \"{{ .Name }},{{ .DesiredState }},{{ .CurrentState }},{{ .Error }}\"" | |
static let lsCmd: String = "docker service ls --format \"{{ .Name }},{{ .Replicas }}\"" | |
static var allInactive: [DockerService] { | |
let inactiveServices = shell(lsCmd).toArray | |
.filter { $0.contains("0/0") } // i.e. 0/0 replicas | |
return inactiveServices | |
.compactMap { service in | |
let values = service.split(separator: ",") | |
guard values.count == 2 else { return nil } | |
let (stack, name) = Self.name(from: values[0]) | |
return .init( | |
name: name, | |
stack: stack, | |
desiredState: .inactive, | |
currentState: .init(state: .inactive, timing: ""), | |
error: nil | |
) | |
} | |
} | |
static var allActive: [DockerService] { | |
let services = shell(psCmd).toArray | |
return services | |
.compactMap { service in | |
let values = service.split(separator: ",").map(String.init) | |
let (stack, name) = Self.name(from: values[0]) | |
guard let desiredState = State(rawValue: values[1]), | |
let currentState = CurrentState(rawValue: values[2]) else { | |
return nil | |
} | |
let error = (values.count < 4 || values[3].isEmpty) ? nil : values[3] | |
return .init( | |
name: name, | |
stack: stack, | |
desiredState: desiredState, | |
currentState: currentState, | |
error: error | |
) | |
} | |
} | |
private static func name<T: StringProtocol>(from dockerName: T) -> (stack: String, service: String) { | |
let fullName = String(dockerName.split(separator: ".")[0]).split(separator: "_", maxSplits: 1).map(String.init) | |
return (stack: fullName[0], service: fullName[1]) | |
} | |
var isWarning: Bool { | |
switch (desiredState, currentState.state) { | |
case (_, .pending): return true | |
default: return false | |
} | |
} | |
var isError: Bool { | |
currentState.state != .pending && !State.equivalent(desiredState, currentState.state) | |
} | |
var isRunning: Bool { | |
return currentState.state == .running | |
} | |
var consoleStyle: ConsoleStyle { | |
return isError | |
? .error | |
: isWarning | |
? .warning | |
: isRunning | |
? .info | |
: .success | |
} | |
var isSetup: Bool { | |
return name.contains("-setup") | |
} | |
var isInactive: Bool { | |
return desiredState == .inactive && currentState.state == .inactive | |
} | |
enum State: String { | |
case running = "Running" | |
case shutdown = "Shutdown" | |
case complete = "Complete" | |
case accepted = "Accepted" | |
case pending = "Pending" | |
case inactive = "0/0 Replicas" | |
static func equivalent(_ lhs: State, _ rhs: State) -> Bool { | |
return lhs == rhs | |
|| (lhs == .shutdown && rhs == .complete) | |
|| (rhs == .shutdown && lhs == .complete) | |
} | |
} | |
struct CurrentState: RawRepresentable, CustomStringConvertible { | |
let state: State | |
let timing: String | |
init?(rawValue: String) { | |
let splitString = rawValue.split(separator: " ", maxSplits: 1).map(String.init) | |
guard splitString.count == 2 else { return nil } | |
guard let state = State(rawValue: splitString[0]) else { return nil } | |
self.state = state | |
self.timing = splitString[1] | |
} | |
init(state: State, timing: String) { | |
self.state = state | |
self.timing = timing | |
} | |
var rawValue: String { | |
return "\(state.rawValue) \(timing)" | |
} | |
var description: String { rawValue } | |
} | |
} | |
struct DockerFailedContainer { | |
let serviceName: String | |
let stack: String | |
let exitCode: Int | |
let timing: String | |
static let cmd: String = "docker ps -a --format \"{{ .Names }},{{ .Status }}\" --filter \"status=exited\"" | |
static var all: [DockerFailedContainer] { | |
let containers = shell(cmd).toArray | |
let ret = containers | |
.compactMap { container -> DockerFailedContainer? in | |
let values = container.split(separator: ",") | |
guard values.count == 2 else { return nil } | |
let containerName = values[0] | |
.split(separator: ".", maxSplits: 1)[0] | |
.split(separator: "_", maxSplits: 1).map(String.init) | |
let (stack, serviceName) = (containerName[0], containerName[1]) | |
let exitAndTiming = values[1].split(maxSplits: 2) { "()".contains($0) }.map(String.init) | |
guard let exitCode = Int(exitAndTiming[1]) else { return nil } | |
guard exitCode != 0 else { return nil } | |
let timing = exitAndTiming[2] | |
return .init( | |
serviceName: serviceName, | |
stack: stack, | |
exitCode: exitCode, | |
timing: timing | |
) | |
} | |
return Array(ret) | |
} | |
} | |
// MARK: - Terminal Table | |
extension ConsoleText { | |
var length: Int { | |
return description.count | |
} | |
} | |
extension Int { | |
func `repeat`(char: Character) -> String { | |
let chars = [Character](repeating: char, count: self) | |
return String(chars) | |
} | |
} | |
extension Array where Element == ConsoleText { | |
var longest: Index { | |
var val = 0 | |
for row in self { | |
guard row.count > val else { continue } | |
val = row.count | |
} | |
return val | |
} | |
} | |
extension Array { | |
subscript(safe idx: Int) -> Element? { | |
guard idx < count else { return nil } | |
return self[idx] | |
} | |
} | |
class TerminalTable { | |
let topBottom: Character //" " or "-" (etc.) | |
let cornerChar: ConsoleText //" " or "+" (etc.) | |
let separator: ConsoleText //" " or "|" (etc.) | |
let rows: [[ConsoleText]] | |
init(rows: [[ConsoleText]], topBottomChar: Character = " ", cornerChar: Character = " ", verticalSeparatorChar: Character = " ") { | |
self.rows = rows | |
self.topBottom = topBottomChar | |
self.cornerChar = String(cornerChar).consoleText(.plain) | |
self.separator = String(verticalSeparatorChar).consoleText(.plain) | |
} | |
lazy var numberOfRows: Int = { | |
return rows.count | |
}() | |
lazy var numberOfColumns: Int = { | |
var longest = 0 | |
for row in rows { | |
guard row.count > longest else { continue } | |
longest = row.count | |
} | |
return longest | |
}() | |
func widthOfColumn(at idx: Int) -> Int { | |
var longest = 0 | |
for row in rows { | |
guard let column = row[safe: idx] else { continue } | |
guard column.length > longest else { continue } | |
longest = column.length | |
} | |
return longest + 2 // pad 1 each side | |
} | |
func drawTable() -> ConsoleText { | |
let lines = drawLines().map { $0 + "\n" } | |
var table: ConsoleText = "" | |
for line in lines { | |
table += line | |
} | |
return table | |
} | |
private func drawLines() -> [ConsoleText] { | |
var lines: [ConsoleText] = [] | |
let border = drawBorder() | |
lines.append(border) | |
lines += rows.map(drawRow) | |
lines.append(border) | |
return lines | |
} | |
func drawRow(with row: [ConsoleText]) -> ConsoleText { | |
var drawn: ConsoleText = separator | |
for idx in 0..<numberOfColumns { | |
let column = row[safe: idx] | |
let desiredWidth = widthOfColumn(at: idx) | |
var padded = column.flatMap { " " + $0 + " " } ?? " " | |
while padded.length < desiredWidth { | |
padded += " " | |
} | |
drawn += padded + separator | |
} | |
return drawn | |
} | |
func drawBorder() -> ConsoleText { | |
var columnPads: [ConsoleText] = [] | |
for i in 0..<numberOfColumns { | |
let width = widthOfColumn(at: i) | |
let pad = width.repeat(char: topBottom) | |
let text = pad.consoleText() | |
columnPads.append(text) | |
} | |
var border: ConsoleText = cornerChar | |
columnPads.forEach { pad in | |
border += pad | |
border += cornerChar | |
} | |
return border | |
} | |
} | |
func activeServices(predicate: (DockerService) -> Bool) -> [DockerService] { | |
return DockerService.allActive | |
.filter(predicate) | |
.sorted { $0.name < $1.name } | |
.sorted { $0.currentState.state.rawValue < $1.currentState.state.rawValue } | |
} | |
func serviceTable() -> TerminalTable? { | |
let titles = [ | |
"Service", | |
"Status", | |
"Time", | |
"Error" | |
].map { $0.consoleText(.plain) } | |
func consoleText(for service: DockerService) -> [ConsoleText] { | |
return [ | |
service.name.consoleText(.plain), | |
"\(service.currentState.state)".consoleText(service.consoleStyle), | |
service.currentState.timing.consoleText(.plain), | |
service.error?.consoleText(.error) | |
].compactMap { $0 } | |
} | |
let setupServices = activeServices { $0.isSetup } | |
.map(consoleText(for:)) | |
let runtimeServices = activeServices { !$0.isSetup } | |
.map(consoleText(for:)) | |
let inactiveServices = DockerService.allInactive | |
.map(consoleText(for:)) | |
guard setupServices.count + runtimeServices.count + inactiveServices.count > 0 else { return nil } | |
let emptyRow = [titles.map { _ in "".consoleText(.plain)}] | |
let spacerRow = [titles.map { _ in "-".consoleText(.plain)}] | |
let activeRows = setupServices | |
+ spacerRow | |
+ runtimeServices | |
return TerminalTable( | |
rows: [titles] | |
+ emptyRow | |
+ activeRows | |
+ spacerRow | |
+ inactiveServices | |
) | |
} | |
func failedContainerTable() -> TerminalTable? { | |
let titles = [ | |
"Service", | |
"Exit Code", | |
"Time" | |
].map { $0.consoleText(.plain) } | |
let containers = DockerFailedContainer.all.prefix(6) | |
.map { container in | |
[ | |
container.serviceName.consoleText(.plain), | |
"\(container.exitCode)".consoleText(.error), | |
container.timing.consoleText(.plain) | |
] | |
} | |
guard containers.count > 0 else { return nil } | |
return TerminalTable( | |
rows: [titles] | |
+ containers | |
) | |
} | |
extension Console { | |
func popPushEphemeral() { | |
popEphemeral() | |
pushEphemeral() | |
} | |
} | |
// MARK: - Monitor Command | |
final class MonitorCommand: Command { | |
struct Signature: CommandSignature { | |
init() { } | |
} | |
var help: String { | |
"Swarm Monitor" | |
} | |
func run(using context: CommandContext, signature: Signature) throws { | |
let frames = ["[- ]", "[ - ]", "[ - ]", "[ - ]", "[ -]", "[ - ]", "[ - ]", "[ - ]"].flatMap { Array(repeating: $0, count: 1) } | |
var frameIdx = 0 | |
context.console.pushEphemeral() | |
context.console.print("Starting...") | |
while true { | |
defer { | |
context.console.pushEphemeral() | |
context.console.print(frames[frameIdx]) | |
frameIdx = (frameIdx + 1) % frames.count | |
context.console.wait(seconds: 0.5) | |
context.console.popEphemeral() | |
} | |
// only talk to Docker every 2.0 seconds | |
guard frameIdx % 4 == 0 else { | |
continue | |
} | |
// check that Docker for Mac is running | |
guard DockerForMac.isRunning else { | |
context.console.popPushEphemeral() | |
context.console.print("Docker daemon not running. Start Docker for Mac and try again.") | |
continue | |
} | |
// check that at least one stack is running | |
let stacks = DockerStack.all | |
guard stacks.count > 0 else { | |
context.console.popPushEphemeral() | |
context.console.print("No Docker Stacks are running.") | |
continue | |
} | |
// check for nodes | |
let nodes = DockerNode.all | |
guard nodes.contains(where: { $0.isActiveLeader }) else { | |
context.console.popPushEphemeral() | |
context.console.error("No active leader node available.") | |
continue | |
} | |
let serviceTableText = serviceTable()?.drawTable() | |
let failedContainerTableText = failedContainerTable()?.drawTable() | |
context.console.popPushEphemeral() | |
context.console.print("---------------------------------------------") | |
context.console.output(" Key: ".consoleText(.plain), newLine: false) | |
context.console.output("Success ".consoleText(.success), newLine: false) | |
context.console.output("In Progress ".consoleText(.info), newLine: false) | |
context.console.output("Warning ".consoleText(.warning), newLine: false) | |
context.console.output("Error".consoleText(.error)) | |
context.console.print("---------------------------------------------") | |
if let serviceTable = serviceTableText { | |
context.console.output(serviceTable) | |
} | |
if let failedContainerTable = failedContainerTableText { | |
context.console.output(failedContainerTable) | |
} | |
} | |
} | |
} | |
// MARK: - Entrypoint | |
let console: Console = Terminal() | |
var input = CommandInput(arguments: CommandLine.arguments) | |
var config = CommandConfiguration() | |
config.use(MonitorCommand(), as: "monitor", isDefault: true) | |
do { | |
let commands = try config.resolve() | |
.group(help: "Swarm Monitor") | |
try console.run(commands, input: input) | |
} catch let error { | |
console.error(error.localizedDescription) | |
exit(1) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment