Compare commits

...

5 Commits

12 changed files with 222 additions and 85 deletions

View File

@ -1,33 +0,0 @@
---
openapi: "3.0.0"
info:
description: |
Janky Server API defintion
version: "1.0.0"
title: Janky APIs
contact:
email: "rtyler+janky@brokenco.de"
license:
name: "AGPL v3.0"
url: "https://www.gnu.org/licenses/agpl-3.0.en.html"
servers:
- url: 'http://localhost:8000/api/v1'
description: Local dev server (APIv1)
paths:
'/projects/{name}':
post:
summary: 'Trigger execution for this project'
description:
parameters:
- in: path
name: name
required: true
example: 'janky'
schema:
type: string
responses:
404:
summary: 'No project configured by that name'
200:
summary: 'Execution has been triggered'

View File

@ -2,7 +2,7 @@
openapi: "3.0.0"
info:
description: |
Janky Agent API defintion
Janky API v1 defintion
version: "1.0.0"
title: Janky APIs
contact:
@ -11,12 +11,42 @@ info:
name: "AGPL v3.0"
url: "https://www.gnu.org/licenses/agpl-3.0.en.html"
servers:
- url: 'http://localhost:9000/api/v1'
description: Local dev agent (APIv1)
- url: 'http://localhost:8000'
description: Local dev server
- url: 'http://localhost:9000'
description: Local dev agent
tags:
- name: 'agent'
description: 'Agent APIs'
- name: 'server'
description: 'Server APIs'
paths:
/capabilities:
'/api/v1/projects/{name}':
post:
tags:
- 'server'
summary: 'Trigger execution for this project'
description:
parameters:
- in: path
name: name
required: true
example: 'janky'
schema:
type: string
responses:
404:
summary: 'No project configured by that name'
200:
summary: 'Execution has been triggered'
'/api/v1/capabilities':
get:
tags:
- 'agent'
summary: "Retrieve a list of capabilities of this agent"
description:
responses:
@ -26,8 +56,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CapsResponse'
/execute:
'/api/v1/execute':
put:
tags:
- 'agent'
summary: "Execute a series of commands on this agent"
description:
requestBody:

View File

