Compare commits

...

23 Commits

Author SHA1 Message Date
deac844d02 Add testing protocol. 2020-09-05 19:16:00 -05:00
80a3ba4621 Add more descriptive message for 404s. 2019-02-21 23:46:45 -06:00
Jonathan Bernard
774d0b446f Fix typo in active runs API, add functional test for same. 2018-12-26 23:43:44 -06:00
Jonathan Bernard
ee1147a1a5 Add configurability of the server port. 2018-12-23 18:20:22 -06:00
Jonathan Bernard
186b7d5b29 Fix /ping unit test (now /version) 2018-12-23 17:42:43 -06:00
Jonathan Bernard
52eaa63f25 Rework build configuration to take advantage of new built-in docker build capabilities. 2018-12-23 17:39:04 -06:00
Jonathan Bernard
e61fe3b01e Add functional tests for docker-based build configurations. 2018-12-23 16:08:36 -06:00
Jonathan Bernard
e83e64273b Minor fixes to unused test file. 2018-12-21 21:12:15 -06:00
Jonathan Bernard
b2d4df0aac WIP Upgrading to Nim 0.19. Getting docker pieces compiling.
* Addressing breaking changes in migration from Nim 0.18 to 0.19.
* Finishing the initial pass at the refactor required to include
  docker-based builds.
* Regaining confidence in the existing functionality by getting all
  tests passing again after docker introduction (still need new tests to
  cover new docker functionality).
2018-12-09 07:09:23 -06:00
Jonathan Bernard
c827beab5e WIP Adding native support for docker. 2017-12-02 20:47:26 -06:00
Jonathan Bernard
0574f0ec6a Include the version being built in BuildStatus objects. 2017-12-02 19:08:29 -06:00
Jonathan Bernard
ce7d4b60de Fix unit tests for latest changes to API. 2017-12-01 09:45:10 -06:00
Jonathan Bernard
2622877db5 Bump version number to 0.4.0 2017-12-01 07:11:17 -06:00
Jonathan Bernard
c6be698572 Artifact and log lookups. Bugfixes around failure scenarios.
* Added endopint to return the logs from a run.
* Refactored the `pathVar & "/" & pathvar` pattern into `pathVar / pathVar`
  using the `ospaths` module. Cleaner code, more resistant to extra `/` bugs.
* Added endpoints and core methods to list artifacts for a build, as well as to
  retrieve specific artifacts.
* Fixed a problem with the `complete` status being overloaded. The problem was
  that in the case of a multi-step build all of the prerequisite steps will
  return a state of `complete` which will get recorded in the status file for
  the run. The run will continue, but anyone watching the run state file (via
  the API for example) had no definitive way to tell the difference between a
  sub-step completing and the requested (last) step completing. This was caught
  in the functional tests (race condition based on when it polls for the run
  status). The fix was to introduce a new build state: `stepComplete`. The
  inner `doRun` procedure uses this instead of `complete`. Now the only place
  that `complete` is set is at the end of the original call to `run`, right
  before the worker terminates. It checks the last result (from the originally
  requested step) and if this result is `stepComplete` it "finishes" the build
  by setting the state to `complete`. Because this is the only place where
  `complete` is set, an observer is now guaranteed not to see `complete` until
  all steps have run successfully.
* Fixed a long-standing bug with the request handling logic in error cases
  (like requested resources not being available). Issue has something to do
  with the way that `except` blocks become special when in an async context.
  The main jester routes block invokes the handlers in an async context. The
  effect is that one `except` block is fine, but adding more than one (to catch
  different exception types, for example) causes the return type of the route
  handler to change and not match what the outer block is expecting (a Future).
  The fix here is to wrap any exception discrimination within a single outer
  except block, re-raise the exception, and catch it inside this new
  synchronous context. Ex:

  ```nim
    try: someCall(mayFail)
    except:
      try: raise getCurrentException()
      except ExceptionType1:
        # do whatever...
      except ExceptionType2:
        # do whatever
      except:
        # general catch-all
    return true
  ```

  The return at the end is also part of the story. Jester's match handler
  allows a route to defer making a decision about whether it matches. If you
  return true from a route block Jester accepts the result as a matched route.
  If you return false, Jester discards the result and looks for another
  matching route. Normally this is taken care of by the `resp` templates
  provided by Jester, but invoking those templates within the except blocks
  also causes problems, so we manually setup the response and `return true` to
  tell Jester that, yes this route matched, use this response.
* Moved the `/service/debug/ping` endpoint back to `/ping` and removed the
  debug-only fence. I envision this as being useful as a simple healthcheck URL.
2017-12-01 07:09:35 -06:00
Jonathan Bernard
07037616ac Fix test targets in build definition. 2017-12-01 02:38:36 -06:00
Jonathan Bernard
b85cf8b367 Hacky dependency pinning to get passing builds.
There is some bug building in the docker image we use to build the project with
the latest version of https://github.com/yglukhov/nim-jwt so I'm pinning it to
commit hash 549aa1eb13b8ddc0c6861d15cc2cc5b52bcbef01 for now. Later versions
add an ifdef branch to support libssl 1.1 but for some reason that ifdef is set
wrong and it tries to build against the 1.1 API even though the image only has
the 1.0 API. I'm crossing my fingers and hoping that our base image supports
libssl 1.1 before I need to update this library.
2017-12-01 02:12:22 -06:00
Jonathan Bernard
741124b734 Expirementing with building strawboss in a docker container. 2017-11-30 17:06:05 -06:00
Jonathan Bernard
a4e6a4cb81 Add simple CLI client based on cURL. 2017-11-30 12:26:21 -06:00
Jonathan Bernard
dcf82d8999 Add build step to the build configurationmake a zipped distributable version.
* Rename previous build step to `compile`
2017-11-30 12:18:55 -06:00
6556a86209 Planning for next features. 2017-11-27 08:09:24 -06:00
Jonathan Bernard
ff7f570ab1 Added systemd unit file. 2017-11-25 20:44:53 -06:00
Jonathan Bernard
d1f04951e5 Updating strawboss project definition so we can self-build. 2017-11-25 19:49:41 -06:00
Jonathan Bernard
f87dcc344b Added support for long-lived API keys. 2017-11-25 19:38:18 -06:00
21 changed files with 680 additions and 220 deletions

17
TODO.md
View File

@ -1,6 +1,11 @@
* Write a tool to convert JSON Schema into a human-readable format suitable for TODO
documentation. Should use the description, title, and other fields from the
JSON spec. Use this for writing the JSON schema docs instead of duplicating * Orchestration of docker containers for running builds.
the description of configuration files between JSON schema and the * Write API docs.
documentation. In other words, use the schemas as the single source of truth
and generate everything else from that. NICE TO HAVE
* Use/create some json-schema -> nim code generator to auto-generate json
handling code from schemas.
* Use some json-schema -> docs generator to document the API.
* Support unique UUID prefixes in URLs.

View File

@ -3,13 +3,12 @@ import cliutils, docopt, os, sequtils, strutils, tempfile, uuids
import strawbosspkg/configuration import strawbosspkg/configuration
import strawbosspkg/core import strawbosspkg/core
import strawbosspkg/server import strawbosspkg/server
import strawbosspkg/version
let SB_VER = "0.2.0"
proc logProcOutput*(outMsg, errMsg: TaintedString, cmd: string) = proc logProcOutput*(outMsg, errMsg: TaintedString, cmd: string) =
let prefix = if cmd != nil: cmd & ": " else: "" let prefix = if cmd.len > 0: cmd & ": " else: ""
if outMsg != nil: stdout.writeLine prefix & outMsg if outMsg.len > 0: stdout.writeLine prefix & outMsg
if errMsg != nil: stderr.writeLine prefix & errMsg if errMsg.len > 0: stderr.writeLine prefix & errMsg
when isMainModule: when isMainModule:
@ -18,14 +17,15 @@ Usage:
strawboss serve [options] strawboss serve [options]
strawboss run <requestFile> [options] strawboss run <requestFile> [options]
strawboss hashpwd <pwd> strawboss hashpwd <pwd>
strawboss api-key <username>
Options Options
-c --config-file <cfgFile> Use this config file instead of the default -c --config-file <cfgFile> Use this config file instead of the default
(strawboss.config.json). (strawboss.config.json).
""" """
let args = docopt(doc, version = "strawboss v" & SB_VER) let args = docopt(doc, version = "strawboss v" & SB_VERSION)
let cfgFile = if args["--config-file"]: $args["--config-file"] let cfgFile = if args["--config-file"]: $args["--config-file"]
else: "strawboss.config.json" else: "strawboss.config.json"
@ -49,7 +49,7 @@ Options
try: try:
if req.workspaceDir.isNilOrEmpty: req.workspaceDir = mkdtemp() if req.workspaceDir.len == 0: req.workspaceDir = mkdtemp()
let status = core.run(cfg, req, logProcOutput) let status = core.run(cfg, req, logProcOutput)
if status.state == BuildState.failed: raiseEx status.details if status.state == BuildState.failed: raiseEx status.details
@ -68,3 +68,6 @@ Options
echo pwd echo pwd
echo pwd[0..28] echo pwd[0..28]
elif args["api-key"]:
let sessionToken = server.makeApiKey(cfg, $args["<username>"])
echo sessionToken

View File

