diff --git a/cli/golem-cli/command-output-schema/command-output.schema.json b/cli/golem-cli/command-output-schema/command-output.schema.json index f10e6576b3..834e8a6239 100644 --- a/cli/golem-cli/command-output-schema/command-output.schema.json +++ b/cli/golem-cli/command-output-schema/command-output.schema.json @@ -192,12 +192,27 @@ { "$ref": "#/definitions/environment.list" }, + { + "$ref": "#/definitions/environment.tool.grant" + }, + { + "$ref": "#/definitions/environment.tool.list" + }, + { + "$ref": "#/definitions/environment.tool.delete" + }, + { + "$ref": "#/definitions/environment.tool.restore" + }, { "$ref": "#/definitions/environment.sync-deployment-options" }, { "$ref": "#/definitions/deploy.environment-setup-plan" }, + { + "$ref": "#/definitions/deploy.environment-tool-grants" + }, { "$ref": "#/definitions/plugin.get" }, @@ -3447,7 +3462,8 @@ "ManifestComponentDependencyReference": { "oneOf": [ { "type": "string" }, - { "$ref": "#/definitions/ManifestStructuredComponentDependencyReference" } + { "$ref": "#/definitions/ManifestStructuredComponentDependencyReference" }, + { "$ref": "#/definitions/ManifestSourcedComponentDependencyReference" } ] }, "ManifestStructuredComponentDependencyReference": { @@ -3459,6 +3475,56 @@ }, "additionalProperties": false }, + "ManifestSourcedComponentDependencyReference": { + "type": "object", + "required": ["source"], + "properties": { + "source": { "$ref": "#/definitions/ManifestSubjectSource" } + }, + "additionalProperties": false + }, + "ManifestSubjectSource": { + "oneOf": [ + { + "type": "object", + "required": ["local"], + "properties": { + "local": { "$ref": "#/definitions/ManifestStructuredComponentDependencyReference" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["registry"], + "properties": { + "registry": { "$ref": "#/definitions/ManifestRegistrySubject" } + }, + "additionalProperties": false + } + ] + }, + "ManifestRegistrySubject": { + "oneOf": [ + { + "type": "object", + "required": ["releaseId"], + "properties": { + "releaseId": { "type": "string", "format": "uuid" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["account", "name", "version"], + "properties": { + "account": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" } + }, + "additionalProperties": false + } + ] + }, "ManifestCustomCommandsMap": { "type": "object", "additionalProperties": { @@ -5097,7 +5163,9 @@ "properties": { "components": { "$ref": "#/definitions/DeploymentDiffSection" }, "httpApiDeployments": { "$ref": "#/definitions/DeploymentDiffSection" }, - "mcpDeployments": { "$ref": "#/definitions/DeploymentDiffSection" } + "mcpDeployments": { "$ref": "#/definitions/DeploymentDiffSection" }, + "remoteTools": { "$ref": "#/definitions/DeploymentDiffSection" }, + "publishedTools": { "$ref": "#/definitions/DeploymentSetDiffSection" } }, "additionalProperties": false }, @@ -5124,9 +5192,64 @@ }, { "$ref": "#/definitions/McpDeploymentDiffPayload" + }, + { + "$ref": "#/definitions/RemoteToolDeploymentDiffPayload" } ] }, + "DeploymentSetDiffSection": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": ["create", "delete"] + } + }, + "RemoteToolDeploymentDiffPayload": { + "type": "object", + "required": [ + "releaseId", + "version", + "sourceDigest", + "ownerAccountId", + "ownerAccountEmail", + "metadataVersion", + "metadataDigest", + "provision", + "bindings" + ], + "properties": { + "releaseId": { "type": "string", "format": "uuid" }, + "version": { "type": "string" }, + "sourceDigest": { "type": "string" }, + "ownerAccountId": { "type": "string", "format": "uuid" }, + "ownerAccountEmail": { "type": "string" }, + "metadataVersion": { "type": "string" }, + "metadataDigest": { "type": "string" }, + "provision": { "$ref": "#/definitions/ToolProvisionConfig" }, + "bindings": { + "type": "object", + "additionalProperties": { "$ref": "#/definitions/EffectiveToolBindingDiffPayload" } + } + }, + "additionalProperties": false + }, + "EffectiveToolBindingDiffPayload": { + "type": "object", + "required": [ + "parameters", + "secretKeysReadable", + "secretKeysRevealable", + "filesystemAccess" + ], + "properties": { + "parameters": {}, + "secretKeysReadable": { "$ref": "#/definitions/SecretKeyScope" }, + "secretKeysRevealable": { "$ref": "#/definitions/SecretKeyScope" }, + "filesystemAccess": { "type": "string", "enum": ["unset", "allowed", "denied"] } + }, + "additionalProperties": false + }, "HashDiffPayload": { "type": "object", "required": ["newHash", "currentHash"], @@ -5685,6 +5808,12 @@ }, "mcpDeployments": { "$ref": "#/definitions/DeploymentDiffSection" + }, + "remoteTools": { + "$ref": "#/definitions/DeploymentDiffSection" + }, + "publishedTools": { + "$ref": "#/definitions/DeploymentSetDiffSection" } }, "additionalProperties": false @@ -5752,6 +5881,69 @@ }, "additionalProperties": false }, + "EnvironmentToolGrantView": { + "type": "object", + "required": ["grantId", "releaseId", "toolName", "toolVersion", "owner", "protected", "automatic", "lifecycle"], + "properties": { + "grantId": { "type": "string", "format": "uuid" }, + "releaseId": { "type": "string", "format": "uuid" }, + "toolName": { "type": "string" }, + "toolVersion": { "type": "string" }, + "owner": { "type": "string" }, + "protected": { "type": "boolean" }, + "automatic": { "type": "boolean" }, + "lifecycle": { "type": "string", "enum": ["active", "deleted"] } + }, + "additionalProperties": false + }, + "environment.tool.grant": { + "type": "object", + "description": "Created environment tool grant emitted by `golem environment tool grant`.", + "x-golem-output-mode": "single", + "x-golem-command": "environment tool grant", + "required": ["$type", "grant"], + "properties": { + "$type": { "const": "environment.tool.grant" }, + "grant": { "$ref": "#/definitions/EnvironmentToolGrantView" } + }, + "additionalProperties": false + }, + "environment.tool.list": { + "type": "object", + "description": "Active environment tool grants listed by `golem environment tool list`.", + "x-golem-output-mode": "single", + "x-golem-command": "environment tool list", + "required": ["$type", "grants"], + "properties": { + "$type": { "const": "environment.tool.list" }, + "grants": { "type": "array", "items": { "$ref": "#/definitions/EnvironmentToolGrantView" } } + }, + "additionalProperties": false + }, + "environment.tool.delete": { + "type": "object", + "description": "Deleted environment tool grant ID emitted by `golem environment tool delete`.", + "x-golem-output-mode": "single", + "x-golem-command": "environment tool delete", + "required": ["$type", "grantId"], + "properties": { + "$type": { "const": "environment.tool.delete" }, + "grantId": { "type": "string", "format": "uuid" } + }, + "additionalProperties": false + }, + "environment.tool.restore": { + "type": "object", + "description": "Restored environment tool grant emitted by `golem environment tool restore`.", + "x-golem-output-mode": "single", + "x-golem-command": "environment tool restore", + "required": ["$type", "grant"], + "properties": { + "$type": { "const": "environment.tool.restore" }, + "grant": { "$ref": "#/definitions/EnvironmentToolGrantView" } + }, + "additionalProperties": false + }, "deploy.environment-setup-plan": { "type": "object", "description": "May be emitted by `golem deploy` when planning environment setup entries. Other deploy output documents may also appear in the same command run.", @@ -5769,6 +5961,35 @@ }, "additionalProperties": false }, + "EnvironmentToolGrantPlanEntry": { + "type": "object", + "required": ["action", "releaseId", "account", "name", "version", "grantId"], + "properties": { + "action": { "type": "string", "enum": ["create", "delete", "retainProtected", "retainAdministratorManaged"] }, + "releaseId": { "type": ["string", "null"], "format": "uuid" }, + "account": { "type": ["string", "null"] }, + "name": { "type": ["string", "null"] }, + "version": { "type": ["string", "null"] }, + "grantId": { "type": ["string", "null"], "format": "uuid" } + }, + "additionalProperties": false + }, + "deploy.environment-tool-grants": { + "type": "object", + "description": "May be emitted by `golem deploy` or `golem build` when planning automatic environment tool grant changes. Other output documents may also appear in the same command run.", + "x-golem-output-mode": "multi-document", + "x-golem-command": "deploy", + "x-golem-commands": ["deploy", "build"], + "required": ["$type", "entries"], + "properties": { + "$type": { "const": "deploy.environment-tool-grants" }, + "entries": { + "type": "array", + "items": { "$ref": "#/definitions/EnvironmentToolGrantPlanEntry" } + } + }, + "additionalProperties": false + }, "plugin.get": { "type": "object", "description": "Single structured output document emitted by `golem plugin get`.", @@ -7831,6 +8052,22 @@ "type": "environment.list", "rustType": "EnvironmentListView" }, + { + "type": "environment.tool.grant", + "rustType": "EnvironmentToolGrantCreateView" + }, + { + "type": "environment.tool.list", + "rustType": "EnvironmentToolGrantListView" + }, + { + "type": "environment.tool.delete", + "rustType": "EnvironmentToolGrantDeleteView" + }, + { + "type": "environment.tool.restore", + "rustType": "EnvironmentToolGrantRestoreView" + }, { "type": "environment.sync-deployment-options", "rustType": "EnvironmentSyncDeploymentOptionsResult" @@ -7839,6 +8076,10 @@ "type": "deploy.environment-setup-plan", "rustType": "EnvironmentSetupPlanView" }, + { + "type": "deploy.environment-tool-grants", + "rustType": "EnvironmentToolGrantPlanView" + }, { "type": "plugin.get", "rustType": "PluginRegistrationGetView" diff --git a/cli/golem-cli/src/app/build/gen_bridge.rs b/cli/golem-cli/src/app/build/gen_bridge.rs index b1ac818f93..da51d4ee0c 100644 --- a/cli/golem-cli/src/app/build/gen_bridge.rs +++ b/cli/golem-cli/src/app/build/gen_bridge.rs @@ -17,8 +17,8 @@ use crate::fs; use crate::log::log_error; use crate::log::{LogColorize, LogIndent, log_action, log_skipping_up_to_date, logln}; use crate::model::app::{ - BridgeSdkTarget, BridgeSdkTargetKind, BridgeSdkTargetSubject, ComponentDependency, - CustomBridgeSdkTarget, + BridgeSdkTarget, BridgeSdkTargetKind, BridgeSdkTargetSource, BridgeSdkTargetSubject, + ComponentDependency, CustomBridgeSdkTarget, }; use crate::model::cli_output::StructuredOutput; use crate::model::language::GuestLanguage; @@ -306,7 +306,7 @@ pub(crate) async fn collect_custom_targets_lenient( }); targets.push(BridgeSdkTarget { - component_name: component_name.clone(), + source: BridgeSdkTargetSource::local(component_name.clone()), subject: BridgeSdkTargetSubject::Agent(agent_type), target_language, bridge_mode: BridgeMode::External, @@ -480,7 +480,7 @@ async fn collect_agent_manifest_targets_for_entry( bridge_mode, ); targets.push(BridgeSdkTarget { - component_name: component_name.clone(), + source: BridgeSdkTargetSource::local(component_name.clone()), subject: BridgeSdkTargetSubject::Agent(agent_type), target_language, bridge_mode, @@ -513,6 +513,43 @@ async fn collect_agent_manifest_targets_for_entry( Ok(()) } +fn collect_registry_tool_manifest_targets_for_entry( + ctx: &BuildContext<'_>, + bridge_mode: BridgeMode, + target_language: GuestLanguage, + matchers: &mut BTreeSet, + is_matching_all: bool, + targets: &mut Vec, +) -> anyhow::Result<()> { + for (name, _) in ctx.application().registry_tool_references() { + if !is_matching_all && !matchers.remove(name.as_str()) { + continue; + } + let grant = ctx.registry_tool_grant_by_name(name).ok_or_else(|| { + anyhow::anyhow!( + "Registry tool '{}' is not granted to the selected environment", + name + ) + })?; + targets.push(BridgeSdkTarget { + source: BridgeSdkTargetSource::Registry { + release_id: grant.release.id, + version: grant.release.version.clone(), + metadata_version: grant.release.metadata_version.clone(), + metadata_digest: grant.release.metadata_digest, + source_digest: grant.release.source_digest, + }, + subject: BridgeSdkTargetSubject::Tool(grant.release.definition.clone()), + target_language, + bridge_mode, + output_dir: ctx + .application() + .tool_bridge_sdk_dir(name.as_str(), target_language), + }); + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] async fn collect_tool_manifest_targets_for_entry( ctx: &BuildContext<'_>, @@ -537,6 +574,14 @@ async fn collect_tool_manifest_targets_for_entry( } let is_matching_all = matchers.remove("*"); + collect_registry_tool_manifest_targets_for_entry( + ctx, + bridge_mode, + target_language, + &mut matchers, + is_matching_all, + targets, + )?; for component_name in source_component_names { if skip_missing_sources @@ -564,6 +609,12 @@ async fn collect_tool_manifest_targets_for_entry( .await? .tools; + tools.retain(|tool| { + tool.name() + .and_then(|name| ToolName::try_from(name).ok()) + .is_none_or(|name| ctx.application().registry_tool_reference(&name).is_none()) + }); + if !is_matching_all && !is_matching_component { tools.retain(|tool| tool.name().is_some_and(|name| matchers.contains(name))); } @@ -576,7 +627,7 @@ async fn collect_tool_manifest_targets_for_entry( let output_dir = ctx.application().tool_bridge_sdk_dir(name, target_language); targets.push(BridgeSdkTarget { - component_name: component_name.clone(), + source: BridgeSdkTargetSource::local(component_name.clone()), subject: BridgeSdkTargetSubject::Tool(tool), target_language, bridge_mode, @@ -629,7 +680,9 @@ async fn collect_dependency_guest_bridge_targets( let metadata = extract_and_store_component_metadata(ctx, component_name).await?; for agent_type in &metadata.agent_types { let dependency = ComponentDependency::Agent { - component_name: component_name.clone(), + source: crate::model::app::SubjectSource::Local { + component_name: component_name.clone(), + }, agent_type_name: agent_type.type_name.clone(), }; let target_languages = dependency_guest_bridge_target_languages( @@ -643,7 +696,7 @@ async fn collect_dependency_guest_bridge_targets( .application() .dependency_bridge_sdk_dir(&agent_type.type_name, target_language); targets.push(BridgeSdkTarget { - component_name: component_name.clone(), + source: BridgeSdkTargetSource::local(component_name.clone()), subject: BridgeSdkTargetSubject::Agent(agent_type.clone()), target_language, bridge_mode: BridgeMode::Guest, @@ -660,7 +713,9 @@ async fn collect_dependency_guest_bridge_targets( continue; }; let dependency = ComponentDependency::Tool { - component_name: component_name.clone(), + source: crate::model::app::SubjectSource::Local { + component_name: component_name.clone(), + }, tool_name: tool_dependency_name, }; let target_languages = dependency_guest_bridge_target_languages( @@ -674,7 +729,7 @@ async fn collect_dependency_guest_bridge_targets( .application() .dependency_tool_bridge_sdk_dir(tool_name, target_language); targets.push(BridgeSdkTarget { - component_name: component_name.clone(), + source: BridgeSdkTargetSource::local(component_name.clone()), subject: BridgeSdkTargetSubject::Tool(tool.clone()), target_language, bridge_mode: BridgeMode::Guest, @@ -684,6 +739,64 @@ async fn collect_dependency_guest_bridge_targets( } } + let registry_dependencies = selection_scope_component_names + .iter() + .flat_map(|component_name| { + ctx.application() + .component(component_name) + .properties() + .dependencies + .clone() + .into_iter() + }) + .filter(|dependency| { + matches!( + dependency, + ComponentDependency::Tool { + source: crate::model::app::SubjectSource::Registry, + .. + } + ) + }) + .collect::>(); + + for dependency in registry_dependencies { + let ComponentDependency::Tool { + source: crate::model::app::SubjectSource::Registry, + tool_name, + } = &dependency + else { + unreachable!() + }; + let grant = ctx.registry_tool_grant_by_name(tool_name).ok_or_else(|| { + anyhow::anyhow!( + "Registry tool dependency '{}' is not granted to the selected environment", + tool_name + ) + })?; + for target_language in dependency_guest_bridge_target_languages( + ctx, + &dependency, + selection_scope_component_names, + ) { + targets.push(BridgeSdkTarget { + source: BridgeSdkTargetSource::Registry { + release_id: grant.release.id, + version: grant.release.version.clone(), + metadata_version: grant.release.metadata_version.clone(), + metadata_digest: grant.release.metadata_digest, + source_digest: grant.release.source_digest, + }, + subject: BridgeSdkTargetSubject::Tool(grant.release.definition.clone()), + target_language, + bridge_mode: BridgeMode::Guest, + output_dir: ctx + .application() + .dependency_tool_bridge_sdk_dir(tool_name.as_str(), target_language), + }); + } + } + Ok(targets) } @@ -770,7 +883,7 @@ async fn collect_custom_targets( }); targets.push(BridgeSdkTarget { - component_name: component_name.clone(), + source: BridgeSdkTargetSource::local(component_name.clone()), subject: BridgeSdkTargetSubject::Agent(agent_type), target_language, bridge_mode: BridgeMode::External, @@ -798,21 +911,27 @@ async fn gen_bridge_sdk_target( ctx: &BuildContext<'_>, target: BridgeSdkTarget, ) -> anyhow::Result<()> { - let component = ctx.application().component(&target.component_name); - let final_wasm = component.final_wasm(); + let freshness_source = match &target.source { + BridgeSdkTargetSource::Local { component_name } => { + ctx.application().component(component_name).final_wasm() + } + BridgeSdkTargetSource::Registry { .. } => { + ctx.application().bridge_sdks_source().to_path_buf() + } + }; let target_name = target.subject.display_name().to_string(); let target_kind = target.subject.kind().as_str(); let output_dir = Utf8PathBuf::try_from(target.output_dir)?; new_task_up_to_date_check(ctx) .with_task_result_marker(GenerateBridgeSdkMarkerHash { - component_name: &target.component_name, + source: &target.source, target_name: &target_name, kind: target_kind, language: &target.target_language, bridge_mode: target.bridge_mode, })? - .with_sources(|| vec![&final_wasm]) + .with_sources(|| vec![&freshness_source]) .with_targets(|| vec![&output_dir]) .run_async_or_skip( || async { @@ -1053,7 +1172,7 @@ mod tests { tempdir().unwrap().path().join("bridge/alpha-guest-client"), ); let tool_target = BridgeSdkTarget { - component_name: ComponentName("component".to_string()), + source: BridgeSdkTargetSource::local(ComponentName("component".to_string())), subject: BridgeSdkTargetSubject::Tool(tool("MyTool")), target_language: language, bridge_mode: BridgeMode::Guest, @@ -1090,7 +1209,7 @@ mod tests { #[test] fn validate_supported_bridge_targets_reports_external_tool_mode_separately() { let target = BridgeSdkTarget { - component_name: ComponentName("component".to_string()), + source: BridgeSdkTargetSource::local(ComponentName("component".to_string())), subject: BridgeSdkTargetSubject::Tool(tool("MyTool")), target_language: GuestLanguage::Rust, bridge_mode: BridgeMode::External, @@ -1109,11 +1228,13 @@ mod tests { fn dependency_guest_bridge_support_accepts_all_current_languages_for_agents_and_tools() { let component_name = ComponentName("component".to_string()); let agent_dependency = ComponentDependency::Agent { - component_name: component_name.clone(), + source: crate::model::app::SubjectSource::Local { + component_name: component_name.clone(), + }, agent_type_name: AgentTypeName("Agent".to_string()), }; let tool_dependency = ComponentDependency::Tool { - component_name, + source: crate::model::app::SubjectSource::Local { component_name }, tool_name: ToolName::try_from("tool").unwrap(), }; @@ -1159,7 +1280,7 @@ mod tests { output_dir: impl Into, ) -> BridgeSdkTarget { BridgeSdkTarget { - component_name: ComponentName("component".to_string()), + source: BridgeSdkTargetSource::local(ComponentName("component".to_string())), subject: BridgeSdkTargetSubject::Agent(agent_type(agent_type_name)), target_language, bridge_mode, diff --git a/cli/golem-cli/src/app/build/mod.rs b/cli/golem-cli/src/app/build/mod.rs index 9fefe8e336..ee279ee260 100644 --- a/cli/golem-cli/src/app/build/mod.rs +++ b/cli/golem-cli/src/app/build/mod.rs @@ -88,7 +88,8 @@ async fn build_app_with_build_plan(ctx: &BuildContext<'_>) -> anyhow::Result<()> .cloned() .collect::>(); let mut built_components = BTreeSet::::new(); - let mut available_guest_bridge_dependencies = BTreeSet::::new(); + let mut available_guest_bridge_dependencies = + available_registry_guest_bridge_dependencies(ctx, &effective_component_names)?; let mut generated_guest_target_keys = BTreeSet::::new(); build_components_with_dependency_ordering( @@ -176,6 +177,40 @@ async fn build_app_with_build_plan(ctx: &BuildContext<'_>) -> anyhow::Result<()> Ok(()) } +fn available_registry_guest_bridge_dependencies( + ctx: &BuildContext<'_>, + component_names: &[ComponentName], +) -> anyhow::Result> { + let mut available = BTreeSet::new(); + for component_name in component_names { + let component = ctx.application().component(component_name); + for dependency in &component.properties().dependencies { + match dependency { + ComponentDependency::Tool { + source: crate::model::app::SubjectSource::Registry, + tool_name, + } => { + ctx.registry_tool_grant_by_name(tool_name).ok_or_else(|| { + anyhow::anyhow!( + "Registry tool dependency '{}' is not granted to the selected environment", + tool_name + ) + })?; + available.insert(dependency.clone()); + } + ComponentDependency::Agent { + source: crate::model::app::SubjectSource::Registry, + .. + } => anyhow::bail!( + "Registry agent dependencies are not supported by the tool release registry" + ), + _ => {} + } + } + } + Ok(available) +} + #[allow(clippy::too_many_arguments)] async fn build_components_with_dependency_ordering( ctx: &BuildContext<'_>, @@ -282,7 +317,7 @@ async fn generate_available_dependency_guest_bridges( selected_guest_bridge_dependency_sources(ctx, &scope_component_names); let built_component_names = dependency_guest_requirements .iter() - .map(|dependency| dependency.component_name().clone()) + .filter_map(|dependency| dependency.component_name().cloned()) .filter(|component_name| built_components.contains(component_name)) .collect::>(); let built_component_names = built_component_names.into_iter().collect::>(); @@ -393,21 +428,18 @@ fn report_guest_bridge_dependency_ordering_cycle( .difference(available_guest_bridge_dependencies) .map(|dependency| match dependency { ComponentDependency::Agent { - component_name, + source, agent_type_name, - } => { - format!( - "agent {}/{}", - component_name.as_str(), - agent_type_name.as_str() - ) - } - ComponentDependency::Tool { - component_name, - tool_name, - } => { - format!("tool {}/{}", component_name.as_str(), tool_name.as_str()) - } + } => format!( + "agent {}/{}", + format_subject_source(source), + agent_type_name.as_str() + ), + ComponentDependency::Tool { source, tool_name } => format!( + "tool {}/{}", + format_subject_source(source), + tool_name.as_str() + ), }) .collect::>(); log_error(format!( @@ -422,9 +454,16 @@ fn report_guest_bridge_dependency_ordering_cycle( anyhow::bail!(NonSuccessfulExit) } +fn format_subject_source(source: &crate::model::app::SubjectSource) -> String { + match source { + crate::model::app::SubjectSource::Local { component_name } => component_name.to_string(), + crate::model::app::SubjectSource::Registry => "registry".to_string(), + } +} + #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] struct BridgeSdkTargetKey { - component_name: ComponentName, + source: String, target_name: String, kind: &'static str, target_language: GuestLanguage, @@ -435,7 +474,8 @@ struct BridgeSdkTargetKey { impl BridgeSdkTargetKey { fn from_bridge_target(ctx: &BuildContext<'_>, target: &BridgeSdkTarget) -> Self { Self { - component_name: target.component_name.clone(), + source: serde_json::to_string(&target.source) + .expect("bridge SDK target sources are serializable"), target_name: target.subject.display_name().to_string(), kind: target.subject.kind().as_str(), target_language: target.target_language, @@ -472,7 +512,9 @@ fn component_guest_bridge_dependencies_provided_by_metadata( .agent_types .iter() .map(|agent_type| ComponentDependency::Agent { - component_name: component_name.clone(), + source: crate::model::app::SubjectSource::Local { + component_name: component_name.clone(), + }, agent_type_name: agent_type.type_name.clone(), }), ); @@ -480,7 +522,9 @@ fn component_guest_bridge_dependencies_provided_by_metadata( tool.name() .and_then(|name| ToolName::try_from(name).ok()) .map(|tool_name| ComponentDependency::Tool { - component_name: component_name.clone(), + source: crate::model::app::SubjectSource::Local { + component_name: component_name.clone(), + }, tool_name, }) })); @@ -524,7 +568,9 @@ async fn selected_component_names_with_dependencies_for_build( .flat_map(|component_name| component_guest_bridge_requirements(ctx, component_name)) .collect::>() { - selected.insert(dependency.component_name().clone()); + if let Some(component_name) = dependency.component_name() { + selected.insert(component_name.clone()); + } } if selected.len() == selected_count_before { @@ -621,7 +667,7 @@ fn bridge_output_dir_claims( if mode == BridgeMode::Guest && let Some(tools) = sdk_targets.tools - && manifest_bridge_request_may_match_selected_components( + && manifest_tool_bridge_request_may_match_selected_components( ctx, tools, selected_component_names, @@ -791,6 +837,26 @@ fn add_manifest_tool_bridge_output_dir_claims( } } +fn manifest_tool_bridge_request_may_match_selected_components( + ctx: &BuildContext<'_>, + tools: &crate::model::app_raw::LenientTokenList, + selected_component_names: &[ComponentName], +) -> bool { + let matchers = tools.clone().into_set(); + let matches_registry = (matchers.contains("*") + && ctx + .application() + .registry_tool_references() + .next() + .is_some()) + || ctx + .application() + .registry_tool_references() + .any(|(name, _)| matchers.contains(name.as_str())); + matches_registry + || manifest_matchers_may_match_selected_components(ctx, matchers, selected_component_names) +} + /// Which kind of manifest bridge matchers a component-based output-dir claim /// is collected for: agent matchers claim the component's agent type client /// directories, tool matchers its tool client directories. @@ -918,7 +984,18 @@ fn manifest_bridge_request_may_match_selected_components( agents: &crate::model::app_raw::LenientTokenList, selected_component_names: &[ComponentName], ) -> bool { - let matchers = agents.clone().into_set(); + manifest_matchers_may_match_selected_components( + ctx, + agents.clone().into_set(), + selected_component_names, + ) +} + +fn manifest_matchers_may_match_selected_components( + ctx: &BuildContext<'_>, + matchers: BTreeSet, + selected_component_names: &[ComponentName], +) -> bool { if matchers.contains("*") { return true; } @@ -1262,7 +1339,7 @@ fn has_explicit_manifest_guest_bridge_request( sdk_targets.agents, selected_component_names, ) || sdk_targets.tools.is_some_and(|tools| { - manifest_bridge_request_may_match_selected_components( + manifest_tool_bridge_request_may_match_selected_components( ctx, tools, selected_component_names, @@ -1282,7 +1359,9 @@ mod tests { let base_dir = std::env::temp_dir().join(format!("golem-cli-build-plan-{}", std::process::id())); let dependency_guest_target = BridgeSdkTarget { - component_name: ComponentName::try_from("app:producer").unwrap(), + source: crate::model::app::BridgeSdkTargetSource::local( + ComponentName::try_from("app:producer").unwrap(), + ), subject: BridgeSdkTargetSubject::Agent(bar_agent_type()), target_language: GuestLanguage::Rust, bridge_mode: BridgeMode::Guest, diff --git a/cli/golem-cli/src/app/build/task_result_marker.rs b/cli/golem-cli/src/app/build/task_result_marker.rs index 006f70e02a..40a55acf1a 100644 --- a/cli/golem-cli/src/app/build/task_result_marker.rs +++ b/cli/golem-cli/src/app/build/task_result_marker.rs @@ -16,6 +16,7 @@ use crate::app::build::task_result_marker::TaskResultMarkerHashSourceKind::{Hash use crate::bridge_gen::BridgeMode; use crate::fs; use crate::log::log_warn_action; +use crate::model::app::BridgeSdkTargetSource; use crate::model::app_raw; use crate::model::app_raw::{ GenerateQuickJSCrate, GenerateQuickJSDTS, InjectToPrebuiltQuickJs, PreinitializeJs, @@ -322,7 +323,7 @@ impl TaskResultMarkerHashSource for GetServerIfsFileHash<'_> { } pub struct GenerateBridgeSdkMarkerHash<'a> { - pub component_name: &'a ComponentName, + pub source: &'a BridgeSdkTargetSource, pub target_name: &'a str, pub kind: &'static str, pub language: &'a GuestLanguage, @@ -340,8 +341,8 @@ impl TaskResultMarkerHashSource for GenerateBridgeSdkMarkerHash<'_> { fn source(&self) -> anyhow::Result { Ok(HashFromString(format!( - "componentName={}\ntargetName={}\nkind={}\nlanguage={}\nbridgeMode={}", - self.component_name, + "source={}\ntargetName={}\nkind={}\nlanguage={}\nbridgeMode={}\ngeneratorVersion=1", + serde_json::to_string(self.source)?, self.target_name, self.kind, self.language.id(), @@ -546,16 +547,19 @@ impl TaskResultMarker { mod tests { use super::*; use crate::bridge_gen::BridgeMode; + use golem_common::model::diff::Hash; + use golem_common::model::tool_release::ToolReleaseId; use test_r::test; #[test] fn bridge_sdk_marker_hash_source_includes_bridge_mode() { let component_name = ComponentName("app:producer".to_string()); + let source = BridgeSdkTargetSource::local(component_name); let agent_type_name = AgentTypeName("AlphaAgent".to_string()); let language = GuestLanguage::Rust; let external_source = GenerateBridgeSdkMarkerHash { - component_name: &component_name, + source: &source, target_name: agent_type_name.as_str(), kind: "agent", language: &language, @@ -564,7 +568,7 @@ mod tests { .source() .unwrap(); let guest_source = GenerateBridgeSdkMarkerHash { - component_name: &component_name, + source: &source, target_name: agent_type_name.as_str(), kind: "agent", language: &language, @@ -591,11 +595,15 @@ mod tests { let marker_dir = tempfile::tempdir().unwrap(); let language = GuestLanguage::Rust; let bridge_mode = BridgeMode::External; + let left_source = + BridgeSdkTargetSource::local(ComponentName::try_from("app:producer-a").unwrap()); + let right_source = + BridgeSdkTargetSource::local(ComponentName::try_from("app:producer").unwrap()); let left_marker = TaskResultMarker::new( marker_dir.path(), GenerateBridgeSdkMarkerHash { - component_name: &ComponentName::try_from("app:producer-a").unwrap(), + source: &left_source, target_name: "b", kind: "agent", language: &language, @@ -606,7 +614,7 @@ mod tests { let right_marker = TaskResultMarker::new( marker_dir.path(), GenerateBridgeSdkMarkerHash { - component_name: &ComponentName::try_from("app:producer").unwrap(), + source: &right_source, target_name: "a-b", kind: "agent", language: &language, @@ -619,6 +627,69 @@ mod tests { assert_ne!(left_marker.marker_file_path, right_marker.marker_file_path); } + #[test] + fn registry_bridge_marker_covers_release_and_metadata_identity() { + let base = BridgeSdkTargetSource::Registry { + release_id: ToolReleaseId::new(), + version: "1.2.0".to_string(), + metadata_version: "0.1.0".to_string(), + metadata_digest: Hash::new(blake3::hash(b"metadata-a")), + source_digest: Hash::new(blake3::hash(b"source-a")), + }; + let base_marker = bridge_marker_source(&base); + + let mut changed_release = base.clone(); + let BridgeSdkTargetSource::Registry { release_id, .. } = &mut changed_release else { + unreachable!() + }; + *release_id = ToolReleaseId::new(); + assert_ne!(base_marker, bridge_marker_source(&changed_release)); + + let mut changed_schema = base.clone(); + let BridgeSdkTargetSource::Registry { + metadata_version, .. + } = &mut changed_schema + else { + unreachable!() + }; + *metadata_version = "0.2.0".to_string(); + assert_ne!(base_marker, bridge_marker_source(&changed_schema)); + + let mut changed_metadata = base.clone(); + let BridgeSdkTargetSource::Registry { + metadata_digest, .. + } = &mut changed_metadata + else { + unreachable!() + }; + *metadata_digest = Hash::new(blake3::hash(b"metadata-b")); + assert_ne!(base_marker, bridge_marker_source(&changed_metadata)); + + let mut changed_source = base.clone(); + let BridgeSdkTargetSource::Registry { source_digest, .. } = &mut changed_source else { + unreachable!() + }; + *source_digest = Hash::new(blake3::hash(b"source-b")); + assert_ne!(base_marker, bridge_marker_source(&changed_source)); + } + + fn bridge_marker_source(source: &BridgeSdkTargetSource) -> String { + let language = GuestLanguage::Rust; + let marker = GenerateBridgeSdkMarkerHash { + source, + target_name: "search", + kind: "tool", + language: &language, + bridge_mode: BridgeMode::Guest, + } + .source() + .unwrap(); + let TaskResultMarkerHashSourceKind::HashFromString(marker) = marker else { + panic!("expected bridge marker to hash from string"); + }; + marker + } + #[test] fn moon_install_markers_do_not_collide_for_distinct_module_roots_with_identical_manifests() { let marker_dir = tempfile::tempdir().unwrap(); diff --git a/cli/golem-cli/src/app/context.rs b/cli/golem-cli/src/app/context.rs index 6863b03a79..4c599b7119 100644 --- a/cli/golem-cli/src/app/context.rs +++ b/cli/golem-cli/src/app/context.rs @@ -43,6 +43,8 @@ use golem_common::model::application::ApplicationName; use golem_common::model::component::ComponentName; use golem_common::model::diff; use golem_common::model::environment::EnvironmentName; +use golem_common::model::environment_tool_grant::EnvironmentToolGrantWithDetails; +use golem_common::model::tool_release::ToolReleaseId; use itertools::Itertools; use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::{Path, PathBuf}; @@ -105,6 +107,37 @@ impl<'a> BuildContext<'a> { pub fn tools_with_ensured_common_deps(&self) -> &ToolsWithEnsuredCommonDeps { &self.application_context.tools_with_ensured_common_deps } + + pub fn registry_tool_grants(&self) -> &[EnvironmentToolGrantWithDetails] { + &self.build_config.registry_tool_grants + } + + pub fn registry_tool_grant( + &self, + reference: &app_raw::RegistrySubject, + ) -> Option<&EnvironmentToolGrantWithDetails> { + self.registry_tool_grants() + .iter() + .find(|grant| match reference { + app_raw::RegistrySubject::ById(reference) => { + grant.release.id == ToolReleaseId(reference.release_id.0) + } + app_raw::RegistrySubject::ByCoordinates(reference) => { + grant.release_owner.email.as_str() == reference.account + && grant.release.name.as_str() == reference.name + && grant.release.version == reference.version + } + }) + } + + pub fn registry_tool_grant_by_name( + &self, + name: &golem_common::model::tool::ToolName, + ) -> Option<&EnvironmentToolGrantWithDetails> { + self.application() + .registry_tool_reference(name) + .and_then(|reference| self.registry_tool_grant(reference)) + } } pub struct ToolsWithEnsuredCommonDeps { diff --git a/cli/golem-cli/src/client.rs b/cli/golem-cli/src/client.rs index 99c5ecc408..440d9cafee 100644 --- a/cli/golem-cli/src/client.rs +++ b/cli/golem-cli/src/client.rs @@ -19,9 +19,9 @@ use golem_client::api::{ AccountClientLive, AccountSummaryClientLive, AgentClientLive, AgentSecretsClientLive, AgentTypesClientLive, ApiDeploymentClientLive, ApiDomainClientLive, ApiSecurityClientLive, ApplicationClientLive, CardClientLive, ComponentClientLive, DeploymentClientLive, - EnvironmentClientLive, LoginClientLive, McpDeploymentClientLive, MeClientLive, - PermissionSharesClientLive, PluginClientLive, ResourcesClientLive, RetryPoliciesClientLive, - TokenClientLive, WorkerClientLive, + EnvironmentClientLive, EnvironmentToolGrantsClientLive, LoginClientLive, + McpDeploymentClientLive, MeClientLive, PermissionSharesClientLive, PluginClientLive, + ResourcesClientLive, RetryPoliciesClientLive, TokenClientLive, WorkerClientLive, }; use golem_client::{Context as ClientContext, Security}; use golem_common::base_model::api; @@ -267,6 +267,7 @@ pub struct GolemClients { pub component: ComponentClientLive, pub deployment: DeploymentClientLive, pub environment: EnvironmentClientLive, + pub environment_tool_grants: EnvironmentToolGrantsClientLive, pub login: LoginClientLive, pub mcp_deployment: McpDeploymentClientLive, pub me: MeClientLive, @@ -377,6 +378,9 @@ impl GolemClients { environment: EnvironmentClientLive { context: registry_context(), }, + environment_tool_grants: EnvironmentToolGrantsClientLive { + context: registry_context(), + }, login: LoginClientLive { context: login_context(), }, diff --git a/cli/golem-cli/src/command.rs b/cli/golem-cli/src/command.rs index 9106ad545c..c5819f5a9d 100644 --- a/cli/golem-cli/src/command.rs +++ b/cli/golem-cli/src/command.rs @@ -774,10 +774,11 @@ pub enum GolemCliSubcommand { /// /// In `--format json/yaml/toon`, `deploy` may emit multiple structured /// documents. Depending on the plan, stdout can contain - /// `deploy.diff` and/or `deploy.plan`, followed by a final - /// `deploy` success document. Parse stdout as a sequence of - /// documents and branch on `$type`; do not assume every possible - /// document appears. + /// `deploy.diff`, `deploy.plan`, `deploy.environment-setup-plan`, + /// and/or `deploy.environment-tool-grants`, followed by a final + /// `deploy` success document. Parse stdout as a sequence of documents + /// and branch on `$type`; do not assume every possible document + /// appears. #[arg(long, conflicts_with_all = ["stage", "approve_staging_steps"])] plan: bool, /// Only plan and stage changes, but do not apply them to the environment; used for testing @@ -1141,7 +1142,11 @@ pub mod exec { } pub mod environment { - use clap::Subcommand; + use crate::model::environment::EnvironmentReference; + use clap::{ArgGroup, Args, Subcommand}; + use golem_common::base_model::environment_tool_grant::EnvironmentToolGrantId; + use golem_common::base_model::tool::ToolName; + use golem_common::base_model::tool_release::ToolReleaseId; #[derive(Debug, Subcommand)] pub enum EnvironmentSubcommand { @@ -1172,6 +1177,51 @@ pub mod environment { /// List application environments on the current server #[command(after_help = crate::command_examples::ENVIRONMENT_LIST)] List, + /// Manage tool grants + Tool { + #[command(subcommand)] + subcommand: EnvironmentToolSubcommand, + }, + } + + #[derive(Debug, Subcommand)] + pub enum EnvironmentToolSubcommand { + /// Grant a published tool release to an environment + Grant(EnvironmentToolGrantArgs), + /// List active tool grants in an environment + List { + /// Environment reference + environment: EnvironmentReference, + }, + /// Delete a tool grant + Delete { + /// Environment tool grant ID + grant_id: EnvironmentToolGrantId, + }, + /// Restore a deleted tool grant + Restore { + /// Environment tool grant ID + grant_id: EnvironmentToolGrantId, + }, + } + + #[derive(Debug, Args)] + #[command(group(ArgGroup::new("release").required(true).multiple(false).args(["release_id", "account"])))] + pub struct EnvironmentToolGrantArgs { + /// Environment reference + pub environment: EnvironmentReference, + /// Published tool release ID + #[arg(long)] + pub release_id: Option, + /// Publisher account email + #[arg(long, requires_all = ["name", "version"])] + pub account: Option, + /// Published tool name + #[arg(long, requires_all = ["account", "version"])] + pub name: Option, + /// Published tool version + #[arg(long, requires_all = ["account", "name"])] + pub version: Option, } } diff --git a/cli/golem-cli/src/command_handler/app/deploy_diff.rs b/cli/golem-cli/src/command_handler/app/deploy_diff.rs index b30661f330..e60bfe91e5 100644 --- a/cli/golem-cli/src/command_handler/app/deploy_diff.rs +++ b/cli/golem-cli/src/command_handler/app/deploy_diff.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::model::component::ComponentDeployProperties; +use crate::model::component::{ComponentDeployProperties, PendingRemoteInitialFile}; use crate::model::deploy::{ DeploymentDisplay, DeploymentDisplayContext, DeploymentDisplayMode, EnvironmentSetupPlan, }; @@ -32,14 +32,18 @@ use golem_common::model::domain_registration::Domain; use golem_common::model::environment::EnvironmentCurrentDeploymentView; use golem_common::model::http_api_deployment::HttpApiDeployment; use golem_common::model::mcp_deployment::McpDeployment; +use golem_common::model::tool::{RemoteToolDeployment, ToolName}; use golem_common::schema::agent::AgentTypeSchema; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use tracing::debug; #[derive(Debug)] pub struct DeployQuickDiff { pub environment: ResolvedEnvironmentIdentity, pub deployable_manifest_components: BTreeMap, + pub remote_tool_deployments: Vec, + pub published_tools: BTreeSet, + pub pending_remote_initial_files: Vec, pub deployable_manifest_http_api_deployments: BTreeMap, #[allow(dead_code)] @@ -80,6 +84,9 @@ pub enum DeployDiffKind { pub struct DeployDiff { pub environment: ResolvedEnvironmentIdentity, pub deployable_components: BTreeMap, + pub remote_tool_deployments: Vec, + pub published_tools: BTreeSet, + pub pending_remote_initial_files: Vec, pub deployable_http_api_deployments: BTreeMap, pub deployable_mcp_deployments: BTreeMap, pub diffable_local_deployment: diff::Deployment, @@ -107,6 +114,8 @@ impl DeployDiff { !self.diff.components.is_empty() || !self.diff.http_api_deployments.is_empty() || !self.diff.mcp_deployments.is_empty() + || !self.diff.remote_tools.is_empty() + || !self.diff.published_tools.is_empty() } pub fn has_environment_setup_entries_to_apply(&self) -> bool { @@ -131,6 +140,8 @@ impl DeployDiff { components: BTreeMap::new(), http_api_deployments: BTreeMap::new(), mcp_deployments: BTreeMap::new(), + remote_tools: BTreeMap::new(), + published_tools: BTreeMap::new(), } } diff --git a/cli/golem-cli/src/command_handler/app/mod.rs b/cli/golem-cli/src/command_handler/app/mod.rs index 51e93d580f..a5da0655ed 100644 --- a/cli/golem-cli/src/command_handler/app/mod.rs +++ b/cli/golem-cli/src/command_handler/app/mod.rs @@ -49,11 +49,13 @@ use crate::model::app::{ AppBuildStep, ApplicationComponentSelectMode, BuildConfig, CleanMode, DynamicHelpSections, WithSource, }; +use crate::model::component::{DeployableManifestComponents, PendingRemoteInitialFile}; use crate::model::config::{collect_unused_leaf_paths, value_at_path}; use crate::model::deploy::{ - DeployConfig, DeployError, DeployResult, DeploySummary, EnvironmentSetupPlan, PostDeployError, - PostDeployResult, PostDeploySummary, UpdateStagedComponentError, build_environment_setup_plan, - preferred_source_language_for_setup, + DeployConfig, DeployError, DeployResult, DeploySummary, EnvironmentSetupPlan, + EnvironmentToolGrantPlanAction, EnvironmentToolGrantPlanEntry, EnvironmentToolGrantPlanView, + PostDeployError, PostDeployResult, PostDeploySummary, UpdateStagedComponentError, + build_environment_setup_plan, preferred_source_language_for_setup, }; use crate::model::deploy::{DeployPlanView, log_unified_diff, log_unified_diff_for_path}; use crate::model::deploy::{DeploymentListView, DeploymentNewView}; @@ -65,10 +67,12 @@ use anyhow::{anyhow, bail}; use colored::Colorize; use futures_util::{StreamExt, TryStreamExt, stream}; use golem_client::api::{ - AgentSecretsClient, ApplicationClient, ComponentClient, EnvironmentClient, ResourcesClient, - RetryPoliciesClient, + AgentSecretsClient, ApplicationClient, ComponentClient, EnvironmentClient, + EnvironmentToolGrantsClient, ResourcesClient, RetryPoliciesClient, +}; +use golem_client::model::{ + ApplicationCreation, DeploymentCreation, DeploymentRollback, EnvironmentToolGrantReconciliation, }; -use golem_client::model::{ApplicationCreation, DeploymentCreation, DeploymentRollback}; use golem_common::model::account::AccountId; use golem_common::model::agent::schema_evolution::validate_schema_evolution; use golem_common::model::agent::{AgentConfigSource, AgentTypeName, DeployedRegisteredAgentType}; @@ -83,6 +87,10 @@ use golem_common::model::diff; use golem_common::model::diff::{Diffable, Hashable}; use golem_common::model::domain_registration::Domain; use golem_common::model::environment::EnvironmentId; +use golem_common::model::environment_tool_grant::{ + EnvironmentToolGrantCreation, EnvironmentToolGrantId, EnvironmentToolGrantWithDetails, +}; +use golem_common::model::tool_release::ToolReleaseReference; use golem_common::schema::schema_type::SchemaType; use itertools::Itertools; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -99,6 +107,111 @@ pub struct AppCommandHandler { ctx: Arc, } +struct ToolGrantReconciliationPlan { + creations: Vec, + deletions: Vec, + view: EnvironmentToolGrantPlanView, +} + +impl ToolGrantReconciliationPlan { + fn has_changes(&self) -> bool { + self.view.has_changes() + } +} + +fn tool_grant_matches_reference( + grant: &EnvironmentToolGrantWithDetails, + reference: &ToolReleaseReference, +) -> bool { + match reference { + ToolReleaseReference::ById(reference) => grant.release.id == reference.release_id, + ToolReleaseReference::ByCoordinates(reference) => { + grant.release_owner.email == reference.account + && grant.release.name == reference.name + && grant.release.version == reference.version + } + } +} + +fn tool_grant_plan_entry( + action: EnvironmentToolGrantPlanAction, + reference: &ToolReleaseReference, + grant_id: Option, +) -> EnvironmentToolGrantPlanEntry { + match reference { + ToolReleaseReference::ById(reference) => EnvironmentToolGrantPlanEntry { + action, + release_id: Some(reference.release_id), + account: None, + name: None, + version: None, + grant_id, + }, + ToolReleaseReference::ByCoordinates(reference) => EnvironmentToolGrantPlanEntry { + action, + release_id: None, + account: Some(reference.account.to_string()), + name: Some(reference.name.to_string()), + version: Some(reference.version.clone()), + grant_id, + }, + } +} + +fn build_tool_grant_reconciliation_plan( + desired: &[ToolReleaseReference], + current: &[EnvironmentToolGrantWithDetails], +) -> ToolGrantReconciliationPlan { + let mut creations = Vec::new(); + let mut deletions = Vec::new(); + let mut entries = Vec::new(); + + for reference in desired { + if !current + .iter() + .any(|grant| tool_grant_matches_reference(grant, reference)) + { + creations.push(reference.clone()); + entries.push(tool_grant_plan_entry( + EnvironmentToolGrantPlanAction::Create, + reference, + None, + )); + } + } + + for grant in current { + if desired + .iter() + .any(|reference| tool_grant_matches_reference(grant, reference)) + { + continue; + } + let action = if grant.grant.protected { + EnvironmentToolGrantPlanAction::RetainProtected + } else if grant.grant.automatic { + deletions.push(grant.grant.id); + EnvironmentToolGrantPlanAction::Delete + } else { + EnvironmentToolGrantPlanAction::RetainAdministratorManaged + }; + entries.push(EnvironmentToolGrantPlanEntry { + action, + release_id: Some(grant.release.id), + account: Some(grant.release_owner.email.to_string()), + name: Some(grant.release.name.to_string()), + version: Some(grant.release.version.clone()), + grant_id: Some(grant.grant.id), + }); + } + + ToolGrantReconciliationPlan { + creations, + deletions, + view: EnvironmentToolGrantPlanView { entries }, + } +} + impl AppCommandHandler { pub fn new(ctx: Arc) -> Self { Self { ctx } @@ -748,6 +861,51 @@ impl AppCommandHandler { .await .map_err(DeployError::PrepareError)?; + let tool_grant_plan = self + .plan_tool_grant_reconciliation(&environment) + .await + .map_err(DeployError::PrepareError)?; + let stage_requires_new_grants = config.stage && !tool_grant_plan.creations.is_empty(); + if !tool_grant_plan.view.entries.is_empty() && (!config.stage || stage_requires_new_grants) + { + log_action("Planning", "environment tool grant reconciliation"); + let _indent = self.ctx.log_handler().decorated_indent_primary(); + self.ctx + .log_handler() + .log_output(tool_grant_plan.view.clone()) + .map_err(DeployError::PrepareError)?; + } + if stage_requires_new_grants { + return Err(DeployError::PrepareError(anyhow!( + "Cannot stage this deployment because it requires new environment tool grants; run a normal deployment to reconcile grants" + ))); + } + if !config.stage { + self.validate_tool_grant_reconciliation(&environment, &tool_grant_plan) + .await + .map_err(DeployError::PrepareError)?; + } + if !config.stage && tool_grant_plan.has_changes() { + if config.plan { + return Ok(DeploySummary::PlanOk); + } + if !self + .ctx + .interactive_handler() + .confirm_deploy_by_plan( + &environment.application_name, + &environment.environment_name, + &self.ctx.selected_server_description(), + ) + .map_err(DeployError::PrepareError)? + { + return Err(DeployError::Cancelled); + } + self.apply_tool_grant_reconciliation(&environment, tool_grant_plan) + .await + .map_err(DeployError::PrepareError)?; + } + if !config.skip_build { self.build(&build_config, vec![], &ApplicationComponentSelectMode::All) .await @@ -1044,7 +1202,13 @@ impl AppCommandHandler { &self, environment: ResolvedEnvironmentIdentity, ) -> anyhow::Result { - let deployable_manifest_components = self + let DeployableManifestComponents { + components: deployable_manifest_components, + remote_tool_deployments, + diffable_remote_tool_deployments, + published_tools, + pending_remote_initial_files, + } = self .ctx .component_handler() .deployable_manifest_components(&environment) @@ -1121,6 +1285,8 @@ impl AppCommandHandler { components: diffable_local_components, http_api_deployments: diffable_local_http_api_deployments, mcp_deployments: diffable_local_mcp_deployments, + remote_tools: diffable_remote_tool_deployments, + published_tools: published_tools.iter().map(ToString::to_string).collect(), }; let local_deployment_hash = diffable_local_deployment.hash()?; @@ -1128,6 +1294,9 @@ impl AppCommandHandler { Ok(DeployQuickDiff { environment, deployable_manifest_components, + remote_tool_deployments, + published_tools, + pending_remote_initial_files, deployable_manifest_http_api_deployments, deployable_manifest_mcp_deployments, diffable_local_deployment, @@ -1183,6 +1352,9 @@ impl AppCommandHandler { Ok(DeployDiff { environment: deploy_quick_diff.environment, deployable_components: deploy_quick_diff.deployable_manifest_components, + remote_tool_deployments: deploy_quick_diff.remote_tool_deployments, + published_tools: deploy_quick_diff.published_tools, + pending_remote_initial_files: deploy_quick_diff.pending_remote_initial_files, deployable_http_api_deployments: deploy_quick_diff .deployable_manifest_http_api_deployments, deployable_mcp_deployments: deploy_quick_diff.deployable_manifest_mcp_deployments, @@ -1335,6 +1507,89 @@ impl AppCommandHandler { ) } + async fn plan_tool_grant_reconciliation( + &self, + environment: &ResolvedEnvironmentIdentity, + ) -> anyhow::Result { + let desired = { + let app_ctx = self.ctx.app_context_lock().await; + app_ctx + .some_or_err()? + .application() + .registry_tool_references() + .map(|(_, reference)| reference.clone()) + .collect::>() + .into_iter() + .map(|reference| reference.to_release_reference().map_err(anyhow::Error::msg)) + .collect::>>()? + }; + let current = self + .ctx + .golem_clients() + .await? + .environment_tool_grants + .list_environment_tool_grants(&environment.environment_id.0) + .await + .map_service_error()? + .values; + + Ok(build_tool_grant_reconciliation_plan(&desired, ¤t)) + } + + async fn validate_tool_grant_reconciliation( + &self, + environment: &ResolvedEnvironmentIdentity, + plan: &ToolGrantReconciliationPlan, + ) -> anyhow::Result<()> { + if plan.has_changes() { + self.ctx + .golem_clients() + .await? + .environment_tool_grants + .validate_automatic_environment_tool_grant_reconciliation( + &environment.environment_id.0, + &EnvironmentToolGrantReconciliation { + creations: plan + .creations + .iter() + .cloned() + .map(|release| EnvironmentToolGrantCreation { release }) + .collect(), + deletions: plan.deletions.iter().map(|grant_id| grant_id.0).collect(), + }, + ) + .await + .map_service_error()?; + } + Ok(()) + } + + async fn apply_tool_grant_reconciliation( + &self, + environment: &ResolvedEnvironmentIdentity, + plan: ToolGrantReconciliationPlan, + ) -> anyhow::Result<()> { + let clients = self.ctx.golem_clients().await?; + for release in plan.creations { + clients + .environment_tool_grants + .create_automatic_environment_tool_grant( + &environment.environment_id.0, + &EnvironmentToolGrantCreation { release }, + ) + .await + .map_service_error()?; + } + for grant_id in plan.deletions { + clients + .environment_tool_grants + .delete_automatic_environment_tool_grant(&grant_id.0) + .await + .map_service_error()?; + } + Ok(()) + } + async fn collect_deploy_diff_details( &self, kind: DeployDiffKind, @@ -2132,6 +2387,12 @@ impl AppCommandHandler { log_action("Deploying", "staged changes to the environment"); + self.upload_remote_initial_files( + &deploy_diff.environment.environment_id, + &deploy_diff.pending_remote_initial_files, + ) + .await?; + let mut reset_fallback_applied = false; let mut replace_incompatible_agent_secrets = false; let result = loop { @@ -2143,6 +2404,8 @@ impl AppCommandHandler { current_revision: deploy_diff.current_deployment_revision(), expected_deployment_hash: deploy_diff.local_deployment_hash, version: deployment_version.clone(), + publish_tools: deploy_diff.published_tools.iter().cloned().collect(), + remote_tools: deploy_diff.remote_tool_deployments.clone(), agent_secret_defaults: if replace_incompatible_agent_secrets { let mut defaults = environment_setup.agent_secret_defaults.clone(); defaults.extend( @@ -2210,6 +2473,38 @@ impl AppCommandHandler { Ok(result) } + async fn upload_remote_initial_files( + &self, + environment_id: &EnvironmentId, + files: &[PendingRemoteInitialFile], + ) -> anyhow::Result<()> { + if files.is_empty() { + return Ok(()); + } + + let clients = self.ctx.golem_clients().await?; + for file in files { + let uploaded = clients + .environment + .upload_environment_initial_agent_file(&environment_id.0, file.content.clone()) + .await + .map_service_error()?; + let uploaded_hash = + golem_common::model::agent::AgentFileContentHash(uploaded.content_hash.parse()?); + if uploaded_hash != file.content_hash || uploaded.size != file.size { + bail!( + "Environment initial file upload returned unexpected identity: expected {} ({} bytes), got {} ({} bytes)", + file.content_hash, + file.size, + uploaded_hash, + uploaded.size, + ); + } + } + + Ok(()) + } + async fn rollback_environment( &self, rollback_diff: &RollbackDiff, @@ -2391,16 +2686,67 @@ impl AppCommandHandler { ) -> anyhow::Result<()> { self.must_select_components(component_names, default_component_select_mode) .await?; + let requires_registry_metadata = { + let app_ctx = self.ctx.app_context_lock().await; + app_ctx + .some_or_err()? + .application() + .requires_registry_bridge_metadata() + }; + let effective_build_config = + if requires_registry_metadata && build_config.registry_tool_grants.is_empty() { + let environment = self + .ctx + .environment_handler() + .resolve_environment(EnvironmentResolveMode::ManifestOnly) + .await?; + let mut plan = self.plan_tool_grant_reconciliation(&environment).await?; + plan.deletions.clear(); + plan.view + .entries + .retain(|entry| entry.action == EnvironmentToolGrantPlanAction::Create); + self.validate_tool_grant_reconciliation(&environment, &plan) + .await?; + if plan.has_changes() { + log_action("Planning", "environment tool grants required by the build"); + let _indent = self.ctx.log_handler().decorated_indent_primary(); + self.ctx.log_handler().log_output(plan.view.clone())?; + if !self + .ctx + .interactive_handler() + .confirm_tool_grant_plan_apply()? + { + bail!(NonSuccessfulExit); + } + self.apply_tool_grant_reconciliation(&environment, plan) + .await?; + } + let grants = self + .ctx + .golem_clients() + .await? + .environment_tool_grants + .list_environment_tool_grants(&environment.environment_id.0) + .await + .map_service_error()? + .values; + build_config.clone().with_registry_tool_grants(grants) + } else { + build_config.clone() + }; let app_ctx = self.ctx.app_context_lock().await; let app_ctx = app_ctx.some_or_err()?; // NOTE: dependency checks are done here, as they are interactive, and they modify // the projects, tool checks are done as part of app_ctx.build - if build_config.should_run_step(AppBuildStep::Check) { - self.plan_and_apply_dependency_fixes(&BuildContext::new(app_ctx, build_config))?; + if effective_build_config.should_run_step(AppBuildStep::Check) { + self.plan_and_apply_dependency_fixes(&BuildContext::new( + app_ctx, + &effective_build_config, + ))?; } - app_ctx.build(build_config).await + app_ctx.build(&effective_build_config).await } fn plan_and_apply_dependency_fixes(&self, build_ctx: &BuildContext<'_>) -> anyhow::Result<()> { @@ -2902,8 +3248,23 @@ fn duplicate_component_matches(found: &[Match]) -> Vec<(String, Vec)> { #[cfg(test)] mod tests { - use super::duplicate_component_matches; + use super::{build_tool_grant_reconciliation_plan, duplicate_component_matches}; use crate::fuzzy::Match; + use chrono::Utc; + use golem_common::model::account::{AccountEmail, AccountId, AccountSummary}; + use golem_common::model::diff::Hash; + use golem_common::model::environment::EnvironmentId; + use golem_common::model::environment_tool_grant::{ + EnvironmentToolGrant, EnvironmentToolGrantId, EnvironmentToolGrantLifecycle, + EnvironmentToolGrantWithDetails, + }; + use golem_common::model::tool::ToolName; + use golem_common::model::tool_release::{ + ToolReleaseByCoordinates, ToolReleaseById, ToolReleaseId, ToolReleaseMetadata, + ToolReleaseReference, + }; + use golem_common::schema::SchemaGraph; + use golem_common::schema::tool::{CommandNode, CommandTree, Doc, Globals, Tool}; use test_r::test; fn matched(option: &str, pattern: &str) -> Match { @@ -2934,4 +3295,111 @@ mod tests { let found = vec![matched("a", "a"), matched("b", "b")]; assert!(duplicate_component_matches(&found).is_empty()); } + + #[test] + fn tool_grant_reconciliation_is_idempotent_for_an_existing_exact_grant() { + let current = grant("search", "1.2.0", "publisher@example.com", false, true); + let desired = vec![ToolReleaseReference::ById(ToolReleaseById { + release_id: current.release.id, + })]; + + let plan = build_tool_grant_reconciliation_plan(&desired, &[current]); + + assert!(plan.creations.is_empty()); + assert!(plan.deletions.is_empty()); + assert!(plan.view.entries.is_empty()); + } + + #[test] + fn tool_grant_reconciliation_deletes_automatic_and_retains_protected_and_administrator_managed() + { + let automatic = grant("old-search", "1.0.0", "old@example.com", false, true); + let protected = grant("host-clock", "1.0.0", "system@example.com", true, true); + let administrator_managed = + grant("admin-tool", "1.0.0", "publisher@example.com", false, false); + let desired = vec![ToolReleaseReference::ByCoordinates( + ToolReleaseByCoordinates { + account: AccountEmail::new("publisher@example.com"), + name: ToolName::try_from("search").unwrap(), + version: "1.2.0".to_string(), + }, + )]; + + let plan = build_tool_grant_reconciliation_plan( + &desired, + &[ + automatic.clone(), + protected.clone(), + administrator_managed.clone(), + ], + ); + + assert_eq!(plan.creations, desired); + assert_eq!(plan.deletions, vec![automatic.grant.id]); + assert_eq!(plan.view.entries.len(), 4); + assert!(plan.view.entries.iter().any(|entry| { + entry.action == crate::model::deploy::EnvironmentToolGrantPlanAction::RetainProtected + && entry.grant_id == Some(protected.grant.id) + })); + assert!(plan.view.entries.iter().any(|entry| { + entry.action + == crate::model::deploy::EnvironmentToolGrantPlanAction::RetainAdministratorManaged + && entry.grant_id == Some(administrator_managed.grant.id) + })); + } + + fn grant( + name: &str, + version: &str, + owner_email: &str, + protected: bool, + automatic: bool, + ) -> EnvironmentToolGrantWithDetails { + let release_id = ToolReleaseId::new(); + let actor = AccountId::new(); + let now = Utc::now(); + let name = ToolName::try_from(name).unwrap(); + let definition = Tool { + version: version.to_string(), + commands: CommandTree { + nodes: vec![CommandNode { + name: name.to_string(), + aliases: Vec::new(), + doc: Doc::default(), + globals: Globals::default(), + subcommands: Vec::new(), + body: None, + }], + }, + schema: SchemaGraph::empty(), + }; + EnvironmentToolGrantWithDetails { + grant: EnvironmentToolGrant { + id: EnvironmentToolGrantId::new(), + environment_id: EnvironmentId::new(), + tool_release_id: release_id, + protected, + automatic, + lifecycle: EnvironmentToolGrantLifecycle::Active, + created_at: now, + created_by: actor, + state_changed_at: now, + state_changed_by: actor, + }, + release: ToolReleaseMetadata { + id: release_id, + name, + version: version.to_string(), + definition, + metadata_version: "0.1.0".to_string(), + metadata_digest: Hash::empty(), + source_digest: Hash::empty(), + }, + release_owner: AccountSummary { + id: AccountId::new(), + name: "publisher".to_string(), + email: AccountEmail::new(owner_email), + }, + } + } } diff --git a/cli/golem-cli/src/command_handler/component/ifs.rs b/cli/golem-cli/src/command_handler/component/ifs.rs index dd4cedf594..851ceb7059 100644 --- a/cli/golem-cli/src/command_handler/component/ifs.rs +++ b/cli/golem-cli/src/command_handler/component/ifs.rs @@ -37,6 +37,11 @@ struct LoadedFile { source: Url, } +pub struct LoadedInitialFile { + pub content: Vec, + pub target: CanonicalFilePathWithPermissions, +} + #[derive(Debug, Clone)] pub struct HashedFile { pub hash: blake3::Hash, @@ -226,6 +231,29 @@ impl IfsFileManager { Self { client } } + pub async fn load_initial_files( + &self, + component_files: &[InitialComponentFile], + ) -> anyhow::Result> { + let loader = FileLoader { + client: self.client.clone(), + }; + let component_files = expand_component_files(component_files).await?; + let mut result = Vec::new(); + for component_file in &component_files { + result.extend( + self.process_component_file(&loader, component_file) + .await? + .into_iter() + .map(|loaded| LoadedInitialFile { + content: loaded.content, + target: component_file.target.clone(), + }), + ); + } + Ok(result) + } + pub async fn build_files_archive( &self, component_files: &[InitialComponentFile], diff --git a/cli/golem-cli/src/command_handler/component/mod.rs b/cli/golem-cli/src/command_handler/component/mod.rs index 107b2fb3d4..500081e3dc 100644 --- a/cli/golem-cli/src/command_handler/component/mod.rs +++ b/cli/golem-cli/src/command_handler/component/mod.rs @@ -34,7 +34,8 @@ use crate::model::app_raw; use crate::model::cascade::property::tool_bindings::ToolBindingState; use crate::model::component::{ AgentTypeManifestProvisionConfig, ComponentDeployProperties, ComponentNameMatchKind, - ComponentRevisionSelection, ComponentView, SelectedComponents, ToolManifestDeploymentConfig, + ComponentRevisionSelection, ComponentView, DeployableManifestComponents, + PendingRemoteInitialFile, SelectedComponents, ToolManifestDeploymentConfig, ToolManifestProvisionConfig, initial_permission_from_manifest_card, initial_permission_recipient_context, }; @@ -57,21 +58,29 @@ use crate::model::tool_deployment::{ use crate::validation::ValidationBuilder; use anyhow::{Context as AnyhowContext, anyhow, bail}; use futures_util::future::OptionFuture; -use golem_client::api::ComponentClient; +use golem_client::api::{ComponentClient, EnvironmentToolGrantsClient}; use golem_client::model::{ComponentCreation, ComponentDto}; use golem_common::cache::SimpleCache; use golem_common::model::account::AccountEmail; +use golem_common::model::agent::AgentFileContentHash; use golem_common::model::agent::{AgentConfigSource, AgentTypeName}; use golem_common::model::agent_secret::CanonicalAgentSecretPath; use golem_common::model::application::ApplicationName; use golem_common::model::component::{ - AgentConfigEntryDto, ComponentId, ComponentName, ComponentRevision, ComponentUpdate, + AgentConfigEntryDto, AgentFilePath, ComponentId, ComponentName, ComponentRevision, + ComponentUpdate, InitialAgentFile, InstalledPlugin, PluginPriority, }; use golem_common::model::deployment::DeploymentPlanComponentEntry; use golem_common::model::diff; use golem_common::model::environment::EnvironmentName; +use golem_common::model::environment_plugin_grant::EnvironmentPluginGrantWithDetails; use golem_common::model::json::NormalizedJsonValue; -use golem_common::model::tool::{SecretKeyScope, ToolBindingInput, ToolName}; +use golem_common::model::plugin_registration::PluginSpecDto; +use golem_common::model::tool::{ + RemoteToolDeployment, SecretKeyScope, ToolBindingInput, ToolFilesystemAccess, ToolName, + ToolProvisionConfig, +}; +use golem_common::model::tool_release::{ToolReleaseById, ToolReleaseReference}; use golem_common::schema::agent::AgentTypeSchema; use golem_common::schema::tool::Tool; use golem_common::schema::tool::validation::validate_tool; @@ -801,7 +810,7 @@ impl ComponentCommandHandler { pub async fn deployable_manifest_components( &self, environment: &ResolvedEnvironmentIdentity, - ) -> anyhow::Result> { + ) -> anyhow::Result { let (component_names, declared_agents) = { let app_ctx = self.ctx.app_context_lock().await; let app = app_ctx.some_or_err()?; @@ -835,14 +844,26 @@ impl ComponentCommandHandler { .filter(|declared_agent| !exported_agents.contains_key(declared_agent)) .collect::>(); - self.resolve_manifest_tool_deployments( - environment, - &mut components, - &unknown_declared_agents, - ) - .await?; + let ( + remote_tool_deployments, + diffable_remote_tool_deployments, + published_tools, + pending_remote_initial_files, + ) = self + .resolve_manifest_tool_deployments( + environment, + &mut components, + &unknown_declared_agents, + ) + .await?; - Ok(components) + Ok(DeployableManifestComponents { + components, + remote_tool_deployments, + diffable_remote_tool_deployments, + published_tools, + pending_remote_initial_files, + }) } async fn resolve_manifest_tool_deployments( @@ -850,7 +871,26 @@ impl ComponentCommandHandler { environment: &ResolvedEnvironmentIdentity, components: &mut BTreeMap, unknown_declared_agents: &BTreeSet, - ) -> anyhow::Result<()> { + ) -> anyhow::Result<( + Vec, + BTreeMap>, + BTreeSet, + Vec, + )> { + let registry_grants = self + .ctx + .golem_clients() + .await? + .environment_tool_grants + .list_environment_tool_grants(&environment.environment_id.0) + .await + .map_service_error()? + .values; + let plugin_grants = self + .ctx + .environment_handler() + .plugin_grants(environment) + .await?; let app_ctx = self.ctx.app_context_lock().await; let app = app_ctx.some_or_err()?.application(); let mut issues = Vec::new(); @@ -918,7 +958,66 @@ impl ComponentCommandHandler { } for (tool_name, declaration) in app.tool_declarations() { - if !implementations.contains_key(tool_name) { + let Some(registry_source) = declaration.value.source.as_ref() else { + continue; + }; + let grant = registry_grants + .iter() + .find(|grant| match ®istry_source.registry { + app_raw::RegistrySubject::ById(reference) => { + grant.release.id == reference.release_id + } + app_raw::RegistrySubject::ByCoordinates(reference) => { + grant.release_owner.email.as_str() == reference.account + && grant.release.name.as_str() == reference.name + && grant.release.version == reference.version + } + }); + let Some(grant) = grant else { + issues.push(ToolValidationIssue::error( + ToolValidationPhase::DeclarationDiscoveryIdentity, + ToolValidationCode::RegistryReleaseNotFound, + ToolEntityPath::tool(tool_name, "tools.source.registry"), + Some(declaration.source.clone()), + "Registry tool release was not found among the active grants for this environment", + )); + continue; + }; + if &grant.release.name != tool_name { + issues.push(ToolValidationIssue::error( + ToolValidationPhase::DeclarationDiscoveryIdentity, + ToolValidationCode::InvalidName, + ToolEntityPath::tool(tool_name, "tools.source.registry"), + Some(declaration.source.clone()), + format!( + "Declaration key must equal resolved registry tool name '{}'", + grant.release.name + ), + )); + } + if let Err(errors) = validate_tool(&grant.release.definition) { + issues.push(ToolValidationIssue::error( + ToolValidationPhase::StructuralMetadata, + ToolValidationCode::InvalidDefinition, + ToolEntityPath::tool(tool_name, "definition"), + Some(declaration.source.clone()), + errors.into_iter().map(|error| error.to_string()).join("; "), + )); + continue; + } + implementations.entry(tool_name.clone()).or_default().push( + DiscoveredToolImplementation { + definition: grant.release.definition.clone(), + implementation: ToolImplementationSource::Registry { + grant: Box::new(grant.clone()), + }, + diagnostic_source: Some(declaration.source.clone()), + }, + ); + } + + for (tool_name, declaration) in app.tool_declarations() { + if declaration.value.source.is_none() && !implementations.contains_key(tool_name) { issues.push(ToolValidationIssue::error( ToolValidationPhase::DeclarationDiscoveryIdentity, ToolValidationCode::MissingImplementation, @@ -951,8 +1050,11 @@ impl ComponentCommandHandler { "Tool is exported by multiple components: {}", sources .iter() - .filter_map(|source| source.implementation.local_component_name()) - .map(ComponentName::as_str) + .map(|source| match &source.implementation { + ToolImplementationSource::Component { component_name } => + component_name.as_str(), + ToolImplementationSource::Registry { .. } => "registry", + }) .join(", ") ), )); @@ -997,9 +1099,12 @@ impl ComponentCommandHandler { } } - let owner = &environment.server_environment.owner_account_email; + let local_owner = &environment.server_environment.owner_account_email; let mut configs_by_component = BTreeMap::>::new(); + let mut remote_tool_deployments = Vec::new(); + let mut diffable_remote_tool_deployments = BTreeMap::new(); + let mut pending_remote_initial_files = Vec::new(); for (tool_name, sources) in &implementations { let Some(source) = sources.as_slice().first() else { @@ -1008,10 +1113,12 @@ impl ComponentCommandHandler { if sources.len() != 1 || !app.tool_declarations().contains_key(tool_name) { continue; } - let Some(component_name) = source.implementation.local_component_name() else { - continue; - }; let definition = &source.definition; + let owner = source + .implementation + .registry_grant() + .map(|grant| &grant.release_owner.email) + .unwrap_or(local_owner); let declaration_source = app .tool_declarations() .get(tool_name) @@ -1079,7 +1186,10 @@ impl ComponentCommandHandler { } } - let provision = match app.resolve_tool_provision(tool_name, component_name) { + let provision = match match source.implementation.local_component_name() { + Some(component_name) => app.resolve_tool_provision(tool_name, component_name), + None => app.resolve_remote_tool_provision(tool_name), + } { Ok(provision) => provision, Err(error) => { issues.push(ToolValidationIssue::error( @@ -1111,16 +1221,24 @@ impl ComponentCommandHandler { )); } } + let materialization_component = source + .implementation + .local_component_name() + .cloned() + .unwrap_or_else(|| ComponentName(format!("registry-tool-{tool_name}"))); let config = resolve_json_value( - component_name, + &materialization_component, "tool config", provision .properties .config .unwrap_or_else(|| serde_json::json!({})), ); - let env = resolve_env_vars(component_name, &provision.properties.env); - let plugins = resolve_plugin_parameters(component_name, &provision.properties.plugins); + let env = resolve_env_vars(&materialization_component, &provision.properties.env); + let plugins = resolve_plugin_parameters( + &materialization_component, + &provision.properties.plugins, + ); let (config, env, plugins) = match (config, env, plugins, files_valid) { (Ok(config), Ok(env), Ok(plugins), true) => (config, env, plugins), (config, env, plugins, _) => { @@ -1140,22 +1258,90 @@ impl ComponentCommandHandler { } }; - configs_by_component - .entry(component_name.clone()) - .or_default() - .insert( - tool_name.clone(), - ToolManifestDeploymentConfig { - provision: ToolManifestProvisionConfig { - config: NormalizedJsonValue::new(config), - env, - files: provision.properties.files, - plugins, - }, - environment_binding, - agent_bindings, - }, + let manifest_config = ToolManifestDeploymentConfig { + provision: ToolManifestProvisionConfig { + config: NormalizedJsonValue::new(config), + env, + files: provision.properties.files, + plugins, + }, + environment_binding, + agent_bindings, + }; + + if let Some(component_name) = source.implementation.local_component_name() { + configs_by_component + .entry(component_name.clone()) + .or_default() + .insert(tool_name.clone(), manifest_config); + } else if let Some(grant) = source.implementation.registry_grant() { + let (provision, pending_files) = self + .materialize_remote_tool_provision( + tool_name, + &manifest_config.provision, + &plugin_grants, + ) + .await?; + pending_remote_initial_files.extend(pending_files); + let request = RemoteToolDeployment { + name: tool_name.clone(), + release: ToolReleaseReference::ById(ToolReleaseById { + release_id: grant.release.id, + }), + provision: provision.clone(), + environment_binding: manifest_config.environment_binding.clone(), + agent_bindings: manifest_config.agent_bindings.clone(), + }; + let bindings = effective_remote_tool_bindings( + &agent_components, + manifest_config.environment_binding.as_ref(), + &manifest_config.agent_bindings, + ); + diffable_remote_tool_deployments.insert( + tool_name.to_string(), + diff::RemoteToolDeployment { + release_id: grant.release.id, + version: grant.release.version.clone(), + source_digest: grant.release.source_digest, + owner_account_id: grant.release_owner.id, + owner_account_email: grant.release_owner.email.clone(), + metadata_version: grant.release.metadata_version.clone(), + metadata_digest: grant.release.metadata_digest, + provision, + bindings, + } + .into(), ); + remote_tool_deployments.push(request); + } + } + + let published_tools = app + .selected_published_tools() + .map(ToolName::try_from) + .collect::, _>>() + .map_err(anyhow::Error::msg)?; + for tool_name in &published_tools { + let local_count = implementations + .get(tool_name) + .into_iter() + .flatten() + .filter(|implementation| { + implementation + .implementation + .local_component_name() + .is_some() + }) + .count(); + if local_count != 1 { + issues.push(ToolValidationIssue::error( + ToolValidationPhase::DeclarationDiscoveryIdentity, + ToolValidationCode::MissingImplementation, + ToolEntityPath::tool(tool_name, "environments.publishTools"), + app.selected_environment_source().map(std::path::Path::to_path_buf), + format!("Published tool must resolve to exactly one local implementation, found {local_count}"), + )); + } } let mut validation = ValidationBuilder::new(); @@ -1173,7 +1359,99 @@ impl ComponentCommandHandler { .tool_deployment_configs = tool_configs; } - Ok(()) + Ok(( + remote_tool_deployments, + diffable_remote_tool_deployments, + published_tools, + pending_remote_initial_files, + )) + } + + async fn materialize_remote_tool_provision( + &self, + tool_name: &ToolName, + provision: &ToolManifestProvisionConfig, + plugin_grants: &HashMap, + ) -> anyhow::Result<(ToolProvisionConfig, Vec)> { + let plugins = provision + .plugins + .iter() + .enumerate() + .map(|(index, plugin)| { + let grant = plugin_grants + .get(&PluginNameAndVersion { + name: plugin.name.clone(), + version: plugin.version.clone(), + }) + .with_context(|| { + format!( + "Plugin {}/{} required by remote tool {} is not granted to this environment", + plugin.name, plugin.version, tool_name + ) + })?; + let PluginSpecDto::OplogProcessor(spec) = &grant.plugin.spec; + Ok(InstalledPlugin { + environment_plugin_grant_id: grant.id, + priority: PluginPriority(index as i32), + parameters: plugin.parameters.clone().into_iter().collect(), + plugin_registration_id: grant.plugin.id, + plugin_name: grant.plugin.name.clone(), + plugin_version: grant.plugin.version.clone(), + oplog_processor_component_id: Some(spec.component_id), + oplog_processor_component_revision: Some(spec.component_revision), + }) + }) + .collect::>>()?; + + let files = provision + .files + .iter() + .map(|file| { + crate::model::app::InitialComponentFileSource::new( + &file.file.source_path, + &file.source, + ) + .map(|source| crate::model::app::InitialComponentFile { + source, + target: crate::model::app::CanonicalFilePathWithPermissions { + path: file.file.target_path.clone(), + permissions: file.file.permissions.unwrap_or_default(), + }, + }) + .map_err(anyhow::Error::msg) + }) + .collect::>>()?; + let loaded_files = IfsFileManager::new(self.ctx.file_download_client().clone()) + .load_initial_files(&files) + .await?; + let mut initial_files = Vec::with_capacity(loaded_files.len()); + let mut pending_files = Vec::with_capacity(loaded_files.len()); + for file in loaded_files { + let content_hash = AgentFileContentHash(diff::Hash::new(blake3::hash(&file.content))); + let size = u64::try_from(file.content.len())?; + initial_files.push(InitialAgentFile { + content_hash, + path: AgentFilePath::from_abs_str(file.target.path.as_abs_str()) + .map_err(anyhow::Error::msg)?, + permissions: file.target.permissions, + size, + }); + pending_files.push(PendingRemoteInitialFile { + content: file.content, + content_hash, + size, + }); + } + + Ok(( + ToolProvisionConfig { + config: provision.config.clone(), + env: provision.env.clone(), + plugins, + files: initial_files, + }, + pending_files, + )) } pub async fn component_deploy_properties( @@ -1974,6 +2252,62 @@ fn validate_effective_tool_binding( } } +fn effective_remote_tool_bindings( + agent_components: &BTreeMap, + environment: Option<&ToolBindingInput>, + agents: &BTreeMap, +) -> BTreeMap { + agent_components + .keys() + .filter_map(|agent_name| { + let agent = agents.get(agent_name); + let (parameters, readable, requested_revealable) = match (environment, agent) { + (None, None) => return None, + (Some(binding), None) | (None, Some(binding)) => ( + binding.parameters.clone(), + binding.secret_keys_readable.clone(), + binding.secret_keys_revealable.clone(), + ), + (Some(environment), Some(agent)) => { + let mut parameters = environment + .parameters + .0 + .as_object() + .expect("validated tool binding parameters are objects") + .clone(); + parameters.extend( + agent + .parameters + .0 + .as_object() + .expect("validated tool binding parameters are objects") + .clone(), + ); + ( + NormalizedJsonValue::new(serde_json::Value::Object(parameters)), + environment + .secret_keys_readable + .intersection(&agent.secret_keys_readable), + environment + .secret_keys_revealable + .intersection(&agent.secret_keys_revealable), + ) + } + }; + let revealable = requested_revealable.intersection(&readable); + Some(( + agent_name.clone(), + diff::EffectiveToolBinding { + parameters, + secret_keys_readable: readable, + secret_keys_revealable: revealable, + filesystem_access: ToolFilesystemAccess::Unset, + }, + )) + }) + .collect() +} + fn resolve_secret_scope( issues: &mut Vec, layers: &[app_raw::ManifestSecretKeyScope], @@ -2313,7 +2647,9 @@ fn collect_unused_agent_config_paths( #[cfg(test)] mod tool_binding_tests { - use super::{resolve_secret_scope, validate_effective_tool_binding}; + use super::{ + effective_remote_tool_bindings, resolve_secret_scope, validate_effective_tool_binding, + }; use crate::model::app_raw::ManifestSecretKeyScope; use crate::model::tool_deployment::{ ToolEntityPath, ToolValidationCode, ToolValidationSeverity, @@ -2321,9 +2657,10 @@ mod tool_binding_tests { use golem_common::model::account::AccountEmail; use golem_common::model::agent::AgentTypeName; use golem_common::model::agent_secret::CanonicalAgentSecretPath; + use golem_common::model::component::ComponentName; use golem_common::model::json::NormalizedJsonValue; use golem_common::model::tool::{SecretKeyScope, ToolBindingInput, ToolName}; - use std::collections::BTreeSet; + use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use test_r::test; @@ -2410,4 +2747,29 @@ mod tool_binding_tests { assert_eq!(issues[0].severity, ToolValidationSeverity::Warning); assert_eq!(issues[0].source.as_deref(), Some(Path::new("agents.yaml"))); } + + #[test] + fn remote_diff_binding_matches_server_parameter_merge_and_scope_intersection() { + let agent_name = AgentTypeName("CoderAgent".to_string()); + let mut environment = binding(SecretKeyScope::All, keys(&["github", "gitlab"])); + environment.parameters = + NormalizedJsonValue::new(serde_json::json!({"shared": "environment", "base": 1})); + let mut agent = binding(keys(&["github"]), SecretKeyScope::All); + agent.parameters = + NormalizedJsonValue::new(serde_json::json!({"shared": "agent", "extra": 2})); + + let bindings = effective_remote_tool_bindings( + &BTreeMap::from([(agent_name.clone(), ComponentName("component".to_string()))]), + Some(&environment), + &BTreeMap::from([(agent_name.clone(), agent)]), + ); + let effective = bindings.get(&agent_name).unwrap(); + + assert_eq!( + effective.parameters.0, + serde_json::json!({"base": 1, "extra": 2, "shared": "agent"}) + ); + assert_eq!(effective.secret_keys_readable, keys(&["github"])); + assert_eq!(effective.secret_keys_revealable, keys(&["github"])); + } } diff --git a/cli/golem-cli/src/command_handler/environment.rs b/cli/golem-cli/src/command_handler/environment.rs index a12c4d449e..18ed7fc097 100644 --- a/cli/golem-cli/src/command_handler/environment.rs +++ b/cli/golem-cli/src/command_handler/environment.rs @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::command::environment::EnvironmentSubcommand; +use crate::command::environment::{ + EnvironmentSubcommand, EnvironmentToolGrantArgs, EnvironmentToolSubcommand, +}; use crate::command_handler::Handlers; use crate::context::Context; use crate::error::HintError; @@ -25,14 +27,21 @@ use crate::log::{ use crate::model::deploy::log_unified_diff; use crate::model::environment::{EnvironmentListView, EnvironmentSyncDeploymentOptionsResult}; use crate::model::environment::{ - EnvironmentReference, EnvironmentResolveMode, ResolvedEnvironmentIdentity, + EnvironmentReference, EnvironmentResolveMode, EnvironmentToolGrantCreateView, + EnvironmentToolGrantDeleteView, EnvironmentToolGrantListView, EnvironmentToolGrantRestoreView, + EnvironmentToolGrantView, ResolvedEnvironmentIdentity, }; use crate::model::help::EnvironmentNameHelp; use crate::model::plugin::PluginNameAndVersion; use crate::model::text_format::log_text_view; use anyhow::{anyhow, bail}; -use golem_client::api::{EnvironmentClient, MeClient}; +use golem_client::api::{EnvironmentClient, EnvironmentToolGrantsClient, MeClient}; use golem_client::model::{EnvironmentCreation, EnvironmentPluginGrantWithDetails}; +use golem_common::base_model::account::AccountEmail; +use golem_common::base_model::environment_tool_grant::EnvironmentToolGrantCreation; +use golem_common::base_model::tool_release::{ + ToolReleaseByCoordinates, ToolReleaseById, ToolReleaseReference, +}; use golem_common::cache::SimpleCache; use golem_common::model::application::ApplicationId; use golem_common::model::diff; @@ -57,6 +66,128 @@ impl EnvironmentCommandHandler { } EnvironmentSubcommand::List => self.cmd_list().await, + EnvironmentSubcommand::Tool { subcommand } => self.cmd_tool(subcommand).await, + } + } + + async fn cmd_tool(&self, subcommand: EnvironmentToolSubcommand) -> anyhow::Result<()> { + match subcommand { + EnvironmentToolSubcommand::Grant(args) => self.cmd_tool_grant(args).await, + EnvironmentToolSubcommand::List { environment } => { + let environment = self.resolve_tool_environment(&environment).await?; + let grants = self + .ctx + .golem_clients() + .await? + .environment_tool_grants + .list_environment_tool_grants(&environment.environment_id.0) + .await + .map_service_error()? + .values + .into_iter() + .map(Into::into) + .collect(); + self.ctx + .log_handler() + .log_output(EnvironmentToolGrantListView { grants })?; + Ok(()) + } + EnvironmentToolSubcommand::Delete { grant_id } => { + self.ctx + .golem_clients() + .await? + .environment_tool_grants + .delete_environment_tool_grant(&grant_id.0) + .await + .map_service_error()?; + self.ctx + .log_handler() + .log_output(EnvironmentToolGrantDeleteView { grant_id })?; + Ok(()) + } + EnvironmentToolSubcommand::Restore { grant_id } => { + let grant = self + .ctx + .golem_clients() + .await? + .environment_tool_grants + .restore_environment_tool_grant(&grant_id.0) + .await + .map_service_error()?; + self.ctx + .log_handler() + .log_output(EnvironmentToolGrantRestoreView { + grant: grant.into(), + })?; + Ok(()) + } + } + } + + async fn cmd_tool_grant(&self, args: EnvironmentToolGrantArgs) -> anyhow::Result<()> { + let environment = self.resolve_tool_environment(&args.environment).await?; + let release = match (args.release_id, args.account, args.name, args.version) { + (Some(release_id), None, None, None) => { + ToolReleaseReference::ById(ToolReleaseById { release_id }) + } + (None, Some(account), Some(name), Some(version)) => { + ToolReleaseReference::ByCoordinates(ToolReleaseByCoordinates { + account: AccountEmail::new(account), + name, + version, + }) + } + _ => unreachable!("clap validates the tool release reference"), + }; + let grant = self + .ctx + .golem_clients() + .await? + .environment_tool_grants + .create_environment_tool_grant( + &environment.environment_id.0, + &EnvironmentToolGrantCreation { release }, + ) + .await + .map_service_error()?; + self.ctx + .log_handler() + .log_output(EnvironmentToolGrantCreateView { + grant: EnvironmentToolGrantView::from(grant), + })?; + Ok(()) + } + + async fn resolve_tool_environment( + &self, + reference: &EnvironmentReference, + ) -> anyhow::Result { + if let EnvironmentReference::Environment { environment_name } = reference { + let manifest_environment = self.ctx.manifest_environment().ok_or_else(|| { + anyhow!("An application manifest is required for an environment name reference") + })?; + let summary = self + .ctx + .golem_clients() + .await? + .me + .list_visible_environments( + manifest_environment.environment.account.as_deref(), + Some(&manifest_environment.application_name.0), + Some(&environment_name.0), + ) + .await + .map_service_error()? + .values + .pop() + .ok_or_else(|| anyhow!("Environment {reference} not found"))?; + self.ensure_diff_model_version_compatible(ResolvedEnvironmentIdentity::from_summary( + Some(reference), + summary, + )) + } else { + self.resolve_environment_reference(EnvironmentResolveMode::Any, reference) + .await } } diff --git a/cli/golem-cli/src/command_handler/interactive.rs b/cli/golem-cli/src/command_handler/interactive.rs index 13744408d2..27d04993a8 100644 --- a/cli/golem-cli/src/command_handler/interactive.rs +++ b/cli/golem-cli/src/command_handler/interactive.rs @@ -654,6 +654,14 @@ impl InteractiveHandler { ) } + pub fn confirm_tool_grant_plan_apply(&self) -> anyhow::Result { + self.confirm( + true, + "The above environment tool grants are required to build registry tool bridges. Do you want to create them?", + None, + ) + } + pub fn confirm_new_app_in_non_empty_dir(&self, path: &Path) -> anyhow::Result { self.confirm( false, diff --git a/cli/golem-cli/src/context.rs b/cli/golem-cli/src/context.rs index 900b88bc2c..f351013941 100644 --- a/cli/golem-cli/src/context.rs +++ b/cli/golem-cli/src/context.rs @@ -1038,6 +1038,7 @@ mod test { version: None, tools_merge_mode: None, tools: None, + publish_tools: Default::default(), }, } } diff --git a/cli/golem-cli/src/model/app.rs b/cli/golem-cli/src/model/app.rs index 4ed40501ac..cbc9c717ca 100644 --- a/cli/golem-cli/src/model/app.rs +++ b/cli/golem-cli/src/model/app.rs @@ -77,6 +77,8 @@ pub struct BuildConfig { pub steps_filter: HashSet, pub custom_bridge_sdk_target: Option, pub repl_bridge_sdk_target: Option, + pub registry_tool_grants: + Vec, } impl BuildConfig { @@ -115,6 +117,16 @@ impl BuildConfig { self } + pub fn with_registry_tool_grants( + mut self, + registry_tool_grants: Vec< + golem_common::model::environment_tool_grant::EnvironmentToolGrantWithDetails, + >, + ) -> Self { + self.registry_tool_grants = registry_tool_grants; + self + } + pub fn should_run_step(&self, step: AppBuildStep) -> bool { if self.steps_filter.is_empty() { !(matches!(step, AppBuildStep::Check) && self.skip_check) @@ -285,30 +297,67 @@ pub enum AppBuildStep { GenBridge, } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub enum SubjectSource { + Local { component_name: ComponentName }, + Registry, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub enum ComponentDependency { Agent { - component_name: ComponentName, + source: SubjectSource, agent_type_name: AgentTypeName, }, Tool { - component_name: ComponentName, + source: SubjectSource, tool_name: ToolName, }, } impl ComponentDependency { - pub fn component_name(&self) -> &ComponentName { + pub fn component_name(&self) -> Option<&ComponentName> { + match self { + ComponentDependency::Agent { source, .. } + | ComponentDependency::Tool { source, .. } => match source { + SubjectSource::Local { component_name } => Some(component_name), + SubjectSource::Registry => None, + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum BridgeSdkTargetSource { + Local { + component_name: ComponentName, + }, + Registry { + release_id: golem_common::model::tool_release::ToolReleaseId, + version: String, + metadata_version: String, + metadata_digest: golem_common::model::diff::Hash, + source_digest: golem_common::model::diff::Hash, + }, +} + +impl BridgeSdkTargetSource { + pub fn local(component_name: ComponentName) -> Self { + Self::Local { component_name } + } + + pub fn component_name(&self) -> Option<&ComponentName> { match self { - ComponentDependency::Agent { component_name, .. } - | ComponentDependency::Tool { component_name, .. } => component_name, + Self::Local { component_name } => Some(component_name), + Self::Registry { .. } => None, } } } #[derive(Debug, Clone)] pub struct BridgeSdkTarget { - pub component_name: ComponentName, + pub source: BridgeSdkTargetSource, pub subject: BridgeSdkTargetSubject, pub target_language: GuestLanguage, pub bridge_mode: BridgeMode, @@ -727,6 +776,65 @@ impl Application { .expect("selected environment must exist") } + pub fn selected_published_tools(&self) -> impl Iterator { + self.selected_environment() + .publish_tools + .keys() + .map(String::as_str) + } + + pub fn registry_tool_references( + &self, + ) -> impl Iterator { + self.tool_declarations + .iter() + .filter_map(|(name, declaration)| { + declaration + .value + .source + .as_ref() + .map(|source| (name, &source.registry)) + }) + } + + pub fn registry_tool_reference(&self, name: &ToolName) -> Option<&app_raw::RegistrySubject> { + self.tool_declarations + .get(name) + .and_then(|declaration| declaration.value.source.as_ref()) + .map(|source| &source.registry) + } + + pub fn requires_registry_bridge_metadata(&self) -> bool { + let registry_names = self + .registry_tool_references() + .map(|(name, _)| name.as_str()) + .collect::>(); + self.bridge_sdks() + .for_all_used_modes() + .into_iter() + .any(|(_, _, targets)| { + let matchers = targets + .tools + .map(|tools| tools.clone().into_set()) + .unwrap_or_default(); + (matchers.contains("*") && !registry_names.is_empty()) + || matchers + .iter() + .any(|matcher| registry_names.contains(matcher.as_str())) + }) + || self.components.values().any(|component| { + component.value.0.dependencies.iter().any(|dependency| { + matches!( + dependency, + ComponentDependency::Tool { + source: SubjectSource::Registry, + .. + } + ) + }) + }) + } + pub fn selected_environment_source(&self) -> Option<&Path> { self.environment_sources .get(self.environment_name()) @@ -931,36 +1039,68 @@ impl Application { &self, tool_name: &ToolName, component_name: &ComponentName, + ) -> anyhow::Result { + self.resolve_tool_provision_with_component(tool_name, Some(component_name)) + } + + pub fn resolve_remote_tool_provision( + &self, + tool_name: &ToolName, + ) -> anyhow::Result { + self.resolve_tool_provision_with_component(tool_name, None) + } + + fn resolve_tool_provision_with_component( + &self, + tool_name: &ToolName, + component_name: Option<&ComponentName>, ) -> anyhow::Result { let declaration = self .tool_declarations .get(tool_name) .with_context(|| format!("Tool '{}' is not declared", tool_name))?; - let component = self.component(component_name); let mut store = Store::new(); - let component_id = ToolLayerId::Component(component_name.clone()); - store - .add_layer(ToolLayer { - id: component_id.clone(), - parents: Vec::new(), - properties: ToolLayerPropertiesKind::Common(Box::new(component.tool_base_layer())), - }) - .map_err(|error| anyhow!(error.to_string()))?; - - let template_apply_context = ComponentLayerApplyContext::new( - Some(component_name.clone()), - Some(self.app_root_dir_str.clone()), - Some(self.golem_temp_dir_str.clone()), - fs::path_to_str(component.component_dir()) - .ok() - .map(str::to_string), - fs::path_to_str(component.component_dir()) - .ok() - .map(|component_dir| self.cargo_manifest_dir_for(component_dir)), - ); + let (mut latest_parent, template_apply_context) = match component_name { + Some(component_name) => { + let component = self.component(component_name); + let component_id = ToolLayerId::Component(component_name.clone()); + store + .add_layer(ToolLayer { + id: component_id.clone(), + parents: Vec::new(), + properties: ToolLayerPropertiesKind::Common(Box::new( + component.tool_base_layer(), + )), + }) + .map_err(|error| anyhow!(error.to_string()))?; + ( + Some(component_id), + ComponentLayerApplyContext::new( + Some(component_name.clone()), + Some(self.app_root_dir_str.clone()), + Some(self.golem_temp_dir_str.clone()), + fs::path_to_str(component.component_dir()) + .ok() + .map(str::to_string), + fs::path_to_str(component.component_dir()) + .ok() + .map(|component_dir| self.cargo_manifest_dir_for(component_dir)), + ), + ) + } + None => ( + None, + ComponentLayerApplyContext::new( + None, + Some(self.app_root_dir_str.clone()), + Some(self.golem_temp_dir_str.clone()), + None, + None, + ), + ), + }; - let mut latest_parent = component_id; for template_name in declaration.value.templates.clone().into_vec() { let component_template_id = ComponentLayerId::TemplateCustomPresets(template_name.clone()); @@ -982,20 +1122,20 @@ impl Application { store .add_layer(ToolLayer { id: id.clone(), - parents: vec![latest_parent], + parents: latest_parent.into_iter().collect(), properties: ToolLayerPropertiesKind::Common(Box::new( ToolLayerInput::from_component_properties(&template), )), }) .map_err(|error| anyhow!(error.to_string()))?; - latest_parent = id; + latest_parent = Some(id); } let common_id = ToolLayerId::ToolCommon(tool_name.clone()); store .add_layer(ToolLayer { id: common_id.clone(), - parents: vec![latest_parent], + parents: latest_parent.into_iter().collect(), properties: ToolLayerPropertiesKind::Common(Box::new(ToolLayerInput::from_raw( declaration.value.tool_layer_properties(), &declaration.source, @@ -2893,22 +3033,34 @@ impl ComponentProperties { let agents = agent_dependencies .iter() .filter_map(|dependency| { - parse_component_dependency_reference(validation, "agent", dependency).map( + parse_local_component_dependency_reference(validation, "agent", dependency).map( |(component_name, name)| ComponentDependency::Agent { - component_name, + source: SubjectSource::Local { component_name }, agent_type_name: AgentTypeName(name), }, ) }) .collect::>(); let tools = tool_dependencies.iter().filter_map(|dependency| { - let (component_name, name) = - parse_component_dependency_reference(validation, "tool", dependency)?; + let (source, name) = match dependency { + app_raw::ComponentDependencyReference::Shortcut(shortcut) => { + if shortcut.contains('/') { + let (component_name, name) = parse_local_component_dependency_reference( + validation, "tool", dependency, + )?; + (SubjectSource::Local { component_name }, name) + } else { + (SubjectSource::Registry, shortcut.clone()) + } + } + app_raw::ComponentDependencyReference::LocalAlias(_) => { + let (component_name, name) = + parse_local_component_dependency_reference(validation, "tool", dependency)?; + (SubjectSource::Local { component_name }, name) + } + }; match ToolName::try_from(name.as_str()) { - Ok(tool_name) => Some(ComponentDependency::Tool { - component_name, - tool_name, - }), + Ok(tool_name) => Some(ComponentDependency::Tool { source, tool_name }), Err(err) => { validation.add_error(format!( "Invalid tool dependency name: {}. {}", @@ -2954,7 +3106,7 @@ impl ComponentProperties { } } -fn parse_component_dependency_reference( +fn parse_local_component_dependency_reference( validation: &mut ValidationBuilder, kind: &str, dependency: &app_raw::ComponentDependencyReference, @@ -2968,13 +3120,22 @@ fn parse_component_dependency_reference( )); return None; }; - (component.to_string(), name.to_string()) + (component, name) } - app_raw::ComponentDependencyReference::Structured(structured) => { - (structured.component.clone(), structured.name.clone()) + app_raw::ComponentDependencyReference::LocalAlias(structured) => { + (structured.component.as_str(), structured.name.as_str()) } }; + let component_name = parse_dependency_component(validation, kind, component, name)?; + Some((component_name, name.to_string())) +} +fn parse_dependency_component( + validation: &mut ValidationBuilder, + kind: &str, + component: &str, + name: &str, +) -> Option { if name.is_empty() { validation.add_error(format!( "Invalid {kind} dependency for component {}. Dependency name must not be empty", @@ -2983,8 +3144,8 @@ fn parse_component_dependency_reference( return None; } - match ComponentName::try_from(component.as_str()) { - Ok(component_name) => Some((component_name, name)), + match ComponentName::try_from(component) { + Ok(component_name) => Some(component_name), Err(err) => { validation.add_error(format!( "Invalid {kind} dependency component {}. {}", @@ -3151,7 +3312,7 @@ mod app_builder { APP_ENV_PRESET_PREFIX, Application, ApplicationPreload, BridgeSdkTargetKind, ComponentDependency, ComponentLayer, ComponentLayerApplyContext, ComponentLayerId, ComponentLayerProperties, ComponentLayerPropertiesKind, ComponentPresetSelector, - ComponentProperties, PartitionedComponentPresets, TEMP_DIR, WithSource, + ComponentProperties, PartitionedComponentPresets, SubjectSource, TEMP_DIR, WithSource, }; use crate::model::app_raw; use crate::model::cascade::store::Store; @@ -3564,6 +3725,7 @@ mod app_builder { builder.validate_selected_preset_references(&mut validation, &component_presets); builder.resolve_and_validate_components(&mut validation, &component_presets); builder.validate_unique_sources(&mut validation); + builder.validate_tool_registry_configuration(&mut validation); builder.validate_http_api_deployments(&mut validation, &environments); validation.build(Application { @@ -4017,6 +4179,7 @@ mod app_builder { { validation.add_error(error); } + }, ); } @@ -4370,6 +4533,85 @@ mod app_builder { }) } + fn validate_tool_registry_configuration(&self, validation: &mut ValidationBuilder) { + for (tool_name, declaration) in &self.tool_declarations { + let Some(source) = &declaration.value.source else { + continue; + }; + if let app_raw::RegistrySubject::ByCoordinates(reference) = &source.registry + && reference.name != tool_name.as_str() + { + validation.add_error(format!( + "Remote tool declaration {} references registry tool {}, but the declaration key must match the registry tool name", + tool_name.as_str().log_color_error_highlight(), + reference.name.log_color_error_highlight(), + )); + } + if let Err(error) = source.registry.to_release_reference() { + validation.add_error(format!( + "Invalid registry source for tool {}: {}", + tool_name.as_str().log_color_error_highlight(), + error, + )); + } + } + + for (component_name, component) in &self.components { + for dependency in &component.value.0.dependencies { + let ComponentDependency::Tool { + source: SubjectSource::Registry, + tool_name, + } = dependency + else { + continue; + }; + match self.tool_declarations.get(tool_name) { + None => validation.add_error(format!( + "Component {} depends on undeclared registry tool {}", + component_name.as_str().log_color_highlight(), + tool_name.as_str().log_color_error_highlight(), + )), + Some(declaration) if declaration.value.source.is_none() => { + validation.add_error(format!( + "Component {} uses name-only dependency {}, but that tool is locally implemented; use component/name to identify its build dependency", + component_name.as_str().log_color_highlight(), + tool_name.as_str().log_color_error_highlight(), + )); + } + Some(_) => {} + } + } + } + + for (environment_name, environment) in &self.environments { + for published_name in environment.publish_tools.keys() { + let Ok(tool_name) = ToolName::try_from(published_name.as_str()) else { + validation.add_error(format!( + "Environment {} publishes invalid tool name {}", + environment_name.0.log_color_highlight(), + published_name.log_color_error_highlight(), + )); + continue; + }; + match self.tool_declarations.get(&tool_name) { + None => validation.add_error(format!( + "Environment {} publishes undeclared tool {}", + environment_name.0.log_color_highlight(), + published_name.log_color_error_highlight(), + )), + Some(declaration) if declaration.value.source.is_some() => { + validation.add_error(format!( + "Environment {} cannot publish remote registry tool {}", + environment_name.0.log_color_highlight(), + published_name.log_color_error_highlight(), + )); + } + Some(_) => {} + } + } + } + } + /// Records preset names (from a component, template, agent, or tool) that an /// environment can select into [`Self::custom_preset_names`]. Env-scoped /// `app-env:` presets are applied automatically rather than selected by @@ -4582,7 +4824,10 @@ mod app_builder { dependencies: &[ComponentDependency], ) { for dependency in dependencies { - if dependency.component_name() == component_name { + let Some(dependency_component_name) = dependency.component_name() else { + continue; + }; + if dependency_component_name == component_name { validation.add_error(format!( "Component {} cannot depend on its own guest bridge SDK", component_name.as_str().log_color_highlight(), @@ -4590,13 +4835,12 @@ mod app_builder { } if !self .component_names_to_source_and_dir - .contains_key(dependency.component_name()) + .contains_key(dependency_component_name) { validation.add_error(format!( "Component {} depends on unknown component {}", component_name.as_str().log_color_highlight(), - dependency - .component_name() + dependency_component_name .as_str() .log_color_error_highlight(), )); @@ -4780,8 +5024,8 @@ mod test { use crate::bridge_gen::{BridgeMode, bridge_client_directory_name}; use crate::fs; use crate::model::app::{ - Application, ApplicationPreload, ComponentDependency, ComponentPresetSelector, ToolName, - includes_from_yaml_file, + Application, ApplicationPreload, ComponentDependency, ComponentPresetSelector, + SubjectSource, ToolName, includes_from_yaml_file, }; use crate::model::app_raw; use golem_common::model::agent::AgentTypeName; @@ -5714,17 +5958,131 @@ mod test { dependencies, &vec![ ComponentDependency::Agent { - component_name: parse_component_name("app:provider"), + source: SubjectSource::Local { + component_name: parse_component_name("app:provider"), + }, agent_type_name: parse_agent_type_name("ShoppingCart"), }, ComponentDependency::Tool { - component_name: parse_component_name("app:provider"), + source: SubjectSource::Local { + component_name: parse_component_name("app:provider"), + }, tool_name: ToolName::try_from("grep").unwrap(), }, ] ); } + #[test] + fn registry_tool_manifest_surfaces_parse_canonical_sources() { + let source = indoc! { r#" + app: hello-app + + environments: + local: + server: local + publishTools: + local-tool: {} + + components: + app:provider: + componentWasm: provider.wasm + app:consumer: + componentWasm: consumer.wasm + dependencies: + tools: + - pinned-tool + - remote-tool + + tools: + local-tool: {} + pinned-tool: + source: + registry: + releaseId: 00000000-0000-0000-0000-000000000001 + remote-tool: + source: + registry: + account: publisher@example.com + name: remote-tool + version: 1.2.3 + + bridge: + rust: + internal: + tools: + - local-tool + - pinned-tool + "# }; + + let (app, _) = load_app_for_env(source, "local", &[]); + assert_eq!( + app.selected_published_tools().collect::>(), + ["local-tool"] + ); + assert_eq!(app.registry_tool_references().count(), 2); + assert!(app.requires_registry_bridge_metadata()); + + let component_name = parse_component_name("app:consumer"); + let component = app.component(&component_name); + let dependencies = &component.properties().dependencies; + assert!(matches!( + &dependencies[0], + ComponentDependency::Tool { + source: SubjectSource::Registry, + tool_name, + } if tool_name.as_str() == "pinned-tool" + )); + assert!(matches!( + &dependencies[1], + ComponentDependency::Tool { + source: SubjectSource::Registry, + tool_name, + } if tool_name.as_str() == "remote-tool" + )); + } + + #[test] + fn registry_tool_manifest_validation_reports_invalid_references() { + let errors = load_app_errors(indoc! { r#" + app: hello-app + environments: + local: + server: local + publishTools: + remote-tool: {} + missing-tool: {} + components: + app:consumer: + componentWasm: consumer.wasm + dependencies: + tools: + - missing-registry-tool + - local-tool + tools: + local-tool: {} + remote-tool: + source: + registry: + account: publisher@example.com + name: other-tool + version: "1" + "# }); + + for expected in [ + "declaration key must match", + "cannot publish remote registry tool", + "publishes undeclared tool", + "depends on undeclared registry tool", + "uses name-only dependency", + ] { + assert!( + errors.iter().any(|error| error.contains(expected)), + "missing {expected:?} in {errors:#?}" + ); + } + } + #[test] fn component_dependencies_reject_malformed_shortcut() { let errors = load_app_errors(indoc! { r#" diff --git a/cli/golem-cli/src/model/app_raw.rs b/cli/golem-cli/src/model/app_raw.rs index ba55636abd..9c711d1f68 100644 --- a/cli/golem-cli/src/model/app_raw.rs +++ b/cli/golem-cli/src/model/app_raw.rs @@ -163,6 +163,8 @@ impl<'de> Deserialize<'de> for ToolDeclarations { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ToolDeclaration { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, #[serde(default, skip_serializing_if = "LenientTokenList::is_empty")] pub templates: LenientTokenList, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -197,6 +199,58 @@ impl ToolDeclaration { } } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RegistrySubject { + ById(RegistrySubjectById), + ByCoordinates(RegistrySubjectByCoordinates), +} + +impl RegistrySubject { + pub fn to_release_reference( + &self, + ) -> Result { + use golem_common::model::account::AccountEmail; + use golem_common::model::tool::ToolName; + use golem_common::model::tool_release::{ + ToolReleaseByCoordinates, ToolReleaseById, ToolReleaseReference, + }; + + match self { + Self::ById(reference) => Ok(ToolReleaseReference::ById(ToolReleaseById { + release_id: reference.release_id, + })), + Self::ByCoordinates(reference) => Ok(ToolReleaseReference::ByCoordinates( + ToolReleaseByCoordinates { + account: AccountEmail::new(reference.account.clone()), + name: ToolName::try_from(reference.name.as_str())?, + version: reference.version.clone(), + }, + )), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegistrySubjectById { + pub release_id: golem_common::model::tool_release::ToolReleaseId, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegistrySubjectByCoordinates { + pub account: String, + pub name: String, + pub version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RegistrySource { + pub registry: RegistrySubject, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ToolPreset { @@ -415,7 +469,7 @@ pub struct ComponentDependencies { #[serde(untagged)] pub enum ComponentDependencyReference { Shortcut(String), - Structured(ComponentDependencyReferenceStruct), + LocalAlias(ComponentDependencyReferenceStruct), } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -846,8 +900,14 @@ pub struct Environment { pub tools_merge_mode: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub tools: Option>, + #[serde(skip_serializing_if = "IndexMap::is_empty", default)] + pub publish_tools: IndexMap, } +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PublishTool {} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] pub enum Server { @@ -1731,6 +1791,7 @@ mod test { files, presets, )| ToolDeclaration { + source: None, templates, config, env_merge_mode, @@ -2273,6 +2334,7 @@ mod test { version, tools_merge_mode, tools, + publish_tools: Default::default(), } }, ) diff --git a/cli/golem-cli/src/model/cli_output/tests.rs b/cli/golem-cli/src/model/cli_output/tests.rs index 67aff8ebcb..d843da8d31 100644 --- a/cli/golem-cli/src/model/cli_output/tests.rs +++ b/cli/golem-cli/src/model/cli_output/tests.rs @@ -263,6 +263,26 @@ static STRUCTURED_OUTPUT_TEST_REGISTRY: &[StructuredOutputTestEntry] = &[ "environment.list", arb_environment_list_result ), + registry_entry!( + "EnvironmentToolGrantCreateView", + "environment.tool.grant", + arb_environment_tool_grant_create_result + ), + registry_entry!( + "EnvironmentToolGrantListView", + "environment.tool.list", + arb_environment_tool_grant_list_result + ), + registry_entry!( + "EnvironmentToolGrantDeleteView", + "environment.tool.delete", + arb_environment_tool_grant_delete_result + ), + registry_entry!( + "EnvironmentToolGrantRestoreView", + "environment.tool.restore", + arb_environment_tool_grant_restore_result + ), registry_entry!( "EnvironmentSyncDeploymentOptionsResult", "environment.sync-deployment-options", @@ -273,6 +293,11 @@ static STRUCTURED_OUTPUT_TEST_REGISTRY: &[StructuredOutputTestEntry] = &[ "deploy.environment-setup-plan", arb_environment_setup_plan_result ), + registry_entry!( + "EnvironmentToolGrantPlanView", + "deploy.environment-tool-grants", + arb_environment_tool_grant_plan_result + ), registry_entry!( "PluginRegistrationGetView", "plugin.get", @@ -1650,6 +1675,8 @@ fn empty_deployment_diff() -> golem_common::model::diff::DeploymentDiff { components: BTreeMap::new(), http_api_deployments: BTreeMap::new(), mcp_deployments: BTreeMap::new(), + remote_tools: BTreeMap::new(), + published_tools: Default::default(), } } @@ -4086,6 +4113,34 @@ fn arb_component_layer_properties() -> BoxedStrategy BoxedStrategy BoxedStrategy BoxedStrategy OutputDocumentStrategy { .boxed() } +fn arb_environment_tool_grant_plan_result() -> OutputDocumentStrategy { + serialized_output( + proptest::collection::vec( + ( + prop_oneof![ + Just(crate::model::deploy::EnvironmentToolGrantPlanAction::Create), + Just(crate::model::deploy::EnvironmentToolGrantPlanAction::Delete), + Just(crate::model::deploy::EnvironmentToolGrantPlanAction::RetainProtected), + Just(crate::model::deploy::EnvironmentToolGrantPlanAction::RetainAdministratorManaged), + ], + proptest::option::of(arb_uuid()), + proptest::option::of(arb_small_string()), + proptest::option::of(arb_small_string()), + proptest::option::of(arb_small_string()), + proptest::option::of(arb_uuid()), + ) + .prop_map(|(action, release_id, account, name, version, grant_id)| { + crate::model::deploy::EnvironmentToolGrantPlanEntry { + action, + release_id: release_id + .map(golem_common::model::tool_release::ToolReleaseId), + account, + name, + version, + grant_id: grant_id.map( + golem_common::model::environment_tool_grant::EnvironmentToolGrantId, + ), + } + }), + 0..5, + ) + .prop_map(|entries| crate::model::deploy::EnvironmentToolGrantPlanView { entries }), + ) +} + fn arb_environment_setup_plan() -> BoxedStrategy { ( arb_small_string(), @@ -4867,6 +5015,66 @@ fn arb_environment_sync_deployment_options_result() -> OutputDocumentStrategy { })) } +fn arb_environment_tool_grant_view() +-> BoxedStrategy { + ( + arb_uuid(), + arb_uuid(), + arb_small_string(), + arb_small_string(), + arb_small_string(), + any::(), + any::(), + any::(), + ) + .prop_map(|(id, release_id, tool_name, tool_version, owner, protected, automatic, deleted)| { + crate::model::environment::EnvironmentToolGrantView { + grant_id: golem_common::base_model::environment_tool_grant::EnvironmentToolGrantId(id), + release_id: golem_common::base_model::tool_release::ToolReleaseId(release_id), + tool_name, + tool_version, + owner, + protected, + automatic, + lifecycle: if deleted { + golem_common::base_model::environment_tool_grant::EnvironmentToolGrantLifecycle::Deleted + } else { + golem_common::base_model::environment_tool_grant::EnvironmentToolGrantLifecycle::Active + }, + } + }) + .boxed() +} + +fn arb_environment_tool_grant_list_result() -> OutputDocumentStrategy { + serialized_output( + proptest::collection::vec(arb_environment_tool_grant_view(), 0..5) + .prop_map(|grants| crate::model::environment::EnvironmentToolGrantListView { grants }), + ) +} + +fn arb_environment_tool_grant_create_result() -> OutputDocumentStrategy { + serialized_output( + arb_environment_tool_grant_view() + .prop_map(|grant| crate::model::environment::EnvironmentToolGrantCreateView { grant }), + ) +} + +fn arb_environment_tool_grant_delete_result() -> OutputDocumentStrategy { + serialized_output(arb_uuid().prop_map(|id| { + crate::model::environment::EnvironmentToolGrantDeleteView { + grant_id: golem_common::base_model::environment_tool_grant::EnvironmentToolGrantId(id), + } + })) +} + +fn arb_environment_tool_grant_restore_result() -> OutputDocumentStrategy { + serialized_output( + arb_environment_tool_grant_view() + .prop_map(|grant| crate::model::environment::EnvironmentToolGrantRestoreView { grant }), + ) +} + fn arb_environment_with_details() -> BoxedStrategy { ( diff --git a/cli/golem-cli/src/model/component.rs b/cli/golem-cli/src/model/component.rs index 00b360202f..0e3fa3bc92 100644 --- a/cli/golem-cli/src/model/component.rs +++ b/cli/golem-cli/src/model/component.rs @@ -29,7 +29,7 @@ use chrono::{DateTime, Utc}; use colored::Colorize; use colored::control::SHOULD_COLORIZE; use golem_common::base_model::component_metadata::AgentTypeProvisionConfig; -use golem_common::model::agent::{AgentConfigSource, AgentTypeName}; +use golem_common::model::agent::{AgentConfigSource, AgentFileContentHash, AgentTypeName}; use golem_common::model::card::recipient::{RecipientMonomorphizationContext, RecipientPattern}; use golem_common::model::card::{ PolymorphicCard, PolymorphicManifestPermissionPattern, @@ -47,6 +47,7 @@ use golem_common::model::component::{InitialAgentFile, InstalledPlugin}; use golem_common::model::environment::EnvironmentId; use golem_common::model::tool::{ToolDeploymentMetadata, ToolName}; use golem_common::model::worker::TypedAgentConfigEntry; +use golem_common::model::{diff, tool}; use golem_common::schema::agent::{AgentTypeSchema, FieldSource, InputSchema, OutputSchema}; use golem_common::schema::graph::SchemaGraph; use golem_common::schema::tool::Tool; @@ -347,6 +348,23 @@ pub struct ComponentDeployProperties { pub tool_deployment_configs: BTreeMap, } +#[derive(Debug)] +pub struct DeployableManifestComponents { + pub components: BTreeMap, + pub remote_tool_deployments: Vec, + pub diffable_remote_tool_deployments: + BTreeMap>, + pub published_tools: BTreeSet, + pub pending_remote_initial_files: Vec, +} + +#[derive(Debug)] +pub struct PendingRemoteInitialFile { + pub content: Vec, + pub content_hash: AgentFileContentHash, + pub size: u64, +} + #[derive(Clone, Debug)] pub struct ToolManifestProvisionConfig { pub config: golem_common::model::json::NormalizedJsonValue, diff --git a/cli/golem-cli/src/model/deploy.rs b/cli/golem-cli/src/model/deploy.rs index 3edf2c162a..2f1f9d618e 100644 --- a/cli/golem-cli/src/model/deploy.rs +++ b/cli/golem-cli/src/model/deploy.rs @@ -47,7 +47,9 @@ use golem_common::model::diff::{ self, AgentTypeProvisionConfigDiff, BTreeMapDiffValue, DeploymentDiff, DiffForHashOf, Hashable, }; use golem_common::model::environment::EnvironmentName; +use golem_common::model::environment_tool_grant::EnvironmentToolGrantId; use golem_common::model::quota::{ResourceDefinition, ResourceDefinitionCreation}; +use golem_common::model::tool_release::ToolReleaseId; use golem_common::schema::agent::{AgentMethodSchema, AgentTypeSchema}; use golem_common::schema::graph::SchemaGraph; use itertools::Itertools; @@ -63,6 +65,10 @@ pub struct DeploymentDisplay { #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub components: BTreeMap, #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub remote_tools: BTreeMap, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + pub published_tools: BTreeSet, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub http_api_deployments: BTreeMap, #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub mcp_deployments: BTreeMap, @@ -155,6 +161,85 @@ pub struct EnvironmentSetupPlan { pub resource_defaults: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EnvironmentToolGrantPlanAction { + Create, + Delete, + RetainProtected, + RetainAdministratorManaged, +} + +impl std::fmt::Display for EnvironmentToolGrantPlanAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Create => "create", + Self::Delete => "delete", + Self::RetainProtected => "retain protected", + Self::RetainAdministratorManaged => "retain administrator-managed", + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantPlanEntry { + pub action: EnvironmentToolGrantPlanAction, + pub release_id: Option, + pub account: Option, + pub name: Option, + pub version: Option, + pub grant_id: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantPlanView { + pub entries: Vec, +} + +impl EnvironmentToolGrantPlanView { + pub fn has_changes(&self) -> bool { + self.entries.iter().any(|entry| { + matches!( + entry.action, + EnvironmentToolGrantPlanAction::Create | EnvironmentToolGrantPlanAction::Delete + ) + }) + } +} + +impl StructuredOutput for EnvironmentToolGrantPlanView { + const KIND: &'static str = "deploy.environment-tool-grants"; +} + +impl TextOutput for EnvironmentToolGrantPlanView { + fn log(&self) { + let mut table = new_table_full_condensed(vec![ + Column::new("Action"), + Column::new("Release ID"), + Column::new("Account"), + Column::new("Tool"), + Column::new("Version"), + Column::new("Grant ID"), + ]); + for entry in &self.entries { + table.add_row(vec![ + entry.action.to_string(), + entry + .release_id + .map(|id| id.to_string()) + .unwrap_or_default(), + entry.account.clone().unwrap_or_default(), + entry.name.clone().unwrap_or_default(), + entry.version.clone().unwrap_or_default(), + entry.grant_id.map(|id| id.to_string()).unwrap_or_default(), + ]); + } + log_table(table); + } +} + #[cfg(test)] mod tests { use super::*; @@ -785,10 +870,35 @@ pub struct DeploymentDisplayMcpAgentOptions { pub security_scheme: Option, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentDisplayRemoteTool { + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub release_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_account: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provision: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub bindings: BTreeMap, +} + impl DeploymentDisplay { pub fn from_context(ctx: DeploymentDisplayContext<'_>) -> anyhow::Result { Ok(Self { components: display_components(&ctx)?, + remote_tools: display_remote_tools(&ctx)?, + published_tools: display_published_tools(&ctx), http_api_deployments: display_http_api_deployments(&ctx), mcp_deployments: display_mcp_deployments(&ctx), }) @@ -822,11 +932,109 @@ impl DeploymentDisplay { fn is_empty(&self) -> bool { self.components.is_empty() + && self.remote_tools.is_empty() + && self.published_tools.is_empty() && self.http_api_deployments.is_empty() && self.mcp_deployments.is_empty() } } +fn display_remote_tools( + ctx: &DeploymentDisplayContext<'_>, +) -> anyhow::Result> { + display_keys( + ctx.mode, + &ctx.deployment.remote_tools, + &ctx.diff.remote_tools, + ) + .filter(|tool_name| ctx.deployment.remote_tools.contains_key(*tool_name)) + .map(|tool_name| { + let remote_tool = ctx + .deployment + .remote_tools + .get(tool_name) + .expect("displayed remote tool must exist in deployment"); + let hash = Some(remote_tool.hash()?.to_string()); + let Some(remote_tool) = remote_tool.as_value() else { + return Ok(( + tool_name.clone(), + DeploymentDisplayRemoteTool { + hash, + release_id: None, + version: None, + source_digest: None, + owner_account: None, + metadata_version: None, + metadata_digest: None, + provision: None, + bindings: BTreeMap::new(), + }, + )); + }; + + let mut provision = remote_tool.provision.clone(); + provision.config = NormalizedJsonValue(mask_json_secret_for_deploy_diff( + ctx.masking, + &provision.config, + )?); + provision.env = display_env(ctx.masking, &provision.env); + for plugin in &mut provision.plugins { + plugin.parameters = plugin + .parameters + .iter() + .map(|(key, value)| { + ( + key.clone(), + mask_sensitive_key_value_for_deploy_diff(ctx.masking, key, value), + ) + }) + .collect(); + } + + let bindings = remote_tool + .bindings + .iter() + .map(|(agent, binding)| { + let mut binding = binding.clone(); + binding.parameters = NormalizedJsonValue(mask_json_secret_for_deploy_diff( + ctx.masking, + &binding.parameters, + )?); + Ok((agent.to_string(), binding)) + }) + .collect::>>()?; + + Ok(( + tool_name.clone(), + DeploymentDisplayRemoteTool { + hash, + release_id: Some(remote_tool.release_id.to_string()), + version: Some(remote_tool.version.clone()), + source_digest: Some(remote_tool.source_digest.to_string()), + owner_account: Some(remote_tool.owner_account_email.to_string()), + metadata_version: Some(remote_tool.metadata_version.clone()), + metadata_digest: Some(remote_tool.metadata_digest.to_string()), + provision: Some(provision), + bindings, + }, + )) + }) + .collect() +} + +fn display_published_tools(ctx: &DeploymentDisplayContext<'_>) -> BTreeSet { + match ctx.mode { + DeploymentDisplayMode::ChangedOnly => ctx + .diff + .published_tools + .keys() + .filter(|tool_name| ctx.deployment.published_tools.contains(*tool_name)) + .cloned() + .collect(), + DeploymentDisplayMode::Full => ctx.deployment.published_tools.clone(), + } +} + fn display_components( ctx: &DeploymentDisplayContext<'_>, ) -> anyhow::Result> { @@ -1739,6 +1947,37 @@ impl TextOutput for DeploymentDiff { } logln(""); } + if !self.remote_tools.is_empty() { + logln("Remote tool changes:".log_color_help_group().to_string()); + for (tool_name, remote_tool_diff) in &self.remote_tools { + let action = match remote_tool_diff { + BTreeMapDiffValue::Create => "create".green(), + BTreeMapDiffValue::Delete => "delete".red(), + BTreeMapDiffValue::Update(_) => "update".yellow(), + }; + logln(format!( + " - {} remote tool {}", + action, + tool_name.log_color_highlight(), + )); + } + logln(""); + } + if !self.published_tools.is_empty() { + logln("Published tool changes:".log_color_help_group().to_string()); + for (tool_name, publication_diff) in &self.published_tools { + let action = match publication_diff { + diff::BTreeSetDiffValue::Create => "publish".green(), + diff::BTreeSetDiffValue::Delete => "de-publish".red(), + }; + logln(format!( + " - {} tool {}", + action, + tool_name.log_color_highlight(), + )); + } + logln(""); + } } fn log_masked(self, config: MaskingConfig) -> anyhow::Result<()> { @@ -1801,6 +2040,44 @@ fn mask_deployment_diff_secrets(diff: &mut DeploymentDiff) -> anyhow::Result<()> } } + for remote_tool_change in diff.remote_tools.values_mut() { + let BTreeMapDiffValue::Update(remote_tool_diff) = remote_tool_change else { + continue; + }; + let DiffForHashOf::ValueDiff { diff: remote_tool } = remote_tool_diff else { + continue; + }; + + remote_tool.provision.config = NormalizedJsonValue(mask_json_secret_for_deploy_diff( + MaskingConfig::hide_secrets(), + &remote_tool.provision.config, + )?); + remote_tool.provision.env = + display_env(MaskingConfig::hide_secrets(), &remote_tool.provision.env); + for plugin in &mut remote_tool.provision.plugins { + plugin.parameters = plugin + .parameters + .iter() + .map(|(key, value)| { + ( + key.clone(), + mask_sensitive_key_value_for_deploy_diff( + MaskingConfig::hide_secrets(), + key, + value, + ), + ) + }) + .collect(); + } + for binding in remote_tool.bindings.values_mut() { + binding.parameters = NormalizedJsonValue(mask_json_secret_for_deploy_diff( + MaskingConfig::hide_secrets(), + &binding.parameters, + )?); + } + } + Ok(()) } @@ -1952,7 +2229,9 @@ impl TextOutput for DeployPlanView<'_> { fn log(&self) { let has_deployment_changes = !self.deployment_diff.components.is_empty() || !self.deployment_diff.http_api_deployments.is_empty() - || !self.deployment_diff.mcp_deployments.is_empty(); + || !self.deployment_diff.mcp_deployments.is_empty() + || !self.deployment_diff.remote_tools.is_empty() + || !self.deployment_diff.published_tools.is_empty(); if has_deployment_changes { self.deployment_diff.log(); diff --git a/cli/golem-cli/src/model/environment.rs b/cli/golem-cli/src/model/environment.rs index 7518d1b8d0..93d407c4fb 100644 --- a/cli/golem-cli/src/model/environment.rs +++ b/cli/golem-cli/src/model/environment.rs @@ -19,6 +19,10 @@ use crate::model::app_raw::Environment; use crate::model::cli_output::StructuredOutput; use crate::model::text_format::*; use anyhow::bail; +use golem_common::base_model::environment_tool_grant::{ + EnvironmentToolGrantId, EnvironmentToolGrantLifecycle, EnvironmentToolGrantWithDetails, +}; +use golem_common::base_model::tool_release::ToolReleaseId; use golem_common::model::account::AccountId; use golem_common::model::application::{ApplicationId, ApplicationName}; use golem_common::model::deployment::DeploymentRevision; @@ -370,3 +374,136 @@ impl TextOutput for EnvironmentListView { log_table(table); } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantView { + pub grant_id: EnvironmentToolGrantId, + pub release_id: ToolReleaseId, + pub tool_name: String, + pub tool_version: String, + pub owner: String, + pub protected: bool, + pub automatic: bool, + pub lifecycle: EnvironmentToolGrantLifecycle, +} + +impl From for EnvironmentToolGrantView { + fn from(value: EnvironmentToolGrantWithDetails) -> Self { + Self { + grant_id: value.grant.id, + release_id: value.release.id, + tool_name: value.release.name.into_inner(), + tool_version: value.release.version, + owner: value.release_owner.email.into_inner(), + protected: value.grant.protected, + automatic: value.grant.automatic, + lifecycle: value.grant.lifecycle, + } + } +} + +impl EnvironmentToolGrantView { + fn row(&self) -> Vec { + vec![ + self.grant_id.to_string(), + self.release_id.to_string(), + self.tool_name.clone(), + self.tool_version.clone(), + self.owner.clone(), + self.protected.to_string(), + self.automatic.to_string(), + self.lifecycle.to_string(), + ] + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantCreateView { + pub grant: EnvironmentToolGrantView, +} + +impl StructuredOutput for EnvironmentToolGrantCreateView { + const KIND: &'static str = "environment.tool.grant"; +} + +impl TextOutput for EnvironmentToolGrantCreateView { + fn log(&self) { + log_text_view(&self.grant); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantRestoreView { + pub grant: EnvironmentToolGrantView, +} + +impl StructuredOutput for EnvironmentToolGrantRestoreView { + const KIND: &'static str = "environment.tool.restore"; +} + +impl TextOutput for EnvironmentToolGrantRestoreView { + fn log(&self) { + log_text_view(&self.grant); + } +} + +impl TextOutput for EnvironmentToolGrantView { + fn log(&self) { + let mut table = new_table_full_condensed(vec![ + Column::new("Grant ID"), + Column::new("Release ID"), + Column::new("Tool"), + Column::new("Version"), + Column::new("Owner"), + Column::new("Protected"), + Column::new("Automatic"), + Column::new("Lifecycle"), + ]); + table.add_row(self.row()); + log_table(table); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantListView { + pub grants: Vec, +} +impl StructuredOutput for EnvironmentToolGrantListView { + const KIND: &'static str = "environment.tool.list"; +} +impl TextOutput for EnvironmentToolGrantListView { + fn log(&self) { + let mut table = new_table_full_condensed(vec![ + Column::new("Grant ID"), + Column::new("Release ID"), + Column::new("Tool"), + Column::new("Version"), + Column::new("Owner"), + Column::new("Protected"), + Column::new("Automatic"), + Column::new("Lifecycle"), + ]); + for grant in &self.grants { + table.add_row(grant.row()); + } + log_table(table); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentToolGrantDeleteView { + pub grant_id: EnvironmentToolGrantId, +} +impl StructuredOutput for EnvironmentToolGrantDeleteView { + const KIND: &'static str = "environment.tool.delete"; +} +impl TextOutput for EnvironmentToolGrantDeleteView { + fn log(&self) { + logln(format!("Deleted environment tool grant {}", self.grant_id)); + } +} diff --git a/cli/golem-cli/src/model/template_render.rs b/cli/golem-cli/src/model/template_render.rs index 60825f5383..e224a582d1 100644 --- a/cli/golem-cli/src/model/template_render.rs +++ b/cli/golem-cli/src/model/template_render.rs @@ -116,8 +116,8 @@ impl TemplateRender for app_raw::ComponentDependencyReference { app_raw::ComponentDependencyReference::Shortcut(shortcut) => Ok( app_raw::ComponentDependencyReference::Shortcut(shortcut.render(env, ctx)?), ), - app_raw::ComponentDependencyReference::Structured(structured) => Ok( - app_raw::ComponentDependencyReference::Structured(structured.render(env, ctx)?), + app_raw::ComponentDependencyReference::LocalAlias(structured) => Ok( + app_raw::ComponentDependencyReference::LocalAlias(structured.render(env, ctx)?), ), } } @@ -132,6 +132,23 @@ impl TemplateRender for app_raw::ComponentDependencyReferenceSt } } +impl TemplateRender for app_raw::RegistrySubject { + fn render(&self, env: &Environment, ctx: &C) -> Result { + Ok(match self { + app_raw::RegistrySubject::ById(reference) => { + app_raw::RegistrySubject::ById(reference.clone()) + } + app_raw::RegistrySubject::ByCoordinates(reference) => { + app_raw::RegistrySubject::ByCoordinates(app_raw::RegistrySubjectByCoordinates { + account: reference.account.render(env, ctx)?, + name: reference.name.render(env, ctx)?, + version: reference.version.render(env, ctx)?, + }) + } + }) + } +} + impl TemplateRender for app_raw::ExternalCommand { fn render(&self, env: &Environment, ctx: &C) -> Result { Ok(app_raw::ExternalCommand { diff --git a/cli/golem-cli/src/model/tool_deployment.rs b/cli/golem-cli/src/model/tool_deployment.rs index 79763c7bc3..bc80e2c2f0 100644 --- a/cli/golem-cli/src/model/tool_deployment.rs +++ b/cli/golem-cli/src/model/tool_deployment.rs @@ -14,6 +14,7 @@ use crate::validation::ValidationBuilder; use golem_common::model::component::ComponentName; +use golem_common::model::environment_tool_grant::EnvironmentToolGrantWithDetails; use golem_common::schema::tool::Tool; use serde::Serialize; use std::cmp::Ordering; @@ -47,6 +48,7 @@ pub enum ToolValidationCode { DuplicateImplementation, MissingDeclaration, MissingImplementation, + RegistryReleaseNotFound, UnknownToolReference, UnknownAgentReference, VersionMismatch, @@ -190,16 +192,29 @@ pub fn add_tool_issues( } } -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum ToolImplementationSource { - Component { component_name: ComponentName }, + Component { + component_name: ComponentName, + }, + Registry { + grant: Box, + }, } impl ToolImplementationSource { pub fn local_component_name(&self) -> Option<&ComponentName> { match self { Self::Component { component_name } => Some(component_name), + Self::Registry { .. } => None, + } + } + + pub fn registry_grant(&self) -> Option<&EnvironmentToolGrantWithDetails> { + match self { + Self::Component { .. } => None, + Self::Registry { grant } => Some(grant), } } } diff --git a/cli/golem-cli/src/versions.rs b/cli/golem-cli/src/versions.rs index 021e0a8517..6e15449488 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.8" + "1.6.0-dev.9" }; } } diff --git a/cli/golem-cli/tests/app/mod.rs b/cli/golem-cli/tests/app/mod.rs index b0473cb138..41fec4365f 100644 --- a/cli/golem-cli/tests/app/mod.rs +++ b/cli/golem-cli/tests/app/mod.rs @@ -23,6 +23,7 @@ mod cards; mod directory_source_ifs; mod moonbit_tool_middleware; mod plugins; +mod registry_tools; mod scala_tool_middleware; mod tool_middleware; diff --git a/cli/golem-cli/tests/app/registry_tools.rs b/cli/golem-cli/tests/app/registry_tools.rs new file mode 100644 index 0000000000..78410c22d3 --- /dev/null +++ b/cli/golem-cli/tests/app/registry_tools.rs @@ -0,0 +1,437 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::app::{TestContext, cmd, flag}; +use crate::{Tracing, workspace_path}; +use chrono::{DateTime, Utc}; +use golem_cli::fs; +use golem_client::api::{RegistryServiceClient, RegistryServiceClientLive}; +use golem_client::model::{DeploymentCreation, TokenCreation}; +use golem_client::{Context, Security}; +use golem_common::model::account::{AccountCreation, AccountEmail, AccountId}; +use golem_common::model::application::{Application, ApplicationCreation, ApplicationName}; +use golem_common::model::auth::TokenSecret; +use golem_common::model::component::{ + ComponentCreation, ComponentName, ToolDeploymentConfigCreation, ToolProvisionConfigCreation, +}; +use golem_common::model::deployment::DeploymentVersion; +use golem_common::model::diff::Hashable; +use golem_common::model::environment::{Environment, EnvironmentCreation, EnvironmentName}; +use golem_common::model::environment_tool_grant::EnvironmentToolGrantCreation; +use golem_common::model::json::NormalizedJsonValue; +use golem_common::model::tool::ToolName; +use golem_common::model::tool_release::{ToolReleaseById, ToolReleaseReference}; +use golem_common::schema::SchemaGraph; +use golem_common::schema::tool::{ + CommandBody, CommandNode, CommandTree, Doc, Globals, Positionals, Tool, +}; +use reqwest_middleware::ClientBuilder; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use test_r::{inherit_test_dep, test, timeout}; +use tokio::fs::File; +use url::Url; +use uuid::Uuid; + +inherit_test_dep!(Tracing); + +struct RegistryUser { + account_id: AccountId, + account_email: AccountEmail, + token: TokenSecret, + client: RegistryServiceClientLive, +} + +fn registry_client(base_url: &Url, token: &str) -> RegistryServiceClientLive { + RegistryServiceClientLive { + context: Context { + client: ClientBuilder::new(reqwest::Client::new()).build(), + base_url: base_url.clone(), + security_token: Security::Bearer(token.to_string()), + }, + } +} + +async fn create_registry_user( + admin: &RegistryServiceClientLive, + base_url: &Url, + label: &str, +) -> anyhow::Result { + let name = format!("{label}-{}", Uuid::new_v4()); + let account = admin + .create_account(&AccountCreation { + name: name.clone(), + email: AccountEmail::new(format!("{name}@golem.cloud")), + roles: Vec::new(), + }) + .await?; + let token = admin + .create_token( + &account.id.0, + &TokenCreation { + expires_at: DateTime::::MAX_UTC, + }, + ) + .await? + .secret; + let client = registry_client(base_url, token.secret()); + + Ok(RegistryUser { + account_id: account.id, + account_email: account.email, + token, + client, + }) +} + +async fn create_app_and_environment( + user: &RegistryUser, + label: &str, +) -> anyhow::Result<(Application, Environment)> { + let application = user + .client + .create_application( + &user.account_id.0, + &ApplicationCreation { + name: ApplicationName(format!("{label}-app-{}", Uuid::new_v4())), + }, + ) + .await?; + let environment = user + .client + .create_environment( + &application.id.0, + &EnvironmentCreation { + name: EnvironmentName(format!("{label}-env-{}", Uuid::new_v4())), + compatibility_check: false, + version_check: false, + security_overrides: false, + }, + ) + .await?; + Ok((application, environment)) +} + +fn registry_tool(version: &str) -> Tool { + Tool { + version: version.to_string(), + commands: CommandTree { + nodes: vec![CommandNode { + name: "search".to_string(), + aliases: Vec::new(), + doc: Doc::default(), + globals: Globals::default(), + subcommands: Vec::new(), + body: Some(CommandBody { + positionals: Positionals::default(), + options: Vec::new(), + flags: Vec::new(), + constraints: Vec::new(), + stdin: None, + stdout: None, + result: None, + errors: Vec::new(), + annotations: None, + }), + }], + }, + schema: SchemaGraph::empty(), + } +} + +fn publisher_tool_config() -> ToolDeploymentConfigCreation { + ToolDeploymentConfigCreation { + provision: ToolProvisionConfigCreation { + config: NormalizedJsonValue::new(serde_json::json!({})), + env: BTreeMap::new(), + plugin_installations: Vec::new(), + files: BTreeMap::new(), + }, + environment_binding: None, + agent_bindings: BTreeMap::new(), + } +} + +fn wasm_files_under(root: &Path) -> anyhow::Result> { + let mut wasm_files = Vec::new(); + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(directory)? { + let path = entry?.path(); + if path.is_dir() { + directories.push(path); + } else if path.extension() == Some(OsStr::new("wasm")) { + wasm_files.push(path); + } + } + } + Ok(wasm_files) +} + +#[test] +#[timeout("2m")] +async fn registry_tool_bridge_automatically_reconciles_its_environment_grant( + _tracing: &Tracing, +) -> anyhow::Result<()> { + let mut ctx = TestContext::new(); + ctx.start_server().await; + + let base_url = Url::parse(&format!("http://localhost:{}", ctx.router_port()))?; + let admin = registry_client(&base_url, golem_client::LOCAL_WELL_KNOWN_TOKEN); + let publisher = create_registry_user(&admin, &base_url, "tool-publisher").await?; + let consumer = create_registry_user(&admin, &base_url, "tool-consumer").await?; + let (_, publisher_environment) = + create_app_and_environment(&publisher, "tool-publisher").await?; + let (consumer_application, consumer_environment) = + create_app_and_environment(&consumer, "tool-consumer").await?; + let tool_name = ToolName::try_from("search").unwrap(); + + let component_wasm = + workspace_path().join("sdks/ts/packages/golem-ts-sdk/wasm/agent_guest.wasm"); + publisher + .client + .create_component( + &publisher_environment.id.0, + &ComponentCreation { + component_name: ComponentName::try_from("publisher-tools:search") + .map_err(anyhow::Error::msg)?, + agent_types: Vec::new(), + agent_type_provision_configs: BTreeMap::new(), + tools: vec![registry_tool("1.2.0")], + tool_deployment_configs: BTreeMap::from([( + tool_name.clone(), + publisher_tool_config(), + )]), + }, + File::open(&component_wasm).await?, + None::, + ) + .await?; + + let publisher_plan = publisher + .client + .get_environment_deployment_plan(&publisher_environment.id.0) + .await?; + let mut publisher_hash_input = publisher_plan.to_diffable(); + publisher_hash_input + .published_tools + .insert(tool_name.to_string()); + publisher + .client + .deploy_environment( + &publisher_environment.id.0, + &DeploymentCreation { + current_revision: publisher_plan.current_revision, + expected_deployment_hash: publisher_hash_input.hash()?, + version: DeploymentVersion("publisher-1.2.0".to_string()), + agent_secret_defaults: Vec::new(), + quota_resource_defaults: Vec::new(), + retry_policy_defaults: Vec::new(), + publish_tools: vec![tool_name.clone()], + remote_tools: Vec::new(), + replace_incompatible_agent_secrets: false, + }, + ) + .await?; + + let release = publisher + .client + .list_account_tool_releases(&publisher.account_id.0) + .await? + .values + .into_iter() + .find(|release| release.name == tool_name && release.version == "1.2.0") + .expect("publisher deployment must create search@1.2.0"); + + let yaml_string = |value: &str| serde_json::to_string(value).unwrap(); + fs::write_str( + ctx.cwd_path_join("golem.yaml"), + format!( + r#"manifestVersion: 1.6.0 +app: {application} + +tools: + search: + source: + registry: + account: {publisher_account} + name: search + version: "1.2.0" + +environments: + {environment}: + server: + url: {server_url} + workerUrl: {server_url} + allowInsecure: true + auth: + staticToken: {token} + +bridge: + rust: + internal: + outputDir: generated + tools: [search] +"#, + application = yaml_string(&consumer_application.name.0), + environment = yaml_string(&consumer_environment.name.0), + server_url = yaml_string(base_url.as_str()), + token = yaml_string(consumer.token.secret()), + publisher_account = yaml_string(publisher.account_email.as_str()), + ), + )?; + + let staged_deployment = ctx.cli([cmd::DEPLOY, "--stage"]).await; + assert!(!staged_deployment.success()); + assert!( + staged_deployment.stdout_contains("requires new environment tool grants") + || staged_deployment.stderr_contains("requires new environment tool grants") + ); + assert!( + consumer + .client + .list_environment_tool_grants(&consumer_environment.id.0) + .await? + .values + .is_empty(), + "staging must not create an environment grant" + ); + + let deployment_plan = ctx.cli([cmd::DEPLOY, "--plan"]).await; + assert!(deployment_plan.success_or_dump()); + assert!( + deployment_plan.stdout_contains("environment tool grant reconciliation") + || deployment_plan.stderr_contains("environment tool grant reconciliation") + ); + assert!( + consumer + .client + .list_environment_tool_grants(&consumer_environment.id.0) + .await? + .values + .is_empty(), + "planning must not create the automatic grant" + ); + + let granted_build = ctx + .cli([flag::YES, cmd::BUILD, flag::STEP, "gen-bridge"]) + .await; + assert!(granted_build.success_or_dump()); + assert!( + granted_build.stdout_contains("environment tool grants required by the build") + || granted_build.stderr_contains("environment tool grants required by the build") + ); + let grants = consumer + .client + .list_environment_tool_grants(&consumer_environment.id.0) + .await? + .values; + assert_eq!(grants.len(), 1); + let grant = &grants[0]; + assert!(grant.grant.automatic); + assert_eq!(grant.release.id, release.id); + assert!( + ctx.cwd_path_join("generated/search-tool-guest-client/Cargo.toml") + .is_file() + ); + assert!( + wasm_files_under(ctx.cwd_path())?.is_empty(), + "registry bridge generation must not fetch or require publisher WASM" + ); + + let marker_dir = ctx.cwd_path_join("golem-temp/task-results"); + let mut registry_bridge_marker = None; + for entry in std::fs::read_dir(&marker_dir)? { + let marker: serde_json::Value = serde_json::from_slice(&std::fs::read(entry?.path())?)?; + if marker.get("kind").and_then(serde_json::Value::as_str) + == Some("GenerateBridgeSdkMarkerHash") + { + registry_bridge_marker = Some(marker); + break; + } + } + let marker_input = registry_bridge_marker + .expect("registry bridge generation must write a cache marker") + .get("hashInput") + .and_then(serde_json::Value::as_str) + .expect("registry bridge cache marker must retain its hash input") + .to_string(); + for expected in [ + release.id.to_string(), + release.metadata_version, + release.metadata_digest.to_string(), + grant.release.source_digest.to_string(), + ] { + assert!( + marker_input.contains(&expected), + "registry bridge cache identity must include {expected}: {marker_input}" + ); + } + + let administrator_managed = consumer + .client + .create_environment_tool_grant( + &consumer_environment.id.0, + &EnvironmentToolGrantCreation { + release: ToolReleaseReference::ById(ToolReleaseById { + release_id: release.id, + }), + }, + ) + .await?; + assert!(!administrator_managed.grant.automatic); + assert!( + consumer + .client + .delete_automatic_environment_tool_grant(&administrator_managed.grant.id.0) + .await + .is_err(), + "automatic reconciliation must not delete an administrator-managed grant" + ); + let automatic_again = consumer + .client + .create_automatic_environment_tool_grant( + &consumer_environment.id.0, + &EnvironmentToolGrantCreation { + release: ToolReleaseReference::ById(ToolReleaseById { + release_id: release.id, + }), + }, + ) + .await?; + assert!(automatic_again.grant.automatic); + + consumer + .client + .delete_automatic_environment_tool_grant(&automatic_again.grant.id.0) + .await?; + let administrator_managed = consumer + .client + .create_environment_tool_grant( + &consumer_environment.id.0, + &EnvironmentToolGrantCreation { + release: ToolReleaseReference::ById(ToolReleaseById { + release_id: release.id, + }), + }, + ) + .await?; + assert!( + !administrator_managed.grant.automatic, + "a grant created through the administrator-managed endpoint must not remain automatic" + ); + + Ok(()) +} diff --git a/cli/golem/src/launch.rs b/cli/golem/src/launch.rs index 64bd43a35a..55c6493b8b 100644 --- a/cli/golem/src/launch.rs +++ b/cli/golem/src/launch.rs @@ -281,7 +281,7 @@ fn registry_service_config( }, ); accounts.insert( - "builtin-plugin-owner".to_string(), + "builtin_plugin_owner".to_string(), PrecreatedAccount { id: AccountId(uuid!("b0a654af-d67f-4d73-a824-cf75e122bfc0")), name: "Builtin Plugin Owner".to_string(), @@ -291,6 +291,17 @@ fn registry_service_config( role: AccountRole::BuiltinPluginOwner, }, ); + accounts.insert( + "builtin_tool_owner".to_string(), + PrecreatedAccount { + id: AccountId(uuid!("58bda34c-10d4-4bfb-8abd-d5e67f09ba3c")), + name: "Builtin Tool Owner".to_string(), + email: AccountEmail::new("builtin-tool-owner@golem.cloud"), + token: None, + plan_id, + role: AccountRole::BuiltinPluginOwner, + }, + ); accounts }, builtin_plugins: BuiltinPluginsConfig::Enabled(Empty {}), diff --git a/cli/schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json b/cli/schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json new file mode 100644 index 0000000000..2dac10e268 --- /dev/null +++ b/cli/schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json @@ -0,0 +1,2559 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "https://schema.golem.cloud/app/golem/1.6.0-dev.9/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" + } + }, + "tools": { + "type": "object", + "description": "Canonical tool declarations by logical tool name. Registry source coordinates are specified only here; all consumers refer to the logical name.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" + }, + "additionalProperties": { + "$ref": "#/definitions/toolDeclaration" + } + }, + "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" + } + }, + "version": { + "$ref": "#/definitions/appVersionSource", + "description": "Application-wide default version source, overridable per 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" + } + } + }, + "initialCard": { + "description": "Initial permission card grants installed into newly created agents of this type.", + "type": "object", + "additionalProperties": false, + "properties": { + "lowerBound": { + "$ref": "#/definitions/initialCardBound" + }, + "upperBound": { + "$ref": "#/definitions/initialCardBound" + } + } + }, + "initialCardBound": { + "type": "object", + "additionalProperties": false, + "properties": { + "positive": { + "type": "array", + "items": { + "type": "string" + } + }, + "negative": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "componentDependencies": { + "description": "Provider-qualified guest bridge dependencies required by a component.", + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "description": "Agent guest bridge dependencies. Each entry identifies both provider component and agent type, either as 'component/name' or as { component, name }.", + "type": "array", + "items": { + "$ref": "#/definitions/componentDependencyReference" + } + }, + "tools": { + "description": "Tool guest bridge dependencies. A registry tool is referenced by its logical name from tools; a local tool identifies both provider component and tool name as 'component/name' or { component, name }.", + "type": "array", + "items": { + "$ref": "#/definitions/componentDependencyReference" + } + } + } + }, + "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." + }, + "dependencies": { + "description": "Provider-qualified guest bridge dependencies required by this component. Entries use 'component/name' or { component, name }.", + "$ref": "#/definitions/componentDependencies" + }, + "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": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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." + }, + "dependencies": { + "description": "Provider-qualified guest bridge dependencies required by this component. Entries use 'component/name' or { component, name }.", + "$ref": "#/definitions/componentDependencies" + }, + "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": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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." + }, + "dependencies": { + "description": "Provider-qualified guest bridge dependencies required by this component. Entries use 'component/name' or { component, name }.", + "$ref": "#/definitions/componentDependencies" + }, + "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": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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." + }, + "dependencies": { + "description": "Provider-qualified guest bridge dependencies required by this component. Entries use 'component/name' or { component, name }.", + "$ref": "#/definitions/componentDependencies" + }, + "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": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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" + } + }, + "toolsMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "tools": { + "$ref": "#/definitions/toolBindings" + }, + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/agentPreset" + } + } + } + }, + "agentPreset": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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" + } + }, + "toolsMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "tools": { + "$ref": "#/definitions/toolBindings" + }, + "default": { + "const": true + } + } + }, + "agentLayerProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "config": {}, + "initialCard": { + "$ref": "#/definitions/initialCard" + }, + "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" + } + }, + "toolsMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "tools": { + "$ref": "#/definitions/toolBindings" + } + } + }, + "toolDeclaration": { + "description": "Canonical source and consumer-owned provision properties for a tool. Omit source for a local implementation or select an exact registry release. Deploy automatically reconciles the required environment grant.", + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "$ref": "#/definitions/registrySource" + }, + "templates": { + "$ref": "#/definitions/lenientTokenList" + }, + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + }, + "presets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/toolPreset" + } + } + } + }, + "toolPreset": { + "description": "Named tool provision-property preset", + "type": "object", + "additionalProperties": false, + "properties": { + "default": { + "const": true + }, + "config": {}, + "envMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "pluginsMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "plugins": { + "type": "array", + "items": { + "$ref": "#/definitions/pluginInstallation" + } + }, + "filesMergeMode": { + "$ref": "#/definitions/vecMergeMode" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/initialComponentFile" + } + } + } + }, + "toolBindings": { + "type": "object", + "description": "Source-neutral bindings by tool name", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" + }, + "additionalProperties": { + "$ref": "#/definitions/toolBinding" + } + }, + "toolBinding": { + "type": "object", + "additionalProperties": false, + "properties": { + "version": { + "type": "string" + }, + "parametersMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "parameters": { + "type": "object", + "additionalProperties": {} + }, + "account": { + "type": "string" + }, + "secretKeysReadableMergeMode": { + "$ref": "#/definitions/secretKeyMergeMode" + }, + "secretKeysReadable": { + "$ref": "#/definitions/secretKeyScope" + }, + "secretKeysRevealableMergeMode": { + "$ref": "#/definitions/secretKeyMergeMode" + }, + "secretKeysRevealable": { + "$ref": "#/definitions/secretKeyScope" + } + } + }, + "secretKeyMergeMode": { + "type": "string", + "enum": [ + "intersect" + ] + }, + "secretKeyScope": { + "oneOf": [ + { + "const": "*" + }, + { + "type": "array", + "items": { + "type": "string", + "not": { + "const": "*" + } + } + } + ] + }, + "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": "Configuration for the built-in local preset. `golem server run` uses these settings to run the local server, other `golem` commands use the router address and port to connect to it, and `golem server clean` uses the data directory.", + "additionalProperties": false, + "properties": { + "routerAddr": { + "type": "string", + "description": "Address where `golem server run` serves the built-in local preset's main API. Other `golem` commands targeting the preset connect to this address; `0.0.0.0` is translated to `127.0.0.1` when connecting. Must be an IPv4 address literal, such as `0.0.0.0` or `127.0.0.1`; host names are not supported." + }, + "routerPort": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port where `golem server run` serves the built-in local preset's main API and where other `golem` commands targeting the preset connect. 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 by `golem server run` for local server data and removed by `golem server clean`." + }, + "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" + }, + "version": { + "$ref": "#/definitions/appVersionSourceOverride", + "description": "Per-environment override, layered over the application-wide version source." + }, + "toolsMergeMode": { + "$ref": "#/definitions/mapMergeMode" + }, + "tools": { + "$ref": "#/definitions/toolBindings" + }, + "publishTools": { + "type": "object", + "description": "Local tools to publish as immutable grantable releases when this environment is deployed.", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "object", + "maxProperties": 0 + } + } + } + }, + "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" + } + } + }, + "appVersionSource": { + "description": "How the logical version attached to a deployment is computed: a literal version string, or a git/env source.", + "oneOf": [ + { + "type": "string", + "description": "Literal version string." + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "git": { + "$ref": "#/definitions/gitVersionSource" + } + }, + "required": [ + "git" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "env": { + "type": "string", + "description": "Name of the environment variable to read the version from." + } + }, + "required": [ + "env" + ] + } + ] + }, + "gitVersionSource": { + "description": "Git version source: either hash mode (hashOnly) or tag mode (the tag options); the two are mutually exclusive.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "hashOnly" + ], + "properties": { + "hashOnly": { + "const": true, + "description": "Use the short commit hash as the version, ignoring tags." + }, + "allowDirty": { + "type": "boolean", + "description": "Allow deploying with a dirty working tree, appending a '-dirty' marker. Default: false." + }, + "staticFallback": { + "type": "string", + "description": "Static version used when git cannot supply one (no git, no repo). Absent means those cases are errors." + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "tagPattern" + ], + "properties": { + "tagPattern": { + "type": "string", + "description": "Only consider tags matching this glob (git describe --match); use '*' for all tags." + }, + "commitInfo": { + "type": "boolean", + "description": "Append '--g' when HEAD is past the tag. Default: true." + }, + "hashFallback": { + "type": "boolean", + "description": "When no matching tag is found, use the short commit hash instead of staticFallback. Default: false." + }, + "allowDirty": { + "type": "boolean", + "description": "Allow deploying with a dirty working tree, appending a '-dirty' marker. Default: false." + }, + "staticFallback": { + "type": "string", + "description": "Static version used when git cannot supply one (no git, no repo, or no tag without hashFallback). Absent means those cases are errors." + } + } + } + ] + }, + "appVersionSourceOverride": { + "description": "Per-environment version override: same shapes as the root version, with all fields optional.", + "oneOf": [ + { + "type": "string", + "description": "Literal version string." + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "git": { + "$ref": "#/definitions/gitVersionSourceOverride" + } + }, + "required": [ + "git" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "env": { + "type": "string" + } + }, + "required": [ + "env" + ] + } + ] + }, + "gitVersionSourceOverride": { + "description": "Git version override: hash mode (hashOnly) or a partial tag-mode override (tagPattern optional).", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "hashOnly" + ], + "properties": { + "hashOnly": { + "const": true + }, + "allowDirty": { + "type": "boolean" + }, + "staticFallback": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "tagPattern": { + "type": "string" + }, + "commitInfo": { + "type": "boolean" + }, + "hashFallback": { + "type": "boolean" + }, + "allowDirty": { + "type": "boolean" + }, + "staticFallback": { + "type": "string" + } + } + } + ] + }, + "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": { + "external": { + "description": "External bridge SDK configuration for use outside Golem components", + "$ref": "#/definitions/bridgeSdkExternalTargets" + }, + "internal": { + "description": "Internal bridge SDK configuration for use inside Golem components", + "$ref": "#/definitions/bridgeSdkInternalTargets" + } + } + }, + "bridgeSdkExternalTargets": { + "description": "External bridge SDK mode 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" + } + } + }, + "bridgeSdkInternalTargets": { + "description": "Internal bridge SDK 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" + }, + "tools": { + "description": "Logical tool names, local component matchers, or \"*\". Registry sources are resolved from the canonical declarations under tools.", + "$ref": "#/definitions/lenientTokenList" + } + } + }, + "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" + ] + }, + "registrySubject": { + "description": "An exact immutable tool release reference by ID or by publisher account, tool root name, and version.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "releaseId" + ], + "properties": { + "releaseId": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "account", + "name", + "version" + ], + "properties": { + "account": { + "type": "string" + }, + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" + }, + "version": { + "type": "string" + } + } + } + ] + }, + "registrySource": { + "type": "object", + "additionalProperties": false, + "required": [ + "registry" + ], + "properties": { + "registry": { + "$ref": "#/definitions/registrySubject" + } + } + }, + "localSubject": { + "type": "object", + "additionalProperties": false, + "required": [ + "component", + "name" + ], + "properties": { + "component": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "componentDependencyReference": { + "description": "A guest bridge dependency reference. Registry tools use their logical tool name; local agents and tools use 'component/name' or {component, name}.", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/localSubject" + } + ] + } + } +} diff --git a/docs/src/content/next/app-manifest.mdx b/docs/src/content/next/app-manifest.mdx index 33ac5742c8..0145e1f589 100644 --- a/docs/src/content/next/app-manifest.mdx +++ b/docs/src/content/next/app-manifest.mdx @@ -14,7 +14,7 @@ import {MultiPlatformCommand} from "@/components/multi-platform-command" ## JSON schema -For the application manifest format we also publish _JSON schemas_. The current version (1.5.0) is available [here](https://schema.golem.cloud/app/golem/1.5.0/golem.schema.json). +For the application manifest format we also publish _JSON schemas_. The current version (1.6.0-dev.9) is available [here](https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json). The _JSON schema_ is intended to be used with _editors_ and _IDEs_, to help with base _validation_ and _code completion_. @@ -24,12 +24,12 @@ Use the following _YAML comments_ at the start of your `golem.yaml` documents to commands={[ { label: "IntelliJ IDEA based products", - command: "# $schema: https://schema.golem.cloud/app/golem/1.5.0/golem.schema.json", + command: "# $schema: https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json", }, { label: "VSCode with YAML Language Support plugin", command: - "# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.5.0/golem.schema.json", + "# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json", }, ]} language={"yaml"} @@ -164,11 +164,59 @@ agents: - credentials.search.token ``` -### Current scope +### Publishing and consuming exact releases -This manifest support deploys component-implemented tools, compiles their effective bindings, and preserves the resulting metadata with the deployment. It does not yet provide runtime tool discovery or invocation. Host-implemented tools, imported MCP tools, middleware chains, capability validation, cross-environment or cross-account registrations and grants, tool child-instance execution, and runtime enforcement of the compiled secret policy are separate capabilities. +A publisher opts a local tool into the registry for each environment. A successful deployment of this environment publishes an immutable, grantable release that pins the component revision, tool version, metadata version, and metadata digest: -Bridge generation continues to use tools discovered from components in the local application: +```yaml +tools: + search: {} + +environments: + production: + server: cloud + publishTools: + search: {} +``` + +A release is not globally visible. A consumer selects an exact release once with `tools..source.registry` and owns the provision configuration and bindings used by its deployment. Referencing the release is explicit consent for Golem to create the environment grant required by the deployment: + +```yaml +tools: + search: + source: + registry: + account: publisher@example.com + name: search + version: "1.2.0" + config: + index: consumer-documents + env: + LOG_LEVEL: info + +environments: + production: + server: cloud + tools: + search: + secretKeysReadable: + - credentials.search.token + +agents: + SupportAgent: + tools: + search: {} +``` + +Registry subjects can use `releaseId` instead of the `account`, `name`, and `version` coordinate. Both forms resolve an exact immutable release. Deployment planning shows the automatic grant changes implied by registry-backed tool declarations. Deployment creates missing grants before reading registry metadata or building bridge clients, and removes obsolete automatically managed grants. Protected system grants and grants created separately by an administrator are retained. The deploy actor must have permission to make the planned grant changes. + +Remote release upgrades are explicit. Publishing a newer release does not move an existing consumer deployment. Selecting the new release creates a new deployment snapshot and reconciles its grant. Removing the registry-backed declaration or de-publishing a release prevents new deployments from selecting it, but existing deployment snapshots and historical replay continue to use their pinned source. De-publication also removes ordinary active grants; restoring the release does not restore those grants. + +Runtime discovery and admission use the consumer deployment snapshot and the caller's environment policy. GOL-28 stops at the admitted component dispatch boundary; actual component-sidecar invocation is not implemented by this work. Executor-native Host tool dispatch is owned separately by GOL-24. + +### Bridge generation + +Local bridge generation continues to accept tool aliases: ```yaml bridge: @@ -177,7 +225,16 @@ bridge: tools: [search] ``` -There is no `local | registry` bridge source selector or registry fallback. Registry-backed bridge generation requires the cross-environment registration and grant model; the future source-selection design must also remain consistent with agent/RPC clients. +Registry-backed bridge generation refers to the logical name. The exact source is resolved from the canonical declaration under `tools`: + +```yaml +bridge: + rust: + internal: + tools: [search] +``` + +The generator reads only the release's safe tool definition and metadata digest. It does not require the publisher's WASM or permission to inspect the publisher component. Release identity and metadata digest are included in generation cache markers, so changing the selected release invalidates generated output. A missing grant is planned and created before metadata access; planning fails clearly when the deploy actor lacks permission to reconcile the grant. ## Field reference @@ -515,6 +572,12 @@ There is no `local | registry` bridge source selector or registry fallback. Regi ***Optional boolean*** to allow security overrides during deployment. + .publishTools", + }}> + ***Optional map of local tool names*** to publish as immutable grantable releases when this environment is deployed. Each value is an empty object. Publication pins the exact source and metadata of the deployed local tool. + + @@ -1520,6 +1583,18 @@ There is no `local | registry` bridge source selector or registry fallback. Regi ***Optional boolean*** which when set to true marks the preset as the default for selection. + + ***Optional map of canonical tool declarations*** indexed by the tool's lower-kebab-case logical name. A declaration either matches a locally discovered implementation or selects an exact registry release with `source.registry`. Source coordinates are specified only here; agents, component dependencies, bridge generation, and other consumers refer to the logical name. Provision fields such as `config`, `env`, `plugins`, and `files` belong to the consuming deployment. + + + .source.registry", + }}> + ***Optional exact registry release source***. Specify either `releaseId` or all of `account`, `name`, and `version`. Referencing the release authorizes Golem to reconcile its automatically managed environment grant; planned changes remain visible during deployment. Omit `source` for a locally implemented tool. + + + .internal.tools", + }}> + ***Optional logical tool names, local component matchers, or `"*"`*** for generated guest bridge clients. Registry release coordinates are resolved from the corresponding declaration under `tools` and cannot be repeated here. + + Make sure to include `Content-Type: multipart/form-data` Header + +**Field `file`**: string binary + +**Example Response JSON** + +```json copy +{ + "contentHash": "string", + "size": 0 +} +``` + ## List all application environments Path|Method|Protected ---|---|--- @@ -253,6 +277,15 @@ Path|Method|Protected "domain": "string", "hash": "string" } + ], + "remoteTools": [ + { + "name": "string", + "hash": "string" + } + ], + "publishedTools": [ + "string" ] } ``` @@ -372,6 +405,8 @@ Path|Method|Protected } } ], + "publishTools": [], + "remoteTools": [], "replaceIncompatibleAgentSecrets": false } ``` @@ -429,6 +464,15 @@ Path|Method|Protected "domain": "string", "hash": "string" } + ], + "remoteTools": [ + { + "name": "string", + "hash": "string" + } + ], + "publishedTools": [ + "string" ] } ``` @@ -1379,6 +1423,7 @@ Path|Method|Protected "values": [ { "deploymentRevision": 0, + "releaseId": "829d5913-a352-42a5-be6f-1526f30c0c85", "definition": { "version": "string", "commands": { @@ -1820,7 +1865,8 @@ Path|Method|Protected }, "ownerAccountId": "2936251c-eefb-44ec-9d94-a810fc3cf72b", "ownerAccountEmail": "string", - "metadataVersion": "string" + "metadataVersion": "string", + "metadataDigest": "string" } ] } @@ -1842,6 +1888,7 @@ Path|Method|Protected ```json copy { "deploymentRevision": 0, + "releaseId": "829d5913-a352-42a5-be6f-1526f30c0c85", "definition": { "version": "string", "commands": { @@ -2289,7 +2336,8 @@ Path|Method|Protected }, "ownerAccountId": "2936251c-eefb-44ec-9d94-a810fc3cf72b", "ownerAccountEmail": "string", - "metadataVersion": "string" + "metadataVersion": "string", + "metadataDigest": "string" } ``` ## Environment API Errors diff --git a/docs/src/content/next/rest-api/tool-releases.mdx b/docs/src/content/next/rest-api/tool-releases.mdx new file mode 100644 index 0000000000..91e967e793 --- /dev/null +++ b/docs/src/content/next/rest-api/tool-releases.mdx @@ -0,0 +1,1913 @@ +# Tool Releases API + +## List tool releases owned by an account +Path|Method|Protected +---|---|--- +`/v1/accounts/{account_id}/tool-releases`|GET|Yes + + + + + + + +**Example Response JSON** + +```json copy +{ + "values": [ + { + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ownerAccountId": "2936251c-eefb-44ec-9d94-a810fc3cf72b", + "name": "string", + "version": "string", + "source": { + "kind": "component", + "componentId": "616ccd92-d666-4180-8349-8d125b269fac", + "componentRevision": 0, + "componentName": "string" + }, + "definition": { + "version": "string", + "commands": { + "nodes": [ + { + "name": "string", + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "globals": { + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + null + ], + "examples": [ + null + ], + "deprecated": "string", + "role": {} + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ] + }, + "subcommands": [ + 0 + ], + "body": { + "positionals": { + "fixed": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + null + ], + "examples": [ + null + ], + "deprecated": "string", + "role": {} + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "accepts_stdio": true + } + ], + "tail": { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "item_type": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "min": 0, + "max": 0, + "separator": "string", + "verbatim": true, + "accepts_stdio": true + } + }, + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + null + ], + "examples": [ + null + ], + "deprecated": "string", + "role": {} + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ], + "constraints": [ + { + "kind": "requires-all", + "value": [ + { + "kind": "present", + "value": "string" + } + ] + } + ], + "stdin": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "stdout": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "result": { + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "formatters": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + } + } + ], + "default_formatter": "string" + }, + "errors": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "kind": "usage-error", + "exit_code": 0, + "payload": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "annotations": { + "read_only": true, + "destructive": true, + "idempotent": true, + "open_world": true + } + } + } + ] + }, + "schema": { + "defs": [ + { + "id": "string", + "name": "string", + "body": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "root": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + }, + "metadataVersion": "string", + "metadataDigest": "string", + "lifecycle": "published", + "origin": "ordinary", + "systemAvailability": "grantable", + "createdAt": "2019-08-24T14:15:22Z", + "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e", + "stateChangedAt": "2019-08-24T14:15:22Z", + "stateChangedBy": "2b961163-2f23-4eaa-a259-05736f8f6815" + } + ] +} +``` + +## Get an account-owned tool release by ID +Path|Method|Protected +---|---|--- +`/v1/tool-releases/{release_id}`|GET|Yes + + + + + + + +**Example Response JSON** + +```json copy +{ + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ownerAccountId": "2936251c-eefb-44ec-9d94-a810fc3cf72b", + "name": "string", + "version": "string", + "source": { + "kind": "component", + "componentId": "616ccd92-d666-4180-8349-8d125b269fac", + "componentRevision": 0, + "componentName": "string" + }, + "definition": { + "version": "string", + "commands": { + "nodes": [ + { + "name": "string", + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "globals": { + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ] + }, + "subcommands": [ + 0 + ], + "body": { + "positionals": { + "fixed": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "accepts_stdio": true + } + ], + "tail": { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "item_type": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "min": 0, + "max": 0, + "separator": "string", + "verbatim": true, + "accepts_stdio": true + } + }, + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ], + "constraints": [ + { + "kind": "requires-all", + "value": [ + { + "kind": "present", + "value": "string" + } + ] + } + ], + "stdin": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "stdout": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "result": { + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "formatters": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + } + } + ], + "default_formatter": "string" + }, + "errors": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "kind": "usage-error", + "exit_code": 0, + "payload": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "annotations": { + "read_only": true, + "destructive": true, + "idempotent": true, + "open_world": true + } + } + } + ] + }, + "schema": { + "defs": [ + { + "id": "string", + "name": "string", + "body": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "root": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + }, + "metadataVersion": "string", + "metadataDigest": "string", + "lifecycle": "published", + "origin": "ordinary", + "systemAvailability": "grantable", + "createdAt": "2019-08-24T14:15:22Z", + "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e", + "stateChangedAt": "2019-08-24T14:15:22Z", + "stateChangedBy": "2b961163-2f23-4eaa-a259-05736f8f6815" +} +``` + +## De-publish an account-owned tool release +Path|Method|Protected +---|---|--- +`/v1/tool-releases/{release_id}`|DELETE|Yes + + + + + + + +**Example Response JSON** + +```json copy +{ + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ownerAccountId": "2936251c-eefb-44ec-9d94-a810fc3cf72b", + "name": "string", + "version": "string", + "source": { + "kind": "component", + "componentId": "616ccd92-d666-4180-8349-8d125b269fac", + "componentRevision": 0, + "componentName": "string" + }, + "definition": { + "version": "string", + "commands": { + "nodes": [ + { + "name": "string", + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "globals": { + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ] + }, + "subcommands": [ + 0 + ], + "body": { + "positionals": { + "fixed": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "accepts_stdio": true + } + ], + "tail": { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "item_type": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "min": 0, + "max": 0, + "separator": "string", + "verbatim": true, + "accepts_stdio": true + } + }, + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ], + "constraints": [ + { + "kind": "requires-all", + "value": [ + { + "kind": "present", + "value": "string" + } + ] + } + ], + "stdin": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "stdout": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "result": { + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "formatters": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + } + } + ], + "default_formatter": "string" + }, + "errors": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "kind": "usage-error", + "exit_code": 0, + "payload": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "annotations": { + "read_only": true, + "destructive": true, + "idempotent": true, + "open_world": true + } + } + } + ] + }, + "schema": { + "defs": [ + { + "id": "string", + "name": "string", + "body": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "root": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + }, + "metadataVersion": "string", + "metadataDigest": "string", + "lifecycle": "published", + "origin": "ordinary", + "systemAvailability": "grantable", + "createdAt": "2019-08-24T14:15:22Z", + "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e", + "stateChangedAt": "2019-08-24T14:15:22Z", + "stateChangedBy": "2b961163-2f23-4eaa-a259-05736f8f6815" +} +``` + +## Restore a de-published account-owned tool release +Path|Method|Protected +---|---|--- +`/v1/tool-releases/{release_id}/restore`|POST|Yes + + + + + + + +**Example Response JSON** + +```json copy +{ + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "ownerAccountId": "2936251c-eefb-44ec-9d94-a810fc3cf72b", + "name": "string", + "version": "string", + "source": { + "kind": "component", + "componentId": "616ccd92-d666-4180-8349-8d125b269fac", + "componentRevision": 0, + "componentName": "string" + }, + "definition": { + "version": "string", + "commands": { + "nodes": [ + { + "name": "string", + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "globals": { + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ] + }, + "subcommands": [ + 0 + ], + "body": { + "positionals": { + "fixed": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "accepts_stdio": true + } + ], + "tail": { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "item_type": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "min": 0, + "max": 0, + "separator": "string", + "verbatim": true, + "accepts_stdio": true + } + }, + "options": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "value_name": "string", + "shape": { + "kind": "scalar", + "value": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + }, + "default": { + "kind": "bool", + "value": true + }, + "required": true, + "env_var": "string" + } + ], + "flags": [ + { + "long": "string", + "short": null, + "aliases": [ + "string" + ], + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "shape": { + "kind": "bool-flag", + "value": { + "default": true, + "negatable": true + } + }, + "env_var": "string" + } + ], + "constraints": [ + { + "kind": "requires-all", + "value": [ + { + "kind": "present", + "value": "string" + } + ] + } + ], + "stdin": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "stdout": { + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "mime": [ + "string" + ], + "required": true + }, + "result": { + "type_": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + }, + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "formatters": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + } + } + ], + "default_formatter": "string" + }, + "errors": [ + { + "name": "string", + "doc": { + "summary": "string", + "description": "string", + "examples": [ + { + "title": "string", + "body": "string" + } + ] + }, + "kind": "usage-error", + "exit_code": 0, + "payload": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "annotations": { + "read_only": true, + "destructive": true, + "idempotent": true, + "open_world": true + } + } + } + ] + }, + "schema": { + "defs": [ + { + "id": "string", + "name": "string", + "body": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + ], + "root": { + "kind": "ref", + "value": { + "id": "string", + "metadata": { + "doc": "string", + "aliases": [ + "string" + ], + "examples": [ + "string" + ], + "deprecated": "string", + "role": { + "tag": "multimodal" + } + } + } + } + } + }, + "metadataVersion": "string", + "metadataDigest": "string", + "lifecycle": "published", + "origin": "ordinary", + "systemAvailability": "grantable", + "createdAt": "2019-08-24T14:15:22Z", + "createdBy": "25a02396-1048-48f9-bf93-102d2fb7895e", + "stateChangedAt": "2019-08-24T14:15:22Z", + "stateChangedBy": "2b961163-2f23-4eaa-a259-05736f8f6815" +} +``` +## Tool Releases API Errors +Status Code|Description|Body +---|---|--- +400|Invalid request, returning with a list of issues detected in the request|`{"code":"string","errors":["string"]}` +401|Unauthorized request|`{"code":"string","error":"string"}` +403|Forbidden Request|`{"code":"string","error":"string"}` +404|Entity not found|`{"code":"string","error":"string"}` +409||`{"code":"string","error":"string"}` +422|Limits of the plan exceeded|`{"code":"string","error":"string"}` +500|Internal server error|`{"code":"string","error":"string"}` \ No newline at end of file diff --git a/golem-api-grpc/proto/golem/registry/environment_state.proto b/golem-api-grpc/proto/golem/registry/environment_state.proto index cee853f355..4eab405832 100644 --- a/golem-api-grpc/proto/golem/registry/environment_state.proto +++ b/golem-api-grpc/proto/golem/registry/environment_state.proto @@ -6,6 +6,8 @@ import "golem/component/agent.proto"; import "golem/component/component_id.proto"; import "golem/component/component_metadata.proto"; import "golem/common/account_id.proto"; +import "golem/common/hash.proto"; +import "golem/common/uuid.proto"; import "golem/registry/agent.proto"; import "golem/registry/agent_secret.proto"; import "golem/tool/tool.proto"; @@ -29,6 +31,18 @@ message ComponentToolSource { string component_name = 3; } +message HostToolSource { + string host_tool_id = 1; + string implementation_version = 2; +} + +message ToolSource { + oneof source { + ComponentToolSource component = 1; + HostToolSource host = 2; + } +} + message RegisteredTool { uint64 deployment_revision = 1; golem.tool.Tool definition = 2; @@ -37,6 +51,9 @@ message RegisteredTool { golem.common.AccountId owner_account_id = 5; string owner_account_email = 6; string metadata_version = 7; + optional golem.common.UUID tool_release_id = 8; + golem.common.Hash metadata_digest = 9; + ToolSource tagged_source = 10; } message CompiledToolBinding { @@ -52,6 +69,9 @@ message CompiledToolBinding { ComponentToolSource source = 10; string metadata_version = 11; ToolFilesystemAccess filesystem_access = 12; + optional golem.common.UUID tool_release_id = 13; + golem.common.Hash metadata_digest = 14; + ToolSource tagged_source = 15; } enum ToolFilesystemAccess { diff --git a/golem-client/build.rs b/golem-client/build.rs index a36fc95812..4bdc14ffc0 100644 --- a/golem-client/build.rs +++ b/golem-client/build.rs @@ -216,6 +216,55 @@ fn generate(yaml_path: PathBuf, out_dir: OsString) { "DeployedRegisteredTool", "golem_common::model::tool::DeployedRegisteredTool", ), + ("ToolSource", "golem_common::model::tool::ToolSource"), + ( + "ToolRelease", + "golem_common::model::tool_release::ToolRelease", + ), + ( + "ToolReleaseMetadata", + "golem_common::model::tool_release::ToolReleaseMetadata", + ), + ( + "ToolReleaseReference", + "golem_common::model::tool_release::ToolReleaseReference", + ), + ( + "ToolReleaseById", + "golem_common::model::tool_release::ToolReleaseById", + ), + ( + "ToolReleaseByCoordinates", + "golem_common::model::tool_release::ToolReleaseByCoordinates", + ), + ( + "ToolReleaseLifecycle", + "golem_common::model::tool_release::ToolReleaseLifecycle", + ), + ( + "ToolReleaseOrigin", + "golem_common::model::tool_release::ToolReleaseOrigin", + ), + ( + "SystemToolAvailability", + "golem_common::model::tool_release::SystemToolAvailability", + ), + ( + "EnvironmentToolGrant", + "golem_common::model::environment_tool_grant::EnvironmentToolGrant", + ), + ( + "EnvironmentToolGrantCreation", + "golem_common::model::environment_tool_grant::EnvironmentToolGrantCreation", + ), + ( + "EnvironmentToolGrantLifecycle", + "golem_common::model::environment_tool_grant::EnvironmentToolGrantLifecycle", + ), + ( + "EnvironmentToolGrantWithDetails", + "golem_common::model::environment_tool_grant::EnvironmentToolGrantWithDetails", + ), // domain_registration ( "DomainRegistration", diff --git a/golem-common/src/base_model/agent.rs b/golem-common/src/base_model/agent.rs index f02ae9c3e2..814bcc45aa 100644 --- a/golem-common/src/base_model/agent.rs +++ b/golem-common/src/base_model/agent.rs @@ -37,6 +37,15 @@ impl std::fmt::Display for AgentFileContentHash { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(poem_openapi::Object))] +#[cfg_attr(feature = "full", oai(rename_all = "camelCase"))] +#[serde(rename_all = "camelCase")] +pub struct InitialAgentFileUpload { + pub content_hash: AgentFileContentHash, + pub size: u64, +} + #[cfg(feature = "full")] impl desert_rust::BinarySerializer for AgentFileContentHash { fn serialize( diff --git a/golem-common/src/base_model/api.rs b/golem-common/src/base_model/api.rs index e0ed289d1e..9cb3351370 100644 --- a/golem-common/src/base_model/api.rs +++ b/golem-common/src/base_model/api.rs @@ -67,6 +67,7 @@ pub mod error_code { pub const AGENT_SECRET_VALUE_TYPE_MISMATCH: &str = "AGENT_SECRET_VALUE_TYPE_MISMATCH"; pub const APPLICATION_ALREADY_EXISTS: &str = "APPLICATION_ALREADY_EXISTS"; pub const COMPONENT_ALREADY_EXISTS: &str = "COMPONENT_ALREADY_EXISTS"; + pub const COMPONENT_IN_USE: &str = "COMPONENT_IN_USE"; pub const COMPONENT_NAME_ALREADY_EXISTS: &str = "COMPONENT_NAME_ALREADY_EXISTS"; pub const COMPONENT_VERSION_ALREADY_EXISTS: &str = "COMPONENT_VERSION_ALREADY_EXISTS"; pub const CONCURRENT_UPDATE: &str = "CONCURRENT_UPDATE"; @@ -80,6 +81,7 @@ pub mod error_code { pub const ENVIRONMENT_NOT_DEPLOYED: &str = "ENVIRONMENT_NOT_DEPLOYED"; pub const ENVIRONMENT_PLUGIN_GRANT_ALREADY_EXISTS: &str = "ENVIRONMENT_PLUGIN_GRANT_ALREADY_EXISTS"; + pub const ENVIRONMENT_TOOL_GRANT_ALREADY_EXISTS: &str = "ENVIRONMENT_TOOL_GRANT_ALREADY_EXISTS"; pub const ENVIRONMENT_PLUGIN_GRANT_CONFLICT: &str = "ENVIRONMENT_PLUGIN_GRANT_CONFLICT"; pub const ENVIRONMENT_SHARE_ALREADY_EXISTS: &str = "ENVIRONMENT_SHARE_ALREADY_EXISTS"; pub const HTTP_API_DEPLOYMENT_ALREADY_EXISTS: &str = "HTTP_API_DEPLOYMENT_ALREADY_EXISTS"; @@ -92,6 +94,7 @@ pub mod error_code { pub const RESOURCE_DEFINITION_ALREADY_EXISTS: &str = "RESOURCE_DEFINITION_ALREADY_EXISTS"; pub const SECURITY_SCHEME_ALREADY_EXISTS: &str = "SECURITY_SCHEME_ALREADY_EXISTS"; pub const TOKEN_ALREADY_EXISTS: &str = "TOKEN_ALREADY_EXISTS"; + pub const TOOL_RELEASE_IMMUTABLE_CONFLICT: &str = "TOOL_RELEASE_IMMUTABLE_CONFLICT"; // --- Validation --- pub const AGENT_TYPE_NOT_DECLARED: &str = "AGENT_TYPE_NOT_DECLARED"; @@ -141,6 +144,15 @@ pub mod error_code { pub const TOOL_DEFINITION_NAME_MISMATCH: &str = "TOOL_DEFINITION_NAME_MISMATCH"; pub const INVALID_TOOL: &str = "INVALID_TOOL"; pub const DUPLICATE_TOOL_IMPLEMENTATION: &str = "DUPLICATE_TOOL_IMPLEMENTATION"; + pub const TOOL_SOURCE_COLLISION: &str = "TOOL_SOURCE_COLLISION"; + pub const REMOTE_TOOL_UNAVAILABLE: &str = "REMOTE_TOOL_UNAVAILABLE"; + pub const REMOTE_TOOL_IDENTITY_MISMATCH: &str = "REMOTE_TOOL_IDENTITY_MISMATCH"; + pub const REMOTE_TOOL_UNSUPPORTED_METADATA_VERSION: &str = + "REMOTE_TOOL_UNSUPPORTED_METADATA_VERSION"; + pub const REMOTE_TOOL_METADATA_DIGEST_MISMATCH: &str = + "REMOTE_TOOL_METADATA_DIGEST_MISMATCH"; + pub const INVALID_REMOTE_TOOL: &str = "INVALID_REMOTE_TOOL"; + pub const REMOTE_TOOL_BINDING_UNKNOWN_AGENT: &str = "REMOTE_TOOL_BINDING_UNKNOWN_AGENT"; pub const TOOL_BINDING_UNKNOWN_AGENT: &str = "TOOL_BINDING_UNKNOWN_AGENT"; pub const TOOL_BINDING_VERSION_MISMATCH: &str = "TOOL_BINDING_VERSION_MISMATCH"; pub const TOOL_BINDING_ACCOUNT_MISMATCH: &str = "TOOL_BINDING_ACCOUNT_MISMATCH"; diff --git a/golem-common/src/base_model/card/class/account_tool_release.rs b/golem-common/src/base_model/card/class/account_tool_release.rs new file mode 100644 index 0000000000..4dc7118cfc --- /dev/null +++ b/golem-common/src/base_model/card/class/account_tool_release.rs @@ -0,0 +1,94 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::{ + ClassPermissionPattern, PermissionClass, PermissionPattern, PolymorphicClassPermissionPattern, + PolymorphicPermissionPattern, ResourcePattern, VerbPattern, +}; +use crate::base_model::card::parsing::CardParseError; +use crate::model::card::owner::AccountOwnerPattern; +use crate::model::tool::ToolName; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] +pub enum AccountToolReleaseResourcePattern { + Any, + Name(ToolName), +} + +impl ResourcePattern for AccountToolReleaseResourcePattern { + fn parse_resource(resource: &str) -> Result { + if resource == "*" { + Ok(Self::Any) + } else { + ToolName::try_from(resource).map(Self::Name).map_err(|_| { + CardParseError::InvalidResource { + class: AccountToolReleaseClass::NAME.to_string(), + resource: resource.to_string(), + } + }) + } + } + + fn subsumes(&self, other: &Self) -> bool { + match (self, other) { + (Self::Any, _) => true, + (Self::Name(a), Self::Name(b)) => a == b, + (Self::Name(_), Self::Any) => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] +pub enum AccountToolReleaseVerb { + View, + Publish, + DePublish, + Restore, +} + +impl VerbPattern for AccountToolReleaseVerb { + fn parse_verb(verb: &str) -> Option { + match verb { + "view" => Some(Self::View), + "publish" => Some(Self::Publish), + "de-publish" => Some(Self::DePublish), + "restore" => Some(Self::Restore), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] +pub struct AccountToolReleaseClass; + +impl PermissionClass for AccountToolReleaseClass { + type Verb = AccountToolReleaseVerb; + type Owner = AccountOwnerPattern; + type Resource = AccountToolReleaseResourcePattern; + const NAME: &'static str = "account.tool-release"; + + fn into_permission(pattern: ClassPermissionPattern) -> PermissionPattern { + PermissionPattern::AccountToolRelease(pattern) + } + + fn into_polymorphic_permission( + pattern: PolymorphicClassPermissionPattern, + ) -> PolymorphicPermissionPattern { + PolymorphicPermissionPattern::AccountToolRelease(pattern) + } +} diff --git a/golem-common/src/base_model/card/class/environment_tool_grant.rs b/golem-common/src/base_model/card/class/environment_tool_grant.rs new file mode 100644 index 0000000000..e17b9a2c17 --- /dev/null +++ b/golem-common/src/base_model/card/class/environment_tool_grant.rs @@ -0,0 +1,94 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::{ + ClassPermissionPattern, PermissionClass, PermissionPattern, PolymorphicClassPermissionPattern, + PolymorphicPermissionPattern, ResourcePattern, VerbPattern, +}; +use crate::base_model::card::parsing::CardParseError; +use crate::model::card::owner::EnvironmentOwnerPattern; +use crate::model::tool::ToolName; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] +pub enum EnvironmentToolGrantResourcePattern { + Any, + Name(ToolName), +} + +impl ResourcePattern for EnvironmentToolGrantResourcePattern { + fn parse_resource(resource: &str) -> Result { + if resource == "*" { + Ok(Self::Any) + } else { + ToolName::try_from(resource).map(Self::Name).map_err(|_| { + CardParseError::InvalidResource { + class: EnvironmentToolGrantClass::NAME.to_string(), + resource: resource.to_string(), + } + }) + } + } + + fn subsumes(&self, other: &Self) -> bool { + match (self, other) { + (Self::Any, _) => true, + (Self::Name(a), Self::Name(b)) => a == b, + (Self::Name(_), Self::Any) => false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] +pub enum EnvironmentToolGrantVerb { + View, + Create, + Delete, + Restore, +} + +impl VerbPattern for EnvironmentToolGrantVerb { + fn parse_verb(verb: &str) -> Option { + match verb { + "view" => Some(Self::View), + "create" => Some(Self::Create), + "delete" => Some(Self::Delete), + "restore" => Some(Self::Restore), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] +pub struct EnvironmentToolGrantClass; + +impl PermissionClass for EnvironmentToolGrantClass { + type Verb = EnvironmentToolGrantVerb; + type Owner = EnvironmentOwnerPattern; + type Resource = EnvironmentToolGrantResourcePattern; + const NAME: &'static str = "environment.tool-grant"; + + fn into_permission(pattern: ClassPermissionPattern) -> PermissionPattern { + PermissionPattern::EnvironmentToolGrant(pattern) + } + + fn into_polymorphic_permission( + pattern: PolymorphicClassPermissionPattern, + ) -> PolymorphicPermissionPattern { + PolymorphicPermissionPattern::EnvironmentToolGrant(pattern) + } +} diff --git a/golem-common/src/base_model/card/class/mod.rs b/golem-common/src/base_model/card/class/mod.rs index b058359690..d6ee8df2e0 100644 --- a/golem-common/src/base_model/card/class/mod.rs +++ b/golem-common/src/base_model/card/class/mod.rs @@ -17,6 +17,7 @@ mod account_oauth2_identity; mod account_permission_share; mod account_plugin; mod account_token; +mod account_tool_release; mod account_usage; mod agent; mod application; @@ -37,6 +38,7 @@ mod environment_plugin_grant; mod environment_resource_definition; mod environment_retry_policy; mod environment_security_scheme; +mod environment_tool_grant; mod filesystem; mod kv; mod network; @@ -58,6 +60,7 @@ pub use account_oauth2_identity::*; pub use account_permission_share::*; pub use account_plugin::*; pub use account_token::*; +pub use account_tool_release::*; pub use account_usage::*; pub use agent::*; pub use application::*; @@ -78,6 +81,7 @@ pub use environment_plugin_grant::*; pub use environment_resource_definition::*; pub use environment_retry_policy::*; pub use environment_security_scheme::*; +pub use environment_tool_grant::*; pub use filesystem::*; pub use kv::*; pub use network::*; @@ -109,9 +113,11 @@ macro_rules! card_permission_classes { AccountUsage: AccountUsageClass, AccountToken: AccountTokenClass, AccountPlugin: AccountPluginClass, + AccountToolRelease: AccountToolReleaseClass, Application: ApplicationClass, Environment: EnvironmentClass, EnvironmentPluginGrant: EnvironmentPluginGrantClass, + EnvironmentToolGrant: EnvironmentToolGrantClass, EnvironmentDomainRegistration: EnvironmentDomainRegistrationClass, EnvironmentSecurityScheme: EnvironmentSecuritySchemeClass, EnvironmentHttpApiDeployment: EnvironmentHttpApiDeploymentClass, diff --git a/golem-common/src/base_model/card/monomorphization.rs b/golem-common/src/base_model/card/monomorphization.rs index d84e8badce..8d73d55b1e 100644 --- a/golem-common/src/base_model/card/monomorphization.rs +++ b/golem-common/src/base_model/card/monomorphization.rs @@ -201,11 +201,17 @@ fn monomorphize_permission( PolymorphicPermissionPattern::AccountPlugin(p) => { mono_permission!(AccountPlugin, p, context) } + PolymorphicPermissionPattern::AccountToolRelease(p) => { + mono_permission!(AccountToolRelease, p, context) + } PolymorphicPermissionPattern::Application(p) => mono_permission!(Application, p, context), PolymorphicPermissionPattern::Environment(p) => mono_permission!(Environment, p, context), PolymorphicPermissionPattern::EnvironmentPluginGrant(p) => { mono_permission!(EnvironmentPluginGrant, p, context) } + PolymorphicPermissionPattern::EnvironmentToolGrant(p) => { + mono_permission!(EnvironmentToolGrant, p, context) + } PolymorphicPermissionPattern::EnvironmentDomainRegistration(p) => { mono_permission!(EnvironmentDomainRegistration, p, context) } diff --git a/golem-common/src/base_model/card/rendering.rs b/golem-common/src/base_model/card/rendering.rs index 8a6e899de7..36b23e4b6a 100644 --- a/golem-common/src/base_model/card/rendering.rs +++ b/golem-common/src/base_model/card/rendering.rs @@ -627,9 +627,11 @@ render_verb!(AccountVerb { View => "view", Update => "update", Delete => "delete render_verb!(AccountUsageVerb { View => "view", Update => "update" }); render_verb!(AccountTokenVerb { View => "view", Create => "create", Delete => "delete" }); render_verb!(AccountPluginVerb { View => "view", Register => "register", Delete => "delete", Restore => "restore" }); +render_verb!(AccountToolReleaseVerb { View => "view", Publish => "publish", DePublish => "de-publish", Restore => "restore" }); render_verb!(ApplicationVerb { View => "view", Create => "create", Update => "update", Delete => "delete" }); render_verb!(EnvironmentVerb { View => "view", Create => "create", Update => "update", Delete => "delete", Deploy => "deploy", Rollback => "rollback", ViewDeployment => "view-deployment", ViewDeploymentPlan => "view-deployment-plan", ViewAgentTypes => "view-agent-types", ViewTools => "view-tools", WriteDeploymentRecord => "write-deployment-record" }); render_verb!(EnvironmentPluginGrantVerb { View => "view", Create => "create", Delete => "delete" }); +render_verb!(EnvironmentToolGrantVerb { View => "view", Create => "create", Delete => "delete", Restore => "restore" }); render_verb!(EnvironmentDomainRegistrationVerb { View => "view", Create => "create", Delete => "delete" }); render_verb!(EnvironmentSecuritySchemeVerb { View => "view", Create => "create", Update => "update", Delete => "delete", Restore => "restore" }); render_verb!(EnvironmentHttpApiDeploymentVerb { View => "view", Create => "create", Update => "update", Delete => "delete", Restore => "restore" }); @@ -895,6 +897,14 @@ impl RenderFragment for AccountPluginResourcePattern { }) } } +impl RenderFragment for AccountToolReleaseResourcePattern { + fn render_fragment(&self) -> Result { + Ok(match self { + Self::Any => "*".to_string(), + Self::Name(name) => name.to_string(), + }) + } +} impl RenderFragment for AccountPermissionShareResourcePattern { fn render_fragment(&self) -> Result { Ok(match self { @@ -943,6 +953,15 @@ render_environment_named_resource!( EnvironmentBlobBucketResourcePattern, ); +impl RenderFragment for EnvironmentToolGrantResourcePattern { + fn render_fragment(&self) -> Result { + Ok(match self { + Self::Any => "*".to_string(), + Self::Name(name) => name.to_string(), + }) + } +} + impl RenderFragment for EnvironmentDomainRegistrationResourcePattern { fn render_fragment(&self) -> Result { Ok(match self { diff --git a/golem-common/src/base_model/card/rendering_tests.rs b/golem-common/src/base_model/card/rendering_tests.rs index 1edd377517..bdb1139e0f 100644 --- a/golem-common/src/base_model/card/rendering_tests.rs +++ b/golem-common/src/base_model/card/rendering_tests.rs @@ -22,6 +22,7 @@ use crate::model::card::recipient::RecipientPattern; use crate::model::component::ComponentName; use crate::model::environment::EnvironmentName; use crate::model::permission_share::PermissionShareName; +use crate::model::tool::ToolName; use proptest::collection::vec; use proptest::prelude::*; use std::str::FromStr; @@ -701,6 +702,22 @@ fn permission_strategy() -> BoxedStrategy { ] .boxed() ), + class_permission::( + account_owner(), + option_verb(vec![ + AccountToolReleaseVerb::View, + AccountToolReleaseVerb::Publish, + AccountToolReleaseVerb::DePublish, + AccountToolReleaseVerb::Restore + ]), + prop_oneof![ + Just(AccountToolReleaseResourcePattern::Any), + ident() + .prop_map(|name| ToolName::try_from(name).unwrap()) + .prop_map(AccountToolReleaseResourcePattern::Name) + ] + .boxed() + ), class_permission::( application_owner(), option_verb(vec![ @@ -747,6 +764,22 @@ fn permission_strategy() -> BoxedStrategy { ] .boxed() ), + class_permission::( + environment_owner(), + option_verb(vec![ + EnvironmentToolGrantVerb::View, + EnvironmentToolGrantVerb::Create, + EnvironmentToolGrantVerb::Delete, + EnvironmentToolGrantVerb::Restore + ]), + prop_oneof![ + Just(EnvironmentToolGrantResourcePattern::Any), + ident() + .prop_map(|name| ToolName::try_from(name).unwrap()) + .prop_map(EnvironmentToolGrantResourcePattern::Name) + ] + .boxed() + ), class_permission::( environment_owner(), option_verb(vec![ diff --git a/golem-common/src/base_model/card/subsumption_tests.rs b/golem-common/src/base_model/card/subsumption_tests.rs index 4f10b5fa42..df515d9c69 100644 --- a/golem-common/src/base_model/card/subsumption_tests.rs +++ b/golem-common/src/base_model/card/subsumption_tests.rs @@ -1599,6 +1599,11 @@ fn attenuation_accepts_fully_denied_ceiling_against_implicit_and_explicit_top() AccountOwnerPattern::Any, AccountPluginResourcePattern::Any ), + grant!( + AccountToolRelease, + AccountOwnerPattern::Any, + AccountToolReleaseResourcePattern::Any + ), grant!( Application, ApplicationOwnerPattern::AnyApplications, @@ -1614,6 +1619,11 @@ fn attenuation_accepts_fully_denied_ceiling_against_implicit_and_explicit_top() EnvironmentOwnerPattern::AnyEnvironments, EnvironmentPluginGrantResourcePattern::Any ), + grant!( + EnvironmentToolGrant, + EnvironmentOwnerPattern::AnyEnvironments, + EnvironmentToolGrantResourcePattern::Any + ), grant!( EnvironmentDomainRegistration, EnvironmentOwnerPattern::AnyEnvironments, @@ -1680,7 +1690,7 @@ fn attenuation_accepts_fully_denied_ceiling_against_implicit_and_explicit_top() AccountPermissionShareResourcePattern::Any ), ]; - assert_eq!(universal.len(), 34); + assert_eq!(universal.len(), 36); let implicit_top = card(Vec::new(), Vec::new()); let implicit_top_surface = DelegationSurface::from_cards(std::slice::from_ref(&implicit_top)); diff --git a/golem-common/src/base_model/deployment.rs b/golem-common/src/base_model/deployment.rs index b81a9f66de..6a3aadd6d1 100644 --- a/golem-common/src/base_model/deployment.rs +++ b/golem-common/src/base_model/deployment.rs @@ -21,6 +21,7 @@ use super::environment::EnvironmentId; use super::http_api_deployment::{HttpApiDeploymentId, HttpApiDeploymentRevision}; use super::mcp_deployment::{McpDeploymentId, McpDeploymentRevision}; use super::quota::ResourceDefinitionCreation; +use super::tool::{RemoteToolDeployment, ToolName}; use crate::{declare_revision, declare_structs, declare_transparent_newtypes}; use derive_more::Display; @@ -97,6 +98,12 @@ declare_structs! { pub retry_policy_defaults: Vec, #[serde(default)] #[cfg_attr(feature = "full", oai(default))] + pub publish_tools: Vec, + #[serde(default)] + #[cfg_attr(feature = "full", oai(default))] + pub remote_tools: Vec, + #[serde(default)] + #[cfg_attr(feature = "full", oai(default))] pub replace_incompatible_agent_secrets: bool, } @@ -112,6 +119,8 @@ declare_structs! { pub components: Vec, pub http_api_deployments: Vec, pub mcp_deployments: Vec, + pub remote_tools: Vec, + pub published_tools: Vec, } /// Summary of all entities tracked by the deployment @@ -121,6 +130,8 @@ declare_structs! { pub components: Vec, pub http_api_deployments: Vec, pub mcp_deployments: Vec, + pub remote_tools: Vec, + pub published_tools: Vec, } pub struct DeploymentPlanComponentEntry { @@ -143,4 +154,9 @@ declare_structs! { pub domain: Domain, pub hash: Hash, } + + pub struct DeploymentPlanRemoteToolEntry { + pub name: ToolName, + pub hash: Hash, + } } diff --git a/golem-common/src/base_model/diff/hash.rs b/golem-common/src/base_model/diff/hash.rs index a4fa9f7368..ec9e4d0432 100644 --- a/golem-common/src/base_model/diff/hash.rs +++ b/golem-common/src/base_model/diff/hash.rs @@ -22,6 +22,12 @@ use std::str::FromStr; #[derive(Clone, Copy, std::hash::Hash, PartialEq, Eq, Debug)] pub struct Hash(pub(crate) blake3::Hash); +impl Default for Hash { + fn default() -> Self { + Self::empty() + } +} + impl Hash { pub fn new(hash: blake3::Hash) -> Self { Self(hash) @@ -74,6 +80,32 @@ impl FromStr for Hash { } } +#[cfg(feature = "full")] +impl desert_rust::BinarySerializer for Hash { + fn serialize( + &self, + context: &mut desert_rust::SerializationContext, + ) -> desert_rust::Result<()> { + desert_rust::BinarySerializer::serialize(&self.0.as_bytes().to_vec(), context) + } +} + +#[cfg(feature = "full")] +impl desert_rust::BinaryDeserializer for Hash { + fn deserialize( + context: &mut desert_rust::DeserializationContext<'_>, + ) -> desert_rust::Result { + let bytes = as desert_rust::BinaryDeserializer>::deserialize(context)?; + let bytes: [u8; 32] = bytes.try_into().map_err(|bytes: Vec| { + desert_rust::Error::DeserializationFailure(format!( + "Invalid BLAKE3 digest length: {}", + bytes.len() + )) + })?; + Ok(Self::new(blake3::Hash::from_bytes(bytes))) + } +} + impl Serialize for Hash { fn serialize(&self, serializer: S) -> Result where diff --git a/golem-common/src/base_model/diff/mod.rs b/golem-common/src/base_model/diff/mod.rs index 901795a4bd..afa0861af5 100644 --- a/golem-common/src/base_model/diff/mod.rs +++ b/golem-common/src/base_model/diff/mod.rs @@ -16,4 +16,4 @@ pub mod hash; pub use hash::Hash; -pub const DIFF_MODEL_VERSION: u32 = 4; +pub const DIFF_MODEL_VERSION: u32 = 5; diff --git a/golem-common/src/base_model/environment_tool_grant.rs b/golem-common/src/base_model/environment_tool_grant.rs new file mode 100644 index 0000000000..0d2c17f509 --- /dev/null +++ b/golem-common/src/base_model/environment_tool_grant.rs @@ -0,0 +1,58 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::base_model::account::{AccountId, AccountSummary}; +use crate::base_model::environment::EnvironmentId; +use crate::base_model::tool_release::{ToolReleaseId, ToolReleaseMetadata, ToolReleaseReference}; +use crate::{declare_enums, declare_structs, newtype_uuid}; +use chrono::{DateTime, Utc}; + +newtype_uuid!(EnvironmentToolGrantId); + +declare_enums! { + pub enum EnvironmentToolGrantLifecycle { + Active, + Deleted, + } +} + +declare_structs! { + pub struct EnvironmentToolGrant { + pub id: EnvironmentToolGrantId, + pub environment_id: EnvironmentId, + pub tool_release_id: ToolReleaseId, + pub protected: bool, + pub automatic: bool, + pub lifecycle: EnvironmentToolGrantLifecycle, + pub created_at: DateTime, + pub created_by: AccountId, + pub state_changed_at: DateTime, + pub state_changed_by: AccountId, + } + + pub struct EnvironmentToolGrantWithDetails { + pub grant: EnvironmentToolGrant, + pub release: ToolReleaseMetadata, + pub release_owner: AccountSummary, + } + + pub struct EnvironmentToolGrantCreation { + pub release: ToolReleaseReference, + } + + pub struct EnvironmentToolGrantReconciliation { + pub creations: Vec, + pub deletions: Vec, + } +} diff --git a/golem-common/src/base_model/mod.rs b/golem-common/src/base_model/mod.rs index ac16db8688..1018ce2fed 100644 --- a/golem-common/src/base_model/mod.rs +++ b/golem-common/src/base_model/mod.rs @@ -32,6 +32,7 @@ pub mod domain_registration; pub mod durable_stream; pub mod environment; pub mod environment_plugin_grant; +pub mod environment_tool_grant; pub mod error; pub mod http_api_deployment; pub mod invocation_context; @@ -50,6 +51,7 @@ pub mod reports; pub mod retry_policy; pub mod security_scheme; pub mod tool; +pub mod tool_release; pub mod worker; pub mod worker_filter; diff --git a/golem-common/src/base_model/tool.rs b/golem-common/src/base_model/tool.rs index f5469edc69..749c48ab7c 100644 --- a/golem-common/src/base_model/tool.rs +++ b/golem-common/src/base_model/tool.rs @@ -15,11 +15,13 @@ use crate::base_model::account::{AccountEmail, AccountId}; use crate::base_model::agent_secret::CanonicalAgentSecretPath; use crate::base_model::component::{InitialAgentFile, InstalledPlugin}; +use crate::base_model::diff::Hash; use crate::base_model::json::NormalizedJsonValue; use crate::base_model::validate_lower_kebab_case_identifier; use crate::model::agent::AgentTypeName; use crate::model::component::{ComponentId, ComponentName, ComponentRevision}; use crate::model::deployment::DeploymentRevision; +use crate::model::tool_release::{ToolReleaseId, ToolReleaseReference}; use crate::schema::tool::Tool; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -211,8 +213,57 @@ pub struct ToolDeploymentMetadata { pub agent_bindings: BTreeMap, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(desert_rust::BinaryCodec, poem_openapi::Object) +)] +#[cfg_attr(feature = "full", desert(evolution()))] +#[cfg_attr(feature = "full", oai(rename_all = "camelCase"))] +#[serde(rename_all = "camelCase")] +#[allow(clippy::derive_partial_eq_without_eq)] +pub struct RemoteToolDeployment { + pub name: ToolName, + pub release: ToolReleaseReference, + pub provision: ToolProvisionConfig, + pub environment_binding: Option, + #[serde(default)] + #[cfg_attr(feature = "full", oai(default))] + pub agent_bindings: BTreeMap, +} + pub const TOOL_METADATA_WIT_VERSION: &str = "0.1.0"; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(desert_rust::BinaryCodec, poem_openapi::NewType) +)] +#[cfg_attr(feature = "full", desert(transparent))] +#[serde(try_from = "String", into = "String")] +pub struct HostToolId(String); + +impl HostToolId { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for HostToolId { + type Error = String; + + fn try_from(value: String) -> Result { + validate_lower_kebab_case_identifier("Host tool id", &value)?; + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: HostToolId) -> Self { + value.0 + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr( feature = "full", @@ -229,6 +280,12 @@ pub enum ToolSource { #[serde(rename = "componentName")] component_name: ComponentName, }, + Host { + #[serde(rename = "hostToolId")] + host_tool_id: HostToolId, + #[serde(rename = "implementationVersion")] + implementation_version: String, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -242,12 +299,18 @@ pub enum ToolSource { #[allow(clippy::derive_partial_eq_without_eq)] pub struct RegisteredTool { pub deployment_revision: DeploymentRevision, + #[serde(default)] + #[cfg_attr(feature = "full", desert(default))] + pub release_id: Option, pub definition: Tool, pub provision: ToolProvisionConfig, pub source: ToolSource, pub owner_account_id: AccountId, pub owner_account_email: AccountEmail, pub metadata_version: String, + #[serde(default)] + #[cfg_attr(feature = "full", desert(default))] + pub metadata_digest: Hash, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -260,10 +323,16 @@ pub struct RegisteredTool { #[serde(rename_all = "camelCase")] pub struct CompiledToolBinding { pub deployment_revision: DeploymentRevision, + #[serde(default)] + #[cfg_attr(feature = "full", desert(default))] + pub release_id: Option, pub agent_type_name: AgentTypeName, pub tool_name: ToolName, pub version: String, pub metadata_version: String, + #[serde(default)] + #[cfg_attr(feature = "full", desert(default))] + pub metadata_digest: Hash, pub account_id: AccountId, pub account_email: AccountEmail, pub parameters: NormalizedJsonValue, @@ -283,22 +352,26 @@ pub struct CompiledToolBinding { #[allow(clippy::derive_partial_eq_without_eq)] pub struct DeployedRegisteredTool { pub deployment_revision: DeploymentRevision, + pub release_id: Option, pub definition: Tool, pub source: ToolSource, pub owner_account_id: AccountId, pub owner_account_email: AccountEmail, pub metadata_version: String, + pub metadata_digest: Hash, } impl From for DeployedRegisteredTool { fn from(value: RegisteredTool) -> Self { Self { deployment_revision: value.deployment_revision, + release_id: value.release_id, definition: value.definition, source: value.source, owner_account_id: value.owner_account_id, owner_account_email: value.owner_account_email, metadata_version: value.metadata_version, + metadata_digest: value.metadata_digest, } } } diff --git a/golem-common/src/base_model/tool_release.rs b/golem-common/src/base_model/tool_release.rs new file mode 100644 index 0000000000..b1701e9995 --- /dev/null +++ b/golem-common/src/base_model/tool_release.rs @@ -0,0 +1,146 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::base_model::account::{AccountEmail, AccountId}; +use crate::base_model::diff::Hash; +use crate::base_model::tool::{ToolName, ToolSource}; +use crate::schema::tool::Tool; +use crate::{declare_enums, declare_structs, declare_unions, newtype_uuid}; +use chrono::{DateTime, Utc}; + +newtype_uuid!(ToolReleaseId); + +pub type ToolReleaseSource = ToolSource; + +declare_enums! { + pub enum ToolReleaseLifecycle { + Published, + DePublished, + } + + pub enum ToolReleaseOrigin { + Ordinary, + ProtectedSystem, + } + + pub enum SystemToolAvailability { + Grantable, + AutoGranted, + Ambient, + } +} + +declare_structs! { + pub struct ToolRelease { + pub id: ToolReleaseId, + pub owner_account_id: AccountId, + pub name: ToolName, + pub version: String, + pub source: ToolReleaseSource, + pub definition: Tool, + pub metadata_version: String, + pub metadata_digest: Hash, + pub lifecycle: ToolReleaseLifecycle, + pub origin: ToolReleaseOrigin, + pub system_availability: Option, + pub created_at: DateTime, + pub created_by: AccountId, + pub state_changed_at: DateTime, + pub state_changed_by: AccountId, + } + + /// Safe release metadata available to a consumer through an active environment grant. + /// Executable source identities remain publisher-only. + pub struct ToolReleaseMetadata { + pub id: ToolReleaseId, + pub name: ToolName, + pub version: String, + pub definition: Tool, + pub metadata_version: String, + pub metadata_digest: Hash, + pub source_digest: Hash, + } + + #[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] + #[cfg_attr(feature = "full", desert(evolution()))] + pub struct ToolReleaseById { + pub release_id: ToolReleaseId, + } + + #[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] + #[cfg_attr(feature = "full", desert(evolution()))] + pub struct ToolReleaseByCoordinates { + pub account: AccountEmail, + pub name: ToolName, + pub version: String, + } + + pub struct SystemToolReleaseProvision { + pub name: ToolName, + pub version: String, + pub source: ToolReleaseSource, + pub definition: Tool, + pub metadata_version: String, + pub availability: SystemToolAvailability, + } +} + +declare_unions! { + #[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] + #[cfg_attr(feature = "full", desert(evolution()))] + pub enum ToolReleaseReference { + ById(ToolReleaseById), + ByCoordinates(ToolReleaseByCoordinates), + } +} + +impl From<&ToolRelease> for ToolReleaseMetadata { + fn from(value: &ToolRelease) -> Self { + Self { + id: value.id, + name: value.name.clone(), + version: value.version.clone(), + definition: value.definition.clone(), + metadata_version: value.metadata_version.clone(), + metadata_digest: value.metadata_digest, + source_digest: tool_source_digest(&value.source), + } + } +} + +pub fn tool_source_digest(source: &ToolReleaseSource) -> Hash { + let mut input = Vec::from(b"golem:tool-source:v1\0".as_slice()); + match source { + ToolReleaseSource::Component { + component_id, + component_revision, + component_name, + } => { + input.extend_from_slice(b"component\0"); + input.extend_from_slice(component_id.0.as_bytes()); + input.extend_from_slice(&component_revision.get().to_le_bytes()); + input.extend_from_slice(component_name.0.as_bytes()); + } + ToolReleaseSource::Host { + host_tool_id, + implementation_version, + } => { + input.extend_from_slice(b"host\0"); + input.extend_from_slice(host_tool_id.as_str().as_bytes()); + input.push(0); + input.extend_from_slice(implementation_version.as_bytes()); + } + } + blake3::hash(&input).into() +} diff --git a/golem-common/src/model/component_metadata.rs b/golem-common/src/model/component_metadata.rs index 25a74ea23e..0cc28cfc99 100644 --- a/golem-common/src/model/component_metadata.rs +++ b/golem-common/src/model/component_metadata.rs @@ -1081,17 +1081,50 @@ mod protobuf { } } - impl From for golem_api_grpc::proto::golem::registry::ComponentToolSource { - fn from(value: ToolSource) -> Self { - let ToolSource::Component { + fn legacy_component_tool_source( + source: &ToolSource, + ) -> Option { + match source { + ToolSource::Component { component_id, component_revision, component_name, - } = value; + } => Some( + golem_api_grpc::proto::golem::registry::ComponentToolSource { + component_id: Some((*component_id).into()), + component_revision: (*component_revision).into(), + component_name: component_name.0.clone(), + }, + ), + ToolSource::Host { .. } => None, + } + } + + impl From for golem_api_grpc::proto::golem::registry::ToolSource { + fn from(value: ToolSource) -> Self { Self { - component_id: Some(component_id.into()), - component_revision: component_revision.into(), - component_name: component_name.0, + source: Some(match value { + ToolSource::Component { + component_id, + component_revision, + component_name, + } => golem_api_grpc::proto::golem::registry::tool_source::Source::Component( + golem_api_grpc::proto::golem::registry::ComponentToolSource { + component_id: Some(component_id.into()), + component_revision: component_revision.into(), + component_name: component_name.0, + }, + ), + ToolSource::Host { + host_tool_id, + implementation_version, + } => golem_api_grpc::proto::golem::registry::tool_source::Source::Host( + golem_api_grpc::proto::golem::registry::HostToolSource { + host_tool_id: host_tool_id.as_str().to_string(), + implementation_version, + }, + ), + }), } } } @@ -1114,16 +1147,52 @@ mod protobuf { } } + impl TryFrom for ToolSource { + type Error = String; + + fn try_from( + value: golem_api_grpc::proto::golem::registry::ToolSource, + ) -> Result { + match value.source.ok_or("missing ToolSource.source")? { + golem_api_grpc::proto::golem::registry::tool_source::Source::Component(value) => { + value.try_into() + } + golem_api_grpc::proto::golem::registry::tool_source::Source::Host(value) => { + Ok(Self::Host { + host_tool_id: crate::model::tool::HostToolId::try_from(value.host_tool_id)?, + implementation_version: value.implementation_version, + }) + } + } + } + } + + fn tool_source_from_proto( + tagged_source: Option, + legacy_source: Option, + ) -> Result { + if let Some(source) = tagged_source { + source.try_into() + } else { + legacy_source.ok_or("missing tool source")?.try_into() + } + } + impl From for golem_api_grpc::proto::golem::registry::RegisteredTool { fn from(value: RegisteredTool) -> Self { + let source = legacy_component_tool_source(&value.source); + let tagged_source = Some(value.source.into()); Self { deployment_revision: value.deployment_revision.into(), definition: Some(value.definition.into()), provision: Some(value.provision.into()), - source: Some(value.source.into()), + source, owner_account_id: Some(value.owner_account_id.into()), owner_account_email: value.owner_account_email.into_inner(), metadata_version: value.metadata_version, + tool_release_id: value.release_id.map(|id| id.0.into()), + metadata_digest: Some(value.metadata_digest.into()), + tagged_source, } } } @@ -1134,33 +1203,48 @@ mod protobuf { fn try_from( value: golem_api_grpc::proto::golem::registry::RegisteredTool, ) -> Result { + let definition: crate::schema::tool::Tool = value + .definition + .ok_or("missing RegisteredTool.definition")? + .try_into()?; + let metadata_version = value.metadata_version; + let metadata_digest = match value.metadata_digest { + Some(metadata_digest) => metadata_digest.try_into()?, + None => { + crate::model::tool_release::tool_metadata_digest(&metadata_version, &definition) + .map_err(|error| { + format!("failed to derive tool metadata digest: {error}") + })? + } + }; Ok(Self { deployment_revision: DeploymentRevision::try_from(value.deployment_revision)?, - definition: value - .definition - .ok_or("missing RegisteredTool.definition")? - .try_into()?, + definition, provision: value .provision .ok_or("missing RegisteredTool.provision")? .try_into()?, - source: value - .source - .ok_or("missing RegisteredTool.source")? - .try_into()?, + source: tool_source_from_proto(value.tagged_source, value.source)?, owner_account_id: AccountId::try_from( value .owner_account_id .ok_or("missing RegisteredTool.owner_account_id")?, )?, owner_account_email: AccountEmail::new(value.owner_account_email), - metadata_version: value.metadata_version, + metadata_version, + release_id: value + .tool_release_id + .map(uuid::Uuid::from) + .map(crate::model::tool_release::ToolReleaseId), + metadata_digest, }) } } impl From for golem_api_grpc::proto::golem::registry::CompiledToolBinding { fn from(value: CompiledToolBinding) -> Self { + let source = legacy_component_tool_source(&value.source); + let tagged_source = Some(value.source.into()); Self { deployment_revision: value.deployment_revision.into(), agent_type_name: value.agent_type_name.0, @@ -1172,11 +1256,14 @@ mod protobuf { parameters_json: value.parameters.to_string(), secret_keys_readable: Some(value.secret_keys_readable.into()), secret_keys_revealable: Some(value.secret_keys_revealable.into()), - source: Some(value.source.into()), + source, filesystem_access: golem_api_grpc::proto::golem::registry::ToolFilesystemAccess::from( value.filesystem_access, ) as i32, + tool_release_id: value.release_id.map(|id| id.0.into()), + metadata_digest: Some(value.metadata_digest.into()), + tagged_source, } } } @@ -1193,6 +1280,15 @@ mod protobuf { tool_name: ToolName::try_from(value.tool_name)?, version: value.version, metadata_version: value.metadata_version, + release_id: value + .tool_release_id + .map(uuid::Uuid::from) + .map(crate::model::tool_release::ToolReleaseId), + metadata_digest: value + .metadata_digest + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default(), account_id: AccountId::try_from( value .account_id @@ -1217,10 +1313,7 @@ mod protobuf { ) .map_err(|error| error.to_string())? .into(), - source: value - .source - .ok_or("missing CompiledToolBinding.source")? - .try_into()?, + source: tool_source_from_proto(value.tagged_source, value.source)?, }) } } @@ -1300,22 +1393,26 @@ mod protobuf { } let mut agent_tool_bindings = BTreeMap::new(); for proto in value.agent_tool_bindings { - let binding: CompiledToolBinding = proto.try_into()?; + let metadata_digest_missing = proto.metadata_digest.is_none(); + let tool_name = ToolName::try_from(proto.tool_name.as_str())?; + let registered = registered_tools.get(&tool_name).ok_or_else(|| { + format!("compiled binding references unregistered tool {tool_name}") + })?; + let mut binding: CompiledToolBinding = proto.try_into()?; + if metadata_digest_missing { + binding.metadata_digest = registered.metadata_digest; + } if binding.deployment_revision != deployment_revision { return Err(format!( "compiled tool binding deployment revision {} does not match snapshot revision {}", binding.deployment_revision, deployment_revision )); } - let registered = registered_tools.get(&binding.tool_name).ok_or_else(|| { - format!( - "compiled binding references unregistered tool {}", - binding.tool_name - ) - })?; if binding.source != registered.source || binding.version != registered.definition.version || binding.metadata_version != registered.metadata_version + || binding.release_id != registered.release_id + || binding.metadata_digest != registered.metadata_digest || binding.account_id != registered.owner_account_id || binding.account_email != registered.owner_account_email { @@ -1622,6 +1719,7 @@ mod tests { fn tool_deployment_state_proto_rejects_registered_tool_from_another_revision() { let registered_tool = RegisteredTool { deployment_revision: DeploymentRevision::try_from(1_u64).unwrap(), + release_id: None, definition: sample_tool(), provision: ToolProvisionConfig::default(), source: ToolSource::Component { @@ -1632,6 +1730,7 @@ mod tests { owner_account_id: AccountId(uuid::Uuid::new_v4()), owner_account_email: AccountEmail::new("owner@example.com"), metadata_version: "0.1.0".to_string(), + metadata_digest: Default::default(), }; let proto = golem_api_grpc::proto::golem::registry::ToolDeploymentState { deployment_revision: 2, @@ -1659,21 +1758,28 @@ mod tests { }; let owner_account_id = AccountId(uuid::Uuid::new_v4()); let owner_account_email = AccountEmail::new("owner@example.com"); + let definition = sample_tool(); + let metadata_digest = + crate::model::tool_release::tool_metadata_digest("0.1.0", &definition).unwrap(); let registered = RegisteredTool { deployment_revision, - definition: sample_tool(), + release_id: None, + definition, provision: ToolProvisionConfig::default(), source: source.clone(), owner_account_id, owner_account_email: owner_account_email.clone(), metadata_version: "0.1.0".to_string(), + metadata_digest, }; let binding = CompiledToolBinding { deployment_revision, + release_id: None, agent_type_name: agent_type_name.clone(), tool_name: tool_name.clone(), version: registered.definition.version.clone(), metadata_version: registered.metadata_version.clone(), + metadata_digest, account_id: owner_account_id, account_email: owner_account_email, parameters: NormalizedJsonValue::new(serde_json::json!({})), @@ -1712,21 +1818,28 @@ mod tests { }; let owner_account_id = AccountId(uuid::Uuid::new_v4()); let owner_account_email = AccountEmail::new("owner@example.com"); + let definition = sample_tool(); + let metadata_digest = + crate::model::tool_release::tool_metadata_digest("0.1.0", &definition).unwrap(); let registered = RegisteredTool { deployment_revision, - definition: sample_tool(), + release_id: None, + definition, provision: ToolProvisionConfig::default(), source: source.clone(), owner_account_id, owner_account_email: owner_account_email.clone(), metadata_version: "0.1.0".to_string(), + metadata_digest, }; let binding = CompiledToolBinding { deployment_revision, + release_id: None, agent_type_name: agent_type_name.clone(), tool_name: tool_name.clone(), version: registered.definition.version.clone(), metadata_version: registered.metadata_version.clone(), + metadata_digest, account_id: owner_account_id, account_email: owner_account_email, parameters: NormalizedJsonValue::new(serde_json::json!({ "root": "/workspace" })), @@ -1752,10 +1865,84 @@ mod tests { "a coherent deployment snapshot must reject bindings from another revision" ); + let mut mismatched_release_proto: golem_api_grpc::proto::golem::registry::ToolDeploymentState = + state.clone().into(); + mismatched_release_proto.agent_tool_bindings[0].tool_release_id = + Some(crate::model::tool_release::ToolReleaseId::new().0.into()); + assert!( + ToolDeploymentState::try_from(mismatched_release_proto).is_err(), + "a coherent deployment snapshot must reject mismatched release identities" + ); + + let mut mismatched_digest_proto: golem_api_grpc::proto::golem::registry::ToolDeploymentState = + state.clone().into(); + mismatched_digest_proto.agent_tool_bindings[0].metadata_digest = + Some(crate::model::diff::Hash::empty().into()); + assert!( + ToolDeploymentState::try_from(mismatched_digest_proto).is_err(), + "a coherent deployment snapshot must reject mismatched metadata digests" + ); + let proto: golem_api_grpc::proto::golem::registry::ToolDeploymentState = state.clone().into(); - let decoded = ToolDeploymentState::try_from(proto).unwrap(); + assert!(proto.registered_tools[0].source.is_some()); + assert!(proto.registered_tools[0].tagged_source.is_some()); + assert!(proto.agent_tool_bindings[0].source.is_some()); + assert!(proto.agent_tool_bindings[0].tagged_source.is_some()); + let decoded = ToolDeploymentState::try_from(proto.clone()).unwrap(); assert_eq!(decoded, state); + + let mut legacy_proto = proto; + legacy_proto.registered_tools[0].tagged_source = None; + legacy_proto.registered_tools[0].metadata_digest = None; + legacy_proto.agent_tool_bindings[0].tagged_source = None; + legacy_proto.agent_tool_bindings[0].metadata_digest = None; + let decoded_legacy = ToolDeploymentState::try_from(legacy_proto).unwrap(); + + assert_eq!(decoded_legacy, state); + } + + #[test] + fn tool_deployment_state_proto_roundtrip_preserves_host_source() { + let deployment_revision = DeploymentRevision::INITIAL; + let tool_name = ToolName::try_from("grep").unwrap(); + let definition = sample_tool(); + let metadata_version = "0.1.0".to_string(); + let state = ToolDeploymentState { + deployment_revision, + registered_tools: BTreeMap::from([( + tool_name, + RegisteredTool { + deployment_revision, + release_id: None, + metadata_digest: crate::model::tool_release::tool_metadata_digest( + &metadata_version, + &definition, + ) + .unwrap(), + definition, + provision: ToolProvisionConfig::default(), + source: ToolSource::Host { + host_tool_id: crate::model::tool::HostToolId::try_from( + "host-search".to_string(), + ) + .unwrap(), + implementation_version: "host-v1".to_string(), + }, + owner_account_id: AccountId::SYSTEM, + owner_account_email: AccountEmail::new("system@golem.cloud"), + metadata_version, + }, + )]), + agent_tool_bindings: BTreeMap::new(), + }; + + let proto: golem_api_grpc::proto::golem::registry::ToolDeploymentState = + state.clone().into(); + assert!(proto.registered_tools[0].source.is_none()); + assert!(proto.registered_tools[0].tagged_source.is_some()); + + assert_eq!(ToolDeploymentState::try_from(proto).unwrap(), state); } } diff --git a/golem-common/src/model/deployment.rs b/golem-common/src/model/deployment.rs index 8810eb8dce..92d1470806 100644 --- a/golem-common/src/model/deployment.rs +++ b/golem-common/src/model/deployment.rs @@ -46,6 +46,16 @@ impl DeploymentPlan { .iter() .map(|mcd| (mcd.domain.0.clone(), mcd.hash.into())) .collect(), + remote_tools: self + .remote_tools + .iter() + .map(|tool| (tool.name.to_string(), tool.hash.into())) + .collect(), + published_tools: self + .published_tools + .iter() + .map(ToString::to_string) + .collect(), } } } @@ -68,6 +78,16 @@ impl DeploymentSummary { .iter() .map(|mcd| (mcd.domain.0.clone(), mcd.hash.into())) .collect(), + remote_tools: self + .remote_tools + .iter() + .map(|tool| (tool.name.to_string(), tool.hash.into())) + .collect(), + published_tools: self + .published_tools + .iter() + .map(ToString::to_string) + .collect(), } } } diff --git a/golem-common/src/model/diff/deployment.rs b/golem-common/src/model/diff/deployment.rs index b83e3ceb33..8f982721eb 100644 --- a/golem-common/src/model/diff/deployment.rs +++ b/golem-common/src/model/diff/deployment.rs @@ -12,14 +12,118 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::{HttpApiDeployment, McpDeployment}; +use super::{BTreeSetDiff, HttpApiDeployment, McpDeployment}; +use crate::model::account::{AccountEmail, AccountId}; +use crate::model::agent::AgentTypeName; +use crate::model::component::{ComponentId, ComponentRevision}; use crate::model::diff::DiffError; use crate::model::diff::component::Component; use crate::model::diff::hash::{Hash, HashOf, Hashable, hash_from_serialized_value}; use crate::model::diff::ser::serialize_with_mode; use crate::model::diff::{BTreeMapDiff, Diffable}; +use crate::model::json::NormalizedJsonValue; +use crate::model::tool::{ + CompiledToolBinding, RegisteredTool, SecretKeyScope, ToolFilesystemAccess, ToolName, + ToolProvisionConfig, ToolSource, +}; +use crate::model::tool_release::ToolReleaseId; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectiveToolBinding { + pub parameters: NormalizedJsonValue, + pub secret_keys_readable: SecretKeyScope, + pub secret_keys_revealable: SecretKeyScope, + pub filesystem_access: ToolFilesystemAccess, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteToolDeployment { + pub release_id: ToolReleaseId, + pub version: String, + pub source_digest: Hash, + pub owner_account_id: AccountId, + pub owner_account_email: AccountEmail, + pub metadata_version: String, + pub metadata_digest: Hash, + pub provision: ToolProvisionConfig, + pub bindings: BTreeMap, +} + +impl Hashable for RemoteToolDeployment { + fn hash(&self) -> Result { + hash_from_serialized_value(self) + } +} + +impl Diffable for RemoteToolDeployment { + type DiffResult = RemoteToolDeployment; + + fn diff(new: &Self, current: &Self) -> Result, DiffError> { + Ok((new != current).then(|| new.clone())) + } +} + +pub fn remote_tool_deployments( + registered_tools: impl IntoIterator, + bindings: impl IntoIterator, + local_component_revisions: &BTreeSet<(ComponentId, ComponentRevision)>, +) -> BTreeMap> { + let mut bindings_by_tool = + BTreeMap::>::new(); + for binding in bindings { + bindings_by_tool + .entry(binding.tool_name) + .or_default() + .insert( + binding.agent_type_name, + EffectiveToolBinding { + parameters: binding.parameters, + secret_keys_readable: binding.secret_keys_readable, + secret_keys_revealable: binding.secret_keys_revealable, + filesystem_access: binding.filesystem_access, + }, + ); + } + + registered_tools + .into_iter() + .filter_map(|tool| { + let is_local = match &tool.source { + ToolSource::Component { + component_id, + component_revision, + .. + } => local_component_revisions.contains(&(*component_id, *component_revision)), + ToolSource::Host { .. } => false, + }; + if is_local { + return None; + } + let release_id = tool.release_id?; + let name = ToolName::try_from(tool.definition.name()?).ok()?; + let bindings = bindings_by_tool.remove(&name).unwrap_or_default(); + Some(( + name.to_string(), + RemoteToolDeployment { + release_id, + version: tool.definition.version, + source_digest: crate::model::tool_release::tool_source_digest(&tool.source), + owner_account_id: tool.owner_account_id, + owner_account_email: tool.owner_account_email, + metadata_version: tool.metadata_version, + metadata_digest: tool.metadata_digest, + provision: tool.provision, + bindings, + } + .into(), + )) + }) + .collect() +} #[derive(Debug, Clone, Serialize, Default)] #[serde(rename_all = "camelCase")] @@ -33,6 +137,11 @@ pub struct Deployment { #[serde(skip_serializing_if = "BTreeMap::is_empty")] #[serde(serialize_with = "serialize_with_mode")] pub mcp_deployments: BTreeMap>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(serialize_with = "serialize_with_mode")] + pub remote_tools: BTreeMap>, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + pub published_tools: BTreeSet, } #[derive(Debug, Clone, Serialize)] @@ -44,6 +153,10 @@ pub struct DeploymentDiff { pub http_api_deployments: BTreeMapDiff>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub mcp_deployments: BTreeMapDiff>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub remote_tools: BTreeMapDiff>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub published_tools: BTreeSetDiff, } impl Diffable for Deployment { @@ -57,13 +170,24 @@ impl Diffable for Deployment { let mcp_deployments = new .mcp_deployments .diff_with_current(¤t.mcp_deployments)?; + let remote_tools = new.remote_tools.diff_with_current(¤t.remote_tools)?; + let published_tools = new + .published_tools + .diff_with_current(¤t.published_tools)?; Ok( - if components.is_some() || http_api_deployments.is_some() || mcp_deployments.is_some() { + if components.is_some() + || http_api_deployments.is_some() + || mcp_deployments.is_some() + || remote_tools.is_some() + || published_tools.is_some() + { Some(DeploymentDiff { components: components.unwrap_or_default(), http_api_deployments: http_api_deployments.unwrap_or_default(), mcp_deployments: mcp_deployments.unwrap_or_default(), + remote_tools: remote_tools.unwrap_or_default(), + published_tools: published_tools.unwrap_or_default(), }) } else { None @@ -77,3 +201,93 @@ impl Hashable for Deployment { hash_from_serialized_value(self) } } + +#[cfg(test)] +mod tests { + use super::{Deployment, EffectiveToolBinding, RemoteToolDeployment}; + use crate::model::account::{AccountEmail, AccountId}; + use crate::model::agent::AgentTypeName; + use crate::model::component::{ComponentId, ComponentName, ComponentRevision}; + use crate::model::diff::{Hash, Hashable}; + use crate::model::json::NormalizedJsonValue; + use crate::model::tool::{ + HostToolId, SecretKeyScope, ToolFilesystemAccess, ToolProvisionConfig, ToolSource, + }; + use crate::model::tool_release::ToolReleaseId; + use std::collections::{BTreeMap, BTreeSet}; + use test_r::test; + + fn remote_tool() -> RemoteToolDeployment { + RemoteToolDeployment { + release_id: ToolReleaseId::new(), + version: "1.0.0".to_string(), + source_digest: crate::model::tool_release::tool_source_digest(&ToolSource::Component { + component_id: ComponentId::new(), + component_revision: ComponentRevision::INITIAL, + component_name: ComponentName("publisher-tools".to_string()), + }), + owner_account_id: AccountId::new(), + owner_account_email: AccountEmail::new("publisher@example.com"), + metadata_version: "0.1.0".to_string(), + metadata_digest: Hash::new(blake3::hash(b"metadata-a")), + provision: ToolProvisionConfig::default(), + bindings: BTreeMap::new(), + } + } + + fn deployment_hash(tool: RemoteToolDeployment, published: bool) -> Hash { + Deployment { + remote_tools: BTreeMap::from([("grep".to_string(), tool.into())]), + published_tools: if published { + BTreeSet::from(["local-tool".to_string()]) + } else { + BTreeSet::new() + }, + ..Deployment::default() + } + .hash() + .unwrap() + } + + #[test] + fn remote_tool_identity_hash_covers_release_source_metadata_provision_bindings_and_publication() + { + let base = remote_tool(); + let base_hash = deployment_hash(base.clone(), false); + + let mut changed_release = base.clone(); + changed_release.release_id = ToolReleaseId::new(); + assert_ne!(base_hash, deployment_hash(changed_release, false)); + + let mut changed_source = base.clone(); + changed_source.source_digest = + crate::model::tool_release::tool_source_digest(&ToolSource::Host { + host_tool_id: HostToolId::try_from("native-grep".to_string()).unwrap(), + implementation_version: "2026.08".to_string(), + }); + assert_ne!(base_hash, deployment_hash(changed_source, false)); + + let mut changed_metadata = base.clone(); + changed_metadata.metadata_digest = Hash::new(blake3::hash(b"metadata-b")); + assert_ne!(base_hash, deployment_hash(changed_metadata, false)); + + let mut changed_provision = base.clone(); + changed_provision.provision.config = + NormalizedJsonValue::new(serde_json::json!({ "consumer": true })); + assert_ne!(base_hash, deployment_hash(changed_provision, false)); + + let mut changed_binding = base.clone(); + changed_binding.bindings.insert( + AgentTypeName("Agent".to_string()), + EffectiveToolBinding { + parameters: NormalizedJsonValue::new(serde_json::json!({ "limit": 5 })), + secret_keys_readable: SecretKeyScope::All, + secret_keys_revealable: SecretKeyScope::All, + filesystem_access: ToolFilesystemAccess::Unset, + }, + ); + assert_ne!(base_hash, deployment_hash(changed_binding, false)); + + assert_ne!(base_hash, deployment_hash(base, true)); + } +} diff --git a/golem-common/src/model/entity.rs b/golem-common/src/model/entity.rs index 9969260895..a652101b07 100644 --- a/golem-common/src/model/entity.rs +++ b/golem-common/src/model/entity.rs @@ -363,17 +363,22 @@ impl EntityActivation { .to_string(), ); } - let crate::model::tool::ToolSource::Component { - component_id, - component_revision, - .. - } = &binding.source; - if *component_id != executable.component_id - || *component_revision != executable.component_revision - { - return Err( - "Entity executable does not match the tool binding source".to_string() - ); + match &binding.source { + crate::model::tool::ToolSource::Component { + component_id, + component_revision, + .. + } => { + if *component_id != executable.component_id + || *component_revision != executable.component_revision + { + return Err("Entity executable does not match the tool binding source" + .to_string()); + } + } + crate::model::tool::ToolSource::Host { .. } => { + return Err("Host tools do not use component entity activation".to_string()); + } } if !binding .secret_keys_revealable @@ -1057,10 +1062,12 @@ mod tests { }; let binding = CompiledToolBinding { deployment_revision, + release_id: None, agent_type_name: AgentTypeName("Example".to_string()), tool_name: ToolName::try_from("search").unwrap(), version: "1.0.0".to_string(), metadata_version: "0.1.0".to_string(), + metadata_digest: Default::default(), account_id: AccountId::new(), account_email: AccountEmail::new("owner@example.com"), parameters: NormalizedJsonValue::new(serde_json::json!({})), diff --git a/golem-common/src/model/environment_tool_grant.rs b/golem-common/src/model/environment_tool_grant.rs new file mode 100644 index 0000000000..1b91a9e19e --- /dev/null +++ b/golem-common/src/model/environment_tool_grant.rs @@ -0,0 +1,15 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub use crate::base_model::environment_tool_grant::*; diff --git a/golem-common/src/model/mod.rs b/golem-common/src/model/mod.rs index da130f6532..c61afbeb5e 100644 --- a/golem-common/src/model/mod.rs +++ b/golem-common/src/model/mod.rs @@ -28,6 +28,7 @@ pub mod domain_registration; pub mod entity; pub mod environment; pub mod environment_plugin_grant; +pub mod environment_tool_grant; pub mod error; pub mod http_api_deployment; pub mod invocation_context; @@ -48,6 +49,7 @@ pub mod retry_policy; pub mod security_scheme; #[cfg(test)] mod tests; +pub mod tool_release; pub mod worker; pub use crate::base_model::*; diff --git a/golem-common/src/model/tool_release.rs b/golem-common/src/model/tool_release.rs new file mode 100644 index 0000000000..9c0630f381 --- /dev/null +++ b/golem-common/src/model/tool_release.rs @@ -0,0 +1,81 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub use crate::base_model::tool_release::*; + +use crate::model::diff; +use crate::schema::tool::Tool; + +pub fn tool_metadata_digest( + metadata_version: &str, + definition: &Tool, +) -> anyhow::Result { + let mut input = Vec::from(b"golem:tool-metadata:v1\0".as_slice()); + input.extend_from_slice(metadata_version.as_bytes()); + input.push(0); + input.extend_from_slice(&desert_rust::serialize_to_byte_vec(definition)?); + Ok(blake3::hash(&input).into()) +} + +#[cfg(test)] +mod tests { + use super::tool_metadata_digest; + use crate::model::tool::HostToolId; + use crate::schema::tool::{CommandNode, CommandTree, Doc, Globals, Tool}; + use golem_schema::schema::SchemaGraph; + use test_r::test; + + fn tool(name: &str, version: &str) -> Tool { + Tool { + version: version.to_string(), + commands: CommandTree { + nodes: vec![CommandNode { + name: name.to_string(), + aliases: Vec::new(), + doc: Doc::default(), + globals: Globals::default(), + subcommands: Vec::new(), + body: None, + }], + }, + schema: SchemaGraph::empty(), + } + } + + #[test] + fn metadata_digest_is_deterministic_and_covers_schema_version_and_definition() { + let definition = tool("search", "1.0.0"); + let digest = tool_metadata_digest("0.1.0", &definition).unwrap(); + + assert_eq!(digest, tool_metadata_digest("0.1.0", &definition).unwrap()); + assert_ne!(digest, tool_metadata_digest("0.2.0", &definition).unwrap()); + assert_ne!( + digest, + tool_metadata_digest("0.1.0", &tool("search", "1.1.0")).unwrap() + ); + } + + #[test] + fn host_tool_id_requires_lower_kebab_case() { + assert_eq!( + HostToolId::try_from("golem-search".to_string()) + .unwrap() + .as_str(), + "golem-search" + ); + assert!(HostToolId::try_from(String::new()).is_err()); + assert!(HostToolId::try_from("GolemSearch".to_string()).is_err()); + assert!(HostToolId::try_from("golem search".to_string()).is_err()); + } +} diff --git a/golem-common/src/schema/tool/mod.rs b/golem-common/src/schema/tool/mod.rs index 479c595b57..8ba2567895 100644 --- a/golem-common/src/schema/tool/mod.rs +++ b/golem-common/src/schema/tool/mod.rs @@ -48,6 +48,18 @@ impl From for DiscoveredTool { } = value; let implemented_by = match source { ToolSource::Component { component_id, .. } => component_id, + ToolSource::Host { + host_tool_id, + implementation_version, + } => { + const HOST_TOOL_COMPONENT_NAMESPACE: uuid::Uuid = + uuid::uuid!("2e53c904-6751-5bc7-8264-0ecfbb58dbd5"); + let identity = format!("{}@{implementation_version}", host_tool_id.as_str()); + ComponentId(uuid::Uuid::new_v5( + &HOST_TOOL_COMPONENT_NAMESPACE, + identity.as_bytes(), + )) + } }; Self { diff --git a/golem-common/tests/goldenfiles/diff_model_fingerprint_v5.txt b/golem-common/tests/goldenfiles/diff_model_fingerprint_v5.txt new file mode 100644 index 0000000000..cf80ee91f0 --- /dev/null +++ b/golem-common/tests/goldenfiles/diff_model_fingerprint_v5.txt @@ -0,0 +1 @@ +db9f18f1c65b7af33f9f99a9759e9f40d418a80f63aaa948fc60717072712684 diff --git a/golem-registry-service/config/registry-service.sample.env b/golem-registry-service/config/registry-service.sample.env index 743b425dee..8b10f8828d 100644 --- a/golem-registry-service/config/registry-service.sample.env +++ b/golem-registry-service/config/registry-service.sample.env @@ -21,7 +21,7 @@ GOLEM__COMPONENT_COMPILATION__CONFIG__RETRIES_ON_UNAVAILABLE__MULTIPLIER=2.0 GOLEM__COMPONENT_COMPILATION__CONFIG__TLS__TYPE="Disabled" GOLEM__DB__TYPE="Sqlite" GOLEM__DB__CONFIG__DATABASE="golem_registry_service.db" -GOLEM__DB__CONFIG__FOREIGN_KEYS=false +GOLEM__DB__CONFIG__FOREIGN_KEYS=true GOLEM__DB__CONFIG__MAX_CONNECTIONS=10 GOLEM__DEPLOYMENT_EVENTS__CLEANUP_INTERVAL="1h" GOLEM__DEPLOYMENT_EVENTS__RETENTION="1day" @@ -34,6 +34,12 @@ GOLEM__INITIAL_ACCOUNTS__BUILTIN_PLUGIN_OWNER__NAME="Builtin Plugin Owner" GOLEM__INITIAL_ACCOUNTS__BUILTIN_PLUGIN_OWNER__PLAN_ID="157dc684-00eb-496d-941c-da8fd1d15c63" GOLEM__INITIAL_ACCOUNTS__BUILTIN_PLUGIN_OWNER__ROLE="builtin-plugin-owner" #GOLEM__INITIAL_ACCOUNTS__BUILTIN_PLUGIN_OWNER__TOKEN= +GOLEM__INITIAL_ACCOUNTS__BUILTIN_TOOL_OWNER__EMAIL="builtin-tool-owner@golem.cloud" +GOLEM__INITIAL_ACCOUNTS__BUILTIN_TOOL_OWNER__ID="58bda34c-10d4-4bfb-8abd-d5e67f09ba3c" +GOLEM__INITIAL_ACCOUNTS__BUILTIN_TOOL_OWNER__NAME="Builtin Tool Owner" +GOLEM__INITIAL_ACCOUNTS__BUILTIN_TOOL_OWNER__PLAN_ID="157dc684-00eb-496d-941c-da8fd1d15c63" +GOLEM__INITIAL_ACCOUNTS__BUILTIN_TOOL_OWNER__ROLE="builtin-plugin-owner" +#GOLEM__INITIAL_ACCOUNTS__BUILTIN_TOOL_OWNER__TOKEN= GOLEM__INITIAL_ACCOUNTS__MARKETING__EMAIL="marketing@user" GOLEM__INITIAL_ACCOUNTS__MARKETING__ID="0e8a0431-94b9-4644-89ca-fbf403edb6e7" GOLEM__INITIAL_ACCOUNTS__MARKETING__NAME="Marketing User" diff --git a/golem-registry-service/config/registry-service.toml b/golem-registry-service/config/registry-service.toml index c308ce5f9d..b3ea1448b2 100644 --- a/golem-registry-service/config/registry-service.toml +++ b/golem-registry-service/config/registry-service.toml @@ -41,7 +41,7 @@ type = "Sqlite" [db.config] database = "golem_registry_service.db" -foreign_keys = false +foreign_keys = true max_connections = 10 [deployment_events] @@ -68,6 +68,13 @@ name = "Builtin Plugin Owner" plan_id = "157dc684-00eb-496d-941c-da8fd1d15c63" role = "builtin-plugin-owner" +[initial_accounts.builtin_tool_owner] +email = "builtin-tool-owner@golem.cloud" +id = "58bda34c-10d4-4bfb-8abd-d5e67f09ba3c" +name = "Builtin Tool Owner" +plan_id = "157dc684-00eb-496d-941c-da8fd1d15c63" +role = "builtin-plugin-owner" + [initial_accounts.marketing] email = "marketing@user" id = "0e8a0431-94b9-4644-89ca-fbf403edb6e7" diff --git a/golem-registry-service/db/migration/postgres/034_tool_releases.sql b/golem-registry-service/db/migration/postgres/034_tool_releases.sql new file mode 100644 index 0000000000..322ddce2fc --- /dev/null +++ b/golem-registry-service/db/migration/postgres/034_tool_releases.sql @@ -0,0 +1,181 @@ +CREATE TABLE tool_releases +( + tool_release_id UUID NOT NULL, + owner_account_id UUID NOT NULL, + tool_name TEXT NOT NULL, + tool_version TEXT NOT NULL, + source_kind SMALLINT NOT NULL, + component_id UUID, + component_revision BIGINT, + component_name TEXT, + host_tool_id TEXT, + implementation_version TEXT, + tool_definition BYTEA NOT NULL, + metadata_version TEXT NOT NULL, + metadata_digest BYTEA NOT NULL, + lifecycle SMALLINT NOT NULL, + origin SMALLINT NOT NULL, + system_availability SMALLINT, + created_at TIMESTAMP NOT NULL, + created_by UUID NOT NULL, + state_changed_at TIMESTAMP NOT NULL, + state_changed_by UUID NOT NULL, + + CONSTRAINT tool_releases_pk + PRIMARY KEY (tool_release_id), + CONSTRAINT tool_releases_owner_account_fk + FOREIGN KEY (owner_account_id) REFERENCES accounts, + CONSTRAINT tool_releases_component_revision_fk + FOREIGN KEY (component_id, component_revision) REFERENCES component_revisions, + CONSTRAINT tool_releases_source_kind_check + CHECK (source_kind IN (0, 1)), + CONSTRAINT tool_releases_source_fields_check + CHECK ( + (source_kind = 0 + AND component_id IS NOT NULL + AND component_revision IS NOT NULL + AND component_name IS NOT NULL + AND host_tool_id IS NULL + AND implementation_version IS NULL) + OR (source_kind = 1 + AND component_id IS NULL + AND component_revision IS NULL + AND component_name IS NULL + AND host_tool_id IS NOT NULL + AND implementation_version IS NOT NULL) + ), + CONSTRAINT tool_releases_lifecycle_check + CHECK (lifecycle IN (0, 1)), + CONSTRAINT tool_releases_origin_check + CHECK (origin IN (0, 1)), + CONSTRAINT tool_releases_system_availability_check + CHECK ( + (origin = 0 AND system_availability IS NULL) + OR (origin = 1 + AND system_availability IS NOT NULL + AND system_availability IN (0, 1, 2)) + ) +); + +CREATE UNIQUE INDEX tool_releases_owner_name_version_uk + ON tool_releases (owner_account_id, tool_name, tool_version); + +CREATE INDEX tool_releases_component_revision_idx + ON tool_releases (component_id, component_revision); + +CREATE FUNCTION validate_tool_release_component_owner() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.source_kind = 0 AND NOT EXISTS ( + SELECT 1 + FROM components c + JOIN environments e ON e.environment_id = c.environment_id + JOIN applications app ON app.application_id = e.application_id + WHERE c.component_id = NEW.component_id + AND app.account_id = NEW.owner_account_id + ) THEN + RAISE EXCEPTION 'component tool release source must belong to the release owner account'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tool_releases_component_owner_check + BEFORE INSERT OR UPDATE OF owner_account_id, source_kind, component_id + ON tool_releases + FOR EACH ROW EXECUTE FUNCTION validate_tool_release_component_owner(); + +CREATE TABLE environment_tool_grants +( + environment_tool_grant_id UUID NOT NULL, + environment_id UUID NOT NULL, + tool_release_id UUID NOT NULL, + protected BOOLEAN NOT NULL, + automatic BOOLEAN NOT NULL, + lifecycle SMALLINT NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by UUID NOT NULL, + state_changed_at TIMESTAMP NOT NULL, + state_changed_by UUID NOT NULL, + deleted_at TIMESTAMP, + deleted_by UUID, + + CONSTRAINT environment_tool_grants_pk + PRIMARY KEY (environment_tool_grant_id), + CONSTRAINT environment_tool_grants_environment_fk + FOREIGN KEY (environment_id) REFERENCES environments, + CONSTRAINT environment_tool_grants_release_fk + FOREIGN KEY (tool_release_id) REFERENCES tool_releases, + CONSTRAINT environment_tool_grants_lifecycle_check + CHECK (lifecycle IN (0, 1)), + CONSTRAINT environment_tool_grants_deletion_state_check + CHECK ( + (lifecycle = 0 AND deleted_at IS NULL AND deleted_by IS NULL) + OR (lifecycle = 1 AND deleted_at IS NOT NULL AND deleted_by IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX environment_tool_grants_environment_release_uk + ON environment_tool_grants (environment_id, tool_release_id); +CREATE INDEX environment_tool_grants_active_environment_idx + ON environment_tool_grants (environment_id, deleted_at); +CREATE INDEX environment_tool_grants_active_release_idx + ON environment_tool_grants (tool_release_id, deleted_at); + +ALTER TABLE deployment_registered_tools + DROP CONSTRAINT deployment_registered_tools_component_revision_fk; + +ALTER TABLE deployment_registered_tools + ADD COLUMN tool_release_id UUID, + ADD COLUMN source_kind SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN component_name TEXT, + ADD COLUMN host_tool_id TEXT, + ADD COLUMN implementation_version TEXT, + ADD COLUMN owner_account_id UUID, + ADD COLUMN owner_account_email TEXT, + ADD COLUMN metadata_digest BYTEA; + +UPDATE deployment_registered_tools registered +SET component_name = components.name, + owner_account_id = applications.account_id, + owner_account_email = accounts.email +FROM component_revisions, components, environments, applications, accounts +WHERE components.component_id = component_revisions.component_id + AND environments.environment_id = registered.environment_id + AND applications.application_id = environments.application_id + AND accounts.account_id = applications.account_id + AND component_revisions.component_id = registered.component_id + AND component_revisions.revision_id = registered.component_revision_id; + +ALTER TABLE deployment_registered_tools + ALTER COLUMN source_kind DROP DEFAULT, + ALTER COLUMN component_id DROP NOT NULL, + ALTER COLUMN component_revision_id DROP NOT NULL, + ALTER COLUMN owner_account_id SET NOT NULL, + ALTER COLUMN owner_account_email SET NOT NULL, + ADD CONSTRAINT deployment_registered_tools_source_kind_check + CHECK (source_kind IN (0, 1)), + ADD CONSTRAINT deployment_registered_tools_source_fields_check + CHECK ( + (source_kind = 0 + AND component_id IS NOT NULL + AND component_revision_id IS NOT NULL + AND component_name IS NOT NULL + AND host_tool_id IS NULL + AND implementation_version IS NULL) + OR (source_kind = 1 + AND component_id IS NULL + AND component_revision_id IS NULL + AND component_name IS NULL + AND host_tool_id IS NOT NULL + AND implementation_version IS NOT NULL) + ), + ADD CONSTRAINT deployment_registered_tools_component_revision_fk + FOREIGN KEY (component_id, component_revision_id) + REFERENCES component_revisions (component_id, revision_id), + ADD CONSTRAINT deployment_registered_tools_deployment_fk + FOREIGN KEY (environment_id, deployment_revision_id) + REFERENCES deployment_revisions (environment_id, revision_id), + ADD CONSTRAINT deployment_registered_tools_release_fk + FOREIGN KEY (tool_release_id) REFERENCES tool_releases, + ADD CONSTRAINT deployment_registered_tools_owner_fk + FOREIGN KEY (owner_account_id) REFERENCES accounts; diff --git a/golem-registry-service/db/migration/sqlite/034_tool_releases.sql b/golem-registry-service/db/migration/sqlite/034_tool_releases.sql new file mode 100644 index 0000000000..23074ef87f --- /dev/null +++ b/golem-registry-service/db/migration/sqlite/034_tool_releases.sql @@ -0,0 +1,248 @@ +CREATE TABLE tool_releases +( + tool_release_id UUID NOT NULL, + owner_account_id UUID NOT NULL, + tool_name TEXT NOT NULL, + tool_version TEXT NOT NULL, + source_kind SMALLINT NOT NULL, + component_id UUID, + component_revision BIGINT, + component_name TEXT, + host_tool_id TEXT, + implementation_version TEXT, + tool_definition BLOB NOT NULL, + metadata_version TEXT NOT NULL, + metadata_digest BLOB NOT NULL, + lifecycle SMALLINT NOT NULL, + origin SMALLINT NOT NULL, + system_availability SMALLINT, + created_at TIMESTAMP NOT NULL, + created_by UUID NOT NULL, + state_changed_at TIMESTAMP NOT NULL, + state_changed_by UUID NOT NULL, + + CONSTRAINT tool_releases_pk + PRIMARY KEY (tool_release_id), + CONSTRAINT tool_releases_owner_account_fk + FOREIGN KEY (owner_account_id) REFERENCES accounts, + CONSTRAINT tool_releases_component_revision_fk + FOREIGN KEY (component_id, component_revision) REFERENCES component_revisions, + CONSTRAINT tool_releases_source_kind_check + CHECK (source_kind IN (0, 1)), + CONSTRAINT tool_releases_source_fields_check + CHECK ( + (source_kind = 0 + AND component_id IS NOT NULL + AND component_revision IS NOT NULL + AND component_name IS NOT NULL + AND host_tool_id IS NULL + AND implementation_version IS NULL) + OR (source_kind = 1 + AND component_id IS NULL + AND component_revision IS NULL + AND component_name IS NULL + AND host_tool_id IS NOT NULL + AND implementation_version IS NOT NULL) + ), + CONSTRAINT tool_releases_lifecycle_check + CHECK (lifecycle IN (0, 1)), + CONSTRAINT tool_releases_origin_check + CHECK (origin IN (0, 1)), + CONSTRAINT tool_releases_system_availability_check + CHECK ( + (origin = 0 AND system_availability IS NULL) + OR (origin = 1 + AND system_availability IS NOT NULL + AND system_availability IN (0, 1, 2)) + ) +); + +CREATE UNIQUE INDEX tool_releases_owner_name_version_uk + ON tool_releases (owner_account_id, tool_name, tool_version); + +CREATE INDEX tool_releases_component_revision_idx + ON tool_releases (component_id, component_revision); + +CREATE TRIGGER tool_releases_component_owner_check_insert +BEFORE INSERT ON tool_releases +WHEN NEW.source_kind = 0 AND NOT EXISTS ( + SELECT 1 + FROM components c + JOIN environments e ON e.environment_id = c.environment_id + JOIN applications app ON app.application_id = e.application_id + WHERE c.component_id = NEW.component_id + AND app.account_id = NEW.owner_account_id +) +BEGIN + SELECT RAISE(ABORT, 'component tool release source must belong to the release owner account'); +END; + +CREATE TRIGGER tool_releases_component_owner_check_update +BEFORE UPDATE OF owner_account_id, source_kind, component_id ON tool_releases +WHEN NEW.source_kind = 0 AND NOT EXISTS ( + SELECT 1 + FROM components c + JOIN environments e ON e.environment_id = c.environment_id + JOIN applications app ON app.application_id = e.application_id + WHERE c.component_id = NEW.component_id + AND app.account_id = NEW.owner_account_id +) +BEGIN + SELECT RAISE(ABORT, 'component tool release source must belong to the release owner account'); +END; + +CREATE TABLE environment_tool_grants +( + environment_tool_grant_id UUID NOT NULL, + environment_id UUID NOT NULL, + tool_release_id UUID NOT NULL, + protected BOOLEAN NOT NULL, + automatic BOOLEAN NOT NULL, + lifecycle SMALLINT NOT NULL, + created_at TIMESTAMP NOT NULL, + created_by UUID NOT NULL, + state_changed_at TIMESTAMP NOT NULL, + state_changed_by UUID NOT NULL, + deleted_at TIMESTAMP, + deleted_by UUID, + + CONSTRAINT environment_tool_grants_pk + PRIMARY KEY (environment_tool_grant_id), + CONSTRAINT environment_tool_grants_environment_fk + FOREIGN KEY (environment_id) REFERENCES environments, + CONSTRAINT environment_tool_grants_release_fk + FOREIGN KEY (tool_release_id) REFERENCES tool_releases, + CONSTRAINT environment_tool_grants_lifecycle_check + CHECK (lifecycle IN (0, 1)), + CONSTRAINT environment_tool_grants_deletion_state_check + CHECK ( + (lifecycle = 0 AND deleted_at IS NULL AND deleted_by IS NULL) + OR (lifecycle = 1 AND deleted_at IS NOT NULL AND deleted_by IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX environment_tool_grants_environment_release_uk + ON environment_tool_grants (environment_id, tool_release_id); +CREATE INDEX environment_tool_grants_active_environment_idx + ON environment_tool_grants (environment_id, deleted_at); +CREATE INDEX environment_tool_grants_active_release_idx + ON environment_tool_grants (tool_release_id, deleted_at); + +CREATE TABLE deployment_registered_tools_v2 +( + environment_id UUID NOT NULL, + deployment_revision_id BIGINT NOT NULL, + tool_name TEXT NOT NULL, + tool_release_id UUID, + source_kind SMALLINT NOT NULL, + component_id UUID, + component_revision_id BIGINT, + component_name TEXT, + host_tool_id TEXT, + implementation_version TEXT, + owner_account_id UUID NOT NULL, + owner_account_email TEXT NOT NULL, + tool_definition BLOB NOT NULL, + tool_provision_config BLOB NOT NULL, + metadata_version TEXT NOT NULL, + metadata_digest BLOB, + + CONSTRAINT deployment_registered_tools_v2_pk + PRIMARY KEY (environment_id, deployment_revision_id, tool_name), + CONSTRAINT deployment_registered_tools_v2_deployment_fk + FOREIGN KEY (environment_id, deployment_revision_id) + REFERENCES deployment_revisions (environment_id, revision_id), + CONSTRAINT deployment_registered_tools_v2_component_revision_fk + FOREIGN KEY (component_id, component_revision_id) + REFERENCES component_revisions (component_id, revision_id), + CONSTRAINT deployment_registered_tools_v2_release_fk + FOREIGN KEY (tool_release_id) REFERENCES tool_releases, + CONSTRAINT deployment_registered_tools_v2_owner_fk + FOREIGN KEY (owner_account_id) REFERENCES accounts, + CONSTRAINT deployment_registered_tools_v2_source_kind_check + CHECK (source_kind IN (0, 1)), + CONSTRAINT deployment_registered_tools_v2_source_fields_check + CHECK ( + (source_kind = 0 + AND component_id IS NOT NULL + AND component_revision_id IS NOT NULL + AND component_name IS NOT NULL + AND host_tool_id IS NULL + AND implementation_version IS NULL) + OR (source_kind = 1 + AND component_id IS NULL + AND component_revision_id IS NULL + AND component_name IS NULL + AND host_tool_id IS NOT NULL + AND implementation_version IS NOT NULL) + ) +); + +INSERT INTO deployment_registered_tools_v2 ( + environment_id, deployment_revision_id, tool_name, + tool_release_id, source_kind, + component_id, component_revision_id, component_name, + host_tool_id, implementation_version, + owner_account_id, owner_account_email, + tool_definition, tool_provision_config, metadata_version, metadata_digest +) +SELECT registered.environment_id, + registered.deployment_revision_id, + registered.tool_name, + NULL, + 0, + registered.component_id, + registered.component_revision_id, + components.name, + NULL, + NULL, + applications.account_id, + accounts.email, + registered.tool_definition, + registered.tool_provision_config, + registered.metadata_version, + NULL +FROM deployment_registered_tools registered +JOIN component_revisions + ON component_revisions.component_id = registered.component_id + AND component_revisions.revision_id = registered.component_revision_id +JOIN components ON components.component_id = component_revisions.component_id +JOIN environments ON environments.environment_id = registered.environment_id +JOIN applications ON applications.application_id = environments.application_id +JOIN accounts ON accounts.account_id = applications.account_id; + +CREATE TABLE deployment_agent_tool_bindings_v2 +( + environment_id UUID NOT NULL, + deployment_revision_id BIGINT NOT NULL, + agent_type_name TEXT NOT NULL, + tool_name TEXT NOT NULL, + compiled_binding BLOB NOT NULL, + + CONSTRAINT deployment_agent_tool_bindings_v2_pk + PRIMARY KEY (environment_id, deployment_revision_id, agent_type_name, tool_name), + CONSTRAINT deployment_agent_tool_bindings_v2_agent_type_fk + FOREIGN KEY (environment_id, deployment_revision_id, agent_type_name) + REFERENCES deployment_registered_agent_types + (environment_id, deployment_revision_id, agent_type_name), + CONSTRAINT deployment_agent_tool_bindings_v2_tool_fk + FOREIGN KEY (environment_id, deployment_revision_id, tool_name) + REFERENCES deployment_registered_tools_v2 + (environment_id, deployment_revision_id, tool_name) +); + +INSERT INTO deployment_agent_tool_bindings_v2 +SELECT environment_id, deployment_revision_id, agent_type_name, tool_name, compiled_binding +FROM deployment_agent_tool_bindings; + +DROP TABLE deployment_agent_tool_bindings; +DROP TABLE deployment_registered_tools; +ALTER TABLE deployment_registered_tools_v2 RENAME TO deployment_registered_tools; +ALTER TABLE deployment_agent_tool_bindings_v2 RENAME TO deployment_agent_tool_bindings; + +CREATE INDEX deployment_registered_tools_component_idx + ON deployment_registered_tools (component_id, component_revision_id, deployment_revision_id DESC); +CREATE INDEX deployment_registered_tools_release_idx + ON deployment_registered_tools (tool_release_id, deployment_revision_id DESC); +CREATE INDEX deployment_agent_tool_bindings_tool_idx + ON deployment_agent_tool_bindings (environment_id, deployment_revision_id, tool_name, agent_type_name); diff --git a/golem-registry-service/src/api/environment_tool_grants.rs b/golem-registry-service/src/api/environment_tool_grants.rs new file mode 100644 index 0000000000..d3f272be2a --- /dev/null +++ b/golem-registry-service/src/api/environment_tool_grants.rs @@ -0,0 +1,283 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::ApiResult; +use crate::services::auth::AuthService; +use crate::services::environment_tool_grant::EnvironmentToolGrantService; +use golem_common::model::Page; +use golem_common::model::environment::EnvironmentId; +use golem_common::model::environment_tool_grant::{ + EnvironmentToolGrantCreation, EnvironmentToolGrantId, EnvironmentToolGrantReconciliation, + EnvironmentToolGrantWithDetails, +}; +use golem_common::model::poem::NoContentResponse; +use golem_common::recorded_http_api_request; +use golem_service_base::api_tags::ApiTags; +use golem_service_base::model::auth::GolemSecurityScheme; +use poem_openapi::OpenApi; +use poem_openapi::param::Path; +use poem_openapi::payload::Json; +use std::sync::Arc; +use tracing::Instrument; + +pub struct EnvironmentToolGrantsApi { + environment_tool_grant_service: Arc, + auth_service: Arc, +} + +#[OpenApi( + prefix_path = "/v1", + tag = ApiTags::RegistryService, + tag = ApiTags::EnvironmentToolGrants +)] +impl EnvironmentToolGrantsApi { + pub fn new( + environment_tool_grant_service: Arc, + auth_service: Arc, + ) -> Self { + Self { + environment_tool_grant_service, + auth_service, + } + } + + /// Grant an exact published tool release to an environment + #[oai( + path = "/envs/:environment_id/tool-grants", + method = "post", + operation_id = "create_environment_tool_grant", + tag = ApiTags::Environment + )] + async fn create_environment_tool_grant( + &self, + environment_id: Path, + creation: Json, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "create_environment_tool_grant", + environment_id = environment_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.environment_tool_grant_service + .create(environment_id.0, creation.0, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Create an automatically managed grant required by an application deployment + #[oai( + path = "/envs/:environment_id/tool-grants/automatic", + method = "post", + operation_id = "create_automatic_environment_tool_grant", + tag = ApiTags::Environment + )] + async fn create_automatic_environment_tool_grant( + &self, + environment_id: Path, + creation: Json, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "create_automatic_environment_tool_grant", + environment_id = environment_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.environment_tool_grant_service + .create_automatic(environment_id.0, creation.0, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Validate an automatically managed grant reconciliation without changing any grants + #[oai( + path = "/envs/:environment_id/tool-grants/automatic/validate", + method = "post", + operation_id = "validate_automatic_environment_tool_grant_reconciliation", + tag = ApiTags::Environment + )] + async fn validate_automatic_environment_tool_grant_reconciliation( + &self, + environment_id: Path, + reconciliation: Json, + token: GolemSecurityScheme, + ) -> ApiResult { + let record = recorded_http_api_request!( + "validate_automatic_environment_tool_grant_reconciliation", + environment_id = environment_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + self.environment_tool_grant_service + .validate_reconciliation(environment_id.0, reconciliation.0, &auth) + .await?; + Ok(NoContentResponse::NoContent) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// List active tool grants in an environment + #[oai( + path = "/envs/:environment_id/tool-grants", + method = "get", + operation_id = "list_environment_tool_grants", + tag = ApiTags::Environment + )] + async fn list_environment_tool_grants( + &self, + environment_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult>> { + let record = recorded_http_api_request!( + "list_environment_tool_grants", + environment_id = environment_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json(Page { + values: self + .environment_tool_grant_service + .list_in_environment(environment_id.0, &auth) + .await?, + })) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Get an active environment tool grant + #[oai( + path = "/environment-tool-grants/:grant_id", + method = "get", + operation_id = "get_environment_tool_grant" + )] + async fn get_environment_tool_grant( + &self, + grant_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "get_environment_tool_grant", + grant_id = grant_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.environment_tool_grant_service + .get(grant_id.0, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Delete an environment tool grant + #[oai( + path = "/environment-tool-grants/:grant_id", + method = "delete", + operation_id = "delete_environment_tool_grant" + )] + async fn delete_environment_tool_grant( + &self, + grant_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult { + let record = recorded_http_api_request!( + "delete_environment_tool_grant", + grant_id = grant_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + self.environment_tool_grant_service + .delete(grant_id.0, &auth) + .await?; + Ok(NoContentResponse::NoContent) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Delete an environment tool grant only if it is automatically managed + #[oai( + path = "/environment-tool-grants/:grant_id/automatic", + method = "delete", + operation_id = "delete_automatic_environment_tool_grant" + )] + async fn delete_automatic_environment_tool_grant( + &self, + grant_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult { + let record = recorded_http_api_request!( + "delete_automatic_environment_tool_grant", + grant_id = grant_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + self.environment_tool_grant_service + .delete_automatic(grant_id.0, &auth) + .await?; + Ok(NoContentResponse::NoContent) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Restore a deleted environment tool grant + #[oai( + path = "/environment-tool-grants/:grant_id/restore", + method = "post", + operation_id = "restore_environment_tool_grant" + )] + async fn restore_environment_tool_grant( + &self, + grant_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "restore_environment_tool_grant", + grant_id = grant_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.environment_tool_grant_service + .restore(grant_id.0, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } +} diff --git a/golem-registry-service/src/api/environments.rs b/golem-registry-service/src/api/environments.rs index 52f76ffd6b..8e1d00b17c 100644 --- a/golem-registry-service/src/api/environments.rs +++ b/golem-registry-service/src/api/environments.rs @@ -17,8 +17,9 @@ use crate::services::auth::AuthService; use crate::services::deployment::{DeploymentService, DeploymentWriteService}; use crate::services::environment::EnvironmentService; use golem_common::model::Page; -use golem_common::model::agent::AgentTypeName; -use golem_common::model::agent::DeployedRegisteredAgentType; +use golem_common::model::agent::{ + AgentTypeName, DeployedRegisteredAgentType, InitialAgentFileUpload, +}; use golem_common::model::application::ApplicationId; use golem_common::model::deployment::{ CurrentDeployment, Deployment, DeploymentCreation, DeploymentPlan, DeploymentRevision, @@ -31,9 +32,10 @@ use golem_common::recorded_http_api_request; use golem_service_base::api_tags::ApiTags; use golem_service_base::model::auth::AuthCtx; use golem_service_base::model::auth::GolemSecurityScheme; -use poem_openapi::OpenApi; +use golem_service_base::poem::TempFileUpload; use poem_openapi::param::{Path, Query}; use poem_openapi::payload::Json; +use poem_openapi::{Multipart, OpenApi}; use std::sync::Arc; use tracing::Instrument; @@ -64,6 +66,37 @@ impl EnvironmentsApi { } } + /// Upload a content-addressed initial agent file for deployment in this environment + #[oai( + path = "/envs/:environment_id/initial-agent-files", + method = "post", + operation_id = "upload_environment_initial_agent_file" + )] + async fn upload_environment_initial_agent_file( + &self, + environment_id: Path, + payload: UploadInitialAgentFileRequest, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "upload_environment_initial_agent_file", + environment_id = environment_id.0.to_string(), + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + let file = payload.file.into_file(); + let data = tokio::fs::read(file.path()).await?; + Ok(Json( + self.deployment_write_service + .upload_initial_agent_file(environment_id.0, data, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + /// Create an application environment #[oai( path = "/apps/:application_id/envs", @@ -690,3 +723,8 @@ impl EnvironmentsApi { Ok(Json(tool)) } } + +#[derive(Multipart)] +struct UploadInitialAgentFileRequest { + file: TempFileUpload, +} diff --git a/golem-registry-service/src/api/error.rs b/golem-registry-service/src/api/error.rs index 163f7c0c71..e15cc59c78 100644 --- a/golem-registry-service/src/api/error.rs +++ b/golem-registry-service/src/api/error.rs @@ -25,6 +25,7 @@ use crate::services::deployment::{DeployValidationError, DeploymentError, Deploy use crate::services::domain_registration::DomainRegistrationError; use crate::services::environment::EnvironmentError; use crate::services::environment_plugin_grant::EnvironmentPluginGrantError; +use crate::services::environment_tool_grant::EnvironmentToolGrantError; use crate::services::http_api_deployment::HttpApiDeploymentError; use crate::services::mcp_deployment::McpDeploymentError; use crate::services::oauth2::OAuth2Error; @@ -36,6 +37,7 @@ use crate::services::resource_definition::ResourceDefinitionError; use crate::services::retry_policy::RetryPolicyError; use crate::services::security_scheme::SecuritySchemeError; use crate::services::token::TokenError; +use crate::services::tool_release::ToolReleaseError; use golem_common::base_model::api; use golem_common::metrics::api::ApiErrorDetails; use golem_common::model::error::{ErrorBody, ErrorsBody}; @@ -218,6 +220,29 @@ fn deployment_validation_subcode(error: &DeployValidationError) -> &'static str DeployValidationError::DuplicateToolImplementation { .. } => { api::error_code::deployment_validation::DUPLICATE_TOOL_IMPLEMENTATION } + DeployValidationError::ToolSourceCollision { .. } => { + api::error_code::deployment_validation::TOOL_SOURCE_COLLISION + } + DeployValidationError::RemoteToolUnavailable { .. } => { + api::error_code::deployment_validation::REMOTE_TOOL_UNAVAILABLE + } + DeployValidationError::RemoteToolNameMismatch { .. } + | DeployValidationError::RemoteToolDefinitionNameMismatch { .. } + | DeployValidationError::RemoteToolVersionMismatch { .. } => { + api::error_code::deployment_validation::REMOTE_TOOL_IDENTITY_MISMATCH + } + DeployValidationError::RemoteToolUnsupportedMetadataVersion { .. } => { + api::error_code::deployment_validation::REMOTE_TOOL_UNSUPPORTED_METADATA_VERSION + } + DeployValidationError::RemoteToolMetadataDigestMismatch { .. } => { + api::error_code::deployment_validation::REMOTE_TOOL_METADATA_DIGEST_MISMATCH + } + DeployValidationError::InvalidRemoteTool { .. } => { + api::error_code::deployment_validation::INVALID_REMOTE_TOOL + } + DeployValidationError::RemoteToolBindingUnknownAgent { .. } => { + api::error_code::deployment_validation::REMOTE_TOOL_BINDING_UNKNOWN_AGENT + } DeployValidationError::ToolBindingUnknownAgent { .. } => { api::error_code::deployment_validation::TOOL_BINDING_UNKNOWN_AGENT } @@ -615,6 +640,9 @@ impl From for ApiError { ComponentError::ConcurrentUpdate => { Self::conflict(api::error_code::CONCURRENT_UPDATE, error) } + ComponentError::ComponentSourceInUse(_) => { + Self::conflict(api::error_code::COMPONENT_IN_USE, error) + } ComponentError::ParentEnvironmentNotFound(_) => { Self::not_found(api::error_code::ENVIRONMENT_NOT_FOUND, error) } @@ -851,6 +879,69 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(value: ToolReleaseError) -> Self { + let error = value.to_safe_string(); + match value { + ToolReleaseError::ToolReleaseNotFound(_) + | ToolReleaseError::ReferencedToolReleaseNotFound => { + Self::not_found(api::error_code::TOOL_NOT_FOUND, error) + } + ToolReleaseError::ParentAccountNotFound(_) => { + Self::not_found(api::error_code::ACCOUNT_NOT_FOUND, error) + } + ToolReleaseError::PublicationToolNotFound(_) + | ToolReleaseError::DuplicatePublication(_) + | ToolReleaseError::PublicationOwnerMismatch(_) + | ToolReleaseError::PublicationHostSource(_) => { + Self::bad_request(api::error_code::TOOL_NOT_FOUND, error) + } + ToolReleaseError::ImmutableReleaseConflict => { + Self::conflict(api::error_code::TOOL_RELEASE_IMMUTABLE_CONFLICT, error) + } + ToolReleaseError::ProtectedToolRelease => { + Self::forbidden(api::error_code::AUTH_FORBIDDEN, error) + } + ToolReleaseError::Unauthorized(inner) => inner.into(), + ToolReleaseError::InternalError(_) => Self::InternalError(Json(ErrorBody { + error, + code: api::error_code::INTERNAL_UNKNOWN.to_string(), + cause: Some(value.into_anyhow()), + })), + } + } +} + +impl From for ApiError { + fn from(value: EnvironmentToolGrantError) -> Self { + let error = value.to_safe_string(); + match value { + EnvironmentToolGrantError::ParentEnvironmentNotFound(_) => { + Self::not_found(api::error_code::ENVIRONMENT_NOT_FOUND, error) + } + EnvironmentToolGrantError::EnvironmentToolGrantNotFound(_) + | EnvironmentToolGrantError::ReferencedToolReleaseNotFound => { + Self::not_found(api::error_code::TOOL_NOT_FOUND, error) + } + EnvironmentToolGrantError::GrantAlreadyExists + | EnvironmentToolGrantError::GrantNotDeleted(_) + | EnvironmentToolGrantError::AdministratorManagedToolGrant(_) => Self::conflict( + api::error_code::ENVIRONMENT_TOOL_GRANT_ALREADY_EXISTS, + error, + ), + EnvironmentToolGrantError::ProtectedToolGrant(_) => { + Self::forbidden(api::error_code::AUTH_FORBIDDEN, error) + } + EnvironmentToolGrantError::Unauthorized(inner) => inner.into(), + EnvironmentToolGrantError::InternalError(_) => Self::InternalError(Json(ErrorBody { + error, + code: api::error_code::INTERNAL_UNKNOWN.to_string(), + cause: Some(value.into_anyhow()), + })), + } + } +} + impl From for ApiError { fn from(value: DeploymentWriteError) -> Self { let error: String = value.to_safe_string(); diff --git a/golem-registry-service/src/api/mod.rs b/golem-registry-service/src/api/mod.rs index 4cf2c6612c..4c41f2f47a 100644 --- a/golem-registry-service/src/api/mod.rs +++ b/golem-registry-service/src/api/mod.rs @@ -22,6 +22,7 @@ pub mod cards; pub mod components; pub mod domain_registrations; pub mod environment_plugin_grants; +pub mod environment_tool_grants; pub mod environments; pub mod error; pub mod http_api_deployments; @@ -35,6 +36,7 @@ pub mod resource_definitions; pub mod retry_policies; pub mod security_schemes; pub mod tokens; +pub mod tool_releases; use self::account_usage::AccountUsageApi; use self::accounts::AccountsApi; @@ -45,6 +47,7 @@ use self::cards::CardsApi; use self::components::ComponentsApi; use self::domain_registrations::DomainRegistrationsApi; use self::environment_plugin_grants::EnvironmentPluginGrantsApi; +use self::environment_tool_grants::EnvironmentToolGrantsApi; use self::environments::EnvironmentsApi; use self::error::ApiError; use self::http_api_deployments::HttpApiDeploymentsApi; @@ -58,6 +61,7 @@ use self::resource_definitions::ResourceDefinitionsApi; use self::retry_policies::RetryPoliciesApi; use self::security_schemes::SecuritySchemesApi; use self::tokens::TokensApi; +use self::tool_releases::ToolReleasesApi; use crate::bootstrap::Services; use golem_service_base::api::HealthcheckApi; use poem_openapi::OpenApiService; @@ -71,7 +75,12 @@ pub type Apis = ( CardsApi, ComponentsApi, DomainRegistrationsApi, - (AdminApi, EnvironmentPluginGrantsApi, EnvironmentsApi), + ( + AdminApi, + EnvironmentPluginGrantsApi, + EnvironmentToolGrantsApi, + EnvironmentsApi, + ), HttpApiDeploymentsApi, (LoginApi, MeApi), ( @@ -82,7 +91,7 @@ pub type Apis = ( (ReportsApi, ResourceDefinitionsApi), RetryPoliciesApi, SecuritySchemesApi, - TokensApi, + (TokensApi, ToolReleasesApi), ); pub fn make_open_api_service(services: &Services) -> OpenApiService { @@ -126,6 +135,10 @@ pub fn make_open_api_service(services: &Services) -> OpenApiService { services.environment_plugin_grant_service.clone(), services.auth_service.clone(), ), + EnvironmentToolGrantsApi::new( + services.environment_tool_grant_service.clone(), + services.auth_service.clone(), + ), EnvironmentsApi::new( services.environment_service.clone(), services.deployment_service.clone(), @@ -177,9 +190,15 @@ pub fn make_open_api_service(services: &Services) -> OpenApiService { services.security_scheme_service.clone(), services.auth_service.clone(), ), - TokensApi::new( - services.token_service.clone(), - services.auth_service.clone(), + ( + TokensApi::new( + services.token_service.clone(), + services.auth_service.clone(), + ), + ToolReleasesApi::new( + services.tool_release_service.clone(), + services.auth_service.clone(), + ), ), ), "Golem API", diff --git a/golem-registry-service/src/api/tool_releases.rs b/golem-registry-service/src/api/tool_releases.rs new file mode 100644 index 0000000000..8c8e9791f6 --- /dev/null +++ b/golem-registry-service/src/api/tool_releases.rs @@ -0,0 +1,160 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::ApiResult; +use crate::services::auth::AuthService; +use crate::services::tool_release::ToolReleaseService; +use golem_common::model::Page; +use golem_common::model::account::AccountId; +use golem_common::model::tool_release::{ToolRelease, ToolReleaseId}; +use golem_common::recorded_http_api_request; +use golem_service_base::api_tags::ApiTags; +use golem_service_base::model::auth::GolemSecurityScheme; +use poem_openapi::OpenApi; +use poem_openapi::param::Path; +use poem_openapi::payload::Json; +use std::sync::Arc; +use tracing::Instrument; + +pub struct ToolReleasesApi { + tool_release_service: Arc, + auth_service: Arc, +} + +#[OpenApi( + prefix_path = "/v1", + tag = ApiTags::RegistryService, + tag = ApiTags::ToolReleases +)] +impl ToolReleasesApi { + pub fn new( + tool_release_service: Arc, + auth_service: Arc, + ) -> Self { + Self { + tool_release_service, + auth_service, + } + } + + /// List tool releases owned by an account + #[oai( + path = "/accounts/:account_id/tool-releases", + method = "get", + operation_id = "list_account_tool_releases", + tag = ApiTags::Account + )] + async fn list_account_tool_releases( + &self, + account_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult>> { + let record = recorded_http_api_request!( + "list_account_tool_releases", + account_id = account_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json(Page { + values: self + .tool_release_service + .list_in_account(account_id.0, &auth) + .await?, + })) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Get an account-owned tool release by ID + #[oai( + path = "/tool-releases/:release_id", + method = "get", + operation_id = "get_tool_release" + )] + async fn get_tool_release( + &self, + release_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = + recorded_http_api_request!("get_tool_release", release_id = release_id.0.to_string()); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.tool_release_service.get(release_id.0, &auth).await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// De-publish an account-owned tool release + #[oai( + path = "/tool-releases/:release_id", + method = "delete", + operation_id = "de_publish_tool_release" + )] + async fn de_publish_tool_release( + &self, + release_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "de_publish_tool_release", + release_id = release_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.tool_release_service + .de_publish(release_id.0, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } + + /// Restore a de-published account-owned tool release + #[oai( + path = "/tool-releases/:release_id/restore", + method = "post", + operation_id = "restore_tool_release" + )] + async fn restore_tool_release( + &self, + release_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "restore_tool_release", + release_id = release_id.0.to_string() + ); + let auth = self.auth_service.authenticate_token(token.secret()).await?; + let result = async { + Ok(Json( + self.tool_release_service + .restore(release_id.0, &auth) + .await?, + )) + } + .instrument(record.span.clone()) + .await; + record.result(result) + } +} diff --git a/golem-registry-service/src/bootstrap/mod.rs b/golem-registry-service/src/bootstrap/mod.rs index 29848aa12f..a83351937a 100644 --- a/golem-registry-service/src/bootstrap/mod.rs +++ b/golem-registry-service/src/bootstrap/mod.rs @@ -31,6 +31,7 @@ use crate::repo::environment::{DbEnvironmentRepo, EnvironmentRepo}; use crate::repo::environment_plugin_grant::{ DbEnvironmentPluginGrantRepo, EnvironmentPluginGrantRepo, }; +use crate::repo::environment_tool_grant::{DbEnvironmentToolGrantRepo, EnvironmentToolGrantRepo}; use crate::repo::http_api_deployment::{DbHttpApiDeploymentRepo, HttpApiDeploymentRepo}; use crate::repo::mcp_deployment::{DbMcpDeploymentRepo, McpDeploymentRepo}; use crate::repo::oauth2_token::{DbOAuth2TokenRepo, OAuth2TokenRepo}; @@ -44,6 +45,7 @@ use crate::repo::resource_definition::{DbResourceDefinitionRepo, ResourceDefinit use crate::repo::retry_policy::{DbRetryPolicyRepo, RetryPolicyRepo}; use crate::repo::security_scheme::{DbSecuritySchemeRepo, SecuritySchemeRepo}; use crate::repo::token::{DbTokenRepo, TokenRepo}; +use crate::repo::tool_release::{DbToolReleaseRepo, ToolReleaseRepo}; use crate::services::account::AccountService; use crate::services::account_resource_override::AccountResourceOverrideService; use crate::services::account_usage::AccountUsageService; @@ -62,6 +64,7 @@ use crate::services::domain_registration::DomainRegistrationService; use crate::services::environment::EnvironmentService; use crate::services::environment_plugin_grant::EnvironmentPluginGrantService; use crate::services::environment_state::EnvironmentStateService; +use crate::services::environment_tool_grant::EnvironmentToolGrantService; use crate::services::http_api_deployment::HttpApiDeploymentService; use crate::services::mcp_deployment::McpDeploymentService; use crate::services::permission_share::PermissionShareService; @@ -75,6 +78,7 @@ use crate::services::resource_definition::ResourceDefinitionService; use crate::services::retry_policy::RetryPolicyService; use crate::services::security_scheme::SecuritySchemeService; use crate::services::token::TokenService; +use crate::services::tool_release::ToolReleaseService; use anyhow::{Context, anyhow}; use golem_common::IntoAnyhow; use golem_common::config::DbConfig; @@ -112,6 +116,7 @@ pub struct Services { pub deployment_write_service: Arc, pub domain_registration_service: Arc, pub environment_plugin_grant_service: Arc, + pub environment_tool_grant_service: Arc, pub environment_service: Arc, pub environment_state_service: Arc, pub http_api_deployment_service: Arc, @@ -125,6 +130,7 @@ pub struct Services { pub reports_service: Arc, pub security_scheme_service: Arc, pub token_service: Arc, + pub tool_release_service: Arc, } struct Repos { @@ -139,6 +145,7 @@ struct Repos { deployment_repo: Arc, domain_registration_repo: Arc, environment_plugin_grant_repo: Arc, + environment_tool_grant_repo: Arc, environment_repo: Arc, http_api_deployment_repo: Arc, mcp_deployment_repo: Arc, @@ -152,6 +159,7 @@ struct Repos { reports_repo: Arc, security_scheme_repo: Arc, token_repo: Arc, + tool_release_repo: Arc, } impl Services { @@ -232,11 +240,18 @@ impl Services { let builtin_plugin_owner_account_id = config .initial_accounts - .values() - .find(|a| a.role == golem_common::model::auth::AccountRole::BuiltinPluginOwner) - .map(|a| a.id) + .get("builtin_plugin_owner") + .map(|account| account.id) .ok_or(anyhow!( - "No builtin-plugin-owner account found in initial_accounts" + "No builtin_plugin_owner account found in initial_accounts" + ))?; + + let builtin_tool_owner_account_id = config + .initial_accounts + .get("builtin_tool_owner") + .map(|account| account.id) + .ok_or(anyhow!( + "No builtin_tool_owner account found in initial_accounts" ))?; let application_service = Arc::new(ApplicationService::new( @@ -303,11 +318,23 @@ impl Services { builtin_plugin_owner_account_id, )); + let tool_release_service = Arc::new(ToolReleaseService::new( + repos.tool_release_repo.clone(), + account_service.clone(), + builtin_tool_owner_account_id, + )); + + let environment_tool_grant_service = Arc::new(EnvironmentToolGrantService::new( + repos.environment_tool_grant_repo.clone(), + environment_service.clone(), + tool_release_service.clone(), + )); + let component_write_service = Arc::new(ComponentWriteService::new( repos.component_repo.clone(), component_object_store, component_compilation_service.clone(), - initial_agent_files, + initial_agent_files.clone(), account_usage_service.clone(), environment_service.clone(), environment_plugin_grant_service.clone(), @@ -388,6 +415,9 @@ impl Services { security_scheme_service.clone(), resource_definition_service.clone(), retry_policy_service.clone(), + environment_tool_grant_service.clone(), + tool_release_service.clone(), + initial_agent_files.clone(), )); let deployed_routes_service = @@ -456,6 +486,7 @@ impl Services { deployment_write_service, domain_registration_service, environment_plugin_grant_service, + environment_tool_grant_service, environment_service, environment_state_service, http_api_deployment_service, @@ -467,6 +498,7 @@ impl Services { reports_service, security_scheme_service, token_service, + tool_release_service, }) } } @@ -507,6 +539,9 @@ async fn make_repos( let plugin_repo = Arc::new(DbPluginRepo::logged(db_pool.clone())); let environment_plugin_grant_repo = Arc::new(DbEnvironmentPluginGrantRepo::logged(db_pool.clone())); + let environment_tool_grant_repo = + Arc::new(DbEnvironmentToolGrantRepo::logged(db_pool.clone())); + let tool_release_repo = Arc::new(DbToolReleaseRepo::logged(db_pool.clone())); let deployment_repo = Arc::new(DbDeploymentRepo::logged(db_pool.clone())); let domain_registration_repo = Arc::new(DbDomainRegistrationRepo::logged(db_pool.clone())); @@ -531,6 +566,7 @@ async fn make_repos( deployment_repo, domain_registration_repo, environment_plugin_grant_repo, + environment_tool_grant_repo, environment_repo, http_api_deployment_repo, mcp_deployment_repo, @@ -544,6 +580,7 @@ async fn make_repos( reports_repo, security_scheme_repo, token_repo, + tool_release_repo, }) } DbConfig::Sqlite(sqlite_config) => { @@ -572,6 +609,9 @@ async fn make_repos( let plugin_repo = Arc::new(DbPluginRepo::logged(db_pool.clone())); let environment_plugin_grant_repo = Arc::new(DbEnvironmentPluginGrantRepo::logged(db_pool.clone())); + let environment_tool_grant_repo = + Arc::new(DbEnvironmentToolGrantRepo::logged(db_pool.clone())); + let tool_release_repo = Arc::new(DbToolReleaseRepo::logged(db_pool.clone())); let deployment_repo = Arc::new(DbDeploymentRepo::logged(db_pool.clone())); let domain_registration_repo = Arc::new(DbDomainRegistrationRepo::logged(db_pool.clone())); @@ -596,6 +636,7 @@ async fn make_repos( deployment_repo, domain_registration_repo, environment_plugin_grant_repo, + environment_tool_grant_repo, environment_repo, http_api_deployment_repo, mcp_deployment_repo, @@ -609,6 +650,7 @@ async fn make_repos( reports_repo, security_scheme_repo, token_repo, + tool_release_repo, }) } } diff --git a/golem-registry-service/src/config.rs b/golem-registry-service/src/config.rs index ff18d5a3cb..dffc78b71f 100644 --- a/golem-registry-service/src/config.rs +++ b/golem-registry-service/src/config.rs @@ -125,7 +125,7 @@ impl SafeDisplay for RegistryServiceConfig { impl Default for RegistryServiceConfig { fn default() -> Self { - let mut initial_accounts = HashMap::with_capacity(3); + let mut initial_accounts = HashMap::with_capacity(4); initial_accounts.insert( "root".to_string(), PrecreatedAccount { @@ -163,6 +163,17 @@ impl Default for RegistryServiceConfig { plan_id: PlanId(uuid!("157dc684-00eb-496d-941c-da8fd1d15c63")), }, ); + initial_accounts.insert( + "builtin_tool_owner".to_string(), + PrecreatedAccount { + id: AccountId(uuid!("58bda34c-10d4-4bfb-8abd-d5e67f09ba3c")), + name: "Builtin Tool Owner".to_string(), + email: AccountEmail::new("builtin-tool-owner@golem.cloud"), + token: None, + role: AccountRole::BuiltinPluginOwner, + plan_id: PlanId(uuid!("157dc684-00eb-496d-941c-da8fd1d15c63")), + }, + ); let mut initial_plans = HashMap::with_capacity(1); initial_plans.insert( @@ -204,6 +215,7 @@ impl Default for RegistryServiceConfig { grpc: GrpcApiConfig::default(), db: DbConfig::Sqlite(DbSqliteConfig { database: "golem_registry_service.db".to_string(), + foreign_keys: true, ..Default::default() }), login: LoginConfig::default(), diff --git a/golem-registry-service/src/repo/component.rs b/golem-registry-service/src/repo/component.rs index f253892918..c4acfcd9b0 100644 --- a/golem-registry-service/src/repo/component.rs +++ b/golem-registry-service/src/repo/component.rs @@ -531,6 +531,45 @@ impl ComponentRepo for DbComponentRepo { let deleted_cards = self .with_tx_err("delete", |tx| { async move { + let environment_id = tx + .fetch_optional( + sqlx::query(indoc! { r#" + SELECT environment_id + FROM components + WHERE component_id = $1 + AND deleted_at IS NULL + "#}) + .bind(component_id), + ) + .await? + .ok_or(ComponentRepoError::ConcurrentModification)? + .try_get::("environment_id") + .map_err(RepoError::from)?; + Self::lock_live_environment(tx, environment_id).await?; + + let source_reference = tx + .fetch_optional( + sqlx::query(indoc! { r#" + SELECT component_id + FROM tool_releases + WHERE component_id = $1 + UNION ALL + SELECT component_id + FROM deployment_component_revisions + WHERE component_id = $1 + UNION ALL + SELECT component_id + FROM deployment_registered_tools + WHERE component_id = $1 + LIMIT 1 + "#}) + .bind(component_id), + ) + .await?; + if source_reference.is_some() { + return Err(ComponentRepoError::ComponentSourceInUse); + } + let active_revisions: Vec = tx .fetch_all_as( sqlx::query_as(indoc! { r#" diff --git a/golem-registry-service/src/repo/deployment.rs b/golem-registry-service/src/repo/deployment.rs index 96a828d317..5ff7be8259 100644 --- a/golem-registry-service/src/repo/deployment.rs +++ b/golem-registry-service/src/repo/deployment.rs @@ -31,6 +31,7 @@ use super::model::resource_definition::ResourceDefinitionRepoError; use super::model::retry_policy::RetryPolicyRepoError; use super::resource_definition::DbResourceDefinitionRepo; use super::retry_policy::DbRetryPolicyRepo; +use super::tool_release::{DbToolReleaseRepo, ToolReleaseRepoError}; use crate::repo::model::audit::RevisionAuditFields; use crate::repo::model::component::ComponentRevisionIdentityRecord; use crate::repo::model::deployment::{ @@ -50,9 +51,10 @@ use futures::future::BoxFuture; use golem_service_base::db::postgres::PostgresPool; use golem_service_base::db::sqlite::SqlitePool; use golem_service_base::db::{LabelledPoolApi, LabelledPoolTransaction, Pool, PoolApi}; -use golem_service_base::repo::{BindingsStack, RepoError, RepoResult, ResultExt}; +use golem_service_base::repo::{BindingsStack, Blob, RepoError, RepoResult, ResultExt}; use indoc::{formatdoc, indoc}; use sqlx::{Database, Row}; +use std::collections::HashMap; use std::fmt::Debug; use tap::Pipe; use tracing::{Instrument, Span, info_span}; @@ -794,6 +796,12 @@ impl DeploymentRepo for DbDeploymentRepo { }; let revision_id = deployment_revision.revision_id; + let tool_state_record = self + .get_tool_deployment_state(environment_id, revision_id) + .await?; + let tool_state = + golem_common::model::tool::ToolDeploymentState::try_from(tool_state_record) + .map_err(|err| RepoError::InternalError(anyhow::anyhow!(err)))?; Ok(Some(DeployedDeploymentIdentity { deployment_revision, identity: DeploymentIdentity { @@ -806,6 +814,12 @@ impl DeploymentRepo for DbDeploymentRepo { mcp_deployments: self .get_deployed_mcp_deployments(environment_id, revision_id) .await?, + registered_tools: tool_state.registered_tools.into_values().collect(), + agent_tool_bindings: tool_state + .agent_tool_bindings + .into_values() + .flat_map(|bindings| bindings.into_values()) + .collect(), }, })) } @@ -832,6 +846,7 @@ impl DeploymentRepo for DbDeploymentRepo { let result = self .with_tx_err("deploy", |tx| { async move { + let mut deployment_creation = deployment_creation; let environment_id = deployment_creation.environment_id; let deployment_revision_id = deployment_creation.deployment_revision_id; @@ -872,6 +887,50 @@ impl DeploymentRepo for DbDeploymentRepo { .await?; } + let mut resolved_release_ids = HashMap::new(); + for tool_release in &deployment_creation.tool_releases { + let persisted = + DbToolReleaseRepo::::create_or_restore_within_transaction( + tx, + tool_release, + ) + .await + .map_err(|err| match err { + ToolReleaseRepoError::ImmutableConflict => { + DeployRepoError::ToolReleaseConflict + } + ToolReleaseRepoError::ConcurrentModification => { + DeployRepoError::ConcurrentModification + } + other => DeployRepoError::InternalError(anyhow::Error::new(other)), + })?; + resolved_release_ids.insert( + tool_release.tool_release_id, + persisted.release.tool_release_id, + ); + } + + for registered_tool in &mut deployment_creation.registered_tools { + if let Some(actual) = registered_tool + .tool_release_id + .and_then(|candidate| resolved_release_ids.get(&candidate)) + { + registered_tool.tool_release_id = Some(*actual); + } + } + for agent_tool_binding in &mut deployment_creation.agent_tool_bindings { + let binding = agent_tool_binding.compiled_binding.value(); + if let Some(actual) = binding + .release_id + .and_then(|candidate| resolved_release_ids.get(&candidate.0)) + { + let mut binding = binding.clone(); + binding.release_id = + Some(golem_common::model::tool_release::ToolReleaseId(*actual)); + agent_tool_binding.compiled_binding = Blob::new(binding); + } + } + for registered_tool in &deployment_creation.registered_tools { Self::create_deployment_registered_tool(tx, registered_tool).await?; } @@ -1282,24 +1341,20 @@ impl DeploymentRepo for DbDeploymentRepo { r.environment_id, r.deployment_revision_id, r.tool_name, + r.tool_release_id, + r.source_kind, r.component_id, r.component_revision_id, - c.name AS component_name, - a.account_id AS owner_account_id, - ac.email AS owner_account_email, + r.component_name, + r.host_tool_id, + r.implementation_version, + r.owner_account_id, + r.owner_account_email, r.tool_definition, r.tool_provision_config, - r.metadata_version + r.metadata_version, + r.metadata_digest FROM deployment_registered_tools r - JOIN components c - ON c.component_id = r.component_id - AND c.environment_id = r.environment_id - JOIN environments e - ON e.environment_id = r.environment_id - JOIN applications a - ON a.application_id = e.application_id - JOIN accounts ac - ON ac.account_id = a.account_id WHERE r.environment_id = $1 AND r.deployment_revision_id = $2 AND r.tool_name = $3 "#}) @@ -1322,24 +1377,20 @@ impl DeploymentRepo for DbDeploymentRepo { r.environment_id, r.deployment_revision_id, r.tool_name, + r.tool_release_id, + r.source_kind, r.component_id, r.component_revision_id, - c.name AS component_name, - a.account_id AS owner_account_id, - ac.email AS owner_account_email, + r.component_name, + r.host_tool_id, + r.implementation_version, + r.owner_account_id, + r.owner_account_email, r.tool_definition, r.tool_provision_config, - r.metadata_version + r.metadata_version, + r.metadata_digest FROM deployment_registered_tools r - JOIN components c - ON c.component_id = r.component_id - AND c.environment_id = r.environment_id - JOIN environments e - ON e.environment_id = r.environment_id - JOIN applications a - ON a.application_id = e.application_id - JOIN accounts ac - ON ac.account_id = a.account_id WHERE r.environment_id = $1 AND r.deployment_revision_id = $2 ORDER BY r.tool_name "#}) @@ -1922,6 +1973,8 @@ impl DeploymentRepoInternal for DbDeploymentRepo { components: Self::get_staged_components(tx, environment_id).await?, http_api_deployments: Self::get_staged_http_api_deployments(tx, environment_id).await?, mcp_deployments: Self::get_staged_mcp_deployments(tx, environment_id).await?, + registered_tools: Vec::new(), + agent_tool_bindings: Vec::new(), }) } @@ -2118,18 +2171,29 @@ impl DeploymentRepoInternal for DbDeploymentRepo { sqlx::query(indoc! { r#" INSERT INTO deployment_registered_tools (environment_id, deployment_revision_id, tool_name, - component_id, component_revision_id, - tool_definition, tool_provision_config, metadata_version) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + tool_release_id, source_kind, + component_id, component_revision_id, component_name, + host_tool_id, implementation_version, + owner_account_id, owner_account_email, + tool_definition, tool_provision_config, metadata_version, metadata_digest) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) "#}) .bind(registered_tool.environment_id) .bind(registered_tool.deployment_revision_id) .bind(®istered_tool.tool_name) + .bind(registered_tool.tool_release_id) + .bind(registered_tool.source_kind) .bind(registered_tool.component_id) .bind(registered_tool.component_revision_id) + .bind(®istered_tool.component_name) + .bind(®istered_tool.host_tool_id) + .bind(®istered_tool.implementation_version) + .bind(registered_tool.owner_account_id) + .bind(®istered_tool.owner_account_email) .bind(®istered_tool.tool_definition) .bind(®istered_tool.tool_provision_config) - .bind(®istered_tool.metadata_version), + .bind(®istered_tool.metadata_version) + .bind(registered_tool.metadata_digest), ) .await?; diff --git a/golem-registry-service/src/repo/environment_tool_grant.rs b/golem-registry-service/src/repo/environment_tool_grant.rs new file mode 100644 index 0000000000..567402531c --- /dev/null +++ b/golem-registry-service/src/repo/environment_tool_grant.rs @@ -0,0 +1,516 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::repo::model::environment_tool_grant::{ + ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE, ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED, + EnvironmentToolGrantRecord, EnvironmentToolGrantWithDetailsRecord, +}; +use crate::repo::model::tool_release::TOOL_RELEASE_LIFECYCLE_PUBLISHED; +use async_trait::async_trait; +use conditional_trait_gen::trait_gen; +use futures::FutureExt; +use golem_common::error_forwarding; +use golem_service_base::db::postgres::PostgresPool; +use golem_service_base::db::sqlite::SqlitePool; +use golem_service_base::db::{Pool, PoolApi}; +use golem_service_base::repo::{RepoError, ResultExt, SqlDateTime}; +use indoc::indoc; +use tracing::{Instrument, info_span}; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum EnvironmentToolGrantRepoError { + #[error("A grant for this environment and tool release already exists")] + GrantAlreadyExists, + #[error("Environment tool grant was modified concurrently")] + ConcurrentModification, + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +error_forwarding!(EnvironmentToolGrantRepoError, RepoError); + +#[async_trait] +pub trait EnvironmentToolGrantRepo: Send + Sync { + async fn create( + &self, + record: EnvironmentToolGrantRecord, + ) -> Result; + + async fn get_by_id( + &self, + grant_id: Uuid, + include_deleted: bool, + ) -> Result, EnvironmentToolGrantRepoError>; + + async fn get_by_environment_and_release( + &self, + environment_id: Uuid, + release_id: Uuid, + include_deleted: bool, + ) -> Result, EnvironmentToolGrantRepoError>; + + async fn list_by_environment( + &self, + environment_id: Uuid, + ) -> Result, EnvironmentToolGrantRepoError>; + + async fn get_active_by_release_ids( + &self, + environment_id: Uuid, + release_ids: &[Uuid], + ) -> Result, EnvironmentToolGrantRepoError>; + + async fn delete( + &self, + grant_id: Uuid, + actor: Uuid, + automatic_only: bool, + ) -> Result; + + async fn set_automatic( + &self, + grant_id: Uuid, + actor: Uuid, + automatic: bool, + ) -> Result, EnvironmentToolGrantRepoError>; + + async fn restore( + &self, + grant_id: Uuid, + actor: Uuid, + automatic: bool, + ) -> Result, EnvironmentToolGrantRepoError>; +} + +pub struct LoggedEnvironmentToolGrantRepo { + repo: Repo, +} + +impl LoggedEnvironmentToolGrantRepo { + pub fn new(repo: Repo) -> Self { + Self { repo } + } +} + +#[async_trait] +impl EnvironmentToolGrantRepo + for LoggedEnvironmentToolGrantRepo +{ + async fn create( + &self, + record: EnvironmentToolGrantRecord, + ) -> Result { + let span = info_span!("environment tool grant repository", grant_id = %record.environment_tool_grant_id); + self.repo.create(record).instrument(span).await + } + + async fn get_by_id( + &self, + grant_id: Uuid, + include_deleted: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + self.repo + .get_by_id(grant_id, include_deleted) + .instrument(info_span!("environment tool grant repository", grant_id = %grant_id)) + .await + } + + async fn get_by_environment_and_release( + &self, + environment_id: Uuid, + release_id: Uuid, + include_deleted: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + self.repo + .get_by_environment_and_release(environment_id, release_id, include_deleted) + .instrument(info_span!( + "environment tool grant repository", + environment_id = %environment_id, + release_id = %release_id, + )) + .await + } + + async fn list_by_environment( + &self, + environment_id: Uuid, + ) -> Result, EnvironmentToolGrantRepoError> { + self.repo + .list_by_environment(environment_id) + .instrument( + info_span!("environment tool grant repository", environment_id = %environment_id), + ) + .await + } + + async fn get_active_by_release_ids( + &self, + environment_id: Uuid, + release_ids: &[Uuid], + ) -> Result, EnvironmentToolGrantRepoError> { + self.repo + .get_active_by_release_ids(environment_id, release_ids) + .instrument( + info_span!("environment tool grant repository", environment_id = %environment_id), + ) + .await + } + + async fn delete( + &self, + grant_id: Uuid, + actor: Uuid, + automatic_only: bool, + ) -> Result { + self.repo + .delete(grant_id, actor, automatic_only) + .instrument(info_span!("environment tool grant repository", grant_id = %grant_id)) + .await + } + + async fn set_automatic( + &self, + grant_id: Uuid, + actor: Uuid, + automatic: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + self.repo + .set_automatic(grant_id, actor, automatic) + .instrument(info_span!("environment tool grant repository", grant_id = %grant_id)) + .await + } + + async fn restore( + &self, + grant_id: Uuid, + actor: Uuid, + automatic: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + self.repo + .restore(grant_id, actor, automatic) + .instrument(info_span!("environment tool grant repository", grant_id = %grant_id)) + .await + } +} + +pub struct DbEnvironmentToolGrantRepo { + db_pool: DBP, +} + +const METRICS_SVC_NAME: &str = "environment_tool_grants"; + +impl DbEnvironmentToolGrantRepo { + pub fn new(db_pool: DBP) -> Self { + Self { db_pool } + } + + pub fn logged(db_pool: DBP) -> LoggedEnvironmentToolGrantRepo + where + Self: EnvironmentToolGrantRepo, + { + LoggedEnvironmentToolGrantRepo::new(Self::new(db_pool)) + } + + fn with_ro(&self, api_name: &'static str) -> DBP::LabelledApi { + self.db_pool.with_ro(METRICS_SVC_NAME, api_name) + } + + fn with_rw(&self, api_name: &'static str) -> DBP::LabelledApi { + self.db_pool.with_rw(METRICS_SVC_NAME, api_name) + } +} + +const GRANT_DETAILS_SELECT: &str = r#" + SELECT + etg.environment_tool_grant_id, etg.environment_id, etg.protected, etg.automatic, + etg.lifecycle AS grant_lifecycle, + etg.created_at AS grant_created_at, etg.created_by AS grant_created_by, + etg.state_changed_at AS grant_state_changed_at, + etg.state_changed_by AS grant_state_changed_by, + etg.deleted_at AS grant_deleted_at, etg.deleted_by AS grant_deleted_by, + tr.tool_release_id, tr.owner_account_id, tr.tool_name, tr.tool_version, + tr.source_kind, tr.tool_definition, tr.metadata_version, tr.metadata_digest, + tr.lifecycle, tr.origin, tr.system_availability, + tr.created_at, tr.created_by, tr.state_changed_at, tr.state_changed_by, + tr.component_id, tr.component_revision, tr.component_name, + tr.host_tool_id, tr.implementation_version, + ar.name AS owner_account_name, a.email AS owner_account_email + FROM environment_tool_grants etg + JOIN tool_releases tr ON tr.tool_release_id = etg.tool_release_id + JOIN accounts a ON a.account_id = tr.owner_account_id + JOIN account_revisions ar + ON ar.account_id = a.account_id AND ar.revision_id = a.current_revision_id +"#; + +#[trait_gen(PostgresPool -> PostgresPool, SqlitePool)] +#[async_trait] +impl EnvironmentToolGrantRepo for DbEnvironmentToolGrantRepo { + async fn create( + &self, + record: EnvironmentToolGrantRecord, + ) -> Result { + let grant_id = record.environment_tool_grant_id; + self.db_pool + .with_tx_err(METRICS_SVC_NAME, "create", |tx| { + async move { + let release = tx + .execute( + sqlx::query(indoc! { r#" + UPDATE tool_releases + SET lifecycle = lifecycle + WHERE tool_release_id = $1 AND lifecycle = $2 + "#}) + .bind(record.tool_release_id) + .bind(TOOL_RELEASE_LIFECYCLE_PUBLISHED), + ) + .await?; + if release.rows_affected() != 1 { + return Err(EnvironmentToolGrantRepoError::ConcurrentModification); + } + tx.execute( + sqlx::query(indoc! { r#" + INSERT INTO environment_tool_grants ( + environment_tool_grant_id, environment_id, tool_release_id, + protected, automatic, lifecycle, created_at, created_by, + state_changed_at, state_changed_by, deleted_at, deleted_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL, NULL) + "#}) + .bind(record.environment_tool_grant_id) + .bind(record.environment_id) + .bind(record.tool_release_id) + .bind(record.protected) + .bind(record.automatic) + .bind(record.lifecycle) + .bind(record.created_at) + .bind(record.created_by) + .bind(record.state_changed_at) + .bind(record.state_changed_by), + ) + .await + .to_error_on_unique_violation( + EnvironmentToolGrantRepoError::GrantAlreadyExists, + )?; + + let query = + format!("{GRANT_DETAILS_SELECT} WHERE etg.environment_tool_grant_id = $1"); + tx.fetch_optional_as(sqlx::query_as(&query).bind(grant_id)) + .await? + .ok_or(EnvironmentToolGrantRepoError::ConcurrentModification) + } + .boxed() + }) + .await + } + + async fn get_by_id( + &self, + grant_id: Uuid, + include_deleted: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + let query = format!( + "{GRANT_DETAILS_SELECT} WHERE etg.environment_tool_grant_id = $1 AND ($2 OR (etg.lifecycle = {ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE} AND tr.lifecycle = {TOOL_RELEASE_LIFECYCLE_PUBLISHED}))" + ); + Ok(self + .with_ro("get_by_id") + .fetch_optional_as(sqlx::query_as(&query).bind(grant_id).bind(include_deleted)) + .await?) + } + + async fn get_by_environment_and_release( + &self, + environment_id: Uuid, + release_id: Uuid, + include_deleted: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + let query = format!( + "{GRANT_DETAILS_SELECT} WHERE etg.environment_id = $1 AND etg.tool_release_id = $2 AND ($3 OR (etg.lifecycle = {ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE} AND tr.lifecycle = {TOOL_RELEASE_LIFECYCLE_PUBLISHED}))" + ); + Ok(self + .with_ro("get_by_environment_and_release") + .fetch_optional_as( + sqlx::query_as(&query) + .bind(environment_id) + .bind(release_id) + .bind(include_deleted), + ) + .await?) + } + + async fn list_by_environment( + &self, + environment_id: Uuid, + ) -> Result, EnvironmentToolGrantRepoError> { + let query = format!( + "{GRANT_DETAILS_SELECT} WHERE etg.environment_id = $1 AND etg.deleted_at IS NULL AND tr.lifecycle = {TOOL_RELEASE_LIFECYCLE_PUBLISHED} ORDER BY tr.tool_name, tr.tool_version" + ); + Ok(self + .with_ro("list_by_environment") + .fetch_all_as(sqlx::query_as(&query).bind(environment_id)) + .await?) + } + + async fn get_active_by_release_ids( + &self, + environment_id: Uuid, + release_ids: &[Uuid], + ) -> Result, EnvironmentToolGrantRepoError> { + if release_ids.is_empty() { + return Ok(Vec::new()); + } + let placeholders = (0..release_ids.len()) + .map(|index| format!("${}", index + 2)) + .collect::>() + .join(", "); + let query = format!( + "{GRANT_DETAILS_SELECT} WHERE etg.environment_id = $1 AND etg.tool_release_id IN ({placeholders}) AND etg.deleted_at IS NULL AND tr.lifecycle = {TOOL_RELEASE_LIFECYCLE_PUBLISHED}" + ); + let mut query = sqlx::query_as(&query).bind(environment_id); + for release_id in release_ids { + query = query.bind(*release_id); + } + Ok(self + .with_ro("get_active_by_release_ids") + .fetch_all_as(query) + .await?) + } + + async fn delete( + &self, + grant_id: Uuid, + actor: Uuid, + automatic_only: bool, + ) -> Result { + let result = self + .with_rw("delete") + .fetch_optional( + sqlx::query(indoc! { r#" + UPDATE environment_tool_grants + SET lifecycle = $2, state_changed_at = $3, state_changed_by = $4, + deleted_at = $3, deleted_by = $4 + WHERE environment_tool_grant_id = $1 + AND lifecycle = $5 + AND NOT protected + AND (NOT $6 OR automatic) + RETURNING environment_tool_grant_id + "#}) + .bind(grant_id) + .bind(ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED) + .bind(SqlDateTime::now()) + .bind(actor) + .bind(ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE) + .bind(automatic_only), + ) + .await?; + Ok(result.is_some()) + } + + async fn set_automatic( + &self, + grant_id: Uuid, + actor: Uuid, + automatic: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + self.db_pool + .with_tx_err(METRICS_SVC_NAME, "set_automatic", |tx| { + async move { + let updated = tx + .execute( + sqlx::query(indoc! { r#" + UPDATE environment_tool_grants + SET automatic = $2, state_changed_at = $3, state_changed_by = $4 + WHERE environment_tool_grant_id = $1 + AND lifecycle = $5 + AND NOT protected + "#}) + .bind(grant_id) + .bind(automatic) + .bind(SqlDateTime::now()) + .bind(actor) + .bind(ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE), + ) + .await?; + if updated.rows_affected() != 1 { + return Ok(None); + } + let query = + format!("{GRANT_DETAILS_SELECT} WHERE etg.environment_tool_grant_id = $1"); + Ok(tx + .fetch_optional_as(sqlx::query_as(&query).bind(grant_id)) + .await?) + } + .boxed() + }) + .await + } + + async fn restore( + &self, + grant_id: Uuid, + actor: Uuid, + automatic: bool, + ) -> Result, EnvironmentToolGrantRepoError> { + self.db_pool + .with_tx_err(METRICS_SVC_NAME, "restore", |tx| { + async move { + let release = tx + .execute( + sqlx::query(indoc! { r#" + UPDATE tool_releases + SET lifecycle = lifecycle + WHERE lifecycle = $2 AND tool_release_id = ( + SELECT tool_release_id FROM environment_tool_grants + WHERE environment_tool_grant_id = $1 + ) + "#}) + .bind(grant_id) + .bind(TOOL_RELEASE_LIFECYCLE_PUBLISHED), + ) + .await?; + if release.rows_affected() != 1 { + return Ok(None); + } + let updated = tx + .execute( + sqlx::query(indoc! { r#" + UPDATE environment_tool_grants + SET lifecycle = $2, state_changed_at = $3, state_changed_by = $4, + automatic = $5, deleted_at = NULL, deleted_by = NULL + WHERE environment_tool_grant_id = $1 + AND lifecycle = $6 + AND NOT protected + "#}) + .bind(grant_id) + .bind(ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE) + .bind(SqlDateTime::now()) + .bind(actor) + .bind(automatic) + .bind(ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED), + ) + .await?; + if updated.rows_affected() != 1 { + return Ok(None); + } + let query = + format!("{GRANT_DETAILS_SELECT} WHERE etg.environment_tool_grant_id = $1"); + Ok(tx + .fetch_optional_as(sqlx::query_as(&query).bind(grant_id)) + .await?) + } + .boxed() + }) + .await + } +} diff --git a/golem-registry-service/src/repo/mod.rs b/golem-registry-service/src/repo/mod.rs index 48d9d45589..2fcef3231e 100644 --- a/golem-registry-service/src/repo/mod.rs +++ b/golem-registry-service/src/repo/mod.rs @@ -24,6 +24,7 @@ pub mod deployment; pub mod domain_registration; pub mod environment; pub mod environment_plugin_grant; +pub mod environment_tool_grant; pub mod http_api_deployment; pub mod mcp_deployment; pub mod oauth2_token; @@ -37,6 +38,7 @@ pub mod resource_definition; pub mod retry_policy; pub mod security_scheme; pub mod token; +pub mod tool_release; pub(crate) const REGISTRY_CHANGE_ADVISORY_LOCK_KEY: i64 = 100; diff --git a/golem-registry-service/src/repo/model/component.rs b/golem-registry-service/src/repo/model/component.rs index 6e990d928e..32d273b02c 100644 --- a/golem-registry-service/src/repo/model/component.rs +++ b/golem-registry-service/src/repo/model/component.rs @@ -44,6 +44,8 @@ pub enum ComponentRepoError { ComponentViolatesUniqueness, #[error("Concurrent modification")] ConcurrentModification, + #[error("Component is referenced by a tool release or deployment snapshot")] + ComponentSourceInUse, #[error("Version already exists: {version}")] VersionAlreadyExists { version: String }, #[error(transparent)] diff --git a/golem-registry-service/src/repo/model/deployment.rs b/golem-registry-service/src/repo/model/deployment.rs index e84d7f06a0..f0a4ca2997 100644 --- a/golem-registry-service/src/repo/model/deployment.rs +++ b/golem-registry-service/src/repo/model/deployment.rs @@ -19,6 +19,8 @@ use super::agent_secrets::{ use super::audit::DeletableRevisionAuditFields; use super::resource_definition::{ResourceDefinitionCreationArgs, ResourceDefinitionRepoError}; use super::retry_policy::{RetryPolicyCreationRecord, RetryPolicyRepoError}; +use super::tool_release::ToolReleaseRecord; +use super::tool_release::{TOOL_RELEASE_SOURCE_COMPONENT, TOOL_RELEASE_SOURCE_HOST}; use crate::model::agent_secret::{ DeploymentAgentSecretCreation, DeploymentAgentSecretReplacement, DeploymentAgentSecretUpdate, }; @@ -62,6 +64,7 @@ use golem_service_base::repo::Blob; use golem_service_base::repo::RepoError; use heck::ToKebabCase; use sqlx::FromRow; +use std::collections::BTreeSet; use std::str::FromStr; use uuid::Uuid; @@ -77,6 +80,8 @@ pub enum DeployRepoError { ResourceConflict { name: String }, #[error("Retry policy for name {name} already exists")] RetryPolicyConflict { name: String }, + #[error("Tool release coordinate exists with different immutable metadata")] + ToolReleaseConflict, #[error(transparent)] InternalError(#[from] anyhow::Error), } @@ -241,6 +246,8 @@ pub struct DeploymentIdentity { pub components: Vec, pub http_api_deployments: Vec, pub mcp_deployments: Vec, + pub registered_tools: Vec, + pub agent_tool_bindings: Vec, } impl DeploymentIdentity { @@ -248,10 +255,27 @@ impl DeploymentIdentity { self, current_revision: Option, ) -> Result { + let diffable = self.to_diffable(); + let remote_tools = diffable + .remote_tools + .iter() + .map(|(name, tool)| { + Ok( + golem_common::model::deployment::DeploymentPlanRemoteToolEntry { + name: ToolName::try_from(name.as_str()).map_err(anyhow::Error::msg)?, + hash: tool.hash()?, + }, + ) + }) + .collect::>>()?; + let published_tools = diffable + .published_tools + .iter() + .map(|name| ToolName::try_from(name.as_str()).map_err(anyhow::Error::msg)) + .collect::>>()?; Ok(DeploymentPlan { current_revision, - deployment_hash: self - .to_diffable() + deployment_hash: diffable .hash() .map_err(|err| DeployRepoError::InternalError(anyhow!(err)))?, components: self @@ -269,12 +293,43 @@ impl DeploymentIdentity { .into_iter() .map(|mcd| mcd.try_into()) .collect::, _>>()?, + remote_tools, + published_tools, }) } } impl DeploymentIdentity { pub fn to_diffable(&self) -> diff::Deployment { + let local_component_revisions = self + .components + .iter() + .map(|component| { + ( + golem_common::model::component::ComponentId(component.component_id), + component + .revision_id + .try_into() + .expect("deployment component revision was validated when persisted"), + ) + }) + .collect::>(); + let published_tools = self + .registered_tools + .iter() + .filter(|tool| { + tool.release_id.is_some() + && matches!( + &tool.source, + ToolSource::Component { + component_id, + component_revision, + .. + } if local_component_revisions.contains(&(*component_id, *component_revision)) + ) + }) + .filter_map(|tool| tool.definition.name().map(ToOwned::to_owned)) + .collect(); diff::Deployment { components: self .components @@ -306,6 +361,12 @@ impl DeploymentIdentity { ) }) .collect(), + remote_tools: diff::remote_tool_deployments( + self.registered_tools.clone(), + self.agent_tool_bindings.clone(), + &local_component_revisions, + ), + published_tools, } } } @@ -318,6 +379,24 @@ pub struct DeployedDeploymentIdentity { impl TryFrom for DeploymentSummary { type Error = DeployRepoError; fn try_from(value: DeployedDeploymentIdentity) -> Result { + let diffable = value.identity.to_diffable(); + let remote_tools = diffable + .remote_tools + .iter() + .map(|(name, tool)| { + Ok( + golem_common::model::deployment::DeploymentPlanRemoteToolEntry { + name: ToolName::try_from(name.as_str()).map_err(anyhow::Error::msg)?, + hash: tool.hash()?, + }, + ) + }) + .collect::>>()?; + let published_tools = diffable + .published_tools + .iter() + .map(|name| ToolName::try_from(name.as_str()).map_err(anyhow::Error::msg)) + .collect::>>()?; Ok(Self { deployment_revision: value.deployment_revision.revision_id.try_into()?, deployment_hash: value.deployment_revision.hash.into(), @@ -339,6 +418,8 @@ impl TryFrom for DeploymentSummary { .into_iter() .map(|mcd| mcd.try_into()) .collect::, _>>()?, + remote_tools, + published_tools, }) } } @@ -459,23 +540,55 @@ pub struct DeploymentRegisteredToolRecord { pub environment_id: Uuid, pub deployment_revision_id: i64, pub tool_name: String, - pub component_id: Uuid, - pub component_revision_id: i64, - pub component_name: String, + pub tool_release_id: Option, + pub source_kind: i16, + pub component_id: Option, + pub component_revision_id: Option, + pub component_name: Option, + pub host_tool_id: Option, + pub implementation_version: Option, pub owner_account_id: Uuid, pub owner_account_email: String, pub tool_definition: Blob, pub tool_provision_config: Blob, pub metadata_version: String, + pub metadata_digest: Option, } impl DeploymentRegisteredToolRecord { pub fn from_model(environment_id: EnvironmentId, registered_tool: RegisteredTool) -> Self { - let ToolSource::Component { + let ( + source_kind, component_id, - component_revision, + component_revision_id, component_name, - } = registered_tool.source; + host_tool_id, + implementation_version, + ) = match registered_tool.source { + ToolSource::Component { + component_id, + component_revision, + component_name, + } => ( + TOOL_RELEASE_SOURCE_COMPONENT, + Some(component_id.0), + Some(component_revision.into()), + Some(component_name.0), + None, + None, + ), + ToolSource::Host { + host_tool_id, + implementation_version, + } => ( + TOOL_RELEASE_SOURCE_HOST, + None, + None, + None, + Some(host_tool_id.as_str().to_string()), + Some(implementation_version), + ), + }; Self { environment_id: environment_id.0, deployment_revision_id: registered_tool.deployment_revision.into(), @@ -484,14 +597,19 @@ impl DeploymentRegisteredToolRecord { .name() .expect("registered tool definition has a validated name") .to_string(), - component_id: component_id.0, - component_revision_id: component_revision.into(), - component_name: component_name.0, + tool_release_id: registered_tool.release_id.map(|id| id.0), + source_kind, + component_id, + component_revision_id, + component_name, + host_tool_id, + implementation_version, owner_account_id: registered_tool.owner_account_id.0, owner_account_email: registered_tool.owner_account_email.into_inner(), tool_definition: Blob::new(registered_tool.definition), tool_provision_config: Blob::new(registered_tool.provision), metadata_version: registered_tool.metadata_version, + metadata_digest: Some(registered_tool.metadata_digest.into()), } } } @@ -500,18 +618,58 @@ impl TryFrom for RegisteredTool { type Error = DeployRepoError; fn try_from(value: DeploymentRegisteredToolRecord) -> Result { + let source = + match value.source_kind { + TOOL_RELEASE_SOURCE_COMPONENT => ToolSource::Component { + component_id: value + .component_id + .ok_or_else(|| anyhow!("component tool snapshot is missing component_id"))? + .into(), + component_revision: value + .component_revision_id + .ok_or_else(|| { + anyhow!("component tool snapshot is missing component_revision") + })? + .try_into()?, + component_name: ComponentName(value.component_name.ok_or_else(|| { + anyhow!("component tool snapshot is missing component_name") + })?), + }, + TOOL_RELEASE_SOURCE_HOST => ToolSource::Host { + host_tool_id: value + .host_tool_id + .ok_or_else(|| anyhow!("host tool snapshot is missing host_tool_id"))? + .try_into() + .map_err(|error: String| anyhow!(error))?, + implementation_version: value.implementation_version.ok_or_else(|| { + anyhow!("host tool snapshot is missing implementation_version") + })?, + }, + other => return Err(anyhow!("unknown tool snapshot source kind {other}").into()), + }; + let definition = value.tool_definition.into_value(); + let metadata_digest = value + .metadata_digest + .map(Into::into) + .map(Ok) + .unwrap_or_else(|| { + golem_common::model::tool_release::tool_metadata_digest( + &value.metadata_version, + &definition, + ) + })?; Ok(Self { deployment_revision: value.deployment_revision_id.try_into()?, - definition: value.tool_definition.into_value(), + release_id: value + .tool_release_id + .map(golem_common::model::tool_release::ToolReleaseId), + definition, provision: value.tool_provision_config.into_value(), - source: ToolSource::Component { - component_id: value.component_id.into(), - component_revision: value.component_revision_id.try_into()?, - component_name: ComponentName(value.component_name), - }, + source, owner_account_id: value.owner_account_id.into(), owner_account_email: AccountEmail::new(value.owner_account_email), metadata_version: value.metadata_version, + metadata_digest, }) } } @@ -578,7 +736,7 @@ impl TryFrom for ToolDeploymentState { value.deployment_revision_id ))); } - let binding = record.compiled_binding.into_value(); + let mut binding = record.compiled_binding.into_value(); if binding.deployment_revision != deployment_revision || binding.agent_type_name.0 != record.agent_type_name || binding.tool_name.as_str() != record.tool_name @@ -593,9 +751,14 @@ impl TryFrom for ToolDeploymentState { binding.tool_name )) })?; + if binding.metadata_digest == Hash::empty() { + binding.metadata_digest = registered.metadata_digest; + } if binding.source != registered.source + || binding.release_id != registered.release_id || binding.version != registered.definition.version || binding.metadata_version != registered.metadata_version + || binding.metadata_digest != registered.metadata_digest || binding.account_id != registered.owner_account_id || binding.account_email != registered.owner_account_email { @@ -694,6 +857,7 @@ pub struct DeploymentRevisionCreationRecord { pub registered_agent_types: Vec, pub registered_tools: Vec, pub agent_tool_bindings: Vec, + pub tool_releases: Vec, pub created_agent_secrets: Vec, pub updated_agent_secrets: Vec, @@ -719,6 +883,7 @@ impl DeploymentRevisionCreationRecord { registered_agent_types: Vec, registered_tools: Vec, agent_tool_bindings: Vec, + tool_releases: Vec, created_agent_secrets: Vec, updated_agent_secrets: Vec, replaced_agent_secrets: Vec, @@ -796,6 +961,7 @@ impl DeploymentRevisionCreationRecord { DeploymentAgentToolBindingRecord::from_model(environment_id, binding) }) .collect(), + tool_releases, created_agent_secrets: created_agent_secrets .into_iter() .map(|r| { diff --git a/golem-registry-service/src/repo/model/environment_tool_grant.rs b/golem-registry-service/src/repo/model/environment_tool_grant.rs new file mode 100644 index 0000000000..010ed0c26d --- /dev/null +++ b/golem-registry-service/src/repo/model/environment_tool_grant.rs @@ -0,0 +1,137 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::tool_release::ToolReleaseWithOwnerRecord; +use golem_common::model::account::{AccountId, AccountSummary}; +use golem_common::model::environment::EnvironmentId; +use golem_common::model::environment_tool_grant::{ + EnvironmentToolGrant, EnvironmentToolGrantId, EnvironmentToolGrantLifecycle, + EnvironmentToolGrantWithDetails, +}; +use golem_common::model::tool_release::{ToolRelease, ToolReleaseId, ToolReleaseMetadata}; +use golem_service_base::repo::SqlDateTime; +use sqlx::FromRow; +use uuid::Uuid; + +pub const ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE: i16 = 0; +pub const ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED: i16 = 1; + +#[derive(Debug, Clone, PartialEq, FromRow)] +pub struct EnvironmentToolGrantRecord { + pub environment_tool_grant_id: Uuid, + pub environment_id: Uuid, + pub tool_release_id: Uuid, + pub protected: bool, + pub automatic: bool, + pub lifecycle: i16, + pub created_at: SqlDateTime, + pub created_by: Uuid, + pub state_changed_at: SqlDateTime, + pub state_changed_by: Uuid, + pub deleted_at: Option, + pub deleted_by: Option, +} + +impl EnvironmentToolGrantRecord { + pub fn creation( + environment_id: EnvironmentId, + tool_release_id: ToolReleaseId, + protected: bool, + automatic: bool, + actor: AccountId, + ) -> Self { + let now = SqlDateTime::now(); + Self { + environment_tool_grant_id: EnvironmentToolGrantId::new().0, + environment_id: environment_id.0, + tool_release_id: tool_release_id.0, + protected, + automatic, + lifecycle: ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE, + created_at: now.clone(), + created_by: actor.0, + state_changed_at: now, + state_changed_by: actor.0, + deleted_at: None, + deleted_by: None, + } + } +} + +impl From for EnvironmentToolGrant { + fn from(value: EnvironmentToolGrantRecord) -> Self { + Self { + id: EnvironmentToolGrantId(value.environment_tool_grant_id), + environment_id: EnvironmentId(value.environment_id), + tool_release_id: ToolReleaseId(value.tool_release_id), + protected: value.protected, + automatic: value.automatic, + lifecycle: grant_lifecycle(value.lifecycle), + created_at: value.created_at.into(), + created_by: AccountId(value.created_by), + state_changed_at: value.state_changed_at.into(), + state_changed_by: AccountId(value.state_changed_by), + } + } +} + +#[derive(Debug, Clone, PartialEq, FromRow)] +pub struct EnvironmentToolGrantWithDetailsRecord { + pub environment_tool_grant_id: Uuid, + pub environment_id: Uuid, + pub protected: bool, + pub automatic: bool, + pub grant_lifecycle: i16, + pub grant_created_at: SqlDateTime, + pub grant_created_by: Uuid, + pub grant_state_changed_at: SqlDateTime, + pub grant_state_changed_by: Uuid, + pub grant_deleted_at: Option, + pub grant_deleted_by: Option, + #[sqlx(flatten)] + pub release: ToolReleaseWithOwnerRecord, +} + +impl TryFrom for EnvironmentToolGrantWithDetails { + type Error = anyhow::Error; + + fn try_from(value: EnvironmentToolGrantWithDetailsRecord) -> Result { + let owner: AccountSummary = value.release.owner(); + let release: ToolRelease = value.release.release.try_into()?; + Ok(Self { + grant: EnvironmentToolGrant { + id: EnvironmentToolGrantId(value.environment_tool_grant_id), + environment_id: EnvironmentId(value.environment_id), + tool_release_id: release.id, + protected: value.protected, + automatic: value.automatic, + lifecycle: grant_lifecycle(value.grant_lifecycle), + created_at: value.grant_created_at.into(), + created_by: AccountId(value.grant_created_by), + state_changed_at: value.grant_state_changed_at.into(), + state_changed_by: AccountId(value.grant_state_changed_by), + }, + release: ToolReleaseMetadata::from(&release), + release_owner: owner, + }) + } +} + +fn grant_lifecycle(value: i16) -> EnvironmentToolGrantLifecycle { + match value { + ENVIRONMENT_TOOL_GRANT_LIFECYCLE_ACTIVE => EnvironmentToolGrantLifecycle::Active, + ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED => EnvironmentToolGrantLifecycle::Deleted, + _ => EnvironmentToolGrantLifecycle::Deleted, + } +} diff --git a/golem-registry-service/src/repo/model/mod.rs b/golem-registry-service/src/repo/model/mod.rs index 15d7b21110..4513eb349b 100644 --- a/golem-registry-service/src/repo/model/mod.rs +++ b/golem-registry-service/src/repo/model/mod.rs @@ -24,6 +24,7 @@ pub mod deployment; pub mod domain_registration; pub mod environment; pub mod environment_plugin_grant; +pub mod environment_tool_grant; pub mod hash; pub mod http_api_deployment; pub mod mcp_deployment; @@ -37,6 +38,7 @@ pub mod resource_definition; pub mod retry_policy; pub mod security_scheme; pub mod token; +pub mod tool_release; use self::audit::ImmutableAuditFields; use crate::repo::model::audit::{AuditFields, DeletableRevisionAuditFields, RevisionAuditFields}; diff --git a/golem-registry-service/src/repo/model/tool_release.rs b/golem-registry-service/src/repo/model/tool_release.rs new file mode 100644 index 0000000000..ba530c2747 --- /dev/null +++ b/golem-registry-service/src/repo/model/tool_release.rs @@ -0,0 +1,293 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::hash::SqlBlake3Hash; +use anyhow::anyhow; +use golem_common::model::account::{AccountEmail, AccountId, AccountSummary}; +use golem_common::model::component::{ComponentId, ComponentName, ComponentRevision}; +use golem_common::model::tool::{HostToolId, RegisteredTool, ToolName, ToolSource}; +use golem_common::model::tool_release::{ + SystemToolAvailability, SystemToolReleaseProvision, ToolRelease, ToolReleaseId, + ToolReleaseLifecycle, ToolReleaseOrigin, tool_metadata_digest, +}; +use golem_common::schema::tool::Tool; +use golem_service_base::repo::{Blob, SqlDateTime}; +use sqlx::FromRow; +use uuid::Uuid; + +pub const TOOL_RELEASE_SOURCE_COMPONENT: i16 = 0; +pub const TOOL_RELEASE_SOURCE_HOST: i16 = 1; +pub const TOOL_RELEASE_LIFECYCLE_PUBLISHED: i16 = 0; +pub const TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED: i16 = 1; +pub const TOOL_RELEASE_ORIGIN_ORDINARY: i16 = 0; +pub const TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM: i16 = 1; +pub const SYSTEM_TOOL_AVAILABILITY_GRANTABLE: i16 = 0; +pub const SYSTEM_TOOL_AVAILABILITY_AUTO_GRANTED: i16 = 1; +pub const SYSTEM_TOOL_AVAILABILITY_AMBIENT: i16 = 2; + +#[derive(Debug, Clone, PartialEq, FromRow)] +pub struct ToolReleaseRecord { + pub tool_release_id: Uuid, + pub owner_account_id: Uuid, + pub tool_name: String, + pub tool_version: String, + pub source_kind: i16, + pub tool_definition: Blob, + pub metadata_version: String, + pub metadata_digest: SqlBlake3Hash, + pub lifecycle: i16, + pub origin: i16, + pub system_availability: Option, + pub created_at: SqlDateTime, + pub created_by: Uuid, + pub state_changed_at: SqlDateTime, + pub state_changed_by: Uuid, + pub component_id: Option, + pub component_revision: Option, + pub component_name: Option, + pub host_tool_id: Option, + pub implementation_version: Option, +} + +#[derive(Debug, Clone, PartialEq, FromRow)] +pub struct ToolReleaseWithOwnerRecord { + #[sqlx(flatten)] + pub release: ToolReleaseRecord, + pub owner_account_name: String, + pub owner_account_email: String, +} + +impl ToolReleaseRecord { + pub fn from_registered_tool(tool: &RegisteredTool, actor: AccountId) -> anyhow::Result { + let name = tool + .definition + .name() + .ok_or_else(|| anyhow!("published tool definition has no root name"))?; + let now = SqlDateTime::now(); + let mut record = Self { + tool_release_id: ToolReleaseId::new().0, + owner_account_id: tool.owner_account_id.0, + tool_name: name.to_string(), + tool_version: tool.definition.version.clone(), + source_kind: TOOL_RELEASE_SOURCE_COMPONENT, + tool_definition: Blob::new(tool.definition.clone()), + metadata_version: tool.metadata_version.clone(), + metadata_digest: tool_metadata_digest(&tool.metadata_version, &tool.definition)?.into(), + lifecycle: TOOL_RELEASE_LIFECYCLE_PUBLISHED, + origin: TOOL_RELEASE_ORIGIN_ORDINARY, + system_availability: None, + created_at: now.clone(), + created_by: actor.0, + state_changed_at: now, + state_changed_by: actor.0, + component_id: None, + component_revision: None, + component_name: None, + host_tool_id: None, + implementation_version: None, + }; + record.set_source(&tool.source); + Ok(record) + } + + pub fn from_system_provision( + owner_account_id: AccountId, + provision: SystemToolReleaseProvision, + actor: AccountId, + ) -> anyhow::Result { + if provision.definition.name() != Some(provision.name.as_str()) + || provision.definition.version != provision.version + { + return Err(anyhow!( + "system tool release coordinate does not match its definition" + )); + } + if !matches!(provision.source, ToolSource::Host { .. }) { + return Err(anyhow!( + "protected system tool releases must use a host source" + )); + } + let now = SqlDateTime::now(); + let mut record = Self { + tool_release_id: ToolReleaseId::new().0, + owner_account_id: owner_account_id.0, + tool_name: provision.name.into_inner(), + tool_version: provision.version, + source_kind: TOOL_RELEASE_SOURCE_HOST, + metadata_digest: tool_metadata_digest( + &provision.metadata_version, + &provision.definition, + )? + .into(), + tool_definition: Blob::new(provision.definition), + metadata_version: provision.metadata_version, + lifecycle: TOOL_RELEASE_LIFECYCLE_PUBLISHED, + origin: TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM, + system_availability: Some(availability_to_i16(provision.availability)), + created_at: now.clone(), + created_by: actor.0, + state_changed_at: now, + state_changed_by: actor.0, + component_id: None, + component_revision: None, + component_name: None, + host_tool_id: None, + implementation_version: None, + }; + record.set_source(&provision.source); + Ok(record) + } + + fn set_source(&mut self, source: &ToolSource) { + match source { + ToolSource::Component { + component_id, + component_revision, + component_name, + } => { + self.source_kind = TOOL_RELEASE_SOURCE_COMPONENT; + self.component_id = Some(component_id.0); + self.component_revision = Some((*component_revision).into()); + self.component_name = Some(component_name.0.clone()); + } + ToolSource::Host { + host_tool_id, + implementation_version, + } => { + self.source_kind = TOOL_RELEASE_SOURCE_HOST; + self.host_tool_id = Some(host_tool_id.as_str().to_string()); + self.implementation_version = Some(implementation_version.clone()); + } + } + } + + pub fn immutable_fields_match(&self, other: &Self) -> bool { + self.owner_account_id == other.owner_account_id + && self.tool_name == other.tool_name + && self.tool_version == other.tool_version + && self.source_kind == other.source_kind + && self.tool_definition == other.tool_definition + && self.metadata_version == other.metadata_version + && self.metadata_digest == other.metadata_digest + && self.origin == other.origin + && self.system_availability == other.system_availability + && self.component_id == other.component_id + && self.component_revision == other.component_revision + && self.component_name == other.component_name + && self.host_tool_id == other.host_tool_id + && self.implementation_version == other.implementation_version + } +} + +impl TryFrom for ToolRelease { + type Error = anyhow::Error; + + fn try_from(value: ToolReleaseRecord) -> Result { + let source = match value.source_kind { + TOOL_RELEASE_SOURCE_COMPONENT => ToolSource::Component { + component_id: ComponentId( + value + .component_id + .ok_or_else(|| anyhow!("missing component id"))?, + ), + component_revision: ComponentRevision::try_from( + value + .component_revision + .ok_or_else(|| anyhow!("missing component revision"))?, + )?, + component_name: ComponentName( + value + .component_name + .ok_or_else(|| anyhow!("missing component name"))?, + ), + }, + TOOL_RELEASE_SOURCE_HOST => ToolSource::Host { + host_tool_id: HostToolId::try_from( + value + .host_tool_id + .ok_or_else(|| anyhow!("missing host tool id"))?, + ) + .map_err(anyhow::Error::msg)?, + implementation_version: value + .implementation_version + .ok_or_else(|| anyhow!("missing implementation version"))?, + }, + other => return Err(anyhow!("unknown tool release source kind {other}")), + }; + + Ok(Self { + id: ToolReleaseId(value.tool_release_id), + owner_account_id: AccountId(value.owner_account_id), + name: ToolName::try_from(value.tool_name).map_err(anyhow::Error::msg)?, + version: value.tool_version, + source, + definition: value.tool_definition.into_value(), + metadata_version: value.metadata_version, + metadata_digest: value.metadata_digest.into(), + lifecycle: lifecycle_from_i16(value.lifecycle)?, + origin: origin_from_i16(value.origin)?, + system_availability: value + .system_availability + .map(availability_from_i16) + .transpose()?, + created_at: value.created_at.into(), + created_by: AccountId(value.created_by), + state_changed_at: value.state_changed_at.into(), + state_changed_by: AccountId(value.state_changed_by), + }) + } +} + +impl ToolReleaseWithOwnerRecord { + pub fn owner(&self) -> AccountSummary { + AccountSummary { + id: AccountId(self.release.owner_account_id), + name: self.owner_account_name.clone(), + email: AccountEmail::new(self.owner_account_email.clone()), + } + } +} + +fn lifecycle_from_i16(value: i16) -> anyhow::Result { + match value { + TOOL_RELEASE_LIFECYCLE_PUBLISHED => Ok(ToolReleaseLifecycle::Published), + TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED => Ok(ToolReleaseLifecycle::DePublished), + other => Err(anyhow!("unknown tool release lifecycle {other}")), + } +} + +fn origin_from_i16(value: i16) -> anyhow::Result { + match value { + TOOL_RELEASE_ORIGIN_ORDINARY => Ok(ToolReleaseOrigin::Ordinary), + TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM => Ok(ToolReleaseOrigin::ProtectedSystem), + other => Err(anyhow!("unknown tool release origin {other}")), + } +} + +fn availability_to_i16(value: SystemToolAvailability) -> i16 { + match value { + SystemToolAvailability::Grantable => SYSTEM_TOOL_AVAILABILITY_GRANTABLE, + SystemToolAvailability::AutoGranted => SYSTEM_TOOL_AVAILABILITY_AUTO_GRANTED, + SystemToolAvailability::Ambient => SYSTEM_TOOL_AVAILABILITY_AMBIENT, + } +} + +fn availability_from_i16(value: i16) -> anyhow::Result { + match value { + SYSTEM_TOOL_AVAILABILITY_GRANTABLE => Ok(SystemToolAvailability::Grantable), + SYSTEM_TOOL_AVAILABILITY_AUTO_GRANTED => Ok(SystemToolAvailability::AutoGranted), + SYSTEM_TOOL_AVAILABILITY_AMBIENT => Ok(SystemToolAvailability::Ambient), + other => Err(anyhow!("unknown system tool availability {other}")), + } +} diff --git a/golem-registry-service/src/repo/tool_release.rs b/golem-registry-service/src/repo/tool_release.rs new file mode 100644 index 0000000000..b81ca8f856 --- /dev/null +++ b/golem-registry-service/src/repo/tool_release.rs @@ -0,0 +1,488 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::repo::model::environment_tool_grant::ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED; +use crate::repo::model::tool_release::{ + TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED, TOOL_RELEASE_LIFECYCLE_PUBLISHED, + TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM, ToolReleaseRecord, ToolReleaseWithOwnerRecord, +}; +use async_trait::async_trait; +use conditional_trait_gen::trait_gen; +use futures::FutureExt; +use futures::future::BoxFuture; +use golem_common::error_forwarding; +use golem_service_base::db::postgres::PostgresPool; +use golem_service_base::db::sqlite::SqlitePool; +use golem_service_base::db::{LabelledPoolApi, Pool, PoolApi}; +use golem_service_base::repo::{RepoError, ResultExt, SqlDateTime}; +use indoc::indoc; +use std::fmt::Debug; +use tracing::{Instrument, info_span}; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum ToolReleaseRepoError { + #[error("Tool release coordinate already exists")] + CoordinateAlreadyExists, + #[error("Tool release coordinate exists with different immutable metadata")] + ImmutableConflict, + #[error("Tool release was modified concurrently")] + ConcurrentModification, + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +error_forwarding!(ToolReleaseRepoError, RepoError); + +#[async_trait] +pub trait ToolReleaseRepo: Send + Sync { + async fn create( + &self, + record: ToolReleaseRecord, + ) -> Result; + + async fn get_by_id( + &self, + tool_release_id: Uuid, + ) -> Result, ToolReleaseRepoError>; + + async fn get_by_coordinates( + &self, + owner_account_id: Uuid, + name: &str, + version: &str, + ) -> Result, ToolReleaseRepoError>; + + async fn list_by_owner( + &self, + owner_account_id: Uuid, + ) -> Result, ToolReleaseRepoError>; + + async fn de_publish( + &self, + tool_release_id: Uuid, + actor: Uuid, + ) -> Result, ToolReleaseRepoError>; + + async fn restore( + &self, + tool_release_id: Uuid, + actor: Uuid, + ) -> Result, ToolReleaseRepoError>; +} + +pub struct LoggedToolReleaseRepo { + repo: Repo, +} + +impl LoggedToolReleaseRepo { + pub fn new(repo: Repo) -> Self { + Self { repo } + } +} + +#[async_trait] +impl ToolReleaseRepo for LoggedToolReleaseRepo { + async fn create( + &self, + record: ToolReleaseRecord, + ) -> Result { + let span = info_span!("tool release repository", tool_release_id = %record.tool_release_id); + self.repo.create(record).instrument(span).await + } + + async fn get_by_id( + &self, + tool_release_id: Uuid, + ) -> Result, ToolReleaseRepoError> { + self.repo + .get_by_id(tool_release_id) + .instrument(info_span!("tool release repository", tool_release_id = %tool_release_id)) + .await + } + + async fn get_by_coordinates( + &self, + owner_account_id: Uuid, + name: &str, + version: &str, + ) -> Result, ToolReleaseRepoError> { + self.repo + .get_by_coordinates(owner_account_id, name, version) + .instrument(info_span!( + "tool release repository", + owner_account_id = %owner_account_id, + tool_name = name, + tool_version = version + )) + .await + } + + async fn list_by_owner( + &self, + owner_account_id: Uuid, + ) -> Result, ToolReleaseRepoError> { + self.repo + .list_by_owner(owner_account_id) + .instrument(info_span!("tool release repository", owner_account_id = %owner_account_id)) + .await + } + + async fn de_publish( + &self, + tool_release_id: Uuid, + actor: Uuid, + ) -> Result, ToolReleaseRepoError> { + self.repo + .de_publish(tool_release_id, actor) + .instrument(info_span!("tool release repository", tool_release_id = %tool_release_id)) + .await + } + + async fn restore( + &self, + tool_release_id: Uuid, + actor: Uuid, + ) -> Result, ToolReleaseRepoError> { + self.repo + .restore(tool_release_id, actor) + .instrument(info_span!("tool release repository", tool_release_id = %tool_release_id)) + .await + } +} + +pub struct DbToolReleaseRepo { + db_pool: DBP, +} + +const METRICS_SVC_NAME: &str = "tool_releases"; + +impl DbToolReleaseRepo { + pub fn new(db_pool: DBP) -> Self { + Self { db_pool } + } + + pub fn logged(db_pool: DBP) -> LoggedToolReleaseRepo + where + Self: ToolReleaseRepo, + { + LoggedToolReleaseRepo::new(Self::new(db_pool)) + } + + fn with_ro(&self, api_name: &'static str) -> DBP::LabelledApi { + self.db_pool.with_ro(METRICS_SVC_NAME, api_name) + } + + async fn with_tx_err(&self, api_name: &'static str, f: F) -> Result + where + R: Send, + E: Debug + Send + From, + F: for<'f> FnOnce( + &'f mut ::LabelledTransaction, + ) -> BoxFuture<'f, Result> + + Send, + { + self.db_pool + .with_tx_err(METRICS_SVC_NAME, api_name, f) + .await + } +} + +const RELEASE_SELECT: &str = r#" + SELECT + tr.tool_release_id, tr.owner_account_id, tr.tool_name, tr.tool_version, + tr.source_kind, tr.tool_definition, tr.metadata_version, tr.metadata_digest, + tr.lifecycle, tr.origin, tr.system_availability, + tr.created_at, tr.created_by, tr.state_changed_at, tr.state_changed_by, + tr.component_id, tr.component_revision, tr.component_name, + tr.host_tool_id, tr.implementation_version, + ar.name AS owner_account_name, a.email AS owner_account_email + FROM tool_releases tr + JOIN accounts a ON a.account_id = tr.owner_account_id + JOIN account_revisions ar + ON ar.account_id = a.account_id AND ar.revision_id = a.current_revision_id +"#; + +#[trait_gen(PostgresPool -> PostgresPool, SqlitePool)] +impl DbToolReleaseRepo { + pub async fn create_or_restore_within_transaction( + tx: &mut <::LabelledApi as LabelledPoolApi>::LabelledTransaction, + record: &ToolReleaseRecord, + ) -> Result { + let inserted = tx + .execute( + sqlx::query(indoc! { r#" + INSERT INTO tool_releases ( + tool_release_id, owner_account_id, tool_name, tool_version, + source_kind, component_id, component_revision, component_name, + host_tool_id, implementation_version, + tool_definition, metadata_version, metadata_digest, + lifecycle, origin, system_availability, + created_at, created_by, state_changed_at, state_changed_by + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 + ) + ON CONFLICT (owner_account_id, tool_name, tool_version) DO NOTHING + "#}) + .bind(record.tool_release_id) + .bind(record.owner_account_id) + .bind(&record.tool_name) + .bind(&record.tool_version) + .bind(record.source_kind) + .bind(record.component_id) + .bind(record.component_revision) + .bind(&record.component_name) + .bind(&record.host_tool_id) + .bind(&record.implementation_version) + .bind(&record.tool_definition) + .bind(&record.metadata_version) + .bind(record.metadata_digest) + .bind(record.lifecycle) + .bind(record.origin) + .bind(record.system_availability) + .bind(&record.created_at) + .bind(record.created_by) + .bind(&record.state_changed_at) + .bind(record.state_changed_by), + ) + .await?; + let _ = inserted; + + let query = format!( + "{RELEASE_SELECT} WHERE tr.owner_account_id = $1 AND tr.tool_name = $2 AND tr.tool_version = $3" + ); + let mut existing: ToolReleaseWithOwnerRecord = tx + .fetch_one_as( + sqlx::query_as(&query) + .bind(record.owner_account_id) + .bind(&record.tool_name) + .bind(&record.tool_version), + ) + .await?; + if !existing.release.immutable_fields_match(record) { + return Err(ToolReleaseRepoError::ImmutableConflict); + } + + if existing.release.lifecycle == TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED { + let updated = tx + .execute( + sqlx::query(indoc! { r#" + UPDATE tool_releases + SET lifecycle = $2, state_changed_at = $3, state_changed_by = $4 + WHERE tool_release_id = $1 AND lifecycle = $5 AND origin != $6 + "#}) + .bind(existing.release.tool_release_id) + .bind(TOOL_RELEASE_LIFECYCLE_PUBLISHED) + .bind(&record.state_changed_at) + .bind(record.state_changed_by) + .bind(TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED) + .bind(TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM), + ) + .await?; + if updated.rows_affected() != 1 { + return Err(ToolReleaseRepoError::ConcurrentModification); + } + existing.release.lifecycle = TOOL_RELEASE_LIFECYCLE_PUBLISHED; + existing.release.state_changed_at = record.state_changed_at.clone(); + existing.release.state_changed_by = record.state_changed_by; + } + + Ok(existing) + } +} + +#[trait_gen(PostgresPool -> PostgresPool, SqlitePool)] +#[async_trait] +impl ToolReleaseRepo for DbToolReleaseRepo { + async fn create( + &self, + record: ToolReleaseRecord, + ) -> Result { + let release_id = record.tool_release_id; + self.with_tx_err("create", |tx| { + async move { + tx.execute( + sqlx::query(indoc! { r#" + INSERT INTO tool_releases ( + tool_release_id, owner_account_id, tool_name, tool_version, + source_kind, component_id, component_revision, component_name, + host_tool_id, implementation_version, + tool_definition, metadata_version, metadata_digest, + lifecycle, origin, system_availability, + created_at, created_by, state_changed_at, state_changed_by + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 + ) + "#}) + .bind(record.tool_release_id) + .bind(record.owner_account_id) + .bind(&record.tool_name) + .bind(&record.tool_version) + .bind(record.source_kind) + .bind(record.component_id) + .bind(record.component_revision) + .bind(&record.component_name) + .bind(&record.host_tool_id) + .bind(&record.implementation_version) + .bind(&record.tool_definition) + .bind(&record.metadata_version) + .bind(record.metadata_digest) + .bind(record.lifecycle) + .bind(record.origin) + .bind(record.system_availability) + .bind(&record.created_at) + .bind(record.created_by) + .bind(&record.state_changed_at) + .bind(record.state_changed_by), + ) + .await + .to_error_on_unique_violation(ToolReleaseRepoError::CoordinateAlreadyExists)?; + + let query = format!("{RELEASE_SELECT} WHERE tr.tool_release_id = $1"); + tx.fetch_one_as(sqlx::query_as(&query).bind(release_id)) + .await + .map_err(Into::into) + } + .boxed() + }) + .await + } + + async fn get_by_id( + &self, + tool_release_id: Uuid, + ) -> Result, ToolReleaseRepoError> { + let query = format!("{RELEASE_SELECT} WHERE tr.tool_release_id = $1"); + Ok(self + .with_ro("get_by_id") + .fetch_optional_as(sqlx::query_as(&query).bind(tool_release_id)) + .await?) + } + + async fn get_by_coordinates( + &self, + owner_account_id: Uuid, + name: &str, + version: &str, + ) -> Result, ToolReleaseRepoError> { + let query = format!( + "{RELEASE_SELECT} WHERE tr.owner_account_id = $1 AND tr.tool_name = $2 AND tr.tool_version = $3" + ); + Ok(self + .with_ro("get_by_coordinates") + .fetch_optional_as( + sqlx::query_as(&query) + .bind(owner_account_id) + .bind(name) + .bind(version), + ) + .await?) + } + + async fn list_by_owner( + &self, + owner_account_id: Uuid, + ) -> Result, ToolReleaseRepoError> { + let query = format!( + "{RELEASE_SELECT} WHERE tr.owner_account_id = $1 ORDER BY tr.tool_name, tr.tool_version" + ); + Ok(self + .with_ro("list_by_owner") + .fetch_all_as(sqlx::query_as(&query).bind(owner_account_id)) + .await?) + } + + async fn de_publish( + &self, + tool_release_id: Uuid, + actor: Uuid, + ) -> Result, ToolReleaseRepoError> { + self.with_tx_err("de_publish", |tx| { + async move { + let now = SqlDateTime::now(); + let updated = tx + .fetch_optional( + sqlx::query(indoc! { r#" + UPDATE tool_releases + SET lifecycle = $2, state_changed_at = $3, state_changed_by = $4 + WHERE tool_release_id = $1 AND lifecycle = $5 AND origin != $6 + RETURNING tool_release_id + "#}) + .bind(tool_release_id) + .bind(TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED) + .bind(&now) + .bind(actor) + .bind(TOOL_RELEASE_LIFECYCLE_PUBLISHED) + .bind(TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM), + ) + .await?; + if updated.is_none() { + return Ok(None); + } + + tx.execute( + sqlx::query(indoc! { r#" + UPDATE environment_tool_grants + SET lifecycle = $2, state_changed_at = $3, state_changed_by = $4, + deleted_at = $3, deleted_by = $4 + WHERE tool_release_id = $1 + AND deleted_at IS NULL + AND NOT protected + "#}) + .bind(tool_release_id) + .bind(ENVIRONMENT_TOOL_GRANT_LIFECYCLE_DELETED) + .bind(&now) + .bind(actor), + ) + .await?; + + let query = format!("{RELEASE_SELECT} WHERE tr.tool_release_id = $1"); + Ok(tx + .fetch_optional_as(sqlx::query_as(&query).bind(tool_release_id)) + .await?) + } + .boxed() + }) + .await + } + + async fn restore( + &self, + tool_release_id: Uuid, + actor: Uuid, + ) -> Result, ToolReleaseRepoError> { + let now = SqlDateTime::now(); + self.db_pool + .with_rw(METRICS_SVC_NAME, "restore") + .execute( + sqlx::query(indoc! { r#" + UPDATE tool_releases + SET lifecycle = $2, state_changed_at = $3, state_changed_by = $4 + WHERE tool_release_id = $1 AND lifecycle = $5 AND origin != $6 + "#}) + .bind(tool_release_id) + .bind(TOOL_RELEASE_LIFECYCLE_PUBLISHED) + .bind(now) + .bind(actor) + .bind(TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED) + .bind(TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM), + ) + .await?; + self.get_by_id(tool_release_id).await + } +} diff --git a/golem-registry-service/src/services/account/card.rs b/golem-registry-service/src/services/account/card.rs index 76df54aed0..9a8bd406ab 100644 --- a/golem-registry-service/src/services/account/card.rs +++ b/golem-registry-service/src/services/account/card.rs @@ -23,18 +23,20 @@ use golem_common::model::card::recipient::RecipientPattern; use golem_common::model::card::{ AccountOauth2IdentityResourcePattern, AccountPermissionShareResourcePattern, AccountPluginResourcePattern, AccountResourcePattern, AccountTokenResourcePattern, - AccountUsageResourcePattern, AgentResourcePattern, ApplicationResourcePattern, - BlobResourcePattern, CardId, CardManagedBy, CardManagedByAccountRoot, CardResourcePattern, - ClassPermissionPattern, ComponentResourcePattern, ConfigResourcePattern, EnvResourcePattern, + AccountToolReleaseResourcePattern, AccountUsageResourcePattern, AgentResourcePattern, + ApplicationResourcePattern, BlobResourcePattern, CardId, CardManagedBy, + CardManagedByAccountRoot, CardResourcePattern, ClassPermissionPattern, + ComponentResourcePattern, ConfigResourcePattern, EnvResourcePattern, EnvironmentAgentSecretResourcePattern, EnvironmentBlobBucketResourcePattern, EnvironmentDomainRegistrationResourcePattern, EnvironmentHttpApiDeploymentResourcePattern, EnvironmentInitialFilesResourcePattern, EnvironmentKvBucketResourcePattern, EnvironmentMcpDeploymentResourcePattern, EnvironmentPluginGrantResourcePattern, EnvironmentResourceDefinitionResourcePattern, EnvironmentResourcePattern, EnvironmentRetryPolicyResourcePattern, EnvironmentSecuritySchemeResourcePattern, - FilesystemResourcePattern, KvResourcePattern, NetworkResourcePattern, OplogResourcePattern, - PermissionPattern, PlanResourcePattern, RdbmsResourcePattern, SecretResourcePattern, - SystemResourcePattern, SystemVerb, ToolResourcePattern, + EnvironmentToolGrantResourcePattern, FilesystemResourcePattern, KvResourcePattern, + NetworkResourcePattern, OplogResourcePattern, PermissionPattern, PlanResourcePattern, + RdbmsResourcePattern, SecretResourcePattern, SystemResourcePattern, SystemVerb, + ToolResourcePattern, }; pub(super) fn account_root_card_record( @@ -171,6 +173,12 @@ fn add_account_grants( recipient: RecipientPattern::Any, resource: AccountPluginResourcePattern::Any, }), + PermissionPattern::AccountToolRelease(ClassPermissionPattern { + verb: None, + owner: account_owner.clone(), + recipient: RecipientPattern::Any, + resource: AccountToolReleaseResourcePattern::Any, + }), PermissionPattern::AccountPermissionShare(ClassPermissionPattern { verb: None, owner: account_owner.clone(), @@ -255,6 +263,12 @@ fn add_account_grants( recipient: RecipientPattern::Any, resource: EnvironmentPluginGrantResourcePattern::Any, }), + PermissionPattern::EnvironmentToolGrant(ClassPermissionPattern { + verb: None, + owner: environment_owner.clone(), + recipient: RecipientPattern::Any, + resource: EnvironmentToolGrantResourcePattern::Any, + }), PermissionPattern::EnvironmentResourceDefinition(ClassPermissionPattern { verb: None, owner: environment_owner.clone(), @@ -341,3 +355,31 @@ fn add_account_grants( }), ]); } + +#[cfg(test)] +mod tests { + use super::account_root_card_record; + use golem_common::model::account::{AccountEmail, AccountId}; + use golem_common::model::card::{Card, PermissionPattern}; + use test_r::test; + + #[test] + fn account_root_card_includes_tool_release_and_environment_grant_permissions() { + let account_id = AccountId::new(); + let account_email = AccountEmail::new("tool-owner@example.com"); + let card: Card = account_root_card_record(account_id, account_email, &[]) + .try_into() + .unwrap(); + + assert!( + card.lower_positive + .iter() + .any(|grant| matches!(grant, PermissionPattern::AccountToolRelease(_))) + ); + assert!( + card.lower_positive + .iter() + .any(|grant| matches!(grant, PermissionPattern::EnvironmentToolGrant(_))) + ); + } +} diff --git a/golem-registry-service/src/services/builtin_plugin_provisioner.rs b/golem-registry-service/src/services/builtin_plugin_provisioner.rs index 26bb1d1c07..207dfbc84c 100644 --- a/golem-registry-service/src/services/builtin_plugin_provisioner.rs +++ b/golem-registry-service/src/services/builtin_plugin_provisioner.rs @@ -311,6 +311,8 @@ async fn deploy_environment( current_revision: plan.current_revision, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion(Uuid::new_v4().to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), diff --git a/golem-registry-service/src/services/component/error.rs b/golem-registry-service/src/services/component/error.rs index 6a4cac07d9..1861954fea 100644 --- a/golem-registry-service/src/services/component/error.rs +++ b/golem-registry-service/src/services/component/error.rs @@ -61,6 +61,8 @@ pub enum ComponentError { }, #[error("Concurrent update of component")] ConcurrentUpdate, + #[error("Component {0} is referenced by a tool release or deployment snapshot")] + ComponentSourceInUse(ComponentId), #[error("Environment not found: {0}")] ParentEnvironmentNotFound(EnvironmentId), #[error("Deployment revision {0} not found")] @@ -187,6 +189,7 @@ impl SafeDisplay for ComponentError { Self::EnvironmentPluginNotFound(_) => self.to_string(), Self::InvalidPluginScope { .. } => self.to_string(), Self::ConcurrentUpdate => self.to_string(), + Self::ComponentSourceInUse(_) => self.to_string(), Self::PluginInstallationNotFound(_) => self.to_string(), Self::ParentEnvironmentNotFound(_) => self.to_string(), Self::DeploymentRevisionNotFound(_) => self.to_string(), diff --git a/golem-registry-service/src/services/component/write.rs b/golem-registry-service/src/services/component/write.rs index ab2e23857b..2f9ad6dce2 100644 --- a/golem-registry-service/src/services/component/write.rs +++ b/golem-registry-service/src/services/component/write.rs @@ -648,6 +648,9 @@ impl ComponentWriteService { .await .map_err(|err| match err { ComponentRepoError::ConcurrentModification => ComponentError::ConcurrentUpdate, + ComponentRepoError::ComponentSourceInUse => { + ComponentError::ComponentSourceInUse(component_id) + } other => other.into(), })? .signal_new_events_available(self.registry_change_notifier.as_ref()); diff --git a/golem-registry-service/src/services/deployment/deploy_validation_error.rs b/golem-registry-service/src/services/deployment/deploy_validation_error.rs index 442bc433ee..f67d11ab5c 100644 --- a/golem-registry-service/src/services/deployment/deploy_validation_error.rs +++ b/golem-registry-service/src/services/deployment/deploy_validation_error.rs @@ -180,6 +180,56 @@ pub enum DeployValidationError { tool_name: ToolName, components: Vec, }, + #[error( + "Tool {tool_name} has multiple local or registry sources: {sources}", + sources = sources.join(", ") + )] + ToolSourceCollision { + tool_name: ToolName, + sources: Vec, + }, + #[error("Registry tool {tool_name} is unavailable in this environment")] + RemoteToolUnavailable { tool_name: ToolName }, + #[error("Registry tool declaration {tool_name} selected a release for tool {release_name}")] + RemoteToolNameMismatch { + tool_name: ToolName, + release_name: ToolName, + }, + #[error("Registry tool {tool_name} release definition has root name {definition_name:?}")] + RemoteToolDefinitionNameMismatch { + tool_name: ToolName, + definition_name: Option, + }, + #[error( + "Registry tool {tool_name} release version {release_version} does not match its definition version {definition_version}" + )] + RemoteToolVersionMismatch { + tool_name: ToolName, + release_version: String, + definition_version: String, + }, + #[error( + "Registry tool {tool_name} uses unsupported metadata schema version {metadata_version}" + )] + RemoteToolUnsupportedMetadataVersion { + tool_name: ToolName, + metadata_version: String, + }, + #[error("Registry tool {tool_name} has an invalid metadata digest")] + RemoteToolMetadataDigestMismatch { tool_name: ToolName }, + #[error( + "Registry tool {tool_name} is invalid: {errors}", + errors = errors.join(", ") + )] + InvalidRemoteTool { + tool_name: ToolName, + errors: Vec, + }, + #[error("Registry tool {tool_name} has a binding for unknown agent type {agent_type}")] + RemoteToolBindingUnknownAgent { + tool_name: ToolName, + agent_type: AgentTypeName, + }, #[error( "Tool {tool_name} in component {component_name} has a binding for unknown agent type {agent_type}" )] diff --git a/golem-registry-service/src/services/deployment/deployment_context.rs b/golem-registry-service/src/services/deployment/deployment_context.rs index 2082c1ed5c..4e2ed4a421 100644 --- a/golem-registry-service/src/services/deployment/deployment_context.rs +++ b/golem-registry-service/src/services/deployment/deployment_context.rs @@ -28,7 +28,8 @@ use crate::model::api_definition::UnboundCompiledRoute; use crate::repo::model::retry_policy::RetryPolicyCreationRecord; use crate::services::agent_secret::schema_contains_host_managed_capability; use crate::services::deployment::route_compilation::validate_path_segments; -use golem_common::base_model::account::AccountId; +use crate::services::environment_tool_grant::ResolvedGrantedToolRelease; +use golem_common::base_model::account::{AccountEmail, AccountId}; use golem_common::model::agent::{ AgentConfigSource, AgentTypeName, DeployedRegisteredAgentType, RegisteredAgentTypeImplementer, }; @@ -43,9 +44,10 @@ use golem_common::model::quota::{ResourceDefinition, ResourceDefinitionCreation, use golem_common::model::retry_policy::RetryPolicyId; use golem_common::model::security_scheme::SecuritySchemeName; use golem_common::model::tool::{ - CompiledToolBinding, RegisteredTool, TOOL_METADATA_WIT_VERSION, ToolBindingInput, - ToolDeploymentMetadata, ToolName, ToolSource, + CompiledToolBinding, RegisteredTool, RemoteToolDeployment, TOOL_METADATA_WIT_VERSION, + ToolBindingInput, ToolDeploymentMetadata, ToolName, ToolSource, }; +use golem_common::model::tool_release::ToolReleaseId; use golem_common::schema::AgentTypeSchema; use golem_common::schema::agent::reachable_defs; use golem_common::schema::graph::SchemaGraph; @@ -58,7 +60,7 @@ use golem_service_base::model::agent_secret::AgentSecret; use golem_service_base::model::component::Component; use golem_service_base::model::retry_policy::StoredRetryPolicy; use heck::ToKebabCase; -use std::collections::{BTreeMap, HashMap, HashSet, hash_map}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, hash_map}; #[derive(Debug)] pub struct CompiledTools { @@ -128,7 +130,16 @@ impl DeploymentContext { }) } - pub fn hash(&self) -> Result { + pub fn hash_with_tools( + &self, + compiled_tools: &CompiledTools, + published_tools: &[ToolName], + ) -> Result { + let local_component_revisions = self + .components + .values() + .map(|component| (component.id, component.revision)) + .collect::>(); let diffable = diff::Deployment { components: self .components @@ -145,15 +156,32 @@ impl DeploymentContext { .iter() .map(|(k, v)| (k.0.clone(), HashOf::from_hash(v.hash))) .collect(), + remote_tools: diff::remote_tool_deployments( + compiled_tools.registered_tools.clone(), + compiled_tools.agent_tool_bindings.clone(), + &local_component_revisions, + ), + published_tools: published_tools.iter().map(ToString::to_string).collect(), }; diffable.hash() } + #[cfg(test)] pub fn compile_tools( &self, deployment_revision: golem_common::model::deployment::DeploymentRevision, errors: &mut Vec, warnings: &mut Vec, + ) -> CompiledTools { + self.compile_tools_with_remote(deployment_revision, &[], errors, warnings) + } + + pub fn compile_tools_with_remote( + &self, + deployment_revision: golem_common::model::deployment::DeploymentRevision, + remote_tools: &[(RemoteToolDeployment, Option)], + errors: &mut Vec, + warnings: &mut Vec, ) -> CompiledTools { let mut implementations = BTreeMap::>::new(); @@ -211,6 +239,38 @@ impl DeploymentContext { } } + let mut all_sources = BTreeMap::>::new(); + for (tool_name, local_implementations) in &implementations { + all_sources.insert( + tool_name.clone(), + local_implementations + .iter() + .map(|(component, _, _)| format!("component {}", component.component_name)) + .collect(), + ); + } + let remote_tool_names = remote_tools + .iter() + .map(|(deployment, _)| deployment.name.clone()) + .collect::>(); + for (index, (deployment, resolved)) in remote_tools.iter().enumerate() { + let source = resolved + .as_ref() + .map(|resolved| format!("registry release {}", resolved.release.id)) + .unwrap_or_else(|| format!("registry reference {}", index + 1)); + all_sources + .entry(deployment.name.clone()) + .or_default() + .push(source); + } + let mut colliding_tools = HashSet::new(); + for (tool_name, sources) in all_sources { + if sources.len() > 1 && remote_tool_names.contains(&tool_name) { + colliding_tools.insert(tool_name.clone()); + errors.push(DeployValidationError::ToolSourceCollision { tool_name, sources }); + } + } + let mut registered_tools = Vec::new(); let mut agent_tool_bindings = Vec::new(); @@ -234,6 +294,9 @@ impl DeploymentContext { }); continue; } + if colliding_tools.contains(&tool_name) { + continue; + } let (component, metadata, valid, (environment_binding, valid_agent_bindings)) = implementations @@ -248,14 +311,21 @@ impl DeploymentContext { component_revision: component.revision, component_name: component.component_name.clone(), }; + let metadata_digest = golem_common::model::tool_release::tool_metadata_digest( + TOOL_METADATA_WIT_VERSION, + &metadata.definition, + ) + .expect("validated tool metadata can be serialized"); registered_tools.push(RegisteredTool { deployment_revision, + release_id: None, definition: metadata.definition.clone(), provision: metadata.provision.clone(), source: source.clone(), owner_account_id: component.account_id, owner_account_email: component.account_email.clone(), metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + metadata_digest, }); let mut agent_types = self.registered_agent_types.keys().collect::>(); @@ -269,9 +339,145 @@ impl DeploymentContext { &tool_name, environment_binding, agent_binding, - component, + None, + component.account_id, + &component.account_email, source.clone(), &metadata.definition.version, + TOOL_METADATA_WIT_VERSION, + metadata_digest, + warnings, + ) else { + continue; + }; + agent_tool_bindings.push(binding); + } + } + + for (deployment, resolved) in remote_tools { + let Some(resolved) = resolved else { + errors.push(DeployValidationError::RemoteToolUnavailable { + tool_name: deployment.name.clone(), + }); + continue; + }; + let release = &resolved.release; + let mut valid = true; + if deployment.name != release.name { + valid = false; + errors.push(DeployValidationError::RemoteToolNameMismatch { + tool_name: deployment.name.clone(), + release_name: release.name.clone(), + }); + } + if release.definition.name() != Some(release.name.as_str()) { + valid = false; + errors.push(DeployValidationError::RemoteToolDefinitionNameMismatch { + tool_name: deployment.name.clone(), + definition_name: release.definition.name().map(ToOwned::to_owned), + }); + } + if release.version != release.definition.version { + valid = false; + errors.push(DeployValidationError::RemoteToolVersionMismatch { + tool_name: deployment.name.clone(), + release_version: release.version.clone(), + definition_version: release.definition.version.clone(), + }); + } + if release.metadata_version != TOOL_METADATA_WIT_VERSION { + valid = false; + errors.push( + DeployValidationError::RemoteToolUnsupportedMetadataVersion { + tool_name: deployment.name.clone(), + metadata_version: release.metadata_version.clone(), + }, + ); + } + if !matches!( + golem_common::model::tool_release::tool_metadata_digest( + &release.metadata_version, + &release.definition, + ), + Ok(digest) if digest == release.metadata_digest + ) { + valid = false; + errors.push(DeployValidationError::RemoteToolMetadataDigestMismatch { + tool_name: deployment.name.clone(), + }); + } + if let Err(validation_errors) = validate_tool(&release.definition) { + valid = false; + errors.push(DeployValidationError::InvalidRemoteTool { + tool_name: deployment.name.clone(), + errors: validation_errors + .into_iter() + .map(|error| error.to_string()) + .collect(), + }); + } + + let environment_binding = deployment.environment_binding.as_ref().and_then(|binding| { + validate_tool_binding( + &deployment.name, + None, + binding, + &resolved.owner.email, + &release.version, + errors, + ) + }); + let mut valid_agent_bindings = BTreeMap::new(); + for (agent_type, binding) in &deployment.agent_bindings { + if !self.registered_agent_types.contains_key(agent_type) { + errors.push(DeployValidationError::RemoteToolBindingUnknownAgent { + tool_name: deployment.name.clone(), + agent_type: agent_type.clone(), + }); + } + if let Some(binding) = validate_tool_binding( + &deployment.name, + Some(agent_type), + binding, + &resolved.owner.email, + &release.version, + errors, + ) { + valid_agent_bindings.insert(agent_type.clone(), binding); + } + } + + if !valid || colliding_tools.contains(&deployment.name) { + continue; + } + registered_tools.push(RegisteredTool { + deployment_revision, + release_id: Some(release.id), + definition: release.definition.clone(), + provision: deployment.provision.clone(), + source: release.source.clone(), + owner_account_id: release.owner_account_id, + owner_account_email: resolved.owner.email.clone(), + metadata_version: release.metadata_version.clone(), + metadata_digest: release.metadata_digest, + }); + + let mut agent_types = self.registered_agent_types.keys().collect::>(); + agent_types.sort(); + for agent_type in agent_types { + let Some(binding) = compile_tool_binding( + deployment_revision, + agent_type, + &deployment.name, + environment_binding, + valid_agent_bindings.get(agent_type).copied(), + Some(release.id), + release.owner_account_id, + &resolved.owner.email, + release.source.clone(), + &release.version, + &release.metadata_version, + release.metadata_digest, warnings, ) else { continue; @@ -297,7 +503,14 @@ impl DeploymentContext { BTreeMap, ) { let environment_binding = metadata.environment_binding.as_ref().and_then(|binding| { - validate_tool_binding(tool_name, None, binding, component, metadata, errors) + validate_tool_binding( + tool_name, + None, + binding, + &component.account_email, + &metadata.definition.version, + errors, + ) }); let mut agent_bindings = BTreeMap::new(); for (agent_type, binding) in &metadata.agent_bindings { @@ -312,8 +525,8 @@ impl DeploymentContext { tool_name, Some(agent_type), binding, - component, - metadata, + &component.account_email, + &metadata.definition.version, errors, ) { agent_bindings.insert(agent_type.clone(), binding); @@ -764,31 +977,31 @@ fn validate_tool_binding<'a>( tool_name: &ToolName, agent_type: Option<&AgentTypeName>, binding: &'a ToolBindingInput, - component: &Component, - metadata: &ToolDeploymentMetadata, + owner_account_email: &AccountEmail, + tool_version: &str, errors: &mut Vec, ) -> Option<&'a ToolBindingInput> { let mut valid = true; if let Some(version) = &binding.version - && version != &metadata.definition.version + && version != tool_version { valid = false; errors.push(DeployValidationError::ToolBindingVersionMismatch { tool_name: tool_name.clone(), agent_type: agent_type.cloned(), requested_version: version.clone(), - tool_version: metadata.definition.version.clone(), + tool_version: tool_version.to_string(), }); } if let Some(account) = &binding.account - && account != &component.account_email + && account != owner_account_email { valid = false; errors.push(DeployValidationError::ToolBindingAccountMismatch { tool_name: tool_name.clone(), agent_type: agent_type.cloned(), requested_account: account.to_string(), - owner_account: component.account_email.to_string(), + owner_account: owner_account_email.to_string(), }); } if !binding.parameters.0.is_object() { @@ -808,9 +1021,13 @@ fn compile_tool_binding( tool_name: &ToolName, environment: Option<&ToolBindingInput>, agent: Option<&ToolBindingInput>, - component: &Component, + release_id: Option, + owner_account_id: AccountId, + owner_account_email: &AccountEmail, source: ToolSource, version: &str, + metadata_version: &str, + metadata_digest: golem_common::model::diff::Hash, warnings: &mut Vec, ) -> Option { let (parameters, readable, requested_revealable) = match (environment, agent) { @@ -842,12 +1059,14 @@ fn compile_tool_binding( Some(CompiledToolBinding { deployment_revision, + release_id, agent_type_name: agent_type.clone(), tool_name: tool_name.clone(), version: version.to_string(), - metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), - account_id: component.account_id, - account_email: component.account_email.clone(), + metadata_version: metadata_version.to_string(), + metadata_digest, + account_id: owner_account_id, + account_email: owner_account_email.clone(), parameters, secret_keys_readable: readable, secret_keys_revealable: revealable, @@ -1071,7 +1290,7 @@ fn validate_final_http_api_router( mod tests { use super::*; use golem_common::model::Empty; - use golem_common::model::account::{AccountEmail, AccountId}; + use golem_common::model::account::{AccountEmail, AccountId, AccountSummary}; use golem_common::model::agent::{AgentMode, Snapshotting}; use golem_common::model::agent_secret::{AgentSecretId, AgentSecretPath, AgentSecretRevision}; use golem_common::model::application::{ApplicationId, ApplicationName}; @@ -1079,7 +1298,11 @@ mod tests { use golem_common::model::component_metadata::{ComponentMetadata, KnownExports}; use golem_common::model::environment::{EnvironmentId, EnvironmentName, EnvironmentRevision}; use golem_common::model::json::NormalizedJsonValue; - use golem_common::model::tool::{SecretKeyScope, ToolProvisionConfig}; + use golem_common::model::tool::{RemoteToolDeployment, SecretKeyScope, ToolProvisionConfig}; + use golem_common::model::tool_release::{ + ToolRelease, ToolReleaseById, ToolReleaseId, ToolReleaseLifecycle, ToolReleaseOrigin, + ToolReleaseReference, + }; use golem_common::schema::agent::{ AgentConfigDeclarationSchema, AgentConstructorSchema, InputSchema, }; @@ -1214,6 +1437,63 @@ mod tests { } } + fn test_remote_tool( + name: &str, + environment_binding: Option, + agent_bindings: BTreeMap, + ) -> (RemoteToolDeployment, Option) { + let name = ToolName::try_from(name).unwrap(); + let owner_account_id = AccountId::new(); + let owner_email = AccountEmail::new("publisher@example.com"); + let release_id = ToolReleaseId::new(); + let definition = test_tool(name.as_str()); + let release = ToolRelease { + id: release_id, + owner_account_id, + name: name.clone(), + version: definition.version.clone(), + source: ToolSource::Component { + component_id: ComponentId::new(), + component_revision: ComponentRevision::INITIAL, + component_name: ComponentName("publisher-tools".to_string()), + }, + definition: definition.clone(), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + metadata_digest: golem_common::model::tool_release::tool_metadata_digest( + TOOL_METADATA_WIT_VERSION, + &definition, + ) + .unwrap(), + lifecycle: ToolReleaseLifecycle::Published, + origin: ToolReleaseOrigin::Ordinary, + system_availability: None, + created_at: chrono::Utc::now(), + created_by: owner_account_id, + state_changed_at: chrono::Utc::now(), + state_changed_by: owner_account_id, + }; + ( + RemoteToolDeployment { + name, + release: ToolReleaseReference::ById(ToolReleaseById { release_id }), + provision: ToolProvisionConfig { + config: NormalizedJsonValue::new(json!({ "consumer": true })), + ..ToolProvisionConfig::default() + }, + environment_binding, + agent_bindings, + }, + Some(ResolvedGrantedToolRelease { + release, + owner: AccountSummary { + id: owner_account_id, + name: "Publisher".to_string(), + email: owner_email, + }, + }), + ) + } + fn test_registered_agent_type( agent_type_name: &str, ) -> (AgentTypeName, InProgressDeployedRegisteredAgentType) { @@ -1274,6 +1554,146 @@ mod tests { assert!(compiled.agent_tool_bindings.is_empty()); } + #[test] + fn compile_tools_registers_remote_source_with_consumer_provision_and_bindings() { + let (agent_a_name, agent_a) = test_registered_agent_type("AgentA"); + let (agent_b_name, agent_b) = test_registered_agent_type("AgentB"); + let remote = test_remote_tool( + "grep", + Some(ToolBindingInput { + parameters: NormalizedJsonValue::new(json!({ "scope": "environment" })), + ..ToolBindingInput::default() + }), + BTreeMap::from([( + agent_a_name.clone(), + ToolBindingInput { + parameters: NormalizedJsonValue::new(json!({ "scope": "agent" })), + ..ToolBindingInput::default() + }, + )]), + ); + let context = DeploymentContext { + environment: test_environment(), + components: BTreeMap::new(), + http_api_deployments: BTreeMap::new(), + mcp_deployments: BTreeMap::new(), + registered_agent_types: HashMap::from([ + (agent_a_name.clone(), agent_a), + (agent_b_name.clone(), agent_b), + ]), + }; + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + let compiled = context.compile_tools_with_remote( + golem_common::model::deployment::DeploymentRevision::INITIAL, + std::slice::from_ref(&remote), + &mut errors, + &mut warnings, + ); + + assert!(errors.is_empty()); + assert!(warnings.is_empty()); + assert!(context.components.is_empty()); + assert_eq!(compiled.registered_tools.len(), 1); + let registered = &compiled.registered_tools[0]; + assert_eq!( + registered.release_id, + Some(remote.1.as_ref().unwrap().release.id) + ); + assert_eq!(registered.source, remote.1.as_ref().unwrap().release.source); + assert_eq!(registered.provision, remote.0.provision); + assert_eq!( + registered.owner_account_email.as_str(), + "publisher@example.com" + ); + assert_eq!(compiled.agent_tool_bindings.len(), 2); + let bindings = compiled + .agent_tool_bindings + .iter() + .map(|binding| { + ( + binding.agent_type_name.clone(), + binding.parameters.0.clone(), + ) + }) + .collect::>(); + assert_eq!(bindings[&agent_a_name], json!({ "scope": "agent" })); + assert_eq!(bindings[&agent_b_name], json!({ "scope": "environment" })); + + let unbound = test_remote_tool("git", None, BTreeMap::new()); + let compiled = context.compile_tools_with_remote( + golem_common::model::deployment::DeploymentRevision::INITIAL, + &[unbound], + &mut Vec::new(), + &mut Vec::new(), + ); + assert_eq!(compiled.registered_tools.len(), 1); + assert!(compiled.agent_tool_bindings.is_empty()); + } + + #[test] + fn compile_tools_accumulates_remote_collisions_and_unavailable_references() { + let grep = ToolName::try_from("grep").unwrap(); + let local = test_tool_component( + "local-tools", + BTreeMap::from([( + grep.clone(), + ToolDeploymentMetadata { + definition: test_tool(grep.as_str()), + provision: ToolProvisionConfig::default(), + environment_binding: None, + agent_bindings: BTreeMap::new(), + }, + )]), + ); + let mut unavailable_a = test_remote_tool("missing-a", None, BTreeMap::new()); + unavailable_a.1 = None; + let mut unavailable_b = test_remote_tool("missing-b", None, BTreeMap::new()); + unavailable_b.1 = None; + let remote_tools = vec![ + test_remote_tool("grep", None, BTreeMap::new()), + test_remote_tool("git", None, BTreeMap::new()), + test_remote_tool("git", None, BTreeMap::new()), + unavailable_a, + unavailable_b, + ]; + let context = DeploymentContext { + environment: test_environment(), + components: BTreeMap::from([(local.component_name.clone(), local)]), + http_api_deployments: BTreeMap::new(), + mcp_deployments: BTreeMap::new(), + registered_agent_types: HashMap::new(), + }; + let mut errors = Vec::new(); + + let compiled = context.compile_tools_with_remote( + golem_common::model::deployment::DeploymentRevision::INITIAL, + &remote_tools, + &mut errors, + &mut Vec::new(), + ); + + assert!(compiled.registered_tools.is_empty()); + assert_eq!( + errors + .iter() + .filter(|error| matches!(error, DeployValidationError::ToolSourceCollision { .. })) + .count(), + 2 + ); + assert_eq!( + errors + .iter() + .filter(|error| matches!( + error, + DeployValidationError::RemoteToolUnavailable { .. } + )) + .count(), + 2 + ); + } + #[test] fn compile_tools_inherits_environment_tools_and_adds_agent_tools() { let grep = ToolName::try_from("grep").unwrap(); @@ -1691,9 +2111,13 @@ mod tests { &ToolName::try_from("grep").unwrap(), Some(&environment), Some(&agent), - &component, + None, + component.account_id, + &component.account_email, source, "1.0.0", + TOOL_METADATA_WIT_VERSION, + Default::default(), &mut warnings, ) .unwrap(); diff --git a/golem-registry-service/src/services/deployment/write.rs b/golem-registry-service/src/services/deployment/write.rs index 6c7e378f86..fa13950fcd 100644 --- a/golem-registry-service/src/services/deployment/write.rs +++ b/golem-registry-service/src/services/deployment/write.rs @@ -21,6 +21,9 @@ use crate::services::agent_secret::{AgentSecretError, AgentSecretService}; use crate::services::component::{ComponentError, ComponentService}; use crate::services::deployment::deploy_validation_error::format_validation_errors; use crate::services::environment::{EnvironmentError, EnvironmentService}; +use crate::services::environment_tool_grant::{ + EnvironmentToolGrantError, EnvironmentToolGrantService, +}; use crate::services::http_api_deployment::{HttpApiDeploymentError, HttpApiDeploymentService}; use crate::services::mcp_deployment::{McpDeploymentError, McpDeploymentService}; use crate::services::registry_change_notifier::{ @@ -29,8 +32,9 @@ use crate::services::registry_change_notifier::{ use crate::services::resource_definition::{ResourceDefinitionError, ResourceDefinitionService}; use crate::services::retry_policy::{RetryPolicyError, RetryPolicyService}; use crate::services::security_scheme::SecuritySchemeService; +use crate::services::tool_release::{ToolReleaseError, ToolReleaseService}; use futures::TryFutureExt; -use golem_common::model::agent::DeployedRegisteredAgentType; +use golem_common::model::agent::{DeployedRegisteredAgentType, InitialAgentFileUpload}; use golem_common::model::card::EnvironmentVerb; use golem_common::model::deployment::{CurrentDeployment, DeploymentRevision, DeploymentRollback}; use golem_common::model::diff; @@ -42,7 +46,9 @@ use golem_common::model::{ }; use golem_common::{SafeDisplay, error_forwarding}; use golem_service_base::model::auth::{AuthCtx, AuthorizationError}; +use golem_service_base::replayable_stream::ReplayableStream; use golem_service_base::repo::RepoError; +use golem_service_base::service::initial_agent_files::InitialAgentFilesService; use std::collections::HashMap; use std::sync::Arc; @@ -102,7 +108,9 @@ error_forwarding!( McpDeploymentError, AgentSecretError, ResourceDefinitionError, - RetryPolicyError + RetryPolicyError, + EnvironmentToolGrantError, + ToolReleaseError ); pub struct DeploymentWriteService { @@ -116,6 +124,9 @@ pub struct DeploymentWriteService { security_scheme_service: Arc, resource_definition_service: Arc, retry_policy_service: Arc, + environment_tool_grant_service: Arc, + tool_release_service: Arc, + initial_agent_files_service: Arc, } impl DeploymentWriteService { @@ -130,6 +141,9 @@ impl DeploymentWriteService { security_scheme_service: Arc, resource_definition_service: Arc, retry_policy_service: Arc, + environment_tool_grant_service: Arc, + tool_release_service: Arc, + initial_agent_files_service: Arc, ) -> DeploymentWriteService { Self { environment_service, @@ -142,9 +156,38 @@ impl DeploymentWriteService { security_scheme_service, resource_definition_service, retry_policy_service, + environment_tool_grant_service, + tool_release_service, + initial_agent_files_service, } } + pub async fn upload_initial_agent_file( + &self, + environment_id: EnvironmentId, + data: Vec, + auth: &AuthCtx, + ) -> Result { + let environment = self + .environment_service + .get(environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(environment_id) => { + DeploymentWriteError::ParentEnvironmentNotFound(environment_id) + } + other => other.into(), + })?; + + store_initial_agent_file( + self.initial_agent_files_service.as_ref(), + &environment, + data, + auth, + ) + .await + } + pub async fn create_deployment( &self, environment_id: EnvironmentId, @@ -234,6 +277,21 @@ impl DeploymentWriteService { ); let account_id = environment.owner_account_id; + let remote_tool_references = data + .remote_tools + .iter() + .map(|deployment| deployment.release.clone()) + .collect::>(); + let resolved_remote_tools = self + .environment_tool_grant_service + .resolve_active_references_partial(&environment, &remote_tool_references, auth) + .await?; + let remote_tools = data + .remote_tools + .iter() + .cloned() + .zip(resolved_remote_tools) + .collect::>(); let deployment_context = DeploymentContext::new( environment, components, @@ -241,16 +299,6 @@ impl DeploymentWriteService { mcp_deployments, )?; - { - let actual_hash = deployment_context.hash().map_err(anyhow::Error::new)?; - if data.expected_deployment_hash != actual_hash { - return Err(DeploymentWriteError::DeploymentHashMismatch { - requested_hash: data.expected_deployment_hash, - actual_hash, - }); - } - } - let mut errors = Vec::new(); let mut warnings: Vec = Vec::new(); @@ -293,8 +341,56 @@ impl DeploymentWriteService { &mut errors, ); - let compiled_tools = - deployment_context.compile_tools(next_deployment_revision, &mut errors, &mut warnings); + let mut compiled_tools = deployment_context.compile_tools_with_remote( + next_deployment_revision, + &remote_tools, + &mut errors, + &mut warnings, + ); + + let registered_tools_by_name = compiled_tools + .registered_tools + .iter() + .filter_map(|tool| { + tool.definition + .name() + .and_then(|name| golem_common::model::tool::ToolName::try_from(name).ok()) + .map(|name| (name, tool.clone())) + }) + .collect(); + let mut tool_releases = self.tool_release_service.prepare_publications( + &deployment_context.environment, + ®istered_tools_by_name, + &data.publish_tools, + auth, + )?; + let publications_need_change = self + .tool_release_service + .publications_need_change(&mut tool_releases) + .await?; + let published_release_ids = tool_releases + .iter() + .map(|release| { + ( + release.tool_name.as_str(), + golem_common::model::tool_release::ToolReleaseId(release.tool_release_id), + ) + }) + .collect::>(); + for tool in &mut compiled_tools.registered_tools { + if let Some(release_id) = tool + .definition + .name() + .and_then(|name| published_release_ids.get(name)) + { + tool.release_id = Some(*release_id); + } + } + for binding in &mut compiled_tools.agent_tool_bindings { + if let Some(release_id) = published_release_ids.get(binding.tool_name.as_str()) { + binding.release_id = Some(*release_id); + } + } let (new_agent_secrets, updated_agent_secrets, replaced_agent_secrets) = deployment_context .deployment_agent_secret_creations_and_updates( @@ -321,12 +417,23 @@ impl DeploymentWriteService { return Err(DeploymentWriteError::DeploymentValidationFailed(errors)); } + let actual_hash = deployment_context + .hash_with_tools(&compiled_tools, &data.publish_tools) + .map_err(anyhow::Error::new)?; + if data.expected_deployment_hash != actual_hash { + return Err(DeploymentWriteError::DeploymentHashMismatch { + requested_hash: data.expected_deployment_hash, + actual_hash, + }); + } + if deployment_hash_unchanged && new_agent_secrets.is_empty() && updated_agent_secrets.is_empty() && replaced_agent_secrets.is_empty() && new_resource_definitions.is_empty() && new_retry_policies.is_empty() + && !publications_need_change { return Err(DeploymentWriteError::NoOpDeployment); } @@ -351,6 +458,7 @@ impl DeploymentWriteService { .collect(), compiled_tools.registered_tools, compiled_tools.agent_tool_bindings, + tool_releases, new_agent_secrets, updated_agent_secrets, replaced_agent_secrets, @@ -474,3 +582,140 @@ impl DeploymentWriteService { Ok(deployment) } } + +async fn store_initial_agent_file( + initial_agent_files_service: &InitialAgentFilesService, + environment: &Environment, + data: Vec, + auth: &AuthCtx, +) -> Result { + authorize_environment_permission(auth, environment, EnvironmentVerb::Deploy)?; + + let size = data.len() as u64; + let stream = data + .map_item(|item| item.map_err(anyhow::Error::from)) + .map_error(anyhow::Error::from); + let content_hash = initial_agent_files_service + .put_if_not_exists(environment.id, stream) + .await?; + + Ok(InitialAgentFileUpload { content_hash, size }) +} + +#[cfg(test)] +mod initial_agent_file_tests { + use super::*; + use futures::TryStreamExt; + use golem_common::model::account::{AccountEmail, AccountId}; + use golem_common::model::application::{ApplicationId, ApplicationName}; + use golem_common::model::card::owner::EnvironmentOwnerPattern; + use golem_common::model::card::{ + ClassPermissionTarget, EffectiveSurface, EnvironmentResourcePattern, GrantSurface, + PermissionTarget, + }; + use golem_common::model::environment::{EnvironmentName, EnvironmentRevision}; + use golem_service_base::storage::blob::memory::InMemoryBlobStorage; + use test_r::test; + + fn environment(id: EnvironmentId, name: &str) -> Environment { + Environment { + id, + revision: EnvironmentRevision::INITIAL, + application_id: ApplicationId::new(), + application_name: ApplicationName::try_from("app").unwrap(), + name: EnvironmentName::try_from(name).unwrap(), + diff_model_version: 0, + compatibility_check: false, + version_check: false, + security_overrides: false, + owner_account_id: AccountId::new(), + owner_account_email: AccountEmail::new("owner@example.com"), + current_deployment: None, + } + } + + fn auth_for(environment: &Environment, verb: EnvironmentVerb) -> AuthCtx { + AuthCtx::agent_with_effective_surface( + environment.owner_account_id, + environment.owner_account_email.clone(), + EffectiveSurface { + source_card_ids: Vec::new(), + lower: vec![GrantSurface { + positive: vec![PermissionTarget::Environment(ClassPermissionTarget { + verb: Some(verb), + owner: EnvironmentOwnerPattern::Environment { + account: environment.owner_account_email.clone(), + application: environment.application_name.clone(), + environment: environment.name.clone(), + }, + resource: EnvironmentResourcePattern::Any, + })], + negative: Vec::new(), + }], + upper: Vec::new(), + }, + ) + } + + #[test] + async fn upload_requires_deploy_permission_and_stores_by_environment() { + let files = InitialAgentFilesService::new(Arc::new(InMemoryBlobStorage::new())); + let allowed = environment(EnvironmentId::new(), "allowed"); + let other = environment(EnvironmentId::new(), "other"); + let content = b"remote tool bridge".to_vec(); + + assert!( + store_initial_agent_file( + &files, + &allowed, + content.clone(), + &auth_for(&allowed, EnvironmentVerb::View), + ) + .await + .is_err() + ); + assert!( + store_initial_agent_file( + &files, + &other, + content.clone(), + &auth_for(&allowed, EnvironmentVerb::Deploy), + ) + .await + .is_err() + ); + + let uploaded = store_initial_agent_file( + &files, + &allowed, + content.clone(), + &auth_for(&allowed, EnvironmentVerb::Deploy), + ) + .await + .unwrap(); + + assert_eq!(uploaded.size, content.len() as u64); + assert_eq!( + uploaded.content_hash, + golem_common::model::agent::AgentFileContentHash(diff::Hash::new(blake3::hash( + &content + ))) + ); + assert!( + files + .exists(allowed.id, uploaded.content_hash) + .await + .unwrap() + ); + assert!(!files.exists(other.id, uploaded.content_hash).await.unwrap()); + let stored = files + .get(allowed.id, uploaded.content_hash) + .await + .unwrap() + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(stored.concat(), content); + } +} diff --git a/golem-registry-service/src/services/environment_tool_grant.rs b/golem-registry-service/src/services/environment_tool_grant.rs new file mode 100644 index 0000000000..3a7b37088a --- /dev/null +++ b/golem-registry-service/src/services/environment_tool_grant.rs @@ -0,0 +1,605 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::environment::{EnvironmentError, EnvironmentService}; +use super::tool_release::{ToolReleaseError, ToolReleaseService}; +use crate::repo::environment_tool_grant::{ + EnvironmentToolGrantRepo, EnvironmentToolGrantRepoError, +}; +use crate::repo::model::environment_tool_grant::{ + EnvironmentToolGrantRecord, EnvironmentToolGrantWithDetailsRecord, +}; +use golem_common::model::account::{AccountId, AccountSummary}; +use golem_common::model::card::owner::EnvironmentOwnerPattern; +use golem_common::model::card::{ + ClassPermissionTarget, EnvironmentToolGrantResourcePattern, EnvironmentToolGrantVerb, + PermissionTarget, +}; +use golem_common::model::environment::{Environment, EnvironmentId}; +use golem_common::model::environment_tool_grant::{ + EnvironmentToolGrantCreation, EnvironmentToolGrantId, EnvironmentToolGrantReconciliation, + EnvironmentToolGrantWithDetails, +}; +use golem_common::model::tool::ToolName; +use golem_common::model::tool_release::{ToolRelease, ToolReleaseId, ToolReleaseReference}; +use golem_common::{SafeDisplay, error_forwarding}; +use golem_service_base::model::auth::{AuthCtx, AuthorizationError}; +use std::collections::HashMap; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct ResolvedGrantedToolRelease { + pub release: ToolRelease, + pub owner: AccountSummary, +} + +#[derive(Debug, thiserror::Error)] +pub enum EnvironmentToolGrantError { + #[error("Parent environment {0} not found")] + ParentEnvironmentNotFound(EnvironmentId), + #[error("Environment tool grant {0} not found")] + EnvironmentToolGrantNotFound(EnvironmentToolGrantId), + #[error("Referenced tool release not found")] + ReferencedToolReleaseNotFound, + #[error("Grant for this tool release already exists in this environment")] + GrantAlreadyExists, + #[error("Protected system tool grant {0} cannot be modified")] + ProtectedToolGrant(EnvironmentToolGrantId), + #[error("Administrator-managed tool grant {0} cannot be deleted automatically")] + AdministratorManagedToolGrant(EnvironmentToolGrantId), + #[error("Environment tool grant {0} is not deleted")] + GrantNotDeleted(EnvironmentToolGrantId), + #[error(transparent)] + Unauthorized(#[from] AuthorizationError), + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +impl SafeDisplay for EnvironmentToolGrantError { + fn to_safe_string(&self) -> String { + match self { + Self::InternalError(_) => "Internal error".to_string(), + Self::Unauthorized(inner) => inner.to_safe_string(), + other => other.to_string(), + } + } +} + +error_forwarding!( + EnvironmentToolGrantError, + EnvironmentError, + EnvironmentToolGrantRepoError, + ToolReleaseError +); + +pub struct EnvironmentToolGrantService { + environment_tool_grant_repo: Arc, + environment_service: Arc, + tool_release_service: Arc, +} + +impl EnvironmentToolGrantService { + pub fn new( + environment_tool_grant_repo: Arc, + environment_service: Arc, + tool_release_service: Arc, + ) -> Self { + Self { + environment_tool_grant_repo, + environment_service, + tool_release_service, + } + } + + pub async fn create( + &self, + environment_id: EnvironmentId, + creation: EnvironmentToolGrantCreation, + auth: &AuthCtx, + ) -> Result { + self.create_with_provenance(environment_id, creation, false, auth) + .await + } + + pub async fn create_automatic( + &self, + environment_id: EnvironmentId, + creation: EnvironmentToolGrantCreation, + auth: &AuthCtx, + ) -> Result { + self.create_with_provenance(environment_id, creation, true, auth) + .await + } + + pub async fn validate_reconciliation( + &self, + environment_id: EnvironmentId, + reconciliation: EnvironmentToolGrantReconciliation, + auth: &AuthCtx, + ) -> Result<(), EnvironmentToolGrantError> { + let environment = self.get_environment(environment_id, auth).await?; + for creation in reconciliation.creations { + let release = self + .tool_release_service + .resolve_user_grantable_reference(&creation.release) + .await + .map_err(|err| match err { + ToolReleaseError::ReferencedToolReleaseNotFound + | ToolReleaseError::ToolReleaseNotFound(_) => { + EnvironmentToolGrantError::ReferencedToolReleaseNotFound + } + other => other.into(), + })?; + let name = ToolName::try_from(release.release.tool_name).map_err(anyhow::Error::msg)?; + authorize_environment_tool_grant_permission( + auth, + &environment, + EnvironmentToolGrantVerb::Create, + name, + )?; + } + for grant_id in reconciliation.deletions { + let (record, grant_environment) = self + .authorize(grant_id, false, EnvironmentToolGrantVerb::Delete, auth) + .await?; + if grant_environment.id != environment_id || !record.automatic { + return Err(EnvironmentToolGrantError::EnvironmentToolGrantNotFound( + grant_id, + )); + } + if record.protected { + return Err(EnvironmentToolGrantError::ProtectedToolGrant(grant_id)); + } + } + Ok(()) + } + + async fn create_with_provenance( + &self, + environment_id: EnvironmentId, + creation: EnvironmentToolGrantCreation, + automatic: bool, + auth: &AuthCtx, + ) -> Result { + let environment = self.get_environment(environment_id, auth).await?; + let release = self + .tool_release_service + .resolve_user_grantable_reference(&creation.release) + .await + .map_err(|err| match err { + ToolReleaseError::ReferencedToolReleaseNotFound + | ToolReleaseError::ToolReleaseNotFound(_) => { + EnvironmentToolGrantError::ReferencedToolReleaseNotFound + } + other => other.into(), + })?; + let name = + ToolName::try_from(release.release.tool_name.clone()).map_err(anyhow::Error::msg)?; + authorize_environment_tool_grant_permission( + auth, + &environment, + EnvironmentToolGrantVerb::Create, + name, + ) + .map_err(|_| EnvironmentToolGrantError::ReferencedToolReleaseNotFound)?; + + let release_id = ToolReleaseId(release.release.tool_release_id); + match self + .environment_tool_grant_repo + .create(EnvironmentToolGrantRecord::creation( + environment_id, + release_id, + false, + automatic, + auth.actor_account_id(), + )) + .await + { + Ok(record) => record.try_into().map_err(Into::into), + Err(EnvironmentToolGrantRepoError::GrantAlreadyExists) => { + let existing = self + .environment_tool_grant_repo + .get_by_environment_and_release(environment_id.0, release_id.0, true) + .await? + .ok_or(EnvironmentToolGrantError::GrantAlreadyExists)?; + if existing.grant_deleted_at.is_none() { + if existing.protected || existing.automatic == automatic { + existing.try_into().map_err(Into::into) + } else { + self.environment_tool_grant_repo + .set_automatic( + existing.environment_tool_grant_id, + auth.actor_account_id().0, + automatic, + ) + .await? + .ok_or(EnvironmentToolGrantError::GrantAlreadyExists)? + .try_into() + .map_err(Into::into) + } + } else { + self.environment_tool_grant_repo + .restore( + existing.environment_tool_grant_id, + auth.actor_account_id().0, + automatic, + ) + .await? + .ok_or(EnvironmentToolGrantError::ReferencedToolReleaseNotFound)? + .try_into() + .map_err(Into::into) + } + } + Err(other) => Err(other.into()), + } + } + + pub async fn list_in_environment( + &self, + environment_id: EnvironmentId, + auth: &AuthCtx, + ) -> Result, EnvironmentToolGrantError> { + let environment = self.get_environment(environment_id, auth).await?; + let mut result = Vec::new(); + for record in self + .environment_tool_grant_repo + .list_by_environment(environment_id.0) + .await? + { + let name = ToolName::try_from(record.release.release.tool_name.clone()) + .map_err(anyhow::Error::msg)?; + if authorize_environment_tool_grant_permission( + auth, + &environment, + EnvironmentToolGrantVerb::View, + name, + ) + .is_ok() + { + result.push(record.try_into()?); + } + } + Ok(result) + } + + pub async fn get( + &self, + grant_id: EnvironmentToolGrantId, + auth: &AuthCtx, + ) -> Result { + let (record, _) = self + .authorize(grant_id, false, EnvironmentToolGrantVerb::View, auth) + .await?; + record.try_into().map_err(Into::into) + } + + pub async fn delete( + &self, + grant_id: EnvironmentToolGrantId, + auth: &AuthCtx, + ) -> Result<(), EnvironmentToolGrantError> { + self.delete_with_provenance(grant_id, false, auth).await + } + + pub async fn delete_automatic( + &self, + grant_id: EnvironmentToolGrantId, + auth: &AuthCtx, + ) -> Result<(), EnvironmentToolGrantError> { + self.delete_with_provenance(grant_id, true, auth).await + } + + async fn delete_with_provenance( + &self, + grant_id: EnvironmentToolGrantId, + automatic_only: bool, + auth: &AuthCtx, + ) -> Result<(), EnvironmentToolGrantError> { + let (record, _) = self + .authorize(grant_id, false, EnvironmentToolGrantVerb::Delete, auth) + .await?; + if record.protected { + return Err(EnvironmentToolGrantError::ProtectedToolGrant(grant_id)); + } + if automatic_only && !record.automatic { + return Err(EnvironmentToolGrantError::AdministratorManagedToolGrant( + grant_id, + )); + } + if !self + .environment_tool_grant_repo + .delete(grant_id.0, auth.actor_account_id().0, automatic_only) + .await? + { + return Err(EnvironmentToolGrantError::EnvironmentToolGrantNotFound( + grant_id, + )); + } + Ok(()) + } + + pub async fn restore( + &self, + grant_id: EnvironmentToolGrantId, + auth: &AuthCtx, + ) -> Result { + let (record, _) = self + .authorize(grant_id, true, EnvironmentToolGrantVerb::Restore, auth) + .await?; + if record.protected { + return Err(EnvironmentToolGrantError::ProtectedToolGrant(grant_id)); + } + if record.grant_deleted_at.is_none() { + return Err(EnvironmentToolGrantError::GrantNotDeleted(grant_id)); + } + self.environment_tool_grant_repo + .restore(grant_id.0, auth.actor_account_id().0, false) + .await? + .ok_or(EnvironmentToolGrantError::ReferencedToolReleaseNotFound)? + .try_into() + .map_err(Into::into) + } + + pub async fn resolve_active_references( + &self, + environment: &Environment, + references: &[ToolReleaseReference], + auth: &AuthCtx, + ) -> Result, EnvironmentToolGrantError> { + let resolved = self + .resolve_active_references_partial(environment, references, auth) + .await?; + if resolved.iter().any(Option::is_none) { + return Err(EnvironmentToolGrantError::ReferencedToolReleaseNotFound); + } + Ok(resolved + .into_iter() + .flatten() + .map(|resolved| (resolved.release.id, resolved.release)) + .collect()) + } + + pub async fn resolve_active_references_partial( + &self, + environment: &Environment, + references: &[ToolReleaseReference], + auth: &AuthCtx, + ) -> Result>, EnvironmentToolGrantError> { + let mut resolved_ids = Vec::with_capacity(references.len()); + for reference in references { + match self + .tool_release_service + .resolve_published_reference(reference) + .await + { + Ok(release) => resolved_ids.push(Some(release.release.tool_release_id)), + Err( + ToolReleaseError::ReferencedToolReleaseNotFound + | ToolReleaseError::ToolReleaseNotFound(_) + | ToolReleaseError::ParentAccountNotFound(_), + ) => resolved_ids.push(None), + Err(other) => return Err(other.into()), + } + } + let ids = resolved_ids.iter().flatten().copied().collect::>(); + let records = self + .environment_tool_grant_repo + .get_active_by_release_ids(environment.id.0, &ids) + .await?; + let mut by_id = HashMap::with_capacity(records.len()); + for record in records { + let name = ToolName::try_from(record.release.release.tool_name.clone()) + .map_err(anyhow::Error::msg)?; + if authorize_environment_tool_grant_permission( + auth, + environment, + EnvironmentToolGrantVerb::View, + name, + ) + .is_err() + { + continue; + } + let owner = record.release.owner(); + let release: ToolRelease = record.release.release.try_into()?; + by_id.insert(release.id.0, ResolvedGrantedToolRelease { release, owner }); + } + Ok(resolved_ids + .into_iter() + .map(|id| id.and_then(|id| by_id.get(&id).cloned())) + .collect()) + } + + pub async fn provision_protected( + &self, + environment_id: EnvironmentId, + release_id: ToolReleaseId, + ) -> Result { + self.tool_release_service + .resolve_auto_grantable_system_release(release_id) + .await + .map_err(|_| EnvironmentToolGrantError::ReferencedToolReleaseNotFound)?; + let record = EnvironmentToolGrantRecord::creation( + environment_id, + release_id, + true, + true, + AccountId::SYSTEM, + ); + match self.environment_tool_grant_repo.create(record).await { + Ok(record) => record.try_into().map_err(Into::into), + Err(EnvironmentToolGrantRepoError::GrantAlreadyExists) => { + let existing = self + .environment_tool_grant_repo + .get_active_by_release_ids(environment_id.0, &[release_id.0]) + .await? + .into_iter() + .next() + .ok_or(EnvironmentToolGrantError::GrantAlreadyExists)?; + if !existing.protected { + return Err(EnvironmentToolGrantError::GrantAlreadyExists); + } + existing.try_into().map_err(Into::into) + } + Err(other) => Err(other.into()), + } + } + + async fn authorize( + &self, + grant_id: EnvironmentToolGrantId, + include_deleted: bool, + verb: EnvironmentToolGrantVerb, + auth: &AuthCtx, + ) -> Result<(EnvironmentToolGrantWithDetailsRecord, Environment), EnvironmentToolGrantError> + { + let record = self + .environment_tool_grant_repo + .get_by_id(grant_id.0, include_deleted) + .await? + .ok_or(EnvironmentToolGrantError::EnvironmentToolGrantNotFound( + grant_id, + ))?; + let environment = self + .get_environment(EnvironmentId(record.environment_id), auth) + .await + .map_err(|_| EnvironmentToolGrantError::EnvironmentToolGrantNotFound(grant_id))?; + let name = ToolName::try_from(record.release.release.tool_name.clone()) + .map_err(anyhow::Error::msg)?; + authorize_environment_tool_grant_permission( + auth, + &environment, + EnvironmentToolGrantVerb::View, + name.clone(), + ) + .map_err(|_| EnvironmentToolGrantError::EnvironmentToolGrantNotFound(grant_id))?; + if verb != EnvironmentToolGrantVerb::View { + authorize_environment_tool_grant_permission(auth, &environment, verb, name)?; + } + Ok((record, environment)) + } + + async fn get_environment( + &self, + environment_id: EnvironmentId, + auth: &AuthCtx, + ) -> Result { + self.environment_service + .get(environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(id) => { + EnvironmentToolGrantError::ParentEnvironmentNotFound(id) + } + other => other.into(), + }) + } +} + +fn authorize_environment_tool_grant_permission( + auth: &AuthCtx, + environment: &Environment, + verb: EnvironmentToolGrantVerb, + name: ToolName, +) -> Result<(), AuthorizationError> { + auth.authorize_permission(&PermissionTarget::EnvironmentToolGrant( + ClassPermissionTarget { + verb: Some(verb), + owner: environment_owner(environment), + resource: EnvironmentToolGrantResourcePattern::Name(name), + }, + )) +} + +fn environment_owner(environment: &Environment) -> EnvironmentOwnerPattern { + EnvironmentOwnerPattern::Environment { + account: environment.owner_account_email.clone(), + application: environment.application_name.clone(), + environment: environment.name.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use golem_common::model::application::{ApplicationId, ApplicationName}; + use golem_common::model::card::{EffectiveSurface, GrantSurface}; + use golem_common::model::environment::{EnvironmentName, EnvironmentRevision}; + use test_r::test; + + fn test_environment() -> Environment { + Environment { + id: EnvironmentId::new(), + revision: EnvironmentRevision::INITIAL, + application_id: ApplicationId::new(), + application_name: ApplicationName::try_from("app").unwrap(), + name: EnvironmentName::try_from("dev").unwrap(), + diff_model_version: 0, + compatibility_check: false, + version_check: false, + security_overrides: false, + owner_account_id: AccountId::new(), + owner_account_email: golem_common::model::account::AccountEmail::new( + "owner@example.com", + ), + current_deployment: None, + } + } + + fn view_permission(environment: &Environment, name: ToolName) -> PermissionTarget { + PermissionTarget::EnvironmentToolGrant(ClassPermissionTarget { + verb: Some(EnvironmentToolGrantVerb::View), + owner: environment_owner(environment), + resource: EnvironmentToolGrantResourcePattern::Name(name), + }) + } + + #[test] + fn name_scoped_view_permission_authorizes_only_that_granted_tool() { + let environment = test_environment(); + let permitted = ToolName::try_from("search").unwrap(); + let denied = ToolName::try_from("payments").unwrap(); + let auth = AuthCtx::agent_with_effective_surface( + environment.owner_account_id, + environment.owner_account_email.clone(), + EffectiveSurface { + source_card_ids: Vec::new(), + lower: vec![GrantSurface { + positive: vec![view_permission(&environment, permitted.clone())], + negative: Vec::new(), + }], + upper: Vec::new(), + }, + ); + + assert!( + authorize_environment_tool_grant_permission( + &auth, + &environment, + EnvironmentToolGrantVerb::View, + permitted, + ) + .is_ok() + ); + assert!( + authorize_environment_tool_grant_permission( + &auth, + &environment, + EnvironmentToolGrantVerb::View, + denied, + ) + .is_err() + ); + } +} diff --git a/golem-registry-service/src/services/mod.rs b/golem-registry-service/src/services/mod.rs index 434bf238e9..1eba8b4897 100644 --- a/golem-registry-service/src/services/mod.rs +++ b/golem-registry-service/src/services/mod.rs @@ -29,6 +29,7 @@ pub mod domain_registration; pub mod environment; pub mod environment_plugin_grant; pub mod environment_state; +pub mod environment_tool_grant; pub mod http_api_deployment; pub mod mcp_deployment; pub mod oauth2; @@ -42,6 +43,7 @@ pub mod resource_definition; pub mod retry_policy; pub mod security_scheme; pub mod token; +pub mod tool_release; /// Run CPU-heavy work on the global Rayon pool, returning a Future pub async fn run_cpu_bound_work(f: F) -> R diff --git a/golem-registry-service/src/services/tool_release.rs b/golem-registry-service/src/services/tool_release.rs new file mode 100644 index 0000000000..c3666fbc2b --- /dev/null +++ b/golem-registry-service/src/services/tool_release.rs @@ -0,0 +1,425 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::account::{AccountError, AccountService}; +use crate::repo::model::tool_release::{ + TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM, ToolReleaseRecord, ToolReleaseWithOwnerRecord, +}; +use crate::repo::tool_release::{ToolReleaseRepo, ToolReleaseRepoError}; +use golem_common::model::account::{AccountEmail, AccountId}; +use golem_common::model::card::owner::AccountOwnerPattern; +use golem_common::model::card::{ + AccountToolReleaseResourcePattern, AccountToolReleaseVerb, ClassPermissionTarget, + PermissionTarget, +}; +use golem_common::model::environment::Environment; +use golem_common::model::tool::{RegisteredTool, ToolName, ToolSource}; +use golem_common::model::tool_release::{ + SystemToolAvailability, SystemToolReleaseProvision, ToolRelease, ToolReleaseId, + ToolReleaseLifecycle, ToolReleaseOrigin, ToolReleaseReference, +}; +use golem_common::{SafeDisplay, error_forwarding}; +use golem_service_base::model::auth::{AuthCtx, AuthorizationError}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum ToolReleaseError { + #[error("Tool release {0} not found")] + ToolReleaseNotFound(ToolReleaseId), + #[error("Tool release not found")] + ReferencedToolReleaseNotFound, + #[error("Parent account {0} not found")] + ParentAccountNotFound(AccountId), + #[error("Tool {0} selected for publication is not implemented by this deployment")] + PublicationToolNotFound(ToolName), + #[error("Tool {0} was selected for publication more than once")] + DuplicatePublication(ToolName), + #[error("Tool {0} is not owned by the publishing environment's account")] + PublicationOwnerMismatch(ToolName), + #[error("Tool {0} cannot be published from a host source")] + PublicationHostSource(ToolName), + #[error("Tool release coordinate already exists with different immutable metadata")] + ImmutableReleaseConflict, + #[error("Protected system tool releases cannot be modified")] + ProtectedToolRelease, + #[error(transparent)] + Unauthorized(#[from] AuthorizationError), + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +impl SafeDisplay for ToolReleaseError { + fn to_safe_string(&self) -> String { + match self { + Self::InternalError(_) => "Internal error".to_string(), + Self::Unauthorized(inner) => inner.to_safe_string(), + other => other.to_string(), + } + } +} + +error_forwarding!(ToolReleaseError, AccountError, ToolReleaseRepoError); + +pub struct ToolReleaseService { + tool_release_repo: Arc, + account_service: Arc, + builtin_tool_owner_account_id: AccountId, +} + +impl ToolReleaseService { + pub fn new( + tool_release_repo: Arc, + account_service: Arc, + builtin_tool_owner_account_id: AccountId, + ) -> Self { + Self { + tool_release_repo, + account_service, + builtin_tool_owner_account_id, + } + } + + pub fn prepare_publications( + &self, + environment: &Environment, + registered_tools: &BTreeMap, + publish_tools: &[ToolName], + auth: &AuthCtx, + ) -> Result, ToolReleaseError> { + let mut seen = BTreeSet::new(); + let mut records = Vec::with_capacity(publish_tools.len()); + + for name in publish_tools { + if !seen.insert(name.clone()) { + return Err(ToolReleaseError::DuplicatePublication(name.clone())); + } + let tool = registered_tools + .get(name) + .ok_or_else(|| ToolReleaseError::PublicationToolNotFound(name.clone()))?; + authorize_account_tool_release_permission( + auth, + &environment.owner_account_email, + AccountToolReleaseVerb::Publish, + name.clone(), + )?; + if tool.owner_account_id != environment.owner_account_id { + return Err(ToolReleaseError::PublicationOwnerMismatch(name.clone())); + } + if !matches!(tool.source, ToolSource::Component { .. }) { + return Err(ToolReleaseError::PublicationHostSource(name.clone())); + } + records.push(ToolReleaseRecord::from_registered_tool( + tool, + auth.actor_account_id(), + )?); + } + + Ok(records) + } + + pub async fn publications_need_change( + &self, + candidates: &mut [ToolReleaseRecord], + ) -> Result { + let mut changed = false; + for candidate in candidates { + match self + .tool_release_repo + .get_by_coordinates( + candidate.owner_account_id, + &candidate.tool_name, + &candidate.tool_version, + ) + .await? + { + None => changed = true, + Some(existing) => { + if !existing.release.immutable_fields_match(candidate) { + return Err(ToolReleaseError::ImmutableReleaseConflict); + } + candidate.tool_release_id = existing.release.tool_release_id; + let release: ToolRelease = existing.release.try_into()?; + if release.lifecycle == ToolReleaseLifecycle::DePublished { + changed = true; + } + } + } + } + Ok(changed) + } + + pub async fn get( + &self, + release_id: ToolReleaseId, + auth: &AuthCtx, + ) -> Result { + let record = self.get_record(release_id).await?; + authorize_account_tool_release_permission( + auth, + &AccountEmail::new(&record.owner_account_email), + AccountToolReleaseVerb::View, + ToolName::try_from(record.release.tool_name.clone()).map_err(anyhow::Error::msg)?, + ) + .map_err(|_| ToolReleaseError::ToolReleaseNotFound(release_id))?; + record.release.try_into().map_err(Into::into) + } + + pub async fn list_in_account( + &self, + account_id: AccountId, + auth: &AuthCtx, + ) -> Result, ToolReleaseError> { + let account = + self.account_service + .get(account_id, auth) + .await + .map_err(|err| match err { + AccountError::AccountNotFound(id) => { + ToolReleaseError::ParentAccountNotFound(id) + } + other => other.into(), + })?; + + let mut releases = Vec::new(); + for record in self.tool_release_repo.list_by_owner(account_id.0).await? { + let name = + ToolName::try_from(record.release.tool_name.clone()).map_err(anyhow::Error::msg)?; + if authorize_account_tool_release_permission( + auth, + &account.email, + AccountToolReleaseVerb::View, + name, + ) + .is_ok() + { + releases.push(record.release.try_into()?); + } + } + Ok(releases) + } + + pub async fn de_publish( + &self, + release_id: ToolReleaseId, + auth: &AuthCtx, + ) -> Result { + let record = self + .authorize_management(release_id, AccountToolReleaseVerb::DePublish, auth) + .await?; + if record.release.origin == TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM { + return Err(ToolReleaseError::ProtectedToolRelease); + } + self.tool_release_repo + .de_publish(release_id.0, auth.actor_account_id().0) + .await? + .ok_or(ToolReleaseError::ToolReleaseNotFound(release_id))? + .release + .try_into() + .map_err(Into::into) + } + + pub async fn restore( + &self, + release_id: ToolReleaseId, + auth: &AuthCtx, + ) -> Result { + let record = self + .authorize_management(release_id, AccountToolReleaseVerb::Restore, auth) + .await?; + if record.release.origin == TOOL_RELEASE_ORIGIN_PROTECTED_SYSTEM { + return Err(ToolReleaseError::ProtectedToolRelease); + } + self.tool_release_repo + .restore(release_id.0, auth.actor_account_id().0) + .await? + .ok_or(ToolReleaseError::ToolReleaseNotFound(release_id))? + .release + .try_into() + .map_err(Into::into) + } + + pub(crate) async fn resolve_published_reference( + &self, + reference: &ToolReleaseReference, + ) -> Result { + let record = match reference { + ToolReleaseReference::ById(reference) => { + self.tool_release_repo + .get_by_id(reference.release_id.0) + .await? + } + ToolReleaseReference::ByCoordinates(reference) => { + let account = self + .account_service + .get_by_email(reference.account.as_str(), &AuthCtx::System) + .await + .map_err(|_| ToolReleaseError::ReferencedToolReleaseNotFound)?; + self.tool_release_repo + .get_by_coordinates(account.id.0, reference.name.as_str(), &reference.version) + .await? + } + } + .ok_or(ToolReleaseError::ReferencedToolReleaseNotFound)?; + + let release: ToolRelease = record.release.clone().try_into()?; + if release.lifecycle != ToolReleaseLifecycle::Published { + return Err(ToolReleaseError::ReferencedToolReleaseNotFound); + } + Ok(record) + } + + pub(crate) async fn resolve_user_grantable_reference( + &self, + reference: &ToolReleaseReference, + ) -> Result { + let record = self.resolve_published_reference(reference).await?; + let release: ToolRelease = record.release.clone().try_into()?; + if !is_user_grantable(release.origin, release.system_availability) { + return Err(ToolReleaseError::ReferencedToolReleaseNotFound); + } + Ok(record) + } + + pub(crate) async fn resolve_auto_grantable_system_release( + &self, + release_id: ToolReleaseId, + ) -> Result { + let record = self.get_record(release_id).await?; + let release: ToolRelease = record.release.clone().try_into()?; + if release.lifecycle != ToolReleaseLifecycle::Published + || release.origin != ToolReleaseOrigin::ProtectedSystem + || !matches!( + release.system_availability, + Some(SystemToolAvailability::AutoGranted | SystemToolAvailability::Ambient) + ) + { + return Err(ToolReleaseError::ReferencedToolReleaseNotFound); + } + Ok(record) + } + + pub async fn provision_system_release( + &self, + provision: SystemToolReleaseProvision, + ) -> Result { + let candidate = ToolReleaseRecord::from_system_provision( + self.builtin_tool_owner_account_id, + provision, + AccountId::SYSTEM, + )?; + match self.tool_release_repo.create(candidate.clone()).await { + Ok(record) => record.release.try_into().map_err(Into::into), + Err(ToolReleaseRepoError::CoordinateAlreadyExists) => { + let existing = self + .tool_release_repo + .get_by_coordinates( + candidate.owner_account_id, + &candidate.tool_name, + &candidate.tool_version, + ) + .await? + .ok_or(ToolReleaseError::ImmutableReleaseConflict)?; + if !existing.release.immutable_fields_match(&candidate) { + return Err(ToolReleaseError::ImmutableReleaseConflict); + } + existing.release.try_into().map_err(Into::into) + } + Err(other) => Err(other.into()), + } + } + + async fn authorize_management( + &self, + release_id: ToolReleaseId, + verb: AccountToolReleaseVerb, + auth: &AuthCtx, + ) -> Result { + let record = self.get_record(release_id).await?; + let name = + ToolName::try_from(record.release.tool_name.clone()).map_err(anyhow::Error::msg)?; + authorize_account_tool_release_permission( + auth, + &AccountEmail::new(&record.owner_account_email), + AccountToolReleaseVerb::View, + name.clone(), + ) + .map_err(|_| ToolReleaseError::ToolReleaseNotFound(release_id))?; + authorize_account_tool_release_permission( + auth, + &AccountEmail::new(&record.owner_account_email), + verb, + name, + )?; + Ok(record) + } + + async fn get_record( + &self, + release_id: ToolReleaseId, + ) -> Result { + self.tool_release_repo + .get_by_id(release_id.0) + .await? + .ok_or(ToolReleaseError::ToolReleaseNotFound(release_id)) + } +} + +fn is_user_grantable( + origin: ToolReleaseOrigin, + availability: Option, +) -> bool { + origin == ToolReleaseOrigin::Ordinary || availability == Some(SystemToolAvailability::Grantable) +} + +fn authorize_account_tool_release_permission( + auth: &AuthCtx, + account_email: &AccountEmail, + verb: AccountToolReleaseVerb, + name: ToolName, +) -> Result<(), AuthorizationError> { + auth.authorize_permission(&PermissionTarget::AccountToolRelease( + ClassPermissionTarget { + verb: Some(verb), + owner: AccountOwnerPattern::Account { + account: account_email.clone(), + }, + resource: AccountToolReleaseResourcePattern::Name(name), + }, + )) +} + +#[cfg(test)] +mod tests { + use super::is_user_grantable; + use golem_common::model::tool_release::{SystemToolAvailability, ToolReleaseOrigin}; + use test_r::test; + + #[test] + fn user_grants_accept_ordinary_and_grantable_system_releases_only() { + assert!(is_user_grantable(ToolReleaseOrigin::Ordinary, None)); + assert!(is_user_grantable( + ToolReleaseOrigin::ProtectedSystem, + Some(SystemToolAvailability::Grantable) + )); + assert!(!is_user_grantable( + ToolReleaseOrigin::ProtectedSystem, + Some(SystemToolAvailability::AutoGranted) + )); + assert!(!is_user_grantable( + ToolReleaseOrigin::ProtectedSystem, + Some(SystemToolAvailability::Ambient) + )); + } +} diff --git a/golem-registry-service/tests/repo/common.rs b/golem-registry-service/tests/repo/common.rs index b12a921d28..55825ec2ab 100644 --- a/golem-registry-service/tests/repo/common.rs +++ b/golem-registry-service/tests/repo/common.rs @@ -31,7 +31,7 @@ use golem_common::model::card::{ }; use golem_common::model::component::{ComponentId, ComponentRevision}; use golem_common::model::component_metadata::{AgentTypeProvisionConfig, ComponentMetadata}; -use golem_common::model::deployment::DeploymentRevision; +use golem_common::model::deployment::{DeploymentRevision, DeploymentSummary}; use golem_common::model::environment::{ EnvironmentCreation, EnvironmentId, EnvironmentName, EnvironmentUpdate, }; @@ -39,9 +39,12 @@ use golem_common::model::http_api_deployment::HttpApiDeploymentAgentOptions; use golem_common::model::json::NormalizedJsonValue; use golem_common::model::plan::PlanId; use golem_common::model::tool::{ - CompiledToolBinding, RegisteredTool, SecretKeyScope, TOOL_METADATA_WIT_VERSION, + CompiledToolBinding, HostToolId, RegisteredTool, SecretKeyScope, TOOL_METADATA_WIT_VERSION, ToolDeploymentMetadata, ToolName, ToolProvisionConfig, ToolSource, }; +use golem_common::model::tool_release::{ + SystemToolAvailability, SystemToolReleaseProvision, ToolReleaseId, +}; use golem_common::model::{AgentId, IdempotencyKey, OplogIndex}; use golem_common::schema::tool::{CommandNode, CommandTree, Doc, Globals, Tool}; use golem_common::schema::{AgentConstructorSchema, AgentTypeSchema, InputSchema, SchemaGraph}; @@ -55,6 +58,7 @@ use golem_registry_service::repo::environment::{ DbEnvironmentRepo, EnvironmentExtRevisionRecord, EnvironmentRevisionRecord, EnvironmentVisibilityFilter, EnvironmentVisibilityScope, }; +use golem_registry_service::repo::environment_tool_grant::EnvironmentToolGrantRepoError; use golem_registry_service::repo::model::account::{ AccountExtRevisionRecord, AccountRepoError, AccountRevisionRecord, }; @@ -79,6 +83,7 @@ use golem_registry_service::repo::model::deployment::{ DeploymentRevisionCreationRecord, }; use golem_registry_service::repo::model::environment::EnvironmentRepoError; +use golem_registry_service::repo::model::environment_tool_grant::EnvironmentToolGrantRecord; use golem_registry_service::repo::model::hash::SqlBlake3Hash; use golem_registry_service::repo::model::http_api_deployment::{ HttpApiDeploymentData, HttpApiDeploymentRepoError, HttpApiDeploymentRevisionRecord, @@ -89,12 +94,17 @@ use golem_registry_service::repo::model::mcp_deployment::{ use golem_registry_service::repo::model::new_repo_uuid; use golem_registry_service::repo::model::plan::PlanRecord; use golem_registry_service::repo::model::plugin::PluginRecord; +use golem_registry_service::repo::model::tool_release::{ + TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED, TOOL_RELEASE_LIFECYCLE_PUBLISHED, + TOOL_RELEASE_SOURCE_COMPONENT, ToolReleaseRecord, +}; use golem_registry_service::repo::permission_share::DbPermissionShareRepo; use golem_registry_service::repo::plan::DbPlanRepo; use golem_registry_service::repo::plugin::DbPluginRepo; use golem_registry_service::repo::registry_change::{ ChangeEventId, NewRegistryChangeEvent, RegistryChangeEvent, }; +use golem_registry_service::repo::tool_release::ToolReleaseRepoError; use golem_registry_service::services::component_object_store::ComponentObjectStore; use golem_registry_service::services::registry_change_notifier::RequiresNotificationSignalExt; use golem_registry_service::services::registry_change_notifier::{ @@ -4384,6 +4394,446 @@ fn make_test_tool(name: &str, version: &str) -> Tool { } } +pub async fn test_component_delete_rejects_retained_source_references(deps: &Deps) { + let owner = deps.create_account().await; + let owner_account_id = owner.revision.account_id; + let app = deps.create_application(owner_account_id).await; + let environment = deps.create_env(app.revision.application_id).await; + let environment_id = environment.revision.environment_id; + + let release_component_name = format!("release-source-{}", new_repo_uuid()); + let release_component = deps + .component_repo + .create( + environment_id, + &release_component_name, + ComponentRevisionRecord { + component_id: new_repo_uuid(), + revision_id: ComponentRevision::INITIAL.into(), + hash: SqlBlake3Hash::empty(), + audit: DeletableRevisionAuditFields::new(owner_account_id), + size: 0.into(), + metadata: Blob::new(ComponentMetadata::from_parts( + KnownExports::default(), + Vec::new(), + None, + None, + Vec::new(), + BTreeMap::new(), + )), + object_store_key: String::new(), + binary_hash: SqlBlake3Hash::empty(), + }, + Vec::new(), + ) + .await + .unwrap(); + let release_source = ToolSource::Component { + component_id: ComponentId(release_component.revision.component_id), + component_revision: ComponentRevision::try_from(release_component.revision.revision_id) + .unwrap(), + component_name: golem_common::model::component::ComponentName(release_component_name), + }; + let release_tool = make_test_tool("retained-release", "1.0.0"); + deps.tool_release_repo + .create( + ToolReleaseRecord::from_registered_tool( + &RegisteredTool { + deployment_revision: DeploymentRevision::INITIAL, + release_id: None, + metadata_digest: golem_common::model::tool_release::tool_metadata_digest( + TOOL_METADATA_WIT_VERSION, + &release_tool, + ) + .unwrap(), + definition: release_tool, + provision: ToolProvisionConfig::default(), + source: release_source, + owner_account_id: AccountId(owner_account_id), + owner_account_email: golem_common::model::account::AccountEmail::new( + owner.revision.email.clone(), + ), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + }, + AccountId(owner_account_id), + ) + .unwrap(), + ) + .await + .unwrap(); + + let release_source_deletion = deps + .component_repo + .delete( + owner_account_id, + release_component.revision.component_id, + release_component.revision.revision_id + 1, + ) + .await; + let_assert!(Err(ComponentRepoError::ComponentSourceInUse) = release_source_deletion); + + let snapshot_component_name = format!("snapshot-source-{}", new_repo_uuid()); + let snapshot_component = deps + .component_repo + .create( + environment_id, + &snapshot_component_name, + ComponentRevisionRecord { + component_id: new_repo_uuid(), + ..release_component.revision.clone() + } + .with_updated_hash() + .unwrap(), + Vec::new(), + ) + .await + .unwrap(); + deps.full_deployment_repo + .deploy( + DeploymentRevisionCreationRecord { + environment_id, + deployment_revision_id: 1, + version: "snapshot-retention".to_string(), + hash: SqlBlake3Hash::empty(), + components: vec![DeploymentComponentRevisionRecord { + environment_id, + deployment_revision_id: 1, + component_id: snapshot_component.revision.component_id, + component_revision_id: snapshot_component.revision.revision_id, + }], + http_api_deployments: Vec::new(), + mcp_deployments: Vec::new(), + compiled_routes: Vec::new(), + compiled_mcp: Vec::new(), + registered_agent_types: Vec::new(), + tool_releases: Vec::new(), + registered_tools: Vec::new(), + agent_tool_bindings: Vec::new(), + created_agent_secrets: Vec::new(), + updated_agent_secrets: Vec::new(), + replaced_agent_secrets: Vec::new(), + created_resource_definitions: Vec::new(), + created_retry_policies: Vec::new(), + user_account_id: owner_account_id, + }, + false, + ) + .await + .unwrap() + .signal_new_events_available(&deps.test_registry_change_notifier()); + + let snapshot_source_deletion = deps + .component_repo + .delete( + owner_account_id, + snapshot_component.revision.component_id, + snapshot_component.revision.revision_id + 1, + ) + .await; + let_assert!(Err(ComponentRepoError::ComponentSourceInUse) = snapshot_source_deletion); +} + +pub async fn test_tool_release_and_grant_repository_contracts(deps: &Deps) { + let owner = deps.create_account().await; + let actor = AccountId(owner.revision.account_id); + let app = deps.create_application(actor.0).await; + let environment = deps.create_env(app.revision.application_id).await; + let protected_environment = deps.create_env(app.revision.application_id).await; + let component_name = format!("release-component-{}", new_repo_uuid()); + let component = deps + .component_repo + .create( + environment.revision.environment_id, + &component_name, + ComponentRevisionRecord { + component_id: new_repo_uuid(), + revision_id: ComponentRevision::INITIAL.into(), + hash: SqlBlake3Hash::empty(), + audit: DeletableRevisionAuditFields::new(actor.0), + size: 0.into(), + metadata: Blob::new(ComponentMetadata::from_parts( + KnownExports::default(), + Vec::new(), + None, + None, + Vec::new(), + BTreeMap::new(), + )), + object_store_key: String::new(), + binary_hash: SqlBlake3Hash::empty(), + }, + Vec::new(), + ) + .await + .unwrap(); + + let definition = make_test_tool("phase-one-tool", "1.0.0"); + let component_source = ToolSource::Component { + component_id: ComponentId(component.revision.component_id), + component_revision: ComponentRevision::try_from(component.revision.revision_id).unwrap(), + component_name: golem_common::model::component::ComponentName(component_name.clone()), + }; + let registered = RegisteredTool { + deployment_revision: DeploymentRevision::INITIAL, + release_id: None, + definition: definition.clone(), + provision: ToolProvisionConfig::default(), + source: component_source.clone(), + owner_account_id: actor, + owner_account_email: golem_common::model::account::AccountEmail::new( + owner.revision.email.clone(), + ), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + metadata_digest: Default::default(), + }; + let component_record = ToolReleaseRecord::from_registered_tool(®istered, actor).unwrap(); + let component_release_id = component_record.tool_release_id; + let created = deps + .tool_release_repo + .create(component_record.clone()) + .await + .unwrap(); + assert_eq!(created.release, component_record); + let read = deps + .tool_release_repo + .get_by_id(component_release_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + read.release.component_id, + Some(component.revision.component_id) + ); + assert_eq!( + read.release.component_revision, + Some(component.revision.revision_id) + ); + assert_eq!( + read.release.component_name.as_deref(), + Some(component_name.as_str()) + ); + assert_eq!(read.release.source_kind, TOOL_RELEASE_SOURCE_COMPONENT); + + let mut invalid_component = component_record.clone(); + invalid_component.tool_release_id = new_repo_uuid(); + invalid_component.tool_version = "invalid-component-fk".to_string(); + invalid_component.component_id = Some(new_repo_uuid()); + assert!(matches!( + deps.tool_release_repo + .create(invalid_component.clone()) + .await, + Err(ToolReleaseRepoError::InternalError(_)) + )); + assert!( + deps.tool_release_repo + .get_by_id(invalid_component.tool_release_id) + .await + .unwrap() + .is_none() + ); + + let host_record = ToolReleaseRecord::from_system_provision( + actor, + SystemToolReleaseProvision { + name: ToolName::try_from("protected-host-tool").unwrap(), + version: "2.0.0".to_string(), + source: ToolSource::Host { + host_tool_id: HostToolId::try_from("phase-one-host".to_string()).unwrap(), + implementation_version: "host-v1".to_string(), + }, + definition: make_test_tool("protected-host-tool", "2.0.0"), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + availability: SystemToolAvailability::Grantable, + }, + actor, + ) + .unwrap(); + let host_release_id = host_record.tool_release_id; + let host = deps + .tool_release_repo + .create(host_record.clone()) + .await + .unwrap(); + assert_eq!(host.release, host_record); + assert_eq!( + deps.tool_release_repo + .get_by_coordinates(actor.0, "protected-host-tool", "2.0.0") + .await + .unwrap() + .unwrap() + .release, + host_record + ); + let mut protected_without_availability = host_record.clone(); + protected_without_availability.tool_release_id = new_repo_uuid(); + protected_without_availability.tool_name = "protected-without-availability".to_string(); + protected_without_availability.tool_version = "1.0.0".to_string(); + protected_without_availability.system_availability = None; + assert!(matches!( + deps.tool_release_repo + .create(protected_without_availability) + .await, + Err(ToolReleaseRepoError::InternalError(_)) + )); + + let invalid_environment_grant = EnvironmentToolGrantRecord::creation( + EnvironmentId::new(), + ToolReleaseId(component_release_id), + false, + false, + actor, + ); + assert!(matches!( + deps.environment_tool_grant_repo + .create(invalid_environment_grant) + .await, + Err(EnvironmentToolGrantRepoError::InternalError(_)) + )); + let invalid_release_grant = EnvironmentToolGrantRecord::creation( + EnvironmentId(environment.revision.environment_id), + ToolReleaseId::new(), + false, + false, + actor, + ); + assert!(matches!( + deps.environment_tool_grant_repo + .create(invalid_release_grant) + .await, + Err(EnvironmentToolGrantRepoError::ConcurrentModification) + )); + + let ordinary_grant = EnvironmentToolGrantRecord::creation( + EnvironmentId(environment.revision.environment_id), + ToolReleaseId(component_release_id), + false, + false, + actor, + ); + let ordinary_grant_id = ordinary_grant.environment_tool_grant_id; + deps.environment_tool_grant_repo + .create(ordinary_grant.clone()) + .await + .unwrap(); + assert!( + deps.environment_tool_grant_repo + .delete(ordinary_grant_id, actor.0, false) + .await + .unwrap() + ); + assert!( + deps.environment_tool_grant_repo + .get_by_id(ordinary_grant_id, false) + .await + .unwrap() + .is_none() + ); + assert!(matches!( + deps.environment_tool_grant_repo + .create(EnvironmentToolGrantRecord::creation( + EnvironmentId(environment.revision.environment_id), + ToolReleaseId(component_release_id), + false, + false, + actor, + )) + .await, + Err(EnvironmentToolGrantRepoError::GrantAlreadyExists) + )); + let restored_grant = deps + .environment_tool_grant_repo + .restore(ordinary_grant_id, actor.0, true) + .await + .unwrap() + .unwrap(); + assert!(restored_grant.automatic); + let administrator_managed_grant = deps + .environment_tool_grant_repo + .set_automatic(ordinary_grant_id, actor.0, false) + .await + .unwrap() + .unwrap(); + assert!(!administrator_managed_grant.automatic); + assert!( + !deps + .environment_tool_grant_repo + .delete(ordinary_grant_id, actor.0, true) + .await + .unwrap() + ); + + let protected_grant = EnvironmentToolGrantRecord::creation( + EnvironmentId(protected_environment.revision.environment_id), + ToolReleaseId(host_release_id), + true, + true, + actor, + ); + let protected_grant_id = protected_grant.environment_tool_grant_id; + deps.environment_tool_grant_repo + .create(protected_grant) + .await + .unwrap(); + + let de_published = deps + .tool_release_repo + .de_publish(component_release_id, actor.0) + .await + .unwrap() + .unwrap(); + assert_eq!( + de_published.release.lifecycle, + TOOL_RELEASE_LIFECYCLE_DE_PUBLISHED + ); + assert!( + deps.environment_tool_grant_repo + .get_by_id(ordinary_grant_id, false) + .await + .unwrap() + .is_none() + ); + assert!( + deps.environment_tool_grant_repo + .get_by_id(protected_grant_id, false) + .await + .unwrap() + .is_some() + ); + assert!(matches!( + deps.tool_release_repo.create(component_record).await, + Err(ToolReleaseRepoError::CoordinateAlreadyExists) + )); + + let restored = deps + .tool_release_repo + .restore(component_release_id, actor.0) + .await + .unwrap() + .unwrap(); + assert_eq!(restored.release.lifecycle, TOOL_RELEASE_LIFECYCLE_PUBLISHED); + assert!( + deps.environment_tool_grant_repo + .get_by_id(ordinary_grant_id, false) + .await + .unwrap() + .is_none() + ); + assert!( + deps.environment_tool_grant_repo + .get_by_id(ordinary_grant_id, true) + .await + .unwrap() + .is_some() + ); + assert!( + deps.tool_release_repo + .de_publish(host_release_id, actor.0) + .await + .unwrap() + .is_none() + ); +} + pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { let owner = deps.create_account().await; let owner_account_id = owner.revision.account_id; @@ -4426,45 +4876,65 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { let deployment_creation = |deployment_revision_id: i64, component_revision_id: i64, version: &str, - tools: Vec| { + tools: Vec, + published_tool: Option<&str>| { let deployment_revision = DeploymentRevision::try_from(deployment_revision_id).unwrap(); let source = ToolSource::Component { component_id: ComponentId(component_id), component_revision: ComponentRevision::try_from(component_revision_id).unwrap(), component_name: golem_common::model::component::ComponentName(component_name.clone()), }; - let registered_tools = tools + let mut registered_tools = tools .into_iter() - .map(|definition| { - DeploymentRegisteredToolRecord::from_model( - EnvironmentId(environment_id), - RegisteredTool { - deployment_revision, - definition, - provision: ToolProvisionConfig::default(), - source: source.clone(), - owner_account_id: AccountId(owner_account_id), - owner_account_email: golem_common::model::account::AccountEmail::new( - owner_account_email.clone(), - ), - metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), - }, + .map(|definition| RegisteredTool { + deployment_revision, + release_id: None, + metadata_digest: golem_common::model::tool_release::tool_metadata_digest( + TOOL_METADATA_WIT_VERSION, + &definition, ) + .unwrap(), + definition, + provision: ToolProvisionConfig::default(), + source: source.clone(), + owner_account_id: AccountId(owner_account_id), + owner_account_email: golem_common::model::account::AccountEmail::new( + owner_account_email.clone(), + ), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + }) + .collect::>(); + let tool_releases = published_tool + .map(|published_name| { + let registered_tool = registered_tools + .iter_mut() + .find(|tool| tool.definition.name() == Some(published_name)) + .unwrap(); + let release = ToolReleaseRecord::from_registered_tool( + registered_tool, + AccountId(owner_account_id), + ) + .unwrap(); + registered_tool.release_id = Some(ToolReleaseId(release.tool_release_id)); + release }) + .into_iter() .collect::>(); let alpha_name = ToolName::try_from("alpha").unwrap(); let agent_tool_bindings = registered_tools .iter() - .find(|tool| tool.tool_name == alpha_name.as_str()) + .find(|tool| tool.definition.name() == Some(alpha_name.as_str())) .map(|tool| { DeploymentAgentToolBindingRecord::from_model( EnvironmentId(environment_id), CompiledToolBinding { deployment_revision, + release_id: tool.release_id, agent_type_name: AgentTypeName(agent_type_name.clone()), tool_name: alpha_name, - version: tool.tool_definition.value().version.clone(), + version: tool.definition.version.clone(), metadata_version: tool.metadata_version.clone(), + metadata_digest: tool.metadata_digest, account_id: AccountId(owner_account_id), account_email: golem_common::model::account::AccountEmail::new( owner_account_email.clone(), @@ -4481,6 +4951,12 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { }) .into_iter() .collect(); + let registered_tools = registered_tools + .into_iter() + .map(|tool| { + DeploymentRegisteredToolRecord::from_model(EnvironmentId(environment_id), tool) + }) + .collect(); DeploymentRevisionCreationRecord { environment_id, @@ -4510,6 +4986,7 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { agent_type: Blob::new(make_test_agent_type(&agent_type_name)), canonical_agent_type_name: agent_type_name.to_kebab_case(), }], + tool_releases, registered_tools, agent_tool_bindings, created_agent_secrets: Vec::new(), @@ -4531,6 +5008,7 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { make_test_tool("zeta", "1.0.0"), make_test_tool("alpha", "1.0.0"), ], + Some("zeta"), ), false, ) @@ -4544,6 +5022,7 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { component_revision_id, "2.0.0", vec![make_test_tool("alpha", "2.0.0")], + None, ), false, ) @@ -4574,6 +5053,37 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { assert_eq!(exact_alpha.deployment_revision.get(), 1); assert_eq!(exact_alpha.definition.version, "1.0.0"); assert_eq!(exact_alpha.metadata_version, TOOL_METADATA_WIT_VERSION); + let published_zeta = deps + .tool_release_repo + .get_by_coordinates(owner_account_id, "zeta", "1.0.0") + .await + .unwrap() + .unwrap(); + let exact_zeta: RegisteredTool = deps + .full_deployment_repo + .get_deployment_registered_tool(environment_id, 1, "zeta") + .await + .unwrap() + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + exact_zeta.release_id, + Some(ToolReleaseId(published_zeta.release.tool_release_id)) + ); + let first_summary: DeploymentSummary = deps + .full_deployment_repo + .get_deployment_identity(environment_id, 1) + .await + .unwrap() + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + first_summary.published_tools, + vec![ToolName::try_from("zeta").unwrap()] + ); + assert!(first_summary.remote_tools.is_empty()); let current: golem_common::model::tool::ToolDeploymentState = deps .full_deployment_repo @@ -4624,6 +5134,7 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { updated_component_revision_id, "3.0.0", vec![make_test_tool("alpha", "3.0.0")], + None, ), false, ) @@ -4658,6 +5169,125 @@ pub async fn test_deployment_tool_snapshot_and_rollback(deps: &Deps) { .unwrap(); assert_eq!(latest_for_updated_component.deployment_revision.get(), 3); + let remote_definition = make_test_tool("remote-search", "1.0.0"); + let remote_source = ToolSource::Component { + component_id: ComponentId(component_id), + component_revision: ComponentRevision::try_from(component_revision_id).unwrap(), + component_name: golem_common::model::component::ComponentName(component_name.clone()), + }; + let mut remote_registered_tool = RegisteredTool { + deployment_revision: DeploymentRevision::try_from(4_i64).unwrap(), + release_id: None, + definition: remote_definition.clone(), + provision: ToolProvisionConfig { + config: NormalizedJsonValue::new(serde_json::json!({ "consumer": true })), + ..ToolProvisionConfig::default() + }, + source: remote_source.clone(), + owner_account_id: AccountId(owner_account_id), + owner_account_email: golem_common::model::account::AccountEmail::new( + owner_account_email.clone(), + ), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + metadata_digest: golem_common::model::tool_release::tool_metadata_digest( + TOOL_METADATA_WIT_VERSION, + &remote_definition, + ) + .unwrap(), + }; + let remote_release = ToolReleaseRecord::from_registered_tool( + &remote_registered_tool, + AccountId(owner_account_id), + ) + .unwrap(); + let remote_release_id = ToolReleaseId(remote_release.tool_release_id); + deps.tool_release_repo.create(remote_release).await.unwrap(); + remote_registered_tool.release_id = Some(remote_release_id); + let remote_binding = CompiledToolBinding { + deployment_revision: DeploymentRevision::try_from(4_i64).unwrap(), + release_id: Some(remote_release_id), + agent_type_name: AgentTypeName(agent_type_name.clone()), + tool_name: ToolName::try_from("remote-search").unwrap(), + version: "1.0.0".to_string(), + metadata_version: TOOL_METADATA_WIT_VERSION.to_string(), + metadata_digest: remote_registered_tool.metadata_digest, + account_id: AccountId(owner_account_id), + account_email: remote_registered_tool.owner_account_email.clone(), + parameters: NormalizedJsonValue::new(serde_json::json!({ "limit": 10 })), + secret_keys_readable: SecretKeyScope::All, + secret_keys_revealable: SecretKeyScope::All, + filesystem_access: golem_common::model::tool::ToolFilesystemAccess::Unset, + source: remote_source, + }; + let mut remote_deployment = + deployment_creation(4, updated_component_revision_id, "4.0.0", Vec::new(), None); + remote_deployment.registered_tools = vec![DeploymentRegisteredToolRecord::from_model( + EnvironmentId(environment_id), + remote_registered_tool.clone(), + )]; + remote_deployment.agent_tool_bindings = vec![DeploymentAgentToolBindingRecord::from_model( + EnvironmentId(environment_id), + remote_binding, + )]; + deps.full_deployment_repo + .deploy(remote_deployment, false) + .await + .unwrap() + .signal_new_events_available(&deps.test_registry_change_notifier()); + + let remote_state: golem_common::model::tool::ToolDeploymentState = deps + .full_deployment_repo + .get_tool_deployment_state(environment_id, 4) + .await + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + remote_state.registered_tools[&ToolName::try_from("remote-search").unwrap()], + remote_registered_tool + ); + let remote_summary: DeploymentSummary = deps + .full_deployment_repo + .get_deployment_identity(environment_id, 4) + .await + .unwrap() + .unwrap() + .try_into() + .unwrap(); + assert_eq!(remote_summary.remote_tools.len(), 1); + assert_eq!( + remote_summary.remote_tools[0].name, + ToolName::try_from("remote-search").unwrap() + ); + assert!(remote_summary.published_tools.is_empty()); + assert_eq!(remote_summary.components.len(), 1); + assert_eq!( + remote_summary.components[0].revision, + ComponentRevision::try_from(updated_component_revision_id).unwrap() + ); + + let mut failed_publication = deployment_creation( + 5, + updated_component_revision_id, + "5.0.0", + vec![make_test_tool("atomic-rollback", "1.0.0")], + Some("atomic-rollback"), + ); + failed_publication.registered_tools[0].owner_account_id = new_repo_uuid(); + assert!( + deps.full_deployment_repo + .deploy(failed_publication, false) + .await + .is_err() + ); + assert!( + deps.tool_release_repo + .get_by_coordinates(owner_account_id, "atomic-rollback", "1.0.0") + .await + .unwrap() + .is_none() + ); + deps.full_deployment_repo .set_current_deployment(owner_account_id, environment_id, 1) .await @@ -4796,6 +5426,7 @@ async fn setup_resolve_env(deps: &Deps) -> ResolveTestEnv { compiled_routes: vec![], compiled_mcp: vec![], registered_agent_types: vec![agent_type_record], + tool_releases: vec![], registered_tools: vec![], agent_tool_bindings: vec![], created_agent_secrets: vec![], diff --git a/golem-registry-service/tests/repo/mod.rs b/golem-registry-service/tests/repo/mod.rs index 7af50ac332..9c1b011f0b 100644 --- a/golem-registry-service/tests/repo/mod.rs +++ b/golem-registry-service/tests/repo/mod.rs @@ -27,6 +27,7 @@ use golem_registry_service::repo::application::ApplicationRepo; use golem_registry_service::repo::component::ComponentRepo; use golem_registry_service::repo::deployment::DeploymentRepo; use golem_registry_service::repo::environment::EnvironmentRepo; +use golem_registry_service::repo::environment_tool_grant::EnvironmentToolGrantRepo; use golem_registry_service::repo::http_api_deployment::HttpApiDeploymentRepo; use golem_registry_service::repo::mcp_deployment::McpDeploymentRepo; use golem_registry_service::repo::model::account::{ @@ -47,6 +48,7 @@ use golem_registry_service::repo::plugin::PluginRepo; use golem_registry_service::repo::registry_change::{ ChangeEventId, DbRegistryChangeRepo, NewRegistryChangeEvent, RegistryChangeRepo, }; +use golem_registry_service::repo::tool_release::ToolReleaseRepo; use golem_registry_service::services::account::AccountService; use golem_registry_service::services::account_usage::AccountUsageService; use golem_registry_service::services::plan::PlanService; @@ -75,6 +77,7 @@ pub struct Deps { pub agent_secret_repo: Box, pub application_repo: Box, pub environment_repo: Box, + pub environment_tool_grant_repo: Box, pub plan_repo: Box, pub component_repo: Box, pub http_api_deployment_repo: Box, @@ -83,6 +86,7 @@ pub struct Deps { pub full_deployment_repo: Box, pub plugin_repo: Box, pub registry_change_repo: Box, + pub tool_release_repo: Box, pub test_db: TestDb, } diff --git a/golem-registry-service/tests/repo/postgres.rs b/golem-registry-service/tests/repo/postgres.rs index 43f7c89fc4..cbb1262359 100644 --- a/golem-registry-service/tests/repo/postgres.rs +++ b/golem-registry-service/tests/repo/postgres.rs @@ -23,6 +23,7 @@ use golem_registry_service::repo::application::DbApplicationRepo; use golem_registry_service::repo::component::DbComponentRepo; use golem_registry_service::repo::deployment::DbDeploymentRepo; use golem_registry_service::repo::environment::DbEnvironmentRepo; +use golem_registry_service::repo::environment_tool_grant::DbEnvironmentToolGrantRepo; use golem_registry_service::repo::http_api_deployment::DbHttpApiDeploymentRepo; use golem_registry_service::repo::mcp_deployment::DbMcpDeploymentRepo; use golem_registry_service::repo::plan::DbPlanRepo; @@ -30,6 +31,7 @@ use golem_registry_service::repo::plugin::DbPluginRepo; use golem_registry_service::repo::registry_change::{ DbRegistryChangeRepo, NewRegistryChangeEvent, RegistryChangeEvent, RegistryChangeRepo, }; +use golem_registry_service::repo::tool_release::DbToolReleaseRepo; use golem_registry_service::services::registry_change_notifier::{ PostgresRegistryChangeNotifier, RegistryChangeNotifier, }; @@ -216,6 +218,7 @@ async fn make_deps(pool: PostgresPool) -> Deps { agent_secret_repo: Box::new(DbAgentSecretRepo::logged(pool.clone())), application_repo: Box::new(DbApplicationRepo::logged(pool.clone())), environment_repo: Box::new(DbEnvironmentRepo::logged(pool.clone())), + environment_tool_grant_repo: Box::new(DbEnvironmentToolGrantRepo::logged(pool.clone())), plan_repo: Box::new(DbPlanRepo::logged(pool.clone())), component_repo: Box::new(DbComponentRepo::logged(pool.clone())), http_api_deployment_repo: Box::new(DbHttpApiDeploymentRepo::logged(pool.clone())), @@ -224,6 +227,7 @@ async fn make_deps(pool: PostgresPool) -> Deps { full_deployment_repo: Box::new(DbDeploymentRepo::logged(pool.clone())), plugin_repo: Box::new(DbPluginRepo::logged(pool.clone())), registry_change_repo: Box::new(DbRegistryChangeRepo::new(pool.clone())), + tool_release_repo: Box::new(DbToolReleaseRepo::logged(pool.clone())), test_db: TestDb::Postgres(pool.clone()), }; deps.setup().await; @@ -478,6 +482,13 @@ async fn test_component_delete_does_not_revoke_reused_agent_initial_card_id( .await; } +#[test] +async fn test_component_delete_rejects_retained_source_references( + #[dimension(postgres_variant)] deps: &Deps, +) { + crate::repo::common::test_component_delete_rejects_retained_source_references(deps).await; +} + #[test] async fn test_initial_permission_card_ids_by_account_excludes_pre_recreate_revisions( #[dimension(postgres_variant)] deps: &Deps, @@ -608,6 +619,13 @@ async fn test_registry_change_mixed_event_types(#[dimension(postgres_variant)] d crate::repo::common::test_registry_change_mixed_event_types(deps).await; } +#[test] +async fn test_tool_release_and_grant_repository_contracts( + #[dimension(postgres_variant)] deps: &Deps, +) { + crate::repo::common::test_tool_release_and_grant_repository_contracts(deps).await; +} + /// Tests that Postgres LISTEN/NOTIFY propagates events through the /// `PostgresRegistryChangeNotifier` background PgListener task. /// This validates the cross-node broadcast path used in multi-registry deployments. diff --git a/golem-registry-service/tests/repo/sqlite.rs b/golem-registry-service/tests/repo/sqlite.rs index 52ce062b71..3f24a8fda3 100644 --- a/golem-registry-service/tests/repo/sqlite.rs +++ b/golem-registry-service/tests/repo/sqlite.rs @@ -23,12 +23,14 @@ use golem_registry_service::repo::application::DbApplicationRepo; use golem_registry_service::repo::component::DbComponentRepo; use golem_registry_service::repo::deployment::DbDeploymentRepo; use golem_registry_service::repo::environment::DbEnvironmentRepo; +use golem_registry_service::repo::environment_tool_grant::DbEnvironmentToolGrantRepo; use golem_registry_service::repo::http_api_deployment::DbHttpApiDeploymentRepo; use golem_registry_service::repo::mcp_deployment::DbMcpDeploymentRepo; use golem_registry_service::repo::model::new_repo_uuid; use golem_registry_service::repo::plan::DbPlanRepo; use golem_registry_service::repo::plugin::DbPluginRepo; use golem_registry_service::repo::registry_change::DbRegistryChangeRepo; +use golem_registry_service::repo::tool_release::DbToolReleaseRepo; use golem_service_base::db; use golem_service_base::db::sqlite::SqlitePool; use golem_service_base::migration::{Migrations, MigrationsDir}; @@ -92,6 +94,7 @@ async fn deps(db: &SqliteDb) -> Deps { agent_secret_repo: Box::new(DbAgentSecretRepo::logged(db.pool.clone())), application_repo: Box::new(DbApplicationRepo::logged(db.pool.clone())), environment_repo: Box::new(DbEnvironmentRepo::logged(db.pool.clone())), + environment_tool_grant_repo: Box::new(DbEnvironmentToolGrantRepo::logged(db.pool.clone())), plan_repo: Box::new(DbPlanRepo::logged(db.pool.clone())), component_repo: Box::new(DbComponentRepo::logged(db.pool.clone())), http_api_deployment_repo: Box::new(DbHttpApiDeploymentRepo::logged(db.pool.clone())), @@ -100,6 +103,7 @@ async fn deps(db: &SqliteDb) -> Deps { full_deployment_repo: Box::new(DbDeploymentRepo::logged(db.pool.clone())), plugin_repo: Box::new(DbPluginRepo::logged(db.pool.clone())), registry_change_repo: Box::new(DbRegistryChangeRepo::new(db.pool.clone())), + tool_release_repo: Box::new(DbToolReleaseRepo::logged(db.pool.clone())), test_db: TestDb::Sqlite(db.pool.clone()), }; deps.setup().await; @@ -264,6 +268,11 @@ async fn test_component_delete_does_not_revoke_reused_agent_initial_card_id(deps .await; } +#[test] +async fn test_component_delete_rejects_retained_source_references(deps: &Deps) { + crate::repo::common::test_component_delete_rejects_retained_source_references(deps).await; +} + #[test] async fn test_initial_permission_card_ids_by_account_excludes_pre_recreate_revisions(deps: &Deps) { crate::repo::common::test_initial_permission_card_ids_by_account_excludes_pre_recreate_revisions( @@ -381,3 +390,8 @@ async fn test_registry_change_cursor_expired_detection(deps: &Deps) { async fn test_registry_change_mixed_event_types(deps: &Deps) { crate::repo::common::test_registry_change_mixed_event_types(deps).await; } + +#[test] +async fn test_tool_release_and_grant_repository_contracts(deps: &Deps) { + crate::repo::common::test_tool_release_and_grant_repository_contracts(deps).await; +} diff --git a/golem-service-base/src/api_tags.rs b/golem-service-base/src/api_tags.rs index 46a5fa208d..86727e2355 100644 --- a/golem-service-base/src/api_tags.rs +++ b/golem-service-base/src/api_tags.rs @@ -31,12 +31,14 @@ pub enum ApiTags { Deployment, Environment, EnvironmentPluginGrants, + EnvironmentToolGrants, Debugging, HealthCheck, /// The login endpoints are implementing an OAuth2 flow. Login, Me, Plugin, + ToolReleases, PermissionShares, Reports, RetryPolicies, diff --git a/golem-test-framework/src/config/dsl_impl.rs b/golem-test-framework/src/config/dsl_impl.rs index a7a5623e7c..4616859937 100644 --- a/golem-test-framework/src/config/dsl_impl.rs +++ b/golem-test-framework/src/config/dsl_impl.rs @@ -1037,6 +1037,8 @@ impl TestDslExtended for TestUserContext { current_revision: plan.current_revision, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion(Uuid::new_v4().to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), diff --git a/golem-worker-executor-test-utils/src/agent_deployments_service.rs b/golem-worker-executor-test-utils/src/agent_deployments_service.rs index 84850c9c08..e213b9c37c 100644 --- a/golem-worker-executor-test-utils/src/agent_deployments_service.rs +++ b/golem-worker-executor-test-utils/src/agent_deployments_service.rs @@ -20,16 +20,15 @@ use golem_common::model::agent_secret::{ use golem_common::model::component::{ComponentId, ComponentRevision}; use golem_common::model::environment::EnvironmentId; use golem_common::model::retry_policy::NamedRetryPolicy; -use golem_common::model::tool::{ - CompiledToolBinding, RegisteredTool, ToolDeploymentState, ToolName, -}; +use golem_common::model::tool::{ToolDeploymentState, ToolName}; use golem_common::schema::tool::DiscoveredTool; use golem_service_base::error::worker_executor::WorkerExecutorError; use golem_service_base::model::AgentDeploymentDetails; use golem_service_base::model::agent_secret::AgentSecret; use golem_worker_executor::services::environment_state::{ - EnvironmentStateService, ToolDiscoveryError, ToolDiscoverySnapshot, + EnvironmentStateService, ToolActivationOutcome, ToolDiscoveryError, ToolDiscoverySnapshot, get_accessible_tool_from_snapshot, get_accessible_tools_from_snapshot, + get_tool_activation_from_deployment, }; use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -128,6 +127,7 @@ pub struct TestEnvironmentStateService { agent_secret_revision_calls: AtomicUsize, accessible_tools_calls: AtomicUsize, accessible_tool_calls: AtomicUsize, + tool_activation_lookups: RwLock>, } impl TestEnvironmentStateService { @@ -176,6 +176,14 @@ impl TestEnvironmentStateService { pub fn accessible_tool_calls(&self) -> usize { self.accessible_tool_calls.load(Ordering::SeqCst) } + + pub fn tool_activation_calls(&self) -> usize { + self.tool_activation_lookups.read().unwrap().len() + } + + pub fn tool_activation_lookups(&self) -> Vec<(EnvironmentId, ComponentId, ComponentRevision)> { + self.tool_activation_lookups.read().unwrap().clone() + } } #[async_trait] @@ -227,36 +235,24 @@ impl EnvironmentStateService for TestEnvironmentStateService { Ok(Vec::new()) } - async fn get_registered_tool( - &self, - environment_id: EnvironmentId, - tool_name: &ToolName, - ) -> Result, WorkerExecutorError> { - Ok(self - .tool_deployments - .read() - .unwrap() - .iter() - .find(|((candidate, _, _), _)| *candidate == environment_id) - .and_then(|(_, deployment)| deployment.state.registered_tools.get(tool_name)) - .cloned()) - } - - async fn get_agent_tool_binding( + async fn get_tool_activation( &self, environment_id: EnvironmentId, + component_id: ComponentId, + component_revision: ComponentRevision, agent_type: &AgentTypeName, tool_name: &ToolName, - ) -> Result, WorkerExecutorError> { - Ok(self - .tool_deployments - .read() - .unwrap() - .iter() - .find(|((candidate, _, _), _)| *candidate == environment_id) - .and_then(|(_, deployment)| deployment.state.agent_tool_bindings.get(agent_type)) - .and_then(|bindings| bindings.get(tool_name)) - .cloned()) + ) -> Result { + self.tool_activation_lookups.write().unwrap().push(( + environment_id, + component_id, + component_revision, + )); + let deployments = self.tool_deployments.read().unwrap(); + let deployment = deployments + .get(&(environment_id, component_id, component_revision)) + .map(|deployment| &deployment.state); + get_tool_activation_from_deployment(deployment, agent_type, tool_name) } async fn get_accessible_tools( diff --git a/golem-worker-executor/src/durable_host/tool/mod.rs b/golem-worker-executor/src/durable_host/tool/mod.rs index e8b14fa680..a6d09090f0 100644 --- a/golem-worker-executor/src/durable_host/tool/mod.rs +++ b/golem-worker-executor/src/durable_host/tool/mod.rs @@ -34,10 +34,16 @@ use crate::preview2::golem::tool::host::{ HostToolRpcWithStore, InvocationResult, RegisteredTool as WitRegisteredTool, RpcError, TypedSchemaValue, }; -use crate::services::environment_state::ToolDiscoveryError; +use crate::services::environment_state::{ + ToolActivationOutcome, ToolDiscoveryError, ToolDispatchTarget, +}; use crate::workerctx::WorkerCtx; use anyhow::{Context, anyhow}; +use golem_common::model::account::AccountEmail; +use golem_common::model::application::ApplicationName; use golem_common::model::card::owner::ToolOwnerPattern; +use golem_common::model::component::ComponentName; +use golem_common::model::environment::EnvironmentName; use golem_common::model::oplog::host_functions::{ GolemToolGetAllTools, GolemToolGetTool, GolemToolRpcAsyncInvokeAndAwait, GolemToolRpcInvoke, GolemToolRpcInvokeAndAwait, @@ -50,7 +56,7 @@ use golem_common::model::oplog::{ HostRequestNoInput, HostResponseGolemToolInvokeResult, HostResponseGolemToolTool, HostResponseGolemToolTools, HostResponseGolemToolUnitOrFailure, }; -use golem_common::model::tool::{RegisteredTool, ToolName, ToolSource}; +use golem_common::model::tool::ToolName; use golem_common::schema::render::cli_text::value_to_cli_text_unredacted; use golem_common::schema::tool::DiscoveredTool; use golem_common::schema::tool::canonical::CanonicalSurfaceRef; @@ -441,19 +447,19 @@ fn project_tool_unit( response.map_err(|error| project_tool_rpc_error(error, ctx)) } -fn tool_owner( - ctx: &DurableWorkerCtx, - rpc: &ToolRpcEntry, - registered_tool: &RegisteredTool, +fn caller_tool_owner( + account: &AccountEmail, + application: &ApplicationName, + environment: &EnvironmentName, + component: &ComponentName, + tool_name: &ToolName, ) -> ToolOwnerPattern { - let component = ctx.component_metadata(); - let ToolSource::Component { component_name, .. } = ®istered_tool.source; ToolOwnerPattern::Tool { - account: registered_tool.owner_account_email.clone(), - application: component.application_name.clone(), - environment: component.environment_name.clone(), - component: component_name.clone(), - tool: rpc.tool_name.to_string(), + account: account.clone(), + application: application.clone(), + environment: environment.clone(), + component: component.clone(), + tool: tool_name.to_string(), } } @@ -486,13 +492,14 @@ struct PreparedToolCall { stdin: Option>, request: HostRequestGolemToolInvoke, permit: LiveAuthorizationPermit, + dispatch_target: ToolDispatchTarget, } enum ToolCallPreparation { - Ready(PreparedToolCall), + Ready(Box), Rejected { request: Box, - response: ToolInvokeResponse, + response: Box, stdin: Option>, }, } @@ -509,23 +516,33 @@ where Ctx: WorkerCtx, { let has_stdin = stdin.is_some(); - let (rpc, input, environment_state_service, environment_id, agent_type) = - accessor.with(|mut access| { - let ctx = access.get(); - let rpc = ctx.table().get(resource)?.clone(); - let input = decode_typed_tool_value(input, ctx); - let agent_type = ctx - .parsed_agent_id() - .map(|agent_id| agent_id.agent_type) - .ok_or_else(|| anyhow!("tool invocation requires an agent caller"))?; - Ok::<_, anyhow::Error>(( - rpc, - input, - ctx.state.environment_state_service.clone(), - ctx.state.owned_agent_id.environment_id, - agent_type, - )) - })?; + let ( + rpc, + input, + environment_state_service, + environment_id, + owner_component_id, + owner_component_revision, + agent_type, + ) = accessor.with(|mut access| { + let ctx = access.get(); + let rpc = ctx.table().get(resource)?.clone(); + let input = decode_typed_tool_value(input, ctx); + let owner_component = ctx.owner_component_metadata(); + let agent_type = ctx + .parsed_agent_id() + .map(|agent_id| agent_id.agent_type) + .ok_or_else(|| anyhow!("tool invocation requires an agent caller"))?; + Ok::<_, anyhow::Error>(( + rpc, + input, + ctx.state.environment_state_service.clone(), + ctx.state.owned_agent_id.environment_id, + owner_component.id, + owner_component.revision, + agent_type, + )) + })?; let input = match input { Ok(input) => input, @@ -538,26 +555,26 @@ where empty_tool_input(), has_stdin, )), - response: Err(SerializableToolRpcError::ProtocolError(format!( + response: Box::new(Err(SerializableToolRpcError::ProtocolError(format!( "invalid tool input: {error}" - ))), + )))), stdin, }); } }; - let registered_tool = environment_state_service - .get_registered_tool(environment_id, &rpc.tool_name) - .await? - .ok_or_else(|| { - SerializableToolRpcError::NotFound(format!( - "tool '{}' is not registered", - rpc.tool_name - )) - }); - let registered_tool = match registered_tool { - Ok(registered_tool) => registered_tool, - Err(error) => { + let activation = match environment_state_service + .get_tool_activation( + environment_id, + owner_component_id, + owner_component_revision, + &agent_type, + &rpc.tool_name, + ) + .await + { + Ok(ToolActivationOutcome::Ready(activation)) => *activation, + Ok(ToolActivationOutcome::NotBound) => { return Ok(ToolCallPreparation::Rejected { request: Box::new(invocation_request( &rpc, @@ -566,22 +583,30 @@ where input, has_stdin, )), - response: Err(error), + response: Box::new(Err(SerializableToolRpcError::Denied(format!( + "tool '{}' is not bound to agent type '{agent_type}'", + rpc.tool_name + )))), stdin, }); } - }; - let binding = environment_state_service - .get_agent_tool_binding(environment_id, &agent_type, &rpc.tool_name) - .await? - .ok_or_else(|| { - SerializableToolRpcError::Denied(format!( - "tool '{}' is not bound to agent type '{agent_type}'", - rpc.tool_name - )) - }); - let binding = match binding { - Ok(binding) => binding, + Ok(ToolActivationOutcome::NotRegistered) => { + return Ok(ToolCallPreparation::Rejected { + request: Box::new(invocation_request( + &rpc, + &command_path, + Vec::new(), + input, + has_stdin, + )), + response: Box::new(Err(SerializableToolRpcError::NotFound(format!( + "tool '{}' is not registered", + rpc.tool_name + )))), + stdin, + }); + } + Err(ToolDiscoveryError::Retrieval(error)) => return Err(error.into()), Err(error) => { return Ok(ToolCallPreparation::Rejected { request: Box::new(invocation_request( @@ -591,29 +616,19 @@ where input, has_stdin, )), - response: Err(error), + response: Box::new(Err(SerializableToolRpcError::RemoteInternalError( + error.to_string(), + ))), stdin, }); } }; - if registered_tool.deployment_revision != binding.deployment_revision { - return Ok(ToolCallPreparation::Rejected { - request: Box::new(invocation_request( - &rpc, - &command_path, - Vec::new(), - input, - has_stdin, - )), - response: Err(SerializableToolRpcError::RemoteInternalError(format!( - "tool '{}' changed while resolving its binding", - rpc.tool_name - ))), - stdin, - }); - } - let args = match canonical_tool_args(®istered_tool.definition, &command_path, &input) { + let args = match canonical_tool_args( + &activation.registered_tool().definition, + &command_path, + &input, + ) { Ok(args) => args, Err(error) => { return Ok(ToolCallPreparation::Rejected { @@ -624,15 +639,34 @@ where input, has_stdin, )), - response: Err(SerializableToolRpcError::ProtocolError(error)), + response: Box::new(Err(SerializableToolRpcError::ProtocolError(error))), stdin, }); } }; let request = invocation_request(&rpc, &command_path, args.clone(), input.clone(), has_stdin); + let dispatch_target = match activation.into_dispatch_target() { + Ok(dispatch_target) => dispatch_target, + Err(error) => { + return Ok(ToolCallPreparation::Rejected { + request: Box::new(request), + response: Box::new(Err(SerializableToolRpcError::RemoteInternalError( + error.to_string(), + ))), + stdin, + }); + } + }; let target = accessor.with(|mut access| { - let owner = tool_owner(access.get(), &rpc, ®istered_tool); + let component = access.get().owner_component_metadata(); + let owner = caller_tool_owner( + &component.account_email, + &component.application_name, + &component.environment_name, + &component.component_name, + &rpc.tool_name, + ); let command_path = command_path.iter().map(String::as_str).collect::>(); let args = args.iter().map(String::as_str).collect::>(); tool_target(owner, &command_path, &args) @@ -642,7 +676,9 @@ where Err(error) => { return Ok(ToolCallPreparation::Rejected { request: Box::new(request), - response: Err(SerializableToolRpcError::ProtocolError(error.to_string())), + response: Box::new(Err(SerializableToolRpcError::ProtocolError( + error.to_string(), + ))), stdin, }); } @@ -658,17 +694,18 @@ where Err(error) => { return Ok(ToolCallPreparation::Rejected { request: Box::new(request), - response: Err(SerializableToolRpcError::Denied(error.to_string())), + response: Box::new(Err(SerializableToolRpcError::Denied(error.to_string()))), stdin, }); } }; - Ok(ToolCallPreparation::Ready(PreparedToolCall { + Ok(ToolCallPreparation::Ready(Box::new(PreparedToolCall { stdin, request, permit, - })) + dispatch_target, + }))) } async fn close_stdin( @@ -723,8 +760,9 @@ impl DurableWorkerCtx { pub(crate) async fn get_all_tools_model(&mut self) -> anyhow::Result>> { let agent_type = self.parsed_agent_id().map(|agent_id| agent_id.agent_type); let environment_id = self.state.owned_agent_id.environment_id; - let component_id = self.state.owned_agent_id.agent_id.component_id; - let component_revision = self.state.component_metadata.revision; + let owner_component = self.owner_component_metadata(); + let component_id = owner_component.id; + let component_revision = owner_component.revision; let mut handle = DurableCallSession::::start( self, @@ -793,8 +831,9 @@ impl DurableWorkerCtx { let agent_type = self.parsed_agent_id().map(|agent_id| agent_id.agent_type); let valid_tool_name = ToolName::try_from(tool_name.as_str()).ok(); let environment_id = self.state.owned_agent_id.environment_id; - let component_id = self.state.owned_agent_id.agent_id.component_id; - let component_revision = self.state.component_metadata.revision; + let owner_component = self.owner_component_metadata(); + let component_id = owner_component.id; + let component_revision = owner_component.revision; let mut handle = DurableCallSession::::start( self, @@ -985,6 +1024,7 @@ impl HostToolRpcWithStore for HasSelf { + let response = *response; close_stdin(accessor, stdin).await?; let handle = DurableCallSession::::start_access( accessor, @@ -1006,7 +1046,7 @@ impl HostToolRpcWithStore for HasSelf prepared, + ToolCallPreparation::Ready(prepared) => *prepared, }; let handle = DurableCallSession::::start_access( @@ -1018,6 +1058,7 @@ impl HostToolRpcWithStore for HasSelf HostToolRpcWithStore for HasSelf { + let response = *response; close_stdin(accessor, stdin).await?; let handle = DurableCallSession::::start_access( @@ -1118,7 +1160,7 @@ impl HostToolRpcWithStore for HasSelf prepared, + ToolCallPreparation::Ready(prepared) => *prepared, }; let handle = DurableCallSession::::start_access( @@ -1130,6 +1172,7 @@ impl HostToolRpcWithStore for HasSelf HostToolRpcWithStore for HasSelf { + let response = *response; close_stdin(accessor, stdin).await?; let handle = DurableCallSession::::start_access( @@ -1240,7 +1284,7 @@ impl HostToolRpcWithStore for HasSelf prepared, + ToolCallPreparation::Ready(prepared) => *prepared, }; let handle = DurableCallSession::::start_access( @@ -1252,6 +1296,7 @@ impl HostToolRpcWithStore for HasSelf HostFutureInvokeResult for DurableWorkerCtx { #[cfg(test)] mod tests { - use super::{WitRegisteredTool, classify_tool_discovery_error, terminal_tool_discovery_error}; + use super::{ + WitRegisteredTool, caller_tool_owner, classify_tool_discovery_error, + terminal_tool_discovery_error, + }; use crate::durable_host::durability::{ClassifiedHostError, HostFailureKind}; use crate::services::environment_state::ToolDiscoveryError; use golem_common::model::account::{AccountEmail, AccountId}; + use golem_common::model::application::ApplicationName; + use golem_common::model::card::owner::ToolOwnerPattern; use golem_common::model::component::{ComponentId, ComponentName, ComponentRevision}; use golem_common::model::deployment::DeploymentRevision; - use golem_common::model::tool::{RegisteredTool, ToolProvisionConfig, ToolSource}; + use golem_common::model::environment::EnvironmentName; + use golem_common::model::tool::{RegisteredTool, ToolName, ToolProvisionConfig, ToolSource}; use golem_common::schema::tool::{ CommandBody, CommandNode, CommandTree, DiscoveredTool, Doc, Globals, Positional, Positionals, Tool, @@ -1393,6 +1444,7 @@ mod tests { ( RegisteredTool { deployment_revision: DeploymentRevision::try_from(1_u64).unwrap(), + release_id: None, definition, provision: ToolProvisionConfig::default(), source: ToolSource::Component { @@ -1403,6 +1455,7 @@ mod tests { owner_account_id: AccountId::new(), owner_account_email: AccountEmail::new("owner@example.com"), metadata_version: "0.1.0".to_string(), + metadata_digest: Default::default(), }, component_id, ) @@ -1425,6 +1478,40 @@ mod tests { assert_eq!(ComponentId::from(wit.implemented_by), component_id); } + #[test] + fn tool_authorization_owner_is_entirely_caller_derived() { + let caller_account = AccountEmail::new("consumer@example.com"); + let publisher_account = AccountEmail::new("publisher@example.com"); + let application = ApplicationName::try_from("consumer-application").unwrap(); + let environment = EnvironmentName::try_from("consumer-environment").unwrap(); + let component = ComponentName("consumer:agent".to_string()); + let tool_name = ToolName::try_from("search").unwrap(); + + let owner = caller_tool_owner( + &caller_account, + &application, + &environment, + &component, + &tool_name, + ); + + assert_eq!( + owner, + ToolOwnerPattern::Tool { + account: caller_account.clone(), + application, + environment, + component, + tool: tool_name.to_string(), + } + ); + let ToolOwnerPattern::Tool { account, .. } = owner else { + panic!("authorization owner must identify one caller-owned tool") + }; + assert_eq!(account, caller_account); + assert_ne!(account, publisher_account); + } + #[test] fn tool_discovery_error_classification_preserves_integrity_semantics() { let retrieval = ToolDiscoveryError::Retrieval(WorkerExecutorError::runtime("offline")); diff --git a/golem-worker-executor/src/services/environment_state.rs b/golem-worker-executor/src/services/environment_state.rs index a6f2394996..c32a226cee 100644 --- a/golem-worker-executor/src/services/environment_state.rs +++ b/golem-worker-executor/src/services/environment_state.rs @@ -25,8 +25,8 @@ use golem_common::model::entity::{ use golem_common::model::environment::EnvironmentId; use golem_common::model::retry_policy::NamedRetryPolicy; use golem_common::model::tool::{ - CompiledToolBinding, RegisteredTool, ToolDeploymentState, ToolFilesystemAccess, ToolName, - ToolSource, + CompiledToolBinding, HostToolId, RegisteredTool, ToolDeploymentState, ToolFilesystemAccess, + ToolName, ToolProvisionConfig, ToolSource, }; use golem_common::schema::tool::DiscoveredTool; use golem_service_base::clients::registry::RegistryService; @@ -43,9 +43,23 @@ use std::time::Duration; type ToolDiscoveryCacheKey = (EnvironmentId, ComponentId, ComponentRevision); +struct CachedToolDeployment { + state: ToolDeploymentState, + discovery: ToolDiscoverySnapshot, +} + +impl From for CachedToolDeployment { + fn from(state: ToolDeploymentState) -> Self { + Self { + discovery: state.clone().into(), + state, + } + } +} + struct ToolDiscoveryCache { values: Arc< - Cache>, WorkerExecutorError>, + Cache>, WorkerExecutorError>, >, invalidation_guard: Arc>, } @@ -70,10 +84,10 @@ impl ToolDiscoveryCache { &self, key: &ToolDiscoveryCacheKey, load: F, - ) -> Result>, WorkerExecutorError> + ) -> Result>, WorkerExecutorError> where F: FnOnce() -> Fut + Send + 'static, - Fut: Future>, WorkerExecutorError>> + Fut: Future>, WorkerExecutorError>> + Send + 'static, { @@ -168,6 +182,26 @@ pub struct ToolActivationSnapshot { filesystem: FilesystemCapability, } +#[derive(Clone, Debug, PartialEq)] +pub enum ToolActivationOutcome { + Ready(Box), + NotBound, + NotRegistered, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ToolDispatchTarget { + Component(EntityActivation), + Host { + host_tool_id: HostToolId, + implementation_version: String, + deployment_revision: golem_common::model::deployment::DeploymentRevision, + provision: ToolProvisionConfig, + binding: Box, + filesystem: FilesystemCapability, + }, +} + impl ToolActivationSnapshot { pub fn registered_tool(&self) -> &RegisteredTool { &self.registered_tool @@ -181,22 +215,35 @@ impl ToolActivationSnapshot { self.filesystem } - pub fn into_entity_activation(self) -> Result { - let ToolSource::Component { - component_id, - component_revision, - .. - } = self.registered_tool.source; - EntityActivation::new( - ExecutableTarget::new(component_id, component_revision), - self.registered_tool.deployment_revision, - EntityActivationPolicy::Tool { + pub fn into_dispatch_target(self) -> Result { + match self.registered_tool.source { + ToolSource::Component { + component_id, + component_revision, + .. + } => EntityActivation::new( + ExecutableTarget::new(component_id, component_revision), + self.registered_tool.deployment_revision, + EntityActivationPolicy::Tool { + provision: self.registered_tool.provision, + binding: Box::new(self.binding), + }, + self.filesystem, + ) + .map(ToolDispatchTarget::Component) + .map_err(|details| ToolDiscoveryError::InconsistentSnapshot { details }), + ToolSource::Host { + host_tool_id, + implementation_version, + } => Ok(ToolDispatchTarget::Host { + host_tool_id, + implementation_version, + deployment_revision: self.registered_tool.deployment_revision, provision: self.registered_tool.provision, binding: Box::new(self.binding), - }, - self.filesystem, - ) - .map_err(|details| ToolDiscoveryError::InconsistentSnapshot { details }) + filesystem: self.filesystem, + }), + } } } @@ -204,9 +251,9 @@ pub fn get_tool_activation_from_deployment( deployment: Option<&ToolDeploymentState>, agent_type: &AgentTypeName, tool_name: &ToolName, -) -> Result, ToolDiscoveryError> { +) -> Result { let Some(deployment) = deployment else { - return Ok(None); + return Ok(ToolActivationOutcome::NotRegistered); }; let binding = deployment .agent_tool_bindings @@ -214,11 +261,14 @@ pub fn get_tool_activation_from_deployment( .and_then(|bindings| bindings.get(tool_name)); let registered_tool = deployment.registered_tools.get(tool_name); - let Some(binding) = binding else { - return Ok(None); - }; let Some(registered_tool) = registered_tool else { - return Err(ToolDiscoveryError::dangling_binding(agent_type, tool_name)); + return match binding { + Some(_) => Err(ToolDiscoveryError::dangling_binding(agent_type, tool_name)), + None => Ok(ToolActivationOutcome::NotRegistered), + }; + }; + let Some(binding) = binding else { + return Ok(ToolActivationOutcome::NotBound); }; let consistent = registered_tool.deployment_revision == deployment.deployment_revision @@ -231,6 +281,8 @@ pub fn get_tool_activation_from_deployment( && binding.tool_name == *tool_name && binding.version == registered_tool.definition.version && binding.metadata_version == registered_tool.metadata_version + && binding.release_id == registered_tool.release_id + && binding.metadata_digest == registered_tool.metadata_digest && binding.account_id == registered_tool.owner_account_id && binding.account_email == registered_tool.owner_account_email && binding.source == registered_tool.source @@ -267,11 +319,13 @@ pub fn get_tool_activation_from_deployment( } }; - Ok(Some(ToolActivationSnapshot { - filesystem, - registered_tool: registered_tool.clone(), - binding: binding.clone(), - })) + Ok(ToolActivationOutcome::Ready(Box::new( + ToolActivationSnapshot { + filesystem, + registered_tool: registered_tool.clone(), + binding: binding.clone(), + }, + ))) } impl From for ToolDiscoverySnapshot { @@ -369,30 +423,15 @@ pub trait EnvironmentStateService: Send + Sync { environment_id: EnvironmentId, ) -> Result, WorkerExecutorError>; - async fn get_registered_tool( - &self, - _environment_id: EnvironmentId, - _tool_name: &ToolName, - ) -> Result, WorkerExecutorError> { - Ok(None) - } - - async fn get_agent_tool_binding( - &self, - _environment_id: EnvironmentId, - _agent_type: &AgentTypeName, - _tool_name: &ToolName, - ) -> Result, WorkerExecutorError> { - Ok(None) - } - async fn get_tool_activation( &self, _environment_id: EnvironmentId, + _component_id: ComponentId, + _component_revision: ComponentRevision, _agent_type: &AgentTypeName, _tool_name: &ToolName, - ) -> Result, ToolDiscoveryError> { - Ok(None) + ) -> Result { + Ok(ToolActivationOutcome::NotRegistered) } async fn get_accessible_tools( @@ -475,12 +514,12 @@ impl GrpcEnvironmentStateService { .await } - async fn get_tool_discovery_snapshot( + async fn get_tool_deployment_snapshot( &self, environment_id: EnvironmentId, component_id: ComponentId, component_revision: ComponentRevision, - ) -> Result>, WorkerExecutorError> { + ) -> Result>, WorkerExecutorError> { let key = (environment_id, component_id, component_revision); let client = self.client.clone(); self.cached_tool_discovery @@ -544,43 +583,19 @@ impl EnvironmentStateService for GrpcEnvironmentStateService { Ok(environment_state.retry_policies.clone()) } - async fn get_registered_tool( - &self, - environment_id: EnvironmentId, - tool_name: &ToolName, - ) -> Result, WorkerExecutorError> { - let environment_state = self.get_environment_state(environment_id).await?; - Ok(environment_state - .tool_deployment - .as_ref() - .and_then(|deployment| deployment.registered_tools.get(tool_name)) - .cloned()) - } - - async fn get_agent_tool_binding( - &self, - environment_id: EnvironmentId, - agent_type: &AgentTypeName, - tool_name: &ToolName, - ) -> Result, WorkerExecutorError> { - let environment_state = self.get_environment_state(environment_id).await?; - Ok(environment_state - .tool_deployment - .as_ref() - .and_then(|deployment| deployment.agent_tool_bindings.get(agent_type)) - .and_then(|bindings| bindings.get(tool_name)) - .cloned()) - } - async fn get_tool_activation( &self, environment_id: EnvironmentId, + component_id: ComponentId, + component_revision: ComponentRevision, agent_type: &AgentTypeName, tool_name: &ToolName, - ) -> Result, ToolDiscoveryError> { - let environment_state = self.get_environment_state(environment_id).await?; + ) -> Result { + let snapshot = self + .get_tool_deployment_snapshot(environment_id, component_id, component_revision) + .await?; get_tool_activation_from_deployment( - environment_state.tool_deployment.as_ref(), + snapshot.as_deref().map(|snapshot| &snapshot.state), agent_type, tool_name, ) @@ -594,9 +609,12 @@ impl EnvironmentStateService for GrpcEnvironmentStateService { agent_type: &AgentTypeName, ) -> Result>, ToolDiscoveryError> { let snapshot = self - .get_tool_discovery_snapshot(environment_id, component_id, component_revision) + .get_tool_deployment_snapshot(environment_id, component_id, component_revision) .await?; - get_accessible_tools_from_snapshot(snapshot.as_deref(), agent_type) + get_accessible_tools_from_snapshot( + snapshot.as_deref().map(|snapshot| &snapshot.discovery), + agent_type, + ) } async fn get_accessible_tool( @@ -608,9 +626,13 @@ impl EnvironmentStateService for GrpcEnvironmentStateService { tool_name: &ToolName, ) -> Result>, ToolDiscoveryError> { let snapshot = self - .get_tool_discovery_snapshot(environment_id, component_id, component_revision) + .get_tool_deployment_snapshot(environment_id, component_id, component_revision) .await?; - get_accessible_tool_from_snapshot(snapshot.as_deref(), agent_type, tool_name) + get_accessible_tool_from_snapshot( + snapshot.as_deref().map(|snapshot| &snapshot.discovery), + agent_type, + tool_name, + ) } async fn invalidate_environment(&self, environment_id: EnvironmentId) { @@ -632,7 +654,8 @@ impl EnvironmentStateService for GrpcEnvironmentStateService { #[cfg(test)] mod tests { use super::{ - ToolDiscoveryCache, ToolDiscoveryError, ToolDiscoverySnapshot, + CachedToolDeployment, ToolActivationOutcome, ToolActivationSnapshot, ToolDiscoveryCache, + ToolDiscoveryError, ToolDiscoverySnapshot, ToolDispatchTarget, get_accessible_tool_from_snapshot, get_accessible_tools_from_snapshot, get_tool_activation_from_deployment, }; @@ -643,10 +666,12 @@ mod tests { InitialAgentFile, }; use golem_common::model::deployment::DeploymentRevision; - use golem_common::model::entity::FilesystemCapability; + use golem_common::model::entity::{ + EntityActivationPolicy, ExecutableTarget, FilesystemCapability, + }; use golem_common::model::json::NormalizedJsonValue; use golem_common::model::tool::{ - CompiledToolBinding, RegisteredTool, SecretKeyScope, ToolDeploymentState, + CompiledToolBinding, HostToolId, RegisteredTool, SecretKeyScope, ToolDeploymentState, ToolFilesystemAccess, ToolName, ToolProvisionConfig, ToolSource, }; use golem_common::schema::SchemaGraph; @@ -659,6 +684,7 @@ mod tests { fn registered_tool(name: &str, deployment_revision: DeploymentRevision) -> RegisteredTool { RegisteredTool { deployment_revision, + release_id: None, definition: Tool { version: "1.0.0".to_string(), commands: CommandTree { @@ -682,6 +708,7 @@ mod tests { owner_account_id: AccountId::new(), owner_account_email: AccountEmail::new("owner@example.com"), metadata_version: "0.1.0".to_string(), + metadata_digest: Default::default(), } } @@ -692,10 +719,12 @@ mod tests { ) -> CompiledToolBinding { CompiledToolBinding { deployment_revision: registered_tool.deployment_revision, + release_id: registered_tool.release_id, agent_type_name: agent_type.clone(), tool_name: tool_name.clone(), version: registered_tool.definition.version.clone(), metadata_version: registered_tool.metadata_version.clone(), + metadata_digest: registered_tool.metadata_digest, account_id: registered_tool.owner_account_id, account_email: registered_tool.owner_account_email.clone(), parameters: NormalizedJsonValue::new(serde_json::json!({})), @@ -744,14 +773,27 @@ mod tests { ) } + fn ready_activation( + deployment: &ToolDeploymentState, + agent_type: &AgentTypeName, + tool_name: &ToolName, + ) -> ToolActivationSnapshot { + match get_tool_activation_from_deployment(Some(deployment), agent_type, tool_name).unwrap() + { + ToolActivationOutcome::Ready(activation) => *activation, + outcome => panic!("expected ready activation, got {outcome:?}"), + } + } + #[test] fn accessible_tools_join_bindings_and_registrations_in_name_order() { let (deployment, agent_a, agent_b) = deployment_state(); let alpha = ToolName::try_from("alpha").unwrap(); let beta = ToolName::try_from("beta").unwrap(); - let ToolSource::Component { component_id, .. } = - &deployment.registered_tools[&alpha].source; - let expected_alpha_component = *component_id; + let expected_alpha_component = match &deployment.registered_tools[&alpha].source { + ToolSource::Component { component_id, .. } => *component_id, + ToolSource::Host { .. } => panic!("test fixture must be component-backed"), + }; let snapshot = ToolDiscoverySnapshot::from(deployment); let agent_a_tools = get_accessible_tools_from_snapshot(Some(&snapshot), &agent_a).unwrap(); @@ -876,27 +918,89 @@ mod tests { } #[test] - fn activation_lookup_returns_one_coherent_registration_and_binding() { - let (deployment, agent_a, _) = deployment_state(); + fn component_dispatch_uses_one_pinned_consumer_snapshot() { + let (mut deployment, agent_a, _) = deployment_state(); let alpha = ToolName::try_from("alpha").unwrap(); - let activation = get_tool_activation_from_deployment(Some(&deployment), &agent_a, &alpha) - .unwrap() - .unwrap(); + let activation = ready_activation(&deployment, &agent_a, &alpha); + let registered = activation.registered_tool().clone(); + let binding = activation.binding().clone(); + let expected_executable = match ®istered.source { + ToolSource::Component { + component_id, + component_revision, + .. + } => ExecutableTarget::new(*component_id, *component_revision), + ToolSource::Host { .. } => panic!("test fixture must be component-backed"), + }; + deployment.registered_tools.clear(); + deployment.agent_tool_bindings.clear(); + let ToolDispatchTarget::Component(entity) = activation.into_dispatch_target().unwrap() + else { + panic!("component source must dispatch through component activation") + }; + assert_eq!(entity.executable(), &expected_executable); + assert_eq!(entity.deployment_revision(), registered.deployment_revision); + assert_eq!(entity.filesystem(), FilesystemCapability::Incapable); assert_eq!( - activation.registered_tool, - deployment.registered_tools[&alpha] - ); - assert_eq!( - activation.binding, - deployment.agent_tool_bindings[&agent_a][&alpha] + entity.policy(), + &EntityActivationPolicy::Tool { + provision: registered.provision, + binding: Box::new(binding), + } ); - assert_eq!(activation.filesystem, FilesystemCapability::Incapable); - assert_eq!( - activation.into_entity_activation().unwrap().filesystem(), - FilesystemCapability::Incapable + } + + #[test] + fn host_dispatch_preserves_exact_handler_and_consumer_policy() { + let (mut deployment, agent_a, _) = deployment_state(); + let alpha = ToolName::try_from("alpha").unwrap(); + let host_tool_id = HostToolId::try_from("native-search".to_string()).unwrap(); + let implementation_version = "2026.08.28".to_string(); + let registered = deployment.registered_tools.get_mut(&alpha).unwrap(); + registered.source = ToolSource::Host { + host_tool_id: host_tool_id.clone(), + implementation_version: implementation_version.clone(), + }; + registered.provision.env.insert( + "CONSUMER_CONFIGURATION".to_string(), + "preserved".to_string(), ); + let binding = deployment + .agent_tool_bindings + .get_mut(&agent_a) + .unwrap() + .get_mut(&alpha) + .unwrap(); + binding.source = registered.source.clone(); + binding.parameters = NormalizedJsonValue::new(serde_json::json!({ + "consumer": "parameters" + })); + binding.filesystem_access = ToolFilesystemAccess::Allowed; + let expected_provision = registered.provision.clone(); + let expected_binding = binding.clone(); + let expected_revision = deployment.deployment_revision; + + let activation = ready_activation(&deployment, &agent_a, &alpha); + let ToolDispatchTarget::Host { + host_tool_id: actual_host_tool_id, + implementation_version: actual_implementation_version, + deployment_revision, + provision, + binding, + filesystem, + } = activation.into_dispatch_target().unwrap() + else { + panic!("host source must dispatch directly without a component activation") + }; + + assert_eq!(actual_host_tool_id, host_tool_id); + assert_eq!(actual_implementation_version, implementation_version); + assert_eq!(deployment_revision, expected_revision); + assert_eq!(provision, expected_provision); + assert_eq!(*binding, expected_binding); + assert_eq!(filesystem, FilesystemCapability::Capable); } #[test] @@ -911,13 +1015,31 @@ mod tests { .unwrap() .filesystem_access = ToolFilesystemAccess::Allowed; - let activation = get_tool_activation_from_deployment(Some(&deployment), &agent_a, &alpha) - .unwrap() - .unwrap(); + let activation = ready_activation(&deployment, &agent_a, &alpha); assert_eq!(activation.filesystem(), FilesystemCapability::Capable); } + #[test] + fn activation_lookup_distinguishes_not_registered_from_not_bound() { + let (deployment, agent_a, _) = deployment_state(); + let unbound = ToolName::try_from("unbound").unwrap(); + let missing = ToolName::try_from("missing").unwrap(); + + assert_eq!( + get_tool_activation_from_deployment(Some(&deployment), &agent_a, &unbound).unwrap(), + ToolActivationOutcome::NotBound + ); + assert_eq!( + get_tool_activation_from_deployment(Some(&deployment), &agent_a, &missing).unwrap(), + ToolActivationOutcome::NotRegistered + ); + assert_eq!( + get_tool_activation_from_deployment(None, &agent_a, &unbound).unwrap(), + ToolActivationOutcome::NotRegistered + ); + } + #[test] fn activation_lookup_rejects_files_with_explicit_filesystem_denial() { let (mut deployment, agent_a, _) = deployment_state(); @@ -971,6 +1093,48 @@ mod tests { )); } + #[test] + fn activation_lookup_rejects_mismatched_release_identity() { + let (mut deployment, agent_a, _) = deployment_state(); + let alpha = ToolName::try_from("alpha").unwrap(); + deployment + .agent_tool_bindings + .get_mut(&agent_a) + .unwrap() + .get_mut(&alpha) + .unwrap() + .release_id = Some(golem_common::model::tool_release::ToolReleaseId::new()); + + assert!(matches!( + get_tool_activation_from_deployment(Some(&deployment), &agent_a, &alpha), + Err(ToolDiscoveryError::InconsistentSnapshot { .. }) + )); + } + + #[test] + fn activation_lookup_rejects_mismatched_metadata_digest() { + let (mut deployment, agent_a, _) = deployment_state(); + let alpha = ToolName::try_from("alpha").unwrap(); + let registered = &deployment.registered_tools[&alpha]; + let mismatched_digest = golem_common::model::tool_release::tool_metadata_digest( + "other-metadata-version", + ®istered.definition, + ) + .unwrap(); + deployment + .agent_tool_bindings + .get_mut(&agent_a) + .unwrap() + .get_mut(&alpha) + .unwrap() + .metadata_digest = mismatched_digest; + + assert!(matches!( + get_tool_activation_from_deployment(Some(&deployment), &agent_a, &alpha), + Err(ToolDiscoveryError::InconsistentSnapshot { .. }) + )); + } + #[test] fn activation_lookup_rejects_registration_under_the_wrong_name() { let (mut deployment, agent_a, _) = deployment_state(); @@ -1022,8 +1186,8 @@ mod tests { ComponentId::new(), ComponentRevision::try_from(1_u64).unwrap(), ); - let stale_snapshot = Arc::new(ToolDiscoverySnapshot::from(deployment_state().0)); - let fresh_snapshot = Arc::new(ToolDiscoverySnapshot::from(deployment_state().0)); + let stale_snapshot = Arc::new(CachedToolDeployment::from(deployment_state().0)); + let fresh_snapshot = Arc::new(CachedToolDeployment::from(deployment_state().0)); let lookup_started = Arc::new(tokio::sync::Notify::new()); let release_lookup = Arc::new(tokio::sync::Notify::new()); @@ -1094,8 +1258,8 @@ mod tests { ComponentId::new(), ComponentRevision::try_from(1_u64).unwrap(), ); - let stale_snapshot = Arc::new(ToolDiscoverySnapshot::from(deployment_state().0)); - let fresh_snapshot = Arc::new(ToolDiscoverySnapshot::from(deployment_state().0)); + let stale_snapshot = Arc::new(CachedToolDeployment::from(deployment_state().0)); + let fresh_snapshot = Arc::new(CachedToolDeployment::from(deployment_state().0)); let lookup_started = Arc::new(tokio::sync::Notify::new()); let release_lookup = Arc::new(tokio::sync::Notify::new()); @@ -1162,8 +1326,8 @@ mod tests { ComponentId::new(), ComponentRevision::try_from(1_u64).unwrap(), ); - let stale_snapshot = Arc::new(ToolDiscoverySnapshot::from(deployment_state().0)); - let fresh_snapshot = Arc::new(ToolDiscoverySnapshot::from(deployment_state().0)); + let stale_snapshot = Arc::new(CachedToolDeployment::from(deployment_state().0)); + let fresh_snapshot = Arc::new(CachedToolDeployment::from(deployment_state().0)); let loaded_stale_snapshot = cache .get_or_insert(&key, { diff --git a/golem-worker-executor/tests/instance_layer.rs b/golem-worker-executor/tests/instance_layer.rs index 94b3c2dc17..ccdc748169 100644 --- a/golem-worker-executor/tests/instance_layer.rs +++ b/golem-worker-executor/tests/instance_layer.rs @@ -353,10 +353,12 @@ fn activation_with_policy( }; let binding = CompiledToolBinding { deployment_revision, + release_id: None, agent_type_name, tool_name, version: "1.0.0".to_string(), metadata_version: "0.1.0".to_string(), + metadata_digest: Default::default(), account_id, account_email: AccountEmail::new("test@golem"), parameters: NormalizedJsonValue::new(serde_json::json!({})), diff --git a/golem-worker-executor/tests/rpc.rs b/golem-worker-executor/tests/rpc.rs index 3472c1c948..8b25006fa7 100644 --- a/golem-worker-executor/tests/rpc.rs +++ b/golem-worker-executor/tests/rpc.rs @@ -534,7 +534,9 @@ async fn read_nested_sibling_output( .await? .into_inner(); let mut root_stream_ids = BTreeSet::new(); + let mut observed_root_stream_ids = BTreeSet::new(); let mut labels_by_nested_stream = BTreeMap::new(); + let mut pending_values_by_nested_stream = BTreeMap::>::new(); let mut durable_stream_ids = BTreeMap::new(); let mut values = BTreeMap::>::new(); let mut terminal_count = 0; @@ -578,13 +580,11 @@ async fn read_nested_sibling_output( assert_eq!(mapped_stream_ids, root_stream_ids); } Some(invocation_response::Response::OutputItem(item)) => { - if root_stream_ids.contains(&item.transport_stream_id) { - let Some(value) = item.value else { - anyhow::bail!("nested sibling item has no value"); - }; - let Some(schema_value::Value::RecordValue(record)) = value.value else { - anyhow::bail!("nested sibling item is not a record"); - }; + let Some(value) = item.value.and_then(|value| value.value) else { + anyhow::bail!("nested sibling item has no value"); + }; + if let schema_value::Value::RecordValue(record) = value { + observed_root_stream_ids.insert(item.transport_stream_id); let [label, nested] = record.fields.as_slice() else { anyhow::bail!("nested sibling item does not have two fields"); }; @@ -619,27 +619,30 @@ async fn read_nested_sibling_output( { anyhow::bail!("nested sibling label was mapped twice"); } - values.insert(label.clone(), Vec::new()); + values.insert( + label.clone(), + pending_values_by_nested_stream + .remove(&nested.stream_id) + .unwrap_or_default(), + ); } else { - let label = labels_by_nested_stream - .get(&item.transport_stream_id) - .ok_or_else(|| { - anyhow::anyhow!( - "item arrived for unknown nested sibling stream {}", - item.transport_stream_id - ) - })? - .clone(); - let value = match item.value.and_then(|value| value.value) { - Some(schema_value::Value::U32Value(value)) => value, + let value = match value { + schema_value::Value::U32Value(value) => value, other => { anyhow::bail!("expected a nested sibling u32 item, got {other:?}") } }; - values - .get_mut(&label) - .expect("known nested sibling label has a value list") - .push(value); + if let Some(label) = labels_by_nested_stream.get(&item.transport_stream_id) { + values + .get_mut(label) + .expect("known nested sibling label has a value list") + .push(value); + } else { + pending_values_by_nested_stream + .entry(item.transport_stream_id) + .or_default() + .push(value); + } } } Some(invocation_response::Response::OutputEnd(_)) => terminal_count += 1, @@ -660,6 +663,8 @@ async fn read_nested_sibling_output( assert!(state.is_complete()); assert!(finished_successfully); assert_eq!(terminal_count, 4); + assert_eq!(observed_root_stream_ids, root_stream_ids); + assert!(pending_values_by_nested_stream.is_empty()); assert_eq!(durable_stream_ids.len(), 2); assert_eq!(values.get("left"), Some(&vec![1, 2])); assert_eq!(values.get("right"), Some(&vec![10, 20, 30])); diff --git a/golem-worker-executor/tests/tool_discovery.rs b/golem-worker-executor/tests/tool_discovery.rs index fa72889fc9..81bdf6d26e 100644 --- a/golem-worker-executor/tests/tool_discovery.rs +++ b/golem-worker-executor/tests/tool_discovery.rs @@ -20,7 +20,7 @@ use golem_common::model::deployment::DeploymentRevision; use golem_common::model::environment::EnvironmentId; use golem_common::model::json::NormalizedJsonValue; use golem_common::model::tool::{ - CompiledToolBinding, RegisteredTool, SecretKeyScope, ToolDeploymentState, ToolName, + CompiledToolBinding, HostToolId, RegisteredTool, SecretKeyScope, ToolDeploymentState, ToolName, ToolProvisionConfig, ToolSource, }; use golem_common::schema::SchemaGraph; @@ -56,6 +56,7 @@ fn registered_tool( ) -> RegisteredTool { RegisteredTool { deployment_revision, + release_id: None, definition: Tool { version: "1.0.0".to_string(), commands: CommandTree { @@ -92,6 +93,7 @@ fn registered_tool( owner_account_id: AccountId::new(), owner_account_email: AccountEmail::new("test@golem"), metadata_version: "0.1.0".to_string(), + metadata_digest: Default::default(), } } @@ -102,10 +104,12 @@ fn binding( ) -> CompiledToolBinding { CompiledToolBinding { deployment_revision: tool.deployment_revision, + release_id: tool.release_id, agent_type_name: agent_type.clone(), tool_name: tool_name.clone(), version: tool.definition.version.clone(), metadata_version: tool.metadata_version.clone(), + metadata_digest: tool.metadata_digest, account_id: tool.owner_account_id, account_email: tool.owner_account_email.clone(), parameters: NormalizedJsonValue::new(serde_json::json!({})), @@ -428,6 +432,161 @@ async fn tool_discovery_host_filters_and_uses_caller_deployment_scope( Ok(()) } +#[test] +#[tracing::instrument] +#[timeout("2m")] +async fn tool_invocation_uses_caller_owned_tagged_snapshot_dispatch( + last_unique_id: &LastUniqueId, + deps: &WorkerExecutorTestDependencies, + #[tagged_as("host_api_tests")] host_api_tests: &PrecompiledComponent, + _tracing: &Tracing, +) -> anyhow::Result<()> { + let context = TestContext::new(last_unique_id); + let service = Arc::new(TestEnvironmentStateService::default()); + let executor = start_with_overrides( + deps, + &context, + TestExecutorOverrides { + environment_state_service: Some(service.clone()), + ..Default::default() + }, + ) + .await?; + let component = executor + .component_dep(&context.default_environment_id, host_api_tests) + .store() + .await?; + let agent_type = AgentTypeName("GolemHostApi".to_string()); + let tool_name = ToolName::try_from("remote-search").unwrap(); + let publisher_account_id = AccountId::new(); + let publisher_account_email = AccountEmail::new("publisher@example.com"); + assert_ne!(publisher_account_id, context.account_id); + + let mut component_deployment = deployment_state( + &agent_type, + 1, + &[(tool_name.as_str(), ComponentId::new(), true)], + ); + let registered = component_deployment + .registered_tools + .get_mut(&tool_name) + .unwrap(); + registered.owner_account_id = publisher_account_id; + registered.owner_account_email = publisher_account_email.clone(); + let binding = component_deployment + .agent_tool_bindings + .get_mut(&agent_type) + .unwrap() + .get_mut(&tool_name) + .unwrap(); + binding.account_id = publisher_account_id; + binding.account_email = publisher_account_email.clone(); + service.set_tool_deployment( + context.default_environment_id, + component.id, + component.revision, + Some(component_deployment), + ); + + let component_caller = agent_id!("GolemHostApi", "component-snapshot-dispatch"); + executor + .start_agent(&component.id, component_caller.clone()) + .await?; + let component_result = executor + .invoke_and_await_agent( + &component, + &component_caller, + "tool_rpc_invoke_and_await_result", + data_value!(tool_name.as_str(), Vec::::new(), String::new()), + ) + .await? + .into_typed::>()?; + assert!( + component_result.as_ref().is_err_and(|error| { + error.contains("RemoteInternalError") + && error.contains("sidecar invocation backend") + && !error.contains("Denied") + }), + "cross-account component source must pass caller-owned authorization and reach dispatch: {component_result:?}" + ); + + let updated_component = executor + .update_component(&component.id, &host_api_tests.wasm_name) + .await?; + let mut host_deployment = deployment_state( + &agent_type, + 2, + &[(tool_name.as_str(), ComponentId::new(), true)], + ); + let host_source = ToolSource::Host { + host_tool_id: HostToolId::try_from("native-search".to_string()).unwrap(), + implementation_version: "2026.08.28".to_string(), + }; + let registered = host_deployment + .registered_tools + .get_mut(&tool_name) + .unwrap(); + registered.owner_account_id = publisher_account_id; + registered.owner_account_email = publisher_account_email.clone(); + registered.source = host_source.clone(); + let binding = host_deployment + .agent_tool_bindings + .get_mut(&agent_type) + .unwrap() + .get_mut(&tool_name) + .unwrap(); + binding.account_id = publisher_account_id; + binding.account_email = publisher_account_email; + binding.source = host_source; + service.set_tool_deployment( + context.default_environment_id, + updated_component.id, + updated_component.revision, + Some(host_deployment), + ); + + let host_caller = agent_id!("GolemHostApi", "host-snapshot-dispatch"); + executor + .start_agent(&updated_component.id, host_caller.clone()) + .await?; + let host_result = executor + .invoke_and_await_agent( + &updated_component, + &host_caller, + "tool_rpc_invoke_and_await_result", + data_value!(tool_name.as_str(), Vec::::new(), String::new()), + ) + .await? + .into_typed::>()?; + assert!( + host_result.as_ref().is_err_and(|error| { + error.contains("RemoteInternalError") + && error.contains("sidecar invocation backend") + && !error.contains("host tool dispatch backend") + && !error.contains("Denied") + }), + "host source must pass shared admission and reach tagged dispatch: {host_result:?}" + ); + assert_eq!(service.tool_activation_calls(), 2); + assert_eq!( + service.tool_activation_lookups(), + vec![ + ( + context.default_environment_id, + component.id, + component.revision, + ), + ( + context.default_environment_id, + updated_component.id, + updated_component.revision, + ), + ] + ); + + Ok(()) +} + #[test] #[tracing::instrument] #[timeout("2m")] diff --git a/integration-tests/tests/api/deployment.rs b/integration-tests/tests/api/deployment.rs index 7af627d364..a40dd7b69d 100644 --- a/integration-tests/tests/api/deployment.rs +++ b/integration-tests/tests/api/deployment.rs @@ -14,35 +14,194 @@ use golem_client::api::{ RegistryServiceClient, RegistryServiceDeployEnvironmentError, - RegistryServiceRollbackEnvironmentError, + RegistryServiceGetToolReleaseError, RegistryServiceRollbackEnvironmentError, }; use golem_client::model::DeploymentCreation; use golem_common::model::agent::AgentTypeName; -use golem_common::model::agent_secret::{AgentSecretCreation, AgentSecretPath}; +use golem_common::model::agent_secret::{ + AgentSecretCreation, AgentSecretPath, CanonicalAgentSecretPath, +}; use golem_common::model::component::{ - AgentTypeProvisionConfigUpdate, ComponentName, ComponentUpdate, + AgentTypeProvisionConfigUpdate, ComponentCreation, ComponentName, ComponentUpdate, + ToolDeploymentConfigCreation, ToolDeploymentConfigUpdate, ToolProvisionConfigCreation, }; use golem_common::model::deployment::{ - DeploymentAgentSecretDefault, DeploymentRollback, DeploymentVersion, + DeploymentAgentSecretDefault, DeploymentPlan, DeploymentRollback, DeploymentVersion, +}; +use golem_common::model::diff::{ + EffectiveToolBinding, RemoteToolDeployment as DiffRemoteToolDeployment, }; -use golem_common::model::diff::Hash; +use golem_common::model::diff::{Hash, Hashable}; use golem_common::model::domain_registration::{Domain, DomainRegistrationCreation}; use golem_common::model::environment::EnvironmentCurrentDeploymentView; use golem_common::model::environment::EnvironmentUpdate; +use golem_common::model::environment_tool_grant::{ + EnvironmentToolGrantCreation, EnvironmentToolGrantWithDetails, +}; use golem_common::model::http_api_deployment::{ HttpApiDeploymentAgentOptions, HttpApiDeploymentCreation, }; +use golem_common::model::json::NormalizedJsonValue; +use golem_common::model::optional_field_update::OptionalFieldUpdate; +use golem_common::model::tool::{ + RemoteToolDeployment, SecretKeyScope, ToolBindingInput, ToolFilesystemAccess, ToolName, + ToolProvisionConfig, +}; +use golem_common::model::tool_release::{ + ToolReleaseByCoordinates, ToolReleaseById, ToolReleaseLifecycle, ToolReleaseReference, +}; +use golem_common::schema::tool::{ + CommandBody, CommandNode, CommandTree, Doc, Globals, Positionals, Tool, +}; use golem_common::schema::validation::is_equivalent_cross_graph; use golem_common::schema::{SchemaGraph, SchemaType, SchemaValue}; +use golem_common::{agent_id, data_value}; use golem_test_framework::config::{EnvBasedTestDependencies, TestDependencies}; use golem_test_framework::dsl::{TestDsl, TestDslExtended}; use pretty_assertions::{assert_eq, assert_matches, assert_ne}; use serde_json::json; -use std::collections::BTreeMap; -use test_r::{inherit_test_dep, test}; +use std::collections::{BTreeMap, BTreeSet}; +use test_r::{inherit_test_dep, test, timeout}; +use tokio::fs::File; inherit_test_dep!(EnvBasedTestDependencies); +fn cross_account_tool(version: &str) -> Tool { + Tool { + version: version.to_string(), + commands: CommandTree { + nodes: vec![CommandNode { + name: "search".to_string(), + aliases: Vec::new(), + doc: Doc::default(), + globals: Globals::default(), + subcommands: Vec::new(), + body: Some(CommandBody { + positionals: Positionals::default(), + options: Vec::new(), + flags: Vec::new(), + constraints: Vec::new(), + stdin: None, + stdout: None, + result: None, + errors: Vec::new(), + annotations: None, + }), + }], + }, + schema: SchemaGraph::empty(), + } +} + +fn publisher_tool_config() -> ToolDeploymentConfigCreation { + ToolDeploymentConfigCreation { + provision: ToolProvisionConfigCreation { + config: NormalizedJsonValue::new(json!({})), + env: BTreeMap::new(), + plugin_installations: Vec::new(), + files: BTreeMap::new(), + }, + environment_binding: None, + agent_bindings: BTreeMap::new(), + } +} + +fn consumer_secret_scope() -> SecretKeyScope { + SecretKeyScope::Keys(BTreeSet::from([CanonicalAgentSecretPath(vec![ + "search".to_string(), + "token".to_string(), + ])])) +} + +fn remote_tool_request( + release: ToolReleaseReference, + bind_to_host_api: bool, +) -> RemoteToolDeployment { + let agent_bindings = if bind_to_host_api { + BTreeMap::from([( + AgentTypeName("GolemHostApi".to_string()), + ToolBindingInput { + version: None, + parameters: NormalizedJsonValue::new(json!({"index": "consumer-documents"})), + account: None, + secret_keys_readable: consumer_secret_scope(), + secret_keys_revealable: consumer_secret_scope(), + }, + )]) + } else { + BTreeMap::new() + }; + RemoteToolDeployment { + name: ToolName::try_from("search").unwrap(), + release, + provision: ToolProvisionConfig { + config: NormalizedJsonValue::new(json!({"tenant": "consumer"})), + env: BTreeMap::from([("LOG_LEVEL".to_string(), "debug".to_string())]), + plugins: Vec::new(), + files: Vec::new(), + }, + environment_binding: None, + agent_bindings, + } +} + +fn remote_tool_hash_input( + grant: &EnvironmentToolGrantWithDetails, + bind_to_host_api: bool, +) -> DiffRemoteToolDeployment { + let bindings = if bind_to_host_api { + BTreeMap::from([( + AgentTypeName("GolemHostApi".to_string()), + EffectiveToolBinding { + parameters: NormalizedJsonValue::new(json!({ + "index": "consumer-documents" + })), + secret_keys_readable: consumer_secret_scope(), + secret_keys_revealable: consumer_secret_scope(), + filesystem_access: ToolFilesystemAccess::Unset, + }, + )]) + } else { + BTreeMap::new() + }; + DiffRemoteToolDeployment { + release_id: grant.release.id, + version: grant.release.version.clone(), + source_digest: grant.release.source_digest, + owner_account_id: grant.release_owner.id, + owner_account_email: grant.release_owner.email.clone(), + metadata_version: grant.release.metadata_version.clone(), + metadata_digest: grant.release.metadata_digest, + provision: ToolProvisionConfig { + config: NormalizedJsonValue::new(json!({"tenant": "consumer"})), + env: BTreeMap::from([("LOG_LEVEL".to_string(), "debug".to_string())]), + plugins: Vec::new(), + files: Vec::new(), + }, + bindings, + } +} + +fn deployment_creation( + plan: &DeploymentPlan, + version: &str, + expected_deployment_hash: Hash, + publish_tools: Vec, + remote_tools: Vec, +) -> DeploymentCreation { + DeploymentCreation { + current_revision: plan.current_revision, + expected_deployment_hash, + version: DeploymentVersion(version.to_string()), + agent_secret_defaults: Vec::new(), + quota_resource_defaults: Vec::new(), + retry_policy_defaults: Vec::new(), + publish_tools, + remote_tools, + replace_incompatible_agent_secrets: false, + } +} + fn assert_secret_type_is_string(secret_type: &SchemaGraph) { let expected = SchemaGraph::anonymous(SchemaType::string()); assert!(is_equivalent_cross_graph( @@ -74,6 +233,8 @@ async fn deploy_environment(deps: &EnvBasedTestDependencies) -> anyhow::Result<( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -146,6 +307,8 @@ async fn deploy_rejects_reset_secret_override_when_compatibility_check_enabled( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -200,6 +363,8 @@ async fn deploy_allows_reset_secret_override_when_compatibility_check_disabled( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -233,6 +398,8 @@ async fn fail_with_409_on_hash_mismatch(deps: &EnvBasedTestDependencies) -> anyh current_revision: None, expected_deployment_hash: Hash::empty(), version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -276,6 +443,8 @@ async fn get_component_version_from_previous_deployment( current_revision: None, expected_deployment_hash: plan_1.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -318,6 +487,8 @@ async fn get_component_version_from_previous_deployment( current_revision: Some(deployment_1.current_revision), expected_deployment_hash: plan_2.deployment_hash, version: DeploymentVersion("0.0.2".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -416,6 +587,8 @@ async fn full_deployment(deps: &EnvBasedTestDependencies) -> anyhow::Result<()> current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: Vec::new(), quota_resource_defaults: Vec::new(), retry_policy_defaults: Vec::new(), @@ -617,6 +790,8 @@ async fn deploy_creates_missing_secret_from_default( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: vec![DeploymentAgentSecretDefault { path: AgentSecretPath(secret_path.clone()), secret_value: json!("foo"), @@ -683,6 +858,8 @@ async fn deploy_ignores_default_if_secret_already_exists( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: vec![DeploymentAgentSecretDefault { path: AgentSecretPath(secret_path.clone()), secret_value: json!("foo"), @@ -751,6 +928,8 @@ async fn deploy_uses_default_if_secret_already_exists_with_no_value( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: vec![DeploymentAgentSecretDefault { path: AgentSecretPath(secret_path.clone()), secret_value: json!("foo"), @@ -818,6 +997,8 @@ async fn deploy_fails_if_existing_secret_type_mismatches_default( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: vec![DeploymentAgentSecretDefault { path: AgentSecretPath(secret_path.clone()), secret_value: json!("abc"), @@ -864,6 +1045,8 @@ async fn deploy_fails_if_secret_default_mismatches_component( current_revision: None, expected_deployment_hash: plan.deployment_hash, version: DeploymentVersion("0.0.1".to_string()), + publish_tools: Vec::new(), + remote_tools: Vec::new(), agent_secret_defaults: vec![DeploymentAgentSecretDefault { path: AgentSecretPath(secret_path.clone()), secret_value: json!(false), @@ -884,3 +1067,398 @@ async fn deploy_fails_if_secret_default_mismatches_component( Ok(()) } + +#[test] +#[timeout("12m")] +#[tracing::instrument] +async fn cross_account_tool_release_lifecycle_reaches_snapshot_activation( + deps: &EnvBasedTestDependencies, +) -> anyhow::Result<()> { + let publisher = deps.user().await?.with_auto_deploy(false); + let publisher_client = publisher.registry_service_client().await; + let (_, publisher_env) = publisher.app_and_env().await?; + let consumer = deps.user().await?.with_auto_deploy(false); + let consumer_client = consumer.registry_service_client().await; + let (consumer_app, consumer_env) = consumer.app_and_env().await?; + let other_consumer_env = consumer.env(&consumer_app.id).await?; + let tool_name = ToolName::try_from("search").unwrap(); + + let publisher_component = publisher_client + .create_component( + &publisher_env.id.0, + &ComponentCreation { + component_name: ComponentName::try_from("publisher-tools:search") + .map_err(anyhow::Error::msg)?, + agent_types: Vec::new(), + agent_type_provision_configs: BTreeMap::new(), + tools: vec![cross_account_tool("1.2.0")], + tool_deployment_configs: BTreeMap::from([( + tool_name.clone(), + publisher_tool_config(), + )]), + }, + File::open( + deps.component_directory() + .join("it_agent_counters_release.wasm"), + ) + .await?, + None::, + ) + .await?; + + let publisher_plan = publisher_client + .get_environment_deployment_plan(&publisher_env.id.0) + .await?; + let mut publisher_hash_input = publisher_plan.to_diffable(); + publisher_hash_input + .published_tools + .insert(tool_name.to_string()); + publisher_client + .deploy_environment( + &publisher_env.id.0, + &deployment_creation( + &publisher_plan, + "publisher-1.2.0", + publisher_hash_input.hash()?, + vec![tool_name.clone()], + Vec::new(), + ), + ) + .await?; + + let release_v12 = publisher_client + .list_account_tool_releases(&publisher.account_id.0) + .await? + .values + .into_iter() + .find(|release| release.name == tool_name && release.version == "1.2.0") + .expect("publisher deployment must create search@1.2.0"); + assert_eq!(release_v12.lifecycle, ToolReleaseLifecycle::Published); + + let published_source_deletion = publisher_client + .delete_component( + &publisher_component.id.0, + publisher_component.revision.into(), + ) + .await; + assert!( + published_source_deletion.is_err(), + "a component revision referenced by a tool release and deployment snapshot must not be deleted" + ); + + assert_matches!( + consumer_client.get_tool_release(&release_v12.id.0).await, + Err(golem_client::Error::Item( + RegistryServiceGetToolReleaseError::Error404(_) + )) + ); + + let release_v12_coordinates = ToolReleaseReference::ByCoordinates(ToolReleaseByCoordinates { + account: publisher.account_email.clone(), + name: tool_name.clone(), + version: "1.2.0".to_string(), + }); + let grant_v12 = consumer_client + .create_environment_tool_grant( + &consumer_env.id.0, + &EnvironmentToolGrantCreation { + release: release_v12_coordinates, + }, + ) + .await?; + assert_eq!(grant_v12.release.id, release_v12.id); + assert!( + consumer_client + .list_environment_tool_grants(&other_consumer_env.id.0) + .await? + .values + .is_empty(), + "a grant must not leak to another environment in the same account" + ); + + let other_plan = consumer_client + .get_environment_deployment_plan(&other_consumer_env.id.0) + .await?; + let other_remote_hash_input = remote_tool_hash_input(&grant_v12, false); + let mut other_hash_input = other_plan.to_diffable(); + other_hash_input.remote_tools.insert( + tool_name.to_string(), + other_remote_hash_input.clone().into(), + ); + let ungranted_deploy = consumer_client + .deploy_environment( + &other_consumer_env.id.0, + &deployment_creation( + &other_plan, + "ungranted", + other_hash_input.hash()?, + Vec::new(), + vec![remote_tool_request( + ToolReleaseReference::ById(ToolReleaseById { + release_id: release_v12.id, + }), + false, + )], + ), + ) + .await; + assert_matches!( + ungranted_deploy, + Err(golem_client::Error::Item( + RegistryServiceDeployEnvironmentError::Error400(_) + )) + ); + + let consumer_component = consumer + .component(&consumer_env.id, "golem_it_host_api_tests_release") + .name("consumer-tools:host-api") + .unique() + .store() + .await?; + let consumer_plan_v12 = consumer_client + .get_environment_deployment_plan(&consumer_env.id.0) + .await?; + let remote_v12_hash_input = remote_tool_hash_input(&grant_v12, true); + let remote_v12_hash = remote_v12_hash_input.hash()?; + let mut consumer_hash_input_v12 = consumer_plan_v12.to_diffable(); + consumer_hash_input_v12 + .remote_tools + .insert(tool_name.to_string(), remote_v12_hash_input.into()); + let consumer_deployment_v12 = consumer_client + .deploy_environment( + &consumer_env.id.0, + &deployment_creation( + &consumer_plan_v12, + "consumer-1.2.0", + consumer_hash_input_v12.hash()?, + Vec::new(), + vec![remote_tool_request( + ToolReleaseReference::ById(ToolReleaseById { + release_id: release_v12.id, + }), + true, + )], + ), + ) + .await?; + let consumer_summary_v12 = consumer_client + .get_deployment_summary(&consumer_env.id.0, consumer_deployment_v12.revision.into()) + .await?; + assert_eq!(consumer_summary_v12.remote_tools.len(), 1); + assert_eq!(consumer_summary_v12.remote_tools[0].hash, remote_v12_hash); + + let initial_agent = agent_id!("GolemHostApi", "remote-search-1-2"); + consumer + .start_agent(&consumer_component.id, initial_agent.clone()) + .await?; + let initial_result = consumer + .invoke_and_await_agent( + &consumer_component, + &initial_agent, + "tool_rpc_invoke_and_await_result", + data_value!(tool_name.as_str(), Vec::::new(), String::new()), + ) + .await? + .into_typed::>()?; + assert!( + initial_result.as_ref().is_err_and(|error| { + error.contains("RemoteInternalError") + && error.contains("sidecar invocation backend") + && !error.contains("Denied") + && !error.contains("NotFound") + }), + "a granted remote release must reach the admitted component dispatch boundary: {initial_result:?}" + ); + + let publisher_component_v13 = publisher_client + .update_component( + &publisher_component.id.0, + &ComponentUpdate { + current_revision: publisher_component.revision, + agent_types: None, + agent_type_provision_config_updates: None, + tools: Some(vec![cross_account_tool("1.3.0")]), + tool_deployment_config_updates: Some(BTreeMap::from([( + tool_name.clone(), + ToolDeploymentConfigUpdate { + provision: None, + environment_binding: OptionalFieldUpdate::NoChange, + agent_bindings: None, + }, + )])), + allow_incompatible_config: false, + }, + None::, + None::, + ) + .await?; + assert_ne!( + publisher_component_v13.revision, + publisher_component.revision + ); + let publisher_plan_v13 = publisher_client + .get_environment_deployment_plan(&publisher_env.id.0) + .await?; + let mut publisher_hash_input_v13 = publisher_plan_v13.to_diffable(); + publisher_hash_input_v13 + .published_tools + .insert(tool_name.to_string()); + publisher_client + .deploy_environment( + &publisher_env.id.0, + &deployment_creation( + &publisher_plan_v13, + "publisher-1.3.0", + publisher_hash_input_v13.hash()?, + vec![tool_name.clone()], + Vec::new(), + ), + ) + .await?; + let release_v13 = publisher_client + .list_account_tool_releases(&publisher.account_id.0) + .await? + .values + .into_iter() + .find(|release| release.name == tool_name && release.version == "1.3.0") + .expect("publisher deployment must create search@1.3.0"); + + let current_consumer_environment = consumer_client.get_environment(&consumer_env.id.0).await?; + let current_consumer_deployment = current_consumer_environment + .current_deployment + .expect("consumer environment must retain its current deployment"); + assert_eq!( + current_consumer_deployment.deployment_revision, + consumer_deployment_v12.revision + ); + let still_pinned_summary = consumer_client + .get_deployment_summary( + &consumer_env.id.0, + current_consumer_deployment.deployment_revision.into(), + ) + .await?; + assert_eq!(still_pinned_summary.remote_tools.len(), 1); + assert_eq!(still_pinned_summary.remote_tools[0].hash, remote_v12_hash); + + let grant_v13 = consumer_client + .create_environment_tool_grant( + &consumer_env.id.0, + &EnvironmentToolGrantCreation { + release: ToolReleaseReference::ById(ToolReleaseById { + release_id: release_v13.id, + }), + }, + ) + .await?; + let consumer_plan_v13 = consumer_client + .get_environment_deployment_plan(&consumer_env.id.0) + .await?; + let remote_v13_hash_input = remote_tool_hash_input(&grant_v13, true); + let remote_v13_hash = remote_v13_hash_input.hash()?; + let mut consumer_hash_input_v13 = consumer_plan_v13.to_diffable(); + consumer_hash_input_v13 + .remote_tools + .insert(tool_name.to_string(), remote_v13_hash_input.into()); + let consumer_deployment_v13 = consumer_client + .deploy_environment( + &consumer_env.id.0, + &deployment_creation( + &consumer_plan_v13, + "consumer-1.3.0", + consumer_hash_input_v13.hash()?, + Vec::new(), + vec![remote_tool_request( + ToolReleaseReference::ById(ToolReleaseById { + release_id: release_v13.id, + }), + true, + )], + ), + ) + .await?; + let consumer_summary_v13 = consumer_client + .get_deployment_summary(&consumer_env.id.0, consumer_deployment_v13.revision.into()) + .await?; + assert_eq!(consumer_summary_v13.remote_tools.len(), 1); + assert_eq!(consumer_summary_v13.remote_tools[0].hash, remote_v13_hash); + assert_ne!(remote_v13_hash, remote_v12_hash); + + consumer_client + .delete_environment_tool_grant(&grant_v13.grant.id.0) + .await?; + let revoked_agent = agent_id!("GolemHostApi", "remote-search-revoked"); + consumer + .start_agent(&consumer_component.id, revoked_agent.clone()) + .await?; + let revoked_result = consumer + .invoke_and_await_agent( + &consumer_component, + &revoked_agent, + "tool_rpc_invoke_and_await_result", + data_value!(tool_name.as_str(), Vec::::new(), String::new()), + ) + .await? + .into_typed::>()?; + assert!( + revoked_result.as_ref().is_err_and(|error| { + error.contains("RemoteInternalError") + && error.contains("sidecar invocation backend") + && !error.contains("Denied") + && !error.contains("NotFound") + }), + "grant revocation must not invalidate the current deployment snapshot: {revoked_result:?}" + ); + + consumer_client + .restore_environment_tool_grant(&grant_v13.grant.id.0) + .await?; + let de_published = publisher_client + .de_publish_tool_release(&release_v13.id.0) + .await?; + assert_eq!(de_published.lifecycle, ToolReleaseLifecycle::DePublished); + let grants_after_depublication = consumer_client + .list_environment_tool_grants(&consumer_env.id.0) + .await? + .values; + assert_eq!(grants_after_depublication.len(), 1); + assert_eq!(grants_after_depublication[0].release.id, release_v12.id); + + let de_published_agent = agent_id!("GolemHostApi", "remote-search-de-published"); + consumer + .start_agent(&consumer_component.id, de_published_agent.clone()) + .await?; + let de_published_result = consumer + .invoke_and_await_agent( + &consumer_component, + &de_published_agent, + "tool_rpc_invoke_and_await_result", + data_value!(tool_name.as_str(), Vec::::new(), String::new()), + ) + .await? + .into_typed::>()?; + assert!( + de_published_result.as_ref().is_err_and(|error| { + error.contains("RemoteInternalError") + && error.contains("sidecar invocation backend") + && !error.contains("Denied") + && !error.contains("NotFound") + }), + "de-publication must not invalidate the current deployment snapshot: {de_published_result:?}" + ); + + let restored_release = publisher_client + .restore_tool_release(&release_v13.id.0) + .await?; + assert_eq!(restored_release.lifecycle, ToolReleaseLifecycle::Published); + let grants_after_release_restore = consumer_client + .list_environment_tool_grants(&consumer_env.id.0) + .await? + .values; + assert_eq!(grants_after_release_restore.len(), 1); + assert_eq!( + grants_after_release_restore[0].release.id, release_v12.id, + "restoring a release must not restore grants tombstoned by de-publication" + ); + + Ok(()) +} diff --git a/integration-tests/tests/capabilities.rs b/integration-tests/tests/capabilities.rs index 51a0101d60..c05d9690ae 100644 --- a/integration-tests/tests/capabilities.rs +++ b/integration-tests/tests/capabilities.rs @@ -135,7 +135,8 @@ async fn quota_token_capability_round_trips_and_is_redacted( ) .await?; - tokio::time::sleep(Duration::from_secs(1)).await; + crate::quota::wait_for_http_request_count(received.as_ref(), 4, Duration::from_secs(60)) + .await?; user.wait_for_statuses( &sender_sys, diff --git a/integration-tests/tests/memory_billing.rs b/integration-tests/tests/memory_billing.rs index 59c3dd9537..f6f5ee6c21 100644 --- a/integration-tests/tests/memory_billing.rs +++ b/integration-tests/tests/memory_billing.rs @@ -78,6 +78,54 @@ mod tests { Ok(usage.usage.memory_gb_seconds) } + async fn wait_for_memory_gb_seconds( + deps: &EnvBasedTestDependencies, + user: &golem_test_framework::config::dsl_impl::TestUserContext, + minimum: u64, + ) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + + loop { + let current = memory_gb_seconds(deps, user).await?; + if current >= minimum { + return Ok(current); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for memory usage to reach {minimum} GiB-seconds; current usage is {current} GiB-seconds" + ); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + + async fn wait_for_memory_billing_to_settle( + deps: &EnvBasedTestDependencies, + user: &golem_test_framework::config::dsl_impl::TestUserContext, + ) -> anyhow::Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut last = memory_gb_seconds(deps, user).await?; + let mut unchanged_since = tokio::time::Instant::now(); + + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + let current = memory_gb_seconds(deps, user).await?; + if current != last { + last = current; + unchanged_since = tokio::time::Instant::now(); + } + if unchanged_since.elapsed() >= Duration::from_secs(1) { + return Ok(current); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "memory billing did not settle before timeout; current usage is {current} GiB-seconds" + ); + } + } + } + #[test] #[timeout("2m")] async fn permit_ownership_defines_allocated_memory_billing_window() -> anyhow::Result<()> { @@ -95,8 +143,8 @@ mod tests { user.invoke_and_await_agent(&component, &agent, "run_with_delay", data_value!(3_000u64)) .await?; - tokio::time::sleep(Duration::from_secs(1)).await; - let after_host_wait = memory_gb_seconds(&deps, &user).await?; + let after_host_wait = + wait_for_memory_gb_seconds(&deps, &user, before.saturating_add(1)).await?; assert!( after_host_wait.saturating_sub(before) >= 1, "allocated memory must accrue while a permit-owning invocation waits in a host sleep: before={before}, after={after_host_wait}" @@ -106,7 +154,7 @@ mod tests { &component, &agent, "run_with_memory_and_work", - data_value!(512u64, 5_000u64), + data_value!(512u64, 10_000u64), ); let recovery = async { user.wait_for_status(&worker, AgentStatus::Running, Duration::from_secs(10)) @@ -120,8 +168,8 @@ mod tests { invocation_result?; let before_replay = before_replay?; - tokio::time::sleep(Duration::from_secs(1)).await; - let after_replay = memory_gb_seconds(&deps, &user).await?; + let after_replay = + wait_for_memory_gb_seconds(&deps, &user, before_replay.saturating_add(2)).await?; assert!( after_replay.saturating_sub(before_replay) >= 2, "recovery of the interrupted 512 MiB workload must accrue memory after the pre-crash baseline: before={before_replay}, after={after_replay}" @@ -129,10 +177,11 @@ mod tests { user.wait_for_status(&worker, AgentStatus::Idle, Duration::from_secs(10)) .await?; + let after_permit_release = wait_for_memory_billing_to_settle(&deps, &user).await?; tokio::time::sleep(Duration::from_secs(5)).await; let after_idle = memory_gb_seconds(&deps, &user).await?; assert_eq!( - after_idle, after_replay, + after_idle, after_permit_release, "loaded-idle time after permit release must not accrue memory usage" ); diff --git a/integration-tests/tests/quota.rs b/integration-tests/tests/quota.rs index a043fbc9ce..f525b4102a 100644 --- a/integration-tests/tests/quota.rs +++ b/integration-tests/tests/quota.rs @@ -85,6 +85,29 @@ pub(crate) async fn provision_rate_resource( Ok(def) } +pub(crate) async fn wait_for_http_request_count( + received: &AtomicU64, + expected: u64, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let count = received.load(Ordering::SeqCst); + if count == expected { + return Ok(()); + } + if count > expected { + anyhow::bail!("expected exactly {expected} HTTP requests, received {count}"); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("timed out waiting for {expected} HTTP requests; received {count}"); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + /// Reserve `amount` units and commit the exact amount back. The agent should /// return the reserved and committed amounts as a record. #[test] @@ -696,7 +719,7 @@ async fn quota_token_rpc_rust( ) .await?; - tokio::time::sleep(Duration::from_secs(1)).await; + wait_for_http_request_count(received.as_ref(), 4, Duration::from_secs(60)).await?; user.wait_for_statuses( &sender_sys, @@ -795,7 +818,7 @@ async fn quota_token_rpc_ts( .await?; tracing::warn!("here2"); - tokio::time::sleep(Duration::from_secs(1)).await; + wait_for_http_request_count(received.as_ref(), 4, Duration::from_secs(60)).await?; user.wait_for_statuses( &sender_sys, @@ -877,10 +900,7 @@ async fn rate_limit_throttle_two_agents( let agent_b_sys = user.start_agent(&component.id, agent_b.clone()).await?; let mut tasks = JoinSet::new(); - for (agent_id, agent_sys_id) in [ - (agent_a.clone(), agent_a_sys.clone()), - (agent_b.clone(), agent_b_sys.clone()), - ] { + for agent_id in [agent_a, agent_b] { let user = user.clone(); let component = component.clone(); let host = host.clone(); @@ -891,15 +911,6 @@ async fn rate_limit_throttle_two_agents( "reserve_in_loop", data_value!("shared-rate".to_string(), 10u64, host, port, 4u64), ) - .await?; - - tokio::time::sleep(Duration::from_secs(1)).await; - - user.wait_for_statuses( - &agent_sys_id, - &[AgentStatus::Idle, AgentStatus::Suspended], - Duration::from_secs(60), - ) .await }); } @@ -908,6 +919,17 @@ async fn rate_limit_throttle_two_agents( r??; } + wait_for_http_request_count(received.as_ref(), 4, Duration::from_secs(60)).await?; + + for agent_system_id in [agent_a_sys, agent_b_sys] { + user.wait_for_statuses( + &agent_system_id, + &[AgentStatus::Idle, AgentStatus::Suspended], + Duration::from_secs(60), + ) + .await?; + } + http_server.abort(); let total = received.load(Ordering::SeqCst); diff --git a/openapi/golem-registry-service.yaml b/openapi/golem-registry-service.yaml index 17db51cba0..bccbe4c8c1 100644 --- a/openapi/golem-registry-service.yaml +++ b/openapi/golem-registry-service.yaml @@ -22,6 +22,7 @@ tags: - name: Deployment - name: Environment - name: EnvironmentPluginGrants +- name: EnvironmentToolGrants - name: HealthCheck - name: Login description: The login endpoints are implementing an OAuth2 flow. @@ -35,6 +36,7 @@ tags: - name: RetryPolicies - name: Token description: The token API allows creating custom access tokens for the Golem Cloud REST API to be used by tools and services. +- name: ToolReleases - name: Worker paths: /healthcheck: @@ -3844,15 +3846,15 @@ paths: - Cookie: [] - Token: [] operationId: delete_environment_plugin_grant - /v1/apps/{application_id}/envs: + /v1/envs/{environment_id}/tool-grants: post: tags: - RegistryService + - EnvironmentToolGrants - Environment - - Application - summary: Create an application environment + summary: Grant an exact published tool release to an environment parameters: - - name: application_id + - name: environment_id schema: type: string format: uuid @@ -3864,7 +3866,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/EnvironmentCreation' + $ref: '#/components/schemas/EnvironmentToolGrantCreation' required: true responses: '200': @@ -3872,7 +3874,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Environment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -3918,15 +3920,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_environment + operationId: create_environment_tool_grant get: tags: - RegistryService + - EnvironmentToolGrants - Environment - - Application - summary: List all application environments + summary: List active tool grants in an environment parameters: - - name: application_id + - name: environment_id schema: type: string format: uuid @@ -3940,7 +3942,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_Environment' + $ref: '#/components/schemas/Page_EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -3986,16 +3988,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_application_environments - /v1/apps/{application_id}/envs/{environment_name}: - get: + operationId: list_environment_tool_grants + /v1/envs/{environment_id}/tool-grants/automatic: + post: tags: - RegistryService + - EnvironmentToolGrants - Environment - - Application - summary: Get application environment by name + summary: Create an automatically managed grant required by an application deployment parameters: - - name: application_id + - name: environment_id schema: type: string format: uuid @@ -4003,20 +4005,19 @@ paths: required: true deprecated: false explode: true - - name: environment_name - schema: - type: string - in: path + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentToolGrantCreation' required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Environment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4062,13 +4063,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_application_environment - /v1/envs/{environment_id}: - get: + operationId: create_automatic_environment_tool_grant + /v1/envs/{environment_id}/tool-grants/automatic/validate: + post: tags: - RegistryService + - EnvironmentToolGrants - Environment - summary: Get environment by id. + summary: Validate an automatically managed grant reconciliation without changing any grants parameters: - name: environment_id schema: @@ -4078,13 +4080,15 @@ paths: required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentToolGrantReconciliation' + required: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4130,14 +4134,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_environment - patch: + operationId: validate_automatic_environment_tool_grant_reconciliation + /v1/environment-tool-grants/{grant_id}: + get: tags: - RegistryService - - Environment - summary: Update environment by id. + - EnvironmentToolGrants + summary: Get an active environment tool grant parameters: - - name: environment_id + - name: grant_id schema: type: string format: uuid @@ -4145,19 +4150,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/EnvironmentUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Environment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4203,14 +4202,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_environment + operationId: get_environment_tool_grant delete: tags: - RegistryService - - Environment - summary: Delete environment by id. + - EnvironmentToolGrants + summary: Delete an environment tool grant parameters: - - name: environment_id + - name: grant_id schema: type: string format: uuid @@ -4218,14 +4217,6 @@ paths: required: true deprecated: false explode: true - - name: current_revision - schema: - type: integer - format: uint64 - in: query - required: true - deprecated: false - explode: true responses: '204': description: '' @@ -4274,15 +4265,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_environment - /v1/envs/{environment_id}/plan: - get: + operationId: delete_environment_tool_grant + /v1/environment-tool-grants/{grant_id}/automatic: + delete: tags: - RegistryService - - Environment - summary: Get the current deployment plan + - EnvironmentToolGrants + summary: Delete an environment tool grant only if it is automatically managed parameters: - - name: environment_id + - name: grant_id schema: type: string format: uuid @@ -4291,12 +4282,8 @@ paths: deprecated: false explode: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/DeploymentPlan' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4342,16 +4329,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_environment_deployment_plan - /v1/envs/{environment_id}/current-deployment: - put: + operationId: delete_automatic_environment_tool_grant + /v1/environment-tool-grants/{grant_id}/restore: + post: tags: - RegistryService - - Environment - - Deployment - summary: Rollback an environment to a previous deployment + - EnvironmentToolGrants + summary: Restore a deleted environment tool grant parameters: - - name: environment_id + - name: grant_id schema: type: string format: uuid @@ -4359,19 +4345,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/DeploymentRollback' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/CurrentDeployment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4417,14 +4397,13 @@ paths: security: - Cookie: [] - Token: [] - operationId: rollback_environment - /v1/envs/{environment_id}/deployments: - get: + operationId: restore_environment_tool_grant + /v1/envs/{environment_id}/initial-agent-files: + post: tags: - RegistryService - Environment - - Deployment - summary: List all deployments in this environment + summary: Upload a content-addressed initial agent file for deployment in this environment parameters: - name: environment_id schema: @@ -4434,20 +4413,25 @@ paths: required: true deprecated: false explode: true - - name: version - schema: - type: string - in: query - required: false - deprecated: false - explode: true + requestBody: + content: + multipart/form-data: + schema: + type: object + required: + - file + properties: + file: + type: string + format: binary + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_Deployment' + $ref: '#/components/schemas/InitialAgentFileUpload' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4493,15 +4477,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_deployments + operationId: upload_environment_initial_agent_file + /v1/apps/{application_id}/envs: post: tags: - RegistryService - Environment - - Deployment - summary: Deploy the current staging area of this environment + - Application + summary: Create an application environment parameters: - - name: environment_id + - name: application_id schema: type: string format: uuid @@ -4513,7 +4498,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/DeploymentCreation' + $ref: '#/components/schemas/EnvironmentCreation' required: true responses: '200': @@ -4521,7 +4506,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/CurrentDeployment' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4567,15 +4552,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: deploy_environment - /v1/envs/{environment_id}/deployments/{deployment_id}/summary: + operationId: create_environment get: tags: - RegistryService - Environment - summary: Get the deployment summary of a deployed deployment + - Application + summary: List all application environments parameters: - - name: environment_id + - name: application_id schema: type: string format: uuid @@ -4583,21 +4568,13 @@ paths: required: true deprecated: false explode: true - - name: deployment_id - schema: - type: integer - format: uint64 - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/DeploymentSummary' + $ref: '#/components/schemas/Page_Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4643,15 +4620,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_deployment_summary - /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types: + operationId: list_application_environments + /v1/apps/{application_id}/envs/{environment_name}: get: tags: - RegistryService - Environment - summary: List all registered agent types in a deployment + - Application + summary: Get application environment by name parameters: - - name: environment_id + - name: application_id schema: type: string format: uuid @@ -4659,10 +4637,9 @@ paths: required: true deprecated: false explode: true - - name: deployment_id + - name: environment_name schema: - type: integer - format: uint64 + type: string in: path required: true deprecated: false @@ -4673,7 +4650,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_DeployedRegisteredAgentType' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4719,13 +4696,13 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_deployment_agent_types - /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types/{agent_type_name}: + operationId: get_application_environment + /v1/envs/{environment_id}: get: tags: - RegistryService - Environment - summary: Get a registered agent type in a deployment + summary: Get environment by id. parameters: - name: environment_id schema: @@ -4735,28 +4712,13 @@ paths: required: true deprecated: false explode: true - - name: deployment_id - schema: - type: integer - format: uint64 - in: path - required: true - deprecated: false - explode: true - - name: agent_type_name - schema: - type: string - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/DeployedRegisteredAgentType' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4802,13 +4764,12 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_deployment_agent_type - /v1/envs/{environment_id}/deployments/{deployment_id}/tools: - get: + operationId: get_environment + patch: tags: - RegistryService - Environment - summary: List all registered tools in a deployment + summary: Update environment by id. parameters: - name: environment_id schema: @@ -4818,21 +4779,19 @@ paths: required: true deprecated: false explode: true - - name: deployment_id - schema: - type: integer - format: uint64 - in: path + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentUpdate' required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_DeployedRegisteredTool' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4878,13 +4837,12 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_deployment_registered_tools - /v1/envs/{environment_id}/deployments/{deployment_id}/tools/{tool_name}: - get: + operationId: update_environment + delete: tags: - RegistryService - Environment - summary: Get a registered tool in a deployment + summary: Delete environment by id. parameters: - name: environment_id schema: @@ -4894,28 +4852,17 @@ paths: required: true deprecated: false explode: true - - name: deployment_id + - name: current_revision schema: type: integer format: uint64 - in: path - required: true - deprecated: false - explode: true - - name: tool_name - schema: - type: string - in: path + in: query required: true deprecated: false explode: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/DeployedRegisteredTool' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -4961,14 +4908,13 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_deployment_registered_tool - /v1/envs/{environment_id}/http-api-deployments: - post: + operationId: delete_environment + /v1/envs/{environment_id}/plan: + get: tags: - RegistryService - - ApiDeployment - Environment - summary: Create a new api-deployment in the environment + summary: Get the current deployment plan parameters: - name: environment_id schema: @@ -4978,19 +4924,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/HttpApiDeploymentCreation' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/DeploymentPlan' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5036,13 +4976,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_http_api_deployment - get: + operationId: get_environment_deployment_plan + /v1/envs/{environment_id}/current-deployment: + put: tags: - RegistryService - - ApiDeployment - Environment - summary: List http api deployment by domain in the environment + - Deployment + summary: Rollback an environment to a previous deployment parameters: - name: environment_id schema: @@ -5052,13 +4993,19 @@ paths: required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/DeploymentRollback' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_HttpApiDeployment' + $ref: '#/components/schemas/CurrentDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5104,15 +5051,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_environment_http_api_deployments - /v1/http-api-deployments/{http_api_deployment_id}: + operationId: rollback_environment + /v1/envs/{environment_id}/deployments: get: tags: - RegistryService - - ApiDeployment - summary: Get an api-deployment by id + - Environment + - Deployment + summary: List all deployments in this environment parameters: - - name: http_api_deployment_id + - name: environment_id schema: type: string format: uuid @@ -5120,13 +5068,20 @@ paths: required: true deprecated: false explode: true + - name: version + schema: + type: string + in: query + required: false + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/Page_Deployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5172,14 +5127,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_http_api_deployment - patch: + operationId: list_deployments + post: tags: - RegistryService - - ApiDeployment - summary: Update an api-deployment + - Environment + - Deployment + summary: Deploy the current staging area of this environment parameters: - - name: http_api_deployment_id + - name: environment_id schema: type: string format: uuid @@ -5191,7 +5147,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeploymentUpdate' + $ref: '#/components/schemas/DeploymentCreation' required: true responses: '200': @@ -5199,7 +5155,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/CurrentDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5245,14 +5201,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_http_api_deployment - delete: + operationId: deploy_environment + /v1/envs/{environment_id}/deployments/{deployment_id}/summary: + get: tags: - RegistryService - - ApiDeployment - summary: Delete an api-deployment + - Environment + summary: Get the deployment summary of a deployed deployment parameters: - - name: http_api_deployment_id + - name: environment_id schema: type: string format: uuid @@ -5260,17 +5217,21 @@ paths: required: true deprecated: false explode: true - - name: current_revision + - name: deployment_id schema: type: integer format: uint64 - in: query + in: path required: true deprecated: false explode: true responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/DeploymentSummary' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5316,15 +5277,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_http_api_deployment - /v1/http-api-deployment/{http_api_deployment_id}/revisions/{revision}: + operationId: get_deployment_summary + /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types: get: tags: - RegistryService - - ApiDeployment - summary: Get a specific http api deployment revision + - Environment + summary: List all registered agent types in a deployment parameters: - - name: http_api_deployment_id + - name: environment_id schema: type: string format: uuid @@ -5332,7 +5293,7 @@ paths: required: true deprecated: false explode: true - - name: revision + - name: deployment_id schema: type: integer format: uint64 @@ -5346,7 +5307,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/Page_DeployedRegisteredAgentType' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5392,14 +5353,13 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_http_api_deployment_revision - /v1/envs/{environment_id}/http-api-deployments/{domain}: + operationId: list_deployment_agent_types + /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types/{agent_type_name}: get: tags: - RegistryService - - ApiDeployment - Environment - summary: Get http api deployment by domain in the environment + summary: Get a registered agent type in a deployment parameters: - name: environment_id schema: @@ -5409,7 +5369,15 @@ paths: required: true deprecated: false explode: true - - name: domain + - name: deployment_id + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + - name: agent_type_name schema: type: string in: path @@ -5422,7 +5390,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/DeployedRegisteredAgentType' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5468,15 +5436,13 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_environment_http_api_deployment - /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments/{domain}: + operationId: get_deployment_agent_type + /v1/envs/{environment_id}/deployments/{deployment_id}/tools: get: tags: - RegistryService - - ApiDeployment - Environment - - Deployment - summary: Get http api deployment by domain in the deployment + summary: List all registered tools in a deployment parameters: - name: environment_id schema: @@ -5486,7 +5452,7 @@ paths: required: true deprecated: false explode: true - - name: deployment_revision + - name: deployment_id schema: type: integer format: uint64 @@ -5494,20 +5460,13 @@ paths: required: true deprecated: false explode: true - - name: domain - schema: - type: string - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/Page_DeployedRegisteredTool' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5553,15 +5512,13 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_deployment_http_api_deployment - /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments: + operationId: list_deployment_registered_tools + /v1/envs/{environment_id}/deployments/{deployment_id}/tools/{tool_name}: get: tags: - RegistryService - - ApiDeployment - Environment - - Deployment - summary: Get http api deployment by domain in the deployment + summary: Get a registered tool in a deployment parameters: - name: environment_id schema: @@ -5571,7 +5528,7 @@ paths: required: true deprecated: false explode: true - - name: deployment_revision + - name: deployment_id schema: type: integer format: uint64 @@ -5579,13 +5536,20 @@ paths: required: true deprecated: false explode: true + - name: tool_name + schema: + type: string + in: path + required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_HttpApiDeployment' + $ref: '#/components/schemas/DeployedRegisteredTool' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5631,44 +5595,36 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_deployment_http_api_deployments - /v1/login/oauth2: + operationId: get_deployment_registered_tool + /v1/envs/{environment_id}/http-api-deployments: post: tags: - RegistryService - - Login - summary: Acquire token with OAuth2 authorization - description: | - Gets a token by authorizing with an external OAuth2 provider. Currently only github is supported. - - In the response: - - `id` is the identifier of the token itself - - `accountId` is the account's identifier, can be used on the account API - - `secret` is the secret key to be sent in the Authorization header as a bearer token for all the other endpoints + - ApiDeployment + - Environment + summary: Create a new api-deployment in the environment parameters: - - name: provider - schema: - $ref: '#/components/schemas/OAuth2Provider' - in: query - description: Currently only `github` is supported. - required: true - deprecated: false - explode: true - - name: access-token + - name: environment_id schema: type: string - in: query - description: OAuth2 access token + format: uuid + in: path required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeploymentCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/TokenWithSecret' + $ref: '#/components/schemas/HttpApiDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5711,36 +5667,32 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - operationId: login_oauth2 - /v1/login/oauth2/web/authorize: - post: + security: + - Cookie: [] + - Token: [] + operationId: create_http_api_deployment + get: tags: - RegistryService - - Login - summary: Initiate OAuth2 Web Flow - description: |- - Starts the OAuth2 web flow. Two flow kinds are supported: - - - `browser`: The callback will immediately redirect to the given URL with the - Golem token secret appended as a `token` query parameter. Intended for - browser-based frontends. - - - `cli`: The callback stores the token in the session. The client polls the - poll endpoint with the returned state id to retrieve the token once available. - Intended for CLI tools and headless environments. - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/OAuth2WebflowStart' + - ApiDeployment + - Environment + summary: List http api deployment by domain in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/OAuth2WebflowData' + $ref: '#/components/schemas/Page_HttpApiDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5783,47 +5735,862 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - operationId: start_oauth2_webflow - /v1/login/oauth2/web/callback: + security: + - Cookie: [] + - Token: [] + operationId: list_environment_http_api_deployments + /v1/http-api-deployments/{http_api_deployment_id}: get: tags: - RegistryService - - Login - summary: OAuth2 Web Flow callback - description: |- - This endpoint handles the callback from the provider after the user has authorized the application. - It exchanges the code for an access token and then uses that to log the user in. + - ApiDeployment + summary: Get an api-deployment by id parameters: - - name: code - schema: - type: string - in: query - description: The authorization code returned by GitHub - required: true - deprecated: false - explode: true - - name: state + - name: http_api_deployment_id schema: type: string format: uuid - in: query - description: The state parameter for CSRF protection + in: path required: true deprecated: false explode: true responses: - '302': - description: Redirect to the given URL after completing the OAuth flow + '200': + description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Empty' - headers: - LOCATION: - required: true - deprecated: false + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_http_api_deployment + patch: + tags: + - RegistryService + - ApiDeployment + summary: Update an api-deployment + parameters: + - name: http_api_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeploymentUpdate' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: update_http_api_deployment + delete: + tags: + - RegistryService + - ApiDeployment + summary: Delete an api-deployment + parameters: + - name: http_api_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: current_revision + schema: + type: integer + format: uint64 + in: query + required: true + deprecated: false + explode: true + responses: + '204': + description: '' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: delete_http_api_deployment + /v1/http-api-deployment/{http_api_deployment_id}/revisions/{revision}: + get: + tags: + - RegistryService + - ApiDeployment + summary: Get a specific http api deployment revision + parameters: + - name: http_api_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_http_api_deployment_revision + /v1/envs/{environment_id}/http-api-deployments/{domain}: + get: + tags: + - RegistryService + - ApiDeployment + - Environment + summary: Get http api deployment by domain in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: domain + schema: + type: string + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_environment_http_api_deployment + /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments/{domain}: + get: + tags: + - RegistryService + - ApiDeployment + - Environment + - Deployment + summary: Get http api deployment by domain in the deployment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: deployment_revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + - name: domain + schema: + type: string + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_deployment_http_api_deployment + /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments: + get: + tags: + - RegistryService + - ApiDeployment + - Environment + - Deployment + summary: Get http api deployment by domain in the deployment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: deployment_revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: list_deployment_http_api_deployments + /v1/login/oauth2: + post: + tags: + - RegistryService + - Login + summary: Acquire token with OAuth2 authorization + description: | + Gets a token by authorizing with an external OAuth2 provider. Currently only github is supported. + + In the response: + - `id` is the identifier of the token itself + - `accountId` is the account's identifier, can be used on the account API + - `secret` is the secret key to be sent in the Authorization header as a bearer token for all the other endpoints + parameters: + - name: provider + schema: + $ref: '#/components/schemas/OAuth2Provider' + in: query + description: Currently only `github` is supported. + required: true + deprecated: false + explode: true + - name: access-token + schema: + type: string + in: query + description: OAuth2 access token + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/TokenWithSecret' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + operationId: login_oauth2 + /v1/login/oauth2/web/authorize: + post: + tags: + - RegistryService + - Login + summary: Initiate OAuth2 Web Flow + description: |- + Starts the OAuth2 web flow. Two flow kinds are supported: + + - `browser`: The callback will immediately redirect to the given URL with the + Golem token secret appended as a `token` query parameter. Intended for + browser-based frontends. + + - `cli`: The callback stores the token in the session. The client polls the + poll endpoint with the returned state id to retrieve the token once available. + Intended for CLI tools and headless environments. + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/OAuth2WebflowStart' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/OAuth2WebflowData' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + operationId: start_oauth2_webflow + /v1/login/oauth2/web/callback: + get: + tags: + - RegistryService + - Login + summary: OAuth2 Web Flow callback + description: |- + This endpoint handles the callback from the provider after the user has authorized the application. + It exchanges the code for an access token and then uses that to log the user in. + parameters: + - name: code + schema: + type: string + in: query + description: The authorization code returned by GitHub + required: true + deprecated: false + explode: true + - name: state + schema: + type: string + format: uuid + in: query + description: The state parameter for CSRF protection + required: true + deprecated: false + explode: true + responses: + '302': + description: Redirect to the given URL after completing the OAuth flow + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Empty' + headers: + LOCATION: + required: true + deprecated: false + schema: + type: string + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + operationId: submit_oauth2_webflow_callback + /v1/login/oauth2/web/poll: + get: + tags: + - RegistryService + - Login + summary: Poll for OAuth2 Web Flow token + description: |- + This endpoint is used by clients to poll for the token after the user has authorized the application via the web flow. + A given state might only be exchanged for a token once. Any further attempts to exchange the state will fail. + parameters: + - name: state + schema: + type: string + format: uuid + in: query + description: The state parameter for identifying the session + required: true + deprecated: false + explode: true + responses: + '200': + description: OAuth flow has completed + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/TokenWithSecret' + '202': + description: OAuth flow is pending + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Empty' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: schema: - type: string + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + operationId: poll_oauth2_webflow + /v1/me/token: + get: + tags: + - RegistryService + - Me + summary: |- + Gets information about the current token. + The JSON is the same as the data object in the oauth2 endpoint's response. + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Token' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5866,39 +6633,120 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - operationId: submit_oauth2_webflow_callback - /v1/login/oauth2/web/poll: + security: + - Cookie: [] + - Token: [] + operationId: current_login_token + /v1/me/visible-environments: get: tags: - RegistryService - - Login - summary: Poll for OAuth2 Web Flow token - description: |- - This endpoint is used by clients to poll for the token after the user has authorized the application via the web flow. - A given state might only be exchanged for a token once. Any further attempts to exchange the state will fail. + - Me + summary: List all environments that are visible to the current user, either directly or through shares. parameters: - - name: state + - name: account_email schema: type: string - format: uuid in: query - description: The state parameter for identifying the session - required: true + required: false + deprecated: false + explode: true + - name: app_name + schema: + type: string + in: query + required: false + deprecated: false + explode: true + - name: env_name + schema: + type: string + in: query + required: false deprecated: false explode: true responses: '200': - description: OAuth flow has completed + description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/TokenWithSecret' - '202': - description: OAuth flow is pending + $ref: '#/components/schemas/Page_EnvironmentWithDetails' + '400': + description: Invalid request, returning with a list of issues detected in the request content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Empty' + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: list_visible_environments + /v1/envs/{environment_id}/mcp-deployments: + post: + tags: + - RegistryService + - McpDeployment + - Environment + summary: Create a new MCP deployment in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeploymentCreation' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -5941,22 +6789,32 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - operationId: poll_oauth2_webflow - /v1/me/token: + security: + - Cookie: [] + - Token: [] + operationId: create_mcp_deployment get: tags: - RegistryService - - Me - summary: |- - Gets information about the current token. - The JSON is the same as the data object in the oauth2 endpoint's response. + - McpDeployment + - Environment + summary: List MCP deployments in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Token' + $ref: '#/components/schemas/Page_McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6002,33 +6860,96 @@ paths: security: - Cookie: [] - Token: [] - operationId: current_login_token - /v1/me/visible-environments: + operationId: list_environment_mcp_deployments + /v1/envs/{environment_id}/mcp-deployments/{domain}: get: tags: - RegistryService - - Me - summary: List all environments that are visible to the current user, either directly or through shares. + - McpDeployment + - Environment + summary: Get MCP deployment by domain in the environment parameters: - - name: account_email + - name: environment_id schema: type: string - in: query - required: false + format: uuid + in: path + required: true deprecated: false explode: true - - name: app_name + - name: domain schema: type: string - in: query - required: false + in: path + required: true deprecated: false explode: true - - name: env_name + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_environment_mcp_deployment + /v1/mcp-deployments/{mcp_deployment_id}: + get: + tags: + - RegistryService + - McpDeployment + summary: Get MCP deployment by ID + parameters: + - name: mcp_deployment_id schema: type: string - in: query - required: false + format: uuid + in: path + required: true deprecated: false explode: true responses: @@ -6037,7 +6958,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_EnvironmentWithDetails' + $ref: '#/components/schemas/McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6083,16 +7004,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_visible_environments - /v1/envs/{environment_id}/mcp-deployments: - post: + operationId: get_mcp_deployment + patch: tags: - RegistryService - McpDeployment - - Environment - summary: Create a new MCP deployment in the environment + summary: Update MCP deployment parameters: - - name: environment_id + - name: mcp_deployment_id schema: type: string format: uuid @@ -6104,7 +7023,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeploymentCreation' + $ref: '#/components/schemas/McpDeploymentUpdate' required: true responses: '200': @@ -6158,15 +7077,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_mcp_deployment - get: + operationId: update_mcp_deployment + delete: tags: - RegistryService - McpDeployment - - Environment - summary: List MCP deployments in the environment + summary: Delete MCP deployment parameters: - - name: environment_id + - name: mcp_deployment_id schema: type: string format: uuid @@ -6174,13 +7092,17 @@ paths: required: true deprecated: false explode: true + - name: current_revision + schema: + type: integer + format: uint64 + in: query + required: true + deprecated: false + explode: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Page_McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6226,16 +7148,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_environment_mcp_deployments - /v1/envs/{environment_id}/mcp-deployments/{domain}: + operationId: delete_mcp_deployment + /v1/mcp-deployment/{mcp_deployment_id}/revisions/{revision}: get: tags: - RegistryService - McpDeployment - - Environment - summary: Get MCP deployment by domain in the environment + summary: Get a specific MCP deployment revision parameters: - - name: environment_id + - name: mcp_deployment_id schema: type: string format: uuid @@ -6243,9 +7164,10 @@ paths: required: true deprecated: false explode: true - - name: domain + - name: revision schema: - type: string + type: integer + format: uint64 in: path required: true deprecated: false @@ -6302,15 +7224,17 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_environment_mcp_deployment - /v1/mcp-deployments/{mcp_deployment_id}: + operationId: get_mcp_deployment_revision + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments/{domain}: get: tags: - RegistryService - McpDeployment - summary: Get MCP deployment by ID + - Environment + - Deployment + summary: Get MCP deployment by domain in the deployment parameters: - - name: mcp_deployment_id + - name: environment_id schema: type: string format: uuid @@ -6318,6 +7242,21 @@ paths: required: true deprecated: false explode: true + - name: deployment_revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + - name: domain + schema: + type: string + in: path + required: true + deprecated: false + explode: true responses: '200': description: '' @@ -6370,14 +7309,17 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_mcp_deployment - patch: + operationId: get_deployment_mcp_deployment + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments: + get: tags: - RegistryService - McpDeployment - summary: Update MCP deployment + - Environment + - Deployment + summary: List MCP deployments by domain in the deployment parameters: - - name: mcp_deployment_id + - name: environment_id schema: type: string format: uuid @@ -6385,19 +7327,21 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/McpDeploymentUpdate' + - name: deployment_revision + schema: + type: integer + format: uint64 + in: path required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeployment' + $ref: '#/components/schemas/Page_McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6443,14 +7387,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_mcp_deployment - delete: + operationId: list_deployment_mcp_deployments + /v1/accounts/{account_id}/permission-shares: + post: tags: - RegistryService - - McpDeployment - summary: Delete MCP deployment + - PermissionShares + - Account + summary: Create a new permission share owned by an account. parameters: - - name: mcp_deployment_id + - name: account_id schema: type: string format: uuid @@ -6458,17 +7404,19 @@ paths: required: true deprecated: false explode: true - - name: current_revision - schema: - type: integer - format: uint64 - in: query + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PermissionShareCreation' required: true - deprecated: false - explode: true responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6514,15 +7462,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_mcp_deployment - /v1/mcp-deployment/{mcp_deployment_id}/revisions/{revision}: + operationId: create_permission_share get: tags: - RegistryService - - McpDeployment - summary: Get a specific MCP deployment revision + - PermissionShares + - Account + summary: List permission shares owned by an account. parameters: - - name: mcp_deployment_id + - name: account_id schema: type: string format: uuid @@ -6530,21 +7478,13 @@ paths: required: true deprecated: false explode: true - - name: revision - schema: - type: integer - format: uint64 - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeployment' + $ref: '#/components/schemas/Page_PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6590,17 +7530,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_mcp_deployment_revision - /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments/{domain}: + operationId: list_owned_permission_shares + /v1/accounts/{account_id}/received-permission-shares: get: tags: - RegistryService - - McpDeployment - - Environment - - Deployment - summary: Get MCP deployment by domain in the deployment + - PermissionShares + - Account + summary: List permission shares targeting an account. parameters: - - name: environment_id + - name: account_id schema: type: string format: uuid @@ -6608,28 +7547,13 @@ paths: required: true deprecated: false explode: true - - name: deployment_revision - schema: - type: integer - format: uint64 - in: path - required: true - deprecated: false - explode: true - - name: domain - schema: - type: string - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeployment' + $ref: '#/components/schemas/Page_PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6675,17 +7599,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_deployment_mcp_deployment - /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments: + operationId: list_received_permission_shares + /v1/permission-shares/{permission_share_id}: get: tags: - RegistryService - - McpDeployment - - Environment - - Deployment - summary: List MCP deployments by domain in the deployment + - PermissionShares + summary: Get permission share by id. parameters: - - name: environment_id + - name: permission_share_id schema: type: string format: uuid @@ -6693,21 +7615,13 @@ paths: required: true deprecated: false explode: true - - name: deployment_revision - schema: - type: integer - format: uint64 - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_McpDeployment' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6753,16 +7667,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_deployment_mcp_deployments - /v1/accounts/{account_id}/permission-shares: - post: + operationId: get_permission_share + patch: tags: - RegistryService - PermissionShares - - Account - summary: Create a new permission share owned by an account. + summary: Update permission share data. parameters: - - name: account_id + - name: permission_share_id schema: type: string format: uuid @@ -6774,7 +7686,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShareCreation' + $ref: '#/components/schemas/PermissionShareUpdate' required: true responses: '200': @@ -6828,15 +7740,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_permission_share - get: + operationId: update_permission_share + delete: tags: - RegistryService - PermissionShares - - Account - summary: List permission shares owned by an account. + summary: Delete permission share. parameters: - - name: account_id + - name: permission_share_id schema: type: string format: uuid @@ -6844,13 +7755,21 @@ paths: required: true deprecated: false explode: true + - name: current_revision + schema: + type: integer + format: uint64 + in: query + required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_PermissionShare' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6896,19 +7815,26 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_owned_permission_shares - /v1/accounts/{account_id}/received-permission-shares: + operationId: delete_permission_share + /v1/accounts/{account_id}/permission-shares/{name}: get: tags: - RegistryService - PermissionShares - Account - summary: List permission shares targeting an account. + summary: Get permission share by owner account and name. parameters: - name: account_id schema: type: string - format: uuid + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: name + schema: + type: string in: path required: true deprecated: false @@ -6919,7 +7845,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_PermissionShare' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6965,15 +7891,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_received_permission_shares - /v1/permission-shares/{permission_share_id}: - get: + operationId: get_permission_share_by_name + /v1/accounts/{account_id}/plugins: + post: tags: - RegistryService - - PermissionShares - summary: Get permission share by id. + - Plugin + - Account + summary: Register a new plugin parameters: - - name: permission_share_id + - name: account_id schema: type: string format: uuid @@ -6981,13 +7908,19 @@ paths: required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PluginRegistrationCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7033,14 +7966,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_permission_share - patch: + operationId: create_plugin + get: tags: - RegistryService - - PermissionShares - summary: Update permission share data. + - Plugin + - Account + summary: List all plugins registered in account parameters: - - name: permission_share_id + - name: account_id schema: type: string format: uuid @@ -7048,19 +7982,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/PermissionShareUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/Page_PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7106,14 +8034,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_permission_share - delete: + operationId: list_account_plugins + /v1/plugins/{plugin_id}: + get: tags: - RegistryService - - PermissionShares - summary: Delete permission share. + - Plugin + summary: Get a plugin by id parameters: - - name: permission_share_id + - name: plugin_id schema: type: string format: uuid @@ -7121,21 +8050,13 @@ paths: required: true deprecated: false explode: true - - name: current_revision - schema: - type: integer - format: uint64 - in: query - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7181,16 +8102,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_permission_share - /v1/accounts/{account_id}/permission-shares/{name}: - get: + operationId: get_plugin_by_id + delete: tags: - RegistryService - - PermissionShares - - Account - summary: Get permission share by owner account and name. + - Plugin + summary: Delete a plugin parameters: - - name: account_id + - name: plugin_id schema: type: string format: uuid @@ -7198,20 +8117,13 @@ paths: required: true deprecated: false explode: true - - name: name - schema: - type: string - in: path - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7257,36 +8169,19 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_permission_share_by_name - /v1/accounts/{account_id}/plugins: - post: + operationId: delete_plugin + /v1/reports/account_summaries: + get: tags: - RegistryService - - Plugin - - Account - summary: Register a new plugin - parameters: - - name: account_id - schema: - type: string - format: uuid - in: path - required: true - deprecated: false - explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/PluginRegistrationCreation' - required: true + - Reports responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PluginRegistrationDto' + $ref: '#/components/schemas/Page_AccountSummaryReport' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7332,29 +8227,19 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_plugin + operationId: get_account_summaries_report + /v1/reports/account_count: get: tags: - RegistryService - - Plugin - - Account - summary: List all plugins registered in account - parameters: - - name: account_id - schema: - type: string - format: uuid - in: path - required: true - deprecated: false - explode: true + - Reports responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_PluginRegistrationDto' + $ref: '#/components/schemas/AccountCountsReport' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7400,15 +8285,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_account_plugins - /v1/plugins/{plugin_id}: - get: + operationId: get_account_count_report + /v1/envs/{environment_id}/resources: + post: tags: - RegistryService - - Plugin - summary: Get a plugin by id + - Resources + - Environment + summary: Create a new resource in the environment parameters: - - name: plugin_id + - name: environment_id schema: type: string format: uuid @@ -7416,13 +8302,19 @@ paths: required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ResourceDefinitionCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PluginRegistrationDto' + $ref: '#/components/schemas/ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7468,14 +8360,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_plugin_by_id - delete: + operationId: create_resource + get: tags: - RegistryService - - Plugin - summary: Delete a plugin + - Resources + - Environment + summary: Get all resources defined in the environment parameters: - - name: plugin_id + - name: environment_id schema: type: string format: uuid @@ -7489,7 +8382,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PluginRegistrationDto' + $ref: '#/components/schemas/Page_ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7535,19 +8428,37 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_plugin - /v1/reports/account_summaries: + operationId: list_environment_resources + /v1/envs/{environment_id}/resources/{resource_name}: get: tags: - RegistryService - - Reports + - Resources + - Environment + summary: Get a resource in the environment by name + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: resource_name + schema: + type: string + in: path + required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_AccountSummaryReport' + $ref: '#/components/schemas/ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7593,19 +8504,29 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_account_summaries_report - /v1/reports/account_count: + operationId: get_environment_resource + /v1/resources/{resource_id}: get: tags: - RegistryService - - Reports + - Resources + summary: Get a resource by id + parameters: + - name: resource_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/AccountCountsReport' + $ref: '#/components/schemas/ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7651,16 +8572,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_account_count_report - /v1/envs/{environment_id}/resources: - post: + operationId: get_resource + patch: tags: - RegistryService - Resources - - Environment - summary: Create a new resource in the environment + summary: Update a resource parameters: - - name: environment_id + - name: resource_id schema: type: string format: uuid @@ -7672,7 +8591,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinitionCreation' + $ref: '#/components/schemas/ResourceDefinitionUpdate' required: true responses: '200': @@ -7726,15 +8645,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_resource - get: + operationId: update_resource + delete: tags: - RegistryService - Resources - - Environment - summary: Get all resources defined in the environment + summary: Delete a resource parameters: - - name: environment_id + - name: resource_id schema: type: string format: uuid @@ -7742,13 +8660,17 @@ paths: required: true deprecated: false explode: true + - name: current_revision + schema: + type: integer + format: uint64 + in: query + required: true + deprecated: false + explode: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Page_ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7794,16 +8716,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_environment_resources - /v1/envs/{environment_id}/resources/{resource_name}: + operationId: delete_resource + /v1/resources/{resource_id}/revisions/{revision}: get: tags: - RegistryService - Resources - - Environment - summary: Get a resource in the environment by name + summary: Get specific revision of a resource parameters: - - name: environment_id + - name: resource_id schema: type: string format: uuid @@ -7811,9 +8732,10 @@ paths: required: true deprecated: false explode: true - - name: resource_name + - name: revision schema: - type: string + type: integer + format: uint64 in: path required: true deprecated: false @@ -7870,15 +8792,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_environment_resource - /v1/resources/{resource_id}: - get: + operationId: get_resource_revision + /v1/envs/{environment_id}/retry-policies: + post: tags: - RegistryService - - Resources - summary: Get a resource by id + - RetryPolicies + - Environment + summary: Create a new retry policy parameters: - - name: resource_id + - name: environment_id schema: type: string format: uuid @@ -7886,13 +8809,19 @@ paths: required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RetryPolicyCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinition' + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7938,14 +8867,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_resource - patch: + operationId: create_retry_policy + get: tags: - RegistryService - - Resources - summary: Update a resource + - RetryPolicies + - Environment + summary: Get all retry policies of the environment parameters: - - name: resource_id + - name: environment_id schema: type: string format: uuid @@ -7953,19 +8883,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/ResourceDefinitionUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinition' + $ref: '#/components/schemas/Page_RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8011,14 +8935,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_resource - delete: + operationId: list_environment_retry_policies + /v1/retry-policies/{retry_policy_id}: + get: tags: - RegistryService - - Resources - summary: Delete a resource + - RetryPolicies + summary: Get retry policy by id. parameters: - - name: resource_id + - name: retry_policy_id schema: type: string format: uuid @@ -8026,17 +8951,13 @@ paths: required: true deprecated: false explode: true - - name: current_revision - schema: - type: integer - format: uint64 - in: query - required: true - deprecated: false - explode: true responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8082,15 +9003,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_resource - /v1/resources/{resource_id}/revisions/{revision}: - get: + operationId: get_retry_policy + patch: tags: - RegistryService - - Resources - summary: Get specific revision of a resource + - RetryPolicies + summary: Update retry policy parameters: - - name: resource_id + - name: retry_policy_id schema: type: string format: uuid @@ -8098,21 +9018,19 @@ paths: required: true deprecated: false explode: true - - name: revision - schema: - type: integer - format: uint64 - in: path + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RetryPolicyUpdate' required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinition' + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8158,16 +9076,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_resource_revision - /v1/envs/{environment_id}/retry-policies: - post: + operationId: update_retry_policy + delete: tags: - RegistryService - RetryPolicies - - Environment - summary: Create a new retry policy + summary: Delete retry policy parameters: - - name: environment_id + - name: retry_policy_id schema: type: string format: uuid @@ -8175,12 +9091,14 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/RetryPolicyCreation' + - name: current_revision + schema: + type: integer + format: uint64 + in: query required: true + deprecated: false + explode: true responses: '200': description: '' @@ -8233,13 +9151,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_retry_policy - get: + operationId: delete_retry_policy + /v1/envs/{environment_id}/security-schemes: + post: tags: - RegistryService - - RetryPolicies + - ApiSecurity - Environment - summary: Get all retry policies of the environment + summary: Create a new security scheme parameters: - name: environment_id schema: @@ -8249,13 +9168,19 @@ paths: required: true deprecated: false explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SecuritySchemeCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_RetryPolicyDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8301,15 +9226,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_environment_retry_policies - /v1/retry-policies/{retry_policy_id}: + operationId: create_security_scheme get: tags: - RegistryService - - RetryPolicies - summary: Get retry policy by id. + - ApiSecurity + - Environment + summary: Get all security-schemes of the environment parameters: - - name: retry_policy_id + - name: environment_id schema: type: string format: uuid @@ -8323,7 +9248,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/Page_SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8369,14 +9294,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_retry_policy - patch: + operationId: list_environment_security_schemes + /v1/security-schemes/{security_scheme_id}: + get: tags: - RegistryService - - RetryPolicies - summary: Update retry policy + - ApiSecurity + summary: Get security scheme parameters: - - name: retry_policy_id + - name: security_scheme_id schema: type: string format: uuid @@ -8384,19 +9310,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/RetryPolicyUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8442,14 +9362,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_retry_policy - delete: + operationId: get_security_scheme + patch: tags: - RegistryService - - RetryPolicies - summary: Delete retry policy + - ApiSecurity + summary: Update security scheme parameters: - - name: retry_policy_id + - name: security_scheme_id schema: type: string format: uuid @@ -8457,21 +9377,19 @@ paths: required: true deprecated: false explode: true - - name: current_revision - schema: - type: integer - format: uint64 - in: query + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SecuritySchemeUpdate' required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8517,16 +9435,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_retry_policy - /v1/envs/{environment_id}/security-schemes: - post: + operationId: update_security_scheme + delete: tags: - RegistryService - ApiSecurity - - Environment - summary: Create a new security scheme + summary: Delete security scheme parameters: - - name: environment_id + - name: security_scheme_id schema: type: string format: uuid @@ -8534,12 +9450,14 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SecuritySchemeCreation' + - name: current_revision + schema: + type: integer + format: uint64 + in: query required: true + deprecated: false + explode: true responses: '200': description: '' @@ -8592,15 +9510,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: create_security_scheme + operationId: delete_security_scheme + /v1/tokens/{token_id}: get: tags: - RegistryService - - ApiSecurity - - Environment - summary: Get all security-schemes of the environment + - Token + summary: Get token by id parameters: - - name: environment_id + - name: token_id schema: type: string format: uuid @@ -8614,7 +9532,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_SecuritySchemeDto' + $ref: '#/components/schemas/Token' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8660,15 +9578,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: list_environment_security_schemes - /v1/security-schemes/{security_scheme_id}: - get: + operationId: get_token + delete: tags: - RegistryService - - ApiSecurity - summary: Get security scheme + - Token + summary: Delete a token + description: Deletes a previously created token given by its identifier. parameters: - - name: security_scheme_id + - name: token_id schema: type: string format: uuid @@ -8682,7 +9600,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/Empty' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8728,14 +9646,16 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_security_scheme - patch: + operationId: delete_token + /v1/accounts/{account_id}/tool-releases: + get: tags: - RegistryService - - ApiSecurity - summary: Update security scheme + - ToolReleases + - Account + summary: List tool releases owned by an account parameters: - - name: security_scheme_id + - name: account_id schema: type: string format: uuid @@ -8743,19 +9663,13 @@ paths: required: true deprecated: false explode: true - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SecuritySchemeUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/Page_ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8801,14 +9715,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: update_security_scheme - delete: + operationId: list_account_tool_releases + /v1/tool-releases/{release_id}: + get: tags: - RegistryService - - ApiSecurity - summary: Delete security scheme + - ToolReleases + summary: Get an account-owned tool release by ID parameters: - - name: security_scheme_id + - name: release_id schema: type: string format: uuid @@ -8816,21 +9731,13 @@ paths: required: true deprecated: false explode: true - - name: current_revision - schema: - type: integer - format: uint64 - in: query - required: true - deprecated: false - explode: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8876,15 +9783,14 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_security_scheme - /v1/tokens/{token_id}: - get: + operationId: get_tool_release + delete: tags: - RegistryService - - Token - summary: Get token by id + - ToolReleases + summary: De-publish an account-owned tool release parameters: - - name: token_id + - name: release_id schema: type: string format: uuid @@ -8898,7 +9804,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Token' + $ref: '#/components/schemas/ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8944,15 +9850,15 @@ paths: security: - Cookie: [] - Token: [] - operationId: get_token - delete: + operationId: de_publish_tool_release + /v1/tool-releases/{release_id}/restore: + post: tags: - RegistryService - - Token - summary: Delete a token - description: Deletes a previously created token given by its identifier. + - ToolReleases + summary: Restore a de-published account-owned tool release parameters: - - name: token_id + - name: release_id schema: type: string format: uuid @@ -8966,7 +9872,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Empty' + $ref: '#/components/schemas/ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9012,7 +9918,7 @@ paths: security: - Cookie: [] - Token: [] - operationId: delete_token + operationId: restore_tool_release components: schemas: Account: @@ -10938,10 +11844,14 @@ components: - ownerAccountId - ownerAccountEmail - metadataVersion + - metadataDigest properties: deploymentRevision: type: integer format: uint64 + releaseId: + type: string + format: uuid definition: $ref: '#/components/schemas/Tool' source: @@ -10953,6 +11863,9 @@ components: type: string metadataVersion: type: string + metadataDigest: + type: string + format: hash Deployment: type: object title: Deployment @@ -11019,6 +11932,16 @@ components: type: array items: $ref: '#/components/schemas/DeploymentRetryPolicyDefault' + publishTools: + type: array + default: [] + items: + type: string + remoteTools: + type: array + default: [] + items: + $ref: '#/components/schemas/RemoteToolDeployment' replaceIncompatibleAgentSecrets: type: boolean default: false @@ -11031,6 +11954,8 @@ components: - components - httpApiDeployments - mcpDeployments + - remoteTools + - publishedTools properties: currentRevision: type: integer @@ -11050,6 +11975,14 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' + remoteTools: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanRemoteToolEntry' + publishedTools: + type: array + items: + type: string DeploymentPlanComponentEntry: type: object title: DeploymentPlanComponentEntry @@ -11110,6 +12043,18 @@ components: hash: type: string format: hash + DeploymentPlanRemoteToolEntry: + type: object + title: DeploymentPlanRemoteToolEntry + required: + - name + - hash + properties: + name: + type: string + hash: + type: string + format: hash DeploymentRetryPolicyDefault: type: object title: DeploymentRetryPolicyDefault @@ -11152,6 +12097,8 @@ components: - components - httpApiDeployments - mcpDeployments + - remoteTools + - publishedTools properties: deploymentRevision: type: integer @@ -11171,6 +12118,14 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' + remoteTools: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanRemoteToolEntry' + publishedTools: + type: array + items: + type: string DiscriminatorRule: type: object oneOf: @@ -11492,6 +12447,91 @@ components: type: boolean currentDeployment: $ref: '#/components/schemas/EnvironmentCurrentDeploymentView' + EnvironmentToolGrant: + type: object + title: EnvironmentToolGrant + required: + - id + - environmentId + - toolReleaseId + - protected + - automatic + - lifecycle + - createdAt + - createdBy + - stateChangedAt + - stateChangedBy + properties: + id: + type: string + format: uuid + environmentId: + type: string + format: uuid + toolReleaseId: + type: string + format: uuid + protected: + type: boolean + automatic: + type: boolean + lifecycle: + $ref: '#/components/schemas/EnvironmentToolGrantLifecycle' + createdAt: + type: string + format: date-time + createdBy: + type: string + format: uuid + stateChangedAt: + type: string + format: date-time + stateChangedBy: + type: string + format: uuid + EnvironmentToolGrantCreation: + type: object + title: EnvironmentToolGrantCreation + required: + - release + properties: + release: + $ref: '#/components/schemas/ToolReleaseReference' + EnvironmentToolGrantLifecycle: + type: string + enum: + - active + - deleted + EnvironmentToolGrantReconciliation: + type: object + title: EnvironmentToolGrantReconciliation + required: + - creations + - deletions + properties: + creations: + type: array + items: + $ref: '#/components/schemas/EnvironmentToolGrantCreation' + deletions: + type: array + items: + type: string + format: uuid + EnvironmentToolGrantWithDetails: + type: object + title: EnvironmentToolGrantWithDetails + required: + - grant + - release + - releaseOwner + properties: + grant: + $ref: '#/components/schemas/EnvironmentToolGrant' + release: + $ref: '#/components/schemas/ToolReleaseMetadata' + releaseOwner: + $ref: '#/components/schemas/AccountSummary' EnvironmentUpdate: type: object title: EnvironmentUpdate @@ -12046,6 +13086,19 @@ components: size: type: integer format: uint64 + InitialAgentFileUpload: + type: object + title: InitialAgentFileUpload + required: + - contentHash + - size + properties: + contentHash: + type: string + format: hash + size: + type: integer + format: uint64 InputSchema: type: object oneOf: @@ -12702,6 +13755,16 @@ components: type: array items: $ref: '#/components/schemas/EnvironmentPluginGrantWithDetails' + Page_EnvironmentToolGrantWithDetails: + type: object + title: Page_EnvironmentToolGrantWithDetails + required: + - values + properties: + values: + type: array + items: + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' Page_EnvironmentWithDetails: type: object title: Page_EnvironmentWithDetails @@ -12792,6 +13855,16 @@ components: type: array items: $ref: '#/components/schemas/Token' + Page_ToolRelease: + type: object + title: Page_ToolRelease + required: + - values + properties: + values: + type: array + items: + $ref: '#/components/schemas/ToolRelease' PathDirection: type: string enum: @@ -13567,6 +14640,27 @@ components: format: uuid accountEmail: type: string + RemoteToolDeployment: + type: object + title: RemoteToolDeployment + required: + - name + - release + - provision + properties: + name: + type: string + release: + $ref: '#/components/schemas/ToolReleaseReference' + provision: + $ref: '#/components/schemas/ToolProvisionConfig' + environmentBinding: + $ref: '#/components/schemas/ToolBindingInput' + agentBindings: + type: object + default: {} + additionalProperties: + $ref: '#/components/schemas/ToolBindingInput' RepeatableListShape: type: object required: @@ -15522,6 +16616,12 @@ components: type: string required: type: boolean + SystemToolAvailability: + type: string + enum: + - grantable + - auto-granted + - ambient SystemVariable: type: string enum: @@ -15829,6 +16929,161 @@ components: default: {} additionalProperties: $ref: '#/components/schemas/AgentFilePermissions' + ToolRelease: + type: object + title: ToolRelease + required: + - id + - ownerAccountId + - name + - version + - source + - definition + - metadataVersion + - metadataDigest + - lifecycle + - origin + - createdAt + - createdBy + - stateChangedAt + - stateChangedBy + properties: + id: + type: string + format: uuid + ownerAccountId: + type: string + format: uuid + name: + type: string + version: + type: string + source: + $ref: '#/components/schemas/ToolSource' + definition: + $ref: '#/components/schemas/Tool' + metadataVersion: + type: string + metadataDigest: + type: string + format: hash + lifecycle: + $ref: '#/components/schemas/ToolReleaseLifecycle' + origin: + $ref: '#/components/schemas/ToolReleaseOrigin' + systemAvailability: + $ref: '#/components/schemas/SystemToolAvailability' + createdAt: + type: string + format: date-time + createdBy: + type: string + format: uuid + stateChangedAt: + type: string + format: date-time + stateChangedBy: + type: string + format: uuid + ToolReleaseByCoordinates: + type: object + title: ToolReleaseByCoordinates + required: + - account + - name + - version + properties: + account: + type: string + name: + type: string + version: + type: string + ToolReleaseById: + type: object + title: ToolReleaseById + required: + - releaseId + properties: + releaseId: + type: string + format: uuid + ToolReleaseLifecycle: + type: string + enum: + - published + - de-published + ToolReleaseMetadata: + type: object + title: ToolReleaseMetadata + description: |- + Safe release metadata available to a consumer through an active environment grant. + Executable source identities remain publisher-only. + required: + - id + - name + - version + - definition + - metadataVersion + - metadataDigest + - sourceDigest + properties: + id: + type: string + format: uuid + name: + type: string + version: + type: string + definition: + $ref: '#/components/schemas/Tool' + metadataVersion: + type: string + metadataDigest: + type: string + format: hash + sourceDigest: + type: string + format: hash + ToolReleaseOrigin: + type: string + enum: + - ordinary + - protected-system + ToolReleaseReference: + type: object + oneOf: + - $ref: '#/components/schemas/ToolReleaseReference_ToolReleaseById' + - $ref: '#/components/schemas/ToolReleaseReference_ToolReleaseByCoordinates' + discriminator: + propertyName: type + mapping: + ById: '#/components/schemas/ToolReleaseReference_ToolReleaseById' + ByCoordinates: '#/components/schemas/ToolReleaseReference_ToolReleaseByCoordinates' + ToolReleaseReference_ToolReleaseByCoordinates: + allOf: + - type: object + required: + - type + properties: + type: + type: string + enum: + - ByCoordinates + example: ByCoordinates + - $ref: '#/components/schemas/ToolReleaseByCoordinates' + ToolReleaseReference_ToolReleaseById: + allOf: + - type: object + required: + - type + properties: + type: + type: string + enum: + - ById + example: ById + - $ref: '#/components/schemas/ToolReleaseById' ToolResultSpec: type: object required: @@ -15880,6 +17135,20 @@ components: format: uint64 componentName: type: string + - type: object + required: + - kind + - hostToolId + - implementationVersion + properties: + kind: + type: string + enum: + - host + hostToolId: + type: string + implementationVersion: + type: string TypedAgentConfigEntry: type: object title: TypedAgentConfigEntry diff --git a/openapi/golem-service.yaml b/openapi/golem-service.yaml index cb92fb4392..976a3e05e9 100644 --- a/openapi/golem-service.yaml +++ b/openapi/golem-service.yaml @@ -5975,17 +5975,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/apps/{application_id}/envs: + /v1/envs/{environment_id}/tool-grants: get: tags: - RegistryService + - EnvironmentToolGrants - Environment - - Application - summary: List all application environments - operationId: list_application_environments + summary: List active tool grants in an environment + operationId: list_environment_tool_grants parameters: - in: path - name: application_id + name: environment_id required: true deprecated: false schema: @@ -5999,7 +5999,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_Environment' + $ref: '#/components/schemas/Page_EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6048,13 +6048,13 @@ paths: post: tags: - RegistryService + - EnvironmentToolGrants - Environment - - Application - summary: Create an application environment - operationId: create_environment + summary: Grant an exact published tool release to an environment + operationId: create_environment_tool_grant parameters: - in: path - name: application_id + name: environment_id required: true deprecated: false schema: @@ -6066,7 +6066,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/EnvironmentCreation' + $ref: '#/components/schemas/EnvironmentToolGrantCreation' required: true responses: '200': @@ -6074,7 +6074,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Environment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6120,17 +6120,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/apps/{application_id}/envs/{environment_name}: - get: + /v1/envs/{environment_id}/tool-grants/automatic: + post: tags: - RegistryService + - EnvironmentToolGrants - Environment - - Application - summary: Get application environment by name - operationId: get_application_environment + summary: Create an automatically managed grant required by an application deployment + operationId: create_automatic_environment_tool_grant parameters: - in: path - name: application_id + name: environment_id required: true deprecated: false schema: @@ -6138,21 +6138,19 @@ paths: format: uuid explode: true style: simple - - in: path - name: environment_name + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentToolGrantCreation' required: true - deprecated: false - schema: - type: string - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Environment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6198,13 +6196,14 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}: - get: + /v1/envs/{environment_id}/tool-grants/automatic/validate: + post: tags: - RegistryService + - EnvironmentToolGrants - Environment - summary: Get environment by id. - operationId: get_environment + summary: Validate an automatically managed grant reconciliation without changing any grants + operationId: validate_automatic_environment_tool_grant_reconciliation parameters: - in: path name: environment_id @@ -6215,13 +6214,15 @@ paths: format: uuid explode: true style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentToolGrantReconciliation' + required: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6267,15 +6268,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + /v1/environment-tool-grants/{grant_id}: + get: tags: - RegistryService - - Environment - summary: Delete environment by id. - operationId: delete_environment + - EnvironmentToolGrants + summary: Get an active environment tool grant + operationId: get_environment_tool_grant parameters: - in: path - name: environment_id + name: grant_id required: true deprecated: false schema: @@ -6283,18 +6285,13 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: form responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6340,15 +6337,15 @@ paths: security: - Cookie: [] - Token: [] - patch: + delete: tags: - RegistryService - - Environment - summary: Update environment by id. - operationId: update_environment + - EnvironmentToolGrants + summary: Delete an environment tool grant + operationId: delete_environment_tool_grant parameters: - in: path - name: environment_id + name: grant_id required: true deprecated: false schema: @@ -6356,19 +6353,9 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/EnvironmentUpdate' - required: true responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6414,16 +6401,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/plan: - get: + /v1/environment-tool-grants/{grant_id}/automatic: + delete: tags: - RegistryService - - Environment - summary: Get the current deployment plan - operationId: get_environment_deployment_plan + - EnvironmentToolGrants + summary: Delete an environment tool grant only if it is automatically managed + operationId: delete_automatic_environment_tool_grant parameters: - in: path - name: environment_id + name: grant_id required: true deprecated: false schema: @@ -6432,12 +6419,8 @@ paths: explode: true style: simple responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/DeploymentPlan' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6483,17 +6466,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/current-deployment: - put: + /v1/environment-tool-grants/{grant_id}/restore: + post: tags: - RegistryService - - Environment - - Deployment - summary: Rollback an environment to a previous deployment - operationId: rollback_environment + - EnvironmentToolGrants + summary: Restore a deleted environment tool grant + operationId: restore_environment_tool_grant parameters: - in: path - name: environment_id + name: grant_id required: true deprecated: false schema: @@ -6501,19 +6483,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/DeploymentRollback' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/CurrentDeployment' + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6559,14 +6535,13 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments: - get: + /v1/envs/{environment_id}/initial-agent-files: + post: tags: - RegistryService - Environment - - Deployment - summary: List all deployments in this environment - operationId: list_deployments + summary: Upload a content-addressed initial agent file for deployment in this environment + operationId: upload_environment_initial_agent_file parameters: - in: path name: environment_id @@ -6577,20 +6552,25 @@ paths: format: uuid explode: true style: simple - - in: query - name: version - deprecated: false - schema: - type: string - explode: true - style: form + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_Deployment' + $ref: '#/components/schemas/InitialAgentFileUpload' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6636,16 +6616,17 @@ paths: security: - Cookie: [] - Token: [] - post: + /v1/apps/{application_id}/envs: + get: tags: - RegistryService - Environment - - Deployment - summary: Deploy the current staging area of this environment - operationId: deploy_environment + - Application + summary: List all application environments + operationId: list_application_environments parameters: - in: path - name: environment_id + name: application_id required: true deprecated: false schema: @@ -6653,19 +6634,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/DeploymentCreation' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/CurrentDeployment' + $ref: '#/components/schemas/Page_Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6711,16 +6686,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_id}/summary: - get: + post: tags: - RegistryService - Environment - summary: Get the deployment summary of a deployed deployment - operationId: get_deployment_summary + - Application + summary: Create an application environment + operationId: create_environment parameters: - in: path - name: environment_id + name: application_id required: true deprecated: false schema: @@ -6728,22 +6703,19 @@ paths: format: uuid explode: true style: simple - - in: path - name: deployment_id + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentCreation' required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/DeploymentSummary' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6789,16 +6761,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types: + /v1/apps/{application_id}/envs/{environment_name}: get: tags: - RegistryService - Environment - summary: List all registered agent types in a deployment - operationId: list_deployment_agent_types + - Application + summary: Get application environment by name + operationId: get_application_environment parameters: - in: path - name: environment_id + name: application_id required: true deprecated: false schema: @@ -6807,12 +6780,11 @@ paths: explode: true style: simple - in: path - name: deployment_id + name: environment_name required: true deprecated: false schema: - type: integer - format: uint64 + type: string explode: true style: simple responses: @@ -6821,7 +6793,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_DeployedRegisteredAgentType' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6867,13 +6839,13 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types/{agent_type_name}: + /v1/envs/{environment_id}: get: tags: - RegistryService - Environment - summary: Get a registered agent type in a deployment - operationId: get_deployment_agent_type + summary: Get environment by id. + operationId: get_environment parameters: - in: path name: environment_id @@ -6884,30 +6856,13 @@ paths: format: uuid explode: true style: simple - - in: path - name: deployment_id - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: simple - - in: path - name: agent_type_name - required: true - deprecated: false - schema: - type: string - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/DeployedRegisteredAgentType' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -6953,13 +6908,12 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_id}/tools: - get: + delete: tags: - RegistryService - Environment - summary: List all registered tools in a deployment - operationId: list_deployment_registered_tools + summary: Delete environment by id. + operationId: delete_environment parameters: - in: path name: environment_id @@ -6970,22 +6924,18 @@ paths: format: uuid explode: true style: simple - - in: path - name: deployment_id + - in: query + name: current_revision required: true deprecated: false schema: type: integer format: uint64 explode: true - style: simple + style: form responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Page_DeployedRegisteredTool' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7031,13 +6981,12 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_id}/tools/{tool_name}: - get: + patch: tags: - RegistryService - Environment - summary: Get a registered tool in a deployment - operationId: get_deployment_registered_tool + summary: Update environment by id. + operationId: update_environment parameters: - in: path name: environment_id @@ -7048,30 +6997,19 @@ paths: format: uuid explode: true style: simple - - in: path - name: deployment_id - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: simple - - in: path - name: tool_name + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/EnvironmentUpdate' required: true - deprecated: false - schema: - type: string - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/DeployedRegisteredTool' + $ref: '#/components/schemas/Environment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7117,14 +7055,13 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/http-api-deployments: + /v1/envs/{environment_id}/plan: get: tags: - RegistryService - - ApiDeployment - Environment - summary: List http api deployment by domain in the environment - operationId: list_environment_http_api_deployments + summary: Get the current deployment plan + operationId: get_environment_deployment_plan parameters: - in: path name: environment_id @@ -7141,7 +7078,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_HttpApiDeployment' + $ref: '#/components/schemas/DeploymentPlan' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7187,13 +7124,14 @@ paths: security: - Cookie: [] - Token: [] - post: + /v1/envs/{environment_id}/current-deployment: + put: tags: - RegistryService - - ApiDeployment - Environment - summary: Create a new api-deployment in the environment - operationId: create_http_api_deployment + - Deployment + summary: Rollback an environment to a previous deployment + operationId: rollback_environment parameters: - in: path name: environment_id @@ -7208,7 +7146,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeploymentCreation' + $ref: '#/components/schemas/DeploymentRollback' required: true responses: '200': @@ -7216,7 +7154,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/CurrentDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7262,16 +7200,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/http-api-deployments/{http_api_deployment_id}: + /v1/envs/{environment_id}/deployments: get: tags: - RegistryService - - ApiDeployment - summary: Get an api-deployment by id - operationId: get_http_api_deployment + - Environment + - Deployment + summary: List all deployments in this environment + operationId: list_deployments parameters: - in: path - name: http_api_deployment_id + name: environment_id required: true deprecated: false schema: @@ -7279,13 +7218,20 @@ paths: format: uuid explode: true style: simple + - in: query + name: version + deprecated: false + schema: + type: string + explode: true + style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/Page_Deployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7331,15 +7277,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + post: tags: - RegistryService - - ApiDeployment - summary: Delete an api-deployment - operationId: delete_http_api_deployment + - Environment + - Deployment + summary: Deploy the current staging area of this environment + operationId: deploy_environment parameters: - in: path - name: http_api_deployment_id + name: environment_id required: true deprecated: false schema: @@ -7347,18 +7294,19 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/DeploymentCreation' required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: form responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/CurrentDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7404,15 +7352,16 @@ paths: security: - Cookie: [] - Token: [] - patch: + /v1/envs/{environment_id}/deployments/{deployment_id}/summary: + get: tags: - RegistryService - - ApiDeployment - summary: Update an api-deployment - operationId: update_http_api_deployment + - Environment + summary: Get the deployment summary of a deployed deployment + operationId: get_deployment_summary parameters: - in: path - name: http_api_deployment_id + name: environment_id required: true deprecated: false schema: @@ -7420,19 +7369,22 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/HttpApiDeploymentUpdate' + - in: path + name: deployment_id required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/DeploymentSummary' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7478,16 +7430,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/http-api-deployment/{http_api_deployment_id}/revisions/{revision}: + /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types: get: tags: - RegistryService - - ApiDeployment - summary: Get a specific http api deployment revision - operationId: get_http_api_deployment_revision + - Environment + summary: List all registered agent types in a deployment + operationId: list_deployment_agent_types parameters: - in: path - name: http_api_deployment_id + name: environment_id required: true deprecated: false schema: @@ -7496,7 +7448,7 @@ paths: explode: true style: simple - in: path - name: revision + name: deployment_id required: true deprecated: false schema: @@ -7510,7 +7462,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/Page_DeployedRegisteredAgentType' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7556,14 +7508,13 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/http-api-deployments/{domain}: + /v1/envs/{environment_id}/deployments/{deployment_id}/agent-types/{agent_type_name}: get: tags: - RegistryService - - ApiDeployment - Environment - summary: Get http api deployment by domain in the environment - operationId: get_environment_http_api_deployment + summary: Get a registered agent type in a deployment + operationId: get_deployment_agent_type parameters: - in: path name: environment_id @@ -7575,7 +7526,16 @@ paths: explode: true style: simple - in: path - name: domain + name: deployment_id + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + - in: path + name: agent_type_name required: true deprecated: false schema: @@ -7588,7 +7548,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/DeployedRegisteredAgentType' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7634,15 +7594,13 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments/{domain}: + /v1/envs/{environment_id}/deployments/{deployment_id}/tools: get: tags: - RegistryService - - ApiDeployment - Environment - - Deployment - summary: Get http api deployment by domain in the deployment - operationId: get_deployment_http_api_deployment + summary: List all registered tools in a deployment + operationId: list_deployment_registered_tools parameters: - in: path name: environment_id @@ -7654,7 +7612,7 @@ paths: explode: true style: simple - in: path - name: deployment_revision + name: deployment_id required: true deprecated: false schema: @@ -7662,21 +7620,13 @@ paths: format: uint64 explode: true style: simple - - in: path - name: domain - required: true - deprecated: false - schema: - type: string - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/HttpApiDeployment' + $ref: '#/components/schemas/Page_DeployedRegisteredTool' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7722,15 +7672,13 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments: + /v1/envs/{environment_id}/deployments/{deployment_id}/tools/{tool_name}: get: tags: - RegistryService - - ApiDeployment - Environment - - Deployment - summary: Get http api deployment by domain in the deployment - operationId: list_deployment_http_api_deployments + summary: Get a registered tool in a deployment + operationId: get_deployment_registered_tool parameters: - in: path name: environment_id @@ -7742,7 +7690,7 @@ paths: explode: true style: simple - in: path - name: deployment_revision + name: deployment_id required: true deprecated: false schema: @@ -7750,13 +7698,21 @@ paths: format: uint64 explode: true style: simple + - in: path + name: tool_name + required: true + deprecated: false + schema: + type: string + explode: true + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_HttpApiDeployment' + $ref: '#/components/schemas/DeployedRegisteredTool' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7802,46 +7758,31 @@ paths: security: - Cookie: [] - Token: [] - /v1/login/oauth2: - post: + /v1/envs/{environment_id}/http-api-deployments: + get: tags: - RegistryService - - Login - summary: Acquire token with OAuth2 authorization - description: | - Gets a token by authorizing with an external OAuth2 provider. Currently only github is supported. - - In the response: - - `id` is the identifier of the token itself - - `accountId` is the account's identifier, can be used on the account API - - `secret` is the secret key to be sent in the Authorization header as a bearer token for all the other endpoints - operationId: login_oauth2 + - ApiDeployment + - Environment + summary: List http api deployment by domain in the environment + operationId: list_environment_http_api_deployments parameters: - - in: query - name: provider - description: Currently only `github` is supported. - required: true - deprecated: false - schema: - $ref: '#/components/schemas/OAuth2Provider' - explode: true - style: form - - in: query - name: access-token - description: OAuth2 access token + - in: path + name: environment_id required: true deprecated: false schema: type: string + format: uuid explode: true - style: form + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/TokenWithSecret' + $ref: '#/components/schemas/Page_HttpApiDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7884,36 +7825,956 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - /v1/login/oauth2/web/authorize: + security: + - Cookie: [] + - Token: [] post: tags: - RegistryService - - Login - summary: Initiate OAuth2 Web Flow - description: |- - Starts the OAuth2 web flow. Two flow kinds are supported: - - - `browser`: The callback will immediately redirect to the given URL with the - Golem token secret appended as a `token` query parameter. Intended for - browser-based frontends. - - - `cli`: The callback stores the token in the session. The client polls the - poll endpoint with the returned state id to retrieve the token once available. - Intended for CLI tools and headless environments. - operationId: start_oauth2_webflow - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/OAuth2WebflowStart' + - ApiDeployment + - Environment + summary: Create a new api-deployment in the environment + operationId: create_http_api_deployment + parameters: + - in: path + name: environment_id required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeploymentCreation' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/http-api-deployments/{http_api_deployment_id}: + get: + tags: + - RegistryService + - ApiDeployment + summary: Get an api-deployment by id + operationId: get_http_api_deployment + parameters: + - in: path + name: http_api_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + delete: + tags: + - RegistryService + - ApiDeployment + summary: Delete an api-deployment + operationId: delete_http_api_deployment + parameters: + - in: path + name: http_api_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: query + name: current_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: form + responses: + '204': + description: '' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + patch: + tags: + - RegistryService + - ApiDeployment + summary: Update an api-deployment + operationId: update_http_api_deployment + parameters: + - in: path + name: http_api_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeploymentUpdate' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/http-api-deployment/{http_api_deployment_id}/revisions/{revision}: + get: + tags: + - RegistryService + - ApiDeployment + summary: Get a specific http api deployment revision + operationId: get_http_api_deployment_revision + parameters: + - in: path + name: http_api_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/http-api-deployments/{domain}: + get: + tags: + - RegistryService + - ApiDeployment + - Environment + summary: Get http api deployment by domain in the environment + operationId: get_environment_http_api_deployment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: domain + required: true + deprecated: false + schema: + type: string + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments/{domain}: + get: + tags: + - RegistryService + - ApiDeployment + - Environment + - Deployment + summary: Get http api deployment by domain in the deployment + operationId: get_deployment_http_api_deployment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: deployment_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + - in: path + name: domain + required: true + deprecated: false + schema: + type: string + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/deployments/{deployment_revision}/http-api-deployments: + get: + tags: + - RegistryService + - ApiDeployment + - Environment + - Deployment + summary: Get http api deployment by domain in the deployment + operationId: list_deployment_http_api_deployments + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: deployment_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_HttpApiDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/login/oauth2: + post: + tags: + - RegistryService + - Login + summary: Acquire token with OAuth2 authorization + description: | + Gets a token by authorizing with an external OAuth2 provider. Currently only github is supported. + + In the response: + - `id` is the identifier of the token itself + - `accountId` is the account's identifier, can be used on the account API + - `secret` is the secret key to be sent in the Authorization header as a bearer token for all the other endpoints + operationId: login_oauth2 + parameters: + - in: query + name: provider + description: Currently only `github` is supported. + required: true + deprecated: false + schema: + $ref: '#/components/schemas/OAuth2Provider' + explode: true + style: form + - in: query + name: access-token + description: OAuth2 access token + required: true + deprecated: false + schema: + type: string + explode: true + style: form + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/TokenWithSecret' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + /v1/login/oauth2/web/authorize: + post: + tags: + - RegistryService + - Login + summary: Initiate OAuth2 Web Flow + description: |- + Starts the OAuth2 web flow. Two flow kinds are supported: + + - `browser`: The callback will immediately redirect to the given URL with the + Golem token secret appended as a `token` query parameter. Intended for + browser-based frontends. + + - `cli`: The callback stores the token in the session. The client polls the + poll endpoint with the returned state id to retrieve the token once available. + Intended for CLI tools and headless environments. + operationId: start_oauth2_webflow + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/OAuth2WebflowStart' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/OAuth2WebflowData' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + /v1/login/oauth2/web/callback: + get: + tags: + - RegistryService + - Login + summary: OAuth2 Web Flow callback + description: |- + This endpoint handles the callback from the provider after the user has authorized the application. + It exchanges the code for an access token and then uses that to log the user in. + operationId: submit_oauth2_webflow_callback + parameters: + - in: query + name: code + description: The authorization code returned by GitHub + required: true + deprecated: false + schema: + type: string + explode: true + style: form + - in: query + name: state + description: The state parameter for CSRF protection + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: form + responses: + '302': + description: Redirect to the given URL after completing the OAuth flow + headers: + LOCATION: + style: simple + required: true + deprecated: false + schema: + type: string + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Empty' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + /v1/login/oauth2/web/poll: + get: + tags: + - RegistryService + - Login + summary: Poll for OAuth2 Web Flow token + description: |- + This endpoint is used by clients to poll for the token after the user has authorized the application via the web flow. + A given state might only be exchanged for a token once. Any further attempts to exchange the state will fail. + operationId: poll_oauth2_webflow + parameters: + - in: query + name: state + description: The state parameter for identifying the session + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: form + responses: + '200': + description: OAuth flow has completed + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/TokenWithSecret' + '202': + description: OAuth flow is pending + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Empty' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + /v1/me/token: + get: + tags: + - RegistryService + - Me + summary: |- + Gets information about the current token. + The JSON is the same as the data object in the oauth2 endpoint's response. + operationId: current_login_token responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/OAuth2WebflowData' + $ref: '#/components/schemas/Token' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -7956,50 +8817,45 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - /v1/login/oauth2/web/callback: + security: + - Cookie: [] + - Token: [] + /v1/me/visible-environments: get: tags: - RegistryService - - Login - summary: OAuth2 Web Flow callback - description: |- - This endpoint handles the callback from the provider after the user has authorized the application. - It exchanges the code for an access token and then uses that to log the user in. - operationId: submit_oauth2_webflow_callback + - Me + summary: List all environments that are visible to the current user, either directly or through shares. + operationId: list_visible_environments parameters: - in: query - name: code - description: The authorization code returned by GitHub - required: true + name: account_email deprecated: false schema: type: string explode: true style: form - in: query - name: state - description: The state parameter for CSRF protection - required: true + name: app_name + deprecated: false + schema: + type: string + explode: true + style: form + - in: query + name: env_name deprecated: false schema: type: string - format: uuid explode: true style: form responses: - '302': - description: Redirect to the given URL after completing the OAuth flow - headers: - LOCATION: - style: simple - required: true - deprecated: false - schema: - type: string + '200': + description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Empty' + $ref: '#/components/schemas/Page_EnvironmentWithDetails' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8042,40 +8898,34 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - /v1/login/oauth2/web/poll: + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/mcp-deployments: get: tags: - RegistryService - - Login - summary: Poll for OAuth2 Web Flow token - description: |- - This endpoint is used by clients to poll for the token after the user has authorized the application via the web flow. - A given state might only be exchanged for a token once. Any further attempts to exchange the state will fail. - operationId: poll_oauth2_webflow + - McpDeployment + - Environment + summary: List MCP deployments in the environment + operationId: list_environment_mcp_deployments parameters: - - in: query - name: state - description: The state parameter for identifying the session + - in: path + name: environment_id required: true deprecated: false schema: type: string format: uuid explode: true - style: form + style: simple responses: '200': - description: OAuth flow has completed - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/TokenWithSecret' - '202': - description: OAuth flow is pending + description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Empty' + $ref: '#/components/schemas/Page_McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8118,22 +8968,39 @@ paths: application/json; charset=utf-8: schema: $ref: '#/components/schemas/ErrorBody' - /v1/me/token: - get: + security: + - Cookie: [] + - Token: [] + post: tags: - RegistryService - - Me - summary: |- - Gets information about the current token. - The JSON is the same as the data object in the oauth2 endpoint's response. - operationId: current_login_token + - McpDeployment + - Environment + summary: Create a new MCP deployment in the environment + operationId: create_mcp_deployment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeploymentCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Token' + $ref: '#/components/schemas/McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8179,42 +9046,108 @@ paths: security: - Cookie: [] - Token: [] - /v1/me/visible-environments: + /v1/envs/{environment_id}/mcp-deployments/{domain}: get: tags: - RegistryService - - Me - summary: List all environments that are visible to the current user, either directly or through shares. - operationId: list_visible_environments + - McpDeployment + - Environment + summary: Get MCP deployment by domain in the environment + operationId: get_environment_mcp_deployment parameters: - - in: query - name: account_email + - in: path + name: environment_id + required: true deprecated: false schema: type: string + format: uuid explode: true - style: form - - in: query - name: app_name + style: simple + - in: path + name: domain + required: true deprecated: false schema: type: string explode: true - style: form - - in: query - name: env_name + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/mcp-deployments/{mcp_deployment_id}: + get: + tags: + - RegistryService + - McpDeployment + summary: Get MCP deployment by ID + operationId: get_mcp_deployment + parameters: + - in: path + name: mcp_deployment_id + required: true deprecated: false schema: type: string + format: uuid explode: true - style: form + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_EnvironmentWithDetails' + $ref: '#/components/schemas/McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8260,17 +9193,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/mcp-deployments: - get: + delete: tags: - RegistryService - McpDeployment - - Environment - summary: List MCP deployments in the environment - operationId: list_environment_mcp_deployments + summary: Delete MCP deployment + operationId: delete_mcp_deployment parameters: - in: path - name: environment_id + name: mcp_deployment_id required: true deprecated: false schema: @@ -8278,13 +9209,18 @@ paths: format: uuid explode: true style: simple + - in: query + name: current_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: form responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Page_McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8330,16 +9266,15 @@ paths: security: - Cookie: [] - Token: [] - post: + patch: tags: - RegistryService - McpDeployment - - Environment - summary: Create a new MCP deployment in the environment - operationId: create_mcp_deployment + summary: Update MCP deployment + operationId: update_mcp_deployment parameters: - in: path - name: environment_id + name: mcp_deployment_id required: true deprecated: false schema: @@ -8351,7 +9286,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeploymentCreation' + $ref: '#/components/schemas/McpDeploymentUpdate' required: true responses: '200': @@ -8405,17 +9340,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/mcp-deployments/{domain}: + /v1/mcp-deployment/{mcp_deployment_id}/revisions/{revision}: get: tags: - RegistryService - McpDeployment - - Environment - summary: Get MCP deployment by domain in the environment - operationId: get_environment_mcp_deployment + summary: Get a specific MCP deployment revision + operationId: get_mcp_deployment_revision parameters: - in: path - name: environment_id + name: mcp_deployment_id required: true deprecated: false schema: @@ -8424,11 +9358,12 @@ paths: explode: true style: simple - in: path - name: domain + name: revision required: true deprecated: false schema: - type: string + type: integer + format: uint64 explode: true style: simple responses: @@ -8483,16 +9418,18 @@ paths: security: - Cookie: [] - Token: [] - /v1/mcp-deployments/{mcp_deployment_id}: + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments/{domain}: get: tags: - RegistryService - McpDeployment - summary: Get MCP deployment by ID - operationId: get_mcp_deployment + - Environment + - Deployment + summary: Get MCP deployment by domain in the deployment + operationId: get_deployment_mcp_deployment parameters: - in: path - name: mcp_deployment_id + name: environment_id required: true deprecated: false schema: @@ -8500,6 +9437,23 @@ paths: format: uuid explode: true style: simple + - in: path + name: deployment_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + - in: path + name: domain + required: true + deprecated: false + schema: + type: string + explode: true + style: simple responses: '200': description: '' @@ -8552,15 +9506,18 @@ paths: security: - Cookie: [] - Token: [] - delete: + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments: + get: tags: - RegistryService - McpDeployment - summary: Delete MCP deployment - operationId: delete_mcp_deployment + - Environment + - Deployment + summary: List MCP deployments by domain in the deployment + operationId: list_deployment_mcp_deployments parameters: - in: path - name: mcp_deployment_id + name: environment_id required: true deprecated: false schema: @@ -8568,18 +9525,22 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision + - in: path + name: deployment_revision required: true deprecated: false schema: type: integer format: uint64 explode: true - style: form + style: simple responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_McpDeployment' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8625,15 +9586,17 @@ paths: security: - Cookie: [] - Token: [] - patch: + /v1/accounts/{account_id}/permission-shares: + get: tags: - RegistryService - - McpDeployment - summary: Update MCP deployment - operationId: update_mcp_deployment + - PermissionShares + - Account + summary: List permission shares owned by an account. + operationId: list_owned_permission_shares parameters: - in: path - name: mcp_deployment_id + name: account_id required: true deprecated: false schema: @@ -8641,19 +9604,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/McpDeploymentUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeployment' + $ref: '#/components/schemas/Page_PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8699,16 +9656,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/mcp-deployment/{mcp_deployment_id}/revisions/{revision}: - get: + post: tags: - RegistryService - - McpDeployment - summary: Get a specific MCP deployment revision - operationId: get_mcp_deployment_revision + - PermissionShares + - Account + summary: Create a new permission share owned by an account. + operationId: create_permission_share parameters: - in: path - name: mcp_deployment_id + name: account_id required: true deprecated: false schema: @@ -8716,22 +9673,19 @@ paths: format: uuid explode: true style: simple - - in: path - name: revision + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PermissionShareCreation' required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeployment' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8777,18 +9731,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments/{domain}: + /v1/accounts/{account_id}/received-permission-shares: get: tags: - RegistryService - - McpDeployment - - Environment - - Deployment - summary: Get MCP deployment by domain in the deployment - operationId: get_deployment_mcp_deployment + - PermissionShares + - Account + summary: List permission shares targeting an account. + operationId: list_received_permission_shares parameters: - in: path - name: environment_id + name: account_id required: true deprecated: false schema: @@ -8796,30 +9749,13 @@ paths: format: uuid explode: true style: simple - - in: path - name: deployment_revision - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: simple - - in: path - name: domain - required: true - deprecated: false - schema: - type: string - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/McpDeployment' + $ref: '#/components/schemas/Page_PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8865,18 +9801,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments: + /v1/permission-shares/{permission_share_id}: get: tags: - RegistryService - - McpDeployment - - Environment - - Deployment - summary: List MCP deployments by domain in the deployment - operationId: list_deployment_mcp_deployments + - PermissionShares + summary: Get permission share by id. + operationId: get_permission_share parameters: - in: path - name: environment_id + name: permission_share_id required: true deprecated: false schema: @@ -8884,22 +9818,13 @@ paths: format: uuid explode: true style: simple - - in: path - name: deployment_revision - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_McpDeployment' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -8945,17 +9870,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/accounts/{account_id}/permission-shares: - get: + delete: tags: - RegistryService - PermissionShares - - Account - summary: List permission shares owned by an account. - operationId: list_owned_permission_shares + summary: Delete permission share. + operationId: delete_permission_share parameters: - in: path - name: account_id + name: permission_share_id required: true deprecated: false schema: @@ -8963,13 +9886,22 @@ paths: format: uuid explode: true style: simple + - in: query + name: current_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_PermissionShare' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9015,16 +9947,15 @@ paths: security: - Cookie: [] - Token: [] - post: + patch: tags: - RegistryService - PermissionShares - - Account - summary: Create a new permission share owned by an account. - operationId: create_permission_share + summary: Update permission share data. + operationId: update_permission_share parameters: - in: path - name: account_id + name: permission_share_id required: true deprecated: false schema: @@ -9036,7 +9967,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShareCreation' + $ref: '#/components/schemas/PermissionShareUpdate' required: true responses: '200': @@ -9090,14 +10021,14 @@ paths: security: - Cookie: [] - Token: [] - /v1/accounts/{account_id}/received-permission-shares: + /v1/accounts/{account_id}/permission-shares/{name}: get: tags: - RegistryService - PermissionShares - Account - summary: List permission shares targeting an account. - operationId: list_received_permission_shares + summary: Get permission share by owner account and name. + operationId: get_permission_share_by_name parameters: - in: path name: account_id @@ -9108,13 +10039,21 @@ paths: format: uuid explode: true style: simple + - in: path + name: name + required: true + deprecated: false + schema: + type: string + explode: true + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_PermissionShare' + $ref: '#/components/schemas/PermissionShare' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9160,16 +10099,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/permission-shares/{permission_share_id}: + /v1/accounts/{account_id}/plugins: get: tags: - RegistryService - - PermissionShares - summary: Get permission share by id. - operationId: get_permission_share + - Plugin + - Account + summary: List all plugins registered in account + operationId: list_account_plugins parameters: - in: path - name: permission_share_id + name: account_id required: true deprecated: false schema: @@ -9183,7 +10123,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/Page_PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9229,15 +10169,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + post: tags: - RegistryService - - PermissionShares - summary: Delete permission share. - operationId: delete_permission_share + - Plugin + - Account + summary: Register a new plugin + operationId: create_plugin parameters: - in: path - name: permission_share_id + name: account_id required: true deprecated: false schema: @@ -9245,22 +10186,19 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PluginRegistrationCreation' required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9306,15 +10244,16 @@ paths: security: - Cookie: [] - Token: [] - patch: + /v1/plugins/{plugin_id}: + get: tags: - RegistryService - - PermissionShares - summary: Update permission share data. - operationId: update_permission_share + - Plugin + summary: Get a plugin by id + operationId: get_plugin_by_id parameters: - in: path - name: permission_share_id + name: plugin_id required: true deprecated: false schema: @@ -9322,19 +10261,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/PermissionShareUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9380,17 +10313,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/accounts/{account_id}/permission-shares/{name}: - get: + delete: tags: - RegistryService - - PermissionShares - - Account - summary: Get permission share by owner account and name. - operationId: get_permission_share_by_name + - Plugin + summary: Delete a plugin + operationId: delete_plugin parameters: - in: path - name: account_id + name: plugin_id required: true deprecated: false schema: @@ -9398,21 +10329,13 @@ paths: format: uuid explode: true style: simple - - in: path - name: name - required: true - deprecated: false - schema: - type: string - explode: true - style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PermissionShare' + $ref: '#/components/schemas/PluginRegistrationDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9458,31 +10381,19 @@ paths: security: - Cookie: [] - Token: [] - /v1/accounts/{account_id}/plugins: + /v1/reports/account_summaries: get: tags: - RegistryService - - Plugin - - Account - summary: List all plugins registered in account - operationId: list_account_plugins - parameters: - - in: path - name: account_id - required: true - deprecated: false - schema: - type: string - format: uuid - explode: true - style: simple + - Reports + operationId: get_account_summaries_report responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_PluginRegistrationDto' + $ref: '#/components/schemas/Page_AccountSummaryReport' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9528,36 +10439,19 @@ paths: security: - Cookie: [] - Token: [] - post: + /v1/reports/account_count: + get: tags: - RegistryService - - Plugin - - Account - summary: Register a new plugin - operationId: create_plugin - parameters: - - in: path - name: account_id - required: true - deprecated: false - schema: - type: string - format: uuid - explode: true - style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/PluginRegistrationCreation' - required: true + - Reports + operationId: get_account_count_report responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PluginRegistrationDto' + $ref: '#/components/schemas/AccountCountsReport' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9603,16 +10497,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/plugins/{plugin_id}: + /v1/envs/{environment_id}/resources: get: tags: - RegistryService - - Plugin - summary: Get a plugin by id - operationId: get_plugin_by_id + - Resources + - Environment + summary: Get all resources defined in the environment + operationId: list_environment_resources parameters: - in: path - name: plugin_id + name: environment_id required: true deprecated: false schema: @@ -9626,7 +10521,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PluginRegistrationDto' + $ref: '#/components/schemas/Page_ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9672,15 +10567,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + post: tags: - RegistryService - - Plugin - summary: Delete a plugin - operationId: delete_plugin + - Resources + - Environment + summary: Create a new resource in the environment + operationId: create_resource parameters: - in: path - name: plugin_id + name: environment_id required: true deprecated: false schema: @@ -9688,13 +10584,19 @@ paths: format: uuid explode: true style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ResourceDefinitionCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/PluginRegistrationDto' + $ref: '#/components/schemas/ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9740,19 +10642,39 @@ paths: security: - Cookie: [] - Token: [] - /v1/reports/account_summaries: + /v1/envs/{environment_id}/resources/{resource_name}: get: tags: - RegistryService - - Reports - operationId: get_account_summaries_report + - Resources + - Environment + summary: Get a resource in the environment by name + operationId: get_environment_resource + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: resource_name + required: true + deprecated: false + schema: + type: string + explode: true + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_AccountSummaryReport' + $ref: '#/components/schemas/ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9798,19 +10720,30 @@ paths: security: - Cookie: [] - Token: [] - /v1/reports/account_count: + /v1/resources/{resource_id}: get: tags: - RegistryService - - Reports - operationId: get_account_count_report + - Resources + summary: Get a resource by id + operationId: get_resource + parameters: + - in: path + name: resource_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/AccountCountsReport' + $ref: '#/components/schemas/ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9856,17 +10789,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/resources: - get: + delete: tags: - RegistryService - Resources - - Environment - summary: Get all resources defined in the environment - operationId: list_environment_resources + summary: Delete a resource + operationId: delete_resource parameters: - in: path - name: environment_id + name: resource_id required: true deprecated: false schema: @@ -9874,13 +10805,18 @@ paths: format: uuid explode: true style: simple + - in: query + name: current_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: form responses: - '200': + '204': description: '' - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/Page_ResourceDefinition' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -9926,16 +10862,15 @@ paths: security: - Cookie: [] - Token: [] - post: + patch: tags: - RegistryService - Resources - - Environment - summary: Create a new resource in the environment - operationId: create_resource + summary: Update a resource + operationId: update_resource parameters: - in: path - name: environment_id + name: resource_id required: true deprecated: false schema: @@ -9947,7 +10882,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinitionCreation' + $ref: '#/components/schemas/ResourceDefinitionUpdate' required: true responses: '200': @@ -10001,17 +10936,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/resources/{resource_name}: + /v1/resources/{resource_id}/revisions/{revision}: get: tags: - RegistryService - Resources - - Environment - summary: Get a resource in the environment by name - operationId: get_environment_resource + summary: Get specific revision of a resource + operationId: get_resource_revision parameters: - in: path - name: environment_id + name: resource_id required: true deprecated: false schema: @@ -10020,11 +10954,12 @@ paths: explode: true style: simple - in: path - name: resource_name + name: revision required: true deprecated: false schema: - type: string + type: integer + format: uint64 explode: true style: simple responses: @@ -10079,16 +11014,17 @@ paths: security: - Cookie: [] - Token: [] - /v1/resources/{resource_id}: + /v1/envs/{environment_id}/retry-policies: get: tags: - RegistryService - - Resources - summary: Get a resource by id - operationId: get_resource + - RetryPolicies + - Environment + summary: Get all retry policies of the environment + operationId: list_environment_retry_policies parameters: - in: path - name: resource_id + name: environment_id required: true deprecated: false schema: @@ -10102,7 +11038,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinition' + $ref: '#/components/schemas/Page_RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10148,15 +11084,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + post: tags: - RegistryService - - Resources - summary: Delete a resource - operationId: delete_resource + - RetryPolicies + - Environment + summary: Create a new retry policy + operationId: create_retry_policy parameters: - in: path - name: resource_id + name: environment_id required: true deprecated: false schema: @@ -10164,18 +11101,19 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RetryPolicyCreation' required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: form responses: - '204': + '200': description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10221,15 +11159,16 @@ paths: security: - Cookie: [] - Token: [] - patch: + /v1/retry-policies/{retry_policy_id}: + get: tags: - RegistryService - - Resources - summary: Update a resource - operationId: update_resource + - RetryPolicies + summary: Get retry policy by id. + operationId: get_retry_policy parameters: - in: path - name: resource_id + name: retry_policy_id required: true deprecated: false schema: @@ -10237,19 +11176,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/ResourceDefinitionUpdate' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinition' + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10295,16 +11228,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/resources/{resource_id}/revisions/{revision}: - get: + delete: tags: - RegistryService - - Resources - summary: Get specific revision of a resource - operationId: get_resource_revision + - RetryPolicies + summary: Delete retry policy + operationId: delete_retry_policy parameters: - in: path - name: resource_id + name: retry_policy_id required: true deprecated: false schema: @@ -10312,22 +11244,22 @@ paths: format: uuid explode: true style: simple - - in: path - name: revision + - in: query + name: current_revision required: true deprecated: false schema: type: integer format: uint64 explode: true - style: simple + style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/ResourceDefinition' + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10373,17 +11305,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/retry-policies: - get: + patch: tags: - RegistryService - RetryPolicies - - Environment - summary: Get all retry policies of the environment - operationId: list_environment_retry_policies + summary: Update retry policy + operationId: update_retry_policy parameters: - in: path - name: environment_id + name: retry_policy_id required: true deprecated: false schema: @@ -10391,13 +11321,19 @@ paths: format: uuid explode: true style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RetryPolicyUpdate' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_RetryPolicyDto' + $ref: '#/components/schemas/RetryPolicyDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10443,13 +11379,14 @@ paths: security: - Cookie: [] - Token: [] - post: + /v1/envs/{environment_id}/security-schemes: + get: tags: - RegistryService - - RetryPolicies + - ApiSecurity - Environment - summary: Create a new retry policy - operationId: create_retry_policy + summary: Get all security-schemes of the environment + operationId: list_environment_security_schemes parameters: - in: path name: environment_id @@ -10460,19 +11397,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/RetryPolicyCreation' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/Page_SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10518,16 +11449,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/retry-policies/{retry_policy_id}: - get: + post: tags: - RegistryService - - RetryPolicies - summary: Get retry policy by id. - operationId: get_retry_policy + - ApiSecurity + - Environment + summary: Create a new security scheme + operationId: create_security_scheme parameters: - in: path - name: retry_policy_id + name: environment_id required: true deprecated: false schema: @@ -10535,13 +11466,19 @@ paths: format: uuid explode: true style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SecuritySchemeCreation' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10587,15 +11524,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + /v1/security-schemes/{security_scheme_id}: + get: tags: - RegistryService - - RetryPolicies - summary: Delete retry policy - operationId: delete_retry_policy + - ApiSecurity + summary: Get security scheme + operationId: get_security_scheme parameters: - in: path - name: retry_policy_id + name: security_scheme_id required: true deprecated: false schema: @@ -10603,22 +11541,13 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10664,15 +11593,15 @@ paths: security: - Cookie: [] - Token: [] - patch: + delete: tags: - RegistryService - - RetryPolicies - summary: Update retry policy - operationId: update_retry_policy + - ApiSecurity + summary: Delete security scheme + operationId: delete_security_scheme parameters: - in: path - name: retry_policy_id + name: security_scheme_id required: true deprecated: false schema: @@ -10680,19 +11609,22 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/RetryPolicyUpdate' + - in: query + name: current_revision required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/RetryPolicyDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10738,17 +11670,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/envs/{environment_id}/security-schemes: - get: + patch: tags: - RegistryService - ApiSecurity - - Environment - summary: Get all security-schemes of the environment - operationId: list_environment_security_schemes + summary: Update security scheme + operationId: update_security_scheme parameters: - in: path - name: environment_id + name: security_scheme_id required: true deprecated: false schema: @@ -10756,13 +11686,19 @@ paths: format: uuid explode: true style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SecuritySchemeUpdate' + required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Page_SecuritySchemeDto' + $ref: '#/components/schemas/SecuritySchemeDto' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10808,16 +11744,16 @@ paths: security: - Cookie: [] - Token: [] - post: + /v1/tokens/{token_id}: + get: tags: - RegistryService - - ApiSecurity - - Environment - summary: Create a new security scheme - operationId: create_security_scheme + - Token + summary: Get token by id + operationId: get_token parameters: - in: path - name: environment_id + name: token_id required: true deprecated: false schema: @@ -10825,19 +11761,13 @@ paths: format: uuid explode: true style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SecuritySchemeCreation' - required: true responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/Token' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10883,16 +11813,16 @@ paths: security: - Cookie: [] - Token: [] - /v1/security-schemes/{security_scheme_id}: - get: + delete: tags: - RegistryService - - ApiSecurity - summary: Get security scheme - operationId: get_security_scheme + - Token + summary: Delete a token + description: Deletes a previously created token given by its identifier. + operationId: delete_token parameters: - in: path - name: security_scheme_id + name: token_id required: true deprecated: false schema: @@ -10906,7 +11836,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/Empty' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -10952,15 +11882,17 @@ paths: security: - Cookie: [] - Token: [] - delete: + /v1/accounts/{account_id}/tool-releases: + get: tags: - RegistryService - - ApiSecurity - summary: Delete security scheme - operationId: delete_security_scheme + - ToolReleases + - Account + summary: List tool releases owned by an account + operationId: list_account_tool_releases parameters: - in: path - name: security_scheme_id + name: account_id required: true deprecated: false schema: @@ -10968,22 +11900,13 @@ paths: format: uuid explode: true style: simple - - in: query - name: current_revision - required: true - deprecated: false - schema: - type: integer - format: uint64 - explode: true - style: form responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/Page_ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -11029,35 +11952,30 @@ paths: security: - Cookie: [] - Token: [] - patch: + /v1/tool-releases/{release_id}: + get: tags: - RegistryService - - ApiSecurity - summary: Update security scheme - operationId: update_security_scheme + - ToolReleases + summary: Get an account-owned tool release by ID + operationId: get_tool_release parameters: - in: path - name: security_scheme_id + name: release_id required: true deprecated: false schema: - type: string - format: uuid - explode: true - style: simple - requestBody: - content: - application/json; charset=utf-8: - schema: - $ref: '#/components/schemas/SecuritySchemeUpdate' - required: true + type: string + format: uuid + explode: true + style: simple responses: '200': description: '' content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/SecuritySchemeDto' + $ref: '#/components/schemas/ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -11103,16 +12021,15 @@ paths: security: - Cookie: [] - Token: [] - /v1/tokens/{token_id}: - get: + delete: tags: - RegistryService - - Token - summary: Get token by id - operationId: get_token + - ToolReleases + summary: De-publish an account-owned tool release + operationId: de_publish_tool_release parameters: - in: path - name: token_id + name: release_id required: true deprecated: false schema: @@ -11126,7 +12043,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Token' + $ref: '#/components/schemas/ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -11172,16 +12089,16 @@ paths: security: - Cookie: [] - Token: [] - delete: + /v1/tool-releases/{release_id}/restore: + post: tags: - RegistryService - - Token - summary: Delete a token - description: Deletes a previously created token given by its identifier. - operationId: delete_token + - ToolReleases + summary: Restore a de-published account-owned tool release + operationId: restore_tool_release parameters: - in: path - name: token_id + name: release_id required: true deprecated: false schema: @@ -11195,7 +12112,7 @@ paths: content: application/json; charset=utf-8: schema: - $ref: '#/components/schemas/Empty' + $ref: '#/components/schemas/ToolRelease' '400': description: Invalid request, returning with a list of issues detected in the request content: @@ -18802,6 +19719,9 @@ components: deploymentRevision: type: integer format: uint64 + releaseId: + type: string + format: uuid definition: $ref: '#/components/schemas/Tool' source: @@ -18813,6 +19733,9 @@ components: type: string metadataVersion: type: string + metadataDigest: + type: string + format: hash required: - deploymentRevision - definition @@ -18820,6 +19743,7 @@ components: - ownerAccountId - ownerAccountEmail - metadataVersion + - metadataDigest Deployment: title: Deployment type: object @@ -18880,6 +19804,16 @@ components: type: array items: $ref: '#/components/schemas/DeploymentRetryPolicyDefault' + publishTools: + default: [] + type: array + items: + type: string + remoteTools: + default: [] + type: array + items: + $ref: '#/components/schemas/RemoteToolDeployment' replaceIncompatibleAgentSecrets: default: false type: boolean @@ -18912,11 +19846,21 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' + remoteTools: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanRemoteToolEntry' + publishedTools: + type: array + items: + type: string required: - deploymentHash - components - httpApiDeployments - mcpDeployments + - remoteTools + - publishedTools DeploymentPlanComponentEntry: title: DeploymentPlanComponentEntry type: object @@ -18977,6 +19921,18 @@ components: - revision - domain - hash + DeploymentPlanRemoteToolEntry: + title: DeploymentPlanRemoteToolEntry + type: object + properties: + name: + type: string + hash: + type: string + format: hash + required: + - name + - hash DeploymentRetryPolicyDefault: title: DeploymentRetryPolicyDefault description: Default retry policy to create as part of deployment @@ -19032,12 +19988,22 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' + remoteTools: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanRemoteToolEntry' + publishedTools: + type: array + items: + type: string required: - deploymentRevision - deploymentHash - components - httpApiDeployments - mcpDeployments + - remoteTools + - publishedTools Doc: type: object properties: @@ -19246,6 +20212,91 @@ components: - compatibilityCheck - versionCheck - securityOverrides + EnvironmentToolGrant: + title: EnvironmentToolGrant + type: object + properties: + id: + type: string + format: uuid + environmentId: + type: string + format: uuid + toolReleaseId: + type: string + format: uuid + protected: + type: boolean + automatic: + type: boolean + lifecycle: + $ref: '#/components/schemas/EnvironmentToolGrantLifecycle' + createdAt: + type: string + format: date-time + createdBy: + type: string + format: uuid + stateChangedAt: + type: string + format: date-time + stateChangedBy: + type: string + format: uuid + required: + - id + - environmentId + - toolReleaseId + - protected + - automatic + - lifecycle + - createdAt + - createdBy + - stateChangedAt + - stateChangedBy + EnvironmentToolGrantCreation: + title: EnvironmentToolGrantCreation + type: object + properties: + release: + $ref: '#/components/schemas/ToolReleaseReference' + required: + - release + EnvironmentToolGrantLifecycle: + type: string + enum: + - active + - deleted + EnvironmentToolGrantReconciliation: + title: EnvironmentToolGrantReconciliation + type: object + properties: + creations: + type: array + items: + $ref: '#/components/schemas/EnvironmentToolGrantCreation' + deletions: + type: array + items: + type: string + format: uuid + required: + - creations + - deletions + EnvironmentToolGrantWithDetails: + title: EnvironmentToolGrantWithDetails + type: object + properties: + grant: + $ref: '#/components/schemas/EnvironmentToolGrant' + release: + $ref: '#/components/schemas/ToolReleaseMetadata' + releaseOwner: + $ref: '#/components/schemas/AccountSummary' + required: + - grant + - release + - releaseOwner EnvironmentUpdate: title: EnvironmentUpdate type: object @@ -19766,6 +20817,19 @@ components: - path - permissions - size + InitialAgentFileUpload: + title: InitialAgentFileUpload + type: object + properties: + contentHash: + type: string + format: hash + size: + type: integer + format: uint64 + required: + - contentHash + - size InputSchema: type: object oneOf: @@ -20326,6 +21390,16 @@ components: $ref: '#/components/schemas/EnvironmentPluginGrantWithDetails' required: - values + Page_EnvironmentToolGrantWithDetails: + title: Page_EnvironmentToolGrantWithDetails + type: object + properties: + values: + type: array + items: + $ref: '#/components/schemas/EnvironmentToolGrantWithDetails' + required: + - values Page_EnvironmentWithDetails: title: Page_EnvironmentWithDetails type: object @@ -20416,6 +21490,16 @@ components: $ref: '#/components/schemas/Token' required: - values + Page_ToolRelease: + title: Page_ToolRelease + type: object + properties: + values: + type: array + items: + $ref: '#/components/schemas/ToolRelease' + required: + - values PathSegment: discriminator: propertyName: type @@ -21023,6 +22107,27 @@ components: - componentName - accountId - accountEmail + RemoteToolDeployment: + title: RemoteToolDeployment + type: object + properties: + name: + type: string + release: + $ref: '#/components/schemas/ToolReleaseReference' + provision: + $ref: '#/components/schemas/ToolProvisionConfig' + environmentBinding: + $ref: '#/components/schemas/ToolBindingInput' + agentBindings: + default: {} + type: object + additionalProperties: + $ref: '#/components/schemas/ToolBindingInput' + required: + - name + - release + - provision RepeatableListShape: type: object properties: @@ -21669,6 +22774,12 @@ components: - doc - mime - required + SystemToolAvailability: + type: string + enum: + - grantable + - auto-granted + - ambient SystemVariable: type: string enum: @@ -21946,6 +23057,161 @@ components: type: object additionalProperties: $ref: '#/components/schemas/AgentFilePermissions' + ToolRelease: + title: ToolRelease + type: object + properties: + id: + type: string + format: uuid + ownerAccountId: + type: string + format: uuid + name: + type: string + version: + type: string + source: + $ref: '#/components/schemas/ToolSource' + definition: + $ref: '#/components/schemas/Tool' + metadataVersion: + type: string + metadataDigest: + type: string + format: hash + lifecycle: + $ref: '#/components/schemas/ToolReleaseLifecycle' + origin: + $ref: '#/components/schemas/ToolReleaseOrigin' + systemAvailability: + $ref: '#/components/schemas/SystemToolAvailability' + createdAt: + type: string + format: date-time + createdBy: + type: string + format: uuid + stateChangedAt: + type: string + format: date-time + stateChangedBy: + type: string + format: uuid + required: + - id + - ownerAccountId + - name + - version + - source + - definition + - metadataVersion + - metadataDigest + - lifecycle + - origin + - createdAt + - createdBy + - stateChangedAt + - stateChangedBy + ToolReleaseByCoordinates: + title: ToolReleaseByCoordinates + type: object + properties: + account: + type: string + name: + type: string + version: + type: string + required: + - account + - name + - version + ToolReleaseById: + title: ToolReleaseById + type: object + properties: + releaseId: + type: string + format: uuid + required: + - releaseId + ToolReleaseLifecycle: + type: string + enum: + - published + - de-published + ToolReleaseMetadata: + title: ToolReleaseMetadata + description: |- + Safe release metadata available to a consumer through an active environment grant. + Executable source identities remain publisher-only. + type: object + properties: + id: + type: string + format: uuid + name: + type: string + version: + type: string + definition: + $ref: '#/components/schemas/Tool' + metadataVersion: + type: string + metadataDigest: + type: string + format: hash + sourceDigest: + type: string + format: hash + required: + - id + - name + - version + - definition + - metadataVersion + - metadataDigest + - sourceDigest + ToolReleaseOrigin: + type: string + enum: + - ordinary + - protected-system + ToolReleaseReference: + discriminator: + propertyName: type + mapping: + ById: '#/components/schemas/ToolReleaseReference_ToolReleaseById' + ByCoordinates: '#/components/schemas/ToolReleaseReference_ToolReleaseByCoordinates' + type: object + oneOf: + - $ref: '#/components/schemas/ToolReleaseReference_ToolReleaseById' + - $ref: '#/components/schemas/ToolReleaseReference_ToolReleaseByCoordinates' + ToolReleaseReference_ToolReleaseByCoordinates: + allOf: + - type: object + properties: + type: + example: ByCoordinates + type: string + enum: + - ByCoordinates + required: + - type + - $ref: '#/components/schemas/ToolReleaseByCoordinates' + ToolReleaseReference_ToolReleaseById: + allOf: + - type: object + properties: + type: + example: ById + type: string + enum: + - ById + required: + - type + - $ref: '#/components/schemas/ToolReleaseById' ToolResultSpec: type: object properties: @@ -21997,6 +23263,20 @@ components: - componentId - componentRevision - componentName + - type: object + properties: + kind: + type: string + enum: + - host + hostToolId: + type: string + implementationVersion: + type: string + required: + - kind + - hostToolId + - implementationVersion UntypedJsonBody: description: A json body without a static schema type: object @@ -22048,6 +23328,7 @@ tags: - name: Deployment - name: Environment - name: EnvironmentPluginGrants +- name: EnvironmentToolGrants - name: HealthCheck - name: Login description: The login endpoints are implementing an OAuth2 flow. @@ -22061,4 +23342,5 @@ tags: - name: RetryPolicies - name: Token description: The token API allows creating custom access tokens for the Golem Cloud REST API to be used by tools and services. +- name: ToolReleases - name: Worker diff --git a/openapi/golem-worker-service.yaml b/openapi/golem-worker-service.yaml index bee9a2670c..27a90693bd 100644 --- a/openapi/golem-worker-service.yaml +++ b/openapi/golem-worker-service.yaml @@ -22,6 +22,7 @@ tags: - name: Deployment - name: Environment - name: EnvironmentPluginGrants +- name: EnvironmentToolGrants - name: HealthCheck - name: Login description: The login endpoints are implementing an OAuth2 flow. @@ -35,6 +36,7 @@ tags: - name: RetryPolicies - name: Token description: The token API allows creating custom access tokens for the Golem Cloud REST API to be used by tools and services. +- name: ToolReleases - name: Worker paths: /healthcheck: diff --git a/test-components/agent-sdk-rust/Cargo.lock b/test-components/agent-sdk-rust/Cargo.lock index 524f1f78b6..d8c252c6c5 100644 --- a/test-components/agent-sdk-rust/Cargo.lock +++ b/test-components/agent-sdk-rust/Cargo.lock @@ -26,6 +26,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-trait" version = "0.1.91" @@ -78,6 +84,20 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", + "rayon-core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -130,12 +150,52 @@ dependencies = [ "memchr", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "ctor" version = "0.4.3" @@ -397,6 +457,7 @@ dependencies = [ "base64", "bigdecimal", "bit-vec", + "blake3", "chrono", "combine", "golem-schema-derive", @@ -828,6 +889,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" version = "1.13.1" diff --git a/test-components/agent-sdk-rust/golem.yaml b/test-components/agent-sdk-rust/golem.yaml index 7c6a44acdf..733ad05a5a 100644 --- a/test-components/agent-sdk-rust/golem.yaml +++ b/test-components/agent-sdk-rust/golem.yaml @@ -1,7 +1,7 @@ # Schema for IDEA: -# $schema: https://schema.golem.cloud/app/golem/1.6.0-dev.6/golem.schema.json +# $schema: https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json # Schema for vscode-yaml: -# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.6.0-dev.6/golem.schema.json +# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json # Field reference: https://learn.golem.cloud/app-manifest#field-reference # Creating HTTP APIs: https://learn.golem.cloud/invoke/making-custom-apis diff --git a/test-components/agent-sdk-ts/package-lock.json b/test-components/agent-sdk-ts/package-lock.json index 08d6a3ec2a..72445f3047 100644 --- a/test-components/agent-sdk-ts/package-lock.json +++ b/test-components/agent-sdk-ts/package-lock.json @@ -24,6 +24,9 @@ "name": "@golemcloud/golem-ts-sdk", "version": "0.0.0", "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@noble/hashes": "^1.8.0" + }, "devDependencies": { "@eslint/js": "^9.33.0", "@rollup/plugin-commonjs": "^28.0.6", diff --git a/test-components/scalability/AGENTS.md b/test-components/scalability/AGENTS.md index 30f5ca9cb3..473bc6e791 100644 --- a/test-components/scalability/AGENTS.md +++ b/test-components/scalability/AGENTS.md @@ -41,7 +41,7 @@ This project includes coding-agent skills in `.agents/skills/`. Load a skill whe | `golem-wait-for-external-input-rust` | Waiting for external input using Golem promises (human-in-the-loop, webhooks, external events) | | `golem-add-webhook-rust` | Creating and awaiting webhooks for integrating with webhook-driven external APIs | | `golem-multi-instance-agent-rust` | Creating multiple agent instances with the same constructor parameters using phantom agents | -| `golem-atomic-block-rust` | Atomic blocks, persistence control, and idempotency | +| `golem-atomic-block-rust` | Atomic blocks and idempotency | | `golem-add-transactions-rust` | Saga-pattern transactions with compensation | | `golem-add-http-endpoint-rust` | Exposing an agent over HTTP with mount paths and endpoint annotations | | `golem-http-params-rust` | Mapping path, query, header, and body parameters for HTTP endpoints | diff --git a/test-components/scalability/golem.yaml b/test-components/scalability/golem.yaml index e7025d2f90..7df6c7a1e1 100644 --- a/test-components/scalability/golem.yaml +++ b/test-components/scalability/golem.yaml @@ -1,7 +1,7 @@ # Schema for IDEA: -# $schema: https://schema.golem.cloud/app/golem/1.6.0-dev.6/golem.schema.json +# $schema: https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json # Schema for vscode-yaml: -# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.6.0-dev.6/golem.schema.json +# yaml-language-server: $schema=https://schema.golem.cloud/app/golem/1.6.0-dev.9/golem.schema.json # Field reference: https://learn.golem.cloud/app-manifest#field-reference # Creating HTTP APIs: https://learn.golem.cloud/invoke/making-custom-apis diff --git a/test-components/scalability/scalability-large-dynamic-memory/Cargo.lock b/test-components/scalability/scalability-large-dynamic-memory/Cargo.lock index 15074557c2..0b13184e35 100644 --- a/test-components/scalability/scalability-large-dynamic-memory/Cargo.lock +++ b/test-components/scalability/scalability-large-dynamic-memory/Cargo.lock @@ -26,6 +26,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-trait" version = "0.1.91" @@ -78,6 +84,20 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", + "rayon-core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -130,12 +150,52 @@ dependencies = [ "memchr", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "ctor" version = "0.4.3" @@ -368,6 +428,7 @@ dependencies = [ "base64", "bigdecimal", "bit-vec", + "blake3", "chrono", "combine", "golem-schema-derive", @@ -775,6 +836,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" version = "1.13.1"