diff --git a/cli/golem-cli/src/app/context.rs b/cli/golem-cli/src/app/context.rs index f2885df18d..582e9e24d0 100644 --- a/cli/golem-cli/src/app/context.rs +++ b/cli/golem-cli/src/app/context.rs @@ -25,8 +25,8 @@ use crate::fs; use crate::log::{LogColorize, LogIndent, LogOutput, Output, log_action, logln}; use crate::model::app::{ AppBuildStep, Application, ApplicationComponentSelectMode, ApplicationConfig, - ApplicationNameAndEnvironments, ApplicationSourceMode, BuildConfig, CleanMode, - ComponentPresetSelector, CustomBridgeSdkTarget, DynamicHelpSections, LoadedRawApps, WithSource, + ApplicationPreload, ApplicationSourceMode, BuildConfig, CleanMode, ComponentPresetSelector, + CustomBridgeSdkTarget, DynamicHelpSections, LoadedRawApps, ResolvedLocalServer, WithSource, includes_from_yaml_file, }; use crate::model::format::Format; @@ -36,7 +36,7 @@ use crate::model::text::fmt::format_component_applied_layers; use crate::model::text::server::ToFormattedServerContext; use crate::model::{GuestLanguage, app_raw}; use crate::validation::{ValidatedResult, ValidationBuilder}; -use anyhow::{Context as _, anyhow, bail}; +use anyhow::{anyhow, bail}; use colored::Colorize; use golem_common::model::application::ApplicationName; use golem_common::model::component::ComponentName; @@ -152,7 +152,8 @@ impl ToolsWithEnsuredCommonDeps { pub struct ApplicationPreloadResult { pub source_mode: ApplicationSourceMode, pub loaded_with_warnings: bool, - pub application_name_and_environments: Option, + pub application_preload: Option, + pub resolved_local_server: Option, pub used_language_templates: HashSet, } @@ -171,26 +172,29 @@ impl ApplicationContext { source_mode: ApplicationSourceMode, yes: bool, ) -> anyhow::Result<()> { - let original_dir = fs::current_dir_lexical()?; + Self::plan_and_apply_manifest_upgrades_before_load_from( + source_mode, + yes, + &fs::current_dir_lexical()?, + ) + } + fn plan_and_apply_manifest_upgrades_before_load_from( + source_mode: ApplicationSourceMode, + yes: bool, + calling_working_dir: &Path, + ) -> anyhow::Result<()> { let collected_sources = { let _output = LogOutput::new(Output::None); match &source_mode { - ApplicationSourceMode::Automatic => collect_sources_and_switch_to_app_root(None), + ApplicationSourceMode::Automatic => collect_sources_from(calling_working_dir, None), ApplicationSourceMode::ByRootManifest(root_manifest) => { - collect_sources_and_switch_to_app_root(Some(root_manifest)) + collect_sources_from(calling_working_dir, Some(root_manifest)) } ApplicationSourceMode::Preloaded(_) | ApplicationSourceMode::None => None, } }; - std::env::set_current_dir(&original_dir).with_context(|| { - anyhow!( - "Failed to restore working directory to {}", - original_dir.display() - ) - })?; - let Some(collected_sources) = collected_sources else { return Ok(()); }; @@ -262,7 +266,8 @@ impl ApplicationContext { None => Ok(ApplicationPreloadResult { source_mode: ApplicationSourceMode::None, loaded_with_warnings: false, - application_name_and_environments: None, + application_preload: None, + resolved_local_server: None, used_language_templates: HashSet::new(), }), } @@ -273,12 +278,14 @@ impl ApplicationContext { config: ApplicationConfig, application_name: WithSource, environments: BTreeMap, + local_server: Option>, component_presets: ComponentPresetSelector, file_download_client: reqwest::Client, ) -> anyhow::Result> { let Some(app_and_calling_working_dir) = load_app( application_name, environments, + local_server, component_presets, source_mode, ) else { @@ -626,6 +633,28 @@ impl ApplicationContext { )); } } + + let mcp_deployments = self + .application + .mcp_deployments(self.application.environment_name()); + if let Some(mcp_deployments) = mcp_deployments { + logln(format!( + "{}", + format!( + "Application MCP deployments for environment {}:", + environment_name.0 + ) + .log_color_help_group() + )); + + for (site, deployment) in mcp_deployments { + logln(format!(" {}", site.to_string().log_color_highlight(),)); + for (agent_name, _) in deployment.value.agents.iter() { + logln(format!(" {}", agent_name.as_str().log_color_highlight(),)); + } + } + logln(""); + } } if config.custom_commands() { @@ -663,6 +692,7 @@ impl ApplicationContext { fn load_app( application_name: WithSource, environments: BTreeMap, + local_server: Option>, component_presets: ComponentPresetSelector, source_mode: ApplicationSourceMode, ) -> Option> { @@ -672,6 +702,7 @@ fn load_app( loaded_raw_apps.app_root_dir, application_name, environments, + local_server, component_presets, loaded_raw_apps.raw_apps, ) @@ -689,38 +720,45 @@ fn preload_app( let used_language_templates = Application::language_templates_from_raw_apps(&loaded_raw_apps.raw_apps); - Application::environments_from_raw_apps(loaded_raw_apps.raw_apps.as_slice()) - .and_then(|application_name_and_environments| { + Application::preload_from_raw_apps(loaded_raw_apps.raw_apps.as_slice()) + .and_then(|application_preload| { ValidatedResult::from_result(ensure_on_demand_commons( &used_language_templates, dev_mode, )) .map(|on_demand_common_raw_apps| { - (on_demand_common_raw_apps, application_name_and_environments) + (on_demand_common_raw_apps, application_preload) }) }) - .map( - |(on_demand_common_raw_apps, application_name_and_environments)| { - let raw_apps = { - let mut raw_apps = loaded_raw_apps.raw_apps; - raw_apps.extend(on_demand_common_raw_apps); - raw_apps - }; - - ApplicationPreloadResult { - source_mode: ApplicationSourceMode::Preloaded(LoadedRawApps { - app_root_dir: loaded_raw_apps.app_root_dir, - calling_working_dir: loaded_raw_apps.calling_working_dir, - raw_apps, - }), - loaded_with_warnings: false, - application_name_and_environments: Some( - application_name_and_environments, - ), - used_language_templates, - } - }, - ) + .map(|(on_demand_common_raw_apps, application_preload)| { + let resolved_local_server = + application_preload + .local_server + .as_ref() + .map(|local_server| { + ResolvedLocalServer::from_raw_with_source( + local_server, + &loaded_raw_apps.app_root_dir, + ) + }); + let raw_apps = { + let mut raw_apps = loaded_raw_apps.raw_apps; + raw_apps.extend(on_demand_common_raw_apps); + raw_apps + }; + + ApplicationPreloadResult { + source_mode: ApplicationSourceMode::Preloaded(LoadedRawApps { + app_root_dir: loaded_raw_apps.app_root_dir, + calling_working_dir: loaded_raw_apps.calling_working_dir, + raw_apps, + }), + loaded_with_warnings: false, + application_preload: Some(application_preload), + resolved_local_server, + used_language_templates, + } + }) }) }) } @@ -794,7 +832,18 @@ fn collect_sources_and_switch_to_app_root( root_manifest: Option<&Path>, ) -> Option> { let calling_working_dir = fs::current_dir_lexical().unwrap(); + collect_sources_from(&calling_working_dir, root_manifest).map(|collected_sources| { + collected_sources.inspect(|collected_sources| { + std::env::set_current_dir(&collected_sources.app_root_dir) + .expect("Failed to set current dir for config parent"); + }) + }) +} +fn collect_sources_from( + calling_working_dir: &Path, + root_manifest: Option<&Path>, +) -> Option> { log_action("Collecting", "application manifests"); let _indent = LogIndent::new(); @@ -803,7 +852,6 @@ fn collect_sources_and_switch_to_app_root( ) -> Option, PathBuf)>> { let source_dir = fs::parent_or_err(source).expect("Failed to get parent dir for config parent"); - std::env::set_current_dir(source_dir).expect("Failed to set current dir for config parent"); let includes = includes_from_yaml_file(source); if includes.is_empty() { @@ -825,18 +873,14 @@ fn collect_sources_and_switch_to_app_root( } let sources_and_root_dir = match root_manifest { - None => match find_main_source() { + None => match find_main_source_from(calling_working_dir) { Some(source) => collect_by_main_source(&source), None => None, }, - Some(source) => match fs::absolute_lexical_path(source) { - Ok(source) => collect_by_main_source(&source), - Err(err) => Some(ValidatedResult::from_error(format!( - "Cannot resolve requested application manifest source {}: {}", - source.log_color_highlight(), - err - ))), - }, + Some(source) => { + let source = fs::absolute_lexical_path_from_base_dir(source, calling_working_dir); + collect_by_main_source(&source) + } }; sources_and_root_dir.map(|sources_and_root_dir| { @@ -860,17 +904,12 @@ fn collect_sources_and_switch_to_app_root( }) .map(|sources_and_root_dir| CollectedSources { app_root_dir: sources_and_root_dir.1, - calling_working_dir, + calling_working_dir: calling_working_dir.to_path_buf(), sources: sources_and_root_dir.0, }) }) } -fn find_main_source() -> Option { - let current_dir = std::env::current_dir().expect("Failed to get current dir"); - find_main_source_from(¤t_dir) -} - pub(crate) fn find_main_source_from(start_dir: &Path) -> Option { let mut current_dir = start_dir.to_path_buf(); let mut last_source: Option = None; @@ -919,11 +958,11 @@ pub fn validated_to_anyhow( #[cfg(test)] mod tests { use super::ApplicationContext; - use crate::model::app::ApplicationSourceMode; + use crate::model::app::{Application, ApplicationSourceMode, ResolvedLocalServer}; use test_r::test; #[test] - fn automatic_manifest_upgrade_applies_before_load_and_restores_cwd() { + fn automatic_manifest_upgrade_applies_before_load_without_changing_cwd() { let original_dir = std::env::current_dir().unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let app_dir = temp_dir.path().join("app"); @@ -940,24 +979,117 @@ app: demo let nested_dir = app_dir.join("src"); std::fs::create_dir(&nested_dir).unwrap(); - std::env::set_current_dir(&nested_dir).unwrap(); - let result = ApplicationContext::plan_and_apply_manifest_upgrades_before_load( + let result = ApplicationContext::plan_and_apply_manifest_upgrades_before_load_from( ApplicationSourceMode::Automatic, true, + &nested_dir, ); let current_dir = std::env::current_dir().unwrap(); - std::env::set_current_dir(&original_dir).unwrap(); - result.unwrap(); assert_eq!( std::fs::canonicalize(current_dir).unwrap(), - std::fs::canonicalize(nested_dir).unwrap() + std::fs::canonicalize(original_dir).unwrap() ); let upgraded = std::fs::read_to_string(manifest).unwrap(); assert!(upgraded.contains("manifestVersion: 1.6.0")); - assert!(upgraded.contains("/1.6.0-dev.2/golem.schema.json")); + assert!(upgraded.contains("/1.6.0-dev.3/golem.schema.json")); + } + + #[test] + fn preload_resolves_local_server_paths_against_declaring_manifest_dir() { + let original_dir = std::env::current_dir().unwrap(); + let temp_dir = tempfile::tempdir().unwrap(); + let app_dir = temp_dir.path().join("app"); + let config_dir = app_dir.join("config"); + std::fs::create_dir(&app_dir).unwrap(); + std::fs::create_dir(&config_dir).unwrap(); + let manifest = app_dir.join("golem.yaml"); + let local_server_manifest = config_dir.join("local-server.yaml"); + std::fs::write( + &manifest, + r#" +manifestVersion: 1.6.0 +app: demo +includes: + - config/local-server.yaml +environments: + local: + server: local +"#, + ) + .unwrap(); + std::fs::write( + &local_server_manifest, + r#" +manifestVersion: 1.6.0 +localServer: + portsFile: .golem/ports.json + dataDir: .golem/data + agentFilesystemRoot: .golem/agents +"#, + ) + .unwrap(); + + let result = super::collect_sources_from(&original_dir, Some(&manifest)) + .expect("manifest should be found"); + let (collected_sources, warns, errors) = result.into_product(); + + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert!(errors.is_empty(), "\n{}", errors.join("\n\n")); + + let collected_sources = collected_sources.unwrap(); + let raw_apps = collected_sources + .sources + .iter() + .map(|source| crate::model::app_raw::ApplicationWithSource::from_yaml_file(source)) + .collect::, _>>() + .unwrap(); + let (application_preload, warns, errors) = + Application::preload_from_raw_apps(raw_apps.as_slice()).into_product(); + + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert!(errors.is_empty(), "\n{}", errors.join("\n\n")); + + let application_preload = application_preload.unwrap(); + let raw_local_server = application_preload + .local_server + .as_ref() + .unwrap() + .value + .clone(); + + assert_eq!( + raw_local_server.ports_file, + Some(std::path::PathBuf::from(".golem/ports.json")) + ); + assert_eq!( + raw_local_server.data_dir, + Some(std::path::PathBuf::from(".golem/data")) + ); + assert_eq!( + raw_local_server.agent_filesystem_root, + Some(std::path::PathBuf::from(".golem/agents")) + ); + + let resolved_local_server = ResolvedLocalServer::from_raw_with_source( + application_preload.local_server.as_ref().unwrap(), + &collected_sources.app_root_dir, + ); + + assert_eq!( + resolved_local_server.ports_file, + Some(config_dir.join(".golem/ports.json")) + ); + assert_eq!( + resolved_local_server.data_dir, + Some(config_dir.join(".golem/data")) + ); + assert_eq!( + resolved_local_server.agent_filesystem_root, + Some(config_dir.join(".golem/agents")) + ); } } diff --git a/cli/golem-cli/src/app/manifest_upgrade.rs b/cli/golem-cli/src/app/manifest_upgrade.rs index be4447fdee..e1eed633d1 100644 --- a/cli/golem-cli/src/app/manifest_upgrade.rs +++ b/cli/golem-cli/src/app/manifest_upgrade.rs @@ -85,7 +85,7 @@ app: demo assert_eq!(steps.len(), 1); assert_eq!(steps[0].path, source); assert!(steps[0].new.contains("manifestVersion: 1.6.0")); - assert!(steps[0].new.contains("/1.6.0-dev.2/golem.schema.json")); + assert!(steps[0].new.contains("/1.6.0-dev.3/golem.schema.json")); assert!(!steps[0].new.contains("/1.5.0/golem.schema.json")); } diff --git a/cli/golem-cli/src/command.rs b/cli/golem-cli/src/command.rs index 4e0965d77e..a564e7e3d4 100644 --- a/cli/golem-cli/src/command.rs +++ b/cli/golem-cli/src/command.rs @@ -2300,6 +2300,9 @@ pub mod account { } pub mod server { + use crate::config::{ + DEFAULT_LOCAL_CUSTOM_REQUEST_PORT, DEFAULT_LOCAL_MCP_PORT, DEFAULT_LOCAL_ROUTER_PORT, + }; use clap::{Args, Subcommand}; use std::path::PathBuf; @@ -2356,14 +2359,15 @@ pub mod server { } pub fn router_port(&self) -> u16 { - self.router_port.unwrap_or(9881) + self.router_port.unwrap_or(DEFAULT_LOCAL_ROUTER_PORT) } pub fn custom_request_port(&self) -> u16 { - self.custom_request_port.unwrap_or(9006) + self.custom_request_port + .unwrap_or(DEFAULT_LOCAL_CUSTOM_REQUEST_PORT) } pub fn mcp_port(&self) -> u16 { - self.mcp_port.unwrap_or(9007) + self.mcp_port.unwrap_or(DEFAULT_LOCAL_MCP_PORT) } } diff --git a/cli/golem-cli/src/config.rs b/cli/golem-cli/src/config.rs index 7ed0dfafa6..af784a31d4 100644 --- a/cli/golem-cli/src/config.rs +++ b/cli/golem-cli/src/config.rs @@ -33,8 +33,16 @@ use std::time::Duration; use url::Url; use uuid::Uuid; -pub const CLOUD_URL: &str = "https://release.api.golem.cloud"; -const BUILTIN_LOCAL_URL: &str = "http://localhost:9881"; +pub const DEFAULT_LOCAL_ROUTER_PORT: u16 = 9881; +pub const DEFAULT_LOCAL_CUSTOM_REQUEST_PORT: u16 = 9006; +pub const DEFAULT_LOCAL_MCP_PORT: u16 = 9007; + +pub const DEFAULT_LOCAL_URL: &str = "http://localhost:9881"; +pub const DEFAULT_CLOUD_URL: &str = "https://release.api.golem.cloud"; + +pub const CLOUD_HTTP_API_DOMAIN: &str = "apps.golem.cloud"; +pub const CLOUD_MCP_DOMAIN: &str = "mcps.golem.cloud"; + const BUILTIN_LOCAL_URL_ENV: &str = "GOLEM_BUILTIN_LOCAL_URL"; const PROFILE_NAME_LOCAL: &str = "local"; const PROFILE_NAME_CLOUD: &str = "cloud"; @@ -58,7 +66,7 @@ fn builtin_local_url_state() -> &'static BuiltinLocalUrlState { } BuiltinLocalUrlState { - url: Url::parse(BUILTIN_LOCAL_URL).expect("Failed to parse BUILTIN_LOCAL_URL"), + url: Url::parse(DEFAULT_LOCAL_URL).expect("Failed to parse DEFAULT_LOCAL_URL"), uses_default: true, } }) @@ -331,7 +339,7 @@ pub struct ClientConfig { impl From<&Profile> for ClientConfig { fn from(profile: &Profile) -> Self { - let default_cloud_url = Url::parse(CLOUD_URL).unwrap(); + let default_cloud_url = Url::parse(DEFAULT_CLOUD_URL).unwrap(); let registry_url = profile.custom_url.clone().unwrap_or(default_cloud_url); let worker_url = profile .custom_worker_url @@ -373,7 +381,8 @@ impl From<&Server> for ClientConfig { allow_insecure: false, }, BuiltinServer::Cloud => { - let cloud_url = Url::parse(CLOUD_URL).expect("Failed to parse CLOUD_URL"); + let cloud_url = + Url::parse(DEFAULT_CLOUD_URL).expect("Failed to parse DEFAULT_CLOUD_URL"); BaseConfig { registry_url: cloud_url.clone(), worker_url: cloud_url.clone(), diff --git a/cli/golem-cli/src/context.rs b/cli/golem-cli/src/context.rs index a974959fcb..ec1bdbae11 100644 --- a/cli/golem-cli/src/context.rs +++ b/cli/golem-cli/src/context.rs @@ -25,9 +25,9 @@ use crate::config::{ use crate::config::{ClientConfig, ProfileName, builtin_local_url}; use crate::error::{ContextInitHintError, HintError, NonSuccessfulExit}; use crate::log::{LogColorize, LogOutput, Output, log_action, set_log_output}; -use crate::model::app::{ApplicationConfig, ComponentPresetSelector}; use crate::model::app::{ - ApplicationNameAndEnvironments, ApplicationSourceMode, ComponentPresetName, WithSource, + ApplicationConfig, ApplicationPreload, ApplicationSourceMode, ComponentPresetName, + ComponentPresetSelector, ResolvedLocalServer, WithSource, }; use crate::model::app_raw::{ BuiltinServer, CustomServerAuth, DeploymentOptions, Environment, Marker, Server, @@ -68,6 +68,7 @@ pub struct Context { environment_reference: Option, manifest_environment: Option, manifest_environment_deployment_options: Option, + manifest_local_server: Option, app_context_config: Option, http_batch_size: u64, http_parallelism: usize, @@ -146,17 +147,19 @@ impl Context { } let app_source_mode = preloaded_app.source_mode; - let application_name_and_environments = preloaded_app.application_name_and_environments; + let manifest_local_server = preloaded_app.resolved_local_server; + let application_preload = preloaded_app.application_preload; let manifest_environment: Option = match &environment_reference { Some(environment_reference) => { match environment_reference { EnvironmentReference::Environment { environment_name } => { - match &application_name_and_environments { - Some(ApplicationNameAndEnvironments { + match &application_preload { + Some(ApplicationPreload { application_name, environments, + .. }) => match environments.get(environment_name) { Some(environment) => Some(SelectedManifestEnvironment { application_name: application_name.value.clone(), @@ -188,10 +191,11 @@ impl Context { EnvironmentReference::AccountApplicationEnvironment { .. } => None, } } - None => match &application_name_and_environments { - Some(ApplicationNameAndEnvironments { + None => match &application_preload { + Some(ApplicationPreload { application_name, environments, + .. }) => environments .iter() .find(|(_, env)| env.default == Some(Marker)) @@ -283,34 +287,31 @@ impl Context { let file_download_client = new_raw_reqwest_client(&client_config.file_download_http_client_config)?; - let app_context_config = manifest_environment - .as_ref() - .zip(application_name_and_environments) - .map( - |(selected_environment, application_name_and_environments)| { - ApplicationContextConfig::new( - &global_flags, - application_name_and_environments, - ComponentPresetSelector { - environment: selected_environment.environment_name.clone(), - presets: { - if global_flags.preset.is_empty() { - selected_environment - .environment - .component_presets - .clone() - .into_vec() - .into_iter() - .map(ComponentPresetName) - .collect::>() - } else { - global_flags.preset.clone() - } - }, + let app_context_config = manifest_environment.as_ref().zip(application_preload).map( + |(selected_environment, application_preload)| { + ApplicationContextConfig::new( + &global_flags, + application_preload, + ComponentPresetSelector { + environment: selected_environment.environment_name.clone(), + presets: { + if global_flags.preset.is_empty() { + selected_environment + .environment + .component_presets + .clone() + .into_vec() + .into_iter() + .map(ComponentPresetName) + .collect::>() + } else { + global_flags.preset.clone() + } }, - ) - }, - ); + }, + ) + }, + ); Ok(Self { config_dir: global_flags.config_dir(), @@ -325,6 +326,7 @@ impl Context { environment_reference, manifest_environment, manifest_environment_deployment_options, + manifest_local_server, yes, dev_mode: global_flags.dev_mode, show_secrets: global_flags.show_secrets, @@ -413,6 +415,10 @@ impl Context { self.manifest_environment_deployment_options.as_ref() } + pub fn manifest_local_server(&self) -> Option<&ResolvedLocalServer> { + self.manifest_local_server.as_ref() + } + pub fn caches(&self) -> &Caches { &self.caches } @@ -675,6 +681,7 @@ struct ApplicationContextConfig { disable_app_manifest_discovery: bool, application_name: WithSource, environments: BTreeMap, + local_server: Option>, component_presets: ComponentPresetSelector, wasm_rpc_client_build_offline: bool, dev_mode: bool, @@ -684,14 +691,15 @@ struct ApplicationContextConfig { impl ApplicationContextConfig { pub fn new( global_flags: &GolemCliGlobalFlags, - application_name_and_environments: ApplicationNameAndEnvironments, + application_preload: ApplicationPreload, component_presets: ComponentPresetSelector, ) -> Self { Self { app_manifest_path: global_flags.app_manifest_path.clone(), disable_app_manifest_discovery: global_flags.disable_app_manifest_discovery, - application_name: application_name_and_environments.application_name, - environments: application_name_and_environments.environments, + application_name: application_preload.application_name, + environments: application_preload.environments, + local_server: application_preload.local_server, component_presets, wasm_rpc_client_build_offline: global_flags.wasm_rpc_offline, dev_mode: global_flags.dev_mode, @@ -781,6 +789,7 @@ impl ApplicationContextState { app_config, config.application_name.clone(), config.environments.clone(), + config.local_server.clone(), config.component_presets.clone(), file_download_client.clone(), ) diff --git a/cli/golem-cli/src/model/app.rs b/cli/golem-cli/src/model/app.rs index 07ee9f2528..a9ff3bb004 100644 --- a/cli/golem-cli/src/model/app.rs +++ b/cli/golem-cli/src/model/app.rs @@ -16,7 +16,7 @@ use super::http_api::{HttpApiDeploymentDeployProperties, McpDeploymentDeployProp use crate::bridge_gen::bridge_client_directory_name; use crate::fs; use crate::log::LogColorize; -use crate::model::app::app_builder::{build_application, build_environments}; +use crate::model::app::app_builder::{build_application, build_application_preload}; use crate::model::cascade::layer::Layer; use crate::model::cascade::property::Property; use crate::model::cascade::property::json::JsonProperty; @@ -320,9 +320,52 @@ impl Default for WithSource { } #[derive(Clone, Debug)] -pub struct ApplicationNameAndEnvironments { +pub struct ApplicationPreload { pub application_name: WithSource, pub environments: BTreeMap, + pub local_server: Option>, +} + +#[derive(Clone, Debug)] +pub struct ResolvedLocalServer { + pub router_addr: Option, + pub router_port: Option, + pub custom_request_port: Option, + pub mcp_port: Option, + pub ports_file: Option, + pub data_dir: Option, + pub agent_filesystem_root: Option, +} + +impl ResolvedLocalServer { + pub fn from_raw_with_source( + local_server: &WithSource, + app_root_dir: &Path, + ) -> Self { + let base_dir = local_server.source.parent().unwrap_or(app_root_dir); + Self::from_raw_with_base_dir(&local_server.value, base_dir) + } + + pub fn from_raw_with_base_dir(local_server: &app_raw::LocalServer, base_dir: &Path) -> Self { + Self { + router_addr: local_server.router_addr.clone(), + router_port: local_server.router_port, + custom_request_port: local_server.custom_request_port, + mcp_port: local_server.mcp_port, + ports_file: local_server + .ports_file + .as_ref() + .map(|path| fs::absolute_lexical_path_from_base_dir(path, base_dir)), + data_dir: local_server + .data_dir + .as_ref() + .map(|path| fs::absolute_lexical_path_from_base_dir(path, base_dir)), + agent_filesystem_root: local_server + .agent_filesystem_root + .as_ref() + .map(|path| fs::absolute_lexical_path_from_base_dir(path, base_dir)), + } + } } #[derive(Clone, Debug)] @@ -433,10 +476,10 @@ impl Application { } } - pub fn environments_from_raw_apps( + pub fn preload_from_raw_apps( apps: &[app_raw::ApplicationWithSource], - ) -> ValidatedResult { - build_environments(apps) + ) -> ValidatedResult { + build_application_preload(apps) } pub fn language_templates_from_raw_apps( @@ -480,6 +523,7 @@ impl Application { root_dir: PathBuf, application_name: WithSource, environments: BTreeMap, + local_server: Option>, component_presets: ComponentPresetSelector, apps: Vec, ) -> ValidatedResult { @@ -487,6 +531,7 @@ impl Application { root_dir, application_name, environments, + local_server, component_presets, apps, ) @@ -2215,7 +2260,7 @@ mod app_builder { use crate::fuzzy::FuzzySearch; use crate::log::LogColorize; use crate::model::app::{ - Application, ApplicationNameAndEnvironments, ComponentLayer, ComponentLayerApplyContext, + Application, ApplicationPreload, ComponentLayer, ComponentLayerApplyContext, ComponentLayerId, ComponentLayerProperties, ComponentLayerPropertiesKind, ComponentPresetName, ComponentPresetSelector, ComponentProperties, PartitionedComponentPresets, TEMP_DIR, WithSource, @@ -2249,6 +2294,7 @@ mod app_builder { root_dir: PathBuf, application_name: WithSource, environments: BTreeMap, + local_server: Option>, component_presets: ComponentPresetSelector, apps: Vec, ) -> ValidatedResult { @@ -2256,16 +2302,17 @@ mod app_builder { root_dir, application_name, environments, + local_server, component_presets, apps, ) } - // Load only environments - pub fn build_environments( + // Load manifest fields needed before full application loading. + pub fn build_application_preload( apps: &[app_raw::ApplicationWithSource], - ) -> ValidatedResult { - AppBuilder::build_environments(apps) + ) -> ValidatedResult { + AppBuilder::build_application_preload(apps) } #[derive(Debug, PartialEq, Eq, Hash)] @@ -2281,6 +2328,7 @@ mod app_builder { RetryPolicyDefaults(EnvironmentName), ResourceDefaults(EnvironmentName), Bridge, + LocalServer, } impl UniqueSourceCheckedEntityKey { @@ -2298,6 +2346,7 @@ mod app_builder { UniqueSourceCheckedEntityKey::RetryPolicyDefaults(_) => property, UniqueSourceCheckedEntityKey::ResourceDefaults(_) => property, UniqueSourceCheckedEntityKey::Bridge => "Bridge", + UniqueSourceCheckedEntityKey::LocalServer => property, } } @@ -2344,16 +2393,179 @@ mod app_builder { ) } UniqueSourceCheckedEntityKey::Bridge => "bridge".log_color_highlight().to_string(), + UniqueSourceCheckedEntityKey::LocalServer => { + "localServer".log_color_highlight().to_string() + } + } + } + } + + fn resolve_http_api_domain( + validation: &mut ValidationBuilder, + deployment: &app_raw::HttpApiDeployment, + environment_name: &EnvironmentName, + environment: Option<&app_raw::Environment>, + local_server: Option<&WithSource>, + source: &Path, + ) -> Option { + resolve_deployment_domain( + validation, + deployment.domain.as_ref(), + deployment.subdomain.as_ref(), + environment_name, + environment, + local_server.map(|local_server| &local_server.value), + |local_server| local_server.and_then(|local_server| local_server.custom_request_port), + crate::config::DEFAULT_LOCAL_CUSTOM_REQUEST_PORT, + crate::config::CLOUD_HTTP_API_DOMAIN, + "HTTP API", + source, + ) + } + + fn resolve_mcp_domain( + validation: &mut ValidationBuilder, + deployment: &app_raw::McpDeployment, + environment_name: &EnvironmentName, + environment: Option<&app_raw::Environment>, + local_server: Option<&WithSource>, + source: &Path, + ) -> Option { + resolve_deployment_domain( + validation, + deployment.domain.as_ref(), + deployment.subdomain.as_ref(), + environment_name, + environment, + local_server.map(|local_server| &local_server.value), + |local_server| local_server.and_then(|local_server| local_server.mcp_port), + crate::config::DEFAULT_LOCAL_MCP_PORT, + crate::config::CLOUD_MCP_DOMAIN, + "MCP", + source, + ) + } + + fn resolve_deployment_domain( + validation: &mut ValidationBuilder, + domain: Option<&app_raw::DeploymentDomain>, + subdomain: Option<&app_raw::DeploymentSubdomain>, + environment_name: &EnvironmentName, + environment: Option<&app_raw::Environment>, + local_server: Option<&app_raw::LocalServer>, + local_port: impl Fn(Option<&app_raw::LocalServer>) -> Option, + default_local_port: u16, + cloud_domain: &str, + deployment_kind: &str, + source: &Path, + ) -> Option { + match (domain, subdomain) { + (Some(domain), None) => { + let raw = domain.as_str(); + Some(Domain(raw.to_string())) + } + (None, Some(subdomain)) => { + let label = subdomain.as_str(); + if !is_valid_deployment_subdomain(label) { + validation.add_error(invalid_deployment_subdomain_error(label, source)); + return None; + } + + let Some(environment) = environment else { + validation.add_error(format!( + "Cannot resolve {} deployment subdomain {} in {} because environment {} is not defined.", + deployment_kind, + label.log_color_highlight(), + source.display().to_string().log_color_highlight(), + environment_name.0.log_color_highlight(), + )); + return None; + }; + + let resolved_domain = match environment.server.as_ref() { + Some(app_raw::Server::Custom(_)) => { + validation.add_error(format!( + "Cannot use {} deployment subdomain {} in {} for custom server environment {}. Use the {} field with a full domain instead.", + deployment_kind, + label.log_color_highlight(), + source.display().to_string().log_color_highlight(), + environment_name.0.log_color_highlight(), + "domain".log_color_highlight(), + )); + return None; + } + Some(app_raw::Server::Builtin(app_raw::BuiltinServer::Cloud)) => { + Domain(format!("{label}.{cloud_domain}")) + } + Some(app_raw::Server::Builtin(app_raw::BuiltinServer::Local)) | None => { + let local_port = local_port(local_server).unwrap_or(default_local_port); + Domain(format!("{label}.localhost:{}", local_port)) + } + }; + + Some(resolved_domain) + } + (Some(_), Some(subdomain)) => { + validation.add_error(format!( + "Deployment in {} cannot define both {} and {}. Use {} for a full domain, or {} for a built-in local/cloud server subdomain.", + source.display().to_string().log_color_highlight(), + "domain".log_color_highlight(), + "subdomain".log_color_highlight(), + "domain".log_color_highlight(), + "subdomain".log_color_highlight(), + )); + if !is_valid_deployment_subdomain(subdomain.as_str()) { + validation.add_error(invalid_deployment_subdomain_error( + subdomain.as_str(), + source, + )); + } + None + } + (None, None) => { + validation.add_error(format!( + "Deployment in {} must define either {} or {}.", + source.display().to_string().log_color_highlight(), + "domain".log_color_highlight(), + "subdomain".log_color_highlight(), + )); + None } } } + fn is_valid_deployment_subdomain(label: &str) -> bool { + !label.is_empty() + && label.len() <= 63 + && label + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + && label + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + && label + .chars() + .last() + .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + } + + fn invalid_deployment_subdomain_error(label: &str, source: &Path) -> String { + format!( + "Invalid deployment subdomain {} in {}. Use a single lowercase DNS label without dots, ports, or URL schemes.", + label.log_color_highlight(), + source.display().to_string().log_color_highlight(), + ) + } + #[derive(Default)] struct AppBuilder { // For environment build app: Option>, default_environment_names: BTreeSet, environments: IndexMap, + local_server: Option>, + deployment_domain_local_server: Option>, // "Consts" for component templating app_root_dir_str: String, @@ -2399,18 +2611,23 @@ mod app_builder { } impl AppBuilder { - // NOTE: build_app DOES NOT include environments, those are preloaded with build_environments, so - // flows that do not use manifest otherwise won't get blocked by high-level validation errors, + // NOTE: build_app receives preloaded application fields, so flows that do not otherwise + // need the full manifest are not blocked by high-level validation errors, // and we do not "steal" manifest loading logs from those which do use the manifest fully. fn build_app( app_root_dir: PathBuf, application_name: WithSource, environments: BTreeMap, + local_server: Option>, component_presets: ComponentPresetSelector, apps: Vec, ) -> ValidatedResult { let mut validation = ValidationBuilder::default(); - let mut builder = Self::default(); + let mut builder = Self { + environments: environments.clone().into_iter().collect(), + deployment_domain_local_server: local_server, + ..Self::default() + }; match Ok::<&PathBuf, anyhow::Error>(&app_root_dir).and_then(|app_root_dir| { Ok(( @@ -2470,14 +2687,14 @@ mod app_builder { // NOTE: Unlike build_app, here we do not consume the source apps, so they can be // used for build_app. For more info on this separation, see build_app. - fn build_environments( + fn build_application_preload( apps: &[app_raw::ApplicationWithSource], - ) -> ValidatedResult { + ) -> ValidatedResult { let mut builder = Self::default(); let mut validation = ValidationBuilder::default(); for app in apps { - builder.add_raw_app_environments_only(&mut validation, app); + builder.add_raw_app_preload_only(&mut validation, app); } if builder.default_environment_names.len() > 1 { @@ -2518,9 +2735,10 @@ mod app_builder { } }; - validation.build(ApplicationNameAndEnvironments { + validation.build(ApplicationPreload { application_name, environments: builder.environments.into_iter().collect(), + local_server: builder.local_server, }) } @@ -2615,6 +2833,19 @@ mod app_builder { if let Some(http_api) = app.application.http_api { for (environment, deployments) in http_api.deployments { for api_deployment in deployments { + let Some(domain) = resolve_http_api_domain( + validation, + &api_deployment, + &environment, + self.environments.get(&environment), + self.deployment_domain_local_server + .as_ref() + .or(self.local_server.as_ref()), + &app.source, + ) else { + continue; + }; + let deployments = self.http_api_deployments.entry(environment.clone()).or_default(); @@ -2630,7 +2861,7 @@ mod app_builder { ) .collect(); - deployments.entry(api_deployment.domain).or_insert(WithSource::new( + deployments.entry(domain).or_insert(WithSource::new( app.source.to_path_buf(), HttpApiDeploymentDeployProperties { webhooks_prefix: HttpApiDeploymentCreation::normalize_webhooks_prefix( @@ -2649,6 +2880,19 @@ mod app_builder { if let Some(mcp) = app.application.mcp { for (environment, deployments) in mcp.deployments { for mcp_deployment in deployments { + let Some(domain) = resolve_mcp_domain( + validation, + &mcp_deployment, + &environment, + self.environments.get(&environment), + self.deployment_domain_local_server + .as_ref() + .or(self.local_server.as_ref()), + &app.source, + ) else { + continue; + }; + let mcp_deployments = self.mcp_deployments.entry(environment.clone()).or_default(); @@ -2659,7 +2903,7 @@ mod app_builder { })) .collect(); - mcp_deployments.entry(mcp_deployment.domain.clone()).or_insert(WithSource::new( + mcp_deployments.entry(domain).or_insert(WithSource::new( app.source.to_path_buf(), McpDeploymentDeployProperties { agents }, )); @@ -2782,7 +3026,7 @@ mod app_builder { }); } - fn add_raw_app_environments_only( + fn add_raw_app_preload_only( &mut self, validation: &mut ValidationBuilder, app: &app_raw::ApplicationWithSource, @@ -2840,10 +3084,77 @@ mod app_builder { ); } } + + if let Some(local_server) = &app.application.local_server { + match &self.local_server { + Some(existing) => validation.add_error(format!( + "{} {} is defined in multiple sources: {}, {}", + UniqueSourceCheckedEntityKey::LocalServer.entity_kind(), + UniqueSourceCheckedEntityKey::LocalServer.entity_name(), + existing.source.log_color_highlight(), + app.source.log_color_highlight() + )), + None => { + Self::validate_local_server_ports( + validation, + local_server, + &app.source, + ); + self.local_server = + Some(WithSource::new(app.source.clone(), local_server.clone())); + } + } + } }, ); } + fn validate_local_server_ports( + validation: &mut ValidationBuilder, + local_server: &app_raw::LocalServer, + source: &Path, + ) { + fn validate_port( + validation: &mut ValidationBuilder, + value: Option, + manifest_field_name: &str, + cli_flag_name: &str, + source: &Path, + ) { + if value == Some(0) { + validation.add_error(format!( + "{} in {} must be nonzero. Port 0 is only allowed when passed directly as {} to {}.", + manifest_field_name.log_color_highlight(), + source.display().to_string().log_color_highlight(), + format!("{cli_flag_name} 0").log_color_highlight(), + "golem server run".log_color_highlight(), + )); + } + } + + validate_port( + validation, + local_server.router_port, + "localServer.routerPort", + "--router-port", + source, + ); + validate_port( + validation, + local_server.custom_request_port, + "localServer.customRequestPort", + "--custom-request-port", + source, + ); + validate_port( + validation, + local_server.mcp_port, + "localServer.mcpPort", + "--mcp-port", + source, + ); + } + fn add_component_template( &mut self, validation: &mut ValidationBuilder, @@ -3256,12 +3567,13 @@ mod app_builder { mod test { use crate::fs; use crate::model::app::{ - Application, ApplicationNameAndEnvironments, ComponentPresetSelector, - includes_from_yaml_file, + Application, ApplicationPreload, ComponentPresetSelector, includes_from_yaml_file, }; use crate::model::app_raw; use golem_common::model::agent::AgentTypeName; use golem_common::model::component::ComponentName; + use golem_common::model::domain_registration::Domain; + use golem_common::model::environment::EnvironmentName; use indoc::indoc; use pretty_assertions::assert_eq; use serde_json::json; @@ -4054,6 +4366,377 @@ mod test { ); } + #[test] + fn deployment_subdomains_resolve_for_local_and_cloud() { + let source = indoc! { r#" + app: hello-app + + localServer: + customRequestPort: 9016 + mcpPort: 9017 + + environments: + local: + server: local + implicit: + default: true + cloud: + server: cloud + + httpApi: + deployments: + local: + - subdomain: hello-api + implicit: + - subdomain: implicit-api + cloud: + - subdomain: hello-api + + mcp: + deployments: + local: + - subdomain: hello-mcp + implicit: + - subdomain: implicit-mcp + cloud: + - subdomain: hello-mcp + "# }; + + let (app, _app_tmp_dir) = load_app_for_env(source, "local", &[]); + + assert!( + app.http_api_deployments(&EnvironmentName("local".to_string())) + .unwrap() + .contains_key(&Domain("hello-api.localhost:9016".to_string())) + ); + assert!( + app.http_api_deployments(&EnvironmentName("cloud".to_string())) + .unwrap() + .contains_key(&Domain("hello-api.apps.golem.cloud".to_string())) + ); + assert!( + app.http_api_deployments(&EnvironmentName("implicit".to_string())) + .unwrap() + .contains_key(&Domain("implicit-api.localhost:9016".to_string())) + ); + assert!( + app.mcp_deployments(&EnvironmentName("local".to_string())) + .unwrap() + .contains_key(&Domain("hello-mcp.localhost:9017".to_string())) + ); + assert!( + app.mcp_deployments(&EnvironmentName("cloud".to_string())) + .unwrap() + .contains_key(&Domain("hello-mcp.mcps.golem.cloud".to_string())) + ); + assert!( + app.mcp_deployments(&EnvironmentName("implicit".to_string())) + .unwrap() + .contains_key(&Domain("implicit-mcp.localhost:9017".to_string())) + ); + } + + #[test] + fn deployment_domains_keep_full_domains_unchanged() { + let source = indoc! { r#" + app: hello-app + + environments: + local: + server: local + + httpApi: + deployments: + local: + - domain: api.example.com + + mcp: + deployments: + local: + - domain: mcp.example.com + "# }; + + let (app, _app_tmp_dir) = load_app_for_env(source, "local", &[]); + + assert!( + app.http_api_deployments(&EnvironmentName("local".to_string())) + .unwrap() + .contains_key(&Domain("api.example.com".to_string())) + ); + assert!( + app.mcp_deployments(&EnvironmentName("local".to_string())) + .unwrap() + .contains_key(&Domain("mcp.example.com".to_string())) + ); + } + + #[test] + fn deployment_subdomains_use_default_local_ports_without_local_server() { + let source = indoc! { r#" + app: hello-app + + environments: + local: + server: local + + httpApi: + deployments: + local: + - subdomain: hello-api + + mcp: + deployments: + local: + - subdomain: hello-mcp + "# }; + + let (app, _app_tmp_dir) = load_app_for_env(source, "local", &[]); + + assert!( + app.http_api_deployments(&EnvironmentName("local".to_string())) + .unwrap() + .contains_key(&Domain("hello-api.localhost:9006".to_string())) + ); + assert!( + app.mcp_deployments(&EnvironmentName("local".to_string())) + .unwrap() + .contains_key(&Domain("hello-mcp.localhost:9007".to_string())) + ); + } + + #[test] + fn local_server_rejects_zero_manifest_ports() { + let source = indoc! { r#" + app: hello-app + + localServer: + routerPort: 0 + customRequestPort: 0 + mcpPort: 0 + portsFile: .golem/ports.json + + environments: + local: + server: local + "# }; + + let tmp_dir = tempfile::tempdir().unwrap(); + let golem_yaml_path = tmp_dir.path().join("golem.yaml"); + fs::write(&golem_yaml_path, source).unwrap(); + let raw_apps = vec![ + app_raw::ApplicationWithSource::from_yaml_file(&golem_yaml_path) + .expect("raw manifest should parse"), + ]; + + let (app_name_and_envs, warns, errors) = + Application::preload_from_raw_apps(&raw_apps).into_product(); + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert!(app_name_and_envs.is_none()); + assert_eq!(errors.len(), 3, "\n{}", errors.join("\n\n")); + assert!( + errors + .iter() + .any(|error| error.contains("localServer.routerPort") + && error.contains("--router-port 0")), + "\n{}", + errors.join("\n\n") + ); + assert!( + errors + .iter() + .any(|error| error.contains("localServer.customRequestPort") + && error.contains("--custom-request-port 0")), + "\n{}", + errors.join("\n\n") + ); + assert!( + errors.iter().any( + |error| error.contains("localServer.mcpPort") && error.contains("--mcp-port 0") + ), + "\n{}", + errors.join("\n\n") + ); + } + + #[test] + fn deployment_subdomains_reject_invalid_values() { + let source = indoc! { r#" + app: hello-app + + environments: + local: + server: local + + httpApi: + deployments: + local: + - subdomain: hello.api + - subdomain: Hello + - subdomain: http://hello + "# }; + + let tmp_dir = tempfile::tempdir().unwrap(); + let golem_yaml_path = tmp_dir.path().join("golem.yaml"); + fs::write(&golem_yaml_path, source).unwrap(); + let raw_apps = vec![ + app_raw::ApplicationWithSource::from_yaml_file(&golem_yaml_path) + .expect("raw manifest should parse"), + ]; + + let (app_name_and_envs, warns, errors) = + Application::preload_from_raw_apps(&raw_apps).into_product(); + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert!(errors.is_empty(), "\n{}", errors.join("\n\n")); + let Some(ApplicationPreload { + application_name, + environments, + local_server, + }) = app_name_and_envs + else { + panic!("expected Some(ApplicationPreload)") + }; + + let (_app, warns, errors) = Application::from_raw_apps( + std::env::current_dir().unwrap(), + application_name, + environments, + local_server, + selector("local", &[]), + raw_apps, + ) + .into_product(); + + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert_eq!(errors.len(), 3, "\n{}", errors.join("\n\n")); + assert!( + errors + .iter() + .all(|error| error.contains("Invalid deployment subdomain")) + ); + } + + #[test] + fn deployment_subdomains_reject_custom_server_environments() { + let source = indoc! { r#" + app: hello-app + + environments: + custom: + server: + url: http://localhost:9881 + workerUrl: http://localhost:9881 + allowInsecure: true + auth: + staticToken: token + + httpApi: + deployments: + custom: + - subdomain: hello-api + "# }; + + let tmp_dir = tempfile::tempdir().unwrap(); + let golem_yaml_path = tmp_dir.path().join("golem.yaml"); + fs::write(&golem_yaml_path, source).unwrap(); + let raw_apps = vec![ + app_raw::ApplicationWithSource::from_yaml_file(&golem_yaml_path) + .expect("raw manifest should parse"), + ]; + + let (app_name_and_envs, warns, errors) = + Application::preload_from_raw_apps(&raw_apps).into_product(); + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert!(errors.is_empty(), "\n{}", errors.join("\n\n")); + let Some(ApplicationPreload { + application_name, + environments, + local_server, + }) = app_name_and_envs + else { + panic!("expected Some(ApplicationPreload)") + }; + + let (_app, warns, errors) = Application::from_raw_apps( + std::env::current_dir().unwrap(), + application_name, + environments, + local_server, + selector("custom", &[]), + raw_apps, + ) + .into_product(); + + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert_eq!(errors.len(), 1, "\n{}", errors.join("\n\n")); + assert!( + errors[0].contains("Cannot use HTTP API deployment subdomain"), + "\n{}", + errors.join("\n\n") + ); + } + + #[test] + fn deployment_entries_require_exactly_one_domain_field() { + let source = indoc! { r#" + app: hello-app + + environments: + local: + server: local + + httpApi: + deployments: + local: + - domain: api.example.com + subdomain: hello-api + - agents: {} + "# }; + + let tmp_dir = tempfile::tempdir().unwrap(); + let golem_yaml_path = tmp_dir.path().join("golem.yaml"); + fs::write(&golem_yaml_path, source).unwrap(); + let raw_apps = vec![ + app_raw::ApplicationWithSource::from_yaml_file(&golem_yaml_path) + .expect("raw manifest should parse"), + ]; + + let (app_name_and_envs, warns, errors) = + Application::preload_from_raw_apps(&raw_apps).into_product(); + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert!(errors.is_empty(), "\n{}", errors.join("\n\n")); + let Some(ApplicationPreload { + application_name, + environments, + local_server, + }) = app_name_and_envs + else { + panic!("expected Some(ApplicationPreload)") + }; + + let (_app, warns, errors) = Application::from_raw_apps( + std::env::current_dir().unwrap(), + application_name, + environments, + local_server, + selector("local", &[]), + raw_apps, + ) + .into_product(); + + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert_eq!(errors.len(), 2, "\n{}", errors.join("\n\n")); + assert!( + errors + .iter() + .any(|error| error.contains("cannot define both")) + ); + assert!( + errors + .iter() + .any(|error| error.contains("must define either")) + ); + } + fn component_applied_layers_trace(component: &crate::model::app::Component<'_>) -> Vec { component .applied_layers() @@ -4138,21 +4821,23 @@ mod test { let raw_apps = vec![raw_app]; let (app_name_and_envs, warns, errors) = - Application::environments_from_raw_apps(&raw_apps).into_product(); + Application::preload_from_raw_apps(&raw_apps).into_product(); assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); assert!(errors.is_empty(), "\n{}", errors.join("\n\n")); - let Some(ApplicationNameAndEnvironments { + let Some(ApplicationPreload { application_name, environments, + local_server, }) = app_name_and_envs else { - panic!("expected Some(ApplicationNameAndEnvironments)") + panic!("expected Some(ApplicationPreload)") }; let (app, warns, errors) = Application::from_raw_apps( std::env::current_dir().unwrap(), application_name, environments, + local_server, selector.clone(), raw_apps, ) @@ -4162,6 +4847,50 @@ mod test { (app.unwrap(), tmp_dir) } + #[test] + fn local_server_is_singleton_across_manifest_sources() { + let tmp_dir = tempfile::tempdir().unwrap(); + let root = tmp_dir.path().join("golem.yaml"); + let included = tmp_dir.path().join("included.yaml"); + + fs::write( + &root, + indoc! {r#" + app: test-app + localServer: + routerPort: 9882 + environments: + local: + server: local + "#}, + ) + .unwrap(); + fs::write( + &included, + indoc! {r#" + localServer: + routerPort: 9883 + "#}, + ) + .unwrap(); + + let raw_apps = vec![ + app_raw::ApplicationWithSource::from_yaml_file(&root).unwrap(), + app_raw::ApplicationWithSource::from_yaml_file(&included).unwrap(), + ]; + + let (_app_name_and_envs, warns, errors) = + Application::preload_from_raw_apps(&raw_apps).into_product(); + + assert!(warns.is_empty(), "\n{}", warns.join("\n\n")); + assert_eq!(errors.len(), 1); + assert!( + errors[0].contains("localServer"), + "unexpected error: {}", + errors[0] + ); + } + #[test] fn includes_loader_is_lenient_to_unknown_top_level_fields() { let tmp_dir = tempfile::tempdir().unwrap(); diff --git a/cli/golem-cli/src/model/app_raw.rs b/cli/golem-cli/src/model/app_raw.rs index 6850696add..bb24072076 100644 --- a/cli/golem-cli/src/model/app_raw.rs +++ b/cli/golem-cli/src/model/app_raw.rs @@ -116,6 +116,8 @@ pub struct Application { pub http_api: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_server: Option, #[serde(default, skip_serializing_if = "IndexMap::is_empty")] pub environments: IndexMap, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -516,7 +518,10 @@ pub struct HttpApi { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HttpApiDeployment { - pub domain: Domain, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdomain: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub webhook_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -535,7 +540,10 @@ pub struct Mcp { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct McpDeployment { - pub domain: Domain, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subdomain: Option, #[serde(default, skip_serializing_if = "IndexMap::is_empty")] pub agents: IndexMap, } @@ -547,6 +555,51 @@ pub struct McpDeploymentAgentOptions { pub security_scheme: Option, } +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeploymentDomain(pub String); + +impl From for DeploymentDomain { + fn from(value: Domain) -> Self { + Self(value.0) + } +} + +impl DeploymentDomain { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DeploymentSubdomain(pub String); + +impl DeploymentSubdomain { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalServer { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub router_addr: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub router_port: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub custom_request_port: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mcp_port: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub ports_file: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub data_dir: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub agent_filesystem_root: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Environment { @@ -1487,6 +1540,42 @@ mod test { .boxed() } + fn arb_path_buf_model() -> BoxedStrategy { + arb_ident().prop_map(PathBuf::from).boxed() + } + + fn arb_local_server_model() -> BoxedStrategy { + ( + arb_opt(arb_ident()), + arb_opt(any::().boxed()), + arb_opt(any::().boxed()), + arb_opt(any::().boxed()), + arb_opt(arb_path_buf_model()), + arb_opt(arb_path_buf_model()), + arb_opt(arb_path_buf_model()), + ) + .prop_map( + |( + router_addr, + router_port, + custom_request_port, + mcp_port, + ports_file, + data_dir, + agent_filesystem_root, + )| LocalServer { + router_addr, + router_port, + custom_request_port, + mcp_port, + ports_file, + data_dir, + agent_filesystem_root, + }, + ) + .boxed() + } + fn arb_http_api_deployment_model() -> BoxedStrategy { ( arb_dns_label(), @@ -1514,7 +1603,8 @@ mod test { ) .prop_map( |(domain, webhook_url, openapi_endpoint, agents)| HttpApiDeployment { - domain: Domain(format!("{domain}.example.com")), + domain: Some(Domain(format!("{domain}.example.com")).into()), + subdomain: None, webhook_url, openapi_endpoint, agents, @@ -1551,7 +1641,8 @@ mod test { .prop_map(IndexMap::from_iter), ) .prop_map(|(domain, agents)| McpDeployment { - domain: Domain(format!("{domain}.example.com")), + domain: Some(Domain(format!("{domain}.example.com")).into()), + subdomain: None, agents, }) .boxed() @@ -1880,6 +1971,7 @@ mod test { ( arb_opt(arb_http_api_model()), arb_opt(arb_mcp_model()), + arb_opt(arb_local_server_model()), prop::collection::vec((arb_ident(), arb_environment_model()), 0..=3) .prop_map(IndexMap::from_iter), arb_opt(arb_bridge_sdks_model()), @@ -1897,6 +1989,7 @@ mod test { ( http_api, mcp, + local_server, environments, bridge, secret_defaults, @@ -1914,6 +2007,7 @@ mod test { clean, http_api, mcp, + local_server, environments, bridge, secret_defaults, @@ -1995,6 +2089,42 @@ mod test { ); } + #[test] + fn local_server_accepts_server_run_defaults() { + let source = indoc::indoc! { r#" + app: test-app + + localServer: + routerAddr: 127.0.0.1 + routerPort: 9882 + customRequestPort: 9008 + mcpPort: 9009 + portsFile: .golem/ports.json + dataDir: .golem/server-data + agentFilesystemRoot: .golem/agents + "# }; + + let app = Application::from_yaml_str(source).unwrap(); + let local_server = app.local_server.expect("localServer should be parsed"); + + assert_eq!(local_server.router_addr.as_deref(), Some("127.0.0.1")); + assert_eq!(local_server.router_port, Some(9882)); + assert_eq!(local_server.custom_request_port, Some(9008)); + assert_eq!(local_server.mcp_port, Some(9009)); + assert_eq!( + local_server.ports_file, + Some(PathBuf::from(".golem/ports.json")) + ); + assert_eq!( + local_server.data_dir, + Some(PathBuf::from(".golem/server-data")) + ); + assert_eq!( + local_server.agent_filesystem_root, + Some(PathBuf::from(".golem/agents")) + ); + } + proptest! { #![proptest_config(ProptestConfig { cases: 400, diff --git a/cli/golem-cli/src/model/text/server.rs b/cli/golem-cli/src/model/text/server.rs index 570b0f15a0..42171eb204 100644 --- a/cli/golem-cli/src/model/text/server.rs +++ b/cli/golem-cli/src/model/text/server.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::config::{CLOUD_URL, NamedProfile, builtin_local_url}; +use crate::config::{DEFAULT_CLOUD_URL, NamedProfile, builtin_local_url}; use crate::model::app_raw::{BuiltinServer, Environment, Server}; use colored::Colorize; @@ -29,7 +29,7 @@ impl ToFormattedServerContext for NamedProfile { } else { match &self.profile.custom_url { Some(custom_url) => custom_url.as_str().underline().to_string(), - None => CLOUD_URL.underline().to_string(), + None => DEFAULT_CLOUD_URL.underline().to_string(), } } } @@ -55,5 +55,5 @@ fn local_builtin() -> String { } fn cloud_builtin() -> String { - format!("cloud - builtin ({})", CLOUD_URL.underline()) + format!("cloud - builtin ({})", DEFAULT_CLOUD_URL.underline()) } diff --git a/cli/golem-cli/src/versions.rs b/cli/golem-cli/src/versions.rs index 3983848e0a..4f932fa858 100644 --- a/cli/golem-cli/src/versions.rs +++ b/cli/golem-cli/src/versions.rs @@ -22,7 +22,7 @@ pub mod sdk { #[macro_export] macro_rules! manifest_schema_version { () => { - "1.6.0-dev.2" + "1.6.0-dev.3" }; } } diff --git a/cli/golem-cli/templates/moonbit/default/golem.yaml b/cli/golem-cli/templates/moonbit/default/golem.yaml index 79afc91484..75e279b372 100644 --- a/cli/golem-cli/templates/moonbit/default/golem.yaml +++ b/cli/golem-cli/templates/moonbit/default/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterAgent: {} diff --git a/cli/golem-cli/templates/moonbit/human-in-the-loop/golem.yaml b/cli/golem-cli/templates/moonbit/human-in-the-loop/golem.yaml index c61d337fc3..89e62912e0 100644 --- a/cli/golem-cli/templates/moonbit/human-in-the-loop/golem.yaml +++ b/cli/golem-cli/templates/moonbit/human-in-the-loop/golem.yaml @@ -3,7 +3,7 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: WorkflowAgent: {} HumanAgent: {} \ No newline at end of file diff --git a/cli/golem-cli/templates/moonbit/json/golem.yaml b/cli/golem-cli/templates/moonbit/json/golem.yaml index c479923c7a..444090249a 100644 --- a/cli/golem-cli/templates/moonbit/json/golem.yaml +++ b/cli/golem-cli/templates/moonbit/json/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: TaskAgent: {} diff --git a/cli/golem-cli/templates/moonbit/snapshotting/golem.yaml b/cli/golem-cli/templates/moonbit/snapshotting/golem.yaml index 1a7e04aa35..68300ecaa0 100644 --- a/cli/golem-cli/templates/moonbit/snapshotting/golem.yaml +++ b/cli/golem-cli/templates/moonbit/snapshotting/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterWithSnapshotAgent: {} diff --git a/cli/golem-cli/templates/rust/default/golem.yaml b/cli/golem-cli/templates/rust/default/golem.yaml index 79afc91484..75e279b372 100644 --- a/cli/golem-cli/templates/rust/default/golem.yaml +++ b/cli/golem-cli/templates/rust/default/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterAgent: {} diff --git a/cli/golem-cli/templates/rust/human-in-the-loop/golem.yaml b/cli/golem-cli/templates/rust/human-in-the-loop/golem.yaml index 4de50106ea..dcd3bd8497 100644 --- a/cli/golem-cli/templates/rust/human-in-the-loop/golem.yaml +++ b/cli/golem-cli/templates/rust/human-in-the-loop/golem.yaml @@ -3,7 +3,7 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: WorkflowAgent: {} HumanAgent: {} diff --git a/cli/golem-cli/templates/rust/json/golem.yaml b/cli/golem-cli/templates/rust/json/golem.yaml index 7c527c65c3..72aa64a4b3 100644 --- a/cli/golem-cli/templates/rust/json/golem.yaml +++ b/cli/golem-cli/templates/rust/json/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: Tasks: {} diff --git a/cli/golem-cli/templates/rust/llm-session/golem.yaml b/cli/golem-cli/templates/rust/llm-session/golem.yaml index bcb3f1bd2f..8c37d36477 100644 --- a/cli/golem-cli/templates/rust/llm-session/golem.yaml +++ b/cli/golem-cli/templates/rust/llm-session/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: ChatAgent: {} diff --git a/cli/golem-cli/templates/rust/llm-websearch-summary-example/golem.yaml b/cli/golem-cli/templates/rust/llm-websearch-summary-example/golem.yaml index db49c8ba35..29399c370c 100644 --- a/cli/golem-cli/templates/rust/llm-websearch-summary-example/golem.yaml +++ b/cli/golem-cli/templates/rust/llm-websearch-summary-example/golem.yaml @@ -3,7 +3,7 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: ResearchAgent: {} diff --git a/cli/golem-cli/templates/rust/snapshotting/golem.yaml b/cli/golem-cli/templates/rust/snapshotting/golem.yaml index 1a7e04aa35..68300ecaa0 100644 --- a/cli/golem-cli/templates/rust/snapshotting/golem.yaml +++ b/cli/golem-cli/templates/rust/snapshotting/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterWithSnapshotAgent: {} diff --git a/cli/golem-cli/templates/scala/default/golem.yaml b/cli/golem-cli/templates/scala/default/golem.yaml index 03b74d40a4..a613957fc3 100644 --- a/cli/golem-cli/templates/scala/default/golem.yaml +++ b/cli/golem-cli/templates/scala/default/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterAgent: {} \ No newline at end of file diff --git a/cli/golem-cli/templates/scala/human-in-the-loop/golem.yaml b/cli/golem-cli/templates/scala/human-in-the-loop/golem.yaml index 4de50106ea..dcd3bd8497 100644 --- a/cli/golem-cli/templates/scala/human-in-the-loop/golem.yaml +++ b/cli/golem-cli/templates/scala/human-in-the-loop/golem.yaml @@ -3,7 +3,7 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: WorkflowAgent: {} HumanAgent: {} diff --git a/cli/golem-cli/templates/scala/json/golem.yaml b/cli/golem-cli/templates/scala/json/golem.yaml index c479923c7a..444090249a 100644 --- a/cli/golem-cli/templates/scala/json/golem.yaml +++ b/cli/golem-cli/templates/scala/json/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: TaskAgent: {} diff --git a/cli/golem-cli/templates/scala/snapshotting/golem.yaml b/cli/golem-cli/templates/scala/snapshotting/golem.yaml index 1a7e04aa35..68300ecaa0 100644 --- a/cli/golem-cli/templates/scala/snapshotting/golem.yaml +++ b/cli/golem-cli/templates/scala/snapshotting/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterWithSnapshotAgent: {} diff --git a/cli/golem-cli/templates/ts/default/golem.yaml b/cli/golem-cli/templates/ts/default/golem.yaml index 79afc91484..75e279b372 100644 --- a/cli/golem-cli/templates/ts/default/golem.yaml +++ b/cli/golem-cli/templates/ts/default/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterAgent: {} diff --git a/cli/golem-cli/templates/ts/human-in-the-loop/golem.yaml b/cli/golem-cli/templates/ts/human-in-the-loop/golem.yaml index 4de50106ea..dcd3bd8497 100644 --- a/cli/golem-cli/templates/ts/human-in-the-loop/golem.yaml +++ b/cli/golem-cli/templates/ts/human-in-the-loop/golem.yaml @@ -3,7 +3,7 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: WorkflowAgent: {} HumanAgent: {} diff --git a/cli/golem-cli/templates/ts/json/golem.yaml b/cli/golem-cli/templates/ts/json/golem.yaml index c479923c7a..444090249a 100644 --- a/cli/golem-cli/templates/ts/json/golem.yaml +++ b/cli/golem-cli/templates/ts/json/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: TaskAgent: {} diff --git a/cli/golem-cli/templates/ts/snapshotting/golem.yaml b/cli/golem-cli/templates/ts/snapshotting/golem.yaml index 1a7e04aa35..68300ecaa0 100644 --- a/cli/golem-cli/templates/ts/snapshotting/golem.yaml +++ b/cli/golem-cli/templates/ts/snapshotting/golem.yaml @@ -3,6 +3,6 @@ httpApi: deployments: local: - - domain: app-name.localhost:9006 + - subdomain: app-name # resolves to app-name.localhost:9006 by default agents: CounterWithSnapshotAgent: {} diff --git a/cli/golem/src/command_handler.rs b/cli/golem/src/command_handler.rs index 011b81f7dc..2c328f4064 100644 --- a/cli/golem/src/command_handler.rs +++ b/cli/golem/src/command_handler.rs @@ -17,6 +17,7 @@ use clap_verbosity_flag::Verbosity; use golem_cli::command::server::{RunArgs, ServerSubcommand}; use golem_cli::command_handler::CommandHandlerHooks; use golem_cli::context::Context; +use golem_cli::model::app::ResolvedLocalServer; use std::path::{Path, PathBuf}; use std::sync::Arc; use tracing::debug; @@ -42,25 +43,15 @@ impl CommandHandlerHooks for ServerCommandHandler { ); } - let data_dir = match &args.data_dir { - Some(data_dir) => data_dir.to_path_buf(), - None => default_data_dir()?, - }; + let launch_args = launch_args_from_run_args_and_manifest(&args, &ctx)?; + let data_dir = launch_args.data_dir.clone(); if args.clean && tokio::fs::metadata(&data_dir).await.is_ok() { clean_data_dir(&data_dir).await?; }; - let mut join_set = launch_golem_services(&LaunchArgs { - router_addr: args.router_addr().to_string(), - router_port: args.router_port(), - custom_request_port: args.custom_request_port(), - mcp_port: args.mcp_port(), - ports_file: args.ports_file.clone(), - data_dir: data_dir.clone(), - agent_filesystem_root: args.agent_filesystem_root.clone(), - }) - .await - .map_err(|err| map_local_server_startup_error(err, &data_dir))?; + let mut join_set = launch_golem_services(&launch_args) + .await + .map_err(|err| map_local_server_startup_error(err, &data_dir))?; while let Some(res) = join_set.join_next().await { res??; @@ -116,8 +107,131 @@ fn default_data_dir() -> anyhow::Result { .join("golem")) } +fn launch_args_from_run_args_and_manifest( + args: &RunArgs, + ctx: &Context, +) -> anyhow::Result { + launch_args_from_run_args_and_local_server(args, ctx.manifest_local_server()) +} + +fn launch_args_from_run_args_and_local_server( + args: &RunArgs, + local_server: Option<&ResolvedLocalServer>, +) -> anyhow::Result { + Ok(LaunchArgs { + router_addr: args + .router_addr + .clone() + .or_else(|| local_server.and_then(|manifest| manifest.router_addr.clone())) + .unwrap_or_else(|| args.router_addr().to_string()), + router_port: args + .router_port + .or_else(|| local_server.and_then(|manifest| manifest.router_port)) + .unwrap_or_else(|| args.router_port()), + custom_request_port: args + .custom_request_port + .or_else(|| local_server.and_then(|manifest| manifest.custom_request_port)) + .unwrap_or_else(|| args.custom_request_port()), + mcp_port: args + .mcp_port + .or_else(|| local_server.and_then(|manifest| manifest.mcp_port)) + .unwrap_or_else(|| args.mcp_port()), + ports_file: args + .ports_file + .clone() + .or_else(|| local_server.and_then(|manifest| manifest.ports_file.clone())), + data_dir: args + .data_dir + .clone() + .or_else(|| local_server.and_then(|manifest| manifest.data_dir.clone())) + .map(Ok) + .unwrap_or_else(default_data_dir)?, + agent_filesystem_root: args + .agent_filesystem_root + .clone() + .or_else(|| local_server.and_then(|manifest| manifest.agent_filesystem_root.clone())), + }) +} + async fn clean_data_dir(data_dir: &Path) -> anyhow::Result<()> { tokio::fs::remove_dir_all(&data_dir) .await .map_err(|err| anyhow!("Failed cleaning data dir ({}): {}", data_dir.display(), err)) } + +#[cfg(test)] +mod tests { + use super::*; + use golem_cli::model::app_raw::LocalServer; + use test_r::test; + + fn local_server(value: LocalServer) -> ResolvedLocalServer { + ResolvedLocalServer::from_raw_with_base_dir(&value, Path::new("/tmp/test-app")) + } + + #[test] + fn manifest_local_server_values_are_used_when_cli_args_are_absent() { + let manifest = local_server(LocalServer { + router_addr: Some("127.0.0.1".to_string()), + router_port: Some(9882), + custom_request_port: Some(9008), + mcp_port: Some(9009), + ports_file: Some(PathBuf::from("/tmp/test-app/.golem/ports.json")), + data_dir: Some(PathBuf::from("/tmp/test-app/.golem/data")), + agent_filesystem_root: Some(PathBuf::from("/tmp/test-app/.golem/agents")), + }); + + let args = launch_args_from_run_args_and_local_server(&RunArgs::default(), Some(&manifest)) + .unwrap(); + + assert_eq!(args.router_addr, "127.0.0.1"); + assert_eq!(args.router_port, 9882); + assert_eq!(args.custom_request_port, 9008); + assert_eq!(args.mcp_port, 9009); + assert_eq!( + args.ports_file, + Some(PathBuf::from("/tmp/test-app/.golem/ports.json")) + ); + assert_eq!(args.data_dir, PathBuf::from("/tmp/test-app/.golem/data")); + assert_eq!( + args.agent_filesystem_root, + Some(PathBuf::from("/tmp/test-app/.golem/agents")) + ); + } + + #[test] + fn cli_args_override_manifest_local_server_values() { + let manifest = local_server(LocalServer { + router_addr: Some("127.0.0.1".to_string()), + router_port: Some(9882), + custom_request_port: Some(9008), + mcp_port: Some(9009), + ports_file: Some(PathBuf::from("/tmp/test-app/.golem/ports.json")), + data_dir: Some(PathBuf::from("/tmp/test-app/.golem/data")), + agent_filesystem_root: Some(PathBuf::from("/tmp/test-app/.golem/agents")), + }); + let run_args = RunArgs { + router_addr: Some("0.0.0.0".to_string()), + router_port: Some(10000), + custom_request_port: Some(10001), + mcp_port: Some(10002), + ports_file: Some(PathBuf::from("cli-ports.json")), + data_dir: Some(PathBuf::from("cli-data")), + clean: false, + agent_filesystem_root: Some(PathBuf::from("cli-agents")), + }; + + let args = launch_args_from_run_args_and_local_server(&run_args, Some(&manifest)).unwrap(); + + assert_eq!(args.router_addr, "0.0.0.0"); + assert_eq!(args.router_port, 10000); + assert_eq!(args.custom_request_port, 10001); + assert_eq!(args.mcp_port, 10002); + assert_eq!(args.ports_file, Some(PathBuf::from("cli-ports.json"))); + assert_eq!(args.data_dir, PathBuf::from("cli-data")); + assert_eq!( + args.agent_filesystem_root, + Some(PathBuf::from("cli-agents")) + ); + } +} diff --git a/cli/schema.golem.cloud/app/golem/1.6.0-dev.3/golem.schema.json b/cli/schema.golem.cloud/app/golem/1.6.0-dev.3/golem.schema.json new file mode 100644 index 0000000000..d86453580a --- /dev/null +++ b/cli/schema.golem.cloud/app/golem/1.6.0-dev.3/golem.schema.json @@ -0,0 +1,1903 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "https://schema.golem.cloud/app/golem/1.6.0-dev.3/golem.schema.json", + "title": "Golem Application Manifest", + "description": "Golem Application Manifest.", + "type": "object", + "additionalProperties": false, + "properties": { + "manifestVersion": { + "type": "string", + "description": "Application manifest document version" + }, + "app": { + "type": "string", + "description": "Application name" + }, + "includes": { + "type": "array", + "description": "Include paths or globs for searching for application manifest documents. Only allowed in root application manifest documents.", + "items": { + "type": "string" + } + }, + "componentTemplates": { + "type": "object", + "description": "Component templates by template names", + "additionalProperties": { + "$ref": "#/definitions/componentTemplate" + } + }, + "components": { + "type": "object", + "description": "Components by component names", + "additionalProperties": { + "$ref": "#/definitions/component" + } + }, + "agents": { + "type": "object", + "description": "Agents by agent type names", + "additionalProperties": { + "$ref": "#/definitions/agent" + } + }, + "customCommands": { + "type": "object", + "description": "User defined custom commands.", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/externalCommand" + } + } + }, + "clean": { + "type": "array", + "description": "User defined extra paths used in the clean command.", + "items": { + "type": "string" + } + }, + "httpApi": { + "$ref": "#/definitions/httpApi" + }, + "mcp": { + "$ref": "#/definitions/mcp" + }, + "localServer": { + "$ref": "#/definitions/localServer" + }, + "environments": { + "type": "object", + "description": "Application environments", + "additionalProperties": { + "$ref": "#/definitions/environment" + } + }, + "bridge": { + "$ref": "#/definitions/bridgeSdks" + }, + "secretDefaults": { + "type": "object", + "description": "Secret defaults by environment name, using nested config-style object paths.", + "additionalProperties": { + "type": "object", + "additionalProperties": {} + } + }, + "retryPolicyDefaults": { + "type": "object", + "description": "Retry policy defaults by environment name. Policies defined here are created in the environment during deployment if they don't already exist.", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/retryPolicyDefault" + } + } + }, + "resourceDefaults": { + "type": "object", + "description": "Quota resource defaults by environment name.", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/resourceDefinitionCreation" + } + } + } + }, + "definitions": { + "lenientTokenList": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "templates": { + "description": "List of parent templates", + "type": "object", + "additionalProperties": false, + "properties": { + "templates": { + "$ref": "#/definitions/lenientTokenList" + } + } + }, + "componentTemplate": { + "description": "Component template definition", + "type": "object", + "additionalProperties": false, + "properties": { + "templates": { + "$ref": "#/definitions/lenientTokenList" + }, + "componentWasm": { + "type": "string", + "description": "File path for the built WASM component." + }, + "outputWasm": { + "type": "string", + "description": "File path for the output WASM component which is ready to be uploaded to Golem." + }, + "buildMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "build": { + "type": "array", + "description": "Commands used for creating component WASM.", + "items": { + "$ref": "#/definitions/buildCommand" + } + }, + "customCommands": { + "type": "object", + "description": "User defined custom commands.", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/externalCommand" + } + } + }, + "clean": { + "type": "array", + "description": "User defined extra paths used in the clean command.", + "items": { + "type": "string" + } + }, + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/componentPreset" + } + } + } + }, + "component": { + "description": "Component definition", + "type": "object", + "additionalProperties": false, + "properties": { + "templates": { + "$ref": "#/definitions/lenientTokenList" + }, + "dir": { + "type": "string", + "description": "Base directory for resolving component paths" + }, + "componentWasm": { + "type": "string", + "description": "File path for the built WASM component." + }, + "outputWasm": { + "type": "string", + "description": "File path for the output WASM component which is ready to be uploaded to Golem." + }, + "buildMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "build": { + "type": "array", + "description": "Commands used for creating component WASM.", + "items": { + "$ref": "#/definitions/buildCommand" + } + }, + "customCommands": { + "type": "object", + "description": "User defined custom commands.", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/externalCommand" + } + } + }, + "clean": { + "type": "array", + "description": "User defined extra paths used in the clean command.", + "items": { + "type": "string" + } + }, + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/componentPreset" + } + } + } + }, + "componentLayerProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "componentWasm": { + "type": "string", + "description": "File path for the built WASM component." + }, + "outputWasm": { + "type": "string", + "description": "File path for the output WASM component which is ready to be uploaded to Golem." + }, + "buildMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "build": { + "type": "array", + "description": "Commands used for creating component WASM.", + "items": { + "$ref": "#/definitions/buildCommand" + } + }, + "customCommands": { + "type": "object", + "description": "User defined custom commands.", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/externalCommand" + } + } + }, + "clean": { + "type": "array", + "description": "User defined extra paths used in the clean command.", + "items": { + "type": "string" + } + } + } + }, + "componentPresets": { + "type": "object", + "description": "Component definition presets", + "additionalProperties": false, + "properties": { + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/componentPreset" + } + } + } + }, + "componentPreset": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "componentWasm": { + "type": "string", + "description": "File path for the built WASM component." + }, + "outputWasm": { + "type": "string", + "description": "File path for the output WASM component which is ready to be uploaded to Golem." + }, + "buildMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "build": { + "type": "array", + "description": "Commands used for creating component WASM.", + "items": { + "$ref": "#/definitions/buildCommand" + } + }, + "customCommands": { + "type": "object", + "description": "User defined custom commands.", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/externalCommand" + } + } + }, + "clean": { + "type": "array", + "description": "User defined extra paths used in the clean command.", + "items": { + "type": "string" + } + }, + "default": { + "const": true + } + } + }, + "agent": { + "description": "Agent definition", + "type": "object", + "additionalProperties": false, + "properties": { + "templates": { + "$ref": "#/definitions/lenientTokenList" + }, + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/agentPreset" + } + } + } + }, + "agentPreset": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "default": { + "const": true + } + } + }, + "agentLayerProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "description": "Environment variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "wasiConfigMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "wasiConfig": { + "type": "object", + "description": "WASI configuration variables for the agent.", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "description": "Installed plugins for the agent", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "description": "Initial component file system", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + } + } + }, + "httpApi": { + "type": "object", + "additionalProperties": false, + "description": "HTTP API deployments", + "properties": { + "deployments": { + "type": "object", + "description": "HTTP API deployments by environments", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/httpApiDeployment" + } + } + } + } + }, + "httpApiDeployment": { + "type": "object", + "additionalProperties": false, + "description": "HTTP API deployment", + "properties": { + "domain": { + "type": "string", + "description": "Full concrete domain for the HTTP API deployment. Use this for custom domains and custom server environments." + }, + "subdomain": { + "type": "string", + "description": "Single DNS label resolved through the deployment environment's built-in server. For local HTTP API deployments this resolves to .localhost:9006 by default, or .localhost: when localServer.customRequestPort is set to a stable nonzero port. For cloud HTTP API deployments this resolves to .apps.golem.cloud." + }, + "webhookUrl": { + "type": "string", + "description": "Webhook URL prefix" + }, + "openapiEndpoint": { + "type": "string", + "description": "OpenApi endpoint URL prefix" + }, + "agents": { + "type": "object", + "description": "HTTP API deployment options by agent type name", + "additionalProperties": { + "$ref": "#/definitions/httpApiDeploymentAgentOptions" + } + } + }, + "oneOf": [ + { + "required": [ + "domain" + ] + }, + { + "required": [ + "subdomain" + ] + } + ] + }, + "httpApiDeploymentAgentOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "securityScheme": { + "type": "string" + }, + "testSessionHeaderName": { + "type": "string" + } + } + }, + "mcp": { + "type": "object", + "additionalProperties": false, + "description": "MCP deployments", + "properties": { + "deployments": { + "type": "object", + "description": "MCP deployments by environments", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/definitions/mcpDeployment" + } + } + } + } + }, + "mcpDeployment": { + "type": "object", + "additionalProperties": false, + "properties": { + "domain": { + "type": "string", + "description": "Full concrete domain for the MCP deployment. Use this for custom domains and custom server environments." + }, + "subdomain": { + "type": "string", + "description": "Single DNS label resolved through the deployment environment's built-in server. For local MCP deployments this resolves to .localhost:9007 by default, or .localhost: when localServer.mcpPort is set to a stable nonzero port. For cloud MCP deployments this resolves to .mcps.golem.cloud." + }, + "agents": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/mcpDeploymentAgentOptions" + } + } + }, + "oneOf": [ + { + "required": [ + "domain" + ] + }, + { + "required": [ + "subdomain" + ] + } + ] + }, + "mcpDeploymentAgentOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "securityScheme": { + "type": "string" + } + } + }, + "localServer": { + "type": "object", + "description": "Defaults for `golem server run` when launched from this application.", + "additionalProperties": false, + "properties": { + "routerAddr": { + "type": "string", + "description": "Address to serve the main API on." + }, + "routerPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port to serve the main API on. Do not set this to 0 in the manifest; port 0 is only allowed when passed directly as --router-port 0 to golem server run." + }, + "customRequestPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port to serve custom requests on. Do not set this to 0 in the manifest; port 0 is only allowed when passed directly as --custom-request-port 0 to golem server run." + }, + "mcpPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port to serve the MCP server on. Do not set this to 0 in the manifest; port 0 is only allowed when passed directly as --mcp-port 0 to golem server run." + }, + "portsFile": { + "type": "string", + "description": "Path where discovered startup ports are written as JSON." + }, + "dataDir": { + "type": "string", + "description": "Directory used for local server data." + }, + "agentFilesystemRoot": { + "type": "string", + "description": "Root directory for deterministic agent filesystem directories." + } + } + }, + "environment": { + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "const": true, + "description": "Use as default environment, only one can be selected, if missing, the first environment is used as default" + }, + "account": { + "type": "string", + "description": "Optional account that owns the application environment." + }, + "server": { + "$ref": "#/definitions/server" + }, + "componentPresets": { + "$ref": "#/definitions/lenientTokenList" + }, + "cli": { + "$ref": "#/definitions/cliOptions" + }, + "deployment": { + "$ref": "#/definitions/deploymentOptions" + } + } + }, + "server": { + "description": "Server for the environment, can be either the built-in local/cloud servers, or a custom one.", + "oneOf": [ + { + "const": "local" + }, + { + "const": "cloud" + }, + { + "$ref": "#/definitions/customServer" + } + ] + }, + "customServer": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "Custom URL for golem services" + }, + "workerUrl": { + "type": "string", + "description": "Custom URL for golem worker service" + }, + "allowInsecure": { + "type": "boolean", + "description": "Allow insecure connections to the server" + }, + "auth": { + "$ref": "#/definitions/customServerAuth" + } + }, + "required": [ + "url", + "auth" + ] + }, + "customServerAuth": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "oauth2": { + "const": true + } + }, + "required": [ + "oauth2" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "staticToken": { + "type": "string" + } + }, + "required": [ + "staticToken" + ] + } + ] + }, + "cliOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "format": { + "enum": [ + "text", + "json", + "yaml", + "pretty", + "pretty-json", + "pretty-yaml", + "toon" + ], + "description": "Default output format" + }, + "autoConfirm": { + "const": true, + "description": "Enables auto-confirm (yes) flag by default" + }, + "redeployAgents": { + "const": true, + "description": "Enables redeploy-agents flag by default" + }, + "reset": { + "const": true, + "description": "Enables reset flag by default" + } + } + }, + "deploymentOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "compatibilityCheck": { + "type": "boolean" + }, + "versionCheck": { + "type": "boolean" + }, + "securityOverrides": { + "type": "boolean" + } + } + }, + "buildCommand": { + "oneOf": [ + { + "$ref": "#/definitions/externalCommand" + }, + { + "$ref": "#/definitions/generateQuickJsCrateCommand" + }, + { + "$ref": "#/definitions/generateQuickJsdtsCommand" + }, + { + "$ref": "#/definitions/injectToPrebuiltQuickjsCommand" + }, + { + "$ref": "#/definitions/preinitializeJsCommand" + } + ] + }, + "externalCommand": { + "type": "object", + "additionalProperties": false, + "description": "External command with optional inputs and outputs with up-to-date checks", + "properties": { + "command": { + "type": "string", + "description": "External command to execute" + }, + "dir": { + "type": "string", + "description": "Working directory for the command" + }, + "env": { + "type": "object", + "description": "Environment variables for the command", + "additionalProperties": { + "type": "string" + } + }, + "rmdirs": { + "type": "array", + "description": "List of directories that should be deleted before running the command, runs before mkdirs.", + "items": { + "type": "string" + } + }, + "mkdirs": { + "type": "array", + "description": "List of directories that should be created before running the command, runs after rmdirs.", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "description": "Inputs (paths and globs) for the external command", + "items": { + "type": "string" + } + }, + "targets": { + "type": "array", + "description": "Outputs (paths and globs) for the external command", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, + "generateQuickJsCrateCommand": { + "type": "object", + "additionalProperties": false, + "description": "Generate QuickJS crate", + "properties": { + "generateQuickjsCrate": { + "type": "string", + "description": "QuickJS crate path" + }, + "wit": { + "type": "string", + "description": "WIT directory" + }, + "jsModules": { + "type": "object", + "description": "JS module paths and modes", + "additionalProperties": { + "type": "string" + } + }, + "world": { + "type": "string", + "description": "Optional WIT world" + } + }, + "required": [ + "generateQuickjsCrate", + "wit", + "jsModules" + ] + }, + "generateQuickJsdtsCommand": { + "type": "object", + "additionalProperties": false, + "description": "Generate QuickJS d.ts", + "properties": { + "generateQuickjsDts": { + "type": "string", + "description": "QuickJS d.ts path" + }, + "wit": { + "type": "string", + "description": "WIT directory" + }, + "world": { + "type": "string", + "description": "Optional WIT world" + } + }, + "required": [ + "generateQuickjsDts", + "wit" + ] + }, + "injectToPrebuiltQuickjsCommand": { + "type": "object", + "additionalProperties": false, + "description": "Inject JS to prebuilt QuickJS", + "properties": { + "injectToPrebuiltQuickjs": { + "type": "string", + "description": "Path to the prebuilt QuickJS WASM file that loads a JS module through a get-script import" + }, + "module": { + "type": "string", + "description": "Path to the JS module" + }, + "into": { + "type": "string", + "description": "Path to the output WASM component containing the injected JS module" + } + }, + "required": [ + "injectToPrebuiltQuickjs", + "module", + "into" + ] + }, + "preinitializeJsCommand": { + "type": "object", + "additionalProperties": false, + "description": "Preinitialize JS runtime", + "properties": { + "preinitializeJs": { + "type": "string", + "description": "Path to the input WASM component to pre-initialize" + }, + "into": { + "type": "string", + "description": "Path to the pre-initialized output WASM component" + } + }, + "required": [ + "preinitializeJs", + "into" + ] + }, + "initialComponentFile": { + "type": "object", + "additionalProperties": false, + "description": "File entry for the initial component file system.", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source path for the component file: either a local file or a URL." + }, + "targetPath": { + "type": "string", + "description": "Target path for the component file, must be an absolute path" + }, + "permissions": { + "enum": [ + "read-only", + "read-write" + ], + "description": "Permission for the component file" + } + }, + "required": [ + "sourcePath", + "targetPath" + ] + }, + "pluginInstallation": { + "type": "object", + "additionalProperties": false, + "description": "Represents an installed plugin", + "properties": { + "account": { + "type": "string", + "description": "Account of the plugin" + }, + "name": { + "type": "string", + "description": "Name of the plugin" + }, + "version": { + "type": "string", + "description": "Version of the plugin" + }, + "parameters": { + "type": "object", + "description": "Key-value pairs for configuring the plugin installation", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "name", + "version" + ] + }, + "bridgeSdks": { + "description": "Bridge SDK generator configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "ts": { + "description": "TypeScript SDK configuration", + "$ref": "#/definitions/bridgeSdkLanguageTargets" + }, + "rust": { + "description": "Rust SDK configuration", + "$ref": "#/definitions/bridgeSdkLanguageTargets" + }, + "scala": { + "description": "Scala SDK configuration", + "$ref": "#/definitions/bridgeSdkLanguageTargets" + }, + "moonbit": { + "description": "MoonBit SDK configuration", + "$ref": "#/definitions/bridgeSdkLanguageTargets" + } + } + }, + "bridgeSdkLanguageTargets": { + "description": "Bridge SDK language targets", + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "description": "List of agent type names, component names or \"*\" for including all agents", + "$ref": "#/definitions/lenientTokenList" + }, + "outputDir": { + "description": "Custom output directory for the generated SDK", + "type": "string" + } + } + }, + "vecMergeMode": { + "enum": [ + "append", + "prepend", + "replace" + ] + }, + "mapMergeMode": { + "enum": [ + "upsert", + "replace", + "remove" + ] + }, + "retryPolicyDefault": { + "type": "object", + "description": "Retry policy default body for a policy key under retryPolicyDefaults.", + "properties": { + "priority": { + "type": "integer", + "minimum": 0, + "description": "Selection priority - higher values are evaluated first" + }, + "predicate": { + "$ref": "#/definitions/retryPredicate", + "description": "Predicate tree defining when this policy applies" + }, + "policy": { + "$ref": "#/definitions/retryPolicy", + "description": "Retry policy tree defining the retry strategy" + } + }, + "required": [ + "priority", + "predicate", + "policy" + ], + "additionalProperties": false + }, + "predicateValue": { + "description": "Predicate value encoded as primitive JSON value.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "boolean" + } + ] + }, + "propertyComparison": { + "type": "object", + "description": "A property name and a value to compare against", + "properties": { + "property": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/predicateValue" + } + }, + "required": [ + "property", + "value" + ], + "additionalProperties": false + }, + "propertySetCheck": { + "type": "object", + "description": "A property name and a set of values to check membership", + "properties": { + "property": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/predicateValue" + } + } + }, + "required": [ + "property", + "values" + ], + "additionalProperties": false + }, + "retryPredicate": { + "description": "Composable retry predicate encoded as compact unions.", + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + { + "type": "object", + "properties": { + "propEq": { + "$ref": "#/definitions/propertyComparison" + } + }, + "required": ["propEq"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propNeq": { + "$ref": "#/definitions/propertyComparison" + } + }, + "required": ["propNeq"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propGt": { + "$ref": "#/definitions/propertyComparison" + } + }, + "required": ["propGt"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propGte": { + "$ref": "#/definitions/propertyComparison" + } + }, + "required": ["propGte"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propLt": { + "$ref": "#/definitions/propertyComparison" + } + }, + "required": ["propLt"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propLte": { + "$ref": "#/definitions/propertyComparison" + } + }, + "required": ["propLte"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propExists": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "property": { + "type": "string" + } + }, + "required": ["property"], + "additionalProperties": false + } + ] + } + }, + "required": ["propExists"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propIn": { + "$ref": "#/definitions/propertySetCheck" + } + }, + "required": ["propIn"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propMatches": { + "type": "object", + "properties": { + "property": { + "type": "string" + }, + "pattern": { + "type": "string" + } + }, + "required": ["property", "pattern"], + "additionalProperties": false + } + }, + "required": ["propMatches"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propStartsWith": { + "type": "object", + "properties": { + "property": { + "type": "string" + }, + "prefix": { + "type": "string" + } + }, + "required": ["property", "prefix"], + "additionalProperties": false + } + }, + "required": ["propStartsWith"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "propContains": { + "type": "object", + "properties": { + "property": { + "type": "string" + }, + "substring": { + "type": "string" + } + }, + "required": ["property", "substring"], + "additionalProperties": false + } + }, + "required": ["propContains"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { + "$ref": "#/definitions/retryPredicate" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["and"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "or": { + "type": "array", + "items": { + "$ref": "#/definitions/retryPredicate" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["or"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "not": { + "$ref": "#/definitions/retryPredicate" + } + }, + "required": ["not"], + "additionalProperties": false + } + ] + }, + "duration": { + "type": "object", + "description": "A duration with seconds and nanoseconds", + "properties": { + "secs": { + "type": "integer", + "minimum": 0 + }, + "nanos": { + "type": "integer", + "minimum": 0, + "maximum": 999999999 + } + }, + "required": [ + "secs", + "nanos" + ], + "additionalProperties": false + }, + "retryPolicy": { + "description": "Composable retry policy encoded as compact unions.", + "oneOf": [ + { + "type": "string", + "enum": [ + "immediate", + "never" + ] + }, + { + "type": "object", + "properties": { + "periodic": { + "$ref": "#/definitions/duration" + } + }, + "required": ["periodic"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "exponential": { + "type": "object", + "properties": { + "baseDelay": { + "$ref": "#/definitions/duration" + }, + "factor": { + "type": "number" + } + }, + "required": ["baseDelay", "factor"], + "additionalProperties": false + } + }, + "required": ["exponential"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "fibonacci": { + "type": "object", + "properties": { + "first": { + "$ref": "#/definitions/duration" + }, + "second": { + "$ref": "#/definitions/duration" + } + }, + "required": ["first", "second"], + "additionalProperties": false + } + }, + "required": ["fibonacci"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "countBox": { + "type": "object", + "properties": { + "maxRetries": { + "type": "integer", + "minimum": 0 + }, + "inner": { + "$ref": "#/definitions/retryPolicy" + } + }, + "required": ["maxRetries", "inner"], + "additionalProperties": false + } + }, + "required": ["countBox"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "timeBox": { + "type": "object", + "properties": { + "limit": { + "$ref": "#/definitions/duration" + }, + "inner": { + "$ref": "#/definitions/retryPolicy" + } + }, + "required": ["limit", "inner"], + "additionalProperties": false + } + }, + "required": ["timeBox"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "clamp": { + "type": "object", + "properties": { + "minDelay": { + "$ref": "#/definitions/duration" + }, + "maxDelay": { + "$ref": "#/definitions/duration" + }, + "inner": { + "$ref": "#/definitions/retryPolicy" + } + }, + "required": ["minDelay", "maxDelay", "inner"], + "additionalProperties": false + } + }, + "required": ["clamp"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "addDelay": { + "type": "object", + "properties": { + "delay": { + "$ref": "#/definitions/duration" + }, + "inner": { + "$ref": "#/definitions/retryPolicy" + } + }, + "required": ["delay", "inner"], + "additionalProperties": false + } + }, + "required": ["addDelay"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jitter": { + "type": "object", + "properties": { + "factor": { + "type": "number" + }, + "inner": { + "$ref": "#/definitions/retryPolicy" + } + }, + "required": ["factor", "inner"], + "additionalProperties": false + } + }, + "required": ["jitter"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "filteredOn": { + "type": "object", + "properties": { + "predicate": { + "$ref": "#/definitions/retryPredicate" + }, + "inner": { + "$ref": "#/definitions/retryPolicy" + } + }, + "required": ["predicate", "inner"], + "additionalProperties": false + } + }, + "required": ["filteredOn"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "andThen": { + "type": "array", + "items": { + "$ref": "#/definitions/retryPolicy" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["andThen"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "union": { + "type": "array", + "items": { + "$ref": "#/definitions/retryPolicy" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["union"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "intersect": { + "type": "array", + "items": { + "$ref": "#/definitions/retryPolicy" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "required": ["intersect"], + "additionalProperties": false + } + ] + }, + "resourceDefinitionCreation": { + "type": "object", + "description": "Quota resource definition body for a resource key under resourceDefaults.", + "additionalProperties": false, + "properties": { + "limit": { + "$ref": "#/definitions/resourceLimit" + }, + "enforcementAction": { + "$ref": "#/definitions/enforcementAction" + }, + "unit": { + "type": "string", + "description": "Single unit of measurement (e.g., token, request)" + }, + "units": { + "type": "string", + "description": "Multiple units of measurement (e.g., tokens, requests)" + } + }, + "required": [ + "limit", + "enforcementAction", + "unit", + "units" + ] + }, + "resourceLimit": { + "oneOf": [ + { + "$ref": "#/definitions/resourceRateLimit" + }, + { + "$ref": "#/definitions/resourceCapacityLimit" + }, + { + "$ref": "#/definitions/resourceConcurrencyLimit" + } + ] + }, + "resourceRateLimit": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "Rate" + ] + }, + "value": { + "type": "integer", + "minimum": 0 + }, + "period": { + "$ref": "#/definitions/timePeriod" + }, + "max": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "value", + "period", + "max" + ] + }, + "resourceCapacityLimit": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "Capacity" + ] + }, + "value": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "value" + ] + }, + "resourceConcurrencyLimit": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "Concurrency" + ] + }, + "value": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "type", + "value" + ] + }, + "enforcementAction": { + "type": "string", + "enum": [ + "reject", + "throttle", + "terminate" + ] + }, + "timePeriod": { + "type": "string", + "enum": [ + "second", + "minute", + "hour", + "day", + "month", + "year" + ] + } + } +} diff --git a/docs/src/content/next/app-manifest.mdx b/docs/src/content/next/app-manifest.mdx index 7d72b30f76..318e75c3c0 100644 --- a/docs/src/content/next/app-manifest.mdx +++ b/docs/src/content/next/app-manifest.mdx @@ -116,6 +116,54 @@ Available naming related string transforming functions: + + ***Optional local server defaults*** used by `golem server run` and by local deployment subdomain expansion. CLI flags passed to `golem server run` override these values. Path values are resolved relative to the manifest that declares `localServer`. Do not set manifest ports to `0`; port `0` is only allowed when passed directly as a `golem server run` port flag. + + + + ***Optional bind address*** for the local server's main API. Defaults to `0.0.0.0`. + + + + ***Optional port*** for the local server's main API. Defaults to `9881`. Do not set this to `0` in the manifest; use `--router-port 0` directly with `golem server run` when you need an OS-assigned free port. + + + + ***Optional HTTP API port*** for local custom requests. Defaults to `9006`; HTTP deployments using `subdomain` resolve to `.localhost:`. Do not set this to `0` in the manifest; use `--custom-request-port 0` directly with `golem server run` when you need an OS-assigned free port. + + + + ***Optional MCP server port*** for local MCP requests. Defaults to `9007`; MCP deployments using `subdomain` resolve to `.localhost:`. Do not set this to `0` in the manifest; use `--mcp-port 0` directly with `golem server run` when you need an OS-assigned free port. + + + + ***Optional path*** where the local server writes the actually-bound ports as JSON on startup. Useful together with direct `golem server run` port flags such as `--custom-request-port 0`. + + + + ***Optional directory*** used for local server data. Defaults to `$XDG_STATE_HOME/golem`. + + + + ***Optional root directory*** for deterministic agent filesystem directories, laid out as `////`. + + [*].domain", since: Release.R_1_5_0 }}> - ***Required domain*** for the deployment. + ***Full concrete domain*** for the deployment. Use `domain` for custom registered domains and custom server environments. Define exactly one of `domain` or `subdomain`. + + + [*].subdomain", + since: Release.R_1_5_0 + }}> + ***Built-in server subdomain*** for the deployment. Must be a single DNS label (lowercase letters, digits, and hyphens only). In a local environment it resolves to `.localhost:9006` by default, or `.localhost:` when `localServer.customRequestPort` is set to a stable nonzero port. In a cloud environment it resolves to `.apps.golem.cloud`. Cannot be used with custom server environments — use `domain` instead. Define exactly one of `domain` or `subdomain`. [*].domain", since: Release.R_1_5_0 }}> - ***Required domain*** for the MCP deployment. + ***Full concrete domain*** for the MCP deployment. Use `domain` for custom registered domains and custom server environments. Define exactly one of `domain` or `subdomain`. + + + [*].subdomain", + since: Release.R_1_5_0 + }}> + ***Built-in server subdomain*** for the MCP deployment. Must be a single DNS label (lowercase letters, digits, and hyphens only). In a local environment it resolves to `.localhost:9007` by default, or `.localhost:` when `localServer.mcpPort` is set to a stable nonzero port. In a cloud environment it resolves to `.mcps.golem.cloud`. Cannot be used with custom server environments — use `domain` instead. Define exactly one of `domain` or `subdomain`. myapp.localhost:9006 app-component-a-api app-component-b-api @@ -143,6 +143,8 @@ Application custom commands: ts-npm-install ``` +Deployment domains shown by the CLI include the manifest intent and the resolved concrete domain. For example, an HTTP API manifest entry such as `subdomain: myapp` is displayed as `subdomain: myapp -> myapp.localhost:9006` with the default local server port. + ## Starting the local development server The `golem` CLI also includes a local development server that can be used to quickly develop, deploy, test and debug Golem applications locally. @@ -243,4 +245,4 @@ export GOLEM_ENVIRONMENT=cloud golem deploy ``` -For more information see the next seection about [Environments and Profiles](/next/cli/envs_and_profiles). \ No newline at end of file +For more information see the next seection about [Environments and Profiles](/next/cli/envs_and_profiles). diff --git a/docs/src/content/next/develop/webhooks.mdx b/docs/src/content/next/develop/webhooks.mdx index e9835f51f6..d51d9aec4d 100644 --- a/docs/src/content/next/develop/webhooks.mdx +++ b/docs/src/content/next/develop/webhooks.mdx @@ -164,10 +164,10 @@ To configure the base URL for webhooks and deploy agents with webhook support, u httpApi: deployments: local: - - domain: my-app.localhost:9006 - webhookUrl: http://my-app.localhost:9006 + - subdomain: my-app # resolves to my-app.localhost:9006 by default + webhookUrl: "/my-custom-webhooks/" # optional path prefix; defaults to /webhooks agents: WebhookAgent: {} ``` -The `webhookUrl` field sets the base URL that Golem uses when generating webhook URLs. This should match the publicly accessible address of your deployment so that external systems can reach the webhook endpoints. +The `webhookUrl` field sets the path prefix that Golem uses when generating webhook URLs (defaults to `/webhooks`). It is combined with the agent's `webhookSuffix` and the generated webhook ID to form the full callback URL, for example `http://my-app.localhost:9006/my-custom-webhooks/webhook-agent/`. diff --git a/docs/src/content/next/how-to-guides/common/golem-configure-api-domain.mdx b/docs/src/content/next/how-to-guides/common/golem-configure-api-domain.mdx index 88353527d4..ae261260e0 100644 --- a/docs/src/content/next/how-to-guides/common/golem-configure-api-domain.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-configure-api-domain.mdx @@ -2,7 +2,7 @@ ## Overview -After adding HTTP mounts and endpoints to agents in code, you must configure a **domain deployment** in `golem.yaml` so Golem knows which agents to expose and on which domain. This skill covers the `httpApi` manifest section, security scheme setup, and the auto-generated OpenAPI specification. +After adding HTTP mounts and endpoints to agents in code, you must configure an HTTP API deployment in `golem.yaml` so Golem knows which agents to expose and on which domain. This skill covers the `httpApi` manifest section, security scheme setup, and the auto-generated OpenAPI specification. ## Adding a Domain Deployment @@ -12,7 +12,7 @@ Add an `httpApi` section to the root `golem.yaml`: httpApi: deployments: local: - - domain: my-app.localhost:9006 + - subdomain: my-app # resolves to my-app.localhost:9006 by default agents: TaskAgent: {} UserAgent: {} @@ -23,9 +23,14 @@ httpApi: - `httpApi.deployments` is a map keyed by **environment name** (e.g., `local`, `staging`, `prod`) - Each environment contains a list of deployment objects - Each deployment has: - - `domain`: the (sub)domain to bind to (e.g., `my-app.localhost:9006` for local development) + - `subdomain`: a single DNS label (lowercase letters, digits, and hyphens only — no dots, port, or URL scheme) resolved through the target environment server. Local HTTP API deployments resolve to `.localhost:9006` by default, or `.localhost:` when `localServer.customRequestPort` is set to a stable nonzero port. Cloud HTTP API deployments resolve to `.apps.golem.cloud`. + - `domain`: a full domain such as `api.example.com` for custom registered domains or custom server environments. - `agents`: a map of agent type names (PascalCase) to their deployment options - - `webhookUrl` (optional): base URL for webhook callbacks + - `webhookUrl` (optional): path prefix for webhook callbacks; defaults to `/webhooks/` + +Define exactly one of `subdomain` or `domain` on each deployment. Prefer `subdomain` for built-in `server: local` and `server: cloud` environments. Use `domain` when you need a full custom domain. `subdomain` cannot be used with custom server environments — those must use `domain`. + +Do not use `localServer.customRequestPort: 0` in the manifest. Port `0` is only allowed when passed directly as `--custom-request-port 0` to `golem server run`; manifest deployment domains require stable nonzero ports. ### Agent Options @@ -55,7 +60,7 @@ golem api security-scheme create my-oidc \ --provider-type google \ --client-id "YOUR_CLIENT_ID" \ --client-secret "YOUR_CLIENT_SECRET" \ - --redirect-url "http://localhost:9006/auth/callback" \ + --redirect-url "http://my-app.localhost:9006/auth/callback" \ --scope openid --scope email --scope profile ``` @@ -97,7 +102,7 @@ After creating a security scheme, reference it by name in the agent deployment o httpApi: deployments: local: - - domain: my-app.localhost:9006 + - subdomain: my-app # resolves to my-app.localhost:9006 by default agents: SecureAgent: securityScheme: my-oidc @@ -113,7 +118,7 @@ For local development without a real OIDC provider, use a test session header: httpApi: deployments: local: - - domain: my-app.localhost:9006 + - subdomain: my-app # resolves to my-app.localhost:9006 by default agents: SecureAgent: testSessionHeaderName: X-Test-Auth @@ -152,34 +157,40 @@ Define different domains and security configurations per environment: httpApi: deployments: local: - - domain: my-app.localhost:9006 + - subdomain: my-app # resolves to my-app.localhost:9006 by default agents: TaskAgent: {} SecureAgent: testSessionHeaderName: X-Test-Auth - prod: - - domain: api.myapp.com + cloud: + - subdomain: my-app # resolves to my-app.apps.golem.cloud agents: TaskAgent: {} SecureAgent: securityScheme: prod-google-oidc + +environments: + local: + server: local + cloud: + server: cloud ``` ## Webhook URL -If agents use webhooks, configure the base URL: +If agents use webhooks, configure the webhook path prefix: ```yaml httpApi: deployments: local: - - domain: my-app.localhost:9006 - webhookUrl: http://my-app.localhost:9006 + - subdomain: my-app # resolves to my-app.localhost:9006 by default + webhookUrl: /my-custom-webhooks/ agents: WebhookAgent: {} ``` -The `webhookUrl` is combined with the agent's `webhookSuffix` (defined in code) to form the full webhook callback URL. +The deployment domain comes from `subdomain` or `domain`. The `webhookUrl` path prefix is combined with the agent's `webhookSuffix` (defined in code) and the generated webhook ID to form the full callback URL, such as `http://my-app.localhost:9006/my-custom-webhooks/order-hooks/`. ## Deploying @@ -209,18 +220,24 @@ This specification includes all endpoints from all agents deployed to that domai httpApi: deployments: local: - - domain: task-app.localhost:9006 - webhookUrl: http://task-app.localhost:9006 + - subdomain: task-app # resolves to task-app.localhost:9006 by default + webhookUrl: /webhooks/ agents: TaskAgent: {} AdminAgent: testSessionHeaderName: X-Admin-Auth - prod: - - domain: api.taskapp.com + cloud: + - subdomain: task-app # resolves to task-app.apps.golem.cloud agents: TaskAgent: {} AdminAgent: securityScheme: google-oidc + +environments: + local: + server: local + cloud: + server: cloud ``` ## Key Constraints @@ -228,5 +245,5 @@ httpApi: - Agent type names in `golem.yaml` use **PascalCase** (matching the class/trait name in code) - Each agent entry can have at most one of `securityScheme` or `testSessionHeaderName` - Security schemes must be created via `golem api security-scheme create` before they can be referenced -- The domain must be unique per environment +- The resolved domain must be unique per environment - After changing `golem.yaml`, run `golem deploy --yes` to apply changes diff --git a/docs/src/content/next/how-to-guides/common/golem-configure-mcp-server.mdx b/docs/src/content/next/how-to-guides/common/golem-configure-mcp-server.mdx index a84e6f6281..68057b9ea0 100644 --- a/docs/src/content/next/how-to-guides/common/golem-configure-mcp-server.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-configure-mcp-server.mdx @@ -14,7 +14,7 @@ Add an `mcp` section to the root `golem.yaml`: mcp: deployments: local: - - domain: my-app.localhost:9007 + - subdomain: my-app # resolves to my-app.localhost:9007 by default agents: CounterAgent: {} TaskAgent: {} @@ -25,9 +25,14 @@ mcp: - `mcp.deployments` is a map keyed by **environment name** (e.g., `local`, `cloud`, `staging`) - Each environment contains a list of deployment objects - Each deployment has: - - `domain`: the (sub)domain to bind to + - `subdomain`: a single DNS label (lowercase letters, digits, and hyphens only — no dots, port, or URL scheme) resolved through the target environment server. Local MCP deployments resolve to `.localhost:9007` by default, or `.localhost:` when `localServer.mcpPort` is set to a stable nonzero port. Cloud MCP deployments resolve to `.mcps.golem.cloud`. + - `domain`: a full domain such as `mcp.example.com` for custom registered domains or custom server environments. - `agents`: a map of agent type names (PascalCase) to their deployment options +Define exactly one of `subdomain` or `domain` on each deployment. Prefer `subdomain` for built-in `server: local` and `server: cloud` environments. Use `domain` when you need a full custom domain. `subdomain` cannot be used with custom server environments — those must use `domain`. + +Do not use `localServer.mcpPort: 0` in the manifest. Port `0` is only allowed when passed directly as `--mcp-port 0` to `golem server run`; manifest deployment domains require stable nonzero ports. + ### Local Development For local development, the MCP server listens on port **9007** by default (separate from the HTTP API gateway on port 9006). Use `*.localhost:9007` domains: @@ -36,7 +41,7 @@ For local development, the MCP server listens on port **9007** by default (separ mcp: deployments: local: - - domain: my-app.localhost:9007 + - subdomain: my-app # resolves to my-app.localhost:9007 by default agents: MyAgent: {} ``` @@ -49,17 +54,17 @@ http://my-app.localhost:9007/mcp ### Cloud Deployment -For Golem Cloud, configure the `cloud` environment with a registered domain: +For Golem Cloud's built-in server, configure the `cloud` environment and use `subdomain`: ```yaml mcp: deployments: local: - - domain: my-app.localhost:9007 + - subdomain: my-app # resolves to my-app.localhost:9007 by default agents: MyAgent: {} cloud: - - domain: my-app.example.com + - subdomain: my-app # resolves to my-app.mcps.golem.cloud agents: MyAgent: securityScheme: my-oauth @@ -77,6 +82,18 @@ Deploy to cloud with: golem deploy --yes --cloud ``` +For custom DNS or custom server environments, use `domain` with a full domain: + +```yaml +mcp: + deployments: + prod: + - domain: mcp.example.com + agents: + MyAgent: + securityScheme: my-oauth +``` + ## Agent Options Each agent entry accepts an optional `securityScheme` field: @@ -134,7 +151,7 @@ After creating a security scheme, reference it by name: mcp: deployments: local: - - domain: my-app.localhost:9007 + - subdomain: my-app # resolves to my-app.localhost:9007 by default agents: SecureAgent: securityScheme: my-oidc @@ -235,11 +252,11 @@ Configure different domains and security settings per environment: mcp: deployments: local: - - domain: my-app.localhost:9007 + - subdomain: my-app # resolves to my-app.localhost:9007 by default agents: MyAgent: {} cloud: - - domain: mcp.myapp.com + - subdomain: my-app # resolves to my-app.mcps.golem.cloud agents: MyAgent: securityScheme: prod-google-oidc @@ -278,11 +295,11 @@ Then connect to `http://my-app.localhost:9007/mcp` using the Streamable HTTP tra mcp: deployments: local: - - domain: counter-app.localhost:9007 + - subdomain: counter-app # resolves to counter-app.localhost:9007 by default agents: CounterAgent: {} cloud: - - domain: mcp.counter-app.com + - subdomain: counter-app # resolves to counter-app.mcps.golem.cloud agents: CounterAgent: securityScheme: google-oidc @@ -300,6 +317,6 @@ environments: - The MCP server listens on port **9007** by default for local development (separate from the HTTP API gateway port 9006) - The MCP endpoint path is always `/mcp` (e.g., `http://my-app.localhost:9007/mcp`) - Security schemes must be created via `golem api security-scheme create` before they can be referenced -- The domain must be unique per environment +- The resolved domain must be unique per environment - After changing `golem.yaml`, run `golem deploy --yes` to apply changes - The OAuth callback path for MCP security schemes is `/mcp/oauth/callback` diff --git a/docs/src/content/next/how-to-guides/common/golem-edit-manifest.mdx b/docs/src/content/next/how-to-guides/common/golem-edit-manifest.mdx index f4410c98a2..63358b7df9 100644 --- a/docs/src/content/next/how-to-guides/common/golem-edit-manifest.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-edit-manifest.mdx @@ -341,20 +341,25 @@ When `format: toon` is used, structured stdout is emitted as framed TOON documen Configure HTTP API domain deployments per environment. +Each deployment must define exactly one of: + +- `subdomain`: a single DNS label resolved through the target environment server (`my-app.localhost:9006` locally by default, `my-app.apps.golem.cloud` on built-in cloud). +- `domain`: a full custom domain such as `api.example.com` for custom DNS or custom server environments. + ```yaml httpApi: deployments: local: - - domain: my-app.localhost:9006 - webhookUrl: http://my-app.localhost:9006 # Optional webhook base URL + - subdomain: my-app # resolves to my-app.localhost:9006 by default + webhookUrl: /webhooks/ # Optional webhook path prefix agents: TaskAgent: {} # No auth SecureAgent: securityScheme: my-oidc # OIDC security scheme name DevAgent: testSessionHeaderName: X-Test-Auth # Test auth header - prod: - - domain: api.myapp.com + cloud: + - subdomain: my-app # resolves to my-app.apps.golem.cloud agents: TaskAgent: {} SecureAgent: @@ -367,11 +372,16 @@ Agent names use **PascalCase** matching the agent type name in code. Configure MCP (Model Context Protocol) deployments per environment. +Each deployment must define exactly one of: + +- `subdomain`: a single DNS label resolved through the target environment server (`my-mcp.localhost:9007` locally by default, `my-mcp.mcps.golem.cloud` on built-in cloud). +- `domain`: a full custom domain such as `mcp.example.com` for custom DNS or custom server environments. + ```yaml mcp: deployments: local: - - domain: mcp.localhost:9006 + - subdomain: my-mcp # resolves to my-mcp.localhost:9007 by default agents: ToolAgent: {} SecureToolAgent: diff --git a/docs/src/content/next/how-to-guides/common/golem-integration-test-setup.mdx b/docs/src/content/next/how-to-guides/common/golem-integration-test-setup.mdx index bef9167a10..7bd0cdf826 100644 --- a/docs/src/content/next/how-to-guides/common/golem-integration-test-setup.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-integration-test-setup.mdx @@ -145,22 +145,44 @@ The `--ports-file` is written **atomically once all services are ready**, so pol | `customRequestPort` | HTTP API endpoints exposed by the application | | `mcpPort` | MCP server requests | -If the test environment in `golem.yaml` uses the built-in `server: local` it always points at `http://localhost:9881`. When you bind to random ports for tests, override the URL with a custom server block instead, templating the port in from the harness: +When you deploy through manifest `subdomain` entries, keep the test environment on the built-in local server and use stable nonzero local ports: ```yaml +localServer: + routerPort: 9881 + customRequestPort: 9006 + mcpPort: 9007 + environments: test: - server: - url: "http://localhost:{{ GOLEM_TEST_ROUTER_PORT }}" - auth: - staticToken: "{{ GOLEM_TEST_TOKEN }}" + server: local componentPresets: test cli: autoConfirm: true reset: true ``` -The harness then exports `GOLEM_TEST_ROUTER_PORT` (read from `ports.json`) before invoking `golem`. If you prefer fixed ports during local debugging, drop `--router-port 0` and reference the defaults (`9881` / `9006` / `9007`) directly. +`customRequestPort` and `mcpPort` make deployment `subdomain` values resolve to the configured HTTP API and MCP ports instead of the defaults. Do not set any manifest `localServer` port field to `0`; port `0` is only allowed when passed directly as a `golem server run` port flag. + +If it helps the test workflow to keep local server settings separate from the main manifest, use an included manifest fragment: + +```yaml +includes: + - golem-local-server.yaml +``` + +Then define `localServer` in `golem-local-server.yaml`: + +```yaml +localServer: + routerPort: 9881 + customRequestPort: 9006 + mcpPort: 9007 + portsFile: .golem/ports.json + dataDir: .golem/data +``` + +`localServer` is a singleton across all manifest sources, so define it either in the main manifest or in the included file, not both. Include paths are relative to the manifest that declares `includes`; `localServer` path fields are relative to the manifest that declares `localServer`. If you load manifests explicitly with `--app`, pass every relevant manifest file because `includes` are only followed during normal auto-discovered manifest loading. ## 5. Deploy Against the Test Environment @@ -179,8 +201,8 @@ For faster iteration during test development, reuse the same server process acro Inside the test code, invoke agents either via the CLI or by calling the HTTP / MCP endpoints directly: - **Management / invocation API** — `http://localhost:` (also where `golem -E test agent invoke ...` connects). -- **HTTP API endpoints** — `http://localhost:/`. -- **MCP** — `http://localhost:`. +- **HTTP API endpoints** — `http://.localhost:/`, for example `http://test-api.localhost:/`. +- **MCP** — `http://.localhost:/mcp`, for example `http://test-mcp.localhost:/mcp`. Use the `httpApi.deployments.test`, `mcp.deployments.test`, `secretDefaults.test`, and `retryPolicyDefaults.test` sections of `golem.yaml` to provide test-specific routing and configuration; these sections are keyed by environment name. @@ -188,7 +210,14 @@ Use the `httpApi.deployments.test`, `mcp.deployments.test`, `secretDefaults.test httpApi: deployments: test: - - domain: localhost # Port comes from --custom-request-port + - subdomain: test-api # resolves to test-api.localhost: + agents: + MyAgent: {} + +mcp: + deployments: + test: + - subdomain: test-mcp # resolves to test-mcp.localhost: agents: MyAgent: {} @@ -217,9 +246,9 @@ Pseudocode for a typical test harness: --router-port 0 --custom-request-port 0 --mcp-port 0 \ --ports-file tests/fixtures/ports.json --clean 3. wait until tests/fixtures/ports.json exists -4. read ports → export GOLEM_TEST_ROUTER_PORT, GOLEM_TEST_CUSTOM_PORT, GOLEM_TEST_MCP_PORT -5. run: golem -E test deploy -6. run integration tests, hitting http://localhost:... +4. read ports from ports.json for direct runtime URLs +5. deploy through a local environment configured with stable nonzero manifest ports when the test needs deployment subdomains +6. run integration tests, using either stable subdomain URLs or direct discovered-port URLs 7. terminate the server process and clean up tests/fixtures/data ``` diff --git a/docs/src/content/next/how-to-guides/common/golem-local-dev-server.mdx b/docs/src/content/next/how-to-guides/common/golem-local-dev-server.mdx index cf5297eab5..2178bd03a0 100644 --- a/docs/src/content/next/how-to-guides/common/golem-local-dev-server.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-local-dev-server.mdx @@ -81,6 +81,37 @@ When `--ports-file` is specified, the server writes a JSON file with the actual } ``` +The application manifest can mirror stable local ports with `localServer.customRequestPort` and `localServer.mcpPort`. Those fields control how deployment `subdomain` values expand: HTTP API domains resolve to `