@ -1,4 +1,5 @@
import cliutils, logging, json, os, sequtils, strtabs, strutils, tables, times, uuids import cliutils, logging, json, os, sequtils, strtabs, strutils, tables, times,
unicode, uuids
from langutils import sameContents from langutils import sameContents
from typeinfo import toAny from typeinfo import toAny
@ -7,23 +8,22 @@ from strutils import parseEnum
const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:sszzz" const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:sszzz"
# Types # Types
#
type type
BuildState* {.pure.} = enum BuildState* {.pure.} = enum
queued, complete, failed, running, setup, rejected complete, failed, queued, rejected, running, setup, stepComplete
BuildStatus* = object BuildStatus* = object
runId*, details*: string runId*, details*, version*: string
state*: BuildState state*: BuildState
Step* = object Step* = object
name*, stepCmd*, workingDir*: string containerImage*, name*, stepCmd*, workingDir*: string
artifacts*, cmdInput*, depends*, expectedEnv*: seq[string] artifacts*, cmdInput*, depends*, expectedEnv*: seq[string]
dontSkip*: bool dontSkip*: bool
ProjectConfig* = object ProjectConfig* = object
name*: string containerImage*, name*, versionCmd*: string
versionCmd*: string
steps*: Table[string, Step] steps*: Table[string, Step]
ProjectDef* = object ProjectDef* = object
@ -33,7 +33,7 @@ type
RunRequest* = object RunRequest* = object
runId*: UUID runId*: UUID
projectName*, stepName*, buildRef*, workspaceDir*: string projectName*, stepName*, buildRef*, workspaceDir*: string
timestamp*: TimeInfo timestamp*: DateTime
forceRebuild*: bool forceRebuild*: bool
Run* = object Run* = object
@ -41,6 +41,10 @@ type
request*: RunRequest request*: RunRequest
status*: BuildStatus status*: BuildStatus
RunLogs* = object
runId*: UUID
stdout*, stderr*: seq[string]
User* = object User* = object
name*: string name*: string
hashedPwd*: string hashedPwd*: string
@ -54,6 +58,7 @@ type
debug*: bool debug*: bool
logLevel*: Level logLevel*: Level
pathToExe*: string pathToExe*: string
port*: int
projects*: seq[ProjectDef] projects*: seq[ProjectDef]
pwdCost*: int8 pwdCost*: int8
users*: seq[UserRef] users*: seq[UserRef]
@ -79,6 +84,7 @@ proc `==`*(a, b: StrawBossConfig): bool =
a.buildDataDir == b.buildDataDir and a.buildDataDir == b.buildDataDir and
a.authSecret == b.authSecret and a.authSecret == b.authSecret and
a.pwdCost == b.pwdCost and a.pwdCost == b.pwdCost and
a.port == b.port and
a.maintenancePeriod == b.maintenancePeriod and a.maintenancePeriod == b.maintenancePeriod and
a.logLevel == b.logLevel and a.logLevel == b.logLevel and
sameContents(a.users, b.users) and sameContents(a.users, b.users) and
@ -114,7 +120,7 @@ proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode =
# Configuration parsing code # Configuration parsing code
proc parseLogLevel*(level: string): Level = proc parseLogLevel*(level: string): Level =
let lvlStr = "lvl" & toUpper(level[0]) & level[1..^1] let lvlStr = "lvl" & toUpperAscii(level[0]) & level[1..^1]
result = parseEnum[Level](lvlStr) result = parseEnum[Level](lvlStr)
proc parseProjectDef*(pJson: JsonNode): ProjectDef = proc parseProjectDef*(pJson: JsonNode): ProjectDef =
@ -139,10 +145,11 @@ proc parseStrawBossConfig*(jsonCfg: JsonNode): StrawBossConfig =
result = StrawBossConfig( result = StrawBossConfig(
buildDataDir: jsonCfg.getIfExists("buildDataDir").getStr("build-data"), buildDataDir: jsonCfg.getIfExists("buildDataDir").getStr("build-data"),
authSecret: jsonCfg.getOrFail("authSecret", "strawboss config").getStr, authSecret: jsonCfg.getOrFail("authSecret", "strawboss config").getStr,
debug: jsonCfg.getIfExists("debug").getBVal(false), debug: jsonCfg.getIfExists("debug").getBool(false),
pwdCost: int8(jsonCfg.getOrFail("pwdCost", "strawboss config").getNum), port: int(jsonCfg.getIfExists("port").getInt(8180)),
pwdCost: int8(jsonCfg.getOrFail("pwdCost", "strawboss config").getInt),
projects: jsonCfg.getIfExists("projects").getElems.mapIt(parseProjectDef(it)), projects: jsonCfg.getIfExists("projects").getElems.mapIt(parseProjectDef(it)),
maintenancePeriod: int(jsonCfg.getIfExists("maintenancePeriod").getNum(10000)), maintenancePeriod: int(jsonCfg.getIfExists("maintenancePeriod").getInt(10000)),
logLevel: parseLogLevel(jsonCfg.getIfExists("logLevel").getStr("info")), logLevel: parseLogLevel(jsonCfg.getIfExists("logLevel").getStr("info")),
users: users) users: users)
@ -166,14 +173,15 @@ proc loadProjectConfig*(cfgFile: string): ProjectConfig =
var steps = initTable[string, Step]() var steps = initTable[string, Step]()
for sName, pJson in jsonCfg.getOrFail("steps", "project configuration").getFields: for sName, pJson in jsonCfg.getOrFail("steps", "project configuration").getFields:
steps[sName] = Step( steps[sName] = Step(
name: sName, name: sName,
workingDir: pJson.getIfExists("workingDir").getStr("."), workingDir: pJson.getIfExists("workingDir").getStr("."),
stepCmd: pJson.getIfExists("stepCmd").getStr("NOT GIVEN"), stepCmd: pJson.getIfExists("stepCmd").getStr("NOT GIVEN"),
depends: pJson.getIfExists("depends").getElems.mapIt(it.getStr), depends: pJson.getIfExists("depends").getElems.mapIt(it.getStr),
artifacts: pJson.getIfExists("artifacts").getElems.mapIt(it.getStr), artifacts: pJson.getIfExists("artifacts").getElems.mapIt(it.getStr),
cmdInput: pJson.getIfExists("cmdInput").getElems.mapIt(it.getStr), cmdInput: pJson.getIfExists("cmdInput").getElems.mapIt(it.getStr),
expectedEnv: pJson.getIfExists("expectedEnv").getElems.mapIt(it.getStr), expectedEnv: pJson.getIfExists("expectedEnv").getElems.mapIt(it.getStr),
dontSkip: pJson.getIfExists("dontSkip").getBVal(false)) containerImage: pJson.getIfExists("containerImage").getStr(""),
dontSkip: pJson.getIfExists("dontSkip").getBool(false))
# cmdInput and stepCmd are related, so we have a conditional defaulting. # cmdInput and stepCmd are related, so we have a conditional defaulting.
# Four possibilities: # Four possibilities:
@ -190,6 +198,7 @@ proc loadProjectConfig*(cfgFile: string): ProjectConfig =
result = ProjectConfig( result = ProjectConfig(
name: jsonCfg.getOrFail("name", "project configuration").getStr, name: jsonCfg.getOrFail("name", "project configuration").getStr,
containerImage: jsonCfg.getIfExists("containerImage").getStr(""),
versionCmd: jsonCfg.getIfExists("versionCmd").getStr("git describe --tags --always"), versionCmd: jsonCfg.getIfExists("versionCmd").getStr("git describe --tags --always"),
steps: steps) steps: steps)
@ -213,7 +222,7 @@ proc parseRunRequest*(reqJson: JsonNode): RunRequest =
buildRef: reqJson.getOrFail("buildRef", "RunRequest").getStr, buildRef: reqJson.getOrFail("buildRef", "RunRequest").getStr,
workspaceDir: reqJson.getOrFail("workspaceDir", "RunRequest").getStr, workspaceDir: reqJson.getOrFail("workspaceDir", "RunRequest").getStr,
timestamp: times.parse(reqJson.getOrFail("timestamp", "RunRequest").getStr, ISO_TIME_FORMAT), timestamp: times.parse(reqJson.getOrFail("timestamp", "RunRequest").getStr, ISO_TIME_FORMAT),
forceRebuild: reqJson.getOrFail("forceRebuild", "RunRequest").getBVal) forceRebuild: reqJson.getOrFail("forceRebuild", "RunRequest").getBool)
proc loadRunRequest*(reqFilePath: string): RunRequest = proc loadRunRequest*(reqFilePath: string): RunRequest =
if not existsFile(reqFilePath): if not existsFile(reqFilePath):
@ -255,6 +264,9 @@ proc `%`*(s: Step): JsonNode =
"expectedEnv": s.expectedEnv, "expectedEnv": s.expectedEnv,
"dontSkip": s.dontSkip } "dontSkip": s.dontSkip }
if s.containerImage.len > 0:
result["containerImage"] = %s.containerImage
proc `%`*(p: ProjectConfig): JsonNode = proc `%`*(p: ProjectConfig): JsonNode =
result = %* { result = %* {
"name": p.name, "name": p.name,
@ -264,6 +276,9 @@ proc `%`*(p: ProjectConfig): JsonNode =
for name, step in p.steps: for name, step in p.steps:
result["steps"][name] = %step result["steps"][name] = %step
if p.containerImage.len > 0:
result["containerImage"] = %p.containerImage
proc `%`*(req: RunRequest): JsonNode = proc `%`*(req: RunRequest): JsonNode =
result = %* { result = %* {
"runId": $(req.runId), "runId": $(req.runId),
@ -284,10 +299,11 @@ proc `%`*(cfg: StrawBossConfig): JsonNode =
"buildDataDir": cfg.buildDataDir, "buildDataDir": cfg.buildDataDir,
"authSecret": cfg.authSecret, "authSecret": cfg.authSecret,
"debug": cfg.debug, "debug": cfg.debug,
"port": cfg.port,
"projects": %cfg.projects, "projects": %cfg.projects,
"pwdCost": cfg.pwdCost, "pwdCost": cfg.pwdCost,
"maintenancePeriod": cfg.maintenancePeriod, "maintenancePeriod": cfg.maintenancePeriod,
"logLevel": toLower(($cfg.logLevel)[3]) & ($cfg.logLevel)[4..^1], "logLevel": toLowerAscii(($cfg.logLevel)[3]) & ($cfg.logLevel)[4..^1],
"users": %cfg.users } "users": %cfg.users }
proc `%`*(run: Run): JsonNode = proc `%`*(run: Run): JsonNode =
@ -296,8 +312,15 @@ proc `%`*(run: Run): JsonNode =
"request": %run.request, "request": %run.request,
"status": %run.status } "status": %run.status }
proc `%`*(logs: RunLogs): JsonNode =
result = %* {
"runId": $logs.runId,
"stdout": %logs.stdout,
"stderr": %logs.stderr }
proc `$`*(s: BuildStatus): string = result = pretty(%s) proc `$`*(s: BuildStatus): string = result = pretty(%s)
proc `$`*(req: RunRequest): string = result = pretty(%req) proc `$`*(req: RunRequest): string = result = pretty(%req)
proc `$`*(pd: ProjectDef): string = result = pretty(%pd) proc `$`*(pd: ProjectDef): string = result = pretty(%pd)
proc `$`*(cfg: StrawBossConfig): string = result = pretty(%cfg) proc `$`*(cfg: StrawBossConfig): string = result = pretty(%cfg)
proc `$`*(run: Run): string = result = pretty(%run) proc `$`*(run: Run): string = result = pretty(%run)
proc `$`*(logs: RunLogs): string = result = pretty(%logs)

View File

