Skip to content

Instantly share code, notes, and snippets.

@mattpolzin
Last active November 21, 2019 17:22
Show Gist options
  • Save mattpolzin/50bedc5ac935a308c38c22d22ef5945b to your computer and use it in GitHub Desktop.
Save mattpolzin/50bedc5ac935a308c38c22d22ef5945b to your computer and use it in GitHub Desktop.
Monitor a Docker Stack from Mac OS
#!/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