diff --git a/.tokeignore b/.tokeignore new file mode 100644 index 0000000..483a9c4 --- /dev/null +++ b/.tokeignore @@ -0,0 +1 @@ +package-lock.json \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 10d90bd..a429a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "bollard", "clap", "console-subscriber", + "deadqueue", "entity", "futures", "migration", @@ -23,6 +24,7 @@ dependencies = [ "sqlx", "tokio", "tower", + "uuid", ] [[package]] @@ -812,6 +814,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadqueue" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a2561fd313df162315935989dceb8c99db4ee1933358270a57a3cfb8c957f3" +dependencies = [ + "crossbeam-queue", + "tokio", +] + [[package]] name = "der" version = "0.7.7" @@ -3269,6 +3281,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ + "getrandom", "serde", ] diff --git a/Cargo.toml b/Cargo.toml index e52d568..543df76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,5 @@ tokio = { version = "1.29.1", features = ["full"] } tower = { version = "0.4.13", features = ["full"] } base64 = "0.21.2" console-subscriber = "0.1.10" +deadqueue = "0.2.4" +uuid = { version = "1.4.1", features = ["v4"] } diff --git a/client/App.tsx b/client/App.tsx index 8e2b140..f55ec73 100644 --- a/client/App.tsx +++ b/client/App.tsx @@ -1,5 +1,36 @@ import { StrictMode } from "react"; +import { RouterProvider } from "react-router"; +import { createBrowserRouter } from "react-router-dom"; + +import "normalize.css"; +import "@blueprintjs/core/lib/css/blueprint.css"; +import "@blueprintjs/icons/lib/css/blueprint-icons.css"; +import "@blueprintjs/icons/lib/css/blueprint-icons.css"; + +import Home from "./pages/Home"; +import Service from "./pages/Service"; +import Root from "./Root"; +import { QueryClient, QueryClientProvider } from "react-query"; + +const queryClient = new QueryClient(); + +const router = createBrowserRouter([ + { + path: "/", + Component: Root, + children: [ + { path: "/", Component: Home }, + { path: "/service/:id", Component: Service }, + ], + }, +]); export default function App() { - return ; + return ( + + + + + + ); } diff --git a/client/Root.module.scss b/client/Root.module.scss new file mode 100644 index 0000000..46095a5 --- /dev/null +++ b/client/Root.module.scss @@ -0,0 +1,11 @@ +.container { + max-width: 980px; + margin: auto; + + display: flex; + flex-direction: row; +} + +.sidebar { + flex-basis: 270px; +} diff --git a/client/Root.tsx b/client/Root.tsx new file mode 100644 index 0000000..d2c090d --- /dev/null +++ b/client/Root.tsx @@ -0,0 +1,44 @@ +import { Link, Outlet, useNavigate } from "react-router-dom"; +import styles from "./Root.module.scss"; +import { useQuery } from "react-query"; +import { MenuItem } from "@blueprintjs/core"; +import { getServices } from "./lib/api"; + +export default function Root() { + const navigate = useNavigate(); + const servicesQuery = useQuery("service/list", getServices); + + if (servicesQuery.status !== "success") { + console.log("SHIET", servicesQuery); + return <>Loading...; + } + + return ( +
+
+

+ aah +

+ "no honey, we have aws at home" ~mom + +
+ +

Services:

+ {servicesQuery.data.map((service) => { + let icon; + if (service.status === "New") { + icon = "clean"; + } + const text = `${service.name} (${service.id})`; + const goto = () => navigate(`/service/${service.id}`); + return ( + + ); + })} +
+
+ +
+
+ ); +} diff --git a/client/lib/api.ts b/client/lib/api.ts new file mode 100644 index 0000000..ce5948d --- /dev/null +++ b/client/lib/api.ts @@ -0,0 +1,13 @@ +export async function getServices() { + const response = await fetch("/api/service/list"); + const body = await response.json(); + console.log(body); + const { services } = body; + return services; +} + +export async function getService(id: number) { + const response = await fetch(`/api/service/${id}`); + const body = await response.json(); + return body; +} diff --git a/client/pages/Home.tsx b/client/pages/Home.tsx new file mode 100644 index 0000000..b9da7c2 --- /dev/null +++ b/client/pages/Home.tsx @@ -0,0 +1,45 @@ +import { FormEvent, useState } from "react"; +import { Button, InputGroup, TextArea } from "@blueprintjs/core"; +import { useNavigate } from "react-router"; +import { useMutation, useQueryClient } from "react-query"; + +export default function Home() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [name, setName] = useState(""); + const [content, setContent] = useState(""); + + const submitCreate = async (evt: FormEvent) => { + evt.stopPropagation(); + evt.preventDefault(); + + const formData = new FormData(); + formData.append("name", name); + formData.append("config", content); + + const result = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + const { id } = await result.json(); + queryClient.invalidateQueries("service/list"); + navigate(`/service/${id}`); + }; + + return ( + <> +
+ setName(evt.target.value)} + placeholder="Service name..." + /> + ") }), ) + .route("/service/list", get(aah::api::service::list)) + .route("/service/:id", get(aah::api::service::get)) .route("/upload", post(handle_upload_configuration)) .layer(from_fn(http_basic_auth)) .with_state(state); diff --git a/src/upload.rs b/src/upload.rs index 5923df4..5d1c95a 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -1,19 +1,27 @@ use std::time::Duration; -use anyhow::{anyhow, Result}; -use axum::extract::{Multipart, State}; +use anyhow::anyhow; +use axum::{ + extract::{Multipart, State}, + Json, +}; use bollard::{ container::{ Config as ContainerConfig, CreateContainerOptions, StartContainerOptions, }, image::CreateImageOptions, + network::CreateNetworkOptions, Docker, }; -use entity::service; +use entity::{process, service}; use futures::{FutureExt, TryStreamExt}; -use sea_orm::{ActiveModelTrait, Set}; +use migration::OnConflict; +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; +use serde_json::{json, Value}; use tokio::time::sleep; +use uuid::Uuid; +use crate::error::Result; use crate::{ config::{Config, SourceConfig}, error::AppError, @@ -24,34 +32,41 @@ use crate::{ pub async fn handle_upload_configuration( state: State, form: Multipart, -) -> Result<(), AppError> { +) -> Result, AppError> { let upload_form = extract_multipart(form).await.unwrap(); let config_string = serde_json::to_string(&upload_form.config).unwrap(); - state - .tx - .send(Job { - code: async move { - spawn_docker(&upload_form.config).await.unwrap(); - println!("done with job"); - } - .boxed(), - schedule: Schedule::ASAP, - }) - .unwrap(); - println!("spawned job"); - - service::ActiveModel { + let mut result = service::ActiveModel { name: Set(upload_form.name), config: Set(config_string), - overall_status: Set("New".into()), + status: Set("New".into()), ..Default::default() } .save(&state.conn) .await .unwrap(); - Ok(()) + let id = result.id.take().unwrap(); + + let conn = state.conn.clone(); + let job = Job { + code: async move { + spawn_docker(SpawnDockerOpts { + service_id: id, + conn, + config: &upload_form.config, + }) + .await + .unwrap(); + println!("done with job"); + } + .boxed(), + schedule: Schedule::ASAP, + }; + state.queue.push(job).await; + println!("spawned job"); + + Ok(Json(json! ({"id": id}))) } struct UploadForm { @@ -79,14 +94,50 @@ async fn extract_multipart(mut form: Multipart) -> Result { }) } -async fn spawn_docker(config: &Config) -> Result<()> { - println!("Loading config: {config:?}"); +struct SpawnDockerOpts<'a> { + conn: DatabaseConnection, + config: &'a Config, + service_id: i32, +} + +async fn spawn_docker(opts: SpawnDockerOpts<'_>) -> Result<()> { + println!("Loading config: {:?}", opts.config); let docker = Docker::connect_with_local_defaults().unwrap(); println!("connected to docker {docker:?}"); sleep(Duration::from_secs(1)).await; - for (service_name, service_config) in config.service.iter() { + // Create a bridge network + let network_id = format!("aah-network-{}", Uuid::new_v4()); + docker + .create_network(CreateNetworkOptions { + name: network_id.clone(), + ..Default::default() + }) + .await?; + println!("Created network."); + + // Create all the services + for (service_name, service_config) in opts.config.service.iter() { + // Insert metadata into the db + let new_process = process::ActiveModel { + service_id: Set(Some(opts.service_id)), + name: Set(service_name.clone()), + status: Set("New".to_owned()), + ..Default::default() + }; + process::Entity::insert(new_process) + .on_conflict( + OnConflict::columns([ + process::Column::ServiceId, + process::Column::Name, + ]) + .update_column(process::Column::Status) + .to_owned(), + ) + .exec(&opts.conn) + .await?; + println!("preparing config for {}", service_name); let SourceConfig::Image { image: from_image } = &service_config.source; @@ -120,6 +171,7 @@ async fn spawn_docker(config: &Config) -> Result<()> { .await .unwrap(); + // Start the container docker .start_container(&result.id, None::>) .await diff --git a/tsconfig.json b/tsconfig.json index 383c938..b9f1100 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1 +1 @@ -{ "compilerOptions": { "jsx": "react-jsx" } } +{ "compilerOptions": { "lib": ["ESNext", "DOM"], "jsx": "react-jsx" } }