fix: remove repository-local server configuration paths
This commit is contained in:
+1
-3
@@ -19,11 +19,9 @@ services:
|
|||||||
- runtime
|
- runtime
|
||||||
expose:
|
expose:
|
||||||
- "8787"
|
- "8787"
|
||||||
environment:
|
|
||||||
YOI_BROWSER_PUBLIC_URL: "${YOI_BROWSER_PUBLIC_URL:-http://localhost:8080}"
|
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/server-data
|
- server-data:/server-data
|
||||||
- ./docker/workspace:/workspace:ro
|
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: yoi-webui:latest
|
image: yoi-webui:latest
|
||||||
|
|||||||
@@ -7,42 +7,50 @@ use url::Url;
|
|||||||
|
|
||||||
use crate::hosts::RemoteRuntimeConfig;
|
use crate::hosts::RemoteRuntimeConfig;
|
||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::repositories::ConfiguredRepository;
|
|
||||||
use crate::server::{AuthConfig, ServerConfig};
|
use crate::server::{AuthConfig, ServerConfig};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
|
|
||||||
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
|
||||||
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str =
|
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||||
include_str!("../../../resources/workspace-backend.default.toml");
|
|
||||||
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
|
||||||
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
|
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
|
||||||
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
|
||||||
const DEFAULT_MAX_RECORDS: usize = 200;
|
const DEFAULT_MAX_RECORDS: usize = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ServerHostConfigFile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub browser: ServerBrowserConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerHostConfigFile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
browser: ServerBrowserConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ServerBrowserConfig {
|
||||||
|
#[serde(default = "default_browser_public_url")]
|
||||||
|
pub public_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerBrowserConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
public_url: default_browser_public_url(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_browser_public_url() -> String {
|
fn default_browser_public_url() -> String {
|
||||||
DEFAULT_BROWSER_PUBLIC_URL.to_string()
|
DEFAULT_BROWSER_PUBLIC_URL.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_auth_cookie_name() -> String {
|
|
||||||
DEFAULT_AUTH_COOKIE_NAME.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendConfigFile {
|
|
||||||
#[serde(default)]
|
|
||||||
pub server: WorkspaceBackendServerConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub data: WorkspaceBackendDataConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub limits: WorkspaceBackendLimitsConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auth: WorkspaceBackendAuthConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
pub repositories: Vec<WorkspaceRepositoryConfigFile>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct BackendRuntimesConfigFile {
|
pub struct BackendRuntimesConfigFile {
|
||||||
@@ -50,63 +58,6 @@ pub struct BackendRuntimesConfigFile {
|
|||||||
pub runtimes: WorkspaceBackendRuntimesConfig,
|
pub runtimes: WorkspaceBackendRuntimesConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendServerConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub listen: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub static_assets_dir: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendDataConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub root: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_database_path: Option<PathBuf>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub embedded_runtime_store_root: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendLimitsConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_records: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceBackendAuthConfig {
|
|
||||||
#[serde(default = "default_browser_public_url")]
|
|
||||||
pub browser_public_url: String,
|
|
||||||
#[serde(default = "default_auth_cookie_name")]
|
|
||||||
pub cookie_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WorkspaceBackendAuthConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
browser_public_url: default_browser_public_url(),
|
|
||||||
cookie_name: default_auth_cookie_name(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceRepositoryConfigFile {
|
|
||||||
pub id: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub uri: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub default_selector: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceBackendRuntimesConfig {
|
pub struct WorkspaceBackendRuntimesConfig {
|
||||||
@@ -125,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
|
|||||||
pub token_ref: Option<String>,
|
pub token_ref: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ConfigDiff {
|
|
||||||
pub differs: bool,
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigDiff {
|
|
||||||
fn new(default: &str, local: &str) -> Self {
|
|
||||||
if default == local {
|
|
||||||
return Self {
|
|
||||||
differs: false,
|
|
||||||
text: "workspace backend local config matches the packaged default\n".to_string(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut text = String::from("--- packaged default\n+++ workspace local\n");
|
|
||||||
let default_lines = default.lines().collect::<Vec<_>>();
|
|
||||||
let local_lines = local.lines().collect::<Vec<_>>();
|
|
||||||
let max = default_lines.len().max(local_lines.len());
|
|
||||||
for index in 0..max {
|
|
||||||
match (default_lines.get(index), local_lines.get(index)) {
|
|
||||||
(Some(left), Some(right)) if left == right => {
|
|
||||||
text.push(' ');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(Some(left), Some(right)) => {
|
|
||||||
text.push('-');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
text.push('+');
|
|
||||||
text.push_str(right);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(Some(left), None) => {
|
|
||||||
text.push('-');
|
|
||||||
text.push_str(left);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(None, Some(right)) => {
|
|
||||||
text.push('+');
|
|
||||||
text.push_str(right);
|
|
||||||
text.push('\n');
|
|
||||||
}
|
|
||||||
(None, None) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
differs: true,
|
|
||||||
text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ResolvedWorkspaceBackendConfig {
|
pub struct ResolvedWorkspaceBackendConfig {
|
||||||
pub server: ServerConfig,
|
pub server: ServerConfig,
|
||||||
@@ -187,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
|
|||||||
pub database_path: PathBuf,
|
pub database_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ServerHostConfigFile {
|
||||||
|
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||||
|
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
manifest::paths::config_dir().map(Self::path_for_config_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_default() -> Result<Self> {
|
||||||
|
let Some(path) = Self::default_path() else {
|
||||||
|
return Ok(Self::default());
|
||||||
|
};
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
||||||
|
Err(error) => Err(Error::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let raw = fs::read_to_string(path).map_err(|error| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"failed to read Server host config `{}`: {error}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Self::parse_str(&raw, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
||||||
|
toml::from_str(raw).map_err(|error| {
|
||||||
|
Error::Config(format!(
|
||||||
|
"failed to parse Server host config `{}`: {error}",
|
||||||
|
path.as_ref().display()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl BackendRuntimesConfigFile {
|
impl BackendRuntimesConfigFile {
|
||||||
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
|
||||||
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
|
||||||
@@ -255,148 +192,22 @@ impl BackendRuntimesConfigFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceBackendConfigFile {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf {
|
pub fn local_dev(
|
||||||
workspace_root
|
|
||||||
.as_ref()
|
|
||||||
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
match fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&path)
|
|
||||||
{
|
|
||||||
Ok(mut file) => {
|
|
||||||
use std::io::Write;
|
|
||||||
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
|
|
||||||
let workspace_root = workspace_root.as_ref();
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
|
|
||||||
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
|
|
||||||
path.display(),
|
|
||||||
workspace_root.display()
|
|
||||||
))),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
|
|
||||||
let path = Self::path_for_workspace(workspace_root);
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let raw = toml::to_string_pretty(self).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to serialize workspace backend config: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
fs::write(path, raw)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
toml::from_str(raw).map_err(|error| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"failed to parse workspace backend config `{}`: {error}",
|
|
||||||
path.as_ref().display()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve(
|
|
||||||
&self,
|
|
||||||
workspace_root: impl AsRef<Path>,
|
|
||||||
identity: WorkspaceIdentity,
|
|
||||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
|
||||||
self.resolve_with_runtime_config(
|
|
||||||
workspace_root,
|
|
||||||
identity,
|
|
||||||
&BackendRuntimesConfigFile::default(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resolve_with_runtime_config(
|
|
||||||
&self,
|
|
||||||
workspace_root: impl AsRef<Path>,
|
workspace_root: impl AsRef<Path>,
|
||||||
identity: WorkspaceIdentity,
|
identity: WorkspaceIdentity,
|
||||||
|
host_config: &ServerHostConfigFile,
|
||||||
runtime_config: &BackendRuntimesConfigFile,
|
runtime_config: &BackendRuntimesConfigFile,
|
||||||
) -> Result<ResolvedWorkspaceBackendConfig> {
|
) -> Result<Self> {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
let data_root = self
|
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
||||||
.data
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
.root
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
|
|
||||||
});
|
|
||||||
let database_path = self
|
|
||||||
.data
|
|
||||||
.workspace_database_path
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(ServerConfig::default_server_database_path);
|
|
||||||
let embedded_runtime_store_root = self
|
|
||||||
.data
|
|
||||||
.embedded_runtime_store_root
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path))
|
|
||||||
.unwrap_or_else(|| data_root.join("embedded-runtime"));
|
|
||||||
let listen = self
|
|
||||||
.server
|
|
||||||
.listen
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(DEFAULT_LISTEN)
|
|
||||||
.parse::<SocketAddr>()
|
|
||||||
.map_err(|_| {
|
|
||||||
Error::Config(format!(
|
|
||||||
"invalid workspace backend server.listen `{}`",
|
|
||||||
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (browser_public_url, browser_rp_id) =
|
let (browser_public_url, browser_rp_id) =
|
||||||
resolve_browser_public_url(&self.auth.browser_public_url)?;
|
resolve_browser_public_url(&host_config.browser.public_url)?;
|
||||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
||||||
server.database_path = database_path.clone();
|
server.database_path = database_path.clone();
|
||||||
server.static_assets_dir = self
|
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||||
.server
|
server.max_records = DEFAULT_MAX_RECORDS;
|
||||||
.static_assets_dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|path| resolve_workspace_path(workspace_root, path));
|
|
||||||
server.embedded_runtime_store_root = embedded_runtime_store_root;
|
|
||||||
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
|
|
||||||
server.repositories = self
|
|
||||||
.repositories
|
|
||||||
.iter()
|
|
||||||
.map(|repository| resolve_repository(workspace_root, repository))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
server.remote_runtime_sources = runtime_config
|
server.remote_runtime_sources = runtime_config
|
||||||
.runtimes
|
.runtimes
|
||||||
.remote
|
.remote
|
||||||
@@ -407,10 +218,13 @@ impl WorkspaceBackendConfigFile {
|
|||||||
rp_id: browser_rp_id,
|
rp_id: browser_rp_id,
|
||||||
origin: browser_public_url.clone(),
|
origin: browser_public_url.clone(),
|
||||||
public_base_url: browser_public_url,
|
public_base_url: browser_public_url,
|
||||||
cookie_name: normalize_required_string("auth.cookie_name", &self.auth.cookie_name)?,
|
cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
|
||||||
};
|
};
|
||||||
|
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
|
||||||
|
Error::Config(format!("invalid built-in Server listen address: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(ResolvedWorkspaceBackendConfig {
|
Ok(Self {
|
||||||
server,
|
server,
|
||||||
listen,
|
listen,
|
||||||
database_path,
|
database_path,
|
||||||
@@ -419,32 +233,6 @@ impl WorkspaceBackendConfigFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedWorkspaceBackendConfig {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
|
|
||||||
let path = path.into();
|
|
||||||
self.database_path = path.clone();
|
|
||||||
self.server.database_path = path;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
|
|
||||||
self.server.static_assets_dir = path;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_browser_public_url(mut self, public_url: &str) -> Result<Self> {
|
|
||||||
let (origin, rp_id) = resolve_browser_public_url(public_url)?;
|
|
||||||
let AuthConfig::Passkey {
|
|
||||||
rp_id: configured_rp_id,
|
|
||||||
origin: configured_origin,
|
|
||||||
public_base_url,
|
|
||||||
..
|
|
||||||
} = &mut self.server.auth;
|
|
||||||
*configured_rp_id = rp_id;
|
|
||||||
*configured_origin = origin.clone();
|
|
||||||
*public_base_url = origin;
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||||
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
|
||||||
self
|
self
|
||||||
@@ -456,33 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_repository(
|
|
||||||
workspace_root: &Path,
|
|
||||||
config: &WorkspaceRepositoryConfigFile,
|
|
||||||
) -> Result<ConfiguredRepository> {
|
|
||||||
let id = normalize_required_string("repository id", &config.id)?;
|
|
||||||
validate_repository_id(&id)?;
|
|
||||||
let provider =
|
|
||||||
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
|
|
||||||
let uri = normalize_required_string("repository uri", &config.uri)?;
|
|
||||||
let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?;
|
|
||||||
let display_name = normalize_optional_string(config.display_name.as_deref());
|
|
||||||
let default_selector = normalize_optional_string(config.default_selector.as_deref());
|
|
||||||
|
|
||||||
Ok(ConfiguredRepository {
|
|
||||||
id,
|
|
||||||
provider,
|
|
||||||
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
|
||||||
source,
|
|
||||||
source_revision: 1,
|
|
||||||
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
|
|
||||||
observed_at: None,
|
|
||||||
path,
|
|
||||||
display_name,
|
|
||||||
default_selector,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -491,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
|
|||||||
Ok(trimmed.to_string())
|
Ok(trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
|
||||||
value.and_then(|value| {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_repository_id(id: &str) -> Result<()> {
|
|
||||||
if id
|
|
||||||
.chars()
|
|
||||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(Error::Config(format!(
|
|
||||||
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_repository_source(
|
|
||||||
workspace_root: &Path,
|
|
||||||
id: &str,
|
|
||||||
uri: &str,
|
|
||||||
) -> Result<(workspace_api::RepositorySource, Option<PathBuf>)> {
|
|
||||||
match crate::repository_source::parse_repository_source(uri) {
|
|
||||||
Ok(source) => {
|
|
||||||
let path = match source.kind {
|
|
||||||
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
|
|
||||||
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
|
|
||||||
.ok()
|
|
||||||
.and_then(|uri| uri.to_file_path().ok()),
|
|
||||||
workspace_api::RepositorySourceKind::Ssh
|
|
||||||
| workspace_api::RepositorySourceKind::Http
|
|
||||||
| workspace_api::RepositorySourceKind::Https => None,
|
|
||||||
workspace_api::RepositorySourceKind::Invalid => {
|
|
||||||
return Err(Error::Config(format!(
|
|
||||||
"repository `{id}` has an invalid source"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok((source, path))
|
|
||||||
}
|
|
||||||
Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => {
|
|
||||||
let path = resolve_workspace_path(workspace_root, Path::new(uri));
|
|
||||||
let source = workspace_api::RepositorySource {
|
|
||||||
kind: workspace_api::RepositorySourceKind::LocalPath,
|
|
||||||
uri: path.to_string_lossy().into_owned(),
|
|
||||||
};
|
|
||||||
Ok((source, Some(path)))
|
|
||||||
}
|
|
||||||
Err(error) => Err(Error::Config(format!(
|
|
||||||
"repository `{id}` has an invalid source: {error}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_remote_runtime(
|
pub(crate) fn resolve_remote_runtime(
|
||||||
config: &RemoteRuntimeConfigFile,
|
config: &RemoteRuntimeConfigFile,
|
||||||
) -> Result<RemoteRuntimeConfig> {
|
) -> Result<RemoteRuntimeConfig> {
|
||||||
if let Some(token_ref) = config.token_ref.as_deref() {
|
if let Some(token_ref) = config.token_ref.as_deref() {
|
||||||
return Err(Error::Config(format!(
|
return Err(Error::Config(format!(
|
||||||
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet",
|
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
|
||||||
config.id
|
config.id
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -573,43 +273,35 @@ pub(crate) fn resolve_remote_runtime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
||||||
let value = normalize_required_string("auth.browser_public_url", value)?;
|
let value = normalize_required_string("browser.public_url", value)?;
|
||||||
let url = Url::parse(&value).map_err(|error| {
|
let url = Url::parse(&value).map_err(|error| {
|
||||||
Error::Config(format!(
|
Error::Config(format!(
|
||||||
"auth.browser_public_url must be an absolute http(s) URL: {error}"
|
"browser.public_url must be an absolute http(s) URL: {error}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
if !matches!(url.scheme(), "http" | "https") {
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
return Err(Error::Config(
|
return Err(Error::Config(
|
||||||
"auth.browser_public_url must use the http or https scheme".to_string(),
|
"browser.public_url must use the http or https scheme".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !url.username().is_empty() || url.password().is_some() {
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
return Err(Error::Config(
|
return Err(Error::Config(
|
||||||
"auth.browser_public_url must not contain user information".to_string(),
|
"browser.public_url must not contain user information".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
|
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
|
||||||
return Err(Error::Config(
|
return Err(Error::Config(
|
||||||
"auth.browser_public_url must contain only an origin without a path, query, or fragment"
|
"browser.public_url must contain only an origin without a path, query, or fragment"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let rp_id = url
|
let rp_id = url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| Error::Config("auth.browser_public_url must contain a host".to_string()))?
|
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
|
||||||
.to_string();
|
.to_string();
|
||||||
Ok((url.origin().ascii_serialization(), rp_id))
|
Ok((url.origin().ascii_serialization(), rp_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf {
|
|
||||||
if path.is_absolute() {
|
|
||||||
path.to_path_buf()
|
|
||||||
} else {
|
|
||||||
workspace_root.join(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -622,11 +314,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn resolved_with_runtimes(
|
||||||
fn missing_config_path_uses_defaults() {
|
runtimes: &BackendRuntimesConfigFile,
|
||||||
|
) -> ResolvedWorkspaceBackendConfig {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap();
|
ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
dir.path(),
|
||||||
|
identity(),
|
||||||
|
&ServerHostConfigFile::default(),
|
||||||
|
runtimes,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_settings_resolve_without_a_repository_file() {
|
||||||
|
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
|
||||||
|
|
||||||
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
|
||||||
let AuthConfig::Passkey {
|
let AuthConfig::Passkey {
|
||||||
@@ -650,12 +353,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_base_url_is_explicit_and_normalized() {
|
fn backend_base_url_is_explicit_and_normalized() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let listen = "127.0.0.1:48787".parse().unwrap();
|
let listen = "127.0.0.1:48787".parse().unwrap();
|
||||||
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path())
|
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
|
||||||
.unwrap()
|
|
||||||
.resolve(dir.path(), identity())
|
|
||||||
.unwrap()
|
|
||||||
.with_listen(listen)
|
.with_listen(listen)
|
||||||
.with_backend_base_url("http://127.0.0.1:48787/");
|
.with_backend_base_url("http://127.0.0.1:48787/");
|
||||||
|
|
||||||
@@ -667,14 +366,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_public_url_drives_all_browser_auth_settings() {
|
fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let host_config = ServerHostConfigFile::parse_str(
|
||||||
let resolved = WorkspaceBackendConfigFile::parse_str(
|
"[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
|
||||||
"[auth]\nbrowser_public_url = \"https://Yoi.Example:443/\"\n",
|
"server.toml",
|
||||||
"test",
|
)
|
||||||
|
.unwrap();
|
||||||
|
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
|
identity(),
|
||||||
|
&host_config,
|
||||||
|
&BackendRuntimesConfigFile::default(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
|
||||||
.resolve(dir.path(), identity())
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let AuthConfig::Passkey {
|
let AuthConfig::Passkey {
|
||||||
@@ -688,26 +391,6 @@ mod tests {
|
|||||||
assert_eq!(public_base_url, "https://yoi.example");
|
assert_eq!(public_base_url, "https://yoi.example");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn browser_public_url_override_replaces_the_derived_settings() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let resolved = WorkspaceBackendConfigFile::default()
|
|
||||||
.resolve(dir.path(), identity())
|
|
||||||
.unwrap()
|
|
||||||
.with_browser_public_url("https://deploy.example.test:8443")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let AuthConfig::Passkey {
|
|
||||||
rp_id,
|
|
||||||
origin,
|
|
||||||
public_base_url,
|
|
||||||
..
|
|
||||||
} = &resolved.server.auth;
|
|
||||||
assert_eq!(rp_id, "deploy.example.test");
|
|
||||||
assert_eq!(origin, "https://deploy.example.test:8443");
|
|
||||||
assert_eq!(public_base_url, "https://deploy.example.test:8443");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn browser_public_url_rejects_non_origin_urls() {
|
fn browser_public_url_rejects_non_origin_urls() {
|
||||||
for value in [
|
for value in [
|
||||||
@@ -715,204 +398,51 @@ mod tests {
|
|||||||
"https://example.test?query=true",
|
"https://example.test?query=true",
|
||||||
"file:///tmp/web",
|
"file:///tmp/web",
|
||||||
] {
|
] {
|
||||||
let result = WorkspaceBackendConfigFile::parse_str(
|
let host_config = ServerHostConfigFile {
|
||||||
&format!("[auth]\nbrowser_public_url = {value:?}\n"),
|
browser: ServerBrowserConfig {
|
||||||
"test",
|
public_url: value.to_string(),
|
||||||
)
|
},
|
||||||
.unwrap()
|
};
|
||||||
.resolve(tempfile::tempdir().unwrap().path(), identity());
|
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
|
identity(),
|
||||||
|
&host_config,
|
||||||
|
&BackendRuntimesConfigFile::default(),
|
||||||
|
);
|
||||||
let error = match result {
|
let error = match result {
|
||||||
Ok(_) => panic!("expected {value} to be rejected"),
|
Ok(_) => panic!("expected {value} to be rejected"),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("auth.browser_public_url"),
|
error.to_string().contains("browser.public_url"),
|
||||||
"unexpected error for {value}: {error}"
|
"unexpected error for {value}: {error}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_legacy_independent_browser_auth_settings() {
|
fn server_host_config_loads_only_from_the_explicit_host_path() {
|
||||||
for key in ["rp_id", "origin", "public_base_url"] {
|
|
||||||
let error = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
&format!("[auth]\n{key} = \"legacy.example\"\n"),
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field"),
|
|
||||||
"unexpected error for {key}: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unknown_fields() {
|
|
||||||
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test")
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_relative_paths_against_workspace_root() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
|
||||||
r#"
|
|
||||||
[server]
|
|
||||||
static_assets_dir = "web/build"
|
|
||||||
|
|
||||||
[data]
|
|
||||||
root = ".yoi/backend-data"
|
|
||||||
workspace_database_path = ".yoi/custom.db"
|
|
||||||
embedded_runtime_store_root = ".yoi/runtime-store"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.static_assets_dir,
|
|
||||||
Some(dir.path().join("web/build"))
|
|
||||||
);
|
|
||||||
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
dir.path().join(".yoi/runtime-store")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absolute_paths_are_preserved() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[data]
|
|
||||||
workspace_database_path = "/tmp/yoi-workspace.db"
|
|
||||||
embedded_runtime_store_root = "/tmp/yoi-runtime"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
resolved.database_path,
|
|
||||||
PathBuf::from("/tmp/yoi-workspace.db")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
PathBuf::from("/tmp/yoi-runtime")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn data_root_derives_runtime_store_path_only() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[data]
|
|
||||||
root = ".local-data"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert!(resolved.database_path.ends_with("server.db"));
|
|
||||||
assert_eq!(
|
|
||||||
resolved.server.embedded_runtime_store_root,
|
|
||||||
dir.path().join(".local-data/embedded-runtime")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn copies_local_config_without_overwriting() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
|
|
||||||
let raw = fs::read_to_string(&path).unwrap();
|
|
||||||
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
|
|
||||||
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
|
|
||||||
|
|
||||||
fs::write(&path, "# custom local config\n").unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(&path).unwrap(),
|
|
||||||
"# custom local config\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_config_diff_reports_match_and_difference() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
|
||||||
let matched =
|
|
||||||
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
|
|
||||||
assert!(!matched.differs);
|
|
||||||
|
|
||||||
fs::write(
|
fs::write(
|
||||||
WorkspaceBackendConfigFile::path_for_workspace(dir.path()),
|
&path,
|
||||||
"[server]\nlisten = \"127.0.0.1:9999\"\n",
|
"[browser]\npublic_url = \"https://deploy.example.test\"\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
|
|
||||||
assert!(diff.differs);
|
let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
|
||||||
assert!(diff.text.contains("+++ workspace local"));
|
assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
|
||||||
assert!(diff.text.contains("127.0.0.1:9999"));
|
assert_eq!(path, dir.path().join("server.toml"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_repository_uri_relative_to_workspace_root() {
|
fn explicit_missing_server_host_config_fails_closed() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
assert!(
|
||||||
r#"
|
error
|
||||||
[[repositories]]
|
.to_string()
|
||||||
id = "main"
|
.contains("failed to read Server host config")
|
||||||
provider = "git"
|
|
||||||
uri = "."
|
|
||||||
display_name = "Main"
|
|
||||||
default_selector = "HEAD"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
let repository = resolved.server.repositories.first().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(repository.id, "main");
|
|
||||||
assert_eq!(repository.provider, "git");
|
|
||||||
assert_eq!(repository.path.as_deref(), Some(dir.path()));
|
|
||||||
assert_eq!(repository.display_name.as_deref(), Some("Main"));
|
|
||||||
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_repository_source_is_preserved_without_a_local_path() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[repositories]]
|
|
||||||
id = "main"
|
|
||||||
provider = "git"
|
|
||||||
uri = "https://example.com/org/repo.git"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
let repository = &resolved.server.repositories[0];
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
repository.source.kind,
|
|
||||||
workspace_api::RepositorySourceKind::Https
|
|
||||||
);
|
);
|
||||||
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
|
|
||||||
assert!(repository.path.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -937,28 +467,8 @@ uri = "https://example.com/org/repo.git"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_backend_config_rejects_runtime_entries() {
|
|
||||||
let error = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
r#"
|
|
||||||
[[runtimes.remote]]
|
|
||||||
id = "arc"
|
|
||||||
endpoint = "http://legacy.example.test"
|
|
||||||
display_name = "legacy arc"
|
|
||||||
"#,
|
|
||||||
"test",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field `runtimes`"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_runtimes_config_is_the_only_runtime_source() {
|
fn backend_runtimes_config_is_the_only_runtime_source() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||||
r#"
|
r#"
|
||||||
[[runtimes.remote]]
|
[[runtimes.remote]]
|
||||||
@@ -969,9 +479,7 @@ display_name = "xdg arc"
|
|||||||
"runtimes.toml",
|
"runtimes.toml",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let resolved = workspace_config
|
let resolved = resolved_with_runtimes(&runtime_config);
|
||||||
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
|
||||||
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1000,8 +508,6 @@ token = "secret"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
fn token_ref_fails_closed_until_secret_resolution_exists() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
|
|
||||||
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
let runtime_config = BackendRuntimesConfigFile::parse_str(
|
||||||
r#"
|
r#"
|
||||||
[[runtimes.remote]]
|
[[runtimes.remote]]
|
||||||
@@ -1012,9 +518,10 @@ token_ref = "local:remote-token"
|
|||||||
"runtimes.toml",
|
"runtimes.toml",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let error = match workspace_config.resolve_with_runtime_config(
|
let error = match ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
dir.path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
identity(),
|
||||||
|
&ServerHostConfigFile::default(),
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
) {
|
) {
|
||||||
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
|
|||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
Ok(raw) => Self::parse_str(&raw, &path),
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
Err(Error::WorkspaceIdentity(format!(
|
Err(Error::WorkspaceIdentity(format!(
|
||||||
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
|
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
|
||||||
workspace_root.as_ref().display(),
|
|
||||||
workspace_root.as_ref().display()
|
workspace_root.as_ref().display()
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
@@ -219,7 +218,7 @@ mod tests {
|
|||||||
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("workspace is not initialized"),
|
error.to_string().contains("workspace identity is missing"),
|
||||||
"unexpected error: {error}"
|
"unexpected error: {error}"
|
||||||
);
|
);
|
||||||
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ pub use authority::{
|
|||||||
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
|
||||||
WorkspaceAuthority,
|
WorkspaceAuthority,
|
||||||
};
|
};
|
||||||
pub use config::{
|
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||||
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
|
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
|
||||||
WorkspaceBackendConfigFile,
|
|
||||||
};
|
|
||||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
||||||
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||||
pub use repositories::{
|
pub use repositories::{
|
||||||
|
|||||||
@@ -11,19 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
|||||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
||||||
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig,
|
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService,
|
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
||||||
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BROWSER_PUBLIC_URL_ENV: &str = "YOI_BROWSER_PUBLIC_URL";
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Serve(ServeOptions),
|
Serve(ServeOptions),
|
||||||
Init(InitOptions),
|
|
||||||
ConfigDefault,
|
|
||||||
ConfigDiff(WorkspacePathOptions),
|
|
||||||
Identity(Vec<String>),
|
Identity(Vec<String>),
|
||||||
TrustRuntime(Vec<String>),
|
TrustRuntime(Vec<String>),
|
||||||
MigrateDryRun { database: Option<PathBuf> },
|
MigrateDryRun { database: Option<PathBuf> },
|
||||||
@@ -34,16 +28,7 @@ enum Command {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ServeOptions {
|
struct ServeOptions {
|
||||||
listen: Option<SocketAddr>,
|
listen: Option<SocketAddr>,
|
||||||
}
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct InitOptions {
|
|
||||||
workspace: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct WorkspacePathOptions {
|
|
||||||
workspace: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -84,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
match parse_command(&args)? {
|
match parse_command(&args)? {
|
||||||
Command::Serve(options) => run_serve(options).await,
|
Command::Serve(options) => run_serve(options).await,
|
||||||
Command::Init(options) => run_init(options).await,
|
|
||||||
Command::ConfigDefault => run_config_default(),
|
|
||||||
Command::ConfigDiff(options) => run_config_diff(options),
|
|
||||||
Command::Identity(args) => run_identity_command(args),
|
Command::Identity(args) => run_identity_command(args),
|
||||||
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
||||||
Command::MigrateDryRun { database } => {
|
Command::MigrateDryRun { database } => {
|
||||||
@@ -112,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"init" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_init_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
Ok(Command::Init(parse_init_options(rest)?))
|
|
||||||
}
|
|
||||||
"config" => parse_config_command(rest),
|
|
||||||
"identity" => Ok(Command::Identity(rest.to_vec())),
|
"identity" => Ok(Command::Identity(rest.to_vec())),
|
||||||
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
||||||
"migrate" => parse_migrate_command(rest),
|
"migrate" => parse_migrate_command(rest),
|
||||||
@@ -136,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_init_with_database_path(
|
|
||||||
options: InitOptions,
|
|
||||||
database_path: PathBuf,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
|
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
|
|
||||||
|
|
||||||
if let Some(parent) = database_path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
|
||||||
let service = WorkspaceCatalogService::new(store);
|
|
||||||
service.create_with_workspace_id(
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: format!("cli-init:{}", identity.workspace_id),
|
|
||||||
display_name: identity.display_name.clone(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: options.workspace.display().to_string(),
|
|
||||||
display_name: Some("Main repository".to_string()),
|
|
||||||
default_ref: Some("HEAD".to_string()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
Some(identity.workspace_id.clone()),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
|
|
||||||
options.workspace.display(),
|
|
||||||
identity.workspace_id,
|
|
||||||
database_path.display()
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
|
|
||||||
print!("{}", diff.text);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct ServerIdentityFile {
|
struct ServerIdentityFile {
|
||||||
identity: RuntimeIdentityMaterial,
|
identity: RuntimeIdentityMaterial,
|
||||||
@@ -628,22 +552,23 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
.to_path_buf(),
|
.to_path_buf(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let host_config = match options.config.as_ref() {
|
||||||
|
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||||
|
None => ServerHostConfigFile::load_default()?,
|
||||||
|
};
|
||||||
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
let runtime_config = BackendRuntimesConfigFile::load_default()?;
|
||||||
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config(
|
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
identity,
|
identity,
|
||||||
|
&host_config,
|
||||||
&runtime_config,
|
&runtime_config,
|
||||||
)?;
|
)?;
|
||||||
resolved.database_path = database_path.clone();
|
resolved.database_path = database_path.clone();
|
||||||
resolved.server.database_path = database_path.clone();
|
resolved.server.database_path = database_path.clone();
|
||||||
if let Some(browser_public_url) = browser_public_url_from_environment()? {
|
|
||||||
resolved = resolved.with_browser_public_url(&browser_public_url)?;
|
|
||||||
}
|
|
||||||
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
||||||
if let Some(listen) = options.listen {
|
if let Some(listen) = options.listen {
|
||||||
resolved = resolved.with_listen(listen);
|
resolved = resolved.with_listen(listen);
|
||||||
}
|
}
|
||||||
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
|
|
||||||
|
|
||||||
let listener = TcpListener::bind(resolved.listen).await?;
|
let listener = TcpListener::bind(resolved.listen).await?;
|
||||||
let local_addr = listener.local_addr()?;
|
let local_addr = listener.local_addr()?;
|
||||||
@@ -660,16 +585,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browser_public_url_from_environment() -> Result<Option<String>, CliError> {
|
|
||||||
match std::env::var(BROWSER_PUBLIC_URL_ENV) {
|
|
||||||
Ok(value) => Ok(Some(value)),
|
|
||||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
|
||||||
Err(std::env::VarError::NotUnicode(_)) => Err(CliError(format!(
|
|
||||||
"{BROWSER_PUBLIC_URL_ENV} must contain valid UTF-8"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_trusted_runtime_sources(
|
fn append_trusted_runtime_sources(
|
||||||
store: &SqliteWorkspaceStore,
|
store: &SqliteWorkspaceStore,
|
||||||
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
|
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
|
||||||
@@ -737,41 +652,6 @@ fn infer_workspace_root_from_repositories(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
|
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
};
|
|
||||||
match subcommand.as_str() {
|
|
||||||
"default" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
if !rest.is_empty() {
|
|
||||||
return Err(CliError(
|
|
||||||
"config default does not accept options".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Command::ConfigDefault)
|
|
||||||
}
|
|
||||||
"diff" => {
|
|
||||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
||||||
print_config_help();
|
|
||||||
return Ok(Command::Help);
|
|
||||||
}
|
|
||||||
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
|
|
||||||
}
|
|
||||||
"--help" | "-h" => {
|
|
||||||
print_config_help();
|
|
||||||
Ok(Command::Help)
|
|
||||||
}
|
|
||||||
other => Err(CliError(format!(
|
|
||||||
"unknown config subcommand `{other}`; expected `default` or `diff`"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
|
||||||
let mut dry_run = false;
|
let mut dry_run = false;
|
||||||
let mut database = None;
|
let mut database = None;
|
||||||
@@ -855,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
|
|||||||
Ok(SkillWorkspaceOptions { workspace_id })
|
Ok(SkillWorkspaceOptions { workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
|
|
||||||
let mut workspace = std::env::current_dir()
|
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
|
||||||
let mut iter = args.iter();
|
|
||||||
while let Some(arg) = iter.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--workspace" => {
|
|
||||||
let value = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
|
|
||||||
workspace = PathBuf::from(value);
|
|
||||||
}
|
|
||||||
value if value.starts_with("--workspace=") => {
|
|
||||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
|
||||||
}
|
|
||||||
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let workspace = workspace
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
|
|
||||||
Ok(WorkspacePathOptions { workspace })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
|
|
||||||
let mut workspace = std::env::current_dir()
|
|
||||||
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
|
|
||||||
let mut iter = args.iter();
|
|
||||||
while let Some(arg) = iter.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"--workspace" => {
|
|
||||||
let value = iter
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
|
|
||||||
workspace = PathBuf::from(value);
|
|
||||||
}
|
|
||||||
value if value.starts_with("--workspace=") => {
|
|
||||||
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
|
|
||||||
}
|
|
||||||
other => return Err(CliError(format!("unknown init option `{other}`"))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace = workspace
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
|
|
||||||
Ok(InitOptions { workspace })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
||||||
let mut listen = None;
|
let mut listen = None;
|
||||||
|
let mut config = None;
|
||||||
|
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
while index < args.len() {
|
while index < args.len() {
|
||||||
@@ -921,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||||||
_ if arg.starts_with("--listen=") => {
|
_ if arg.starts_with("--listen=") => {
|
||||||
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
|
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
|
||||||
}
|
}
|
||||||
|
"--config" => {
|
||||||
|
index += 1;
|
||||||
|
let value = args
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
|
||||||
|
config = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--config=") => {
|
||||||
|
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
|
||||||
|
}
|
||||||
_ if arg.starts_with('-') => {
|
_ if arg.starts_with('-') => {
|
||||||
return Err(CliError(format!("unknown serve option `{arg}`")));
|
return Err(CliError(format!("unknown serve option `{arg}`")));
|
||||||
}
|
}
|
||||||
@@ -933,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ServeOptions { listen })
|
Ok(ServeOptions { listen, config })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
|
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
|
||||||
@@ -955,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_init_help() {
|
|
||||||
println!(
|
|
||||||
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_config_help() {
|
|
||||||
println!(
|
|
||||||
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_skills_help() {
|
fn print_skills_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
|
||||||
@@ -981,24 +811,23 @@ fn print_skills_help() {
|
|||||||
fn print_serve_help() {
|
fn print_serve_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
|
||||||
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help\n\nEnvironment:\n YOI_BROWSER_PUBLIC_URL Browser-facing Vite/Nginx origin used by WebAuthn, device login, cookies, and CSRF checks"
|
yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use yoi_workspace_server::{
|
|
||||||
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
|
|
||||||
WORKSPACE_IDENTITY_RELATIVE_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_init_defaults_workspace_to_cwd_or_flag() {
|
fn removed_repository_local_commands_are_rejected() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
for command in ["init", "config"] {
|
||||||
let args = vec!["--workspace".to_string(), temp.path().display().to_string()];
|
let error = parse_command(&[command.to_string()]).unwrap_err();
|
||||||
let options = parse_init_options(&args).unwrap();
|
assert!(
|
||||||
assert_eq!(options.workspace, temp.path().canonicalize().unwrap());
|
error.to_string().contains("unknown command"),
|
||||||
|
"unexpected error for {command}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1038,10 +867,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_serve_accepts_listen_only() {
|
fn parse_serve_accepts_listen_and_host_config() {
|
||||||
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()];
|
let args = vec![
|
||||||
|
"--listen".to_string(),
|
||||||
|
"127.0.0.1:0".to_string(),
|
||||||
|
"--config=/etc/yoi/server.toml".to_string(),
|
||||||
|
];
|
||||||
let options = parse_serve_options(&args).unwrap();
|
let options = parse_serve_options(&args).unwrap();
|
||||||
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
options.config.unwrap(),
|
||||||
|
PathBuf::from("/etc/yoi/server.toml")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1096,49 +933,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn init_creates_identity_local_config_and_server_records() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let database_path = temp.path().join("data").join("server").join("server.db");
|
|
||||||
std::fs::create_dir(temp.path().join(".git")).unwrap();
|
|
||||||
run_init_with_database_path(
|
|
||||||
InitOptions {
|
|
||||||
workspace: temp.path().canonicalize().unwrap(),
|
|
||||||
},
|
|
||||||
database_path.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
|
|
||||||
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
|
|
||||||
assert!(local_config_path.exists());
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(local_config_path).unwrap(),
|
|
||||||
WORKSPACE_BACKEND_CONFIG_TEMPLATE
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!temp
|
|
||||||
.path()
|
|
||||||
.join(".yoi/workspace-backend.default.toml")
|
|
||||||
.exists()
|
|
||||||
);
|
|
||||||
assert!(!temp.path().join(".yoi/workspace.db").exists());
|
|
||||||
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
|
|
||||||
assert!(database_path.exists());
|
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
|
|
||||||
let workspaces = store.list_workspaces().unwrap();
|
|
||||||
assert_eq!(workspaces.len(), 1);
|
|
||||||
let repositories = store
|
|
||||||
.list_repositories(&workspaces[0].workspace_id)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(repositories.len(), 1);
|
|
||||||
assert_eq!(repositories[0].repository_id, "main");
|
|
||||||
assert_eq!(
|
|
||||||
repositories[0].uri,
|
|
||||||
temp.path().canonicalize().unwrap().display().to_string()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,9 +164,6 @@ pub struct ServerConfig {
|
|||||||
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
|
||||||
pub runtime_config_path: Option<PathBuf>,
|
pub runtime_config_path: Option<PathBuf>,
|
||||||
pub backend_base_url: Option<String>,
|
pub backend_base_url: Option<String>,
|
||||||
/// Allows the first ownerless Workspace to be created without a session.
|
|
||||||
/// This must only be enabled for a loopback-bound local Server.
|
|
||||||
pub allow_local_workspace_bootstrap: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
@@ -195,7 +192,6 @@ impl ServerConfig {
|
|||||||
remote_runtime_sources: Vec::new(),
|
remote_runtime_sources: Vec::new(),
|
||||||
runtime_config_path: BackendRuntimesConfigFile::default_path(),
|
runtime_config_path: BackendRuntimesConfigFile::default_path(),
|
||||||
backend_base_url: None,
|
backend_base_url: None,
|
||||||
allow_local_workspace_bootstrap: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +248,6 @@ impl ServerConfig {
|
|||||||
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
|
Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_local_workspace_bootstrap(mut self, enabled: bool) -> Self {
|
|
||||||
self.allow_local_workspace_bootstrap = enabled;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
|
pub fn with_embedded_runtime_store_root(mut self, root: impl Into<PathBuf>) -> Self {
|
||||||
self.embedded_runtime_store_root = root.into();
|
self.embedded_runtime_store_root = root.into();
|
||||||
self
|
self
|
||||||
@@ -842,9 +833,9 @@ async fn list_server_workspaces(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let owner = match resolve_server_actor(&api, &headers).await {
|
let owner = match resolve_server_actor(&api, &headers).await {
|
||||||
Ok(Some(actor)) => Some(actor.account_id),
|
Ok(Some(actor)) => Some(actor.account_id),
|
||||||
Ok(None) => match api.catalog.list(None, 1) {
|
Ok(None) => match api.catalog.is_empty() {
|
||||||
Ok(workspaces) if workspaces.is_empty() => return Json(workspaces).into_response(),
|
Ok(true) => return Json(Vec::<WorkspaceRecord>::new()).into_response(),
|
||||||
Ok(_) => return StatusCode::UNAUTHORIZED.into_response(),
|
Ok(false) => return StatusCode::UNAUTHORIZED.into_response(),
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
},
|
},
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
@@ -863,19 +854,14 @@ async fn create_server_workspace(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(request): Json<WorkspaceCreateRequest>,
|
Json(request): Json<WorkspaceCreateRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await {
|
let owner_account_id = match resolve_server_actor(&api, &headers).await {
|
||||||
Ok(Some(actor)) => (Some(actor.account_id), false),
|
Ok(Some(actor)) => actor.account_id,
|
||||||
Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true),
|
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return forbidden_server_response("Workspace creation requires an authenticated owner");
|
return forbidden_server_response("Workspace creation requires an authenticated owner");
|
||||||
}
|
}
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
};
|
};
|
||||||
let created = match if local_bootstrap {
|
let created = match api.catalog.create(request, owner_account_id) {
|
||||||
api.catalog.create_first_ownerless(request)
|
|
||||||
} else {
|
|
||||||
api.catalog.create(request, owner_account_id)
|
|
||||||
} {
|
|
||||||
Ok(created) => created,
|
Ok(created) => created,
|
||||||
Err(error) => return server_error_response(error),
|
Err(error) => return server_error_response(error),
|
||||||
};
|
};
|
||||||
@@ -1213,6 +1199,33 @@ pub async fn build_workspace_server_router(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
async fn seed_test_registered_workspace(
|
||||||
|
store: &dyn ControlPlaneStore,
|
||||||
|
config: &ServerConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
let account_id = format!("account-{}", config.workspace_id);
|
||||||
|
store.upsert_account(&AccountRecord {
|
||||||
|
account_id: account_id.clone(),
|
||||||
|
kind: "user".to_owned(),
|
||||||
|
handle: format!("owner-{}", config.workspace_id),
|
||||||
|
display_name: "Workspace Owner".to_owned(),
|
||||||
|
created_at: config.workspace_created_at.clone(),
|
||||||
|
updated_at: config.workspace_created_at.clone(),
|
||||||
|
})?;
|
||||||
|
store
|
||||||
|
.upsert_workspace(&WorkspaceRecord {
|
||||||
|
workspace_id: config.workspace_id.clone(),
|
||||||
|
owner_account_id: Some(account_id),
|
||||||
|
display_name: config.workspace_display_name.clone(),
|
||||||
|
state: "active".to_owned(),
|
||||||
|
created_at: config.workspace_created_at.clone(),
|
||||||
|
updated_at: config.workspace_created_at.clone(),
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl WorkspaceApi {
|
impl WorkspaceApi {
|
||||||
pub fn with_config_schema_provider(
|
pub fn with_config_schema_provider(
|
||||||
mut self,
|
mut self,
|
||||||
@@ -1276,6 +1289,7 @@ impl WorkspaceApi {
|
|||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
|
seed_test_registered_workspace(store.as_ref(), &config).await?;
|
||||||
Self::new_with_execution_backend_and_broker(
|
Self::new_with_execution_backend_and_broker(
|
||||||
config,
|
config,
|
||||||
store,
|
store,
|
||||||
@@ -1295,16 +1309,12 @@ impl WorkspaceApi {
|
|||||||
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
Arc<crate::worker_source::EmbeddedServerWorkerMutationDispatcher>,
|
||||||
>,
|
>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
store
|
if store.get_workspace(&config.workspace_id).await?.is_none() {
|
||||||
.upsert_workspace(&WorkspaceRecord {
|
return Err(crate::Error::Config(format!(
|
||||||
workspace_id: config.workspace_id.clone(),
|
"Workspace {} is not registered in the Server DB",
|
||||||
owner_account_id: None,
|
config.workspace_id
|
||||||
display_name: config.workspace_display_name.clone(),
|
)));
|
||||||
state: "active".to_string(),
|
}
|
||||||
created_at: config.workspace_created_at.clone(),
|
|
||||||
updated_at: config.workspace_created_at.clone(),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
import_configured_repositories(store.as_ref(), &config)?;
|
import_configured_repositories(store.as_ref(), &config)?;
|
||||||
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
|
config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?;
|
||||||
let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
|
let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend(
|
||||||
@@ -15167,13 +15177,34 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_api_does_not_register_a_missing_workspace() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = test_server_config(dir.path());
|
||||||
|
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||||
|
|
||||||
|
let error = match WorkspaceApi::new(config, store.clone()).await {
|
||||||
|
Ok(_) => panic!("missing Workspace registration must fail closed"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("is not registered in the Server DB")
|
||||||
|
);
|
||||||
|
assert!(store.list_workspaces().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() {
|
async fn production_profile_backend_rejects_unrecoverable_pending_orchestrator_restore() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
init_clean_git_workspace(workspace.path());
|
init_clean_git_workspace(workspace.path());
|
||||||
let config = test_server_config(workspace.path());
|
let config = test_server_config(workspace.path());
|
||||||
let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap();
|
let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap());
|
||||||
let api = WorkspaceApi::new(config, Arc::new(store)).await.unwrap();
|
seed_test_registered_workspace(store.as_ref(), &config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let api = WorkspaceApi::new(config, store).await.unwrap();
|
||||||
let workspace_id = api.config.workspace_id.clone();
|
let workspace_id = api.config.workspace_id.clone();
|
||||||
|
|
||||||
let result = scoped_start_workspace_orchestrator(
|
let result = scoped_start_workspace_orchestrator(
|
||||||
@@ -15725,13 +15756,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_errors_preserve_operation_details() {
|
fn backend_errors_preserve_operation_details() {
|
||||||
let sanitized = sanitize_backend_error(
|
let sanitized = sanitize_backend_error("failed to open server database");
|
||||||
"failed to open /home/example/.yoi/workspace-backend.local.toml",
|
assert_eq!(sanitized, "failed to open server database");
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
sanitized,
|
|
||||||
"failed to open /home/example/.yoi/workspace-backend.local.toml"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -16170,6 +16196,16 @@ mod tests {
|
|||||||
} = &config.auth;
|
} = &config.auth;
|
||||||
let expected_origin = expected_origin.clone();
|
let expected_origin = expected_origin.clone();
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
|
||||||
|
store
|
||||||
|
.upsert_account(&AccountRecord {
|
||||||
|
account_id: "account-auth".to_owned(),
|
||||||
|
kind: "user".to_owned(),
|
||||||
|
handle: "auth-user".to_owned(),
|
||||||
|
display_name: "Auth User".to_owned(),
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
let catalog = WorkspaceCatalogService::new(store.clone());
|
let catalog = WorkspaceCatalogService::new(store.clone());
|
||||||
let repository = temp.path().join("repository");
|
let repository = temp.path().join("repository");
|
||||||
std::fs::create_dir_all(&repository).unwrap();
|
std::fs::create_dir_all(&repository).unwrap();
|
||||||
@@ -16192,19 +16228,9 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
None,
|
"account-auth".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
|
||||||
.upsert_account(&AccountRecord {
|
|
||||||
account_id: "account-auth".to_owned(),
|
|
||||||
kind: "user".to_owned(),
|
|
||||||
handle: "auth-user".to_owned(),
|
|
||||||
display_name: "Auth User".to_owned(),
|
|
||||||
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
|
||||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
store
|
store
|
||||||
.upsert_user(&UserRecord {
|
.upsert_user(&UserRecord {
|
||||||
user_id: "user-auth".to_owned(),
|
user_id: "user-auth".to_owned(),
|
||||||
@@ -16556,6 +16582,7 @@ mod tests {
|
|||||||
let mut template = test_server_config(dir.path());
|
let mut template = test_server_config(dir.path());
|
||||||
template.static_assets_dir = Some(static_dir);
|
template.static_assets_dir = Some(static_dir);
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
||||||
|
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
|
||||||
let catalog = WorkspaceCatalogService::new(store.clone());
|
let catalog = WorkspaceCatalogService::new(store.clone());
|
||||||
let workspace_a = catalog
|
let workspace_a = catalog
|
||||||
.create(
|
.create(
|
||||||
@@ -16568,7 +16595,7 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
None,
|
"account-two-workspaces".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let workspace_b = catalog
|
let workspace_b = catalog
|
||||||
@@ -16582,10 +16609,9 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
None,
|
"account-two-workspaces".to_owned(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let token = seed_test_api_token(store.as_ref(), "two-workspaces");
|
|
||||||
let app = build_workspace_server_router(template, store)
|
let app = build_workspace_server_router(template, store)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -16686,13 +16712,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn local_bootstrap_create_activates_workspace_without_server_restart() {
|
async fn local_workspace_creation_requires_an_authenticated_owner() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let repository = dir.path().join("repository");
|
let repository = dir.path().join("repository");
|
||||||
std::fs::create_dir_all(repository.join(".git")).unwrap();
|
std::fs::create_dir_all(repository.join(".git")).unwrap();
|
||||||
let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true);
|
let template = test_server_config(dir.path());
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap());
|
||||||
let token = seed_test_api_token(store.as_ref(), "bootstrap");
|
|
||||||
let app = build_workspace_server_router(template, store)
|
let app = build_workspace_server_router(template, store)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -16706,8 +16731,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let created = app
|
let response = app
|
||||||
.clone()
|
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
@@ -16718,54 +16742,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(created.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
let body = to_bytes(created.into_body(), usize::MAX).await.unwrap();
|
|
||||||
let body: Value = serde_json::from_slice(&body).unwrap();
|
|
||||||
let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap();
|
|
||||||
|
|
||||||
let workspace = get_json_authenticated(
|
|
||||||
app.clone(),
|
|
||||||
&format!("/api/w/{workspace_id}/workspace"),
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(workspace["display_name"], "Created Workspace");
|
|
||||||
|
|
||||||
let replayed = app
|
|
||||||
.clone()
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method(Method::POST)
|
|
||||||
.uri("/api/workspaces")
|
|
||||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(payload.to_string()))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replayed.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let second_payload = json!({
|
|
||||||
"operation_key": "bootstrap-2",
|
|
||||||
"display_name": "Second Ownerless Workspace",
|
|
||||||
"repository": {
|
|
||||||
"uri": repository,
|
|
||||||
"display_name": "Repository",
|
|
||||||
"default_ref": "HEAD"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let second = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method(Method::POST)
|
|
||||||
.uri("/api/workspaces")
|
|
||||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(second_payload.to_string()))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(second.status(), StatusCode::CONFLICT);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ impl WorkspaceCatalogService {
|
|||||||
Self { store }
|
Self { store }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> Result<bool> {
|
||||||
|
Ok(self.store.list_workspaces()?.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list(
|
pub fn list(
|
||||||
&self,
|
&self,
|
||||||
owner_account_id: Option<&str>,
|
owner_account_id: Option<&str>,
|
||||||
@@ -76,33 +80,16 @@ impl WorkspaceCatalogService {
|
|||||||
pub fn create(
|
pub fn create(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceCreateRequest,
|
request: WorkspaceCreateRequest,
|
||||||
owner_account_id: Option<String>,
|
owner_account_id: String,
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
self.create_internal(request, owner_account_id, None, false)
|
self.create_internal(request, owner_account_id, None)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_first_ownerless(
|
|
||||||
&self,
|
|
||||||
request: WorkspaceCreateRequest,
|
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
|
||||||
self.create_internal(request, None, None, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_with_workspace_id(
|
|
||||||
&self,
|
|
||||||
request: WorkspaceCreateRequest,
|
|
||||||
owner_account_id: Option<String>,
|
|
||||||
requested_workspace_id: Option<String>,
|
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
|
||||||
self.create_internal(request, owner_account_id, requested_workspace_id, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_internal(
|
fn create_internal(
|
||||||
&self,
|
&self,
|
||||||
request: WorkspaceCreateRequest,
|
request: WorkspaceCreateRequest,
|
||||||
owner_account_id: Option<String>,
|
owner_account_id: String,
|
||||||
requested_workspace_id: Option<String>,
|
requested_workspace_id: Option<String>,
|
||||||
require_empty_catalog: bool,
|
|
||||||
) -> Result<WorkspaceCreateResponse> {
|
) -> Result<WorkspaceCreateResponse> {
|
||||||
let operation_key = normalize_required(
|
let operation_key = normalize_required(
|
||||||
"operation_key",
|
"operation_key",
|
||||||
@@ -142,7 +129,7 @@ impl WorkspaceCatalogService {
|
|||||||
let fingerprint = workspace_create_fingerprint(
|
let fingerprint = workspace_create_fingerprint(
|
||||||
requested_workspace_id.as_deref(),
|
requested_workspace_id.as_deref(),
|
||||||
&display_name,
|
&display_name,
|
||||||
owner_account_id.as_deref(),
|
Some(&owner_account_id),
|
||||||
&repository_uri,
|
&repository_uri,
|
||||||
&repository_name,
|
&repository_name,
|
||||||
&default_ref,
|
&default_ref,
|
||||||
@@ -153,10 +140,10 @@ impl WorkspaceCatalogService {
|
|||||||
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
|
||||||
operation_key,
|
operation_key,
|
||||||
request_fingerprint: fingerprint.clone(),
|
request_fingerprint: fingerprint.clone(),
|
||||||
require_empty_catalog,
|
require_empty_catalog: false,
|
||||||
workspace: WorkspaceRecord {
|
workspace: WorkspaceRecord {
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
owner_account_id,
|
owner_account_id: Some(owner_account_id),
|
||||||
display_name,
|
display_name,
|
||||||
state: "active".to_string(),
|
state: "active".to_string(),
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
@@ -235,7 +222,7 @@ fn workspace_create_fingerprint(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::SqliteWorkspaceStore;
|
use crate::store::{AccountRecord, SqliteWorkspaceStore};
|
||||||
use workspace_api::RepositorySourceKind;
|
use workspace_api::RepositorySourceKind;
|
||||||
|
|
||||||
fn git_repository() -> tempfile::TempDir {
|
fn git_repository() -> tempfile::TempDir {
|
||||||
@@ -244,6 +231,22 @@ mod tests {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn owner_account(store: &SqliteWorkspaceStore) -> String {
|
||||||
|
let account_id = Uuid::now_v7().to_string();
|
||||||
|
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
|
||||||
|
store
|
||||||
|
.upsert_account(&AccountRecord {
|
||||||
|
account_id: account_id.clone(),
|
||||||
|
kind: "user".to_string(),
|
||||||
|
handle: format!("owner-{}", &account_id[..8]),
|
||||||
|
display_name: "Workspace Owner".to_string(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
account_id
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn create_is_atomic_and_exact_retries_converge() {
|
async fn create_is_atomic_and_exact_retries_converge() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
@@ -259,8 +262,11 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let created = service.create(request.clone(), None).unwrap();
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let replayed = service.create(request, None).unwrap();
|
let created = service
|
||||||
|
.create(request.clone(), owner_account_id.clone())
|
||||||
|
.unwrap();
|
||||||
|
let replayed = service.create(request, owner_account_id).unwrap();
|
||||||
|
|
||||||
assert!(!created.replayed);
|
assert!(!created.replayed);
|
||||||
assert!(replayed.replayed);
|
assert!(replayed.replayed);
|
||||||
@@ -284,64 +290,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() {
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
|
||||||
let service = WorkspaceCatalogService::new(store.clone());
|
|
||||||
let repository_a = git_repository();
|
|
||||||
let repository_b = git_repository();
|
|
||||||
let requests = [
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: "bootstrap-a".to_string(),
|
|
||||||
display_name: "Workspace A".to_string(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: repository_a.path().display().to_string(),
|
|
||||||
display_name: None,
|
|
||||||
default_ref: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
WorkspaceCreateRequest {
|
|
||||||
operation_key: "bootstrap-b".to_string(),
|
|
||||||
display_name: "Workspace B".to_string(),
|
|
||||||
repository: InitialRepositoryIntent {
|
|
||||||
uri: repository_b.path().display().to_string(),
|
|
||||||
display_name: None,
|
|
||||||
default_ref: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
|
||||||
let results = std::thread::scope(|scope| {
|
|
||||||
requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|request| {
|
|
||||||
let service = service.clone();
|
|
||||||
let barrier = barrier.clone();
|
|
||||||
scope.spawn(move || {
|
|
||||||
barrier.wait();
|
|
||||||
service.create_first_ownerless(request)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle| handle.join().unwrap())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
|
||||||
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
|
|
||||||
assert_eq!(store.list_workspaces().unwrap().len(), 1);
|
|
||||||
let error = results
|
|
||||||
.into_iter()
|
|
||||||
.find_map(Result::err)
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
assert!(error.contains("catalog is empty"), "{error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(store);
|
let service = WorkspaceCatalogService::new(store);
|
||||||
let repository = git_repository();
|
let repository = git_repository();
|
||||||
let mut request = WorkspaceCreateRequest {
|
let mut request = WorkspaceCreateRequest {
|
||||||
@@ -353,10 +305,15 @@ mod tests {
|
|||||||
default_ref: None,
|
default_ref: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
service.create(request.clone(), None).unwrap();
|
service
|
||||||
|
.create(request.clone(), owner_account_id.clone())
|
||||||
|
.unwrap();
|
||||||
request.display_name = "Workspace B".to_string();
|
request.display_name = "Workspace B".to_string();
|
||||||
|
|
||||||
let error = service.create(request, None).unwrap_err().to_string();
|
let error = service
|
||||||
|
.create(request, owner_account_id)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
assert!(error.contains("different input"), "{error}");
|
assert!(error.contains("different input"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,9 +335,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
|
||||||
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
|
||||||
|
let owner_account_id = owner_account(store.as_ref());
|
||||||
let service = WorkspaceCatalogService::new(store.clone());
|
let service = WorkspaceCatalogService::new(store.clone());
|
||||||
let result = service
|
let result = service
|
||||||
.create_first_ownerless(WorkspaceCreateRequest {
|
.create(
|
||||||
|
WorkspaceCreateRequest {
|
||||||
operation_key: "remote-create".to_string(),
|
operation_key: "remote-create".to_string(),
|
||||||
display_name: "Remote Workspace".to_string(),
|
display_name: "Remote Workspace".to_string(),
|
||||||
repository: InitialRepositoryIntent {
|
repository: InitialRepositoryIntent {
|
||||||
@@ -388,7 +347,9 @@ mod tests {
|
|||||||
display_name: Some("Remote Repository".to_string()),
|
display_name: Some("Remote Repository".to_string()),
|
||||||
default_ref: Some("main".to_string()),
|
default_ref: Some("main".to_string()),
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
owner_account_id,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let persisted = store
|
let persisted = store
|
||||||
|
|||||||
+3
-1
@@ -264,6 +264,8 @@ in
|
|||||||
"serve"
|
"serve"
|
||||||
"--listen"
|
"--listen"
|
||||||
"0.0.0.0:8787"
|
"0.0.0.0:8787"
|
||||||
|
"--config"
|
||||||
|
"/server-config/server.toml"
|
||||||
];
|
];
|
||||||
Env = [
|
Env = [
|
||||||
"PATH=/bin"
|
"PATH=/bin"
|
||||||
@@ -274,7 +276,7 @@ in
|
|||||||
};
|
};
|
||||||
Volumes = {
|
Volumes = {
|
||||||
"/server-data" = { };
|
"/server-data" = { };
|
||||||
"/workspace" = { };
|
"/server-config" = { };
|
||||||
};
|
};
|
||||||
WorkingDir = "/server-data";
|
WorkingDir = "/server-data";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,19 +58,18 @@ The Compose files live at:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
compose.yaml
|
compose.yaml
|
||||||
docker/workspace/.yoi/workspace.toml
|
|
||||||
docker/workspace/.yoi/workspace-backend.local.toml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
|
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
|
||||||
|
|
||||||
`YOI_BROWSER_PUBLIC_URL` is the single browser-facing deployment setting used by the Server for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks. Compose defaults it to `http://localhost:8080`; deployments exposed through another host, port, or HTTPS endpoint must set the exact Nginx-facing origin, for example:
|
The operator-owned `/etc/yoi/server.toml` is mounted read-only at `/server-config/server.toml`. Its `browser.public_url` is the single browser-facing setting used for WebAuthn, device-login URLs, cookie policy, and cookie-authenticated mutation origin checks:
|
||||||
|
|
||||||
```text
|
```toml
|
||||||
YOI_BROWSER_PUBLIC_URL=https://yoi.example.com docker compose up
|
[browser]
|
||||||
|
public_url = "https://yoi.example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
The value is an origin, not an API/backend URL, and must not include a path, query, or fragment.
|
Create this host file before starting Compose and set it to the exact Nginx-facing origin. It is not an API/backend URL and must not include a path, query, or fragment. It is deployment topology outside the source and Workspace repositories, not Workspace DB state or repository-local configuration.
|
||||||
|
|
||||||
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
|
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.
|
||||||
|
|
||||||
|
|||||||
@@ -153,11 +153,7 @@ For repository builds:
|
|||||||
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
|
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
|
||||||
```
|
```
|
||||||
|
|
||||||
If the Server DB has no workspace record yet, initialize it first:
|
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
|
||||||
|
|
||||||
```bash
|
|
||||||
yoi-server init --workspace <WORKSPACE_ROOT>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smoke checks
|
## Smoke checks
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
# Workspace Backend local config template.
|
|
||||||
#
|
|
||||||
# `yoi-server init` copies this packaged template to
|
|
||||||
# `.yoi/workspace-backend.local.toml` without overwriting an existing file.
|
|
||||||
# The `.local` file is intentionally git-ignored.
|
|
||||||
#
|
|
||||||
# Print the latest packaged template with:
|
|
||||||
# yoi-server config default
|
|
||||||
#
|
|
||||||
# Compare the local config with the latest packaged template with:
|
|
||||||
# yoi-server config diff
|
|
||||||
#
|
|
||||||
# Omit a key to use the built-in fallback. TOML has no `null`, so optional
|
|
||||||
# settings are represented by leaving the key commented out.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# Backend HTTP/WebSocket listen address.
|
|
||||||
listen = "127.0.0.1:8787"
|
|
||||||
|
|
||||||
# Static SPA build directory override. Leave commented for dev/API-only mode.
|
|
||||||
# Relative paths are resolved from the workspace root.
|
|
||||||
# static_assets_dir = "web/workspace/dist"
|
|
||||||
|
|
||||||
[data]
|
|
||||||
# Workspace-scoped runtime/data root override. Leave commented to use the user-data fallback:
|
|
||||||
# <data_dir>/server/workspaces/<workspace_id>/
|
|
||||||
# Relative paths are resolved from the workspace root.
|
|
||||||
# root = ".yoi/workspace-backend.data"
|
|
||||||
|
|
||||||
# Explicit control-plane SQLite DB path override. Normal `serve` uses the Yoi
|
|
||||||
# server DB at `<data_dir>/server/server.db`; keep this commented unless a
|
|
||||||
# local test needs a custom DB path.
|
|
||||||
# workspace_database_path = ".yoi/workspace-backend.data/server.db"
|
|
||||||
|
|
||||||
# Explicit embedded Runtime fs-store root override.
|
|
||||||
# If omitted, this falls back to `<data.root>/embedded-runtime`.
|
|
||||||
# embedded_runtime_store_root = ".yoi/workspace-backend.data/embedded-runtime"
|
|
||||||
|
|
||||||
[limits]
|
|
||||||
max_records = 200
|
|
||||||
|
|
||||||
[auth]
|
|
||||||
# Vite owns this origin in local development. Set YOI_BROWSER_PUBLIC_URL to the
|
|
||||||
# browser-facing Nginx origin in deployments. The Server derives the WebAuthn
|
|
||||||
# origin, RP ID, device-login URL, cookie policy, and CSRF check from this URL.
|
|
||||||
browser_public_url = "http://localhost:5173"
|
|
||||||
cookie_name = "yoi_workspace_session"
|
|
||||||
|
|
||||||
# Repository registry. Browser/API repository projection reads only configured
|
|
||||||
# entries and never falls back to the backend process cwd. Relative URI values
|
|
||||||
# are resolved from this workspace config root. Git is the v0 supported provider.
|
|
||||||
#
|
|
||||||
# [[repositories]]
|
|
||||||
# id = "main"
|
|
||||||
# provider = "git"
|
|
||||||
# uri = "."
|
|
||||||
# display_name = "Main repository"
|
|
||||||
# default_selector = "HEAD"
|
|
||||||
|
|
||||||
# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml`
|
|
||||||
# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config.
|
|
||||||
Reference in New Issue
Block a user