@ -1,9 +1,9 @@
import cliutils, logging, json, os, osproc, sequtils, streams, import cliutils, logging, json, os, ospaths, osproc, sequtils, streams,
strtabs, strutils, tables, times, uuids strtabs, strutils, tables, tempfile, times, uuids
import ./configuration import ./configuration
import nre except toSeq import nre except toSeq
from posix import link from posix import link, realpath
from algorithm import sorted from algorithm import sorted
type type
@ -27,7 +27,7 @@ type
projectName*: string projectName*: string
process*: Process process*: Process
NotFoundException = object of Exception NotFoundException* = object of Exception
proc newCopy(w: Workspace): Workspace = proc newCopy(w: Workspace): Workspace =
var newEnv: StringTableRef = newStringTable() var newEnv: StringTableRef = newStringTable()
@ -49,20 +49,64 @@ proc newCopy(w: Workspace): Workspace =
step: w.step, step: w.step,
version: w.version) version: w.version)
# Logging wrappers around const WKSP_ROOT = "/strawboss/wksp"
const ARTIFACTS_ROOT = "/strawboss/artifacts"
proc execWithOutput(wksp: Workspace, cmd, workingDir: string,
args: openarray[string], env: StringTableRef,
options: set[ProcessOption] = {poUsePath},
msgCB: HandleProcMsgCB = nil):
tuple[output: TaintedString, error: TaintedString, exitCode: int]
{.tags: [ExecIOEffect, ReadIOEffect, RootEffect] .} =
# Look for a container image to use
let containerImage =
if wksp.step.containerImage.len > 0: wksp.step.containerImage
else: wksp.project.containerImage
if containerImage.len == 0:
return execWithOutput(cmd, workingDir, args, env, options, msgCB)
var fullEnv = newStringTable(modeCaseSensitive)
for k,v in env: fullEnv[k] = v
var fullArgs = @["run", "-w", WKSP_ROOT, "-v", wksp.dir & ":" & WKSP_ROOT ]
if wksp.step.name.len == 0:
for depStep in wksp.step.depends:
fullArgs.add(["-v", ARTIFACTS_ROOT / depStep])
fullEnv[depStep & "_DIR"] = ARTIFACTS_ROOT / depStep
let envFile = mkstemp().name
writeFile(envFile, toSeq(fullEnv.pairs()).mapIt(it[0] & "=" & it[1]).join("\n"))
fullArgs.add(["--env-file", envFile])
fullArgs.add(containerImage)
fullArgs.add(cmd)
echo "Executing docker command: \n\t" & "docker " & $(fullArgs & @args)
return execWithOutput("docker", wksp.dir, fullArgs & @args, fullEnv, options, msgCB)
proc exec(w: Workspace, cmd, workingDir: string, args: openarray[string],
env: StringTableRef, options: set[ProcessOption] = {poUsePath},
msgCB: HandleProcMsgCB = nil): int
{.tags: [ExecIOEffect, ReadIOEffect, RootEffect] .} =
return execWithOutput(w, cmd, workingDir, args, env, options, msgCB)[2]
# Utility methods for Workspace activities # Utility methods for Workspace activities
proc sendStatusMsg(oh: HandleProcMsgCB, status: BuildStatus): void = proc sendStatusMsg(oh: HandleProcMsgCB, status: BuildStatus): void =
if not oh.isNil: if not oh.isNil:
oh.sendMsg($status.state & ": " & status.details, nil, "strawboss") oh.sendMsg($status.state & ": " & status.details, "", "strawboss")
proc sendMsg(w: Workspace, msg: TaintedString): void = proc sendMsg(w: Workspace, msg: TaintedString): void =
w.outputHandler.sendMsg(msg, nil, "strawboss") w.outputHandler.sendMsg(msg, "", "strawboss")
proc sendMsg(w: Workspace, l: Level, msg: TaintedString): void = proc sendMsg(w: Workspace, l: Level, msg: TaintedString): void =
if l >= w.logLevel: w.sendMsg(msg) if l >= w.logLevel: w.sendMsg(msg)
proc sendErrMsg(w: Workspace, msg: TaintedString): void = proc sendErrMsg(w: Workspace, msg: TaintedString): void =
w.outputHandler.sendMsg(nil, msg, "strawboss") w.outputHandler.sendMsg("", msg, "strawboss")
proc sendErrMsg(w: Workspace, l: Level, msg: TaintedString): void = proc sendErrMsg(w: Workspace, l: Level, msg: TaintedString): void =
if l >= w.logLevel: w.sendErrMsg(msg) if l >= w.logLevel: w.sendErrMsg(msg)
@ -79,30 +123,33 @@ proc publishStatus(wksp: Workspace, state: BuildState, details: string): void =
## Update the status for a Workspace and publish this status to the ## Update the status for a Workspace and publish this status to the
## Workspace's status file and any output message handlers. ## Workspace's status file and any output message handlers.
wksp.status = BuildStatus( wksp.status = BuildStatus(
runId: $wksp.runRequest.runId, state: state, details: details) runId: $wksp.runRequest.runId,
state: state,
details: details,
version: wksp.version)
# Write to our run directory, and to our version status # Write to our run directory, and to our version status
writeFile(wksp.buildDataDir & "/runs/" & writeFile(wksp.buildDataDir / "runs" /
$wksp.runRequest.runId & ".status.json", $wksp.status) $wksp.runRequest.runId & ".status.json", $wksp.status)
# If we have our step we can save status to the step status # If we have our step we can save status to the step status
if not wksp.step.name.isNilOrEmpty(): if wksp.step.name.len > 0:
let stepStatusDir = wksp.buildDataDir & "/status/" & wksp.step.name let stepStatusDir = wksp.buildDataDir / "status" / wksp.step.name
if not existsDir(stepStatusDir): createDir(stepStatusDir) if not existsDir(stepStatusDir): createDir(stepStatusDir)
writeFile(stepStatusDir & "/" & wksp.version & ".json", $wksp.status) writeFile(stepStatusDir / wksp.version & ".json", $wksp.status)
# If we were asked to build a ref that is not the version directly (like # If we were asked to build a ref that is not the version directly (like
# "master" or something), then let's also save our status under that name. # "master" or something), then let's also save our status under that name.
# We're probably overwriting a prior status, but that's OK. # We're probably overwriting a prior status, but that's OK.
if wksp.runRequest.buildRef != wksp.version: if wksp.runRequest.buildRef != wksp.version:
writeFile(stepStatusDir & "/" & wksp.runRequest.buildRef & ".json", writeFile(stepStatusDir / wksp.runRequest.buildRef & ".json",
$wksp.status) $wksp.status)
wksp.outputHandler.sendStatusMsg(wksp.status) wksp.outputHandler.sendStatusMsg(wksp.status)
proc ensureProjectDirsExist(cfg: StrawBossConfig, p: ProjectDef): void = proc ensureProjectDirsExist(cfg: StrawBossConfig, p: ProjectDef): void =
for subdir in ["configurations", "runs", "status", "artifacts"]: for subdir in ["configurations", "runs", "status", "artifacts"]:
let fullPath = cfg.buildDataDir & "/" & p.name & "/" & subdir let fullPath = cfg.buildDataDir / p.name / subdir
if not existsDir(fullPath): if not existsDir(fullPath):
createDir(fullPath) createDir(fullPath)
@ -112,9 +159,9 @@ proc getProject*(cfg: StrawBossConfig, projectName: string): ProjectDef =
## Get a project definition by name from the service configuration ## Get a project definition by name from the service configuration
let candidates = cfg.projects.filterIt(it.name == projectName) let candidates = cfg.projects.filterIt(it.name == projectName)
if candidates.len == 0: if candidates.len == 0:
raise newException(KeyError, "no project named " & projectName) raise newException(NotFoundException, "no project named " & projectName)
elif candidates.len > 1: elif candidates.len > 1:
raise newException(KeyError, "multiple projects named " & projectName) raise newException(NotFoundException, "multiple projects named " & projectName)
else: result = candidates[0] else: result = candidates[0]
proc setProject*(cfg: var StrawBossConfig, projectName: string, newDef: ProjectDef): void = proc setProject*(cfg: var StrawBossConfig, projectName: string, newDef: ProjectDef): void =
@ -136,23 +183,67 @@ proc listVersions*(cfg: StrawBossConfig, projectName: string): seq[string] =
ensureProjectDirsExist(cfg, project) ensureProjectDirsExist(cfg, project)
let versionFiles = filesMatching( let versionFiles = filesMatching(
cfg.buildDataDir & "/" & project.name & "/configurations/*.json") cfg.buildDataDir / project.name / "configurations/*.json")
result = versionFiles.map(proc(s: string): string = result = versionFiles.map(proc(s: string): string =
let slashIdx = s.rfind('/') let slashIdx = s.rfind('/')
result = s[(slashIdx + 1)..^6]) result = s[(slashIdx + 1)..^6])
proc getBuildStatus*(cfg: StrawBossConfig,
projectName, stepName, buildRef: string): BuildStatus =
let project = cfg.getProject(projectName)
let statusFile = cfg.buildDataDir / project.name / "status" /
stepName / buildRef & ".json"
if not existsFile(statusFile):
raise newException(NotFoundException,
stepName & " has never been built for " & projectName & "@" & buildRef)
result = loadBuildStatus(statusFile)
proc listArtifacts*(cfg: StrawBossConfig,
projectName, stepName, version: string): seq[string] =
## List the artifacts that have been built for a step.
let project = cfg.getProject(projectName)
ensureProjectDirsExist(cfg, project)
let buildStatus = cfg.getBuildStatus(projectName, stepName, version)
if buildStatus.state != BuildState.complete:
raise newException(NotFoundException, "step " & stepName &
" has never been successfully built for " & projectName & "@" & version)
result = filesMatching(
cfg.buildDataDir / project.name / "artifacts" / stepName / version / "*")
.mapIt(it.extractFilename)
proc getArtifactPath*(cfg: StrawBossConfig,
projectName, stepName, version, artifactName: string): string =
let artifacts = cfg.listArtifacts(projectName, stepName, version)
if not artifacts.contains(artifactName):
raise newException(NotFoundException, "no artifact named " &
artifactName & " exists for step " & stepName & " in project " &
projectName & "@" & version)
result = cfg.buildDataDir / projectName / "artifacts" / stepName / version / artifactName
proc existsRun*(cfg: StrawBossConfig, projectName, runId: string): bool = proc existsRun*(cfg: StrawBossConfig, projectName, runId: string): bool =
existsFile(cfg.buildDataDir & "/" & projectName & "/runs/" & runId & ".request.json") existsFile(cfg.buildDataDir / projectName / "runs" / runId & ".request.json")
proc getRun*(cfg: StrawBossConfig, projectName, runId: string): Run = proc getRun*(cfg: StrawBossConfig, projectName, runId: string): Run =
let project = cfg.getProject(projectName) let project = cfg.getProject(projectName)
let runsPath = cfg.buildDataDir & "/" & project.name & "/runs" let runsPath = cfg.buildDataDir / project.name / "runs"
try: result = Run( try: result = Run(
id: parseUUID(runId), id: parseUUID(runId),
request: loadRunRequest(runsPath & "/" & runId & ".request.json"), request: loadRunRequest(runsPath / runId & ".request.json"),
status: loadBuildStatus(runsPath & "/" & runId & ".status.json")) status: loadBuildStatus(runsPath / runId & ".status.json"))
except: raiseEx "unable to load run information for id " & runId except: raiseEx "unable to load run information for id " & runId
proc listRuns*(cfg: StrawBossConfig, projectName: string): seq[Run] = proc listRuns*(cfg: StrawBossConfig, projectName: string): seq[Run] =
@ -160,29 +251,25 @@ proc listRuns*(cfg: StrawBossConfig, projectName: string): seq[Run] =
let project = cfg.getProject(projectName) let project = cfg.getProject(projectName)
ensureProjectDirsExist(cfg, project) ensureProjectDirsExist(cfg, project)
let runsPath = cfg.buildDataDir & "/" & project.name & "/runs" let runsPath = cfg.buildDataDir / project.name / "runs"
let reqPaths = filesMatching(runsPath & "/*.request.json") let reqPaths = filesMatching(runsPath / "*.request.json")
result = reqPaths.map(proc(reqPath: string): Run = result = reqPaths.map(proc(reqPath: string): Run =
let runId = reqPath[(runsPath.len + 1)..^14] let runId = reqPath[(runsPath.len + 1)..^14]
result = Run( result = Run(
id: parseUUID(runId), id: parseUUID(runId),
request: loadRunRequest(reqPath), request: loadRunRequest(reqPath),
status: loadBuildStatus(runsPath & "/" & runId & ".status.json"))) status: loadBuildStatus(runsPath / runId & ".status.json")))
proc getBuildStatus*(cfg: StrawBossConfig,
projectName, stepName, buildRef: string): BuildStatus =
proc getLogs*(cfg: StrawBossConfig, projectname, runId: string): RunLogs =
let project = cfg.getProject(projectName) let project = cfg.getProject(projectName)
let runsPath = cfg.buildDataDir / project.name / "runs"
let statusFile = cfg.buildDataDir & "/" & project.name & "/status/" & try: result = RunLogs(
stepName & "/" & buildRef & ".json" runId: parseUUID(runId),
stdout: toSeq(lines(runsPath / runId & ".stdout.log")),
if not existsFile(statusFile): stderr: toSeq(lines(runsPath / runId & ".stderr.log")))
raise newException(NotFoundException, except: raiseEx "unable to load logs for run " & runId
stepName & " has never been built for reference '" & buildRef)
result = loadBuildStatus(statusFile)
proc getProjectConfig*(cfg: StrawBossConfig, proc getProjectConfig*(cfg: StrawBossConfig,
projectName, version: string): ProjectConfig = projectName, version: string): ProjectConfig =
@ -193,10 +280,10 @@ proc getProjectConfig*(cfg: StrawBossConfig,
# If they didn't give us a version, let try to figure out what is the latest one. # If they didn't give us a version, let try to figure out what is the latest one.
var confFilePath: string var confFilePath: string
if version.isNilOrEmpty: if version.len == 0:
let candidatePaths = filesMatching( let candidatePaths = filesMatching(
cfg.buildDataDir & "/" & project.name & "/configurations/*.json") cfg.buildDataDir / project.name / "configurations/*.json")
if candidatePaths.len == 0: if candidatePaths.len == 0:
raise newException(NotFoundException, raise newException(NotFoundException,
@ -212,8 +299,7 @@ proc getProjectConfig*(cfg: StrawBossConfig,
# If they did, let's try to load that # If they did, let's try to load that
else: else:
confFilePath = confFilePath =
cfg.buildDataDir & "/" & project.name & "/configurations/" & cfg.buildDataDir / project.name / "configurations" / version & ".json"
version & ".json"
if not existsFile(confFilePath): if not existsFile(confFilePath):
raise newException(NotFoundException, raise newException(NotFoundException,
@ -228,7 +314,7 @@ proc setupProject(wksp: Workspace) =
wksp.sendMsg(lvlDebug, "Setting up project.") wksp.sendMsg(lvlDebug, "Setting up project.")
# Clone the project into the $temp directory # Clone the project into the $temp directory
let cloneArgs = ["clone", wksp.projectDef.repo, wksp.dir] let cloneArgs = @["clone", wksp.projectDef.repo, wksp.dir]
wksp.sendMsg(lvlDebug, "git " & $cloneArgs) wksp.sendMsg(lvlDebug, "git " & $cloneArgs)
let cloneResult = exec("git", ".", cloneArgs, wksp.env, {poUsePath}, let cloneResult = exec("git", ".", cloneArgs, wksp.env, {poUsePath},
@ -238,7 +324,7 @@ proc setupProject(wksp: Workspace) =
raiseEx "unable to clone repo for '" & wksp.projectDef.name & "'" raiseEx "unable to clone repo for '" & wksp.projectDef.name & "'"
# Checkout the requested ref # Checkout the requested ref
let checkoutArgs = ["checkout", wksp.buildRef] let checkoutArgs = @["checkout", wksp.buildRef]
wksp.sendMsg(lvlDebug, "git " & $checkoutArgs) wksp.sendMsg(lvlDebug, "git " & $checkoutArgs)
let checkoutResult = exec("git", wksp.dir, checkoutArgs, let checkoutResult = exec("git", wksp.dir, checkoutArgs,
@ -249,7 +335,7 @@ proc setupProject(wksp: Workspace) =
" for '" & wksp.projectDef.name & "'" " for '" & wksp.projectDef.name & "'"
# Find the strawboss project configuration # Find the strawboss project configuration
let projCfgFile = wksp.dir & "/" & wksp.projectDef.cfgFilePath let projCfgFile = wksp.dir / wksp.projectDef.cfgFilePath
wksp.sendMsg(lvlDebug, "Looking for project configuration at '" & projCfgFile & "'") wksp.sendMsg(lvlDebug, "Looking for project configuration at '" & projCfgFile & "'")
if not existsFile(projCfgFile): if not existsFile(projCfgFile):
raiseEx "Cannot find strawboss project configuration in the project " & raiseEx "Cannot find strawboss project configuration in the project " &
@ -283,21 +369,20 @@ proc doStep*(wksp: Workspace, step: Step): BuildStatus =
wksp.step = step wksp.step = step
let artifactsDir = wksp.buildDataDir & "/artifacts/" & let artifactsDir = wksp.buildDataDir / "artifacts" / step.name / wksp.version
step.name & "/" & wksp.version
if not existsDir(artifactsDir): createDir(artifactsDir) if not existsDir(artifactsDir): createDir(artifactsDir)
# Have we tried to build this before and are we caching the results? # Have we tried to build this before and are we caching the results?
let statusFilePath = wksp.buildDataDir & "/status/" & step.name & let statusFilePath = wksp.buildDataDir / "status" / step.name /
"/" & wksp.version & ".json" wksp.version & ".json"
if existsFile(statusFilePath) and not step.dontSkip: if existsFile(statusFilePath) and not step.dontSkip:
let prevStatus = loadBuildStatus(statusFilePath) let prevStatus = loadBuildStatus(statusFilePath)
# If we succeeded last time, no need to rebuild # If we succeeded last time, no need to rebuild
if prevStatus.state == BuildState.complete: if prevStatus.state == BuildState.complete:
wksp.publishStatus(BuildState.complete, wksp.publishStatus(BuildState.stepComplete,
"Skipping step '" & step.name & "' for version '" & wksp.version & "Skipping step '" & step.name & "' for version '" & wksp.version &
"': already completed.") "': already completed.")
return wksp.status return wksp.status
@ -328,7 +413,7 @@ proc doStep*(wksp: Workspace, step: Step): BuildStatus =
# Run that step (may get skipped) # Run that step (may get skipped)
let runStatus = doStep(core.newCopy(wksp), depStep) let runStatus = doStep(core.newCopy(wksp), depStep)
if not (runStatus.state == BuildState.complete): if not (runStatus.state == BuildState.stepComplete):
raiseEx "dependent step failed: " & depStep.name raiseEx "dependent step failed: " & depStep.name
wksp.sendMsg(lvlDebug, "dependent step '" & depStep.name & wksp.sendMsg(lvlDebug, "dependent step '" & depStep.name &
@ -336,8 +421,8 @@ proc doStep*(wksp: Workspace, step: Step): BuildStatus =
# Add the artifacts directory for the dependent step to our env so that # Add the artifacts directory for the dependent step to our env so that
# further steps can reference it via $<stepname>_DIR # further steps can reference it via $<stepname>_DIR
wksp.env[depStep.name & "_DIR"] = wksp.buildDataDir & "/artifacts/" & wksp.env[depStep.name & "_DIR"] = wksp.buildDataDir / "artifacts" /
dep & "/" & wksp.version dep / wksp.version
# Run the step command, piping in cmdInput # Run the step command, piping in cmdInput
let stepCmd = wksp.resolveEnvVars(step.stepCmd) let stepCmd = wksp.resolveEnvVars(step.stepCmd)
@ -345,7 +430,7 @@ proc doStep*(wksp: Workspace, step: Step): BuildStatus =
else: stepCmd else: stepCmd
wksp.sendMsg step.name & ": starting stepCmd: " & stepCmd wksp.sendMsg step.name & ": starting stepCmd: " & stepCmd
let cmdProc = startProcess(stepCmd, let cmdProc = startProcess(stepCmd,
wksp.dir & "/" & step.workingDir, [], wksp.env, {poUsePath, poEvalCommand}) wksp.dir / step.workingDir, [], wksp.env, {poUsePath, poEvalCommand})
let cmdInStream = inputStream(cmdProc) let cmdInStream = inputStream(cmdProc)
@ -353,7 +438,7 @@ proc doStep*(wksp: Workspace, step: Step): BuildStatus =
for line in step.cmdInput: cmdInStream.writeLine(wksp.resolveEnvVars(line)) for line in step.cmdInput: cmdInStream.writeLine(wksp.resolveEnvVars(line))
cmdInStream.flush() cmdInStream.flush()
cmdInStream.close() cmdInStream.close()
let cmdResult = waitFor(cmdProc, wksp.outputHandler, cmdName) let cmdResult = waitFor(cmdProc, wksp.outputHandler, cmdName)
if cmdResult != 0: if cmdResult != 0:
@ -367,16 +452,16 @@ proc doStep*(wksp: Workspace, step: Step): BuildStatus =
let artifactName = artifactPath[(artifactPath.rfind("/")+1)..^1] let artifactName = artifactPath[(artifactPath.rfind("/")+1)..^1]
try: try:
wksp.sendMsg "copy " & wksp.sendMsg "copy " &
wksp.dir & "/" & step.workingDir & "/" & artifactPath & " -> " & wksp.dir / step.workingDir / artifactPath & " -> " &
artifactsDir & "/" & artifactName artifactsDir / artifactName
copyFileWithPermissions(wksp.dir & "/" & step.workingDir & "/" & copyFileWithPermissions(wksp.dir / step.workingDir / artifactPath,
artifactPath, artifactsDir & "/" & artifactName) artifactsDir / artifactName)
except: except:
raiseEx "step " & step.name & " failed: unable to copy artifact " & raiseEx "step " & step.name & " failed: unable to copy artifact " &
artifactPath & ":\n" & getCurrentExceptionMsg() artifactPath & ":\n" & getCurrentExceptionMsg()
wksp.publishStatus(BuildState.complete, "") wksp.publishStatus(BuildState.stepComplete, "step " & step.name & " complete")
result = wksp.status result = wksp.status
proc run*(cfg: StrawBossConfig, req: RunRequest, proc run*(cfg: StrawBossConfig, req: RunRequest,
@ -388,7 +473,8 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
result = BuildStatus( result = BuildStatus(
runId: $req.runId, runId: $req.runId,
state: BuildState.setup, state: BuildState.setup,
details: "initializing build workspace") details: "initializing build workspace",
version: "")
outputHandler.sendStatusMsg(result) outputHandler.sendStatusMsg(result)
var wksp: Workspace var wksp: Workspace
@ -401,8 +487,8 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
ensureProjectDirsExist(cfg, projectDef) ensureProjectDirsExist(cfg, projectDef)
# Update our run status # Update our run status
let runDir = cfg.buildDataDir & "/" & projectDef.name & "/runs" let runDir = cfg.buildDataDir / projectDef.name / "runs"
writeFile(runDir & "/" & $req.runId & ".status.json", $result) writeFile(runDir / $req.runId & ".status.json", $result)
# Read in the existing system environment # Read in the existing system environment
var env = loadEnv() var env = loadEnv()
@ -413,15 +499,15 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
if not existsDir(req.workspaceDir): createDir(req.workspaceDir) if not existsDir(req.workspaceDir): createDir(req.workspaceDir)
# Setup our STDOUT and STDERR files # Setup our STDOUT and STDERR files
let stdoutFile = open(runDir & "/" & $req.runId & ".stdout.log", fmWrite) let stdoutFile = open(runDir / $req.runId & ".stdout.log", fmWrite)
let stderrFile = open(runDir & "/" & $req.runId & ".stderr.log", fmWrite) let stderrFile = open(runDir / $req.runId & ".stderr.log", fmWrite)
let logFilesOH = makeProcMsgHandler(stdoutFile, stderrFile) let logFilesOH = makeProcMsgHandler(stdoutFile, stderrFile)
wksp = Workspace( wksp = Workspace(
buildDataDir: cfg.buildDataDir & "/" & projectDef.name, buildDataDir: cfg.buildDataDir / projectDef.name,
buildRef: buildRef:
if req.buildRef != nil and req.buildRef.len > 0: req.buildRef if req.buildRef.len > 0: req.buildRef
else: projectDef.defaultBranch, else: projectDef.defaultBranch,
dir: req.workspaceDir, dir: req.workspaceDir,
env: env, env: env,
@ -433,12 +519,12 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
runRequest: req, runRequest: req,
status: result, status: result,
step: Step(), step: Step(),
version: nil) version: "")
except: except:
when not defined(release): echo getCurrentException().getStackTrace() when not defined(release): echo getCurrentException().getStackTrace()
result = BuildStatus(runId: $req.runId, state: BuildState.failed, result = BuildStatus(runId: $req.runId, state: BuildState.failed,
details: getCurrentExceptionMsg()) details: getCurrentExceptionMsg(), version: "")
try: outputHandler.sendStatusMsg(result) try: outputHandler.sendStatusMsg(result)
except: discard "" except: discard ""
return return
@ -452,8 +538,8 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
# Update our cache of project configurations. # Update our cache of project configurations.
# TODO: what happens if this fails? # TODO: what happens if this fails?
copyFileWithPermissions( copyFileWithPermissions(
wksp.dir & "/" & wksp.projectDef.cfgFilePath, wksp.dir / wksp.projectDef.cfgFilePath,
wksp.buildDataDir & "/configurations/" & wksp.version & ".json") wksp.buildDataDir / "configurations" / wksp.version & ".json")
# Find the requested step # Find the requested step
if not wksp.project.steps.hasKey(req.stepName): if not wksp.project.steps.hasKey(req.stepName):
@ -462,7 +548,12 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
if req.forceRebuild: step.dontSkip = true if req.forceRebuild: step.dontSkip = true
result = doStep(wksp, step) var buildStatus = doStep(wksp, step)
if buildStatus.state == BuildState.stepComplete:
buildStatus.state = BuildState.complete
wksp.publishStatus(buildStatus.state, "all steps complete")
result = wksp.status
except: except:
when not defined(release): echo getCurrentException().getStackTrace() when not defined(release): echo getCurrentException().getStackTrace()
@ -471,25 +562,26 @@ proc run*(cfg: StrawBossConfig, req: RunRequest,
wksp.publishStatus(BuildState.failed, msg) wksp.publishStatus(BuildState.failed, msg)
result = wksp.status result = wksp.status
except: except:
result = BuildStatus(runId: $req.runId, state: BuildState.failed, details: msg) result = BuildStatus(runId: $req.runId, state: BuildState.failed,
details: msg, version: "")
try: outputHandler.sendStatusMsg(result) try: outputHandler.sendStatusMsg(result)
except: discard "" except: discard ""
finally: finally:
if wksp != nil: if wksp != nil:
# Close open files # Close open files
for f in wksp.openedFiles: for f in wksp.openedFiles:
try: close(f) try: close(f)
except: discard "" except: discard ""
proc spawnWorker*(cfg: StrawBossConfig, req: RunRequest): proc spawnWorker*(cfg: StrawBossConfig, req: RunRequest):
tuple[status: BuildStatus, worker: Worker] = tuple[status: BuildStatus, worker: Worker] =
# Find the project definition (will throw appropriate exceptions) # Find the project definition (will throw appropriate exceptions)
let projectDef = cfg.getProject(req.projectName) let projectDef = cfg.getProject(req.projectName)
let runDir = cfg.buildDataDir & "/" & projectDef.name & "/runs" let runDir = cfg.buildDataDir / projectDef.name / "runs"
let reqFile = runDir & "/" & $req.runId & ".request.json" let reqFile = runDir / $req.runId & ".request.json"
let statusFile = runDir & "/" & $req.runId & ".status.json" let statusFile = runDir / $req.runId & ".status.json"
try: try:
# Make sure the build data directories for this project exist. # Make sure the build data directories for this project exist.
@ -502,7 +594,8 @@ proc spawnWorker*(cfg: StrawBossConfig, req: RunRequest):
let queuedStatus = BuildStatus( let queuedStatus = BuildStatus(
runId: $req.runId, runId: $req.runId,
state: BuildState.queued, state: BuildState.queued,
details: "request queued for execution") details: "request queued for execution",
version: "")
writeFile(statusFile, $queuedStatus) writeFile(statusFile, $queuedStatus)
var args = @["run", reqFile, "-c", cfg.filePath] var args = @["run", reqFile, "-c", cfg.filePath]
@ -517,8 +610,9 @@ proc spawnWorker*(cfg: StrawBossConfig, req: RunRequest):
except: except:
let exMsg = "run request rejected: " & getCurrentExceptionMsg() let exMsg = "run request rejected: " & getCurrentExceptionMsg()
raiseEx exMsg
try: try:
writeFile(statusFile, writeFile(statusFile,
$(BuildStatus(runId: $req.runId, state: BuildState.rejected, details: exMsg))) $(BuildStatus(runId: $req.runId, state: BuildState.rejected,
details: exMsg, version: "")))
except: discard "" except: discard ""
raiseEx exMsg

View File

@ -1,7 +1,13 @@
import asyncdispatch, bcrypt, cliutils, jester, json, jwt, logging, import asyncdispatch, bcrypt, cliutils, jester, json, jwt, logging, md5,
os, osproc, sequtils, strutils, tempfile, times, unittest, uuids options, os, osproc, sequtils, strutils, tempfile, times, unittest, uuids
import ./configuration, ./core from mimetypes import getMimeType
from asyncfile import openAsync, readToStream, close
from asyncnet import send
from re import re, find
from timeutils import trimNanoSec
import ./configuration, ./core, ./version
type type
Session = object Session = object
@ -11,18 +17,41 @@ type
#const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" #const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
const JSON = "application/json" const JSON = "application/json"
proc makeJsonResp(status: HttpCode, details: string = ""): string =
result = $(%* {
"statusCode": status.int,
"status": $status,
"details": details
})
proc newSession*(user: UserRef): Session = proc newSession*(user: UserRef): Session =
result = Session( result = Session(
user: user, user: user,
issuedAt: getTime(), issuedAt: getTime().local.trimNanoSec.toTime,
expires: daysForward(7).toTime()) expires: daysForward(7).trimNanoSec.toTime)
template halt(code: HttpCode,
headers: RawHeaders,
content: string): typed =
## Immediately replies with the specified request. This means any further
## code will not be executed after calling this template in the current
## route.
bind TCActionSend, newHttpHeaders
result[0] = CallbackAction.TCActionSend
result[1] = code
result[2] = some(headers)
result[3] = content
result.matched = true
break allRoutes
template jsonResp(code: HttpCode, details: string = "", headers: RawHeaders = @{:} ) =
halt(
code,
headers & @{"Content-Type": JSON},
$(%* {
"statusCode": code.int,
"status": $code,
"details": details
})
)
template json500Resp(ex: ref Exception, details: string = ""): void =
when not defined(release): debug ex.getStackTrace()
error details & ":\n" & ex.msg
jsonResp(Http500)
proc toJWT*(cfg: StrawBossConfig, session: Session): string = proc toJWT*(cfg: StrawBossConfig, session: Session): string =
## Make a JST token for this session. ## Make a JST token for this session.
@ -30,8 +59,8 @@ proc toJWT*(cfg: StrawBossConfig, session: Session): string =
header: JOSEHeader(alg: HS256, typ: "jwt"), header: JOSEHeader(alg: HS256, typ: "jwt"),
claims: toClaims(%*{ claims: toClaims(%*{
"sub": session.user.name, "sub": session.user.name,
"iat": session.issuedAt.toSeconds().int, "iat": session.issuedAt.toUnix.int,
"exp": session.expires.toSeconds().int })) "exp": session.expires.toUnix.int }))
jwt.sign(cfg.authSecret) jwt.sign(cfg.authSecret)
result = $jwt result = $jwt
@ -50,8 +79,8 @@ proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session =
result = Session( result = Session(
user: users[0], user: users[0],
issuedAt: fromSeconds(jwt.claims["iat"].node.num), issuedAt: fromUnix(jwt.claims["iat"].node.num),
expires: fromSeconds(jwt.claims["exp"].node.num)) expires: fromUnix(jwt.claims["exp"].node.num))
proc extractSession(cfg: StrawBossConfig, request: Request): Session = proc extractSession(cfg: StrawBossConfig, request: Request): Session =
## Helper to extract a session from a reqest. ## Helper to extract a session from a reqest.
@ -79,7 +108,7 @@ proc makeAuthToken*(cfg: StrawBossConfig, uname, pwd: string): string =
## Given a username and pwd, validate the combination and generate a JWT ## Given a username and pwd, validate the combination and generate a JWT
## token string. ## token string.
if uname == nil or pwd == nil: if uname.len == 0 or pwd.len == 0:
raiseEx "fields 'username' and 'password' required" raiseEx "fields 'username' and 'password' required"
# find the user record # find the user record
@ -89,24 +118,42 @@ proc makeAuthToken*(cfg: StrawBossConfig, uname, pwd: string): string =
let user = users[0] let user = users[0]
if not validatePwd(user, pwd): raiseEx "invalid username or password" if not validatePwd(user, pwd): raiseEx "invalid username or password"
result = toJWT(cfg, newSession(user))
let session = newSession(user)
result = toJWT(cfg, session)
proc makeApiKey*(cfg: StrawBossConfig, uname: string): string =
## Given a username, make an API token (JWT token string that does not
## expire). Note that this does not validate the username/pwd combination. It
## is not intended to be exposed publicly via the API, but serve as a utility
## function for an administrator to setup a unsupervised account (git access
## for example).
if uname.len == 0: raiseEx "no username given"
# find the user record
let users = cfg.users.filterIt(it.name == uname)
if users.len != 1: raiseEx "invalid username"
let session = Session(
user: users[0],
issuedAt: getTime(),
expires: daysForward(365 * 1000).toTime())
result = toJWT(cfg, session);
template checkAuth() = template checkAuth() =
## Check this request for authentication and authorization information. ## Check this request for authentication and authorization information.
## Injects two variables into the running context: the session and authed: ## Injects the session into the running context. If the request is not
## true if the request is authorized, false otherwise. If the request is not ## authorized, this template returns an appropriate 401 response.
## authorized, this template sets up the 401 response correctly. The calling
## context needs only to return from the route.
var session {.inject.}: Session var session {.inject.}: Session
var authed {.inject.} = false
try: try: session = extractSession(cfg, request)
session = extractSession(cfg, request)
authed = true
except: except:
debug "Auth failed: " & getCurrentExceptionMsg() debug "Auth failed: " & getCurrentExceptionMsg()
resp(Http401, makeJsonResp(Http401), JSON) jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
proc start*(cfg: StrawBossConfig): void = proc start*(cfg: StrawBossConfig): void =
@ -114,53 +161,64 @@ proc start*(cfg: StrawBossConfig): void =
var workers: seq[Worker] = @[] var workers: seq[Worker] = @[]
settings: settings:
port = Port(8180) port = Port(cfg.port)
appName = "/api" appName = "/api"
routes: routes:
get "/version":
resp($(%("strawboss v" & SB_VERSION)), JSON)
post "/auth-token": post "/auth-token":
var uname, pwd: string var uname, pwd: string
try: try:
let jsonBody = parseJson(request.body) let jsonBody = parseJson(request.body)
uname = jsonBody["username"].getStr uname = jsonBody["username"].getStr
pwd = jsonBody["password"].getStr pwd = jsonBody["password"].getStr
except: resp(Http400, makeJsonResp(Http400), JSON) except: jsonResp(Http400)
try: try:
let authToken = makeAuthToken(cfg, uname, pwd) let authToken = makeAuthToken(cfg, uname, pwd)
resp($(%authToken), JSON) resp($(%authToken), JSON)
except: resp(Http401, makeJsonResp(Http401, getCurrentExceptionMsg()), JSON) except:
jsonResp(Http401, getCurrentExceptionMsg())
if ctx.cfg.debug: echo getStackTrace()
get "/verify-auth": get "/verify-auth":
checkAuth(); if not authed: return true checkAuth()
resp(Http200, $(%*{ "username": session.user.name }), JSON) resp(Http200, $(%*{ "username": session.user.name }), JSON)
get "/projects": get "/projects":
## List project summaries (ProjectDefs only) ## List project summaries (ProjectDefs only)
checkAuth(); if not authed: return true checkAuth()
resp($(%cfg.projects), JSON) resp($(%cfg.projects), JSON)
post "/projects": post "/projects":
## Create a new project definition ## Create a new project definition
checkAuth(); if not authed: return true checkAuth()
# TODO # TODO
resp(Http501, makeJsonResp(Http501), JSON) jsonResp(Http501)
get "/project/@projectName": get "/project/@projectName":
## Return a project's configuration, as well as it's versions. ## Return a project's configuration, as well as it's versions.
checkAuth(); if not authed: return true checkAuth()
# Make sure we know about that project # Make sure we know about that project
var projDef: ProjectDef var projDef: ProjectDef
try: projDef = cfg.getProject(@"projectName") try: projDef = cfg.getProject(@"projectName")
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except:
try: raise getCurrentException()
except NotFoundException:
jsonResp(Http404, getCurrentExceptionMsg())
except:
let msg = "unable to load project definition for project " & @"projectName"
json500Resp(getCurrentException(), msg)
var projConf: ProjectConfig var projConf: ProjectConfig
try: projConf = getProjectConfig(cfg, @"projectName", "") try: projConf = getProjectConfig(cfg, @"projectName", "")
@ -169,7 +227,7 @@ proc start*(cfg: StrawBossConfig): void =
let respJson = newJObject() let respJson = newJObject()
respJson["definition"] = %projDef respJson["definition"] = %projDef
respJson["versions"] = %listVersions(cfg, @"projectName") respJson["versions"] = %listVersions(cfg, @"projectName")
if not projConf.name.isNil: if projConf.name.len > 0:
respJson["latestConfig"] = %projConf respJson["latestConfig"] = %projConf
resp(pretty(respJson), JSON) resp(pretty(respJson), JSON)
@ -177,87 +235,191 @@ proc start*(cfg: StrawBossConfig): void =
get "/project/@projectName/versions": get "/project/@projectName/versions":
## Get a list of all versions that we have built ## Get a list of all versions that we have built
checkAuth(); if not authed: return true checkAuth()
try: resp($(%listVersions(cfg, @"projectName")), JSON) try: resp($(%listVersions(cfg, @"projectName")), JSON)
except: except:
if getCurrentException() is KeyError: try: raise getCurrentException()
resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except NotFoundException:
else: jsonResp(Http404, getCurrentExceptionMsg())
when not defined(release): debug getCurrentException().getStackTrace() except:
error "unable to list versions for project " & @"projectName" & let msg = "unable to list versions for project " & @"projectName"
":\n" & getCurrentExceptionMsg() json500Resp(getCurrentException(), msg)
resp(Http500, makeJsonResp(Http500, "internal server error"), JSON)
get "/project/@projectName/version/@version?": get "/project/@projectName/version/@version?":
## Get a detailed project record including step definitions (ProjectConfig). ## Get a detailed project record including step definitions (ProjectConfig).
checkAuth(); if not authed: return true checkAuth()
# Make sure we know about that project # Make sure we know about that project
try: resp($(%getProjectConfig(cfg, @"projectName", @"version")), JSON) try: resp($(%getProjectConfig(cfg, @"projectName", @"version")), JSON)
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except: jsonResp(Http404, getCurrentExceptionMsg())
get "/project/@projectName/runs": get "/project/@projectName/runs":
## List all runs ## List all runs
checkAuth(); if not authed: return true checkAuth()
try: resp($(%listRuns(cfg, @"projectName")), JSON) try: resp($(%listRuns(cfg, @"projectName")), JSON)
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except: jsonResp(Http404, getCurrentExceptionMsg())
get "/project/@projectName/runs/active": get "/project/@projectName/runs/active":
## List all currently active runs ## List all currently active runs
checkAuth(); if not authed: return true checkAuth()
var details = ""
try: try:
let activeRuns = workers let activeRuns = workers
.filterIt(it.process.running and it.projectName == @"projectName") .filterIt(it.process.running and it.projectName == @"projectName")
.mapIt(cfg.getRun(@"projecName", $it.runId)); .mapIt(cfg.getRun(@"projectName", $it.runId));
resp($(%activeRuns), JSON) resp($(%activeRuns), JSON)
except NotFoundException:
jsonResp(Http404, getCurrentExceptionMsg())
except: except:
if getCurrentException() is KeyError: json500Resp(getCurrentException(), "problem loading active runs")
resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON)
else:
when not defined(release): debug getCurrentException().getStackTrace()
error "problem loading active runs: " & getCurrentExceptionMsg()
resp(Http500, makeJsonResp(Http500, "internal server error"), JSON)
get "/project/@projectName/run/@runId": get "/project/@projectName/run/@runId":
## Details for a specific run ## Details for a specific run
checkAuth(); if not authed: return true checkAuth()
# Make sure we know about that project # Make sure we know about that project
try: discard cfg.getProject(@"projectName") try: discard cfg.getProject(@"projectName")
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except: jsonResp(Http404, getCurrentExceptionMsg())
if not existsRun(cfg, @"projectName", @"runId"): if not existsRun(cfg, @"projectName", @"runId"):
resp(Http404, makeJsonResp(Http404, "no such run for project"), JSON) jsonResp(Http404, "no such run for project")
try: resp($getRun(cfg, @"projectName", @"runId"), JSON) try: resp($getRun(cfg, @"projectName", @"runId"), JSON)
except: resp(Http500, makeJsonResp(Http500, getCurrentExceptionMsg()), JSON) except:
json500Resp(getCurrentException(),
"unable to load run details for project " & @"projectName" &
" run " & @"runId")
get "/project/@projectName/run/@runId/logs":
## Get logs from a specific run
checkAuth()
try: discard cfg.getProject(@"projectName")
except:
jsonResp(Http404, getCurrentExceptionMsg())
if not existsRun(cfg, @"projectName", @"runId"):
jsonResp(Http404, "no such run for project")
try: resp($getLogs(cfg, @"projectName", @"runId"))
except:
json500Resp(getCurrentException(),
"unable to load run logs for " & @"projectName" & " run " & @"runId")
get "/project/@projectName/step/@stepName/artifacts/@version":
## Get the list of artifacts that were built for
checkAuth()
debug "Matched artifacts list request: " & $(%*{
"project": @"projectName",
"step": @"stepName",
"version": @"version"
})
try: resp($(%listArtifacts(cfg, @"projectName", @"stepName", @"version")), JSON)
except:
try: raise getCurrentException()
except NotFoundException:
jsonResp(Http404, getCurrentExceptionMsg())
except:
json500Resp(getCurrentException(), "unable to list artifacts for " &
@"projectName" & ":" & @"stepName" & "@" & @"buildRef")
get "/project/@projectName/step/@stepName/artifact/@version/@artifactName":
## Get a specific artifact that was built.
checkAuth()
var artifactPath: string
try: artifactPath = getArtifactPath(cfg,
@"projectName", @"stepName", @"version", @"artifactName")
except:
try: raise getCurrentException()
except NotFoundException:
jsonResp(Http404, getCurrentExceptionMsg())
except:
json500Resp(getCurrentException(), "unable to check artifact path for " &
@"projectName" & ":" & @"stepName" & "@" & @"version")
enableRawMode
debug "Preparing: " & artifactPath
let fileSize = getFileSize(artifactPath)
let mimetype = request.settings.mimes.getMimetype(artifactPath.splitFile.ext[1 .. ^1])
if fileSize < 10_000_000: # 10 mb
var file = readFile(artifactPath)
var hashed = getMD5(file)
# If the user has a cached version of this file and it matches our
# version, let them use it
if request.headers.hasKey("If-None-Match") and request.headers["If-None-Match"] == hashed:
resp(Http304)
else:
resp(Http200, [
("Content-Disposition", "; filename=\"" & @"artifactName" & "\""),
("Content-Type", mimetype),
("ETag", hashed )], file)
else:
let headers = @{
"Content-Disposition": "; filename=\"" & @"artifactName" & "\"",
"Content-Type": mimetype,
"Content-Length": $fileSize
}
request.sendHeaders(Http200, headers)
var fileStream = newFutureStream[string]("sendStaticIfExists")
var file = openAsync(artifactPath, fmRead)
# Let `readToStream` write file data into fileStream in the
# background.
asyncCheck file.readToStream(fileStream)
# The `writeFromStream` proc will complete once all the data in the
# `bodyStream` has been written to the file.
while true:
let (hasValue, value) = await fileStream.read()
if hasValue: request.send(value)
else: break
file.close()
get "/project/@projectName/step/@stepName/status/@buildRef": get "/project/@projectName/step/@stepName/status/@buildRef":
## Get detailed information about the status of a step (assuming it has been built) ## Get detailed information about the status of a step (assuming it has been built)
checkAuth(); if not authed: return true checkAuth()
try: resp($cfg.getBuildStatus(@"projectName", @"stepName", @"buildRef"), JSON) try: resp($cfg.getBuildStatus(@"projectName", @"stepName", @"buildRef"), JSON)
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except:
try: raise getCurrentException()
except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg())
except:
json500Resp(getCurrentException(), "unable to load the build state for " &
@"projectName" & ":" & @"stepName" & "@" & @"buildRef")
#get "/project/@projectName/step/@stepName/status/@buildRef.svg":
## Get an image representing the status of a build
## TODO: how do we want to handle auth for this? Unlike
#checkAuth(): if not authed: return true
post "/project/@projectName/step/@stepName/run/@buildRef?": post "/project/@projectName/step/@stepName/run/@buildRef?":
# Kick off a run # Kick off a run
checkAuth(); if not authed: return true checkAuth()
let runRequest = RunRequest( let runRequest = RunRequest(
runId: genUUID(), runId: genUUID(),
projectName: @"projectName", projectName: @"projectName",
stepName: @"stepName", stepName: @"stepName",
buildRef: if @"buildRef" != "": @"buildRef" else: nil, buildRef: if @"buildRef" != "": @"buildRef" else: "",
timestamp: getLocalTime(getTime()), timestamp: getTime().local,
forceRebuild: false) # TODO support this with optional query params forceRebuild: false) # TODO support this with optional query params
# TODO: instead of immediately spawning a worker, add the request to a # TODO: instead of immediately spawning a worker, add the request to a
@ -271,26 +433,24 @@ proc start*(cfg: StrawBossConfig): void =
id: runRequest.runId, id: runRequest.runId,
request: runRequest, request: runRequest,
status: status), JSON) status: status), JSON)
except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) except:
try: raise getCurrentException()
get "/service/debug/ping": except NotFoundException: jsonResp(Http404, getCurrentExceptionMsg())
if not cfg.debug: resp(Http404, makeJsonResp(Http404), JSON) except: jsonResp(Http400, getCurrentExceptionMsg())
else: resp($(%"pong"), JSON)
post "/service/debug/stop": post "/service/debug/stop":
if not cfg.debug: resp(Http404, makeJsonResp(Http404), JSON) if not cfg.debug: jsonResp(Http404)
else: else:
let shutdownFut = sleepAsync(100) let shutdownFut = sleepAsync(100)
shutdownFut.callback = proc(): void = complete(stopFuture) shutdownFut.callback = proc(): void = complete(stopFuture)
resp($(%"shutting down"), JSON) resp($(%"shutting down"), JSON)
#[
get re".*": get re".*":
resp(Http404, makeJsonResp(Http404), JSON) jsonResp(Http404, "URL [" & request.path & "] is not present on this server.")
post re".*": post re".*":
resp(Http404, makeJsonResp(Http404), JSON) jsonResp(Http404)
]#
proc performMaintenance(cfg: StrawBossConfig): void = proc performMaintenance(cfg: StrawBossConfig): void =
# Prune workers # Prune workers

View File

@ -0,0 +1,2 @@
const SB_VERSION* = "0.5.1"

View File

@ -0,0 +1,9 @@
[Unit]
Description=StrawBoss build server.
[Service]
Type=simple
User=strawboss
WorkingDirectory=/home/strawboss
ExecStart=/home/strawboss/strawboss
Restart=on-failure

View File

@ -1,8 +1,10 @@
{ {
"name": "dummy-project", "name": "dummy-project",
"versionCmd": "git describe --all --always", "versionCmd": "git describe --all --always",
"containerImage": "ubuntu",
"steps": { "steps": {
"build": { "build": {
"containerImage": "alpine",
"depends": ["test"], "depends": ["test"],
"workingDir": "dir1", "workingDir": "dir1",
"stepCmd": "cust-build", "stepCmd": "cust-build",

View File

@ -6,6 +6,7 @@
{ "name": "bob@builder.com", "hashedPwd": "$2a$11$lVZ9U4optQMhzPh0E9A7Yu6XndXblUF3gCa.zmEvJy4F.4C4718b." }, { "name": "bob@builder.com", "hashedPwd": "$2a$11$lVZ9U4optQMhzPh0E9A7Yu6XndXblUF3gCa.zmEvJy4F.4C4718b." },
{ "name": "sam@sousa.com", "hashedPwd": "testvalue" } { "name": "sam@sousa.com", "hashedPwd": "testvalue" }
], ],
"port": 8180,
"pwdCost": 11, "pwdCost": 11,
"projects": [ "projects": [
{ "name": "dummy-project", { "name": "dummy-project",

View File

@ -1,4 +1,4 @@
import unittest import tempfile, times, unittest, untar
from langutils import sameContents from langutils import sameContents
@ -12,8 +12,8 @@ let TIMEOUT = 2.minutes
suite "strawboss core": suite "strawboss core":
# Suite setup: extract test project # Suite setup: extract test project
let testProjTempDir = mkdir() let testProjTempDir = mkdtemp()
let testProjTarFile = newTarFile("src/test/test-project.tar.gz:) let testProjTarFile = newTarFile("src/test/test-project.tar.gz")
let testProjName = "test-project" let testProjName = "test-project"
testProjTarFile.extract(testProjTempDir) testProjTarFile.extract(testProjTempDir)

View File

@ -2,6 +2,7 @@ import cliutils, httpclient, json, os, osproc, sequtils, strutils, tempfile,
times, unittest, untar, uuids times, unittest, untar, uuids
from langutils import sameContents from langutils import sameContents
from algorithm import sorted
import ../testutil import ../testutil
import ../../../main/nim/strawbosspkg/configuration import ../../../main/nim/strawbosspkg/configuration
@ -50,7 +51,7 @@ suite "strawboss server":
@["serve", "-c", tempCfgPath], loadEnv(), {poUsePath}) @["serve", "-c", tempCfgPath], loadEnv(), {poUsePath})
# give the server time to spin up # give the server time to spin up
sleep(100) sleep(200)
teardown: teardown:
discard newAsyncHttpClient().post(apiBase & "/service/debug/stop") discard newAsyncHttpClient().post(apiBase & "/service/debug/stop")
@ -60,7 +61,7 @@ suite "strawboss server":
removeFile(tempCfgPath) removeFile(tempCfgPath)
# give the server time to spin down but kill it after that # give the server time to spin down but kill it after that
sleep(100) sleep(200)
if serverProcess.running: kill(serverProcess) if serverProcess.running: kill(serverProcess)
test "handle missing project configuration": test "handle missing project configuration":
@ -135,13 +136,68 @@ suite "strawboss server":
let completedRun = http.waitForBuild(apiBase, testProjName, $queuedRun.id) let completedRun = http.waitForBuild(apiBase, testProjName, $queuedRun.id)
# there should be successful status files for both the build and test steps # there should be successful status files for both the build and test steps
for stepName in ["build", "test"]: for step in [("build", BuildState.stepComplete), ("test", BuildState.complete)]:
let statusFile = tempBuildDataDir & "/" & testProjName & "/status/" & stepName & "/0.2.1.json" let statusFile = tempBuildDataDir & "/" & testProjName & "/status/" & step[0] & "/0.2.1.json"
check fileExists(statusFile) check fileExists(statusFile)
let status = loadBuildStatus(statusFile) let status = loadBuildStatus(statusFile)
check status.state == BuildState.complete check status.state == step[1]
test "run a build in docker":
let http = newAuthenticatedHttpClient(apibase, "bob@builder.com", "password")
# Run the "build-docker" step
var resp = http.post(apiBase & "/project/" & testProjName & "/step/build-docker/run/0.3.0")
check resp.status.startsWith("200")
let queuedRun = parseRun(parseJson(resp.body))
check queuedRun.status.state == BuildState.queued
# Wait for the build to complete
let completedRun = http.waitForBuild(apiBase, testProjName, $queuedRun.id)
# check that the run directory, run request, status, and output logs exist
let runsDir = tempBuildDataDir & "/" & testProjName & "/runs"
let runId = $completedRun.id
check existsDir(runsDir)
for suffix in [".request.json", ".status.json", ".stdout.log", ".stderr.log"]:
check existsFile(runsDir & "/" & runId & suffix)
# check that the project directory has been created in the artifacts repo
let runArtifactsDir = tempBuildDataDir & "/" & testProjName & "/artifacts/build-docker/0.3.0"
check existsDir(runArtifactsDir)
# check that the build step status file has been created
let statusFile = tempBuildDataDir & "/" & testProjName & "/status/build-docker/0.3.0.json"
check fileExists(statusFile)
# check that the status is complete
var status = loadBuildStatus(statusFile)
check status.state == BuildState.complete
# check that the artifacts we expect are present
let binFile = runArtifactsDir & "/test_project"
check existsFile(binFile)
test "run a multi-step docker-based build":
let http = newAuthenticatedHttpClient(apibase, "bob@builder.com", "password")
# Run the "test" step (depends on "build")
var resp = http.post(apiBase & "/project/" & testProjname & "/step/test-docker/run/0.3.0")
check resp.status.startsWith("200")
let queuedRun = parseRun(parseJson(resp.body))
let completedRun = http.waitForBuild(apiBase, testProjName, $queuedRun.id)
# there should be successful status files for both the build and test steps
for step in [("build-docker", BuildState.stepComplete), ("test-docker", BuildState.complete)]:
let statusFile = tempBuildDataDir & "/" & testProjName & "/status/" & step[0] & "/0.3.0.json"
check fileExists(statusFile)
let status = loadBuildStatus(statusFile)
check status.state == step[1]
# TODO
#test "already completed steps should not be rebuilt": #test "already completed steps should not be rebuilt":
# let http = newAuthenticatedHttpClient(apibase, "bob@builder.com", "password") # let http = newAuthenticatedHttpClient(apibase, "bob@builder.com", "password")
# let runArtifactsDir = tempBuildDataDir & "/" & testProjName & "/artifacts/build/0.2.1" # let runArtifactsDir = tempBuildDataDir & "/" & testProjName & "/artifacts/build/0.2.1"
@ -150,9 +206,40 @@ suite "strawboss server":
# Run the "build" step # Run the "build" step
# Kick off a build that depends on "build" (which was run in the last test) # Kick off a build that depends on "build" (which was run in the last test)
# TODO test "kick off multiple runs and check the list of active runs via the API":
#test "kick off multiple runs and check the list of active runs via the API": let http = newAuthenticatedHttpClient(apiBase, "bob@builder.com", "password")
# check false
# Kick off multiple runs of the "long-running" job
let queuedRuns = toSeq((1..3)).map(proc (idx: int): Run =
let resp = http.post(apiBase & "/project/" & testProjName & "/step/long-running/run/0.3.1")
check resp.status.startsWith("200")
return parseRun(parseJson(resp.body)))
# Collect run ids.
let runIds = queuedRuns.mapIt($(it.id)).sorted(cmpIgnoreCase)
# Check on the runs
let getActiveResp = http.get(apiBase & "/project/" & testProjName & "/runs/active")
check getActiveResp.status.startsWith("200")
let activeRuns = parseJson(getActiveResp.body).getElems().mapIt(parseRun(it))
let activeRunIds = activeRuns.mapIt($(it.id)).sorted(cmpIgnoreCase)
# Make sure we see all runs in the active state.
check runIds == activeRunIds
let completedRuns = runIds.map(proc (runId: string): Run =
return http.waitForBuild(apiBase, testProjName, runId))
# Make sure all are completed and all are accounted for
check completedRuns.allIt(it.status.state == BuildState.complete)
check completedRuns.mapIt($(it.id)).sorted(cmpIgnoreCase) == runIds;
# Check that there are no more active runs
let getActiveResp2 = http.get(apiBase & "/project/" & testProjName & "/runs/active")
let remainingActiveRuns = parseJson(getActiveResp2.body).getElems().mapIt(parseRun(it))
check remainingActiveRuns.len == 0
# Last-chance catch to kill the server in case some test err'ed and didn't # Last-chance catch to kill the server in case some test err'ed and didn't
# reach it's teardown handler # reach it's teardown handler

View File

@ -26,7 +26,7 @@ proc waitForBuild*(client: HttpClient, apiBase, projectName, runId: string,
#echo "Checking (" & $curElapsed & " has passed)." #echo "Checking (" & $curElapsed & " has passed)."
if curElapsed > toFloat(timeout): if curElapsed > toFloat(timeout):
raise newException(SystemError, "Timeout exceeded waiting for build.") raise newException(Exception, "Timeout exceeded waiting for build.")
let resp = client.get(apiBase & "/project/" & projectName & "/run/" & runId) let resp = client.get(apiBase & "/project/" & projectName & "/run/" & runId)

View File

@ -1,6 +1,7 @@
import json, strtabs, times, tables, unittest, uuids import json, strtabs, times, tables, unittest, uuids
from langutils import sameContents from langutils import sameContents
from timeutils import trimNanoSec
import ../../../main/nim/strawbosspkg/configuration import ../../../main/nim/strawbosspkg/configuration
suite "load and save configuration objects": suite "load and save configuration objects":
@ -26,7 +27,7 @@ suite "load and save configuration objects":
stepName: "build", stepName: "build",
buildRef: "master", buildRef: "master",
workspaceDir: "/no-real/dir", workspaceDir: "/no-real/dir",
timestamp: getLocalTime(getTime()), timestamp: getTime().local.trimNanoSec,
forceRebuild: true) forceRebuild: true)
let rrStr = $rr1 let rrStr = $rr1
@ -99,6 +100,7 @@ suite "load and save configuration objects":
check: check:
pc.name == "dummy-project" pc.name == "dummy-project"
pc.versionCmd == "git describe --all --always" pc.versionCmd == "git describe --all --always"
pc.containerImage == "ubuntu"
pc.steps.len == 2 pc.steps.len == 2
# Explicitly set properties # Explicitly set properties
@ -106,6 +108,7 @@ suite "load and save configuration objects":
pc.steps["build"].dontSkip == true pc.steps["build"].dontSkip == true
pc.steps["build"].stepCmd == "cust-build" pc.steps["build"].stepCmd == "cust-build"
pc.steps["build"].workingDir == "dir1" pc.steps["build"].workingDir == "dir1"
pc.steps["build"].containerImage == "alpine"
sameContents(pc.steps["build"].artifacts, @["bin1", "doc1"]) sameContents(pc.steps["build"].artifacts, @["bin1", "doc1"])
sameContents(pc.steps["build"].depends, @["test"]) sameContents(pc.steps["build"].depends, @["test"])
sameContents(pc.steps["build"].expectedEnv, @["VAR1"]) sameContents(pc.steps["build"].expectedEnv, @["VAR1"])
@ -116,6 +119,7 @@ suite "load and save configuration objects":
pc.steps["test"].dontSkip == false pc.steps["test"].dontSkip == false
pc.steps["test"].stepCmd == "true" pc.steps["test"].stepCmd == "true"
pc.steps["test"].workingDir == "." pc.steps["test"].workingDir == "."
pc.steps["test"].containerImage.len == 0
sameContents(pc.steps["test"].artifacts, @[]) sameContents(pc.steps["test"].artifacts, @[])
sameContents(pc.steps["test"].depends, @[]) sameContents(pc.steps["test"].depends, @[])
sameContents(pc.steps["test"].expectedEnv, @[]) sameContents(pc.steps["test"].expectedEnv, @[])

View File

@ -6,6 +6,7 @@ from langutils import sameContents
import ../testutil import ../testutil
import ../../../main/nim/strawbosspkg/configuration import ../../../main/nim/strawbosspkg/configuration
import ../../../main/nim/strawbosspkg/server import ../../../main/nim/strawbosspkg/server
import ../../../main/nim/strawbosspkg/version
let apiBase = "http://localhost:8180/api" let apiBase = "http://localhost:8180/api"
let cfgFilePath = "src/test/json/strawboss.config.json" let cfgFilePath = "src/test/json/strawboss.config.json"
@ -40,11 +41,11 @@ suite "strawboss server":
let tok = toJWT(cfg, session) let tok = toJWT(cfg, session)
check fromJWT(cfg, tok) == session check fromJWT(cfg, tok) == session
test "ping": test "version":
let resp = http.get(apiBase & "/service/debug/ping") let resp = http.get(apiBase & "/version")
check: check:
resp.status.startsWith("200") resp.status.startsWith("200")
resp.body == "\"pong\"" resp.body == "\"strawboss v" & SB_VERSION & "\""
test "fail auth": test "fail auth":
let resp = http.post(apiBase & "/auth-token", let resp = http.post(apiBase & "/auth-token",

@ -1 +1 @@
Subproject commit 127be8f66fcc6d4d223acf56668d42ff9c37bfb0 Subproject commit ab883bd9602a1373347a23c8bee4ed28dd475aec

Binary file not shown.

20
src/util/bash/client.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
host="${STRAWBOSS_HOST:-localhost:8180}"
if [ $# -eq 1 ]; then
url="$1"
method="GET"
data=""
elif [ $# -eq 2 ]; then
method="$1"
url="$2"
data=""
else
method="$1"
url="$2"
data="$3"
fi
curl -X "$method" -H "Authorization: Bearer $(cat token.txt)" "http://${host}/api/$url" -d "$data"
echo ""
#echo "curl -X \"$method\" -H \"Authorization: Bearer $(cat token.txt)\" \"localhost:8180/api/$url\" | jq . "

View File

@ -5,6 +5,7 @@
"authSecret": "change me", "authSecret": "change me",
"pwdCost": 11, "pwdCost": 11,
"maintenancePeriod": 5000, "maintenancePeriod": 5000,
"logLevel": "info",
"projects": [ "projects": [
{ "name": "new-life-intro-band", { "name": "new-life-intro-band",
"repo": "/home/jdb/projects/new-life-introductory-band" }, "repo": "/home/jdb/projects/new-life-introductory-band" },

View File

@ -1,7 +1,7 @@
# Package # Package
bin = @["strawboss"] bin = @["strawboss"]
version = "0.3.0" version = "0.5.1"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "My personal continious integration worker." description = "My personal continious integration worker."
license = "MIT" license = "MIT"
@ -9,15 +9,22 @@ srcDir = "src/main/nim"
# Dependencies # Dependencies
requires @["nim >= 0.16.1", "docopt >= 0.6.5", "isaac >= 0.1.2", "tempfile", "jester", "bcrypt", requires @["nim >= 0.19.0", "docopt >= 0.6.8", "isaac >= 0.1.3", "tempfile", "jester >= 0.4.1", "bcrypt",
"untar", "uuids"] "untar", "uuids >= 0.1.10", "jwt"]
requires "https://github.com/yglukhov/nim-jwt" # Hacky to point to a specific hash. But there is some bug building in the
requires "https://git.jdb-labs.com/jdb/nim-lang-utils.git >= 0.3.0" # docker image we use to build the project with the next version. It adds an
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.3.1" # ifdef branch to support libssl 1.1 but for some reason that ifdef is set
# wrong and it tries to build against the 1.1 API even though the image only
# has the 1.0 API. I'm crossing my fingers and hoping that our base image
# supports libssl 1.1 before I need to update this library.
#requires "https://github.com/yglukhov/nim-jwt#549aa1eb13b8ddc0c6861d15cc2cc5b52bcbef01"
requires "https://git.jdb-labs.com/jdb/nim-lang-utils.git >= 0.4.0"
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.0"
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.4.0"
# Tasks # Tasks
#
task functest, "Runs the functional test suite.": task functest, "Runs the functional test suite.":
exec "nimble build" exec "nimble build"
exec "nim c -r src/test/nim/run_functional_tests.nim" exec "nim c -r src/test/nim/run_functional_tests.nim"
@ -37,3 +44,8 @@ task test, "Runs both the unit and functional test suites.":
echo "\nRunning functional tests." echo "\nRunning functional tests."
echo "-------------------------" echo "-------------------------"
exec "src/test/nim/run_functional_tests" exec "src/test/nim/run_functional_tests"
task dist, "Creates distributable package.":
exec "nimble build"
mkdir "dist"
exec "cp strawboss strawboss.config.json example.json dist/."

View File

@ -1,12 +1,37 @@
{ {
"name": "strawboss", "name": "strawboss",
"containerImage": "nimlang/nim:0.19.0",
"steps": { "steps": {
"build": { "compile": {
"artifacts": ["strawboss"], "artifacts": ["strawboss"],
"stepCmd": "nimble build" "stepCmd": "nimble build"
}, },
"test": { "depends": ["unittest", "functest"] }, "unittest": {
"functest": { "stepCmd": "nimble functest" }, "depends": ["compile"],
"unittest": { "stepCmd": "nimble unittest" } "stepCmd": "/bin/bash",
"cmdInput": [
"cp $compile_DIR/strawboss .",
"nimble install --depsOnly",
"nim c -r src/test/nim/run_unit_tests"
]
},
"functest": {
"depends": ["compile"],
"stepCmd": "/bin/bash",
"cmdInput": [
"cp $compile_DIR/strawboss .",
"nimble install --depsOnly",
"nim c -r src/test/nim/run_functional_tests"
]
},
"build": {
"artifacts": ["strawboss-$VERSION.zip"],
"depends": ["compile", "unittest", "functest"],
"stepCmd": "/bin/bash",
"cmdInput": [
"cp $compile_DIR/strawboss .",
"zip strawboss-$VERSION.zip strawboss strawboss.config.json example.json src/main/systemd/strawboss.service"
]
}
} }
} }

11
test-spec.txt Normal file
View File

@ -0,0 +1,11 @@
Run a build. Look for:
- Run request archived
- Output logs archived with the run request
- Artifacts archived in the build-data directory.
- Configuration for that version archived in configurations directory.
- Status for that version archived in the status directory
Run the build again for the same project and build ref:
- Build should be skipped.
- Run request should be archived.