@ -1 +1 @@
{"openapi":"3.0.0","info":{"description":"Janky Agent API defintion\n","version":"1.0.0","title":"Janky APIs","contact":{"email":"rtyler+janky@brokenco.de"},"license":{"name":"AGPL v3.0","url":"https://www.gnu.org/licenses/agpl-3.0.en.html"}},"servers":[{"url":"http://localhost:9000/api/v1","description":"Local dev agent (APIv1)"}],"paths":{"/capabilities":{"get":{"summary":"Retrieve a list of capabilities of this agent","description":null,"responses":{"200":{"description":"Getting capabilities","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapsResponse"}}}}}}},"/execute":{"put":{"summary":"Execute a series of commands on this agent","description":null,"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"},"example":{"commands":[{"script":"echo \"Hi\""}]}}}},"responses":{"201":{"description":"Successfully accepted the commands for execution","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandResponse"}}}},"409":{"description":"Returned when the agent is busy with another series of commands"}}}}},"components":{"schemas":{"CapsResponse":{"type":"object","properties":{"caps":{"type":"array","items":{"$ref":"#/components/schemas/Capability"}}}},"Capability":{"type":"object","properties":{"name":{"type":"string"},"path":{"type":"string"},"data":{"type":"object"}}},"Command":{"type":"object","properties":{"script":{"type":"string","description":"A script that can be exec()'d on the agent"}}},"CommandRequest":{"type":"object","properties":{"commands":{"type":"array","items":{"$ref":"#/components/schemas/Command"}}}},"CommandResponse":{"type":"object","properties":{"uuid":{"type":"string","format":"uuid"},"stream":{"description":"URL to streaming WebSockets logs","type":"string","format":"url"},"task":{"description":"URL to the task metadata","type":"string","format":"url"},"log":{"description":"URL to the raw log of the task run","type":"string","format":"url"}}}}}}
{"openapi":"3.0.0","info":{"description":"Janky API defintion\n","version":"1.0.0","title":"Janky APIs","contact":{"email":"rtyler+janky@brokenco.de"},"license":{"name":"AGPL v3.0","url":"https://www.gnu.org/licenses/agpl-3.0.en.html"}},"servers":[{"url":"http://localhost:8000","description":"Local dev server"},{"url":"http://localhost:9000","description":"Local dev agent"}],"tags":[{"name":"agent","description":"Agent APIs"},{"name":"server","description":"Server APIs"}],"paths":{"/api/v1/projects/{name}":{"post":{"tags":["server"],"summary":"Trigger execution for this project","description":null,"parameters":[{"in":"path","name":"name","required":true,"example":"janky","schema":{"type":"string"}}],"responses":{"404":{"summary":"No project configured by that name"},"200":{"summary":"Execution has been triggered"}}}},"/api/v1/capabilities":{"get":{"tags":["agent"],"summary":"Retrieve a list of capabilities of this agent","description":null,"responses":{"200":{"description":"Getting capabilities","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CapsResponse"}}}}}}},"/api/v1/execute":{"put":{"tags":["agent"],"summary":"Execute a series of commands on this agent","description":null,"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandRequest"},"example":{"commands":[{"script":"echo \"Hi\""}]}}}},"responses":{"201":{"description":"Successfully accepted the commands for execution","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommandResponse"}}}},"409":{"description":"Returned when the agent is busy with another series of commands"}}}}},"components":{"schemas":{"CapsResponse":{"type":"object","properties":{"caps":{"type":"array","items":{"$ref":"#/components/schemas/Capability"}}}},"Capability":{"type":"object","properties":{"name":{"type":"string"},"path":{"type":"string"},"data":{"type":"object"}}},"Command":{"type":"object","properties":{"script":{"type":"string","description":"A script that can be exec()'d on the agent"}}},"CommandRequest":{"type":"object","properties":{"commands":{"type":"array","items":{"$ref":"#/components/schemas/Command"}}}},"CommandResponse":{"type":"object","properties":{"uuid":{"type":"string","format":"uuid"},"stream":{"description":"URL to streaming WebSockets logs","type":"string","format":"url"},"task":{"description":"URL to the task metadata","type":"string","format":"url"},"log":{"description":"URL to the raw log of the task run","type":"string","format":"url"}}}}}}

View File

@ -1,8 +1,21 @@
---
agents:
- 'http://localhost:9000'
'Local':
url: 'http://localhost:9000'
'Duplicate Local':
url: 'http://localhost:9000'
projects:
'janky':
description: |
Self-hosted Janky project
filename: 'ci.janky.yml'
scm:
github:
owner: 'rtyler'
repo: 'janky'
ref: 'main'
'janky with spaces':
description: A test configuration with spaces in the name
filename: 'ci.janky.yml'
scm:
github:

View File

@ -1,3 +1,3 @@
#!/bin/sh
exec ruby -ryaml -rjson -e 'puts JSON.dump(YAML.load(STDIN.read))' < api-description-agent.yml > apidocs/api-description.json
exec ruby -ryaml -rjson -e 'puts JSON.dump(YAML.load(STDIN.read))' < api-description.yml > apidocs/api-description.json

View File

@ -1,3 +0,0 @@
#!/bin/sh
exec ruby -ryaml -rjson -e 'puts JSON.dump(YAML.load(STDIN.read))' < api-description-server.yml > apidocs/api-description.json

View File

