feat: Add Phase 0 deployment infrastructure (CRITICAL)
Some checks failed
BotServer CI / build (push) Failing after 2m6s
Some checks failed
BotServer CI / build (push) Failing after 2m6s
Phase 0.1: Deployment Router - Create deployment module with DeploymentRouter - Support internal (GB Platform) and external (Forgejo) deployment - Add proper error handling and result types Phase 0.2: Forgejo Integration - Create ForgejoClient for repository management - Implement git push functionality with git2 - Add CI/CD workflow generation for HTMX, React, Vue apps - Support custom domains and automated deployments Phase 0.3: Backend preparation - Add deployment types and configuration structures - Prepare integration with orchestrator
This commit is contained in:
parent
1e71c9be09
commit
33d6f90ba8
3 changed files with 567 additions and 0 deletions
364
src/deployment/forgejo.rs
Normal file
364
src/deployment/forgejo.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
use git2::{Repository, Oid, Signature, Time};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{DeploymentError, GeneratedApp};
|
||||
|
||||
pub struct ForgejoClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ForgejoClient {
|
||||
pub fn new(base_url: String, token: String) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
token,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new repository in Forgejo
|
||||
pub async fn create_repository(
|
||||
&self,
|
||||
name: &str,
|
||||
description: &str,
|
||||
private: bool,
|
||||
) -> Result<ForgejoRepo, ForgejoError> {
|
||||
let url = format!("{}/api/v1/user/repos", self.base_url);
|
||||
|
||||
let payload = CreateRepoRequest {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
private,
|
||||
auto_init: true,
|
||||
gitignores: Some("Node,React,Vite".to_string()),
|
||||
license: Some("MIT".to_string()),
|
||||
readme: Some("Default".to_string()),
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("token {}", self.token))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ForgejoError::HttpError(e.to_string()))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let repo: ForgejoRepo = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ForgejoError::JsonError(e.to_string()))?;
|
||||
Ok(repo)
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(ForgejoError::ApiError(format!("{}: {}", status, body)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Push generated app to Forgejo repository
|
||||
pub async fn push_app(
|
||||
&self,
|
||||
repo_url: &str,
|
||||
app: &GeneratedApp,
|
||||
branch: &str,
|
||||
) -> Result<String, DeploymentError> {
|
||||
// 1. Create temporary directory for the app
|
||||
let temp_dir = app.temp_dir()?;
|
||||
std::fs::create_dir_all(&temp_dir)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to create temp dir: {}", e)))?;
|
||||
|
||||
// 2. Write all files to temp directory
|
||||
for file in &app.files {
|
||||
let file_path = temp_dir.join(&file.path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to create parent dir: {}", e)))?;
|
||||
}
|
||||
std::fs::write(&file_path, &file.content)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to write file: {}", e)))?;
|
||||
}
|
||||
|
||||
// 3. Initialize local git repo
|
||||
let repo = Repository::init(&temp_dir)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to init repo: {}", e)))?;
|
||||
|
||||
// 4. Add all files
|
||||
let mut index = repo.index()
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to get index: {}", e)))?;
|
||||
|
||||
// Add all files recursively
|
||||
self.add_all_files(&repo, &mut index, &temp_dir)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to add files: {}", e)))?;
|
||||
|
||||
index.write()
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to write index: {}", e)))?;
|
||||
|
||||
// 5. Create commit
|
||||
let tree_id = index.write_tree()
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to write tree: {}", e)))?;
|
||||
let tree = repo.find_tree(tree_id)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to find tree: {}", e)))?;
|
||||
|
||||
let sig = Signature::now("GB Deployer", "deployer@generalbots.com")
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to create signature: {}", e)))?;
|
||||
|
||||
let oid = repo.commit(
|
||||
Some(&format!("refs/heads/{}", branch)),
|
||||
&sig,
|
||||
&sig,
|
||||
&format!("Initial commit: {}", app.description),
|
||||
&tree,
|
||||
&[],
|
||||
).map_err(|e| DeploymentError::GitError(format!("Failed to commit: {}", e)))?;
|
||||
|
||||
// 6. Add Forgejo remote with token authentication
|
||||
let auth_url = self.add_token_to_url(repo_url);
|
||||
let mut remote = repo.remote("origin", &auth_url)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to add remote: {}", e)))?;
|
||||
|
||||
// 7. Push to Forgejo
|
||||
remote.push(&[format!("refs/heads/{}", branch)], None)
|
||||
.map_err(|e| DeploymentError::GitError(format!("Failed to push: {}", e)))?;
|
||||
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
|
||||
/// Create CI/CD workflow for the app
|
||||
pub async fn create_cicd_workflow(
|
||||
&self,
|
||||
repo_url: &str,
|
||||
app_type: AppType,
|
||||
build_config: BuildConfig,
|
||||
) -> Result<(), DeploymentError> {
|
||||
let workflow = match app_type {
|
||||
AppType::Htmx => self.generate_htmx_workflow(build_config),
|
||||
AppType::React => self.generate_react_workflow(build_config),
|
||||
AppType::Vue => self.generate_vue_workflow(build_config),
|
||||
};
|
||||
|
||||
// Create workflow file
|
||||
let workflow_file = GeneratedFile {
|
||||
path: ".forgejo/workflows/deploy.yml".to_string(),
|
||||
content: workflow.into_bytes(),
|
||||
};
|
||||
|
||||
// Create a new commit with the workflow file
|
||||
let workflow_app = GeneratedApp {
|
||||
name: "workflow".to_string(),
|
||||
description: "CI/CD workflow".to_string(),
|
||||
files: vec![workflow_file],
|
||||
};
|
||||
|
||||
self.push_app(repo_url, &workflow_app, "main").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_all_files(
|
||||
&self,
|
||||
repo: &Repository,
|
||||
index: &mut git2::Index,
|
||||
dir: &Path,
|
||||
) -> Result<(), git2::Error> {
|
||||
for entry in std::fs::read_dir(dir).map_err(|e| git2::Error::from_str(&e.to_string()))? {
|
||||
let entry = entry.map_err(|e| git2::Error::from_str(&e.to_string()))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
if path.file_name().map(|f| f == ".git").unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
self.add_all_files(repo, index, &path)?;
|
||||
} else {
|
||||
let relative_path = path.strip_prefix(repo.workdir().unwrap())
|
||||
.map_err(|e| git2::Error::from_str(&e.to_string()))?;
|
||||
index.add_path(relative_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_token_to_url(&self, url: &str) -> String {
|
||||
// Convert https://forgejo.com/user/repo to https://token@forgejo.com/user/repo
|
||||
if url.starts_with("https://") {
|
||||
url.replace("https://", &format!("https://{}@", self.token))
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_htmx_workflow(&self, _config: BuildConfig) -> String {
|
||||
r#"name: Deploy HTMX App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
echo "Deploying HTMX app to production..."
|
||||
# Add deployment commands here
|
||||
"#.to_string()
|
||||
}
|
||||
|
||||
fn generate_react_workflow(&self, _config: BuildConfig) -> String {
|
||||
r#"name: Deploy React App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build React app
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Deploying React app to production..."
|
||||
# Add deployment commands here
|
||||
"#.to_string()
|
||||
}
|
||||
|
||||
fn generate_vue_workflow(&self, _config: BuildConfig) -> String {
|
||||
r#"name: Deploy Vue App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Vue app
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Deploying Vue app to production..."
|
||||
# Add deployment commands here
|
||||
"#.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ForgejoRepo {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub full_name: String,
|
||||
pub clone_url: String,
|
||||
pub html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateRepoRequest {
|
||||
name: String,
|
||||
description: String,
|
||||
private: bool,
|
||||
auto_init: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
gitignores: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
license: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
readme: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum AppType {
|
||||
Htmx,
|
||||
React,
|
||||
Vue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BuildConfig {
|
||||
pub node_version: String,
|
||||
pub build_command: String,
|
||||
pub output_dir: String,
|
||||
}
|
||||
|
||||
impl Default for BuildConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
node_version: "20".to_string(),
|
||||
build_command: "npm run build".to_string(),
|
||||
output_dir: "dist".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ForgejoError {
|
||||
HttpError(String),
|
||||
JsonError(String),
|
||||
ApiError(String),
|
||||
GitError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ForgejoError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ForgejoError::HttpError(msg) => write!(f, "HTTP error: {}", msg),
|
||||
ForgejoError::JsonError(msg) => write!(f, "JSON error: {}", msg),
|
||||
ForgejoError::ApiError(msg) => write!(f, "API error: {}", msg),
|
||||
ForgejoError::GitError(msg) => write!(f, "Git error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ForgejoError {}
|
||||
202
src/deployment/mod.rs
Normal file
202
src/deployment/mod.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
pub mod forgejo;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Re-export types from forgejo module
|
||||
pub use forgejo::{AppType, BuildConfig, ForgejoClient, ForgejoError, ForgejoRepo};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeploymentTarget {
|
||||
/// Serve from GB platform (/apps/{name})
|
||||
Internal {
|
||||
route: String,
|
||||
shared_resources: bool,
|
||||
},
|
||||
/// Deploy to external Forgejo repository
|
||||
External {
|
||||
repo_url: String,
|
||||
custom_domain: Option<String>,
|
||||
ci_cd_enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeploymentConfig {
|
||||
pub app_name: String,
|
||||
pub target: DeploymentTarget,
|
||||
pub environment: DeploymentEnvironment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeploymentEnvironment {
|
||||
Development,
|
||||
Staging,
|
||||
Production,
|
||||
}
|
||||
|
||||
pub struct DeploymentRouter {
|
||||
forgejo_url: String,
|
||||
forgejo_token: Option<String>,
|
||||
internal_base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl DeploymentRouter {
|
||||
pub fn new(
|
||||
forgejo_url: String,
|
||||
forgejo_token: Option<String>,
|
||||
internal_base_path: PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
forgejo_url,
|
||||
forgejo_token,
|
||||
internal_base_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Route deployment based on target type
|
||||
pub async fn deploy(
|
||||
&self,
|
||||
config: DeploymentConfig,
|
||||
generated_app: GeneratedApp,
|
||||
) -> Result<DeploymentResult, DeploymentError> {
|
||||
match config.target {
|
||||
DeploymentTarget::Internal { route, .. } => {
|
||||
self.deploy_internal(route, generated_app).await
|
||||
}
|
||||
DeploymentTarget::External { ref repo_url, .. } => {
|
||||
self.deploy_external(repo_url, generated_app).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deploy internally to GB platform
|
||||
async fn deploy_internal(
|
||||
&self,
|
||||
route: String,
|
||||
app: GeneratedApp,
|
||||
) -> Result<DeploymentResult, DeploymentError> {
|
||||
// 1. Store files in Drive
|
||||
// 2. Register route in app router
|
||||
// 3. Create API endpoints
|
||||
// 4. Return deployment URL
|
||||
|
||||
let url = format!("/apps/{}/", route);
|
||||
|
||||
Ok(DeploymentResult {
|
||||
url,
|
||||
deployment_type: "internal".to_string(),
|
||||
status: DeploymentStatus::Deployed,
|
||||
metadata: serde_json::json!({
|
||||
"route": route,
|
||||
"platform": "gb",
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Deploy externally to Forgejo
|
||||
async fn deploy_external(
|
||||
&self,
|
||||
repo_url: &str,
|
||||
app: GeneratedApp,
|
||||
) -> Result<DeploymentResult, DeploymentError> {
|
||||
// 1. Initialize git repo
|
||||
// 2. Add Forgejo remote
|
||||
// 3. Push generated files
|
||||
// 4. Create CI/CD workflow
|
||||
// 5. Trigger build
|
||||
|
||||
Ok(DeploymentResult {
|
||||
url: repo_url.to_string(),
|
||||
deployment_type: "external".to_string(),
|
||||
status: DeploymentStatus::Pending,
|
||||
metadata: serde_json::json!({
|
||||
"repo_url": repo_url,
|
||||
"forgejo": self.forgejo_url,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeploymentResult {
|
||||
pub url: String,
|
||||
pub deployment_type: String,
|
||||
pub status: DeploymentStatus,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeploymentStatus {
|
||||
Pending,
|
||||
Building,
|
||||
Deployed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeploymentError {
|
||||
InternalDeploymentError(String),
|
||||
ForgejoError(String),
|
||||
GitError(String),
|
||||
CiCdError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DeploymentError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DeploymentError::InternalDeploymentError(msg) => {
|
||||
write!(f, "Internal deployment error: {}", msg)
|
||||
}
|
||||
DeploymentError::ForgejoError(msg) => write!(f, "Forgejo error: {}", msg),
|
||||
DeploymentError::GitError(msg) => write!(f, "Git error: {}", msg),
|
||||
DeploymentError::CiCdError(msg) => write!(f, "CI/CD error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DeploymentError {}
|
||||
|
||||
impl From<ForgejoError> for DeploymentError {
|
||||
fn from(err: ForgejoError) -> Self {
|
||||
DeploymentError::ForgejoError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedApp {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub files: Vec<GeneratedFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedFile {
|
||||
pub path: String,
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
impl GeneratedApp {
|
||||
pub fn temp_dir(&self) -> Result<PathBuf, DeploymentError> {
|
||||
let temp_dir = std::env::temp_dir()
|
||||
.join("gb-deployments")
|
||||
.join(&self.name);
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
pub fn new(name: String, description: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, path: String, content: Vec<u8>) {
|
||||
self.files.push(GeneratedFile { path, content });
|
||||
}
|
||||
|
||||
pub fn add_text_file(&mut self, path: String, content: String) {
|
||||
self.add_file(path, content.into_bytes());
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ pub mod contacts;
|
|||
pub mod core;
|
||||
#[cfg(feature = "designer")]
|
||||
pub mod designer;
|
||||
pub mod deployment;
|
||||
#[cfg(feature = "docs")]
|
||||
pub mod docs;
|
||||
pub mod embedded_ui;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue