import algorithm, asyncdispatch, bcrypt, cliutils, jester, json, jwt, logging, options, os, osproc, sequtils, strutils, tempfile, times, unittest import ./configuration, ./core type Worker = object process*: Process workingDir*: string type Session = object user*: UserRef issuedAt*, expires*: Time #const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" const JSON = "application/json" proc makeJsonResp(status: HttpCode, details: string = ""): string = result = $(%* { "statusCode": status.int, "status": $status, "details": details }) proc newSession*(user: UserRef): Session = result = Session( user: user, issuedAt: getTime(), expires: daysForward(7).toTime()) proc toJWT*(cfg: StrawBossConfig, session: Session): string = ## Make a JST token for this session. var jwt = JWT( header: JOSEHeader(alg: HS256, typ: "jwt"), claims: toClaims(%*{ "sub": session.user.name, "iat": session.issuedAt.toSeconds().int, "exp": session.expires.toSeconds().int })) jwt.sign(cfg.authSecret) result = $jwt proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session = ## Validate a given JWT and extract the session data. let jwt = toJWT(strTok) var secret = cfg.authSecret if not jwt.verify(secret): raiseEx "Unable to verify auth token." jwt.verifyTimeClaims() # Find the user record (if authenticated) let username = jwt.claims["sub"].node.str let users = cfg.users.filterIt(it.name == username) if users.len != 1: raiseEx "Could not find session user." result = Session( user: users[0], issuedAt: fromSeconds(jwt.claims["iat"].node.num), expires: fromSeconds(jwt.claims["exp"].node.num)) proc extractSession(cfg: StrawBossConfig, request: Request): Session = ## Helper to extract a session from a reqest. # Find the auth header if not request.headers.hasKey("Authorization"): raiseEx "No auth token." # Read and verify the JWT token let headerVal = request.headers["Authorization"] if not headerVal.startsWith("Bearer "): raiseEx "Invalid Authentication type (only 'Bearer' is supported)." result = fromJWT(cfg, headerVal[7..^1]) proc spawnWorker(cfg: StrawBossConfig, req: RunRequest): Worker = ## Kick off a new worker process with the given run information let dir = mkdtemp() var args = @["run", req.projectName, req.stepName, "-r", req.buildRef, "-w", dir, "-c", cfg.filePath] if req.forceRebuild: args.add("-f") debug "Launching worker: " & cfg.pathToExe & " " & args.join(" ") result = Worker( process: startProcess(cfg.pathToExe, ".", args, loadEnv(), {poUsePath}), workingDir: dir) proc hashPwd*(pwd: string, cost: int8): string = let salt = genSalt(cost) result = hash(pwd, salt) proc validatePwd*(u: UserRef, givenPwd: string): bool = let salt = u.hashedPwd[0..28] # TODO: magic numbers result = compare(u.hashedPwd, hash(givenPwd, salt)) proc makeAuthToken*(cfg: StrawBossConfig, uname, pwd: string): string = ## Given a username and pwd, validate the combination and generate a JWT ## token string. if uname == nil or pwd == nil: raiseEx "fields 'username' and 'password' required" # find the user record let users = cfg.users.filterIt(it.name == uname) if users.len != 1: raiseEx "invalid username or password" let user = users[0] if not validatePwd(user, pwd): raiseEx "invalid username or password" result = toJWT(cfg, newSession(user)) template checkAuth() = ## Check this request for authentication and authorization information. ## Injects two variables into the running context: the session and authed: ## true if the request is authorized, false otherwise. If the request is not ## authorized, this template sets up the 401 response correctly. The calling ## context needs only to return from the route. var session {.inject.}: Session var authed {.inject.} = false try: session = extractSession(cfg, request) authed = true except: debug "Auth failed: " & getCurrentExceptionMsg() resp(Http401, makeJsonResp(Http401), JSON) proc start*(cfg: StrawBossConfig): void = let stopFuture = newFuture[void]() var workers: seq[Worker] = @[] # TODO: add recurring clean-up down to clear completed workers from the # workers queu and kick off pending requests as worker slots free up. settings: port = Port(8180) appName = "/api" routes: get "/ping": resp($(%*"pong"), JSON) post "/auth-token": var uname, pwd: string try: let jsonBody = parseJson(request.body) uname = jsonBody["username"].getStr pwd = jsonBody["password"].getStr except: resp(Http400, makeJsonResp(Http400), JSON) try: let authToken = makeAuthToken(cfg, uname, pwd) resp("\"" & $authToken & "\"", JSON) except: resp(Http401, makeJsonResp(Http401, getCurrentExceptionMsg()), JSON) get "/verify-auth": checkAuth(); if not authed: return true resp(Http200, $(%*{ "username": session.user.name }), JSON) get "/projects": ## List project summaries (ProjectDefs only) checkAuth(); if not authed: return true resp($(%(cfg.projects)), JSON) post "/projects": ## Create a new project definition checkAuth(); if not authed: return true # TODO resp(Http501, makeJsonResp(Http501), JSON) get "/project/@projectName/versions": ## Get a list of all versions that we have built checkAuth(); if not authed: return true # Make sure we know about that project var project: ProjectDef try: project = cfg.findProject(@"projectName") except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) var versions: seq[string] = @[] for cfgFilePath in walkFiles(cfg.artifactsRepo & "/" & project.name & "/configuration.*.json"): let vstart = cfgFilePath.rfind("/configuration.") + 15 versions.add(cfgFilePath[vstart..^6]) if versions.len == 0: resp(Http404, makeJsonResp(Http404, "I have not built any versions of " & project.name), JSON) resp($(%(versions)), JSON) get "/project/@projectName/version/@version?": ## Get a detailed project record including step definitions (ProjectConfig). checkAuth(); if not authed: return true # Make sure we know about that project var project: ProjectDef try: project = cfg.findProject(@"projectName") except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) # Given version var cachedFilePath: string if @"version" != "": cachedFilePath = cfg.artifactsRepo & "/" & project.name & "/configuration." & @"version" & ".json" if not existsFile(cachedFilePath): resp(Http404, makeJsonResp(Http404, "I have never built version " & @"version"), JSON) # No version requested, use "latest" else: let confFilePaths = toSeq(walkFiles(cfg.artifactsRepo & "/" & project.name & "/configuration.*.json")) if confFilePaths.len == 0: resp(Http404, makeJsonResp(Http404, "I have not built any versions of " & project.name), JSON) let modTimes = confFilePaths.mapIt(it.getLastModificationTime) cachedFilePath = sorted(zip(confFilePaths, modTimes), proc (a, b: tuple): int = cmp(a.b, b.b))[0].a try: resp(readFile(cachedFilePath), JSON) except: debug "Could not serve cached project configuration at: " & cachedFilePath & "\n\t Reason: " & getCurrentExceptionMsg() resp(Http500, makeJsonResp(Http500, "could not read cached project configuration"), JSON) get "/project/@projectName": ## TBD checkAuth(); if not authed: return true # Make sure we know about that project var projDef: ProjectDef try: projDef = cfg.findProject(@"projectName") except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) # Get the project configuration. let projConf = getCurrentProjectConfig(cfg, projDef) var respObj = newJObject() respObj["definition"] = %projDef #if projConf.isSome(): # let pc: ProjectConfig = projConf.get() # respObj["configuration"] = %pc resp($respObj, JSON) get "/project/@projectName/runs": ## List all runs checkAuth(); if not authed: return true # Make sure we know about that project var project: ProjectDef try: project = cfg.findProject(@"projectName") except: resp(Http404, makeJsonResp(Http404, getCurrentExceptionMsg()), JSON) let runRequests = listRuns(cfg, project) resp($(%runRequests), JSON) get "/project/@projectName/runs/active": ## List all currently active runs checkAuth(); if not authed: return true #let statusFiles = workers.mapIt(it.workingDir & "/status.json") #let statuses = statusFiles.mapIt(loadBuildStatus(it)).filterIt(it.state != "completed" && it.) #resp($(%statuses), JSON) resp(Http501, makeJsonResp(Http501), JSON) get "/project/@projectName/step/@stepName": ## Get step details including runs. checkAuth(); if not authed: return true # TODO resp(Http501, makeJsonResp(Http501), JSON) get "/project/@projectName/step/@stepName/run/@buildRef": ## Get detailed information about a run checkAuth(); if not authed: return true # TODO resp(Http501, makeJsonResp(Http501), JSON) post "/project/@projectName/step/@stepName/run/@buildRef?": # Kick off a run checkAuth(); if not authed: return true let runRequest = RunRequest( projectName: @"projectName", stepName: @"stepName", buildRef: if @"buildRef" != "": @"buildRef" else: nil, forceRebuild: false) # TODO support this with optional query params # TODO: instead of immediately spawning a worker, add the request to a # queue to be picked up by a worker. Allows capping the number of worker # prcesses, distributing, etc. let worker = spawnWorker(cfg, runRequest) workers.add(worker) resp($(%*{ "runRequest": runRequest, "status": { "state": "accepted", "details": "Run request has been queued." } })) post "/service/debug/stop": if not cfg.debug: resp(Http404, makeJsonResp(Http404), JSON) else: callSoon(proc(): void = complete(stopFuture)) resp($(%*"shutting down"), JSON) #[ get re".*": resp(Http404, makeJsonResp(Http404), JSON) post re".*": resp(Http404, makeJsonResp(Http404), JSON) ]# waitFor(stopFuture)