@ -186,6 +186,36 @@
},
"query": "SELECT * FROM scm_info WHERE uuid = ?"
},
"8482da66fb4c815cf21576e0b5c8121f5cb3a96b0a3f5e8241dbd677860c62af": {
"describe": {
"columns": [
{
"name": "uuid",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Right": 0
}
},
"query": "SELECT * FROM projects"
},
"980b3cb885d26d06b4178df215617e26aecd79f4d813df14770ec8ae540d0ce2": {
"describe": {
"columns": [

View File

@ -3,11 +3,12 @@
*/
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Serialize;
use sqlx::sqlite::SqliteQueryResult;
use sqlx::{Sqlite, SqlitePool, Transaction};
use uuid::Uuid;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize)]
pub struct Project {
uuid: String,
name: String,
@ -39,6 +40,12 @@ impl Project {
.await
}
pub async fn list(pool: &SqlitePool) -> Result<Vec<Project>, sqlx::Error> {
sqlx::query_as!(Project, "SELECT * FROM projects")
.fetch_all(pool)
.await
}
pub async fn create(
project: &Project,
tx: &SqlitePool,

View File

@ -80,9 +80,10 @@ enum Scm {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
struct Project {
description: String,
filename: String,
#[serde(with = "serde_yaml::with::singleton_map")]
scm: Scm,
filename: String,
}
/*
@ -93,6 +94,7 @@ struct Project {
*/
#[derive(Clone, Debug, Serialize)]
pub struct Agent {
name: String,
url: Url,
capabilities: Vec<janky::Capability>,
}
@ -113,9 +115,14 @@ impl Agent {
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct AgentConfig {
url: Url,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ServerConfig {
agents: Vec<Url>,
agents: HashMap<String, AgentConfig>,
projects: HashMap<String, Project>,
}
@ -128,7 +135,7 @@ impl ServerConfig {
impl Default for ServerConfig {
fn default() -> Self {
Self {
agents: vec![],
agents: HashMap::default(),
projects: HashMap::default(),
}
}
@ -188,14 +195,15 @@ async fn main() -> Result<(), tide::Error> {
}
}
for url in &config.agents {
debug!("Requesting capabilities from agent: {}", url);
let response: janky::CapsResponse = reqwest::get(url.join("/api/v1/capabilities")?)
for (name, agent) in config.agents.iter() {
debug!("Requesting capabilities from agent: {:?}", agent);
let response: janky::CapsResponse = reqwest::get(agent.url.join("/api/v1/capabilities")?)
.await?
.json()
.await?;
state.agents.push(Agent {
url: url.clone(),
name: name.clone(),
url: agent.url.clone(),
capabilities: response.caps,
});
}
@ -233,8 +241,12 @@ async fn main() -> Result<(), tide::Error> {
*/
app.at("/apidocs").serve_dir("apidocs/")?;
app.at("/static").serve_dir("static/")?;
debug!("Configuring routes");
app.at("/").get(routes::index);
app.at("/project/:name").get(routes::project);
debug!("Configuring API routes");
app.at("/api/v1/projects/:name")
.post(routes::api::execute_project);
app.listen(opts.listen).await?;
@ -251,6 +263,7 @@ mod tests {
let needs: Vec<String> = vec!["rspec".into(), "git".into(), "dotnet".into()];
let capabilities = vec![Capability::with_name("rustc")];
let agent = Agent {
name: "test".into(),
url: Url::parse("http://localhost").unwrap(),
capabilities,
};
@ -262,6 +275,7 @@ mod tests {
let needs: Vec<String> = vec!["dotnet".into()];
let capabilities = vec![Capability::with_name("dotnet")];
let agent = Agent {
name: "test".into(),
url: Url::parse("http://localhost").unwrap(),
capabilities,
};
@ -273,6 +287,7 @@ mod tests {
let needs: Vec<String> = vec!["rspec".into(), "git".into(), "dotnet".into()];
let capabilities = vec![Capability::with_name("dotnet")];
let agent = Agent {
name: "test".into(),
url: Url::parse("http://localhost").unwrap(),
capabilities,
};
@ -287,6 +302,7 @@ mod tests {
Capability::with_name("rspec"),
];
let agent = Agent {
name: "test".into(),
url: Url::parse("http://localhost").unwrap(),
capabilities,
};

View File

@ -13,7 +13,9 @@ use tide::{Body, Request};
pub async fn index(req: Request<AppState<'_>>) -> Result<Body, tide::Error> {
let params = json!({
"page": "home",
"agents" : req.state().agents,
"config" : req.state().config,
"projects" : crate::dao::Project::list(&req.state().db).await?,
});
let mut body = req.state().render("index", &params).await?;
@ -21,16 +23,37 @@ pub async fn index(req: Request<AppState<'_>>) -> Result<Body, tide::Error> {
Ok(body)
}
/**
* GET /project/:name
*/
pub async fn project(req: Request<AppState<'_>>) -> Result<Body, tide::Error> {
let name: String = req.param("name")?.into();
let params = json!({
"name" : name,
});
let mut body = req.state().render("project", &params).await?;
body.set_mime("text/html");
Ok(body)
}
pub mod api {
use crate::{AppState, JankyYml, Scm};
use log::*;
use serde::Deserialize;
use tide::{Request, Response, StatusCode};
#[derive(Debug, Deserialize)]
struct RedirectedForm {
next: Option<String>,
}
/**
* POST /projects/{name}
*/
pub async fn execute_project(req: Request<AppState<'_>>) -> Result<Response, tide::Error> {
pub async fn execute_project(mut req: Request<AppState<'_>>) -> tide::Result {
let name: String = req.param("name")?.into();
let next: RedirectedForm = req.body_form().await?;
let state = req.state();
if !state.config.has_project(&name) {
@ -80,6 +103,10 @@ pub mod api {
.send()
.await?;
if let Some(red) = &next.next {
return Ok(tide::Redirect::new(red).into());
}
return Ok(
json!({ "msg": format!("Executing on {}", &agent.url) }).into()
);

View File

@ -11,38 +11,58 @@
{{> _navbar }}
<div class="cover-container d-flex h-100 p-3 mx-auto flex-column">
<main role="main" class="inner cover">
<div id="projects">
<table class="table table-dark table-striped">
<thead>
<td>
<strong>Name</strong>
</td>
<td>
<strong>Description</strong>
</td>
<td>
<strong>Actions</strong>
</td>
</thead>
{{#each config.projects}}
<tr>
<td>
<a class="text-reset" href="/projects/{{@key}}"><strong>{{@key}}</strong></a>
</td>
<td>
No Description
</td>
<td>
<form method="POST" action="/api/v1/projects/{{@key}}">
<input type="submit" value="Execute"/>
</form>
</td>
</tr>
{{/each}}
</table>
</div>
</main>
<div class="row">
<div class="col col-sm-2">
<table class="table table-striped">
<thead>
<td>Agents</td>
</thead>
{{#each agents}}
<tr>
<td>
<span title="Capabilities: {{#each this.capabilities}}
* {{this.name}} {{/each}}">
{{this.name}}
</span>
</td>
</tr>
{{/each}}
</table>
</div>
<div class="col col-lg">
<main role="main" class="inner cover"> <div id="projects">
<table class="table table-dark table-striped">
<thead>
<td>
<strong>Name</strong>
</td>
<td>
<strong>Description</strong>
</td>
<td>
<strong>Actions</strong>
</td>
</thead>
{{#each projects}}
<tr>
<td>
<a class="text-reset" href="/project/{{this.name}}"><strong>{{this.name}}</strong></a>
</td>
<td>
</td>
<td>
<form method="POST" action="/api/v1/projects/{{this.name}}">
<input type="hidden" name="next" value="/project/{{this.name}}"/>
<input type="image" value="Execute" src="/static/icons/actions/view-refresh.svg"/>
</form>
</td>
</tr>
{{/each}}
</table>
</div>
</main>
</div>
</div>
</div>
</body>
</html>

28
views/project.hbs Normal file
View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<title>Janky - {{name}}</title>
<link type="text/css" rel="stylesheet" href="/static/bootstrap.min.css"/>
<script src="/static/bootstrap.bundle.min.js" integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN" crossorigin="anonymous"></script>
</head>
<body class="text-center">
{{> _navbar }}
<div class="cover-container d-flex h-100 p-3 mx-auto flex-column">
<div class="row">
<div class="col col-sm-2">
Links go here
</div>
<div class="col col-lg">
<main role="main" class="inner cover"> <div id="projects">
Runs go here
</main>
</div>
</div>
</div>
</body>
</html>