Compare commits

...

3 Commits

Author SHA1 Message Date
a8a9fce3bc feat: with compose 2025-08-01 13:40:06 +02:00
01b1d79d75 feat: update project 2025-07-31 22:00:10 +02:00
ba0e1ad1dd chore: cleanup 2025-07-20 18:26:40 +02:00
24 changed files with 537 additions and 41 deletions

79
Cargo.lock generated
View File

@@ -215,6 +215,9 @@ name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
dependencies = [
"serde",
]
[[package]]
name = "bollard"
@@ -369,6 +372,27 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -912,6 +936,16 @@ version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libredox"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "litemap"
version = "0.8.0"
@@ -990,12 +1024,14 @@ dependencies = [
"bollard",
"bytes",
"clap",
"dirs",
"futures-util",
"norun-grpc-interface",
"notmad",
"pretty_assertions",
"prost",
"prost-types",
"ron",
"serde",
"tokio",
"tokio-util",
@@ -1003,6 +1039,7 @@ dependencies = [
"tonic",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -1079,6 +1116,12 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "overload"
version = "0.1.1"
@@ -1304,6 +1347,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror",
]
[[package]]
name = "ref-cast"
version = "1.0.24"
@@ -1368,6 +1422,19 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "ron"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f"
dependencies = [
"base64",
"bitflags",
"serde",
"serde_derive",
"unicode-ident",
]
[[package]]
name = "rustc-demangle"
version = "0.1.25"
@@ -1924,6 +1991,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"

View File

@@ -3,42 +3,69 @@
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct PublishRequest {
#[prost(message, optional, tag="1")]
#[prost(message, optional, tag = "1")]
pub project: ::core::option::Option<Project>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
pub struct PublishResponse {
}
pub struct PublishResponse {}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetTopicRequest {
#[prost(string, tag="1")]
#[prost(string, tag = "1")]
pub topic: ::prost::alloc::string::String,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetTopicResponse {
#[prost(message, optional, tag="1")]
#[prost(message, optional, tag = "1")]
pub projects: ::core::option::Option<Projects>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Projects {
#[prost(message, repeated, tag="1")]
#[prost(message, repeated, tag = "1")]
pub projects: ::prost::alloc::vec::Vec<Project>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Project {
#[prost(string, tag="1")]
#[prost(string, tag = "1")]
pub name: ::prost::alloc::string::String,
#[prost(string, tag="2")]
#[prost(string, tag = "2")]
pub image: ::prost::alloc::string::String,
#[prost(string, tag="3")]
#[prost(string, tag = "3")]
#[prost(uint32, optional, tag="2")]
pub port: ::core::option::Option<u32>,
#[prost(oneof="project::ProjectType", tags="3, 4")]
pub project_type: ::core::option::Option<project::ProjectType>,
}
/// Nested message and enum types in `Project`.
pub mod project {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Oneof)]
pub enum ProjectType {
#[prost(message, tag="3")]
Container(super::Container),
#[prost(message, tag="4")]
Compose(super::Compose),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Container {
#[prost(string, tag="1")]
pub image: ::prost::alloc::string::String,
#[prost(string, tag="2")]
pub version: ::prost::alloc::string::String,
#[prost(uint32, optional, tag="4")]
#[prost(uint32, optional, tag = "4")]
pub port: ::core::option::Option<u32>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Compose {
#[prost(map="string, bytes", tag="1")]
pub files: ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::vec::Vec<u8>>,
}
include!("norun.v1.tonic.rs");
// @@protoc_insertion_point(module)
// @@protoc_insertion_point(module)

View File

@@ -4,7 +4,6 @@ pub mod registry_service_client {
#![allow(unused_variables, dead_code, missing_docs, clippy::let_unit_value)]
use tonic::codegen::*;
use tonic::codegen::http::Uri;
///
#[derive(Debug, Clone)]
pub struct RegistryServiceClient<T> {
inner: tonic::client::Grpc<T>,
@@ -85,7 +84,6 @@ pub mod registry_service_client {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
///
pub async fn publish(
&mut self,
request: impl tonic::IntoRequest<super::PublishRequest>,
@@ -111,7 +109,6 @@ pub mod registry_service_client {
.insert(GrpcMethod::new("norun.v1.RegistryService", "Publish"));
self.inner.unary(req, path, codec).await
}
///
pub async fn get_topic(
&mut self,
request: impl tonic::IntoRequest<super::GetTopicRequest>,
@@ -146,12 +143,10 @@ pub mod registry_service_server {
/// Generated trait containing gRPC methods that should be implemented for use with RegistryServiceServer.
#[async_trait]
pub trait RegistryService: Send + Sync + 'static {
///
async fn publish(
&self,
request: tonic::Request<super::PublishRequest>,
) -> std::result::Result<tonic::Response<super::PublishResponse>, tonic::Status>;
///
async fn get_topic(
&self,
request: tonic::Request<super::GetTopicRequest>,
@@ -160,7 +155,6 @@ pub mod registry_service_server {
tonic::Status,
>;
}
///
#[derive(Debug)]
pub struct RegistryServiceServer<T: RegistryService> {
inner: _Inner<T>,

View File

@@ -23,6 +23,9 @@ async-trait = "0.1.88"
notmad = "0.7.2"
bollard = "0.19.1"
futures-util = "0.3.31"
dirs = "6.0.0"
uuid = { version = "1.17.0", features = ["serde", "v4"] }
ron = "0.10.1"
[dev-dependencies]
pretty_assertions = "1.4.1"

View File

@@ -1,10 +1,14 @@
use clap::{Parser, Subcommand};
use crate::{
cli::{publish::PublishCommand, serve::ServeCommand, subscribe::SubscribeCommand},
cli::{
node::NodeCommand, publish::PublishCommand, serve::ServeCommand,
subscribe::SubscribeCommand,
},
state::ClientState,
};
mod node;
mod publish;
mod serve;
mod subscribe;
@@ -25,6 +29,7 @@ struct Cli {
#[derive(Subcommand)]
enum CliSubcommands {
Node(NodeCommand),
Subscribe(SubscribeCommand),
Publish(PublishCommand),
Serve(ServeCommand),
@@ -36,6 +41,7 @@ pub async fn execute() -> anyhow::Result<()> {
let state = ClientState::new(&cmd.server_url);
match cmd.subcommands {
CliSubcommands::Node(cmd) => cmd.execute(&state).await,
CliSubcommands::Publish(cmd) => cmd.execute(&state).await,
CliSubcommands::Serve(cmd) => cmd.execute().await,
CliSubcommands::Subscribe(cmd) => cmd.execute(&state).await,

View File

@@ -0,0 +1,22 @@
use crate::{cli::node::start::StartCommand, state::ClientState};
mod start;
#[derive(clap::Parser)]
pub struct NodeCommand {
#[clap(subcommand)]
commands: NodeCommands,
}
#[derive(clap::Subcommand)]
pub enum NodeCommands {
Start(StartCommand),
}
impl NodeCommand {
pub async fn execute(&self, state: &ClientState) -> anyhow::Result<()> {
match &self.commands {
NodeCommands::Start(start_command) => start_command.execute(state).await,
}
}
}

View File

@@ -0,0 +1,15 @@
use crate::{node::services::node_service::NodeServiceState, state::ClientState};
#[derive(clap::Parser)]
pub struct StartCommand {}
impl StartCommand {
pub async fn execute(&self, state: &ClientState) -> anyhow::Result<()> {
notmad::Mad::builder()
.add(state.node_service())
.run()
.await?;
Ok(())
}
}

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf;
use crate::{grpc_client::GrpcClientState, models::ProjectTag, project_file, state::ClientState};
use crate::{grpc_client::GrpcClientState, project_file, state::ClientState};
#[derive(clap::Parser, Debug)]
pub struct PublishCommand {

View File

@@ -1,3 +1,5 @@
use norun_grpc_interface::project::ProjectType;
use crate::{
container_runtime::ContainerRuntimeState, grpc_client::GrpcClientState, models::port::Port,
state::ClientState,
@@ -19,16 +21,26 @@ impl SubscribeCommand {
for project in projects.projects {
println!("project: {project:?}");
runtime
.ensure_running(
&project.name,
&format!("{}:{}", project.image, project.version),
vec![Port {
host_port: 38080,
container_port: 80,
}],
)
.await?;
match project.project_type {
Some(project_type) => match project_type {
ProjectType::Container(container) => {
runtime
.ensure_running(
&project.name,
&format!("{}:{}", container.image, container.version),
vec![Port {
host_port: 38080,
container_port: 80,
}],
)
.await?;
}
ProjectType::Compose(compose) => {
// Allocate a local project
}
},
None => todo!(),
}
}
Ok(())

View File

@@ -2,10 +2,9 @@ use std::{collections::HashMap, sync::LazyLock};
use bollard::{
Docker,
image::CreateImageOptions,
query_parameters::{
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, ListContainersOptionsBuilder,
StartContainerOptions, StartContainerOptionsBuilder,
StartContainerOptionsBuilder,
},
secret::{ContainerCreateBody, HostConfig, PortBinding},
};

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use norun_grpc_interface::{
GetTopicRequest, Projects, PublishRequest, registry_service_client::RegistryServiceClient,
};
@@ -70,9 +72,39 @@ impl From<ProjectFile> for norun_grpc_interface::Project {
fn from(value: ProjectFile) -> Self {
Self {
name: value.project.name,
image: value.container.image,
version: value.container.version,
port: value.expose.and_then(|e| e.port),
project_type: Some({
match (value.container, value.compose) {
(None, None) => panic!("either a container or compose is required"),
(Some(_), Some(_)) => {
panic!("either a container or compose is required, but not both")
}
(Some(container), None) => {
norun_grpc_interface::project::ProjectType::Container(
norun_grpc_interface::Container {
image: container.image,
version: container.version,
},
)
}
(None, Some(compose)) => norun_grpc_interface::project::ProjectType::Compose(
norun_grpc_interface::Compose {
// TODO: dirty hack to get files out for compose, it should instead
// be transformed into an intermediary format, or simply extraced from the <From>
files: compose
.include
.into_iter()
.map(|i| {
(
i.to_string_lossy().to_string(),
std::fs::read(&i).expect("to be able to read include file"),
)
})
.collect::<HashMap<_, _>>(),
},
),
}
}),
}
}
}

View File

@@ -7,11 +7,15 @@ mod state;
mod server;
mod services;
mod grpc_client;
mod grpc_server;
mod container_runtime;
mod node;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()

View File

@@ -1,5 +1,7 @@
pub mod project_tag;
pub use project_tag::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub mod port {
#[derive(Clone, Debug, PartialEq)]
@@ -8,3 +10,15 @@ pub mod port {
pub container_port: usize,
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub struct Project {
pub id: Uuid,
pub spec: ProjectSpec,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
pub enum ProjectSpec {
Compose {},
Container {},
}

3
crates/norun/src/node.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod config;
pub mod services;

View File

@@ -0,0 +1,15 @@
use std::collections::BTreeMap;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
node_name: Option<String>,
subscriptions: BTreeMap<String, Subscription>,
}
#[derive(Debug, Deserialize)]
pub struct Subscription {
enabled: Option<bool>,
}

View File

@@ -0,0 +1,3 @@
pub mod config_service;
pub mod data_store;
pub mod node_service;

View File

@@ -0,0 +1,31 @@
use anyhow::Context;
use crate::{node::config::Config, state::ClientState};
pub struct ConfigService {}
impl ConfigService {
pub async fn get_config(&self) -> anyhow::Result<Config> {
let config_file_path = dirs::config_dir()
.context("failed to get config dir")?
.join("norun")
.join("node")
.join("config.toml");
let config_file_content = tokio::fs::read_to_string(&config_file_path).await?;
let config: Config = toml::from_str(&config_file_content)?;
Ok(config)
}
}
pub trait ConfigServiceState {
fn config_service(&self) -> ConfigService;
}
impl ConfigServiceState for ClientState {
fn config_service(&self) -> ConfigService {
ConfigService {}
}
}

View File

@@ -0,0 +1,69 @@
use std::sync::Arc;
use notmad::{Component, MadError};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use crate::state::ClientState;
#[derive(Clone)]
pub struct DataStore {
sender: tokio::sync::mpsc::Sender<()>,
receiver: Arc<Mutex<tokio::sync::mpsc::Receiver<()>>>,
}
impl DataStore {
pub async fn execute(&self, cancellation_token: CancellationToken) -> anyhow::Result<()> {
let mut rec = self.receiver.lock().await;
loop {
let msg = tokio::select! {
_ = cancellation_token.cancelled() => {
return Ok(())
},
item = rec.recv() => {
match item {
Some(item) => item,
None => return Ok(()),
}
}
};
tracing::debug!("handling item");
}
}
pub async fn publish(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[async_trait::async_trait]
impl Component for DataStore {
fn name(&self) -> Option<String> {
Some("norun/node/data-store".into())
}
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
self.execute(cancellation_token)
.await
.map_err(notmad::MadError::Inner)?;
Ok(())
}
}
pub trait DataStoreState {
fn data_store(&self) -> DataStore;
}
impl DataStoreState for ClientState {
fn data_store(&self) -> DataStore {
let (sender, receiver) = tokio::sync::mpsc::channel(100);
DataStore {
sender,
receiver: Arc::new(Mutex::new(receiver)),
}
}
}

View File

@@ -0,0 +1,35 @@
use notmad::{Component, MadError};
use tokio_util::sync::CancellationToken;
use crate::state::ClientState;
pub struct NodeService {}
#[async_trait::async_trait]
impl Component for NodeService {
fn name(&self) -> Option<String> {
Some("norun/node".into())
}
async fn setup(&self) -> Result<(), MadError> {
tracing::info!("starting norun/node!");
Ok(())
}
async fn run(&self, cancellation_token: CancellationToken) -> Result<(), MadError> {
cancellation_token.cancelled().await;
Ok(())
}
}
pub trait NodeServiceState {
fn node_service(&self) -> NodeService;
}
impl NodeServiceState for ClientState {
fn node_service(&self) -> NodeService {
NodeService {}
}
}

View File

@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::Deserialize;
@@ -8,8 +8,9 @@ const NORUN_PROJECT_FILE_NAME: &str = "norun.toml";
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct ProjectFile {
pub project: ProjectDecl,
pub container: ContainerDecl,
pub container: Option<ContainerDecl>,
pub expose: Option<ExposeDecl>,
pub compose: Option<ComposeDecl>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -23,6 +24,11 @@ pub struct ContainerDecl {
pub version: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct ComposeDecl {
pub include: Vec<PathBuf>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct ExposeDecl {
pub port: Option<u32>,
@@ -72,10 +78,11 @@ port = 8080
project: ProjectDecl {
name: "hello-world".into(),
},
container: ContainerDecl {
container: Some(ContainerDecl {
image: "kasperhermansen/hello-world".into(),
version: "latest".into(),
},
}),
compose: None,
expose: Some(ExposeDecl { port: Some(8080) }),
};

View File

@@ -0,0 +1 @@
pub mod project_registry;

View File

@@ -0,0 +1,104 @@
use std::path::PathBuf;
use anyhow::Context;
use tokio::io::AsyncWriteExt;
use crate::{models::Project, state::ClientState};
pub struct ProjectRegistry {
state_dir: PathBuf,
}
impl ProjectRegistry {
pub async fn get_project(&self, project: &Project) -> anyhow::Result<Option<Project>> {
let project_dir = self.project_file(project)?;
if !project_dir.exists() {
return Ok(None);
}
let project_content = tokio::fs::read_to_string(&project_dir)
.await
.context("failed to read ron file")?;
let project: Project = ron::from_str(&project_content)
.context(format!("failed to read: '{}'", project_dir.display()))?;
Ok(Some(project))
}
pub async fn create_project(&self, project: &Project) -> anyhow::Result<()> {
match self.get_project(project).await {
Ok(_) => anyhow::bail!("project already exists"),
Err(_) => {
// continue
}
}
let project_file_path = self.project_file(project)?;
if let Some(project_file) = project_file_path.parent() {
tokio::fs::create_dir_all(project_file)
.await
.context("create ron project dir")?;
}
let mut project_file = tokio::fs::File::create_new(&project_file_path)
.await
.context("create project file")?;
let project_content = ron::to_string(project)?;
project_file
.write_all(&project_content.as_bytes())
.await
.context("write project file")?;
Ok(())
}
pub async fn update_project(&self, project: &Project) -> anyhow::Result<()> {
let project_file_path = self.project_file(project)?;
if let Some(project_file) = project_file_path.parent() {
tokio::fs::create_dir_all(project_file)
.await
.context("update ron project dir")?;
}
let mut project_file = tokio::fs::File::create_new(&project_file_path)
.await
.context("update project file")?;
let project_content = ron::to_string(project)?;
project_file
.write_all(project_content.as_bytes())
.await
.context("update project file")?;
Ok(())
}
fn project_file(&self, project: &Project) -> anyhow::Result<PathBuf> {
let project_dir = self
.state_dir
.join(project.id.to_string())
.join("project.ron");
Ok(project_dir)
}
}
pub trait ProjectRegistryState {
fn project_registry(&self) -> ProjectRegistry;
}
impl ProjectRegistryState for ClientState {
fn project_registry(&self) -> ProjectRegistry {
ProjectRegistry {
state_dir: dirs::state_dir()
.expect("to be able to find state")
.join("norun")
.join("projects"),
}
}
}

View File

@@ -27,9 +27,19 @@ message Projects {
message Project {
string name = 1;
optional uint32 port = 2;
string image = 2;
string version = 3;
optional uint32 port = 4;
oneof project_type {
Container container = 3;
Compose compose = 4;
}
}
message Container {
string image = 1;
string version = 2;
}
message Compose {
map<string, bytes> files = 1;
}

11
mise.toml Normal file
View File

@@ -0,0 +1,11 @@
[tasks."node"]
env = { RUST_LOG = "norun=trace,notmad=debug,info" }
run = "cargo run -p norun -- node"
[tasks."test"]
alias = ["t"]
run = "cargo nextest run"
[tasks."generate"]
alias = ["g", "gen"]
run = "buf generate"