Packages

Query system, runtime and environment information

Current section

Files

Jump to
tinman src tinman.gleam
Raw

src/tinman.gleam

//// Tinman allows you to query information about the operating system, runtime
//// and environment.
////
//// It's useful for working around platform quirks or setting up global
//// resource limits at startup. It's **not** suitable as a monitoring library.
////
import gleam/bool
import gleam/int
import gleam/list
import gleam/result
import gleam/string
// -- TYPES -------------------------------------------------------------------
/// The operating system platform.
pub type OperatingSystem {
Linux
Macos
Windows
FreeBsd
OpenBsd
}
/// The CPU architecture.
pub type CpuArchitecture {
X64
Arm64
Arm
Riscv64
}
/// The JavaScript or Erlang runtime.
pub type Runtime {
Beam
AtomVm
Node
Deno
Bun
}
/// The C standard library implementation (Linux only).
pub type Libc {
Glibc
Musl
}
/// Byte order / endianness.
pub type Endianness {
BigEndian
LittleEndian
}
/// Container runtime
pub type ContainerRuntime {
Docker
Podman
Wsl
}
/// Version information.
pub type Version {
Version(major: Int, minor: Int, patch: Int)
}
// -- SYSTEM IDENTIFICATION ---------------------------------------------------
/// Returns the operating system platform.
/// Returns `Error(Nil)` if the platform cannot be determined or is unknown.
pub fn os() -> Result(OperatingSystem, Nil) {
case ffi_os() {
"linux" -> Ok(Linux)
"darwin" -> Ok(Macos)
"win32" -> Ok(Windows)
"freebsd" -> Ok(FreeBsd)
"openbsd" -> Ok(OpenBsd)
_ -> Error(Nil)
}
}
/// Returns whether the current platform is Windows.
@external(erlang, "tinman_ffi", "is_windows")
@external(javascript, "./tinman_ffi.mjs", "is_windows")
fn is_windows() -> Bool
@external(erlang, "tinman_ffi", "platform")
@external(javascript, "./tinman_ffi.mjs", "platform")
fn ffi_os() -> String
/// Returns the C standard library type (Linux only).
/// Returns `Error(Nil)` on non-Linux systems or if detection fails.
@external(erlang, "tinman_ffi", "libc_type")
pub fn libc() -> Result(Libc, Nil) {
case os() {
Ok(Linux) -> {
let output = string.lowercase(shell("ldd --version 2>&1"))
case string.contains(output, "musl") {
True -> Ok(Musl)
False -> Ok(Glibc)
}
}
Ok(_) | Error(_) -> Error(Nil)
}
}
/// Returns the operating system version.
/// Returns `Error(Nil)` if the version cannot be determined or parsed.
@external(javascript, "./tinman_ffi.mjs", "version")
pub fn os_version() -> Result(Version, Nil) {
case is_windows() {
True -> {
// On Windows, use PowerShell to get the actual OS version
// Erlang's os:version() returns kernel version which differs from actual Windows version
parse_version(powershell(
"[System.Environment]::OSVersion.Version.ToString()",
))
}
False -> ffi_os_version()
}
}
@external(erlang, "tinman_ffi", "version")
fn ffi_os_version() -> Result(Version, Nil)
/// Returns the hostname of the system.
@external(erlang, "tinman_ffi", "hostname")
@external(javascript, "./tinman_ffi.mjs", "hostname")
pub fn hostname() -> String
/// Returns the byte order of the system.
@external(erlang, "tinman_ffi", "endianness")
@external(javascript, "./tinman_ffi.mjs", "endianness")
pub fn endianness() -> Endianness
/// Returns the detected container runtime.
/// Detects Docker, Podman, and WSL environments.
/// Returns `Error(Nil)` if no container could be detected.
pub fn container_runtime() -> Result(ContainerRuntime, Nil) {
use <- bool.guard(when: exists("/.dockerenv"), return: Ok(Docker))
use <- bool.guard(when: exists("/run/.containerenv"), return: Ok(Podman))
let version = read("/proc/version") |> result.unwrap("") |> string.lowercase
use <- bool.guard(
when: string.contains(version, "microsoft")
|| string.contains(version, "wsl"),
return: Ok(Wsl),
)
Error(Nil)
}
// -- RUNTIME INFORMATION -----------------------------------------------------
/// Returns the current runtime (Erlang, Node, Deno, or Bun).
/// Returns `Error(Nil)` if the runtime cannot be determined.
pub fn runtime() -> Result(Runtime, Nil) {
case ffi_runtime() {
"BEAM" -> Ok(Beam)
"ATOM" -> Ok(AtomVm)
"node" -> Ok(Node)
"deno" -> Ok(Deno)
"bun" -> Ok(Bun)
_other -> Error(Nil)
}
}
@external(erlang, "tinman_ffi", "runtime")
@external(javascript, "./tinman_ffi.mjs", "runtime")
fn ffi_runtime() -> String
/// Returns the runtime version.
/// Returns `Error(Nil)` if the version cannot be determined or parsed.
///
/// > **Note:** On Erlang, this is the version of the Erlang runtime system (ERTS),
/// > not the version of the OTP release!
pub fn runtime_version() -> Result(Version, Nil) {
parse_version(ffi_runtime_version())
}
@external(erlang, "tinman_ffi", "runtime_version")
@external(javascript, "./tinman_ffi.mjs", "runtime_version")
fn ffi_runtime_version() -> String
/// Returns the runtimes operating system process ID.
@external(erlang, "tinman_ffi", "process_id")
@external(javascript, "./tinman_ffi.mjs", "process_id")
pub fn process_id() -> Int
// -- ENVIRONMENT -------------------------------------------------------------
/// Returns whether your program is running in a CI environment.
///
/// Checks for `CI` and `CONTINUOUS_INTEGRATION` environment variables.
pub fn is_ci() -> Bool {
env("CI") != "" || env("CONTINUOUS_INTEGRATION") != ""
}
/// Returns whether your program is running in an SSH session.
///
/// Checks for `SSH_CONNECTION`, `SSH_CLIENT`, and `SSH_TTY` environment variables.
pub fn is_in_ssh() -> Bool {
env("SSH_CONNECTION") != "" || env("SSH_CLIENT") != "" || env("SSH_TTY") != ""
}
/// Checks whether the program is running in an interactive terminal (TTY).
pub fn is_tty() -> Bool {
do_is_tty() && env("TERM") != "DUMB"
}
@external(erlang, "tinman_ffi", "is_tty")
@external(javascript, "./tinman_ffi.mjs", "is_tty")
fn do_is_tty() -> Bool
/// Returns whether the program is running in test mode.
///
/// Checks if the entrypoint module is `_test`, which is the case when running
/// with `gleam test`.
@external(erlang, "tinman_ffi", "is_test")
@external(javascript, "./tinman_ffi.mjs", "is_test")
pub fn is_test() -> Bool
/// Returns whether the system appears to be online.
///
/// Checks if there is an active non-loopback network interface available.
@external(erlang, "tinman_ffi", "is_online")
@external(javascript, "./tinman_ffi.mjs", "is_online")
pub fn is_online() -> Bool
// -- MEMORY INFORMATION ------------------------------------------------------
/// Returns the total available system memory in bytes. When running inside a
/// container, memory limits applied to the container are respected.
///
/// > *Note:* This information is not exposed by the Erlang runtime system,
/// > and has to be queried from the operating system in various ways.
/// > Doing so might be unexpectedly slow, especially on Windows!
@external(javascript, "./tinman_ffi.mjs", "total_memory")
pub fn total_memory() -> Result(Int, Nil) {
case os() {
Ok(Linux) -> {
case cgroup_memory_limit() {
Ok(limit) -> Ok(limit)
Error(_) -> meminfo("MemTotal:")
}
}
Ok(Macos) -> int(shell("sysctl -n hw.memsize"))
Ok(FreeBsd) | Ok(OpenBsd) -> int(shell("sysctl -n hw.physmem64"))
Ok(Windows) -> {
int(powershell(
"(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize * 1024",
))
}
Error(_) -> Error(Nil)
}
}
/// Returns the memory used by the runtime and your program.
@external(erlang, "tinman_ffi", "used_memory")
@external(javascript, "./tinman_ffi.mjs", "used_memory")
pub fn used_memory() -> Int
/// Returns the available system memory in bytes. When running inside a container,
/// memory limits applied to the container are respected.
///
/// > *Note:* This information is not exposed by the Erlang runtime system,
/// > and has to be queried from the operating system in various ways.
/// > Doing so might be unexpectedly slow, especially on Windows!
@external(javascript, "./tinman_ffi.mjs", "free_memory")
pub fn free_memory() -> Result(Int, Nil) {
case os() {
Ok(Linux) -> {
case cgroup_memory_limit(), cgroup_memory_usage() {
Ok(limit), Ok(usage) -> Ok(int.max(0, limit - usage))
Error(_), _ | _, Error(_) -> meminfo("MemAvailable:")
}
}
Ok(Macos) -> {
let cmd =
"vm_stat | awk 'NR==1{page_size=$8} /Pages free/{free=$3} /Pages inactive/{inactive=$3} END{print (free+inactive)*page_size}'"
int(shell(cmd))
}
Ok(FreeBsd) | Ok(OpenBsd) -> int(shell("sysctl -n hw.usermem64"))
Ok(Windows) -> {
int(powershell(
"(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory * 1024",
))
}
Error(_) -> Error(Nil)
}
}
fn cgroup_memory_limit() -> Result(Int, Nil) {
case cgroup("memory.max") {
Ok(val) -> Ok(val)
Error(_) -> cgroup("memory/memory.limit_in_bytes")
}
}
fn cgroup_memory_usage() -> Result(Int, Nil) {
case cgroup("memory.current") {
Ok(val) -> Ok(val)
Error(_) -> cgroup("memory/memory.usage_in_bytes")
}
}
fn cgroup(path: String) -> Result(Int, Nil) {
case read("/sys/fs/cgroup/" <> path) {
Ok(content) -> int(content)
Error(_) -> Error(Nil)
}
}
fn meminfo(key: String) -> Result(Int, Nil) {
use content <- result.try(read("/proc/meminfo"))
use rest <- result.try(list.last(string.split(content, key)))
use kb_str <- result.try(extract_column(rest, 1))
use kb <- result.try(int(kb_str))
Ok(kb * 1024)
}
// -- DISK INFORMATION --------------------------------------------------------
/// Returns the total disk space in bytes for the root/system drive.
/// Uses `df` on Unix-like systems and PowerShell on Windows.
/// Returns `Error(Nil)` if the information cannot be determined.
pub fn total_disk_space() -> Result(Int, Nil) {
case is_windows() {
True -> {
// Use PowerShell Get-PSDrive to get total disk space (Used + Free)
// Output: just the number in bytes
int(powershell("(Get-PSDrive C).Used + (Get-PSDrive C).Free"))
}
False -> {
let output = shell("df -k / | tail -n 1")
parse_df_column(output, 2)
}
}
}
/// Returns the free disk space in bytes for the root/system drive.
/// Uses `df` on Unix-like systems and PowerShell on Windows.
/// Returns `Error(Nil)` if the information cannot be determined.
pub fn free_disk_space() -> Result(Int, Nil) {
case is_windows() {
True -> {
// Use PowerShell Get-PSDrive to get free disk space
// Output: just the number in bytes
int(powershell("(Get-PSDrive C).Free"))
}
False -> {
// Use df -k to get free space in 1K blocks
let output = shell("df -k / | tail -n 1")
parse_df_column(output, 4)
}
}
}
fn parse_df_column(line: String, column: Int) -> Result(Int, Nil) {
// Parse df output line and extract the specified column (1-indexed)
// df output: "Filesystem 1K-blocks Used Available Use% Mounted on"
// "/dev/sda1 123456789 1234567 12345678 10% /"
// Parse column as KB and convert to bytes
use kb_str <- result.try(extract_column(line, column))
use kb <- result.try(int(kb_str))
Ok(kb * 1024)
}
// -- CPU INFORMATION ---------------------------------------------------------
/// Returns the CPU architecture.
/// Returns `Error(Nil)` if the architecture cannot be determined or is unknown.
pub fn cpu_architecture() -> Result(CpuArchitecture, Nil) {
let arch = case is_windows() {
True -> {
// On Windows, use PROCESSOR_ARCHITECTURE environment variable
// Erlang's system_info returns "win32" instead of the actual architecture
string.lowercase(env("PROCESSOR_ARCHITECTURE"))
}
False -> ffi_cpu_architecture()
}
case arch {
"x64" | "x86_64" | "amd64" -> Ok(X64)
"arm64" | "aarch64" -> Ok(Arm64)
"arm" -> Ok(Arm)
"riscv64" -> Ok(Riscv64)
_ -> Error(Nil)
}
}
@external(erlang, "tinman_ffi", "arch")
@external(javascript, "./tinman_ffi.mjs", "arch")
fn ffi_cpu_architecture() -> String
/// Returns the number of logical CPU cores.
/// Returns `Error(Nil)` if the CPU count cannot be determined.
///
/// > *Note:* Usually you want to use `available_parallelism` instead!
@external(erlang, "tinman_ffi", "cpu_count")
@external(javascript, "./tinman_ffi.mjs", "cpu_count")
pub fn cpu_count() -> Result(Int, Nil)
/// Returns the recommended level of parallelism.
///
/// This is the number of available schedulers on Erlang, or the number of
/// runtime threads on Javascript. Note that Javascript itself is always
/// single-threaded by default.
@external(erlang, "tinman_ffi", "available_parallelism")
@external(javascript, "./tinman_ffi.mjs", "available_parallelism")
pub fn available_parallelism() -> Int
// -- USER INFORMATION --------------------------------------------------------
/// Returns the current username.
/// Returns `Error(Nil)` if the username cannot be determined.
@external(javascript, "./tinman_ffi.mjs", "user_name")
pub fn user_name() -> String {
let whoami = string.trim(shell("whoami"))
case is_windows() {
True ->
case string.split(whoami, "\\") {
[_domain, name, ..] -> name
[name] -> name
[] -> whoami
}
False -> whoami
}
}
/// Returns the current user ID.
/// Returns `Error(Nil)` if the user ID cannot be determined.
@external(javascript, "./tinman_ffi.mjs", "user_id")
pub fn user_id() -> Result(Int, Nil) {
case is_windows() {
True -> Error(Nil)
False -> int(shell("id -u"))
}
}
/// Returns the current group ID.
/// Returns `Error(Nil)` if the group ID cannot be determined.
@external(javascript, "./tinman_ffi.mjs", "group_id")
pub fn group_id() -> Result(Int, Nil) {
case is_windows() {
True -> Error(Nil)
False -> int(shell("id -g"))
}
}
/// Returns whether the current user has administrator/root privileges.
/// On Unix-like systems, checks if the user ID is `0` (root).
/// On Windows, checks if we can successfully run a utility with elevated privileges.
pub fn is_admin() -> Bool {
case is_windows() {
True -> {
// Try to run 'fltmc' (Filter Manager Control) which requires admin privileges
let output = shell("fltmc >nul 2>&1 && echo OK")
string.contains(output, "OK")
}
False ->
case user_id() {
Ok(0) -> True
_ -> False
}
}
}
// -- PLATFORM CONSTANTS ------------------------------------------------------
/// Returns the end-of-line marker for the current platform.
/// `"\n"` on Unix-like systems, `"\r\n"` on Windows.
pub fn end_of_line() -> String {
case is_windows() {
True -> "\r\n"
False -> "\n"
}
}
/// Returns the path delimiter - the character used inside the `PATH` environment
/// variable to separate multiple paths - for the current platform.
/// `":"` on Unix-like systems, `";"` on Windows.
pub fn path_delimiter() -> String {
case is_windows() {
True -> ";"
False -> ":"
}
}
/// Returns the path separator - the character used inside paths to separate
/// files and directories - for the current platform.
/// `"/"` on Unix-like systems, `"\\"` on Windows.
pub fn path_separator() -> String {
case is_windows() {
True -> "\\"
False -> "/"
}
}
/// Returns the executable file extension for the current platform.
/// `".exe"` on Windows, `""` (empty string) on Unix-like systems.
pub fn executable_extension() -> String {
case is_windows() {
True -> ".exe"
False -> ""
}
}
// -- HELPER FUNCTIONS --------------------------------------------------------
fn int(s: String) -> Result(Int, Nil) {
int.parse(string.trim(s))
}
fn powershell(cmd: String) -> String {
// Try pwsh first (PowerShell Core), fall back to powershell (Windows PowerShell)
case
shell("pwsh -NoProfile -NonInteractive -Command \"" <> cmd <> "\" 2>nul")
{
"" ->
shell("powershell -NoProfile -NonInteractive -Command \"" <> cmd <> "\"")
output -> output
}
}
fn split_whitespace(text: String) -> List(String) {
text |> string.split(" ") |> list.filter(fn(s) { s != "" })
}
fn extract_column(line: String, column: Int) -> Result(String, Nil) {
// Extract the nth column (1-indexed) from a whitespace-separated line
line
|> split_whitespace
|> list.drop(column - 1)
|> list.first
}
@internal
pub fn parse_version(version_string: String) -> Result(Version, Nil) {
let version_string = case version_string {
"v" <> version_string -> version_string
other -> other
}
case string.split(version_string, on: ".") {
[major, minor, patch, ..] -> {
use major <- result.try(int.parse(major))
use minor <- result.try(int.parse(minor))
use patch <- result.try(int.parse(patch))
Ok(Version(major:, minor:, patch:))
}
[major, minor] -> {
use major <- result.try(int.parse(major))
use minor <- result.try(int.parse(minor))
Ok(Version(major:, minor:, patch: 0))
}
[major] -> {
use major <- result.try(int.parse(major))
Ok(Version(major:, minor: 0, patch: 0))
}
_ -> Error(Nil)
}
}
// -- FFI FUNCTIONS -----------------------------------------------------------
@external(erlang, "tinman_ffi", "os_cmd")
@external(javascript, "./tinman_ffi.mjs", "os_cmd")
fn shell(command: String) -> String
@external(erlang, "tinman_ffi", "read_file")
@external(javascript, "./tinman_ffi.mjs", "read_file")
fn read(path: String) -> Result(String, Nil)
@external(erlang, "tinman_ffi", "file_exists")
@external(javascript, "./tinman_ffi.mjs", "file_exists")
fn exists(path: String) -> Bool
@external(erlang, "tinman_ffi", "env")
@external(javascript, "./tinman_ffi.mjs", "env")
fn env(name: String) -> String