|
| 1 | +# Postgres in a Box. For Nim |
| 2 | +# |
| 3 | +# This package provides an easy way to run an embedded Postgres server |
| 4 | +# for testing and development purposes. It allows you to start, stop, |
| 5 | +# and manage a Postgres server instance without needing to install it |
| 6 | +# |
| 7 | +# Mainly inspired by https://github.com/fergusstrange/embedded-postgres |
| 8 | +# |
| 9 | +# (c) 2026 George Lemon | MIT License |
| 10 | +# Made by Humans from OpenPeeps |
| 11 | +# https://github.com/openpeeps/postgresbox |
| 12 | + |
| 13 | +import std/[tables, times, os, osproc, strutils, net] |
| 14 | + |
| 15 | +import pkg/semver |
| 16 | +import pkg/threading/channels |
| 17 | + |
| 18 | +type |
| 19 | + PostgresVersion* = enum |
| 20 | + v18 = "18.0.0" |
| 21 | + v17 = "17.5.0" |
| 22 | + v16 = "16.9.0" |
| 23 | + v15 = "15.13.0" |
| 24 | + v14 = "14.18.0" |
| 25 | + v13 = "13.21.0" |
| 26 | + |
| 27 | + # https://github.com/fergusstrange/embedded-postgres/blob/master/config.go#L11 |
| 28 | + |
| 29 | + PostgresBoxConfig* = ref object |
| 30 | + version: PostgresVersion |
| 31 | + # The version of Postgres to use. You can specify a specific |
| 32 | + # version like "16.4.0" or use a predefined version |
| 33 | + # from the PostgresVersion enum. |
| 34 | + port*: Port = Port(5432) |
| 35 | + # Default port for Postgres is 5432, but you |
| 36 | + # can specify a different one if needed. |
| 37 | + database: string |
| 38 | + # Name of the default database to create when Postgres starts. |
| 39 | + username: string |
| 40 | + # Username for the default database. Default is "postgres". |
| 41 | + password: string |
| 42 | + # Password for the default database user. Default is "postgres". |
| 43 | + basePath*: string |
| 44 | + # Base path for all Postgres-related files. This is the root |
| 45 | + # directory under which all other paths (cache, runtime, data, binaries) |
| 46 | + # will be organized. |
| 47 | + cachePath*: string = "cache" |
| 48 | + # Path to cache downloaded Postgres binaries. |
| 49 | + # Ensure this directory is writable. |
| 50 | + runtimePath*: string = "runtime" |
| 51 | + # Path to store runtime files like logs and sockets. |
| 52 | + # Ensure this directory is writable. |
| 53 | + dataPath*: string = "data" |
| 54 | + # Path to store Postgres data. Ensure this directory |
| 55 | + # is writable and has enough space. |
| 56 | + binariesPath*: string = "bin" |
| 57 | + # Path to the Postgres binaries. This should point |
| 58 | + # to the directory containing the Postgres executables. |
| 59 | + locale: string |
| 60 | + # Locale settings for the Postgres cluster. Default is "en_US.UTF-8". |
| 61 | + startParameters: Table[string, string] |
| 62 | + # Additional parameters to pass when starting Postgres, |
| 63 | + # such as shared_buffers, max_connections, etc. |
| 64 | + binaryRepositoryURL: string |
| 65 | + # URL to download Postgres binaries. Default is the Maven Central repository. |
| 66 | + startTimeout: Duration |
| 67 | + # Timeout for starting the Postgres server. Default is 30 seconds. |
| 68 | + logger: string # todo |
| 69 | + |
| 70 | + EmbeddedPostgres* = object |
| 71 | + config: PostgresBoxConfig |
| 72 | + started: bool |
| 73 | + |
| 74 | + GreskewelConfigError* = object of CatchableError |
| 75 | + |
| 76 | +var greskewChan = newChan[string]() |
| 77 | + |
| 78 | +proc getDefaultConfig*(version: PostgresVersion): PostgresBoxConfig = |
| 79 | + ## Returns a default configuration for the specified Postgres version. |
| 80 | + result = PostgresBoxConfig( |
| 81 | + version: PostgresVersion.v16, |
| 82 | + database: "postgres", |
| 83 | + username: "postgres", |
| 84 | + password: "postgres", |
| 85 | + locale: "en_US.UTF-8", |
| 86 | + startParameters: initTable[string, string](), |
| 87 | + dataPath: "data", |
| 88 | + binaryRepositoryURL: "https://repo1.maven.org/maven2/", |
| 89 | + startTimeout: initDuration(seconds = 30) |
| 90 | + ) |
| 91 | + |
| 92 | +proc initGreskewel*(config: PostgresBoxConfig = nil): EmbeddedPostgres = |
| 93 | + result = EmbeddedPostgres( |
| 94 | + config: |
| 95 | + if config != nil: config |
| 96 | + else: getDefaultConfig(PostgresVersion.v16), |
| 97 | + ) |
| 98 | + if result.config.binaryRepositoryURL.len == 0: |
| 99 | + result.config.binaryRepositoryURL = "https://repo1.maven.org/maven2/" |
| 100 | + |
| 101 | +# |
| 102 | +# Remote Controls |
| 103 | +# |
| 104 | +const |
| 105 | + binariesEndpoint = "io/zonky/test/postgres/embedded-postgres-binaries-$1-$2/$3/" |
| 106 | + jarBinaryEndpoint = "embedded-postgres-binaries-$1-$2-$3.jar" |
| 107 | + getCurrentOS* = |
| 108 | + when defined macosx: "darwin" |
| 109 | + else: system.hostOS |
| 110 | + binInitAppPath = "$1/$2/initdb" % [getCurrentOS, "bin"] |
| 111 | + # Note: The actual paths to the Postgres binaries may vary based on the version and platform. |
| 112 | + binPgCtlAppPath = "$1/$2/pg_ctl" % [getCurrentOS, "bin"] |
| 113 | + # Note: The actual paths to the Postgres binaries may vary based on the version and platform. |
| 114 | + |
| 115 | +proc downloadBinaries*(ep: var EmbeddedPostgres) = |
| 116 | + ## Download the Postgres binaries for the specified version and store them in the configured path. |
| 117 | + let jarFile = jarBinaryEndpoint % [getCurrentOS, hostCPU, $ep.config.version] |
| 118 | + let jarUrl = ep.config.binaryRepositoryURL & (binariesEndpoint % [getCurrentOS, hostCPU, $ep.config.version]) & jarFile |
| 119 | + |
| 120 | + # Download the jar file containing the Postgres binaries if it doesn't already exist |
| 121 | + if not fileExists(ep.config.basePath / ep.config.binariesPath / jarFile): |
| 122 | + echo "Downloading Postgres binaries from:\n", jarUrl |
| 123 | + echo execCmdEx("curl -L -o " & ep.config.basePath / ep.config.binariesPath / jarFile & " " & jarUrl) |
| 124 | + |
| 125 | + # unzip the jar file to extract the binaries |
| 126 | + createDir(ep.config.basePath / ep.config.binariesPath / $ep.config.version) |
| 127 | + let unzipPath = ep.config.basePath / ep.config.binariesPath / $ep.config.version / getCurrentOS |
| 128 | + if fileExists(ep.config.basePath / ep.config.binariesPath / jarFile) and not dirExists(unzipPath): |
| 129 | + echo "Unzipping Postgres binaries to:\n", unzipPath |
| 130 | + echo execCmdEx("unzip " & ep.config.basePath / ep.config.binariesPath / jarFile & " -d " & unzipPath) |
| 131 | + |
| 132 | + # extract the tar file to get the actual binaries |
| 133 | + let tarPath = unzipPath / "postgres-" & getCurrentOS & "-x86_64.txz" |
| 134 | + let isExtracted = dirExists(unzipPath / "bin") and dirExists(unzipPath / "lib") |
| 135 | + if fileExists(tarPath) and not isExtracted: |
| 136 | + echo "Extracting Postgres binaries from:\n", tarPath |
| 137 | + let status = execCmdEx("tar -xvf " & tarPath & " -C \"" & unzipPath & "\"") |
| 138 | + if status.exitCode != 0: |
| 139 | + raise newException(GreskewelConfigError, "Failed to extract Postgres binaries: " & status.output) |
| 140 | + |
| 141 | +# |
| 142 | +# Postgres Controls |
| 143 | +# |
| 144 | +proc encodeOptions(port: Port, parameters: Table[string, string]): string = |
| 145 | + var options = @["-p " & $port] |
| 146 | + for k, v in parameters: |
| 147 | + options.add("-c " & k & "=\"" & v & "\"") |
| 148 | + result = options.join(" ") |
| 149 | + |
| 150 | +proc init*(ep: var EmbeddedPostgres) = |
| 151 | + ## Initialize the embedded Postgres server with the specified configuration. |
| 152 | + echo "Initializing embedded Postgres version ", $ep.config.version |
| 153 | + if ep.config.basePath.len == 0 or ep.config.basePath.isAbsolute == false: |
| 154 | + raise newException(GreskewelConfigError, |
| 155 | + "Base path must be specified and absolute in the configuration.") |
| 156 | + if ep.config.dataPath.len == 0: |
| 157 | + raise newException(GreskewelConfigError, |
| 158 | + "Data path must be specified in the configuration.") |
| 159 | + if ep.config.binariesPath.len == 0: |
| 160 | + raise newException(GreskewelConfigError, |
| 161 | + "Binaries path must be specified in the configuration.") |
| 162 | + |
| 163 | + discard existsOrCreateDir(ep.config.basePath) |
| 164 | + discard existsOrCreateDir(ep.config.basePath / ep.config.runtimePath) |
| 165 | + discard existsOrCreateDir(ep.config.basePath / ep.config.binariesPath) |
| 166 | + |
| 167 | + let dataPath = ep.config.basePath / ep.config.dataPath |
| 168 | + if not dataPath.dirExists: |
| 169 | + # Initialize the Postgres data directory using the `initdb` binary |
| 170 | + let initDbApp = ep.config.basePath / ep.config.binariesPath / $ep.config.version / binInitAppPath |
| 171 | + if not initDbApp.fileExists(): |
| 172 | + raise newException(GreskewelConfigError, |
| 173 | + "`initdb` binary not found at expected path: " & initDbApp) |
| 174 | + let res = execCmdEx(initDbApp & " -D " & dataPath & " --username postgres") |
| 175 | + if res.exitCode != 0: |
| 176 | + raise newException(GreskewelConfigError, |
| 177 | + "Failed to initialize Postgres data directory: " & res.output) |
| 178 | + |
| 179 | +type |
| 180 | + PostgresThreadInfo = tuple |
| 181 | + channel: ptr Chan[string] |
| 182 | + dataPath: string |
| 183 | + binPath: string |
| 184 | + port: Port |
| 185 | + |
| 186 | +proc postgresThread(pg: PostgresThreadInfo) {.thread.} = |
| 187 | + # this thread will run a loop to listen for commands to start/stop the Postgres server |
| 188 | + var pgProc: Process |
| 189 | + var isRunning = false |
| 190 | + while true: |
| 191 | + var command: string |
| 192 | + if pg[0][].tryRecv(command): |
| 193 | + let pidPath = pg[1] / "postmaster.pid" |
| 194 | + if command == "pg.start": |
| 195 | + if not pidPath.fileExists(): |
| 196 | + let encodedOpts = encodeOptions(pg[3], initTable[string, string]()) |
| 197 | + let pgProc = startProcess(pg.binPath / binPgCtlAppPath, |
| 198 | + args = ["start", "-w", "-D", pg[1], "-o", encodedOpts]) |
| 199 | + elif command == "pg.stop" and pidPath.fileExists(): |
| 200 | + let res = execCmdEx(pg.binPath / binPgCtlAppPath & " stop -w -D " & pg[1]) |
| 201 | + # echo res |
| 202 | + sleep(100) # small delay to prevent busy waiting |
| 203 | + |
| 204 | +var worker: Thread[PostgresThreadInfo] |
| 205 | +proc start*(ep: var EmbeddedPostgres) = |
| 206 | + ## Start the embedded Postgres server |
| 207 | + let currentBinPath = ep.config.basePath / ep.config.binariesPath / $ep.config.version |
| 208 | + let dataPath = ep.config.basePath / ep.config.dataPath |
| 209 | + createThread(worker, postgresThread, (addr(greskewChan), dataPath, currentBinPath, ep.config.port)) |
| 210 | + sleep(1000) # wait a bit for the server to start |
| 211 | + greskewChan.send("pg.start") |
| 212 | + sleep(2000) # wait a bit for the server to start |
| 213 | + ep.started = true # todo: this should be set based on the actual status of the server, not just after sending the start command |
| 214 | + |
| 215 | +proc stop*(ep: var EmbeddedPostgres) = |
| 216 | + ## Stop the embedded Postgres server |
| 217 | + greskewChan.send("pg.stop") |
| 218 | + |
| 219 | +proc restart*(ep: var EmbeddedPostgres) = |
| 220 | + ## Restart the embedded Postgres server |
| 221 | + stop(ep) |
| 222 | + start(ep) |
| 223 | + |
| 224 | +proc status*(ep: EmbeddedPostgres): string = |
| 225 | + ## Get the status of the embedded Postgres server |
| 226 | + let cmd = @["pg_ctl", "status", "-D", ep.config.dataPath] |
| 227 | + result = staticExec(cmd.join(" ")) |
| 228 | + |
| 229 | +proc isRunning*(ep: EmbeddedPostgres): bool = |
| 230 | + ## Check if the embedded Postgres server is running |
| 231 | + result = ep.started |
| 232 | + |
| 233 | +proc getConnectionString*(ep: EmbeddedPostgres): string = |
| 234 | + ## Get the connection string for the embedded Postgres server |
| 235 | + result = "postgresql://" & ep.config.username & ":" & ep.config.password & |
| 236 | + "@localhost:" & $ep.config.port & "/" & ep.config.database |
| 237 | + |
| 238 | +proc getVersion*(ep: EmbeddedPostgres): PostgresVersion = |
| 239 | + ## Get the version of the embedded Postgres server |
| 240 | + result = ep.config.version |
| 241 | + |
| 242 | +proc getConfig*(ep: EmbeddedPostgres): PostgresBoxConfig = |
| 243 | + ## Get the configuration of the embedded Postgres server |
| 244 | + result = ep.config |
0 commit comments